feat: implement Open-Meteo weather integration with backfill scripts and updated lake data models.
continuous-integration/drone/push Build encountered an error

This commit is contained in:
David Fencl
2026-06-05 23:34:13 +02:00
parent 8193ce818a
commit 57e9bf12ca
24 changed files with 1122 additions and 758 deletions
+22 -18
View File
@@ -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 && (
+13 -3
View File
@@ -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 */}
+66 -23
View File
@@ -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>
+7 -5
View File
@@ -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>
+13 -64
View File
@@ -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>
+1 -1
View File
@@ -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={{
+16 -6
View File
@@ -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
View File
@@ -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>,
)
+9
View File
@@ -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"
};