feat: implement Open-Meteo weather integration with backfill scripts and updated lake data models.
continuous-integration/drone/push Build encountered an error
continuous-integration/drone/push Build encountered an error
This commit is contained in:
+22
-18
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Routes, Route, useParams, useLocation, useNavigate, Navigate } from 'react-router-dom';
|
||||
import LakeDetail from './components/LakeDetail';
|
||||
import LakesOverview from './components/LakesOverview';
|
||||
import LakeMap from './components/LakeMap';
|
||||
@@ -6,14 +7,23 @@ import Sidebar from './components/Sidebar';
|
||||
import Topbar from './components/Topbar';
|
||||
import SettingsModal from './components/SettingsModal';
|
||||
import { type Language } from './translations';
|
||||
import { lakesConfig } from '../scripts/lakesConfig';
|
||||
import { slugify } from './utils/slugify';
|
||||
import './App.css';
|
||||
|
||||
const LakeDetailWrapper = ({ language }: { language: Language }) => {
|
||||
const { slug } = useParams();
|
||||
const lake = lakesConfig.find(l => slugify(l.text) === slug);
|
||||
|
||||
if (!lake) return <Navigate to="/" replace />;
|
||||
|
||||
return <LakeDetail language={language} lakeId={lake.id} />;
|
||||
};
|
||||
|
||||
function App() {
|
||||
const [language, setLanguage] = useState<Language>('en');
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [activeView, setActiveView] = useState<'overview' | 'detail' | 'map'>('overview');
|
||||
const [activeLakeId, setActiveLakeId] = useState<string | null>(null);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -22,19 +32,13 @@ function App() {
|
||||
} else {
|
||||
document.body.classList.remove('light-mode');
|
||||
}
|
||||
|
||||
// Clean up empty hash from URL (e.g. if the user clicked an empty anchor)
|
||||
if (window.location.href.endsWith('#')) {
|
||||
window.history.replaceState(null, '', window.location.href.slice(0, -1));
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const handleSelectLake = (id: string) => {
|
||||
setActiveLakeId(id);
|
||||
setActiveView('detail');
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleNavigate = (view: 'overview' | 'detail' | 'map') => {
|
||||
setActiveView(view);
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
{/* Mobile overlay */}
|
||||
@@ -48,17 +52,17 @@ function App() {
|
||||
<Sidebar
|
||||
language={language}
|
||||
onOpenSettings={() => setIsSettingsOpen(true)}
|
||||
activeView={activeView}
|
||||
onNavigate={handleNavigate}
|
||||
isMobileMenuOpen={isMobileMenuOpen}
|
||||
onCloseMobileMenu={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="main-content">
|
||||
<Topbar language={language} onToggleMobileMenu={() => setIsMobileMenuOpen(!isMobileMenuOpen)} />
|
||||
{activeView === 'overview' && <LakesOverview language={language} onSelectLake={handleSelectLake} />}
|
||||
{activeView === 'detail' && <LakeDetail language={language} lakeId={activeLakeId} />}
|
||||
{activeView === 'map' && <LakeMap language={language} onSelectLake={handleSelectLake} />}
|
||||
<Routes>
|
||||
<Route path="/" element={<LakesOverview language={language} />} />
|
||||
<Route path="/map" element={<LakeMap language={language} />} />
|
||||
<Route path="/:slug" element={<LakeDetailWrapper language={language} />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
{isSettingsOpen && (
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useState, useEffect } from 'react';
|
||||
|
||||
interface KpiData {
|
||||
level: number;
|
||||
levelDiff24h?: number;
|
||||
levelDiff7d?: number;
|
||||
levelDiff30d?: number;
|
||||
inflow: number;
|
||||
outflow: number;
|
||||
outflow: number;
|
||||
volume: number;
|
||||
fullness: number;
|
||||
storageDiff?: number;
|
||||
@@ -42,8 +44,16 @@ const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
|
||||
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1, marginBottom: '0.5rem' }}>
|
||||
{data.level.toFixed(2)} <span style={{ fontSize: '1rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>m n. m.</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--color-green)' }}>
|
||||
(+0.02 m / 24h)
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
<div style={{ fontSize: '0.85rem', color: (data.levelDiff24h ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
|
||||
({(data.levelDiff24h ?? 0) > 0 ? '+' : ''}{((data.levelDiff24h ?? 0) * 100).toFixed(1)} cm / 24h)
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: (data.levelDiff7d ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
|
||||
({(data.levelDiff7d ?? 0) > 0 ? '+' : ''}{((data.levelDiff7d ?? 0) * 100).toFixed(1)} cm / 7d)
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: (data.levelDiff30d ?? 0) >= 0 ? 'var(--color-green)' : 'var(--color-red)' }}>
|
||||
({(data.levelDiff30d ?? 0) > 0 ? '+' : ''}{((data.levelDiff30d ?? 0) * 100).toFixed(1)} cm / 30d)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Circle for Level */}
|
||||
|
||||
@@ -42,7 +42,7 @@ const CustomTooltip = ({ active, payload, label, language, isWeather }: any) =>
|
||||
<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>
|
||||
{[...payload].sort((a: any, b: any) => {
|
||||
const order = ['level', 'inflow', 'outflow'];
|
||||
const order = ['level', 'inflow', 'outflow', 'temperature', 'precipitation'];
|
||||
const indexA = order.indexOf(a.dataKey);
|
||||
const indexB = order.indexOf(b.dataKey);
|
||||
return (indexA === -1 ? 99 : indexA) - (indexB === -1 ? 99 : indexB);
|
||||
@@ -53,6 +53,8 @@ const CustomTooltip = ({ active, payload, label, language, isWeather }: any) =>
|
||||
if (entry.dataKey === 'level') { labelStr = dict.level; unit = 'm n. m.'; color = 'var(--color-cyan)'; }
|
||||
else if (entry.dataKey === 'outflow') { labelStr = dict.outflow; unit = 'm³/s'; color = 'var(--color-orange)'; }
|
||||
else if (entry.dataKey === 'inflow') { labelStr = dict.inflow; unit = 'm³/s'; color = '#8b5cf6'; }
|
||||
else if (entry.dataKey === 'temperature') { labelStr = language === 'cs' ? 'Teplota' : 'Temperature'; unit = '°C'; color = 'var(--color-red)'; }
|
||||
else if (entry.dataKey === 'precipitation') { labelStr = language === 'cs' ? 'Srážky' : 'Precipitation'; unit = 'mm'; color = 'var(--color-cyan)'; }
|
||||
|
||||
if (!labelStr || entry.value === null || entry.value === undefined) return null;
|
||||
|
||||
@@ -157,8 +159,51 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
|
||||
const animate = chartData.length < 150;
|
||||
|
||||
// Find record from 24h, 7d, 30d ago
|
||||
const nowMs = new Date(latestData.timestamp).getTime();
|
||||
const targetMs24h = nowMs - 24 * 60 * 60 * 1000;
|
||||
const targetMs7d = nowMs - 7 * 24 * 60 * 60 * 1000;
|
||||
const targetMs30d = nowMs - 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let level24hAgo = latestData.level;
|
||||
let level7dAgo = latestData.level;
|
||||
let level30dAgo = latestData.level;
|
||||
|
||||
let minDiff24h = Infinity;
|
||||
let minDiff7d = Infinity;
|
||||
let minDiff30d = Infinity;
|
||||
|
||||
for (const d of data) {
|
||||
const t = new Date(d.timestamp).getTime();
|
||||
|
||||
const diff24h = Math.abs(t - targetMs24h);
|
||||
if (diff24h < minDiff24h) {
|
||||
minDiff24h = diff24h;
|
||||
level24hAgo = d.level;
|
||||
}
|
||||
|
||||
const diff7d = Math.abs(t - targetMs7d);
|
||||
if (diff7d < minDiff7d) {
|
||||
minDiff7d = diff7d;
|
||||
level7dAgo = d.level;
|
||||
}
|
||||
|
||||
const diff30d = Math.abs(t - targetMs30d);
|
||||
if (diff30d < minDiff30d) {
|
||||
minDiff30d = diff30d;
|
||||
level30dAgo = d.level;
|
||||
}
|
||||
}
|
||||
|
||||
const levelDiff24h = latestData.level - level24hAgo;
|
||||
const levelDiff7d = latestData.level - level7dAgo;
|
||||
const levelDiff30d = latestData.level - level30dAgo;
|
||||
|
||||
const kpiData = {
|
||||
level: latestData.level,
|
||||
levelDiff24h,
|
||||
levelDiff7d,
|
||||
levelDiff30d,
|
||||
inflow: lastValidFlowData.inflow,
|
||||
outflow: lastValidFlowData.outflow,
|
||||
volume: lakeInfo?.volume || 0,
|
||||
@@ -219,29 +264,14 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: '#8b5cf6' }}></div> {dict.inflow}</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>
|
||||
|
||||
{/* WEATHER CHART SECTION */}
|
||||
<div className="chart-card" style={{ marginTop: '1.5rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)' }}>Počasí (Teplota a Srážky)</h3>
|
||||
{/* WEATHER CHART SECTION */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', marginTop: '2rem', flexWrap: 'wrap', gap: '1rem' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem', color: 'var(--text-main)' }}>{language === 'cs' ? 'Počasí (Teplota a Srážky)' : 'Weather (Temperature & Precipitation)'}</h3>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: '250px', width: '100%', marginTop: '1rem' }}>
|
||||
<div style={{ flex: 1, minHeight: '200px', width: '100%', marginTop: '0.5rem' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chartData} margin={{ top: 20, right: 0, left: 10, bottom: 0 }}>
|
||||
<ComposedChart data={chartData} margin={{ top: 10, right: 0, left: 10, bottom: 0 }}>
|
||||
<XAxis dataKey="date" stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} minTickGap={50} />
|
||||
<YAxis yAxisId="temp" domain={['auto', 'auto']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(1)} />
|
||||
<YAxis yAxisId="precip" orientation="right" domain={[0, 'auto']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
|
||||
@@ -256,8 +286,21 @@ const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
</div>
|
||||
|
||||
<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-red)' }}></div> Teplota vzduchu [°C]</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '12px', backgroundColor: 'var(--color-cyan)', opacity: 0.6 }}></div> Srážky (24h) [mm]</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-red)' }}></div> {language === 'cs' ? 'Teplota vzduchu' : 'Temperature'} [°C]</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '12px', backgroundColor: 'var(--color-cyan)', opacity: 0.6 }}></div> {language === 'cs' ? 'Srážky' : 'Precipitation'} [mm]</span>
|
||||
</div>
|
||||
|
||||
{/* Smoothed Toggle Control */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '1rem', marginTop: '3rem', 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>
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { FiX, FiSearch, FiDroplet } from 'react-icons/fi';
|
||||
import { type Language } from '../translations';
|
||||
import { type Language, t } from '../translations';
|
||||
import { slugify } from '../utils/slugify';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface LakeData {
|
||||
id: string;
|
||||
@@ -21,7 +23,6 @@ interface LakeData {
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
onSelectLake: (id: string) => void;
|
||||
}
|
||||
|
||||
// Create custom icon
|
||||
@@ -39,8 +40,9 @@ const createCustomIcon = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const LakeMap = ({ language, onSelectLake }: Props) => {
|
||||
const LakeMap = ({ language }: Props) => {
|
||||
const [lakes, setLakes] = useState<LakeData[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isPanelVisible, setIsPanelVisible] = useState(true);
|
||||
|
||||
@@ -77,7 +79,7 @@ const LakeMap = ({ language, onSelectLake }: Props) => {
|
||||
position={[lake.lat, lake.lng]}
|
||||
icon={customIcon}
|
||||
eventHandlers={{
|
||||
click: () => onSelectLake(lake.id)
|
||||
click: () => navigate(`/${slugify(lake.name)}`)
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
@@ -114,7 +116,7 @@ const LakeMap = ({ language, onSelectLake }: Props) => {
|
||||
|
||||
<div className="map-overlay-list">
|
||||
{filteredLakes.map((lake, index) => (
|
||||
<div key={lake.id} className="map-lake-card" onClick={() => onSelectLake(lake.id)}>
|
||||
<div key={lake.id} className="map-lake-card" onClick={() => navigate(`/${slugify(lake.name)}`)}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '0.5rem' }}>{index + 1}. Jezero {lake.name}</div>
|
||||
<div className="map-lake-stats">
|
||||
<div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { slugify } from '../utils/slugify';
|
||||
|
||||
interface Lake {
|
||||
id: string;
|
||||
@@ -19,7 +20,6 @@ interface Lake {
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
onSelectLake: (id: string) => void;
|
||||
}
|
||||
|
||||
const CircularProgress = ({ value, size = 60, strokeWidth = 6 }: { value: number, size?: number, strokeWidth?: number }) => {
|
||||
@@ -57,11 +57,16 @@ const CircularProgress = ({ value, size = 60, strokeWidth = 6 }: { value: number
|
||||
);
|
||||
};
|
||||
|
||||
const PriorityCard = ({ lake, onSelectLake }: { lake: Lake, onSelectLake: (id: string) => void }) => {
|
||||
const LakeCard = ({ lake, language }: { lake: Lake, language: Language }) => {
|
||||
const navigate = useNavigate();
|
||||
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' }}>
|
||||
<div
|
||||
className="kpi-card priority-lake-card"
|
||||
onClick={() => navigate(`/${slugify(lake.name)}`)}
|
||||
style={{ cursor: 'pointer', 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' }}>
|
||||
@@ -73,7 +78,6 @@ const PriorityCard = ({ lake, onSelectLake }: { lake: Lake, onSelectLake: (id: s
|
||||
<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>
|
||||
|
||||
@@ -105,72 +109,18 @@ const PriorityCard = ({ lake, onSelectLake }: { lake: Lake, onSelectLake: (id: s
|
||||
<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>
|
||||
<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 LakesOverview = ({ language }: Props) => {
|
||||
const [lakes, setLakes] = useState<Lake[]>([]);
|
||||
const [sortBy, setSortBy] = useState<'name' | 'level' | 'capacity' | 'inflow'>('name');
|
||||
|
||||
@@ -184,7 +134,6 @@ const LakesOverview = ({ language, onSelectLake }: Props) => {
|
||||
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;
|
||||
@@ -223,7 +172,7 @@ const LakesOverview = ({ language, onSelectLake }: Props) => {
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
||||
gap: '1.5rem'
|
||||
}}>
|
||||
{priorityLakes.map(lake => <PriorityCard key={lake.id} lake={lake} onSelectLake={onSelectLake} />)}
|
||||
{priorityLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} />)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -235,7 +184,7 @@ const LakesOverview = ({ language, onSelectLake }: Props) => {
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{otherLakes.map(lake => <SmallCard key={lake.id} lake={lake} onSelectLake={onSelectLake} />)}
|
||||
{otherLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} />)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,7 @@ const SettingsModal = ({ language, setLanguage, theme, setTheme, onClose }: Prop
|
||||
{/* Buy me a coffee */}
|
||||
<div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '1.5rem', textAlign: 'center' }}>
|
||||
<a
|
||||
href="#"
|
||||
href="https://buymeacoffee.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
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' | 'map';
|
||||
onNavigate: (view: 'overview' | 'detail' | 'map') => void;
|
||||
isMobileMenuOpen?: boolean;
|
||||
onCloseMobileMenu?: () => void;
|
||||
}
|
||||
|
||||
const Sidebar = ({ language, onOpenSettings, activeView, onNavigate, isMobileMenuOpen, onCloseMobileMenu }: Props) => {
|
||||
const Sidebar = ({ language, onOpenSettings, isMobileMenuOpen, onCloseMobileMenu }: Props) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const dict = t[language].sidebar;
|
||||
|
||||
const isOverview = location.pathname === '/';
|
||||
const isMap = location.pathname === '/map';
|
||||
const isDetail = !isOverview && !isMap;
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
navigate(path);
|
||||
if (onCloseMobileMenu) onCloseMobileMenu();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
|
||||
<div className="sidebar-logo" style={{ position: 'relative' }}>
|
||||
@@ -39,15 +49,15 @@ const Sidebar = ({ language, onOpenSettings, activeView, onNavigate, isMobileMen
|
||||
</div>
|
||||
|
||||
<div className="nav-links">
|
||||
<div className={`nav-item ${activeView === 'detail' ? 'active' : ''}`} onClick={() => onNavigate('detail')}>
|
||||
<div className={`nav-item ${isDetail ? 'active' : ''}`} onClick={() => handleNavigate('/lipno-1')}>
|
||||
<FiStar />
|
||||
<span className="sidebar-text">{dict.favorites}</span>
|
||||
</div>
|
||||
<div className={`nav-item ${activeView === 'overview' ? 'active' : ''}`} onClick={() => onNavigate('overview')}>
|
||||
<div className={`nav-item ${isOverview ? 'active' : ''}`} onClick={() => handleNavigate('/')}>
|
||||
<FiMenu />
|
||||
<span className="sidebar-text">{dict.lakes}</span>
|
||||
</div>
|
||||
<div className={`nav-item ${activeView === 'map' ? 'active' : ''}`} onClick={() => onNavigate('map')}>
|
||||
<div className={`nav-item ${isMap ? 'active' : ''}`} onClick={() => handleNavigate('/map')}>
|
||||
<FiMap />
|
||||
<span className="sidebar-text">{dict.map}</span>
|
||||
</div>
|
||||
|
||||
+4
-1
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const slugify = (text: string) => {
|
||||
return text
|
||||
.split(' - ')[0] // "VD Lipno 1 - Vltava" -> "VD Lipno 1"
|
||||
.replace(/^VD\s+/i, '') // Remove "VD " prefix -> "Lipno 1"
|
||||
.normalize('NFD') // Decompose diacritics
|
||||
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-'); // Replace spaces with dashes -> "lipno-1"
|
||||
};
|
||||
Reference in New Issue
Block a user