feat: add disclaimer modal, update lake data, and improve component interactions

This commit is contained in:
David Fencl
2026-06-06 20:35:47 +02:00
parent a67a2247c3
commit 231961da19
48 changed files with 1238 additions and 133 deletions
+2
View File
@@ -7,6 +7,7 @@ import FavoritesOverview from './components/FavoritesOverview';
import Sidebar from './components/Sidebar';
import Topbar from './components/Topbar';
import SettingsModal from './components/SettingsModal';
import { DisclaimerModal } from './components/DisclaimerModal';
import { type Language, t } from './translations';
import { lakesConfig } from '../scripts/lakesConfig';
import { slugify } from './utils/slugify';
@@ -58,6 +59,7 @@ function App() {
return (
<div className="dashboard-container">
<DisclaimerModal language={language} setLanguage={setLanguage} />
{/* Mobile overlay */}
{isMobileMenuOpen && (
<div
+127
View File
@@ -0,0 +1,127 @@
import { useState, useEffect } from 'react';
import { type Language, t } from '../translations';
import { TbSwimming, TbSailboat } from 'react-icons/tb';
interface Props {
language: Language;
setLanguage: (lang: Language) => void;
}
export const DisclaimerModal = ({ language, setLanguage }: Props) => {
const [show, setShow] = useState(false);
useEffect(() => {
const isAccepted = localStorage.getItem('hladinator_disclaimer_accepted');
if (!isAccepted) {
setShow(true);
}
}, []);
const handleAccept = () => {
localStorage.setItem('hladinator_disclaimer_accepted', 'true');
setShow(false);
};
if (!show) return null;
return (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.8)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 999999, padding: '1.5rem', backdropFilter: 'blur(4px)'
}}>
<div style={{
backgroundColor: 'var(--bg-card)',
borderRadius: '12px',
padding: '2rem',
maxWidth: '550px',
width: '100%',
boxShadow: '0 10px 30px rgba(0,0,0,0.5)',
display: 'flex', flexDirection: 'column', gap: '1.5rem',
border: '1px solid var(--border-color)',
color: 'var(--text-main)',
maxHeight: '90vh',
overflowY: 'auto'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', margin: 0, color: 'var(--color-cyan)' }}>
{t[language].disclaimer.title}
</h2>
<div style={{ display: 'flex', gap: '0.25rem', backgroundColor: 'rgba(0,0,0,0.2)', padding: '4px', borderRadius: '8px' }}>
<button
onClick={() => setLanguage('cs')}
style={{ background: language === 'cs' ? 'var(--color-cyan)' : 'transparent', color: language === 'cs' ? '#fff' : 'var(--text-muted)', border: 'none', padding: '4px 8px', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold', transition: '0.2s' }}
>
CS
</button>
<button
onClick={() => setLanguage('en')}
style={{ background: language === 'en' ? 'var(--color-cyan)' : 'transparent', color: language === 'en' ? '#fff' : 'var(--text-muted)', border: 'none', padding: '4px 8px', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold', transition: '0.2s' }}
>
EN
</button>
</div>
</div>
<p style={{ margin: 0, lineHeight: 1.5 }}>
{t[language].disclaimer.text1}
</p>
<p style={{ margin: 0, lineHeight: 1.5, color: 'var(--text-muted)' }}>
{t[language].disclaimer.text2}
</p>
<div style={{
marginTop: '0.5rem', padding: '1rem',
backgroundColor: 'rgba(255,255,255,0.03)',
borderRadius: '8px', display: 'flex', flexDirection: 'column', gap: '1rem'
}}>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', gap: '0.25rem', marginTop: '0.25rem' }}>
<TbSwimming size={24} color="var(--color-green)" />
<TbSailboat size={24} color="var(--color-green)" />
</div>
<div style={{ fontSize: '0.9rem', color: 'var(--text-muted)', lineHeight: 1.4 }}>
<div style={{ color: 'var(--text-main)', fontWeight: 'bold', marginBottom: '0.2rem' }}>{language === 'cs' ? 'Zelené ikony (Povoleno)' : 'Green icons (Allowed)'}</div>
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
<li>{t[language].disclaimer.swimDesc}</li>
<li>{t[language].disclaimer.sailDesc}</li>
</ul>
</div>
</div>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', gap: '0.25rem', marginTop: '0.25rem' }}>
<TbSwimming size={24} color="var(--color-red)" />
<TbSailboat size={24} color="var(--color-red)" />
</div>
<div style={{ fontSize: '0.9rem', color: 'var(--text-muted)', lineHeight: 1.4 }}>
<div style={{ color: 'var(--text-main)', fontWeight: 'bold', marginBottom: '0.2rem' }}>{language === 'cs' ? 'Červené ikony (Zakázáno)' : 'Red icons (Forbidden)'}</div>
{t[language].disclaimer.forbiddenDesc}
</div>
</div>
</div>
<button
onClick={handleAccept}
style={{
marginTop: '1rem',
padding: '1rem',
backgroundColor: 'var(--color-cyan)',
color: '#fff',
border: 'none',
borderRadius: '8px',
fontSize: '1.1rem',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'opacity 0.2s'
}}
onMouseOver={(e) => e.currentTarget.style.opacity = '0.9'}
onMouseOut={(e) => e.currentTarget.style.opacity = '1'}
>
{t[language].disclaimer.button}
</button>
</div>
</div>
);
};
+16 -3
View File
@@ -8,6 +8,8 @@ import { useNavigate } from 'react-router-dom';
import { slugify } from '../utils/slugify';
import { AreaChart, Area, ResponsiveContainer, YAxis } from 'recharts';
import { FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
import { TbSwimming, TbSailboat } from 'react-icons/tb';
import { Tooltip } from './Tooltip';
interface Lake {
id: string;
@@ -21,6 +23,7 @@ interface Lake {
outflow: number;
volume: number;
maxVolume: number;
navigationForbidden: boolean;
sparkline: number[];
}
@@ -119,9 +122,19 @@ const FavoritesOverview = ({ language }: Props) => {
<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', paddingRight: '2rem' }}>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
{lake.name} {lake.river ? `- ${lake.river}` : ''}
</h3>
<div style={{ display: 'flex', gap: '0.4rem', flexShrink: 0 }}>
<Tooltip content={lake.navigationForbidden ? (language === 'cs' ? 'Koupání zakázáno' : 'Swimming forbidden') : (language === 'cs' ? 'Koupání (bez omezení)' : 'Swimming allowed')}>
<TbSwimming size={20} color={lake.navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: lake.navigationForbidden ? 0.5 : 0.8 }} />
</Tooltip>
<Tooltip content={lake.navigationForbidden ? (language === 'cs' ? 'Plavba zakázána' : 'Navigation forbidden') : (language === 'cs' ? 'Plavba (i bezmotorová) povolena' : 'Navigation allowed')}>
<TbSailboat size={20} color={lake.navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: lake.navigationForbidden ? 0.5 : 0.8 }} />
</Tooltip>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
+11
View File
@@ -8,7 +8,9 @@ import { WindChart } from './WindChart';
import { NAVIGATION_LIMITS } from '../utils/navigationLimits';
import { lakesConfig } from '../../scripts/lakesConfig';
import { FiAlertCircle, FiStar } from 'react-icons/fi';
import { TbSwimming, TbSailboat } from 'react-icons/tb';
import { useFavorites } from '../hooks/useFavorites';
import { Tooltip as IconTooltip } from './Tooltip';
interface LipnoData {
timestamp: string;
@@ -283,6 +285,15 @@ const LakeDetail = ({ language, lakeId, windUnit = 'kmh' }: Props) => {
>
<FiStar size={24} fill={isFav ? '#f59e0b' : 'none'} color={isFav ? '#f59e0b' : 'var(--text-muted)'} />
</button>
<div style={{ display: 'flex', gap: '0.5rem', marginLeft: 'auto' }}>
<IconTooltip content={(lakeInfo as any).navigationForbidden ? (language === 'cs' ? 'Koupání zakázáno' : 'Swimming forbidden') : (language === 'cs' ? 'Koupání (bez omezení)' : 'Swimming allowed')}>
<TbSwimming size={24} color={(lakeInfo as any).navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: (lakeInfo as any).navigationForbidden ? 0.5 : 0.8 }} />
</IconTooltip>
<IconTooltip content={(lakeInfo as any).navigationForbidden ? (language === 'cs' ? 'Plavba zakázána' : 'Navigation forbidden') : (language === 'cs' ? 'Plavba (i bezmotorová) povolena' : 'Navigation allowed')}>
<TbSailboat size={24} color={(lakeInfo as any).navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: (lakeInfo as any).navigationForbidden ? 0.5 : 0.8 }} />
</IconTooltip>
</div>
</div>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.95rem', lineHeight: '1.5' }}>
{t[language].seo.lakeDesc.replace('{name}', lakeInfo.name)}
+16 -1
View File
@@ -7,6 +7,8 @@ import { useNavigate } from 'react-router-dom';
import { slugify } from '../utils/slugify';
import { useFavorites } from '../hooks/useFavorites';
import { CircularProgress } from './CircularProgress';
import { Tooltip } from './Tooltip';
import { TbSwimming, TbSailboat } from 'react-icons/tb';
interface Lake {
id: string;
@@ -20,6 +22,7 @@ interface Lake {
outflow: number;
volume: number;
maxVolume: number;
navigationForbidden: boolean;
sparkline: number[];
}
@@ -72,7 +75,19 @@ const LakeCard = ({ lake, language, isFav, onToggleFav }: { lake: Lake, language
<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', paddingRight: '2rem' }}>
<h3 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>
{lake.name} {lake.river ? `- ${lake.river}` : ''}
</h3>
<div style={{ display: 'flex', gap: '0.4rem', flexShrink: 0 }}>
<Tooltip content={lake.navigationForbidden ? (language === 'cs' ? 'Koupání zakázáno' : 'Swimming forbidden') : (language === 'cs' ? 'Koupání (bez omezení)' : 'Swimming allowed')}>
<TbSwimming size={20} color={lake.navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: lake.navigationForbidden ? 0.5 : 0.8 }} />
</Tooltip>
<Tooltip content={lake.navigationForbidden ? (language === 'cs' ? 'Plavba zakázána' : 'Navigation forbidden') : (language === 'cs' ? 'Plavba (i bezmotorová) povolena' : 'Navigation allowed')}>
<TbSailboat size={20} color={lake.navigationForbidden ? 'var(--color-red)' : 'var(--color-green)'} style={{ opacity: lake.navigationForbidden ? 0.5 : 0.8 }} />
</Tooltip>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1rem' }}>
<CircularProgress value={lake.capacity} size={70} strokeWidth={6} />
+60
View File
@@ -0,0 +1,60 @@
import { useState, useRef, useEffect, type ReactNode } from 'react';
interface Props {
content: string;
children: ReactNode;
}
export const Tooltip = ({ content, children }: Props) => {
const [show, setShow] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShow(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div
ref={containerRef}
style={{ position: 'relative', display: 'inline-flex' }}
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
onClick={(e) => {
e.stopPropagation();
setShow(!show);
}}
>
{children}
{show && (
<div style={{
position: 'absolute',
bottom: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginBottom: '8px',
backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border-color)',
padding: '0.5rem 0.75rem',
borderRadius: '8px',
width: 'max-content',
maxWidth: '220px',
zIndex: 9999,
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
color: 'var(--text-main)',
fontSize: '0.8rem',
lineHeight: 1.4,
textAlign: 'center',
pointerEvents: 'none'
}}>
{content}
</div>
)}
</div>
);
};
+20 -2
View File
@@ -62,6 +62,15 @@ export const t = {
contact: 'Contact',
contactPlaceholder: 'Your email address',
buyCoffee: 'Buy Me a Coffee'
},
disclaimer: {
title: 'Important Notice',
text1: 'Data in this application (water levels, flows, navigation limits, and weather) are obtained from public sources and are purely for informational purposes.',
text2: 'The application does not serve as an official source for navigation, boating, recreation, or crisis management. We bear no responsibility for inaccuracies, delays, or data outages. For binding information and verification of restrictions, always contact the relevant authorities (e.g., State Navigation Administration).',
swimDesc: 'Swimming allowed (informational). May be restricted locally by hygiene stations.',
sailDesc: 'Navigation allowed (informational). May be restricted to non-motorized vessels.',
forbiddenDesc: 'Strictly forbidden. Usually a drinking water reservoir or nature reserve.',
button: 'I understand and agree'
}
},
cs: {
@@ -123,8 +132,17 @@ export const t = {
windUnitKmh: 'km/h',
windUnitMs: 'm/s',
contact: 'Kontakt',
contactPlaceholder: 'Vaše e-mailová adresa',
buyCoffee: 'Kup mi kávu'
contactPlaceholder: 'Váš e-mail',
buyCoffee: 'Kupte mi kávu'
},
disclaimer: {
title: 'Důležité upozornění',
text1: 'Data v této aplikaci (hladiny, průtoky, limity plavby a počasí) jsou získávána z veřejných zdrojů a mají čistě informativní charakter.',
text2: 'Aplikace neslouží jako oficiální zdroj pro navigaci, plavbu, rekreaci ani krizové řízení. Za případné nepřesnosti, zpoždění nebo výpadky dat neneseme žádnou odpovědnost. Pro závazné informace a ověření omezení vždy kontaktujte příslušné úřady (např. Státní plavební správu).',
swimDesc: 'Koupání obecně povoleno. Upozorňujeme na možné lokální zákazy z důvodu zhoršené kvality vody (sinice).',
sailDesc: 'Plavba obecně povolena. Typ plavidel (např. se spalovacím motorem) může být místně omezen vyhláškou SPS.',
forbiddenDesc: 'Přísný zákaz! Jedná se o vodárenskou nádrž (zdroj pitné vody) nebo přísně chráněnou přírodní rezervaci.',
button: 'Rozumím a souhlasím'
}
}
};