feat: implement multilingual SEO support and enhance map UI with data synchronization updates

This commit is contained in:
David Fencl
2026-06-06 17:24:30 +02:00
parent 66021e001e
commit 6395df1992
30 changed files with 3036 additions and 280 deletions
+73 -9
View File
@@ -1,10 +1,14 @@
import { useState, useEffect } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart, ReferenceLine, Bar } from 'recharts';
import { Helmet } from 'react-helmet-async';
import { type Language, t } from '../translations';
import KpiCards from './KpiCards';
import { WeatherWidget } from './WeatherWidget';
import { WindChart } from './WindChart';
import { NAVIGATION_LIMITS } from '../utils/navigationLimits';
import { FiAlertCircle } from 'react-icons/fi';
import { lakesConfig } from '../../scripts/lakesConfig';
import { FiAlertCircle, FiStar } from 'react-icons/fi';
import { useFavorites } from '../hooks/useFavorites';
interface LipnoData {
timestamp: string;
@@ -21,6 +25,7 @@ interface LipnoData {
interface Props {
language: Language;
lakeId: string | null;
windUnit?: 'kmh' | 'ms';
}
const CustomTooltip = ({ active, payload, label, language, isWeather }: any) => {
@@ -34,7 +39,7 @@ const CustomTooltip = ({ active, payload, label, language, isWeather }: any) =>
const isTemp = entry.name === 'temperature' || entry.dataKey === 'temperature';
return (
<p key={index} style={{ margin: 0, color: 'var(--text-main)' }}>
{isTemp ? 'Teplota' : 'Srážky'}: <span style={{ fontWeight: 'bold' }}>{entry.value !== undefined && entry.value !== null ? entry.value.toFixed(1) : 'N/A'} {isTemp ? '°C' : 'mm'}</span>
{isTemp ? (language === 'cs' ? 'Teplota' : 'Temperature') : (language === 'cs' ? 'Srážky' : 'Precipitation')}: <span style={{ fontWeight: 'bold' }}>{entry.value !== undefined && entry.value !== null ? entry.value.toFixed(1) : 'N/A'} {isTemp ? '°C' : 'mm'}</span>
</p>
);
})}
@@ -74,7 +79,7 @@ const CustomTooltip = ({ active, payload, label, language, isWeather }: any) =>
return null;
};
const LakeDetail = ({ language, lakeId }: Props) => {
const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
const [data, setData] = useState<LipnoData[]>([]);
const [loading, setLoading] = useState(true);
const [lakeInfo, setLakeInfo] = useState<any>(null);
@@ -174,6 +179,9 @@ const LakeDetail = ({ language, lakeId }: Props) => {
const targetMs7d = nowMs - 7 * 24 * 60 * 60 * 1000;
const targetMs30d = nowMs - 30 * 24 * 60 * 60 * 1000;
const { isFavorite, toggleFavorite } = useFavorites();
const isFav = lakeId ? isFavorite(lakeId) : false;
let level24hAgo = latestData.level;
let level7dAgo = latestData.level;
let level30dAgo = latestData.level;
@@ -221,19 +229,64 @@ const LakeDetail = ({ language, lakeId }: Props) => {
};
const limits = lakeInfo ? NAVIGATION_LIMITS[lakeInfo.id] : undefined;
const staticConfig = lakeInfo ? lakesConfig.find(l => l.id === lakeInfo.id) : undefined;
const leftYAxisDomain = [
(dataMin: number) => {
let min = dataMin;
if (limits) limits.forEach(l => { if (l.level < min) min = l.level; });
return min - 0.5;
},
(dataMax: number) => {
let max = dataMax;
if (staticConfig?.maxLevel && staticConfig.maxLevel > max) max = staticConfig.maxLevel;
if (staticConfig?.storageLevel && staticConfig.storageLevel > max) max = staticConfig.storageLevel;
return max + 0.5;
}
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', width: '100%' }}>
{lakeInfo && (
<>
<Helmet>
<title>{t[language].seo.lakeTitle.replace('{name}', lakeInfo.name)}</title>
<meta name="description" content={t[language].seo.lakeDesc.replace('{name}', lakeInfo.name)} />
<meta property="og:title" content={t[language].seo.lakeTitle.replace('{name}', lakeInfo.name)} />
<meta property="og:description" content={t[language].seo.lakeDesc.replace('{name}', lakeInfo.name)} />
</Helmet>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', margin: '0 0 0.5rem 0' }}>
<h1 style={{ fontSize: '2rem', fontWeight: 'bold', margin: 0, color: 'var(--text-main)' }}>
{lakeInfo.name}
</h1>
<button
onClick={(e) => { e.preventDefault(); if (lakeId) toggleFavorite(lakeId); }}
style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', padding: '0.25rem' }}
title={isFav ? (language === 'cs' ? "Odebrat z oblíbených" : "Remove from favorites") : (language === 'cs' ? "Přidat do oblíbených" : "Add to favorites")}
>
<FiStar size={24} fill={isFav ? '#f59e0b' : 'none'} color={isFav ? '#f59e0b' : 'var(--text-muted)'} />
</button>
</div>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.95rem', lineHeight: '1.5' }}>
{t[language].seo.lakeDesc.replace('{name}', lakeInfo.name)}
</p>
</div>
</>
)}
<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>
<KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} />
{lakeInfo && lakeInfo.lat && lakeInfo.lng && (
<WeatherWidget lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} sensorTemp={latestData.temperature} />
)}
<div className="kpi-grid-container">
<KpiCards data={kpiData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} />
{lakeInfo && lakeInfo.lat && lakeInfo.lng && (
<WeatherWidget lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} windUnit={windUnit} sensorTemp={latestData.temperature} />
)}
</div>
{limits && limits.map((limit, idx) => {
const diff = latestData.level - limit.level;
@@ -279,7 +332,7 @@ const LakeDetail = ({ language, lakeId }: Props) => {
</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="left" domain={leftYAxisDomain as any} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} tickFormatter={(v) => v.toFixed(2)} />
<YAxis yAxisId="right" orientation="right" domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
@@ -289,6 +342,12 @@ const LakeDetail = ({ language, lakeId }: Props) => {
{limits && limits.map((limit, idx) => (
<ReferenceLine key={idx} yAxisId="left" y={limit.level} stroke={limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b'} strokeDasharray="3 3" label={{ position: 'insideBottomRight', value: language === 'cs' ? limit.labelCs : limit.labelEn, fill: limit.type === 'danger' ? 'var(--color-red)' : '#f59e0b', fontSize: 12 }} />
))}
{staticConfig && staticConfig.maxLevel && (
<ReferenceLine yAxisId="left" y={staticConfig.maxLevel} stroke="var(--color-red)" strokeDasharray="3 3" label={{ position: 'insideTopLeft', value: language === 'cs' ? `Maximální retenční hladina (${staticConfig.maxLevel.toFixed(2)})` : `Max retention level (${staticConfig.maxLevel.toFixed(2)})`, fill: 'var(--color-red)', fontSize: 12 }} />
)}
{staticConfig && staticConfig.storageLevel && (
<ReferenceLine yAxisId="left" y={staticConfig.storageLevel} stroke="var(--color-green)" strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: language === 'cs' ? `Hladina zásobního prostoru (${staticConfig.storageLevel.toFixed(2)})` : `Storage space level (${staticConfig.storageLevel.toFixed(2)})`, fill: 'var(--color-green)', fontSize: 12 }} />
)}
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" isAnimationActive={animate} />
<Line yAxisId="right" type={curveType} dataKey="outflow" stroke="var(--color-orange)" strokeWidth={2} dot={false} isAnimationActive={animate} />
<Line yAxisId="right" type={curveType} dataKey="inflow" stroke="#8b5cf6" strokeWidth={2} dot={false} isAnimationActive={animate} />
@@ -329,6 +388,11 @@ const LakeDetail = ({ language, lakeId }: Props) => {
<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>
{/* Wind Chart placed inside the main card below the weather graph */}
{lakeInfo && lakeInfo.lat && lakeInfo.lng && (
<WindChart lat={lakeInfo.lat} lng={lakeInfo.lng} language={language} timeRange={timeRange} windUnit={windUnit} />
)}
{/* 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>