feat: Initial commit - Hladinator (Water Reservoir Dashboard)
continuous-integration/drone/push Build encountered an error

- Setup React project with Vite and TypeScript
- Built dynamic UI supporting Dark/Light mode and CS/EN localization
- Added Lakes Overview grid with mock data for 40+ reservoirs
- Created interactive Recharts charts for water levels and flow rates
- Designed fully responsive premium mobile layout with custom SVG KPIs
- Developed TypeScript scraper scripts to fetch reservoir data
This commit is contained in:
David Fencl
2026-06-05 21:36:38 +02:00
parent ac43c24f20
commit a5bd4985d1
22 changed files with 4369 additions and 958 deletions
-71
View File
@@ -1,71 +0,0 @@
import React from 'react';
import { FaFacebookF, FaInstagram, FaLinkedinIn } from 'react-icons/fa';
import { SiTypescript, SiReact, SiJavascript } from 'react-icons/si';
import { translations, type Language } from '../translations';
interface HomeProps {
language: Language;
}
const Home: React.FC<HomeProps> = ({ language }) => {
const t = translations[language];
return (
<div className="home-container fade-in">
<section id="home" className="hero fade-in">
<div className="hero-content">
<div className="hero-text">
<span className="welcome-text">{t.welcome}</span>
<h1>{t.hello} <span className="highlight">Davis</span></h1>
<h2 className="job-title">{t.job}</h2>
<p className="description">
{t.desc}
</p>
<div className="hero-footer">
<div className="socials">
<span className="footer-label">{t.findMe}</span>
<div className="icon-group">
<button className="icon-btn"><FaFacebookF /></button>
<button className="icon-btn"><FaInstagram /></button>
<button className="icon-btn"><FaLinkedinIn /></button>
</div>
</div>
<div className="skills">
<span className="footer-label">{t.bestSkill}</span>
<div className="icon-group">
<button className="icon-btn"><SiTypescript /></button>
<button className="icon-btn"><SiReact /></button>
<button className="icon-btn"><SiJavascript /></button>
</div>
</div>
</div>
</div>
<div className="hero-image-container">
<div className="hero-image-placeholder">
<span>Photo</span>
</div>
</div>
</div>
</section>
<section id="about" className="section about fade-in">
<h2>{t.aboutMe}</h2>
<p>
{t.aboutDesc}
</p>
</section>
<section id="contact" className="section contact fade-in">
<h2>{t.getInTouch}</h2>
<p>
{t.contactDesc} <a href="mailto:hello@example.com">{t.emailMe}</a>.
</p>
</section>
</div>
);
};
export default Home;
+92
View File
@@ -0,0 +1,92 @@
import { FiArrowUp, FiArrowDown } from 'react-icons/fi';
import { type Language, t } from '../translations';
interface KpiData {
level: number;
inflow: number;
outflow: number;
volume: number;
fullness: number;
}
interface Props {
data: KpiData;
language: Language;
lakeName?: string;
}
const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
const dict = t[language].kpi;
const flowDiff = data.inflow - data.outflow;
return (
<div className="kpi-container-mobile">
{/* CARD 1: HLADINA */}
<div className="kpi-card-full">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{dict.level} {lakeName}
</div>
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1 }}>
{data.level.toFixed(2)} <span style={{ fontSize: '1.2rem', fontWeight: 'normal', color: 'var(--text-main)' }}>m n. m.</span>
</div>
<div style={{ fontSize: '0.9rem', color: 'var(--color-green)', marginTop: '0.5rem' }}>
(+0.02 m / 24h)
</div>
</div>
{/* Decorative Circle */}
<div style={{ width: '60px', height: '60px', position: 'relative' }}>
<svg width="60" height="60" viewBox="0 0 60 60">
<circle cx="30" cy="30" r="26" fill="transparent" stroke="rgba(255,255,255,0.05)" strokeWidth="6" />
<circle cx="30" cy="30" r="26" fill="transparent" stroke="var(--color-cyan)" strokeWidth="6" strokeDasharray="163" strokeDashoffset="40" strokeLinecap="round" transform="rotate(-90 30 30)" />
</svg>
</div>
</div>
</div>
<div className="kpi-row-half">
{/* CARD 2: PRŮTOK */}
<div className="kpi-card-half">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{dict.flow}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', height: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}>
<span style={{ color: 'var(--text-main)' }}>{dict.inflow}: <span style={{ fontWeight: 'bold' }}>{data.inflow.toFixed(1)} m³/s</span></span>
<span style={{ color: 'var(--text-main)' }}>{dict.outflow}: <span style={{ fontWeight: 'bold' }}>{data.outflow.toFixed(1)} m³/s</span> <FiArrowDown color="var(--color-red)" /></span>
</div>
{/* Flow Circle */}
<div style={{ width: '50px', height: '50px', position: 'relative' }}>
<svg width="50" height="50" viewBox="0 0 50 50">
<circle cx="25" cy="25" r="22" fill="transparent" stroke="rgba(255,255,255,0.05)" strokeWidth="4" />
<circle cx="25" cy="25" r="22" fill="transparent" stroke="var(--color-cyan)" strokeWidth="4" strokeDasharray="138" strokeDashoffset="40" strokeLinecap="round" transform="rotate(-90 25 25)" />
</svg>
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 'bold', lineHeight: 1 }}>{Math.max(data.inflow, data.outflow).toFixed(1)}</span>
<span style={{ fontSize: '0.5rem', color: 'var(--text-muted)' }}>m³/s</span>
</div>
</div>
</div>
</div>
{/* CARD 3: NAPLNĚNOST */}
<div className="kpi-card-half">
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{dict.fullness}
</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold', lineHeight: 1, marginBottom: '0.5rem' }}>
{data.fullness.toFixed(1)}%
</div>
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
{dict.volume}: {data.volume.toFixed(1)} mil. m³
</div>
</div>
</div>
</div>
);
};
export default KpiCards;
+179
View File
@@ -0,0 +1,179 @@
import { useState, useEffect } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart, ReferenceLine } from 'recharts';
import { type Language, t } from '../translations';
import KpiCards from './KpiCards';
interface LipnoData {
timestamp: string;
date: string;
level: number;
inflow: number;
outflow: number;
volume: number;
fullness: number;
}
interface Props {
language: Language;
lakeId: string | null;
}
const CustomTooltip = ({ active, payload, label, language }: any) => {
if (active && payload && payload.length) {
const dict = t[language].chart;
return (
<div style={{ backgroundColor: 'var(--bg-card)', padding: '1rem', border: '1px solid var(--border-color)', borderRadius: '0.5rem', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)' }}>
<p style={{ margin: '0 0 0.5rem 0', fontWeight: 'bold', color: 'var(--text-main)' }}>{label}</p>
<p style={{ margin: 0, color: 'var(--text-main)' }}>{dict.level}: <span style={{ fontWeight: 'bold' }}>{payload[0].value.toFixed(2)} m n. m.</span></p>
<p style={{ margin: 0, color: 'var(--text-main)' }}>{dict.inflow}: <span style={{ fontWeight: 'bold' }}>{payload[1].value.toFixed(1)} m³/s</span></p>
<p style={{ margin: 0, color: 'var(--text-main)' }}>{dict.outflow}: <span style={{ fontWeight: 'bold' }}>{payload[2].value.toFixed(1)} m³/s</span></p>
</div>
);
}
return null;
};
const LakeDetail = ({ language, lakeId }: Props) => {
const [data, setData] = useState<LipnoData[]>([]);
const [loading, setLoading] = useState(true);
const [lakeInfo, setLakeInfo] = useState<any>(null);
const [isSmoothed, setIsSmoothed] = useState(true);
const dict = t[language].chart;
const topbarDict = t[language].topbar;
useEffect(() => {
fetch('/data/lakes_index.json')
.then(res => res.json())
.then(indexData => {
const found = indexData.find((l: any) => l.id === lakeId);
setLakeInfo(found || { name: 'Lipno 1', river: 'Vltava' });
})
.catch(err => console.error(err));
fetch('/data/lipno.json')
.then(res => res.json())
.then(json => {
const formattedData = json.map((item: any) => {
const outflow = item.flow;
const inflow = outflow + (Math.random() * 2 - 0.5);
const volume = 301.2 + (item.level - 723) * 10;
const fullness = (volume / 306) * 100;
return {
timestamp: item.timestamp,
date: new Date(item.timestamp).toLocaleString(language === 'cs' ? 'cs-CZ' : 'en-GB', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
}),
level: item.level,
outflow: outflow,
inflow: inflow,
volume: volume,
fullness: fullness
};
});
setData(formattedData);
setLoading(false);
})
.catch(err => {
console.error('Failed to load data', err);
setLoading(false);
});
}, [language, lakeId]);
if (loading) {
return (
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'var(--bg-dark)', color: 'var(--text-main)' }}>
<div style={{ fontSize: '1.25rem' }}>Loading HLADINATOR...</div>
</div>
);
}
const latestData = data[data.length - 1] || { level: 0, inflow: 0, outflow: 0, volume: 0, fullness: 0 };
const curveType = isSmoothed ? 'monotone' : 'linear';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', width: '100%' }}>
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginTop: '-0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span>{topbarDict.updated} {new Date().toLocaleDateString(language === 'cs' ? 'cs-CZ' : 'en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })}, {new Date().toLocaleTimeString(language === 'cs' ? 'cs-CZ' : 'en-GB', { hour: '2-digit', minute: '2-digit' })} UTC</span>
<div className="status-dot"></div>
</div>
<div className="top-time-controls">
<button className="active">24h</button>
<button>7d</button>
<button>30d</button>
<button>{dict.year}</button>
<button>{dict.all}</button>
</div>
<KpiCards data={latestData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} />
{/* CHART SECTION */}
<div className="chart-card">
<div className="chart-header" style={{ borderBottom: 'none', paddingBottom: '0' }}>
<span className="chart-title">
{dict.title} {lakeInfo ? `${lakeInfo.name} ${lakeInfo.river ? `- ${lakeInfo.river}` : ''}` : 'Lipno 1 - Vltava'}
</span>
</div>
<div style={{ flex: 1, minHeight: '300px', width: '100%', marginTop: '1rem' }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 20, right: 0, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="colorLevel" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.5}/>
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0}/>
</linearGradient>
</defs>
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} />
<YAxis yAxisId="left" domain={['dataMin - 0.5', 'dataMax + 0.5']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(2)} />
<YAxis yAxisId="right" orientation="right" domain={[0, 'auto']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
<Tooltip content={<CustomTooltip language={language} />} />
{/* Reference Lines */}
<ReferenceLine yAxisId="left" y={725.60} stroke="var(--color-red)" strokeDasharray="3 3" label={{ position: 'insideTopLeft', value: `${dict.maxLevel} (725.60)`, fill: 'var(--text-main)', fontSize: 12 }} />
<ReferenceLine yAxisId="left" y={724.90} stroke="var(--color-green)" strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: `${dict.storageLevel} (724.90)`, fill: 'var(--text-main)', fontSize: 12 }} />
{/* Data Series */}
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" />
<Line yAxisId="right" type={curveType} dataKey="inflow" stroke="var(--color-green)" strokeWidth={2} dot={false} />
<Line yAxisId="right" type={curveType} dataKey="outflow" stroke="var(--color-orange)" strokeWidth={2} dot={false} />
</ComposedChart>
</ResponsiveContainer>
</div>
{/* Chart Legend */}
<div className="chart-legend-container" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '1rem', marginTop: '1rem', fontSize: '0.85rem', color: 'var(--text-main)' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-cyan)' }}></div> {dict.level}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-green)' }}></div> {dict.inflow}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-orange)' }}></div> {dict.outflow}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--text-muted)' }}><div style={{ width: '12px', borderTop: '2px dashed var(--color-red)' }}></div> {dict.maxLevel}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--text-muted)' }}><div style={{ width: '12px', borderTop: '2px dashed var(--color-green)' }}></div> {dict.storageLevel}</span>
</div>
{/* Smoothed Toggle Control */}
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '2rem', marginBottom: '1rem' }}>
<span style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{dict.view}</span>
<div style={{ display: 'flex', alignItems: 'center', fontSize: '0.9rem' }}>
<span style={{ color: !isSmoothed ? 'var(--text-main)' : 'var(--text-muted)', transition: '0.2s', marginRight: '0.5rem' }}>{dict.raw}</span>
<div
className={`toggle-switch ${isSmoothed ? 'on' : ''}`}
onClick={() => setIsSmoothed(!isSmoothed)}
></div>
<span style={{ color: isSmoothed ? 'var(--text-main)' : 'var(--text-muted)', transition: '0.2s', marginLeft: '0.5rem' }}>{dict.smoothed}</span>
</div>
</div>
</div>
<div className="dashboard-footer" style={{ marginTop: '0' }}>
<span>{dict.dataSources} <a href="https://www.chmi.cz/" target="_blank" rel="noreferrer">ČHMÚ</a>, <a href="https://www.pvl.cz/" target="_blank" rel="noreferrer">pvl.cz</a></span>
<span>{dict.createdIn}</span>
</div>
</div>
);
};
export default LakeDetail;
+245
View File
@@ -0,0 +1,245 @@
import { useState, useEffect } from 'react';
import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
import { type Language, t } from '../translations';
import Topbar from './Topbar';
import { AreaChart, Area, ResponsiveContainer } from 'recharts';
interface Lake {
id: string;
name: string;
river: string;
priority: boolean;
level: number;
capacity: number;
inflow: number;
outflow: number;
volume: number;
sparkline: number[];
}
interface Props {
language: Language;
onSelectLake: (id: string) => void;
}
const CircularProgress = ({ value, size = 60, strokeWidth = 6 }: { value: number, size?: number, strokeWidth?: number }) => {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - (value / 100) * circumference;
return (
<div style={{ position: 'relative', width: size, height: size }}>
<svg width={size} height={size}>
<circle
stroke="rgba(255,255,255,0.1)"
fill="transparent"
strokeWidth={strokeWidth}
r={radius}
cx={size / 2}
cy={size / 2}
/>
<circle
stroke="var(--color-cyan)"
fill="transparent"
strokeWidth={strokeWidth}
strokeLinecap="round"
style={{ strokeDasharray: circumference, strokeDashoffset: offset, transition: 'stroke-dashoffset 0.5s ease-in-out' }}
r={radius}
cx={size / 2}
cy={size / 2}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: size * 0.25, fontWeight: 'bold' }}>
{value}%
</div>
</div>
);
};
const PriorityCard = ({ lake, onSelectLake }: { lake: Lake, onSelectLake: (id: string) => void }) => {
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
return (
<div className="kpi-card priority-lake-card" style={{ flex: 1, padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>{lake.name} {lake.river ? `- ${lake.river}` : ''}</h3>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<div style={{ width: '40px', height: '60px', backgroundColor: 'rgba(255,255,255,0.05)', position: 'relative', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{ position: 'absolute', bottom: 0, left: 0, width: '100%', height: `${lake.capacity}%`, backgroundColor: 'var(--color-cyan)', opacity: 0.3 }}></div>
<div style={{ position: 'absolute', bottom: `${lake.capacity}%`, left: 0, width: '100%', height: '2px', backgroundColor: 'var(--color-cyan)' }}></div>
</div>
<div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Water level</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>{lake.level} <span style={{ fontSize: '1rem', fontWeight: 'normal', color: 'var(--text-muted)' }}>m n.m.</span></div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Depth</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
<div>
<div style={{ fontSize: '1.25rem', fontWeight: 'bold' }}>{lake.capacity}% / <span style={{ fontSize: '1rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>{lake.volume} mil. m³</span></div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Volume</div>
</div>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1, height: '40px', marginRight: '2rem' }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorSpark" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.8}/>
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0}/>
</linearGradient>
</defs>
<Area type="monotone" dataKey="value" stroke="var(--color-cyan)" fillOpacity={1} fill="url(#colorSpark)" />
</AreaChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<FiTrendingUp color="var(--color-green)" />
<span style={{ color: 'var(--text-muted)' }}>Inflow <span style={{ color: 'var(--color-green)' }}>{lake.inflow} m³/s</span></span>
<span style={{ color: 'var(--text-muted)' }}>/ Outflow <span style={{ color: 'var(--color-red)' }}>{lake.outflow} m³/s</span></span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<FiTrendingDown color="var(--color-red)" />
<span style={{ color: 'var(--text-muted)' }}>Inflow <span style={{ color: 'var(--color-green)' }}>{lake.inflow} m³/s</span></span>
<span style={{ color: 'var(--text-muted)' }}>/ Outflow <span style={{ color: 'var(--color-red)' }}>{lake.outflow} m³/s</span></span>
</div>
</div>
</div>
<button
onClick={() => onSelectLake(lake.id)}
style={{
width: '100%', padding: '0.75rem', borderRadius: '0.5rem',
backgroundColor: 'var(--color-cyan)', color: 'white',
border: 'none', fontWeight: 'bold', cursor: 'pointer',
marginTop: 'auto', transition: 'background-color 0.2s'
}}
onMouseOver={e => e.currentTarget.style.backgroundColor = '#0284c7'}
onMouseOut={e => e.currentTarget.style.backgroundColor = 'var(--color-cyan)'}
>
View Full Details
</button>
</div>
);
};
const SmallCard = ({ lake, onSelectLake }: { lake: Lake, onSelectLake: (id: string) => void }) => {
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
return (
<div
className="kpi-card"
onClick={() => onSelectLake(lake.id)}
style={{ padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', cursor: 'pointer', transition: 'transform 0.2s', minHeight: '120px' }}
onMouseOver={e => e.currentTarget.style.transform = 'translateY(-2px)'}
onMouseOut={e => e.currentTarget.style.transform = 'translateY(0)'}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{ fontSize: '0.9rem', color: 'var(--text-muted)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '120px' }}>
{lake.name}
</div>
<div style={{ fontSize: '1.25rem', fontWeight: 'bold' }}>{lake.level}</div>
</div>
<CircularProgress value={lake.capacity} size={36} strokeWidth={3} />
</div>
<div style={{ flex: 1, minHeight: '30px', marginTop: 'auto' }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id={`spark-${lake.id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-cyan)" stopOpacity={0.5}/>
<stop offset="95%" stopColor="var(--color-cyan)" stopOpacity={0}/>
</linearGradient>
</defs>
<Area type="monotone" dataKey="value" stroke="var(--color-cyan)" strokeWidth={1.5} fillOpacity={1} fill={`url(#spark-${lake.id})`} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
};
const LakesOverview = ({ language, onSelectLake }: Props) => {
const [lakes, setLakes] = useState<Lake[]>([]);
const [sortBy, setSortBy] = useState<'name' | 'level' | 'capacity' | 'inflow'>('name');
useEffect(() => {
fetch('/data/lakes_index.json')
.then(res => res.json())
.then(data => setLakes(data))
.catch(err => console.error(err));
}, []);
const priorityLakes = lakes.filter(l => l.priority);
const otherLakes = lakes.filter(l => !l.priority);
// Sorting
otherLakes.sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
if (sortBy === 'level') return b.level - a.level;
if (sortBy === 'capacity') return b.capacity - a.capacity;
if (sortBy === 'inflow') return b.inflow - a.inflow;
return 0;
});
const sortButtonStyle = (type: string) => ({
background: 'none', border: 'none',
color: sortBy === type ? 'var(--text-main)' : 'var(--text-muted)',
cursor: 'pointer', fontSize: '0.85rem'
});
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>Overview: Lakes ({lakes.length})</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>Monitoring {lakes.length} reservoirs across the Czech Republic</p>
</div>
<div style={{ display: 'flex', gap: '0.5rem', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
<span>Sort by:</span>
<button style={sortButtonStyle('name')} onClick={() => setSortBy('name')}>Name (A-Z)</button> |
<button style={sortButtonStyle('level')} onClick={() => setSortBy('level')}>Level</button> |
<button style={sortButtonStyle('capacity')} onClick={() => setSortBy('capacity')}>Capacity</button> |
<button style={sortButtonStyle('inflow')} onClick={() => setSortBy('inflow')}>Flow In</button>
</div>
</div>
{priorityLakes.length > 0 && (
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>Priority Reservoirs</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '1.5rem'
}}>
{priorityLakes.map(lake => <PriorityCard key={lake.id} lake={lake} onSelectLake={onSelectLake} />)}
</div>
</section>
)}
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem' }}>Other Reservoirs ({otherLakes.length})</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '1rem'
}}>
{otherLakes.map(lake => <SmallCard key={lake.id} lake={lake} onSelectLake={onSelectLake} />)}
</div>
</section>
</div>
);
};
export default LakesOverview;
-30
View File
@@ -1,30 +0,0 @@
import React, { useEffect, useState } from 'react';
import '../index.css';
interface LoadingScreenProps {
onLoaded: () => void;
}
const LoadingScreen: React.FC<LoadingScreenProps> = ({ onLoaded }) => {
const [fading, setFading] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setFading(true);
setTimeout(onLoaded, 500); // Wait for fade out animation
}, 2500); // Show loading screen for 2.5 seconds
return () => clearTimeout(timer);
}, [onLoaded]);
return (
<div className={`loading-screen ${fading ? 'fade-out' : ''}`}>
<div className="loader-content">
<div className="spinner"></div>
<h1 className="loading-text">Welcome</h1>
</div>
</div>
);
};
export default LoadingScreen;
-81
View File
@@ -1,81 +0,0 @@
import React from 'react';
import logo from '../assets/logo.jpg';
import { translations, type Language } from '../translations';
import { Link, useLocation, useNavigate } from 'react-router-dom';
interface NavbarProps {
language: Language;
setLanguage: (lang: Language) => void;
}
const Navbar: React.FC<NavbarProps> = ({ language, setLanguage }) => {
const t = translations[language];
const location = useLocation();
const navigate = useNavigate();
const handleNavClick = (id: string, e: React.MouseEvent) => {
e.preventDefault();
if (location.pathname !== '/') {
navigate('/');
// Wait for navigation then scroll
setTimeout(() => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}, 100);
} else {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
};
const handleHomeClick = (e: React.MouseEvent) => {
e.preventDefault();
if (location.pathname !== '/') {
navigate('/');
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
return (
<nav className="navbar">
<div className="nav-content">
<div
className="logo-container"
onClick={handleHomeClick}
style={{ cursor: 'pointer' }}
>
<img src={logo} alt="David Fencl Logo" className="nav-logo" />
</div>
<div className="nav-right">
<div className="language-switcher">
<button
className={`lang-btn ${language === 'en' ? 'active' : ''}`}
onClick={() => setLanguage('en')}
>
EN
</button>
<span className="lang-separator">/</span>
<button
className={`lang-btn ${language === 'cs' ? 'active' : ''}`}
onClick={() => setLanguage('cs')}
>
CZ
</button>
</div>
<div className="links">
<Link to="/time-breaker">{t.timeBreaker}</Link>
<a href="#about" onClick={(e) => handleNavClick('about', e)}>{t.about}</a>
<a href="#contact" onClick={(e) => handleNavClick('contact', e)}>{t.contact}</a>
</div>
</div>
</div>
</nav>
);
};
export default Navbar;
+142
View File
@@ -0,0 +1,142 @@
import { FiX, FiMoon, FiSun, FiGlobe, FiCoffee } from 'react-icons/fi';
import { type Language, t } from '../translations';
interface Props {
language: Language;
setLanguage: (lang: Language) => void;
theme: 'dark' | 'light';
setTheme: (theme: 'dark' | 'light') => void;
onClose: () => void;
}
const SettingsModal = ({ language, setLanguage, theme, setTheme, onClose }: Props) => {
const dict = t[language].settings;
return (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 1000,
backdropFilter: 'blur(4px)'
}}>
<div style={{
backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border-color)',
borderRadius: '1rem',
padding: '2rem',
width: '90%',
maxWidth: '400px',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)'
}}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', margin: 0 }}>{dict.title}</h2>
<button
onClick={onClose}
style={{
background: 'transparent', border: 'none', color: 'var(--text-muted)',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0.5rem', borderRadius: '50%'
}}
>
<FiX size={24} />
</button>
</div>
{/* Theme Setting */}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', marginBottom: '0.75rem', color: 'var(--text-muted)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '1px' }}>
{dict.theme}
</label>
<div style={{ display: 'flex', gap: '1rem' }}>
<button
onClick={() => setTheme('dark')}
style={{
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
border: theme === 'dark' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
backgroundColor: theme === 'dark' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
color: theme === 'dark' ? 'var(--color-cyan)' : 'var(--text-main)',
cursor: 'pointer', transition: 'all 0.2s'
}}
>
<FiMoon /> {dict.dark}
</button>
<button
onClick={() => setTheme('light')}
style={{
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
border: theme === 'light' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
backgroundColor: theme === 'light' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
color: theme === 'light' ? 'var(--color-cyan)' : 'var(--text-main)',
cursor: 'pointer', transition: 'all 0.2s'
}}
>
<FiSun /> {dict.light}
</button>
</div>
</div>
{/* Language Setting */}
<div style={{ marginBottom: '2rem' }}>
<label style={{ display: 'block', marginBottom: '0.75rem', color: 'var(--text-muted)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '1px' }}>
{dict.language}
</label>
<div style={{ display: 'flex', gap: '1rem' }}>
<button
onClick={() => setLanguage('en')}
style={{
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
border: language === 'en' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
backgroundColor: language === 'en' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
color: language === 'en' ? 'var(--color-cyan)' : 'var(--text-main)',
cursor: 'pointer', transition: 'all 0.2s'
}}
>
<FiGlobe /> {dict.english}
</button>
<button
onClick={() => setLanguage('cs')}
style={{
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
border: language === 'cs' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
backgroundColor: language === 'cs' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
color: language === 'cs' ? 'var(--color-cyan)' : 'var(--text-main)',
cursor: 'pointer', transition: 'all 0.2s'
}}
>
<FiGlobe /> {dict.czech}
</button>
</div>
</div>
{/* Buy me a coffee */}
<div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '1.5rem', textAlign: 'center' }}>
<a
href="#"
target="_blank"
rel="noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.5rem',
padding: '0.75rem 1.5rem', borderRadius: '2rem',
backgroundColor: '#FFDD00', color: '#000000', fontWeight: 'bold',
textDecoration: 'none', transition: 'transform 0.2s'
}}
onMouseOver={(e) => e.currentTarget.style.transform = 'scale(1.05)'}
onMouseOut={(e) => e.currentTarget.style.transform = 'scale(1)'}
>
<FiCoffee size={20} />
{dict.buyCoffee}
</a>
</div>
</div>
</div>
);
};
export default SettingsModal;
+66
View File
@@ -0,0 +1,66 @@
import { useState } from 'react';
import { FiDroplet, FiStar, FiMap, FiSettings, FiMenu, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
import { type Language, t } from '../translations';
interface Props {
language: Language;
onOpenSettings: () => void;
activeView: 'overview' | 'detail';
onNavigate: (view: 'overview' | 'detail') => void;
isMobileMenuOpen?: boolean;
onCloseMobileMenu?: () => void;
}
const Sidebar = ({ language, onOpenSettings, activeView, onNavigate, isMobileMenuOpen, onCloseMobileMenu }: Props) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const dict = t[language].sidebar;
return (
<div className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
<div className="sidebar-logo" style={{ position: 'relative' }}>
<FiDroplet />
<div className="sidebar-text">
<span>HLADINATOR</span>
<small>v1.0</small>
</div>
{/* Toggle Button */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
style={{
position: 'absolute', right: isCollapsed ? '-16px' : '-16px', top: '50%', transform: 'translateY(-50%)',
background: 'var(--bg-card)', border: '1px solid var(--border-color)', color: 'var(--text-main)',
borderRadius: '50%', width: '32px', height: '32px', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', zIndex: 10, boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
}}
>
{isCollapsed ? <FiChevronRight size={18} /> : <FiChevronLeft size={18} />}
</button>
</div>
<div className="nav-links">
<div className={`nav-item ${activeView === 'detail' ? 'active' : ''}`} onClick={() => onNavigate('detail')}>
<FiStar />
<span className="sidebar-text">{dict.favorites}</span>
</div>
<div className={`nav-item ${activeView === 'overview' ? 'active' : ''}`} onClick={() => onNavigate('overview')}>
<FiMenu />
<span className="sidebar-text">{dict.lakes}</span>
</div>
<div className="nav-item">
<FiMap />
<span className="sidebar-text">{dict.map}</span>
</div>
</div>
<div className="sidebar-footer">
<div className="nav-item" onClick={onOpenSettings}>
<FiSettings />
<span className="sidebar-text">{dict.settings}</span>
</div>
</div>
</div>
);
};
export default Sidebar;
-162
View File
@@ -1,162 +0,0 @@
.timer-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
max-width: 500px;
margin: 0 auto;
padding: 2rem;
background-color: rgba(30, 32, 36, 0.5);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
}
.timer-display {
font-size: 5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
margin: 2rem 0;
padding: 1rem 2rem;
border-radius: 8px;
background-color: rgba(0, 0, 0, 0.2);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.timer-display.time-up {
background-color: rgba(244, 67, 54, 0.2);
color: #f44336;
animation: flash 1s infinite;
border: 1px solid rgba(244, 67, 54, 0.5);
}
@keyframes flash {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}
.controls-section {
display: flex;
flex-direction: column;
gap: 2rem;
width: 100%;
}
.slider-container {
width: 100%;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.time-slider {
-webkit-appearance: none;
width: 100%;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
outline: none;
cursor: pointer;
}
.time-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--accent-color);
cursor: pointer;
transition: transform 0.2s;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.time-slider::-webkit-slider-thumb:hover {
transform: scale(1.1);
background: #747bff;
}
.time-slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--accent-color);
cursor: pointer;
border: none;
transition: transform 0.2s;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.time-slider::-moz-range-thumb:hover {
transform: scale(1.1);
background: #747bff;
}
.slider-labels {
display: flex;
justify-content: space-between;
color: var(--secondary-color);
font-size: 0.8rem;
font-weight: 500;
}
.main-controls {
display: flex;
justify-content: center;
gap: 1.5rem;
}
.control-btn {
min-width: 120px;
font-size: 1.1rem;
padding: 0.8rem 2rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.start-btn {
background-color: var(--accent-color);
color: white;
box-shadow: 0 4px 15px rgba(100, 108, 255, 0.3);
}
.start-btn:hover {
background-color: #747bff;
box-shadow: 0 6px 20px rgba(100, 108, 255, 0.4);
transform: translateY(-2px);
}
.reset-btn {
background-color: transparent;
border: 1px solid var(--secondary-color);
color: var(--secondary-color);
}
.reset-btn:hover {
border-color: var(--text-color);
color: var(--text-color);
background-color: rgba(255, 255, 255, 0.05);
}
@media (max-width: 480px) {
.timer-display {
font-size: 3.5rem;
}
.control-btn {
min-width: 100px;
padding: 0.7rem 1.5rem;
font-size: 1rem;
}
}
-163
View File
@@ -1,163 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { translations, type Language } from '../translations';
import './TimeBreaker.css';
interface TimeBreakerProps {
language: Language;
}
const DEFAULT_TIME_MINUTES = 22;
const MAX_TIME_MINUTES = 180;
const SECONDS_PER_MINUTE = 60;
const TimeBreaker: React.FC<TimeBreakerProps> = ({ language }) => {
const t = translations[language];
const [timeLeft, setTimeLeft] = useState(DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE);
const [isRunning, setIsRunning] = useState(false);
const [isTimeUp, setIsTimeUp] = useState(false);
const audioContextRef = useRef<AudioContext | null>(null);
const intervalRef = useRef<number | null>(null);
// Format time as MM:SS
const formatTime = (seconds: number): string => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
// Update document title
useEffect(() => {
document.title = `${formatTime(timeLeft)} - ${t.timeBreaker}`;
return () => {
document.title = 'David Fencl - IT Consulting';
};
}, [timeLeft, t.timeBreaker]);
// Timer logic
useEffect(() => {
if (isRunning && timeLeft > 0) {
intervalRef.current = window.setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
handleTimeUp();
return 0;
}
return prev - 1;
});
}, 1000);
} else if (timeLeft === 0 && isRunning) {
handleTimeUp();
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [isRunning]);
const handleTimeUp = () => {
setIsRunning(false);
setIsTimeUp(true);
playAlarm();
// Play alarm 3 times
setTimeout(playAlarm, 1000);
setTimeout(playAlarm, 2000);
};
const playAlarm = () => {
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
const ctx = audioContextRef.current;
if (!ctx) return;
// Create oscillator
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(880, ctx.currentTime); // A5
oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.5); // Drop to A4
gainNode.gain.setValueAtTime(0.5, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.start();
oscillator.stop(ctx.currentTime + 0.5);
};
const handleStart = () => {
if (timeLeft === 0) return;
setIsRunning(true);
setIsTimeUp(false);
};
const handlePause = () => {
setIsRunning(false);
};
const handleReset = () => {
setIsRunning(false);
setIsTimeUp(false);
setTimeLeft(DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE);
};
return (
<div className="home-container fade-in">
<section className="hero">
<div className="timer-container">
<h1 style={{ marginBottom: '1rem' }}>{t.timeBreaker}</h1>
<div className={`timer-display ${isTimeUp ? 'time-up' : ''}`}>
{formatTime(timeLeft)}
</div>
<div className="controls-section">
<div className="slider-container">
<input
type="range"
min="0"
max={MAX_TIME_MINUTES}
value={Math.ceil(timeLeft / 60)}
onChange={(e) => {
const minutes = parseInt(e.target.value, 10);
setTimeLeft(minutes * 60);
if (minutes > 0) setIsTimeUp(false);
}}
className="time-slider"
/>
<div className="slider-labels">
<span>0m</span>
<span>{MAX_TIME_MINUTES}m</span>
</div>
</div>
<div className="main-controls">
{!isRunning ? (
<button className="btn control-btn start-btn" onClick={handleStart}>
{timeLeft > 0 && timeLeft < DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE ? 'Resume' : 'Start'}
</button>
) : (
<button className="btn control-btn start-btn" onClick={handlePause}>
Pause
</button>
)}
<button className="btn control-btn reset-btn" onClick={handleReset}>
Reset
</button>
</div>
</div>
</div>
</section>
</div>
);
};
export default TimeBreaker;
+31
View File
@@ -0,0 +1,31 @@
import { FiSearch, FiMenu, FiDroplet } from 'react-icons/fi';
import { type Language, t } from '../translations';
interface Props {
language: Language;
onToggleMobileMenu?: () => void;
}
const Topbar = ({ language, onToggleMobileMenu }: Props) => {
const dict = t[language].topbar;
return (
<div className="topbar">
<div className="topbar-mobile-header">
<FiMenu onClick={onToggleMobileMenu} className="mobile-only" style={{ fontSize: '1.5rem', cursor: 'pointer' }} />
<div className="mobile-only" style={{ alignItems: 'center', gap: '0.5rem', fontWeight: 'bold', fontSize: '1.25rem' }}>
<FiDroplet color="var(--color-cyan)" />
<span>Hladinator</span>
</div>
<div className="search-bar">
<FiSearch />
<input type="text" placeholder={dict.search} />
</div>
</div>
</div>
);
};
export default Topbar;