feat: Initial commit - Hladinator (Water Reservoir Dashboard)
continuous-integration/drone/push Build encountered an error
continuous-integration/drone/push Build encountered an error
- Setup React project with Vite and TypeScript - Built dynamic UI supporting Dark/Light mode and CS/EN localization - Added Lakes Overview grid with mock data for 40+ reservoirs - Created interactive Recharts charts for water levels and flow rates - Designed fully responsive premium mobile layout with custom SVG KPIs - Developed TypeScript scraper scripts to fetch reservoir data
This commit is contained in:
+26
@@ -39,3 +39,29 @@ steps:
|
||||
commands:
|
||||
- curl -u 'howard:Papadopolus0' -X POST 'https://portainer.martinfencl.eu/api/stacks/webhooks/72df3f63-b271-4aef-9325-772a2ccbaeca'
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: scrape-cron
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- cron
|
||||
cron:
|
||||
- lipno-scraper
|
||||
|
||||
steps:
|
||||
- name: scrape-and-commit
|
||||
image: node:18-alpine
|
||||
environment:
|
||||
GIT_AUTHOR_NAME: drone
|
||||
GIT_AUTHOR_EMAIL: drone@internet-master.cz
|
||||
GIT_COMMITTER_NAME: drone
|
||||
GIT_COMMITTER_EMAIL: drone@internet-master.cz
|
||||
commands:
|
||||
- apk add --no-cache git
|
||||
- npm ci
|
||||
- node scripts/scrapeLipno.js
|
||||
- git add public/data/lipno.json
|
||||
- git commit -m "chore: update lipno reservoir data [CI SKIP]" || true
|
||||
- git push origin main || true
|
||||
Generated
+1532
-5
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -7,13 +7,19 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"mock": "tsx scripts/generateMockLakes.ts",
|
||||
"scrape": "tsx scripts/scrapeLipno.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.17.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router-dom": "^7.9.6"
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@@ -25,6 +31,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
[
|
||||
{
|
||||
"timestamp": "2026-05-30T05:00:00.000Z",
|
||||
"level": 723.04,
|
||||
"flow": 1.03
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-31T05:00:00.000Z",
|
||||
"level": 723.06,
|
||||
"flow": 1.03
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-01T05:00:00.000Z",
|
||||
"level": 723.08,
|
||||
"flow": 30.94
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-02T05:00:00.000Z",
|
||||
"level": 723.08,
|
||||
"flow": 1.51
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-03T05:00:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-04T05:00:00.000Z",
|
||||
"level": 723.08,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-04T18:00:00.000Z",
|
||||
"level": 723.08,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-04T19:00:00.000Z",
|
||||
"level": 723.08,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-04T20:00:00.000Z",
|
||||
"level": 723.08,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-04T21:00:00.000Z",
|
||||
"level": 723.08,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-04T22:00:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-04T23:00:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T00:00:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T01:00:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T02:00:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T03:00:00.000Z",
|
||||
"level": 723.08,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T04:00:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T05:00:00.000Z",
|
||||
"level": 723.08,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T06:00:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T07:00:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T08:00:00.000Z",
|
||||
"level": 723.1,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T09:00:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T10:00:00.000Z",
|
||||
"level": 723.1,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T11:00:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T12:00:00.000Z",
|
||||
"level": 723.1,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T13:00:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T14:00:00.000Z",
|
||||
"level": 723.1,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T15:00:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T16:00:00.000Z",
|
||||
"level": 723.1,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T17:00:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T17:10:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T17:20:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T17:30:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-06-05T17:40:00.000Z",
|
||||
"level": 723.09,
|
||||
"flow": 1.49
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface LakeRaw {
|
||||
id: string;
|
||||
text: string;
|
||||
priority?: boolean;
|
||||
}
|
||||
|
||||
const lakesRaw: LakeRaw[] = [
|
||||
{ id: "VLL1|1", text: "VD Lipno 1 - Vltava", priority: true },
|
||||
{ id: "VLOR|1", text: "VD Orlík - Vltava", priority: true },
|
||||
{ id: "VLSL|1", text: "VD Slapy - Vltava", priority: false },
|
||||
{ id: "BLHU|1", text: "VD Husinec - Blanice (PI)" },
|
||||
{ id: "BIBI|1", text: "VD Bílsko - Bílský potok" },
|
||||
{ id: "KLDP|3", text: "VD Dolejší Padrťský rybník" },
|
||||
{ id: "KLHP|3", text: "VD Hořejší Padrťský rybník" },
|
||||
{ id: "KLKL|3", text: "VD Klabava - Klabava" },
|
||||
{ id: "KCKC|3", "text": "VD Klíčava - Klíčava" },
|
||||
{ id: "LILA|3", "text": "VD Láz - Litavka" },
|
||||
{ id: "MARI|1", "text": "VD Římov - Malše" },
|
||||
{ id: "MZHR|3", "text": "VD Hracholusky - Mže" },
|
||||
{ id: "MZLU|3", "text": "VD Lučina - Mže" },
|
||||
{ id: "MZSS|3", "text": "VD Plzeň-Štruncovy sady" },
|
||||
{ id: "OPOB|3", "text": "VD Obecnice - Obecnický potok" },
|
||||
{ id: "PPPI|3", "text": "VD Pilská - Pilský potok" },
|
||||
{ id: "RACU|3", "text": "VD České Údolí - Radbuza" },
|
||||
{ id: "SPNE|2", "text": "VD Němčice - Sedlický potok" },
|
||||
{ id: "SVKR|1", "text": "VD Švihov - Želivka" },
|
||||
{ id: "UHKA|1", "text": "VD Kamýk - Vltava" },
|
||||
{ id: "VRSN|1", "text": "VD Vrané - Vltava" },
|
||||
{ id: "ZLUT|3", "text": "VD Žlutice - Střela" },
|
||||
// Adding dummies to reach ~40
|
||||
...Array.from({length: 18}).map((_, i) => ({ id: `DUMMY${i}`, text: `VD Dummy Lake ${i+1}` }))
|
||||
];
|
||||
|
||||
const lakes = lakesRaw.map(lake => {
|
||||
const sparkline = Array.from({length: 12}).map(() => 50 + Math.random() * 20);
|
||||
return {
|
||||
id: lake.id,
|
||||
name: lake.text.replace('VD ', '').split('-')[0].trim(),
|
||||
river: lake.text.includes('-') ? lake.text.split('-')[1].trim() : '',
|
||||
priority: lake.priority || false,
|
||||
level: (200 + Math.random() * 500).toFixed(2),
|
||||
capacity: Math.floor(20 + Math.random() * 80), // 20% to 100%
|
||||
inflow: (Math.random() * 20).toFixed(1),
|
||||
outflow: (Math.random() * 20).toFixed(1),
|
||||
volume: (Math.random() * 300).toFixed(1),
|
||||
sparkline
|
||||
};
|
||||
});
|
||||
|
||||
const outputPath = path.resolve(process.cwd(), 'public/data/lakes_index.json');
|
||||
fs.writeFileSync(outputPath, JSON.stringify(lakes, null, 2));
|
||||
console.log('Mock lakes generated:', lakes.length);
|
||||
@@ -0,0 +1,93 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as cheerio from 'cheerio';
|
||||
import axios from 'axios';
|
||||
import https from 'https';
|
||||
|
||||
const URL = 'https://www.pvl.cz/portal/nadrze/cz/pc/Mereni.aspx?oid=1&id=VLL1';
|
||||
const DATA_FILE = path.resolve('public/data/lipno.json');
|
||||
|
||||
interface DataRecord {
|
||||
timestamp: string;
|
||||
level: number;
|
||||
flow: number;
|
||||
}
|
||||
|
||||
// Parse date from DD.MM.YYYY HH:MM to ISO
|
||||
function parseDateString(dateStr: string): string {
|
||||
const [datePart, timePart] = dateStr.trim().split(' ');
|
||||
const [day, month, year] = datePart.split('.');
|
||||
const [hours, minutes] = timePart.split(':');
|
||||
|
||||
const d = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hours), parseInt(minutes));
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
async function scrape(): Promise<void> {
|
||||
try {
|
||||
const agent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
});
|
||||
|
||||
const response = await axios.get(URL, {
|
||||
httpsAgent: agent,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
const html = response.data;
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const rows = $('table tr');
|
||||
const newData: DataRecord[] = [];
|
||||
|
||||
rows.each((i, row) => {
|
||||
const tds = $(row).find('td');
|
||||
if (tds.length >= 3) {
|
||||
const datetimeText = $(tds[0]).text().trim();
|
||||
// Check if it's a valid date string matching DD.MM.YYYY HH:MM
|
||||
if (/^\d{2}\.\d{2}\.\d{4}\s\d{2}:\d{2}$/.test(datetimeText)) {
|
||||
const timestamp = parseDateString(datetimeText);
|
||||
const levelText = $(tds[1]).text().trim().replace(',', '.');
|
||||
const flowText = $(tds[2]).text().trim().replace(',', '.');
|
||||
|
||||
newData.push({
|
||||
timestamp,
|
||||
level: parseFloat(levelText),
|
||||
flow: parseFloat(flowText)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Load existing data
|
||||
let existingData: DataRecord[] = [];
|
||||
if (fs.existsSync(DATA_FILE)) {
|
||||
const fileContent = fs.readFileSync(DATA_FILE, 'utf-8');
|
||||
existingData = JSON.parse(fileContent);
|
||||
}
|
||||
|
||||
// Merge and deduplicate by timestamp
|
||||
const dataMap = new Map<string, DataRecord>();
|
||||
existingData.forEach(item => dataMap.set(item.timestamp, item));
|
||||
newData.forEach(item => dataMap.set(item.timestamp, item));
|
||||
|
||||
// Sort chronologically
|
||||
const mergedData = Array.from(dataMap.values()).sort((a, b) => {
|
||||
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
||||
});
|
||||
|
||||
// Save back
|
||||
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(mergedData, null, 2), 'utf-8');
|
||||
|
||||
console.log(`Scraped ${newData.length} records. Total records in DB: ${mergedData.length}`);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Error scraping data:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
scrape();
|
||||
+444
-33
@@ -1,42 +1,453 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
.dashboard-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
background-color: var(--bg-card);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.5rem 1rem;
|
||||
transition: width 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 72px;
|
||||
padding: 1.5rem 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-logo {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-logo svg {
|
||||
color: var(--color-cyan);
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.sidebar-logo div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-logo span {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sidebar-logo small {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: linear-gradient(135deg, var(--color-cyan) 0%, #0284c7 100%);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(6, 182, 212, 0.3);
|
||||
}
|
||||
|
||||
.nav-item.active svg {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.nav-item svg {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem 2rem;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-card);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
width: 400px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.search-bar svg {
|
||||
color: var(--text-muted);
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-main);
|
||||
outline: none;
|
||||
width: 100%;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-bar input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--color-green);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 8px var(--color-green);
|
||||
}
|
||||
|
||||
.kpi-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
background-color: var(--bg-card);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.kpi-title {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.kpi-subtitle {
|
||||
font-size: 0.85rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.kpi-trend.positive { color: var(--color-green); }
|
||||
.kpi-trend.negative { color: var(--color-red); }
|
||||
|
||||
.chart-card {
|
||||
background-color: var(--bg-card);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
.button-group {
|
||||
display: flex;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
.control-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
|
||||
.toggle-switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.toggle-switch.on {
|
||||
background-color: var(--color-cyan);
|
||||
}
|
||||
|
||||
.toggle-switch.on::after {
|
||||
left: calc(100% - 18px);
|
||||
}
|
||||
|
||||
.dashboard-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-footer a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Mobile Responsiveness */
|
||||
.mobile-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-only {
|
||||
display: flex !important;
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.dashboard-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar.mobile-open {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.topbar-mobile-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
width: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.search-bar svg {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.kpi-container {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.kpi-subtitle {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 0.5rem;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chart-legend-container {
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem !important;
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
.chart-legend-container > span {
|
||||
flex: 0 0 calc(50% - 0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
+71
-24
@@ -1,31 +1,78 @@
|
||||
import { useState } from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||
import LoadingScreen from './components/LoadingScreen'
|
||||
import Home from './components/Home'
|
||||
import Navbar from './components/Navbar'
|
||||
import TimeBreaker from './components/TimeBreaker'
|
||||
import { type Language } from './translations'
|
||||
import './App.css'
|
||||
import { useState, useEffect } from 'react';
|
||||
import LakeDetail from './components/LakeDetail';
|
||||
import LakesOverview from './components/LakesOverview';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Topbar from './components/Topbar';
|
||||
import SettingsModal from './components/SettingsModal';
|
||||
import { type Language } from './translations';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [language, setLanguage] = useState<Language>('en')
|
||||
const [language, setLanguage] = useState<Language>('en');
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||
const [activeView, setActiveView] = useState<'overview' | 'detail'>('overview');
|
||||
const [activeLakeId, setActiveLakeId] = useState<string | null>(null);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'light') {
|
||||
document.body.classList.add('light-mode');
|
||||
} else {
|
||||
document.body.classList.remove('light-mode');
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const handleSelectLake = (id: string) => {
|
||||
setActiveLakeId(id);
|
||||
setActiveView('detail');
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleNavigate = (view: 'overview' | 'detail') => {
|
||||
setActiveView(view);
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<LoadingScreen onLoaded={() => setIsLoading(false)} />
|
||||
) : (
|
||||
<Router>
|
||||
<Navbar language={language} setLanguage={setLanguage} />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home language={language} />} />
|
||||
<Route path="/time-breaker" element={<TimeBreaker language={language} />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
<div className="dashboard-container">
|
||||
{/* Mobile overlay */}
|
||||
{isMobileMenuOpen && (
|
||||
<div
|
||||
style={{ position: 'fixed', top: 0, left: 0, width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.5)', zIndex: 999 }}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
></div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
<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} />
|
||||
) : (
|
||||
<LakeDetail language={language} lakeId={activeLakeId} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSettingsOpen && (
|
||||
<SettingsModal
|
||||
language={language}
|
||||
setLanguage={setLanguage}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
onClose={() => setIsSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FaFacebookF, FaInstagram, FaLinkedinIn } from 'react-icons/fa';
|
||||
import { SiTypescript, SiReact, SiJavascript } from 'react-icons/si';
|
||||
import { translations, type Language } from '../translations';
|
||||
|
||||
interface HomeProps {
|
||||
language: Language;
|
||||
}
|
||||
|
||||
const Home: React.FC<HomeProps> = ({ language }) => {
|
||||
const t = translations[language];
|
||||
|
||||
return (
|
||||
<div className="home-container fade-in">
|
||||
<section id="home" className="hero fade-in">
|
||||
<div className="hero-content">
|
||||
<div className="hero-text">
|
||||
<span className="welcome-text">{t.welcome}</span>
|
||||
<h1>{t.hello} <span className="highlight">Davis</span></h1>
|
||||
<h2 className="job-title">{t.job}</h2>
|
||||
<p className="description">
|
||||
{t.desc}
|
||||
</p>
|
||||
|
||||
<div className="hero-footer">
|
||||
<div className="socials">
|
||||
<span className="footer-label">{t.findMe}</span>
|
||||
<div className="icon-group">
|
||||
<button className="icon-btn"><FaFacebookF /></button>
|
||||
<button className="icon-btn"><FaInstagram /></button>
|
||||
<button className="icon-btn"><FaLinkedinIn /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="skills">
|
||||
<span className="footer-label">{t.bestSkill}</span>
|
||||
<div className="icon-group">
|
||||
<button className="icon-btn"><SiTypescript /></button>
|
||||
<button className="icon-btn"><SiReact /></button>
|
||||
<button className="icon-btn"><SiJavascript /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-image-container">
|
||||
<div className="hero-image-placeholder">
|
||||
<span>Photo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="about" className="section about fade-in">
|
||||
<h2>{t.aboutMe}</h2>
|
||||
<p>
|
||||
{t.aboutDesc}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="contact" className="section contact fade-in">
|
||||
<h2>{t.getInTouch}</h2>
|
||||
<p>
|
||||
{t.contactDesc} <a href="mailto:hello@example.com">{t.emailMe}</a>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { FiArrowUp, FiArrowDown } from 'react-icons/fi';
|
||||
import { type Language, t } from '../translations';
|
||||
|
||||
interface KpiData {
|
||||
level: number;
|
||||
inflow: number;
|
||||
outflow: number;
|
||||
volume: number;
|
||||
fullness: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: KpiData;
|
||||
language: Language;
|
||||
lakeName?: string;
|
||||
}
|
||||
|
||||
const KpiCards = ({ data, language, lakeName = 'Lipno 1' }: Props) => {
|
||||
const dict = t[language].kpi;
|
||||
const flowDiff = data.inflow - data.outflow;
|
||||
|
||||
return (
|
||||
<div className="kpi-container-mobile">
|
||||
{/* CARD 1: HLADINA */}
|
||||
<div className="kpi-card-full">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
{dict.level} {lakeName}
|
||||
</div>
|
||||
<div style={{ fontSize: '2.5rem', fontWeight: 'bold', color: 'var(--color-cyan)', lineHeight: 1 }}>
|
||||
{data.level.toFixed(2)} <span style={{ fontSize: '1.2rem', fontWeight: 'normal', color: 'var(--text-main)' }}>m n. m.</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.9rem', color: 'var(--color-green)', marginTop: '0.5rem' }}>
|
||||
(+0.02 m / 24h)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Circle */}
|
||||
<div style={{ width: '60px', height: '60px', position: 'relative' }}>
|
||||
<svg width="60" height="60" viewBox="0 0 60 60">
|
||||
<circle cx="30" cy="30" r="26" fill="transparent" stroke="rgba(255,255,255,0.05)" strokeWidth="6" />
|
||||
<circle cx="30" cy="30" r="26" fill="transparent" stroke="var(--color-cyan)" strokeWidth="6" strokeDasharray="163" strokeDashoffset="40" strokeLinecap="round" transform="rotate(-90 30 30)" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="kpi-row-half">
|
||||
{/* CARD 2: PRŮTOK */}
|
||||
<div className="kpi-card-half">
|
||||
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
{dict.flow}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', height: '100%' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', fontSize: '0.85rem' }}>
|
||||
<span style={{ color: 'var(--text-main)' }}>{dict.inflow}: <span style={{ fontWeight: 'bold' }}>{data.inflow.toFixed(1)} m³/s</span></span>
|
||||
<span style={{ color: 'var(--text-main)' }}>{dict.outflow}: <span style={{ fontWeight: 'bold' }}>{data.outflow.toFixed(1)} m³/s</span> <FiArrowDown color="var(--color-red)" /></span>
|
||||
</div>
|
||||
|
||||
{/* Flow Circle */}
|
||||
<div style={{ width: '50px', height: '50px', position: 'relative' }}>
|
||||
<svg width="50" height="50" viewBox="0 0 50 50">
|
||||
<circle cx="25" cy="25" r="22" fill="transparent" stroke="rgba(255,255,255,0.05)" strokeWidth="4" />
|
||||
<circle cx="25" cy="25" r="22" fill="transparent" stroke="var(--color-cyan)" strokeWidth="4" strokeDasharray="138" strokeDashoffset="40" strokeLinecap="round" transform="rotate(-90 25 25)" />
|
||||
</svg>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span style={{ fontSize: '0.8rem', fontWeight: 'bold', lineHeight: 1 }}>{Math.max(data.inflow, data.outflow).toFixed(1)}</span>
|
||||
<span style={{ fontSize: '0.5rem', color: 'var(--text-muted)' }}>m³/s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CARD 3: NAPLNĚNOST */}
|
||||
<div className="kpi-card-half">
|
||||
<div style={{ fontSize: '1rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
{dict.fullness}
|
||||
</div>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', lineHeight: 1, marginBottom: '0.5rem' }}>
|
||||
{data.fullness.toFixed(1)}%
|
||||
</div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
{dict.volume}: {data.volume.toFixed(1)} mil. m³
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KpiCards;
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Line, ComposedChart, ReferenceLine } from 'recharts';
|
||||
import { type Language, t } from '../translations';
|
||||
import KpiCards from './KpiCards';
|
||||
|
||||
interface LipnoData {
|
||||
timestamp: string;
|
||||
date: string;
|
||||
level: number;
|
||||
inflow: number;
|
||||
outflow: number;
|
||||
volume: number;
|
||||
fullness: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
lakeId: string | null;
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload, label, language }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const dict = t[language].chart;
|
||||
return (
|
||||
<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>
|
||||
<p style={{ margin: 0, color: 'var(--text-main)' }}>{dict.level}: <span style={{ fontWeight: 'bold' }}>{payload[0].value.toFixed(2)} m n. m.</span></p>
|
||||
<p style={{ margin: 0, color: 'var(--text-main)' }}>{dict.inflow}: <span style={{ fontWeight: 'bold' }}>{payload[1].value.toFixed(1)} m³/s</span></p>
|
||||
<p style={{ margin: 0, color: 'var(--text-main)' }}>{dict.outflow}: <span style={{ fontWeight: 'bold' }}>{payload[2].value.toFixed(1)} m³/s</span></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const LakeDetail = ({ language, lakeId }: Props) => {
|
||||
const [data, setData] = useState<LipnoData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lakeInfo, setLakeInfo] = useState<any>(null);
|
||||
const [isSmoothed, setIsSmoothed] = useState(true);
|
||||
const dict = t[language].chart;
|
||||
const topbarDict = t[language].topbar;
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/data/lakes_index.json')
|
||||
.then(res => res.json())
|
||||
.then(indexData => {
|
||||
const found = indexData.find((l: any) => l.id === lakeId);
|
||||
setLakeInfo(found || { name: 'Lipno 1', river: 'Vltava' });
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
|
||||
fetch('/data/lipno.json')
|
||||
.then(res => res.json())
|
||||
.then(json => {
|
||||
const formattedData = json.map((item: any) => {
|
||||
const outflow = item.flow;
|
||||
const inflow = outflow + (Math.random() * 2 - 0.5);
|
||||
const volume = 301.2 + (item.level - 723) * 10;
|
||||
const fullness = (volume / 306) * 100;
|
||||
|
||||
return {
|
||||
timestamp: item.timestamp,
|
||||
date: new Date(item.timestamp).toLocaleString(language === 'cs' ? 'cs-CZ' : 'en-GB', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
}),
|
||||
level: item.level,
|
||||
outflow: outflow,
|
||||
inflow: inflow,
|
||||
volume: volume,
|
||||
fullness: fullness
|
||||
};
|
||||
});
|
||||
setData(formattedData);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to load data', err);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [language, lakeId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'var(--bg-dark)', color: 'var(--text-main)' }}>
|
||||
<div style={{ fontSize: '1.25rem' }}>Loading HLADINATOR...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const latestData = data[data.length - 1] || { level: 0, inflow: 0, outflow: 0, volume: 0, fullness: 0 };
|
||||
const curveType = isSmoothed ? 'monotone' : 'linear';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem', width: '100%' }}>
|
||||
<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>
|
||||
|
||||
<div className="top-time-controls">
|
||||
<button className="active">24h</button>
|
||||
<button>7d</button>
|
||||
<button>30d</button>
|
||||
<button>{dict.year}</button>
|
||||
<button>{dict.all}</button>
|
||||
</div>
|
||||
|
||||
<KpiCards data={latestData} language={language} lakeName={lakeInfo ? lakeInfo.name : 'Lipno 1'} />
|
||||
|
||||
{/* CHART SECTION */}
|
||||
<div className="chart-card">
|
||||
<div className="chart-header" style={{ borderBottom: 'none', paddingBottom: '0' }}>
|
||||
<span className="chart-title">
|
||||
{dict.title} {lakeInfo ? `${lakeInfo.name} ${lakeInfo.river ? `- ${lakeInfo.river}` : ''}` : 'Lipno 1 - Vltava'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: '300px', width: '100%', marginTop: '1rem' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={data} margin={{ top: 20, right: 0, left: 10, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorLevel" 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>
|
||||
<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="right" orientation="right" domain={[0, 'auto']} stroke="var(--text-muted)" tick={{fill: 'var(--text-muted)', fontSize: 12}} />
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<Tooltip content={<CustomTooltip language={language} />} />
|
||||
|
||||
{/* Reference Lines */}
|
||||
<ReferenceLine yAxisId="left" y={725.60} stroke="var(--color-red)" strokeDasharray="3 3" label={{ position: 'insideTopLeft', value: `${dict.maxLevel} (725.60)`, fill: 'var(--text-main)', fontSize: 12 }} />
|
||||
<ReferenceLine yAxisId="left" y={724.90} stroke="var(--color-green)" strokeDasharray="3 3" label={{ position: 'insideBottomLeft', value: `${dict.storageLevel} (724.90)`, fill: 'var(--text-main)', fontSize: 12 }} />
|
||||
|
||||
{/* Data Series */}
|
||||
<Area yAxisId="left" type={curveType} dataKey="level" stroke="var(--color-cyan)" strokeWidth={2} fillOpacity={1} fill="url(#colorLevel)" />
|
||||
<Line yAxisId="right" type={curveType} dataKey="inflow" stroke="var(--color-green)" strokeWidth={2} dot={false} />
|
||||
<Line yAxisId="right" type={curveType} dataKey="outflow" stroke="var(--color-orange)" strokeWidth={2} dot={false} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Chart Legend */}
|
||||
<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-cyan)' }}></div> {dict.level}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-green)' }}></div> {dict.inflow}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}><div style={{ width: '12px', height: '4px', backgroundColor: 'var(--color-orange)' }}></div> {dict.outflow}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--text-muted)' }}><div style={{ width: '12px', borderTop: '2px dashed var(--color-red)' }}></div> {dict.maxLevel}</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', color: 'var(--text-muted)' }}><div style={{ width: '12px', borderTop: '2px dashed var(--color-green)' }}></div> {dict.storageLevel}</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>
|
||||
|
||||
<div className="dashboard-footer" style={{ marginTop: '0' }}>
|
||||
<span>{dict.dataSources} <a href="https://www.chmi.cz/" target="_blank" rel="noreferrer">ČHMÚ</a>, <a href="https://www.pvl.cz/" target="_blank" rel="noreferrer">pvl.cz</a></span>
|
||||
<span>{dict.createdIn}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LakeDetail;
|
||||
@@ -0,0 +1,245 @@
|
||||
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';
|
||||
|
||||
interface Lake {
|
||||
id: string;
|
||||
name: string;
|
||||
river: string;
|
||||
priority: boolean;
|
||||
level: number;
|
||||
capacity: number;
|
||||
inflow: number;
|
||||
outflow: number;
|
||||
volume: number;
|
||||
sparkline: number[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
onSelectLake: (id: string) => void;
|
||||
}
|
||||
|
||||
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}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PriorityCard = ({ lake, onSelectLake }: { lake: Lake, onSelectLake: (id: string) => void }) => {
|
||||
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' }}>
|
||||
<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' }}>
|
||||
<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' }}>
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, width: '100%', height: `${lake.capacity}%`, backgroundColor: 'var(--color-cyan)', opacity: 0.3 }}></div>
|
||||
<div style={{ position: 'absolute', bottom: `${lake.capacity}%`, left: 0, width: '100%', height: '2px', backgroundColor: 'var(--color-cyan)' }}></div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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}% / <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>
|
||||
</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}/>
|
||||
</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)" />
|
||||
<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>
|
||||
</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 [lakes, setLakes] = useState<Lake[]>([]);
|
||||
const [sortBy, setSortBy] = useState<'name' | 'level' | 'capacity' | 'inflow'>('name');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/data/lakes_index.json')
|
||||
.then(res => res.json())
|
||||
.then(data => setLakes(data))
|
||||
.catch(err => console.error(err));
|
||||
}, []);
|
||||
|
||||
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;
|
||||
if (sortBy === 'capacity') return b.capacity - a.capacity;
|
||||
if (sortBy === 'inflow') return b.inflow - a.inflow;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const sortButtonStyle = (type: string) => ({
|
||||
background: 'none', border: 'none',
|
||||
color: sortBy === type ? 'var(--text-main)' : 'var(--text-muted)',
|
||||
cursor: 'pointer', fontSize: '0.85rem'
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1.75rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>Overview: Lakes ({lakes.length})</h1>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>Monitoring {lakes.length} reservoirs across the Czech Republic</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', color: 'var(--text-muted)', fontSize: '0.85rem' }}>
|
||||
<span>Sort by:</span>
|
||||
<button style={sortButtonStyle('name')} onClick={() => setSortBy('name')}>Name (A-Z)</button> |
|
||||
<button style={sortButtonStyle('level')} onClick={() => setSortBy('level')}>Level</button> |
|
||||
<button style={sortButtonStyle('capacity')} onClick={() => setSortBy('capacity')}>Capacity</button> |
|
||||
<button style={sortButtonStyle('inflow')} onClick={() => setSortBy('inflow')}>Flow In</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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'
|
||||
}}>
|
||||
{priorityLakes.map(lake => <PriorityCard key={lake.id} lake={lake} onSelectLake={onSelectLake} />)}
|
||||
</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'
|
||||
}}>
|
||||
{otherLakes.map(lake => <SmallCard key={lake.id} lake={lake} onSelectLake={onSelectLake} />)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LakesOverview;
|
||||
@@ -1,30 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import '../index.css';
|
||||
|
||||
interface LoadingScreenProps {
|
||||
onLoaded: () => void;
|
||||
}
|
||||
|
||||
const LoadingScreen: React.FC<LoadingScreenProps> = ({ onLoaded }) => {
|
||||
const [fading, setFading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setFading(true);
|
||||
setTimeout(onLoaded, 500); // Wait for fade out animation
|
||||
}, 2500); // Show loading screen for 2.5 seconds
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [onLoaded]);
|
||||
|
||||
return (
|
||||
<div className={`loading-screen ${fading ? 'fade-out' : ''}`}>
|
||||
<div className="loader-content">
|
||||
<div className="spinner"></div>
|
||||
<h1 className="loading-text">Welcome</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingScreen;
|
||||
@@ -1,81 +0,0 @@
|
||||
import React from 'react';
|
||||
import logo from '../assets/logo.jpg';
|
||||
import { translations, type Language } from '../translations';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
interface NavbarProps {
|
||||
language: Language;
|
||||
setLanguage: (lang: Language) => void;
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ language, setLanguage }) => {
|
||||
const t = translations[language];
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleNavClick = (id: string, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (location.pathname !== '/') {
|
||||
navigate('/');
|
||||
// Wait for navigation then scroll
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleHomeClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (location.pathname !== '/') {
|
||||
navigate('/');
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="navbar">
|
||||
<div className="nav-content">
|
||||
<div
|
||||
className="logo-container"
|
||||
onClick={handleHomeClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<img src={logo} alt="David Fencl Logo" className="nav-logo" />
|
||||
</div>
|
||||
<div className="nav-right">
|
||||
<div className="language-switcher">
|
||||
<button
|
||||
className={`lang-btn ${language === 'en' ? 'active' : ''}`}
|
||||
onClick={() => setLanguage('en')}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<span className="lang-separator">/</span>
|
||||
<button
|
||||
className={`lang-btn ${language === 'cs' ? 'active' : ''}`}
|
||||
onClick={() => setLanguage('cs')}
|
||||
>
|
||||
CZ
|
||||
</button>
|
||||
</div>
|
||||
<div className="links">
|
||||
<Link to="/time-breaker">{t.timeBreaker}</Link>
|
||||
<a href="#about" onClick={(e) => handleNavClick('about', e)}>{t.about}</a>
|
||||
<a href="#contact" onClick={(e) => handleNavClick('contact', e)}>{t.contact}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -0,0 +1,142 @@
|
||||
import { FiX, FiMoon, FiSun, FiGlobe, FiCoffee } from 'react-icons/fi';
|
||||
import { type Language, t } from '../translations';
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
setLanguage: (lang: Language) => void;
|
||||
theme: 'dark' | 'light';
|
||||
setTheme: (theme: 'dark' | 'light') => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SettingsModal = ({ language, setLanguage, theme, setTheme, onClose }: Props) => {
|
||||
const dict = t[language].settings;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderRadius: '1rem',
|
||||
padding: '2rem',
|
||||
width: '90%',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', margin: 0 }}>{dict.title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'transparent', border: 'none', color: 'var(--text-muted)',
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '0.5rem', borderRadius: '50%'
|
||||
}}
|
||||
>
|
||||
<FiX size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Theme Setting */}
|
||||
<div style={{ marginBottom: '1.5rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem', color: 'var(--text-muted)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
||||
{dict.theme}
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
style={{
|
||||
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
|
||||
border: theme === 'dark' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
|
||||
backgroundColor: theme === 'dark' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
|
||||
color: theme === 'dark' ? 'var(--color-cyan)' : 'var(--text-main)',
|
||||
cursor: 'pointer', transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<FiMoon /> {dict.dark}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
style={{
|
||||
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
|
||||
border: theme === 'light' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
|
||||
backgroundColor: theme === 'light' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
|
||||
color: theme === 'light' ? 'var(--color-cyan)' : 'var(--text-main)',
|
||||
cursor: 'pointer', transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<FiSun /> {dict.light}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language Setting */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem', color: 'var(--text-muted)', fontSize: '0.85rem', textTransform: 'uppercase', letterSpacing: '1px' }}>
|
||||
{dict.language}
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '1rem' }}>
|
||||
<button
|
||||
onClick={() => setLanguage('en')}
|
||||
style={{
|
||||
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
|
||||
border: language === 'en' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
|
||||
backgroundColor: language === 'en' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
|
||||
color: language === 'en' ? 'var(--color-cyan)' : 'var(--text-main)',
|
||||
cursor: 'pointer', transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<FiGlobe /> {dict.english}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLanguage('cs')}
|
||||
style={{
|
||||
flex: 1, padding: '0.75rem', borderRadius: '0.5rem',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.5rem',
|
||||
border: language === 'cs' ? '1px solid var(--color-cyan)' : '1px solid var(--border-color)',
|
||||
backgroundColor: language === 'cs' ? 'rgba(6, 182, 212, 0.1)' : 'transparent',
|
||||
color: language === 'cs' ? 'var(--color-cyan)' : 'var(--text-main)',
|
||||
cursor: 'pointer', transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<FiGlobe /> {dict.czech}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buy me a coffee */}
|
||||
<div style={{ borderTop: '1px solid var(--border-color)', paddingTop: '1.5rem', textAlign: 'center' }}>
|
||||
<a
|
||||
href="#"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '0.5rem',
|
||||
padding: '0.75rem 1.5rem', borderRadius: '2rem',
|
||||
backgroundColor: '#FFDD00', color: '#000000', fontWeight: 'bold',
|
||||
textDecoration: 'none', transition: 'transform 0.2s'
|
||||
}}
|
||||
onMouseOver={(e) => e.currentTarget.style.transform = 'scale(1.05)'}
|
||||
onMouseOut={(e) => e.currentTarget.style.transform = 'scale(1)'}
|
||||
>
|
||||
<FiCoffee size={20} />
|
||||
{dict.buyCoffee}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsModal;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
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';
|
||||
onNavigate: (view: 'overview' | 'detail') => void;
|
||||
isMobileMenuOpen?: boolean;
|
||||
onCloseMobileMenu?: () => void;
|
||||
}
|
||||
|
||||
const Sidebar = ({ language, onOpenSettings, activeView, onNavigate, isMobileMenuOpen, onCloseMobileMenu }: Props) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const dict = t[language].sidebar;
|
||||
|
||||
return (
|
||||
<div className={`sidebar ${isCollapsed ? 'collapsed' : ''} ${isMobileMenuOpen ? 'mobile-open' : ''}`}>
|
||||
<div className="sidebar-logo" style={{ position: 'relative' }}>
|
||||
<FiDroplet />
|
||||
<div className="sidebar-text">
|
||||
<span>HLADINATOR</span>
|
||||
<small>v1.0</small>
|
||||
</div>
|
||||
|
||||
{/* Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
style={{
|
||||
position: 'absolute', right: isCollapsed ? '-16px' : '-16px', top: '50%', transform: 'translateY(-50%)',
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-color)', color: 'var(--text-main)',
|
||||
borderRadius: '50%', width: '32px', height: '32px', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', zIndex: 10, boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? <FiChevronRight size={18} /> : <FiChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="nav-links">
|
||||
<div className={`nav-item ${activeView === 'detail' ? 'active' : ''}`} onClick={() => onNavigate('detail')}>
|
||||
<FiStar />
|
||||
<span className="sidebar-text">{dict.favorites}</span>
|
||||
</div>
|
||||
<div className={`nav-item ${activeView === 'overview' ? 'active' : ''}`} onClick={() => onNavigate('overview')}>
|
||||
<FiMenu />
|
||||
<span className="sidebar-text">{dict.lakes}</span>
|
||||
</div>
|
||||
<div className="nav-item">
|
||||
<FiMap />
|
||||
<span className="sidebar-text">{dict.map}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="nav-item" onClick={onOpenSettings}>
|
||||
<FiSettings />
|
||||
<span className="sidebar-text">{dict.settings}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -1,162 +0,0 @@
|
||||
.timer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
background-color: rgba(30, 32, 36, 0.5);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.timer-display {
|
||||
font-size: 5rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin: 2rem 0;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.timer-display.time-up {
|
||||
background-color: rgba(244, 67, 54, 0.2);
|
||||
color: #f44336;
|
||||
animation: flash 1s infinite;
|
||||
border: 1px solid rgba(244, 67, 54, 0.5);
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.controls-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.time-slider {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.time-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.time-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
background: #747bff;
|
||||
}
|
||||
|
||||
.time-slider::-moz-range-thumb {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-color);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: transform 0.2s;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.time-slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
background: #747bff;
|
||||
}
|
||||
|
||||
.slider-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--secondary-color);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.main-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
min-width: 120px;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.8rem 2rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(100, 108, 255, 0.3);
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
background-color: #747bff;
|
||||
box-shadow: 0 6px 20px rgba(100, 108, 255, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--secondary-color);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
border-color: var(--text-color);
|
||||
color: var(--text-color);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.timer-display {
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
min-width: 100px;
|
||||
padding: 0.7rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { translations, type Language } from '../translations';
|
||||
import './TimeBreaker.css';
|
||||
|
||||
interface TimeBreakerProps {
|
||||
language: Language;
|
||||
}
|
||||
|
||||
const DEFAULT_TIME_MINUTES = 22;
|
||||
const MAX_TIME_MINUTES = 180;
|
||||
const SECONDS_PER_MINUTE = 60;
|
||||
|
||||
const TimeBreaker: React.FC<TimeBreakerProps> = ({ language }) => {
|
||||
const t = translations[language];
|
||||
|
||||
const [timeLeft, setTimeLeft] = useState(DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isTimeUp, setIsTimeUp] = useState(false);
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
|
||||
// Format time as MM:SS
|
||||
const formatTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Update document title
|
||||
useEffect(() => {
|
||||
document.title = `${formatTime(timeLeft)} - ${t.timeBreaker}`;
|
||||
return () => {
|
||||
document.title = 'David Fencl - IT Consulting';
|
||||
};
|
||||
}, [timeLeft, t.timeBreaker]);
|
||||
|
||||
// Timer logic
|
||||
useEffect(() => {
|
||||
if (isRunning && timeLeft > 0) {
|
||||
intervalRef.current = window.setInterval(() => {
|
||||
setTimeLeft((prev) => {
|
||||
if (prev <= 1) {
|
||||
handleTimeUp();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} else if (timeLeft === 0 && isRunning) {
|
||||
handleTimeUp();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [isRunning]);
|
||||
|
||||
const handleTimeUp = () => {
|
||||
setIsRunning(false);
|
||||
setIsTimeUp(true);
|
||||
playAlarm();
|
||||
// Play alarm 3 times
|
||||
setTimeout(playAlarm, 1000);
|
||||
setTimeout(playAlarm, 2000);
|
||||
};
|
||||
|
||||
const playAlarm = () => {
|
||||
if (!audioContextRef.current) {
|
||||
audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
}
|
||||
|
||||
const ctx = audioContextRef.current;
|
||||
if (!ctx) return;
|
||||
|
||||
// Create oscillator
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.setValueAtTime(880, ctx.currentTime); // A5
|
||||
oscillator.frequency.exponentialRampToValueAtTime(440, ctx.currentTime + 0.5); // Drop to A4
|
||||
|
||||
gainNode.gain.setValueAtTime(0.5, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
oscillator.start();
|
||||
oscillator.stop(ctx.currentTime + 0.5);
|
||||
};
|
||||
|
||||
const handleStart = () => {
|
||||
if (timeLeft === 0) return;
|
||||
setIsRunning(true);
|
||||
setIsTimeUp(false);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setIsRunning(false);
|
||||
setIsTimeUp(false);
|
||||
setTimeLeft(DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home-container fade-in">
|
||||
<section className="hero">
|
||||
<div className="timer-container">
|
||||
<h1 style={{ marginBottom: '1rem' }}>{t.timeBreaker}</h1>
|
||||
|
||||
<div className={`timer-display ${isTimeUp ? 'time-up' : ''}`}>
|
||||
{formatTime(timeLeft)}
|
||||
</div>
|
||||
|
||||
<div className="controls-section">
|
||||
<div className="slider-container">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={MAX_TIME_MINUTES}
|
||||
value={Math.ceil(timeLeft / 60)}
|
||||
onChange={(e) => {
|
||||
const minutes = parseInt(e.target.value, 10);
|
||||
setTimeLeft(minutes * 60);
|
||||
if (minutes > 0) setIsTimeUp(false);
|
||||
}}
|
||||
className="time-slider"
|
||||
/>
|
||||
<div className="slider-labels">
|
||||
<span>0m</span>
|
||||
<span>{MAX_TIME_MINUTES}m</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="main-controls">
|
||||
{!isRunning ? (
|
||||
<button className="btn control-btn start-btn" onClick={handleStart}>
|
||||
{timeLeft > 0 && timeLeft < DEFAULT_TIME_MINUTES * SECONDS_PER_MINUTE ? 'Resume' : 'Start'}
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn control-btn start-btn" onClick={handlePause}>
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
<button className="btn control-btn reset-btn" onClick={handleReset}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeBreaker;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { FiSearch, FiMenu, FiDroplet } from 'react-icons/fi';
|
||||
import { type Language, t } from '../translations';
|
||||
|
||||
interface Props {
|
||||
language: Language;
|
||||
onToggleMobileMenu?: () => void;
|
||||
}
|
||||
|
||||
const Topbar = ({ language, onToggleMobileMenu }: Props) => {
|
||||
const dict = t[language].topbar;
|
||||
|
||||
return (
|
||||
<div className="topbar">
|
||||
<div className="topbar-mobile-header">
|
||||
<FiMenu onClick={onToggleMobileMenu} className="mobile-only" style={{ fontSize: '1.5rem', cursor: 'pointer' }} />
|
||||
|
||||
<div className="mobile-only" style={{ alignItems: 'center', gap: '0.5rem', fontWeight: 'bold', fontSize: '1.25rem' }}>
|
||||
<FiDroplet color="var(--color-cyan)" />
|
||||
<span>Hladinator</span>
|
||||
</div>
|
||||
|
||||
<div className="search-bar">
|
||||
<FiSearch />
|
||||
<input type="text" placeholder={dict.search} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Topbar;
|
||||
+114
-353
@@ -1,366 +1,127 @@
|
||||
:root {
|
||||
--bg-color: #0f0f0f;
|
||||
--text-color: #f0f0f0;
|
||||
--accent-color: #646cff;
|
||||
--secondary-color: #a0a0a0;
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color-scheme: dark;
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-color);
|
||||
scroll-behavior: smooth;
|
||||
/* Colors based on HLADINATOR design */
|
||||
--bg-dark: #1e293b; /* Unified lighter navy background */
|
||||
--bg-card: #1e293b; /* Card/Panel background */
|
||||
--bg-card-hover: #334155;
|
||||
--text-main: #f8fafc; /* White text */
|
||||
--text-muted: #94a3b8; /* Gray text */
|
||||
|
||||
--color-cyan: #06b6d4; /* Hladina / Primary */
|
||||
--color-green: #22c55e; /* Přítok / Positive trend */
|
||||
--color-red: #ef4444; /* Odtok / Negative trend */
|
||||
--color-orange: #f97316; /* Odtok line chart color */
|
||||
|
||||
.kpi-container-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-padding-top: 100px;
|
||||
/* Offset for sticky header */
|
||||
.kpi-card-full {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kpi-row-half {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.kpi-card-half {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Time controls pill layout */
|
||||
.top-time-controls {
|
||||
display: flex;
|
||||
background-color: var(--bg-card);
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.top-time-controls button {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
padding: 0.75rem 0;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.top-time-controls button:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.top-time-controls button.active {
|
||||
background-color: var(--color-cyan);
|
||||
color: white;
|
||||
}
|
||||
|
||||
--border-color: rgba(255, 255, 255, 0.05);
|
||||
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
body.light-mode {
|
||||
--bg-dark: #f1f5f9; /* Unified light background */
|
||||
--bg-card: #f1f5f9; /* Card/Panel background */
|
||||
--bg-card-hover: #e2e8f0;
|
||||
--text-main: #0f172a; /* Dark navy text */
|
||||
--text-muted: #64748b; /* Muted gray text */
|
||||
--border-color: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Slightly darker graph colors for white background */
|
||||
--color-cyan: #0891b2;
|
||||
--color-green: #16a34a;
|
||||
--color-red: #dc2626;
|
||||
--color-orange: #ea580c;
|
||||
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: dark;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-dark);
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Loading Screen */
|
||||
.loading-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--bg-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
transition: opacity 0.5s ease-out;
|
||||
}
|
||||
|
||||
.loading-screen.fade-out {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loader-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.1);
|
||||
border-left-color: var(--accent-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* Home */
|
||||
.home-container {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 1s ease-in;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background-color: rgba(15, 15, 15, 0.85);
|
||||
backdrop-filter: blur(10px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.8rem 8rem;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
height: 120px;
|
||||
width: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.links a {
|
||||
margin-left: 2rem;
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
color: var(--secondary-color);
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.links a:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.hero {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 4rem 8rem;
|
||||
padding-top: 10vh;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 3px;
|
||||
color: var(--secondary-color);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 3.5rem;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.job-title {
|
||||
font-size: 3.5rem;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 1rem;
|
||||
line-height: 1.8;
|
||||
color: var(--secondary-color);
|
||||
max-width: 600px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.hero-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 4rem;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 2px;
|
||||
color: var(--secondary-color);
|
||||
margin-bottom: 1rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.icon-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
background: linear-gradient(145deg, #1e2024, #23272b);
|
||||
box-shadow: 10px 10px 19px #1c1e22, -10px -10px 19px #262a2e;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color);
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: linear-gradient(145deg, #23272b, #1e2024);
|
||||
transform: translateY(-2px);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.hero-image-container {
|
||||
flex: 0.8;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-image-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 3/4;
|
||||
background: linear-gradient(145deg, #1e2024, #23272b);
|
||||
box-shadow: 10px 10px 19px #1c1e22, -10px -10px 19px #262a2e;
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--secondary-color);
|
||||
font-size: 1.2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.language-switcher {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.lang-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.3s, color 0.3s;
|
||||
padding: 0;
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.lang-btn:hover {
|
||||
opacity: 1;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.lang-btn.active {
|
||||
opacity: 1;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.hero-content {
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-footer {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-image-container {
|
||||
width: 80%;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
.about {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
+93
-31
@@ -1,38 +1,100 @@
|
||||
export type Language = 'en' | 'cs';
|
||||
|
||||
export const translations = {
|
||||
export const t = {
|
||||
en: {
|
||||
home: 'Home',
|
||||
about: 'About',
|
||||
contact: 'Contact',
|
||||
timeBreaker: 'Time-Breaker',
|
||||
welcome: 'WELCOME TO MY WORLD',
|
||||
hello: "Hello, I'm",
|
||||
job: 'a Developer.',
|
||||
desc: "I use animation as a third dimension by which to simplify experiences and guiding through each and every interaction. I'm not adding motion just to spruce things up, but doing it in ways that matter.",
|
||||
findMe: 'FIND WITH ME',
|
||||
bestSkill: 'BEST SKILL ON',
|
||||
aboutMe: 'About Me',
|
||||
aboutDesc: 'I build accessible, pixel-perfect, and performant web experiences. Passionate about technology and design.',
|
||||
getInTouch: 'Get In Touch',
|
||||
contactDesc: 'Interested in working together?',
|
||||
emailMe: 'Email me',
|
||||
sidebar: {
|
||||
favorites: 'Favorites',
|
||||
lakes: 'Lakes',
|
||||
map: 'Map',
|
||||
settings: 'Settings'
|
||||
},
|
||||
topbar: {
|
||||
search: 'Search river or reservoir (e.g. Lipno)...',
|
||||
updated: 'Last updated:'
|
||||
},
|
||||
kpi: {
|
||||
level: 'WATER LEVEL',
|
||||
flow: 'FLOW RATE',
|
||||
inflow: 'Inflow',
|
||||
outflow: 'Outflow',
|
||||
fullness: 'CAPACITY',
|
||||
volume: 'Volume'
|
||||
},
|
||||
chart: {
|
||||
title: 'Long-term development',
|
||||
timeframe: 'Timeframe',
|
||||
timeframeMobile: 'Time',
|
||||
view: 'View',
|
||||
raw: 'Raw data',
|
||||
smoothed: 'Smoothed',
|
||||
calendar: 'Calendar',
|
||||
all: 'All',
|
||||
year: 'Year',
|
||||
level: 'Water level',
|
||||
inflow: 'Inflow',
|
||||
outflow: 'Outflow',
|
||||
maxLevel: 'Max retention level',
|
||||
storageLevel: 'Storage space level',
|
||||
dataSources: 'Data sources:',
|
||||
createdIn: 'Created with ♥ in the Czech Republic'
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
theme: 'Theme',
|
||||
dark: 'Dark',
|
||||
light: 'Light',
|
||||
language: 'Language',
|
||||
english: 'English',
|
||||
czech: 'Čeština',
|
||||
buyCoffee: 'Buy Me a Coffee'
|
||||
}
|
||||
},
|
||||
cs: {
|
||||
home: 'Domů',
|
||||
about: 'O mně',
|
||||
contact: 'Kontakt',
|
||||
timeBreaker: 'Time-Breaker',
|
||||
welcome: 'VÍTEJTE V MÉM SVĚTĚ',
|
||||
hello: "Ahoj, jsem",
|
||||
job: 'Vývojář.',
|
||||
desc: "Používám animaci jako třetí rozměr, kterým zjednodušuji zážitky a provázím každou interakcí. Nepřidávám pohyb jen pro efekt, ale dělám to způsoby, které mají smysl.",
|
||||
findMe: 'NAJDETE MĚ NA',
|
||||
bestSkill: 'DOVEDNOSTI',
|
||||
aboutMe: 'O mně',
|
||||
aboutDesc: 'Tvořím přístupné, pixel-perfect a výkonné webové zážitky. Vášnivý pro technologie a design.',
|
||||
getInTouch: 'Napište mi',
|
||||
contactDesc: 'Máte zájem o spolupráci?',
|
||||
emailMe: 'Napište mi',
|
||||
sidebar: {
|
||||
favorites: 'Oblíbené',
|
||||
lakes: 'Jezera',
|
||||
map: 'Mapa',
|
||||
settings: 'Nastavení'
|
||||
},
|
||||
topbar: {
|
||||
search: 'Hledat tok nebo nádrž (např. Lipno)...',
|
||||
updated: 'Aktualizováno:'
|
||||
},
|
||||
kpi: {
|
||||
level: 'HLADINA',
|
||||
flow: 'PRŮTOK',
|
||||
inflow: 'Přítok',
|
||||
outflow: 'Odtok',
|
||||
fullness: 'NAPLNĚNOST',
|
||||
volume: 'Objem'
|
||||
},
|
||||
chart: {
|
||||
title: 'Dlouhodobý vývoj',
|
||||
timeframe: 'Časové období',
|
||||
timeframeMobile: 'Časové',
|
||||
view: 'Zobrazení',
|
||||
raw: 'Syrová data',
|
||||
smoothed: 'Vyhlazená',
|
||||
calendar: 'Kalendář',
|
||||
all: 'Vše',
|
||||
year: 'Rok',
|
||||
level: 'Hladina',
|
||||
inflow: 'Přítok',
|
||||
outflow: 'Odtok',
|
||||
maxLevel: 'Max. retenční hladina',
|
||||
storageLevel: 'Hladina zásobního prostoru',
|
||||
dataSources: 'Zdroje dat:',
|
||||
createdIn: 'Vytvořeno s ♥ v České republice'
|
||||
},
|
||||
settings: {
|
||||
title: 'Nastavení',
|
||||
theme: 'Vzhled',
|
||||
dark: 'Tmavý',
|
||||
light: 'Světlý',
|
||||
language: 'Jazyk',
|
||||
english: 'English',
|
||||
czech: 'Čeština',
|
||||
buyCoffee: 'Kup mi kávu'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user