feat: implement Favorites feature with persistent storage and sidebar integration and update lake data.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FiStar } from 'react-icons/fi';
|
||||
import { type Language } from '../translations';
|
||||
import { useFavorites } from '../hooks/useFavorites';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { slugify } from '../utils/slugify';
|
||||
import { AreaChart, Area, ResponsiveContainer } from 'recharts';
|
||||
import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
|
||||
|
||||
interface Lake {
|
||||
id: string;
|
||||
name: string;
|
||||
river: string;
|
||||
priority: boolean;
|
||||
level: number;
|
||||
capacity: number;
|
||||
storageDiff?: number;
|
||||
inflow: number;
|
||||
outflow: number;
|
||||
volume: number;
|
||||
sparkline: number[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
}
|
||||
|
||||
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 > 0 ? `${value}%` : 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FavoritesOverview = ({ language }: Props) => {
|
||||
const [lakes, setLakes] = useState<Lake[]>([]);
|
||||
const { isFavorite, toggleFavorite } = useFavorites();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/data/lakes_index.json')
|
||||
.then(res => res.json())
|
||||
.then(data => setLakes(data))
|
||||
.catch(err => console.error(err));
|
||||
}, []);
|
||||
|
||||
const favoriteLakes = lakes.filter(l => isFavorite(l.id));
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold', margin: '0 0 0.5rem 0', display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
|
||||
<FiStar size={24} fill="#f59e0b" color="#f59e0b" />
|
||||
{language === 'cs' ? 'Oblíbená' : 'Favourites'}
|
||||
</h1>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||
{language === 'cs'
|
||||
? 'Jezera připnutá v přehledu. Připnout nebo odepnout lze ikonou hvězdičky.'
|
||||
: 'Lakes you pinned in the overview. Use the star icon to pin or unpin.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{favoriteLakes.length === 0 ? (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
gap: '1rem', padding: '4rem 2rem', color: 'var(--text-muted)', textAlign: 'center'
|
||||
}}>
|
||||
<FiStar size={48} strokeWidth={1.2} color="var(--text-muted)" />
|
||||
<p style={{ margin: 0, fontSize: '1.1rem' }}>
|
||||
{language === 'cs' ? 'Zatím žádná oblíbená jezera.' : 'No favourites yet.'}
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: '0.85rem' }}>
|
||||
{language === 'cs'
|
||||
? 'Přejdi na Jezera a nádrže a klikni na ⭐ u jezera, které tě zajímá.'
|
||||
: 'Go to Lakes & Reservoirs and click the ⭐ on any lake to pin it here.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}>
|
||||
{favoriteLakes.map(lake => {
|
||||
const chartData = lake.sparkline.map((val, i) => ({ name: i, value: val }));
|
||||
const isFav = isFavorite(lake.id);
|
||||
return (
|
||||
<div
|
||||
key={lake.id}
|
||||
className="kpi-card priority-lake-card"
|
||||
onClick={() => navigate(`/${slugify(lake.name)}`)}
|
||||
style={{ cursor: 'pointer', padding: '1.5rem', display: 'flex', flexDirection: 'column', gap: '1.5rem', position: 'relative' }}
|
||||
>
|
||||
{/* Unpin button */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleFavorite(lake.id); }}
|
||||
title="Odepnout"
|
||||
style={{
|
||||
position: 'absolute', top: '1rem', right: '1rem',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: '#f59e0b', transition: 'transform 0.15s',
|
||||
padding: '4px', display: 'flex', alignItems: 'center', zIndex: 2,
|
||||
}}
|
||||
onMouseOver={(e) => { e.currentTarget.style.transform = 'scale(1.2)'; }}
|
||||
onMouseOut={(e) => { e.currentTarget.style.transform = 'scale(1)'; }}
|
||||
>
|
||||
<FiStar size={18} fill="#f59e0b" />
|
||||
</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>
|
||||
<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>
|
||||
</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' }}>
|
||||
<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', gap: '1rem', 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>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<FiTrendingDown color="var(--color-red)" />
|
||||
<span style={{ color: 'var(--text-muted)' }}>Outflow <span style={{ color: 'var(--color-red)' }}>{lake.outflow} m³/s</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FavoritesOverview;
|
||||
Reference in New Issue
Block a user