feat: implement Favorites feature with persistent storage and sidebar integration and update lake data.

This commit is contained in:
David Fencl
2026-06-05 23:57:17 +02:00
parent b660f0f6c3
commit 27551f9183
16 changed files with 569 additions and 74 deletions
+126 -29
View File
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react';
import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
import { FiTrendingUp, FiTrendingDown, FiStar } from 'react-icons/fi';
import { type Language, t } from '../translations';
import { AreaChart, Area, ResponsiveContainer } from 'recharts';
import { useNavigate } from 'react-router-dom';
import { slugify } from '../utils/slugify';
import { useFavorites } from '../hooks/useFavorites';
interface Lake {
id: string;
@@ -12,6 +13,7 @@ interface Lake {
priority: boolean;
level: number;
capacity: number;
storageDiff?: number;
inflow: number;
outflow: number;
volume: number;
@@ -26,7 +28,7 @@ const CircularProgress = ({ value, size = 60, strokeWidth = 6 }: { value: 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}>
@@ -57,18 +59,38 @@ const CircularProgress = ({ value, size = 60, strokeWidth = 6 }: { value: number
);
};
const LakeCard = ({ lake, language }: { lake: Lake, language: Language }) => {
const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language: Language, isFav: boolean, onToggleFav: (id: string) => void }) => {
const navigate = useNavigate();
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
return (
<div
className="kpi-card priority-lake-card"
<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>
{/* Star / Favorite button */}
<button
onClick={(e) => { e.stopPropagation(); onToggleFav(lake.id); }}
title={isFav ? 'Odepnout' : 'Připnout jako oblíbené'}
style={{
position: 'absolute', top: '1rem', right: '1rem',
background: 'none', border: 'none', cursor: 'pointer',
color: isFav ? '#f59e0b' : 'var(--text-muted)',
opacity: isFav ? 1 : 0.4,
transition: 'color 0.2s, opacity 0.2s, transform 0.15s',
padding: '4px',
display: 'flex', alignItems: 'center',
zIndex: 2,
}}
onMouseOver={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.transform = 'scale(1.2)'; }}
onMouseOut={(e) => { e.currentTarget.style.opacity = isFav ? '1' : '0.4'; e.currentTarget.style.transform = 'scale(1)'; }}
>
<FiStar size={18} fill={isFav ? '#f59e0b' : 'none'} />
</button>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0, paddingRight: '2rem' }}>{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' }}>
@@ -80,31 +102,39 @@ const LakeCard = ({ lake, language }: { lake: Lake, language: Language }) => {
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>{lake.level} <span style={{ fontSize: '1rem', fontWeight: 'normal', color: 'var(--text-muted)' }}>m n.m.</span></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 > 0 ? `${lake.capacity}%` : 'N/A'} / <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 style={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
<span style={{ color: lake.capacity >= 80 ? 'var(--color-green)' : lake.capacity < 40 ? 'var(--color-red)' : 'var(--text-main)' }}>
{lake.capacity > 0 ? `${lake.capacity}%` : 'N/A'}
</span>
</div>
{lake.storageDiff !== undefined && (
<div style={{ fontSize: '0.8rem', color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', fontWeight: 500 }}>
{lake.storageDiff > 0 ? '+' : ''}{lake.storageDiff.toFixed(2)} m
</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}/>
<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)" />
@@ -120,9 +150,55 @@ const LakeCard = ({ lake, language }: { lake: Lake, language: Language }) => {
);
};
const SmallLakeCard = ({ lake, isFav, onToggleFav }: { lake: Lake, isFav: boolean, onToggleFav: (id: string) => void }) => {
const navigate = useNavigate();
return (
<div
className="kpi-card"
onClick={() => navigate(`/${slugify(lake.name)}`)}
style={{ cursor: 'pointer', padding: '1rem', display: 'flex', flexDirection: 'column', gap: '0.5rem', position: 'relative' }}
>
{/* Star button */}
<button
onClick={(e) => { e.stopPropagation(); onToggleFav(lake.id); }}
title={isFav ? 'Odepnout' : 'Připnout jako oblíbené'}
style={{
position: 'absolute', top: '0.6rem', right: '0.6rem',
background: 'none', border: 'none', cursor: 'pointer',
color: isFav ? '#f59e0b' : 'var(--text-muted)',
opacity: isFav ? 1 : 0.4,
transition: 'color 0.2s, opacity 0.2s, transform 0.15s',
padding: '2px',
display: 'flex', alignItems: 'center',
zIndex: 2,
}}
onMouseOver={(e) => { e.currentTarget.style.opacity = '1'; e.currentTarget.style.transform = 'scale(1.2)'; }}
onMouseOut={(e) => { e.currentTarget.style.opacity = isFav ? '1' : '0.4'; e.currentTarget.style.transform = 'scale(1)'; }}
>
<FiStar size={14} fill={isFav ? '#f59e0b' : 'none'} />
</button>
<div style={{ fontSize: '0.85rem', fontWeight: 'bold', paddingRight: '1.5rem', lineHeight: 1.2 }}>{lake.name}</div>
<div style={{ fontSize: '1.1rem', fontWeight: 'bold', color: 'var(--color-cyan)' }}>{lake.level} <span style={{ fontSize: '0.7rem', color: 'var(--text-muted)', fontWeight: 'normal' }}>m n.m.</span></div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
<span style={{ color: lake.capacity >= 80 ? 'var(--color-green)' : lake.capacity < 40 ? 'var(--color-red)' : 'var(--text-muted)', fontWeight: 600 }}>
{lake.capacity > 0 ? `${lake.capacity}%` : 'N/A'}
</span>
{lake.storageDiff !== undefined && (
<span style={{ color: lake.storageDiff >= 0 ? 'var(--color-green)' : 'var(--color-red)', marginLeft: '4px' }}>
({lake.storageDiff > 0 ? '+' : ''}{lake.storageDiff.toFixed(2)} m)
</span>
)}
</div>
</div>
);
};
const LakesOverview = ({ language }: Props) => {
const [lakes, setLakes] = useState<Lake[]>([]);
const [sortBy, setSortBy] = useState<'name' | 'level' | 'capacity' | 'inflow'>('name');
const { isFavorite, toggleFavorite, favorites } = useFavorites();
useEffect(() => {
fetch('/data/lakes_index.json')
@@ -131,8 +207,9 @@ const LakesOverview = ({ language }: Props) => {
.catch(err => console.error(err));
}, []);
const priorityLakes = lakes.filter(l => l.priority);
const otherLakes = lakes.filter(l => !l.priority);
const favoriteLakes = lakes.filter(l => isFavorite(l.id));
const priorityLakes = lakes.filter(l => l.priority && !isFavorite(l.id));
const otherLakes = lakes.filter(l => !l.priority && !isFavorite(l.id));
otherLakes.sort((a, b) => {
if (sortBy === 'name') return a.name.localeCompare(b.name);
@@ -143,8 +220,8 @@ const LakesOverview = ({ language }: Props) => {
});
const sortButtonStyle = (type: string) => ({
background: 'none', border: 'none',
color: sortBy === type ? 'var(--text-main)' : 'var(--text-muted)',
background: 'none', border: 'none',
color: sortBy === type ? 'var(--text-main)' : 'var(--text-muted)',
cursor: 'pointer', fontSize: '0.85rem'
});
@@ -164,27 +241,47 @@ const LakesOverview = ({ language }: Props) => {
</div>
</div>
{/* Favorites section */}
{favoriteLakes.length > 0 && (
<section>
<h2 style={{ fontSize: '1.1rem', fontWeight: 'bold', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<FiStar size={16} fill="#f59e0b" color="#f59e0b" /> Oblíbená ({favoriteLakes.length})
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '1.5rem'
}}>
{favoriteLakes.map(lake => (
<LakeCard key={lake.id} lake={lake} language={language} isFav={true} onToggleFav={toggleFavorite} />
))}
</div>
</section>
)}
{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'
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
gap: '1.5rem'
}}>
{priorityLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} />)}
{priorityLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} isFav={false} onToggleFav={toggleFavorite} />)}
</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'
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '1rem'
}}>
{otherLakes.map(lake => <LakeCard key={lake.id} lake={lake} language={language} />)}
{otherLakes.map(lake => (
<SmallLakeCard key={lake.id} lake={lake} isFav={isFavorite(lake.id)} onToggleFav={toggleFavorite} />
))}
</div>
</section>
</div>