From a1f6a21828cac5c04c557d1280e6feebe68cb189 Mon Sep 17 00:00:00 2001 From: Nicolay Braetter Date: Wed, 15 Apr 2026 09:24:31 +0200 Subject: [PATCH] Initial commit: Aquarium Logbuch React/Vite App - React 18 + Vite Frontend - Node.js/Express Backend - Vollstaendige Logbuch-Funktionalitaet fuer Aquarien - Deploy-Script fuer aqualog CT 211 (192.168.0.246) --- .gitignore | 4 + backend/package.json | 14 + backend/server.js | 226 +++++ deploy-v3.sh | 108 +++ index.html | 14 + package-lock.json | 1568 ++++++++++++++++++++++++++++++ package.json | 18 + postgres-ha-install.sh | 589 ++++++++++++ src/App.jsx | 2053 ++++++++++++++++++++++++++++++++++++++++ src/main.jsx | 9 + vite.config.js | 11 + 11 files changed, 4614 insertions(+) create mode 100644 .gitignore create mode 100644 backend/package.json create mode 100644 backend/server.js create mode 100644 deploy-v3.sh create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postgres-ha-install.sh create mode 100644 src/App.jsx create mode 100644 src/main.jsx create mode 100644 vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b08070 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.env +*.local diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..fb258ed --- /dev/null +++ b/backend/package.json @@ -0,0 +1,14 @@ +{ + "name": "aquarium-backend", + "version": "1.0.0", + "description": "Aquarium Logbuch API Backend", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "bcrypt": "^5.1.1", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2" + } +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 0000000..fb22bda --- /dev/null +++ b/backend/server.js @@ -0,0 +1,226 @@ +"use strict"; +const express = require("express"); +const bcrypt = require("bcrypt"); +const jwt = require("jsonwebtoken"); +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const app = express(); +const PORT = 3001; +const DATA_FILE = "/var/lib/aquarium/data.json"; +const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(48).toString("hex"); +const SALT_ROUNDS = 12; + +app.use(express.json()); + +// ─── Data helpers ────────────────────────────────────────────────────────── +function loadData() { + try { + if (fs.existsSync(DATA_FILE)) return JSON.parse(fs.readFileSync(DATA_FILE, "utf8")); + } catch {} + return { users: [], aquariums: [], entries: [], reminders: [] }; +} +function saveData(d) { + const dir = path.dirname(DATA_FILE); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(DATA_FILE, JSON.stringify(d, null, 2)); +} + +// ─── Middleware ──────────────────────────────────────────────────────────── +function auth(req, res, next) { + const h = req.headers.authorization; + if (!h || !h.startsWith("Bearer ")) return res.status(401).json({ error: "Nicht angemeldet" }); + try { + req.user = jwt.verify(h.slice(7), JWT_SECRET); + next(); + } catch { + res.status(401).json({ error: "Sitzung abgelaufen" }); + } +} +function admin(req, res, next) { + if (req.user.role !== "admin") return res.status(403).json({ error: "Admin-Rechte erforderlich" }); + next(); +} +function ownsAquarium(d, aqId, user) { + const aq = d.aquariums.find(a => a.id === aqId); + if (!aq) return null; + if (aq.userId === user.id || user.role === "admin") return aq; + return null; +} + +// ─── Init admin ──────────────────────────────────────────────────────────── +function initAdmin() { + const d = loadData(); + if (!d.users.find(u => u.username === "aqlab")) { + d.users.push({ + id: "admin-1", + username: "aqlab", + passwordHash: bcrypt.hashSync("D4sP4sswortBek0mmtKe1ner!", SALT_ROUNDS), + role: "admin", + displayName: "Administrator", + createdAt: new Date().toISOString(), + }); + saveData(d); + console.log("[aquarium] Admin-Benutzer 'aqlab' angelegt."); + } +} + +// ─── Auth routes ─────────────────────────────────────────────────────────── +app.post("/api/auth/login", (req, res) => { + const { username, password } = req.body || {}; + if (!username || !password) return res.status(400).json({ error: "Benutzername und Passwort erforderlich" }); + const d = loadData(); + const u = d.users.find(x => x.username === username); + if (!u || !bcrypt.compareSync(password, u.passwordHash)) + return res.status(401).json({ error: "Ungültiger Benutzername oder Passwort" }); + const token = jwt.sign({ id: u.id, username: u.username, role: u.role, displayName: u.displayName }, JWT_SECRET, { expiresIn: "14d" }); + res.json({ token, user: { id: u.id, username: u.username, role: u.role, displayName: u.displayName } }); +}); + +app.get("/api/auth/me", auth, (req, res) => res.json(req.user)); + +app.post("/api/auth/change-password", auth, (req, res) => { + const { currentPassword, newPassword } = req.body || {}; + if (!currentPassword || !newPassword) return res.status(400).json({ error: "Felder erforderlich" }); + if (newPassword.length < 6) return res.status(400).json({ error: "Passwort mindestens 6 Zeichen" }); + const d = loadData(); + const u = d.users.find(x => x.id === req.user.id); + if (!u || !bcrypt.compareSync(currentPassword, u.passwordHash)) + return res.status(401).json({ error: "Aktuelles Passwort falsch" }); + u.passwordHash = bcrypt.hashSync(newPassword, SALT_ROUNDS); + saveData(d); + res.json({ ok: true }); +}); + +// ─── User management (admin) ─────────────────────────────────────────────── +app.get("/api/users", auth, admin, (req, res) => { + const d = loadData(); + res.json(d.users.map(u => ({ id: u.id, username: u.username, role: u.role, displayName: u.displayName, createdAt: u.createdAt }))); +}); + +app.post("/api/users", auth, admin, (req, res) => { + const { username, password, role = "user", displayName = "" } = req.body || {}; + if (!username || !password) return res.status(400).json({ error: "Benutzername und Passwort erforderlich" }); + if (password.length < 6) return res.status(400).json({ error: "Passwort mindestens 6 Zeichen" }); + const d = loadData(); + if (d.users.find(u => u.username === username)) return res.status(409).json({ error: "Benutzername bereits vergeben" }); + const u = { id: `user-${Date.now()}`, username, passwordHash: bcrypt.hashSync(password, SALT_ROUNDS), role, displayName: displayName || username, createdAt: new Date().toISOString() }; + d.users.push(u); + saveData(d); + res.status(201).json({ id: u.id, username: u.username, role: u.role, displayName: u.displayName, createdAt: u.createdAt }); +}); + +app.put("/api/users/:id", auth, admin, (req, res) => { + const d = loadData(); + const u = d.users.find(x => x.id === req.params.id); + if (!u) return res.status(404).json({ error: "Benutzer nicht gefunden" }); + if (req.body.displayName !== undefined) u.displayName = req.body.displayName; + if (req.body.role !== undefined && u.id !== "admin-1") u.role = req.body.role; + if (req.body.password && req.body.password.length >= 6) u.passwordHash = bcrypt.hashSync(req.body.password, SALT_ROUNDS); + saveData(d); + res.json({ id: u.id, username: u.username, role: u.role, displayName: u.displayName }); +}); + +app.delete("/api/users/:id", auth, admin, (req, res) => { + if (req.params.id === "admin-1") return res.status(403).json({ error: "Admin-Benutzer kann nicht gelöscht werden" }); + if (req.params.id === req.user.id) return res.status(403).json({ error: "Eigener Account kann nicht gelöscht werden" }); + const d = loadData(); + const idx = d.users.findIndex(u => u.id === req.params.id); + if (idx === -1) return res.status(404).json({ error: "Benutzer nicht gefunden" }); + d.users.splice(idx, 1); + saveData(d); + res.json({ ok: true }); +}); + +// ─── Aquarium routes ─────────────────────────────────────────────────────── +app.get("/api/aquariums", auth, (req, res) => { + const d = loadData(); + const list = req.user.role === "admin" + ? d.aquariums + : d.aquariums.filter(a => a.userId === req.user.id); + res.json(list); +}); + +app.post("/api/aquariums", auth, (req, res) => { + const { name, volume = 60, description = "" } = req.body || {}; + if (!name?.trim()) return res.status(400).json({ error: "Name erforderlich" }); + const d = loadData(); + const aq = { id: `aq-${Date.now()}`, userId: req.user.id, name: name.trim(), volume: Number(volume) || 60, description, createdAt: new Date().toISOString() }; + d.aquariums.push(aq); + saveData(d); + res.status(201).json(aq); +}); + +app.put("/api/aquariums/:id", auth, (req, res) => { + const d = loadData(); + const aq = ownsAquarium(d, req.params.id, req.user); + if (!aq) return res.status(404).json({ error: "Aquarium nicht gefunden" }); + if (req.body.name?.trim()) aq.name = req.body.name.trim(); + if (req.body.volume !== undefined) aq.volume = Number(req.body.volume) || aq.volume; + if (req.body.description !== undefined) aq.description = req.body.description; + saveData(d); + res.json(aq); +}); + +app.delete("/api/aquariums/:id", auth, (req, res) => { + const d = loadData(); + const aq = ownsAquarium(d, req.params.id, req.user); + if (!aq) return res.status(404).json({ error: "Aquarium nicht gefunden" }); + d.aquariums = d.aquariums.filter(a => a.id !== req.params.id); + d.entries = d.entries.filter(e => e.aquariumId !== req.params.id); + d.reminders = (d.reminders || []).filter(r => r.aquariumId !== req.params.id); + saveData(d); + res.json({ ok: true }); +}); + +// ─── Entry routes ────────────────────────────────────────────────────────── +app.get("/api/aquariums/:aqId/entries", auth, (req, res) => { + const d = loadData(); + if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" }); + const entries = d.entries + .filter(e => e.aquariumId === req.params.aqId) + .sort((a, b) => new Date(b.date) - new Date(a.date)); + res.json(entries); +}); + +app.post("/api/aquariums/:aqId/entries", auth, (req, res) => { + const d = loadData(); + if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" }); + const entry = { id: `e-${Date.now()}`, aquariumId: req.params.aqId, date: new Date().toISOString(), values: req.body.values || {}, note: req.body.note || "" }; + d.entries.push(entry); + saveData(d); + res.status(201).json(entry); +}); + +app.delete("/api/aquariums/:aqId/entries/:id", auth, (req, res) => { + const d = loadData(); + if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" }); + const idx = d.entries.findIndex(e => e.id === req.params.id && e.aquariumId === req.params.aqId); + if (idx === -1) return res.status(404).json({ error: "Eintrag nicht gefunden" }); + d.entries.splice(idx, 1); + saveData(d); + res.json({ ok: true }); +}); + +// ─── Reminder routes ─────────────────────────────────────────────────────── +app.get("/api/aquariums/:aqId/reminder", auth, (req, res) => { + const d = loadData(); + if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" }); + const r = (d.reminders || []).find(x => x.aquariumId === req.params.aqId) || null; + res.json(r); +}); + +app.put("/api/aquariums/:aqId/reminder", auth, (req, res) => { + const d = loadData(); + if (!ownsAquarium(d, req.params.aqId, req.user)) return res.status(404).json({ error: "Aquarium nicht gefunden" }); + if (!d.reminders) d.reminders = []; + const idx = d.reminders.findIndex(r => r.aquariumId === req.params.aqId); + const r = { aquariumId: req.params.aqId, intervalDays: req.body.intervalDays ?? 7, lastChange: req.body.lastChange ?? new Date().toISOString(), enabled: req.body.enabled ?? true }; + idx >= 0 ? (d.reminders[idx] = r) : d.reminders.push(r); + saveData(d); + res.json(r); +}); + +initAdmin(); +app.listen(PORT, "127.0.0.1", () => console.log(`[aquarium] API läuft auf :${PORT}`)); diff --git a/deploy-v3.sh b/deploy-v3.sh new file mode 100644 index 0000000..1883bb0 --- /dev/null +++ b/deploy-v3.sh @@ -0,0 +1,108 @@ +#!/bin/bash +set -e +echo "=== Aquarium Logbuch v3 Deploy ===" + +# 1. Download aquascape header photo (CC0 from Pixabay) +echo "[1/7] Header-Foto herunterladen..." +mkdir -p /tmp/aquarium-deploy/app/public +wget -q --timeout=15 -O /tmp/aquarium-deploy/app/public/aquascape.jpg \ + "https://cdn.pixabay.com/photo/2023/02/08/06/09/aquarium-7776393_1280.jpg" \ + 2>/dev/null && echo " Foto heruntergeladen." || \ + wget -q --timeout=15 -O /tmp/aquarium-deploy/app/public/aquascape.jpg \ + "https://cdn.pixabay.com/photo/2017/07/14/15/48/aquarium-2503350_1280.jpg" \ + 2>/dev/null && echo " Foto (Fallback) heruntergeladen." || \ + echo " WARN: Foto-Download fehlgeschlagen, CSS-Gradient wird als Fallback genutzt." + +# 2. Build frontend +echo "[2/7] Frontend bauen..." +cd /tmp/aquarium-deploy/app +npm run build >> /tmp/build-v3-full.log 2>&1 +echo " Build abgeschlossen." + +# 3. Deploy frontend +echo "[3/7] Frontend deployen..." +cp -r dist/* /var/www/aquarium-logbuch/ +# Copy photo to webroot too if it exists +[ -f public/aquascape.jpg ] && cp public/aquascape.jpg /var/www/aquarium-logbuch/aquascape.jpg +echo " Frontend kopiert." + +# 4. Install backend +echo "[4/7] Backend installieren..." +mkdir -p /var/lib/aquarium-backend +cp /tmp/backend/server.js /var/lib/aquarium-backend/server.js +cp /tmp/backend/package.json /var/lib/aquarium-backend/package.json +cd /var/lib/aquarium-backend +npm install --omit=dev >> /tmp/build-v3-full.log 2>&1 +echo " Backend-Pakete installiert." + +# 5. Setup data directory +echo "[5/7] Datenverzeichnis anlegen..." +mkdir -p /var/lib/aquarium +chown -R www-data:www-data /var/lib/aquarium 2>/dev/null || true + +# 6. Create systemd service +echo "[6/7] Systemd-Service anlegen..." +cat > /etc/systemd/system/aquarium-api.service << 'EOF' +[Unit] +Description=Aquarium Logbuch API +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/var/lib/aquarium-backend +ExecStart=/usr/bin/node /var/lib/aquarium-backend/server.js +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=aquarium-api +Environment=NODE_ENV=production + +[Install] +WantedBy=multi-user.target +EOF +systemctl daemon-reload +systemctl enable aquarium-api +systemctl restart aquarium-api +sleep 2 +systemctl is-active aquarium-api && echo " API-Service läuft." || echo " WARN: API-Service startet nicht, Logs prüfen." + +# 7. Update nginx config +echo "[7/7] Nginx konfigurieren..." +cat > /etc/nginx/sites-available/aquarium.conf << 'NGINX' +server { + listen 80 default_server; + server_name _; + root /var/www/aquarium-logbuch; + index index.html; + client_max_body_size 10M; + + # API proxy → Node backend + location /api { + proxy_pass http://127.0.0.1:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 30s; + } + + # SPA routing + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ { + expires 7d; + add_header Cache-Control "public, no-transform"; + } +} +NGINX +nginx -t && nginx -s reload && echo " Nginx neu geladen." + +echo "" +echo "=== Deploy abgeschlossen! ===" +echo "App: http://$(hostname -I | awk '{print $1}')/" +echo "Admin: aqlab / D4sP4sswortBek0mmtKe1ner!" diff --git a/index.html b/index.html new file mode 100644 index 0000000..fb297fe --- /dev/null +++ b/index.html @@ -0,0 +1,14 @@ + + + + + + Aquarium Logbuch + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..6dfe006 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1568 @@ +{ + "name": "aquarium-logbuch", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aquarium-logbuch", + "version": "2.0.0", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.4.21" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", + "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", + "dev": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.335", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", + "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cb9e39c --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "aquarium-logbuch", + "version": "2.0.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.4.21" + } +} diff --git a/postgres-ha-install.sh b/postgres-ha-install.sh new file mode 100644 index 0000000..6c3c4a0 --- /dev/null +++ b/postgres-ha-install.sh @@ -0,0 +1,589 @@ +#!/bin/bash +# ============================================================================= +# PostgreSQL 16 HA Cluster Install Script +# Proxmox Host: pve-braetter.braetter.local +# +# Architektur: +# CT 300 sql1 192.168.0.220 PostgreSQL 16 + Patroni + etcd (Leader) +# CT 301 sql2 192.168.0.221 PostgreSQL 16 + Patroni + etcd (Replica) +# CT 302 sql3 192.168.0.222 PostgreSQL 16 + Patroni + etcd (Replica) +# CT 303 pgadmin 192.168.0.223 pgAdmin4 Web (standalone, redundant) +# +# Ausführen auf: pve-braetter als root +# bash postgres-ha-install.sh +# ============================================================================= + +set -e + +# --- Farben --- +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +BLUE='\033[0;34m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' + +log() { echo -e "${GREEN}[+]${NC} $1"; } +info() { echo -e "${BLUE}[i]${NC} $1"; } +warn() { echo -e "${YELLOW}[!]${NC} $1"; } +step() { echo -e "\n${BOLD}${CYAN}==> $1${NC}"; } +fail() { echo -e "${RED}[FEHLER]${NC} $1"; exit 1; } + +# ============================================================================= +# KONFIGURATION — hier anpassen falls nötig +# ============================================================================= +TEMPLATE="Isos:vztmpl/ubuntu-24.04-standard_24.04-2_amd64.tar.zst" +STORAGE="osdisk" +BRIDGE="vmbr0" +GATEWAY="192.168.0.1" +NAMESERVER="192.168.0.202" +SEARCHDOMAIN="braetter.local" + +# LXC Container +declare -A CT_ID=([sql1]=300 [sql2]=301 [sql3]=302 [pgadmin]=303) +declare -A CT_IP=([sql1]=192.168.0.220 [sql2]=192.168.0.221 [sql3]=192.168.0.222 [pgadmin]=192.168.0.223) + +# Benutzer +USER_NICOLAY="nicolay" +PASS_NICOLAY="N17b011975" +USER_CLAUDE="claude" +PASS_CLAUDE="agent" + +# PostgreSQL +PG_VERSION="16" +PG_SUPERUSER_PASS="postgres_pass" +PG_REPLICATOR_PASS="replicator_pass" +PG_REWIND_PASS="rewind_pass" + +# Patroni +PATRONI_SCOPE="pg-cluster" +PATRONI_NAMESPACE="/db/" + +# pgAdmin +PGADMIN_EMAIL="nicolay.braetter@googlemail.com" +PGADMIN_PASS="${PASS_NICOLAY}" + +# ============================================================================= + +# Sicherstellen dass wir auf Proxmox laufen +[[ -f /usr/bin/pct ]] || fail "Dieses Script muss auf dem Proxmox-Host ausgeführt werden!" + +# ============================================================================= +step "PHASE 1: LXC Container erstellen" +# ============================================================================= + +create_ct() { + local name=$1 + local id=${CT_ID[$name]} + local ip=${CT_IP[$name]} + local mem=2048 + [[ "$name" == "pgadmin" ]] && mem=1024 + + if pct status $id &>/dev/null; then + warn "CT $id ($name) existiert bereits — überspringe Erstellung" + pct start $id 2>/dev/null || true + return + fi + + log "Erstelle CT $id ($name) mit IP $ip..." + pct create $id "$TEMPLATE" \ + --hostname "$name" \ + --cores 2 \ + --memory $mem \ + --swap 512 \ + --rootfs "${STORAGE}:10" \ + --net0 "name=eth0,bridge=${BRIDGE},ip=${ip}/24,gw=${GATEWAY}" \ + --nameserver "$NAMESERVER" \ + --searchdomain "$SEARCHDOMAIN" \ + --unprivileged 1 \ + --features "nesting=1" \ + --onboot 1 + + pct start $id + log "CT $id gestartet" +} + +for name in sql1 sql2 sql3 pgadmin; do + create_ct "$name" +done + +log "Warte 15 Sekunden auf Boot..." +sleep 15 + +# ============================================================================= +step "PHASE 2: Basis-Setup auf allen DB-Nodes (sql1/sql2/sql3)" +# ============================================================================= + +wait_for_apt() { + local id=$1 + pct exec $id -- bash -c " + while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || \ + fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do + echo 'Warte auf apt-Lock...' + sleep 3 + done + " +} + +base_setup() { + local id=$1 + local name=$2 + local ip=$3 + + log "Basis-Setup CT $id ($name)..." + + wait_for_apt $id + + pct exec $id -- bash -c " + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + + # Benutzer anlegen + id ${USER_NICOLAY} &>/dev/null || useradd -m -s /bin/bash -G sudo ${USER_NICOLAY} + echo '${USER_NICOLAY}:${PASS_NICOLAY}' | chpasswd + id ${USER_CLAUDE} &>/dev/null || useradd -m -s /bin/bash -G sudo ${USER_CLAUDE} + echo '${USER_CLAUDE}:${PASS_CLAUDE}' | chpasswd + + # sudo ohne Passwort für beide + echo '${USER_NICOLAY} ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/${USER_NICOLAY} + echo '${USER_CLAUDE} ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/${USER_CLAUDE} + + # SSH aktivieren + apt-get install -y -qq openssh-server curl wget gnupg lsb-release ca-certificates python3 python3-pip + systemctl enable ssh && systemctl start ssh + + # PasswordAuthentication + sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config + systemctl restart ssh + " + log "Basis-Setup $name abgeschlossen" +} + +for name in sql1 sql2 sql3; do + base_setup "${CT_ID[$name]}" "$name" "${CT_IP[$name]}" & +done +wait +log "Basis-Setup aller DB-Nodes abgeschlossen" + +# ============================================================================= +step "PHASE 3: PostgreSQL 16 installieren (sql1/sql2/sql3)" +# ============================================================================= + +install_postgres() { + local id=$1 + local name=$2 + + log "Installiere PostgreSQL 16 auf CT $id ($name)..." + wait_for_apt $id + + pct exec $id -- bash -c " + export DEBIAN_FRONTEND=noninteractive + + # PostgreSQL Repository + curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor -o /usr/share/keyrings/postgresql.gpg + echo 'deb [signed-by=/usr/share/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt \$(lsb_release -cs)-pgdg main' \ + > /etc/apt/sources.list.d/pgdg.list + apt-get update -qq + apt-get install -y -qq postgresql-${PG_VERSION} postgresql-client-${PG_VERSION} + + # PostgreSQL NICHT autostart — Patroni übernimmt die Kontrolle + systemctl stop postgresql || true + systemctl disable postgresql || true + + # Datenverzeichnis für Patroni vorbereiten + rm -rf /var/lib/postgresql/${PG_VERSION}/patroni + mkdir -p /var/lib/postgresql/${PG_VERSION}/patroni + chown postgres:postgres /var/lib/postgresql/${PG_VERSION}/patroni + chmod 700 /var/lib/postgresql/${PG_VERSION}/patroni + " + log "PostgreSQL 16 auf $name installiert" +} + +for name in sql1 sql2 sql3; do + install_postgres "${CT_ID[$name]}" "$name" & +done +wait +log "PostgreSQL 16 auf allen DB-Nodes installiert" + +# ============================================================================= +step "PHASE 4: etcd installieren und konfigurieren" +# ============================================================================= + +install_etcd() { + local id=$1 + local name=$2 + + log "Installiere etcd auf CT $id ($name)..." + wait_for_apt $id + + pct exec $id -- bash -c " + export DEBIAN_FRONTEND=noninteractive + apt-get install -y -qq etcd + systemctl stop etcd || true + systemctl disable etcd || true + " + log "etcd auf $name installiert" +} + +for name in sql1 sql2 sql3; do + install_etcd "${CT_ID[$name]}" "$name" & +done +wait + +# etcd konfigurieren — jeder Node bekommt seine eigene Config +configure_etcd() { + local id=$1 + local name=$2 + local ip=${CT_IP[$name]} + + log "Konfiguriere etcd auf $name ($ip)..." + + pct exec $id -- bash -c " + cat > /etc/default/etcd << 'ETCDEOF' +ETCD_NAME=\"${name}\" +ETCD_DATA_DIR=\"/var/lib/etcd/default\" +ETCD_LISTEN_PEER_URLS=\"http://${ip}:2380\" +ETCD_LISTEN_CLIENT_URLS=\"http://${ip}:2379,http://127.0.0.1:2379\" +ETCD_INITIAL_ADVERTISE_PEER_URLS=\"http://${ip}:2380\" +ETCD_ADVERTISE_CLIENT_URLS=\"http://${ip}:2379\" +ETCD_INITIAL_CLUSTER=\"sql1=http://${CT_IP[sql1]}:2380,sql2=http://${CT_IP[sql2]}:2380,sql3=http://${CT_IP[sql3]}:2380\" +ETCD_INITIAL_CLUSTER_TOKEN=\"pg-etcd-cluster\" +ETCD_INITIAL_CLUSTER_STATE=\"new\" +ETCD_ENABLE_V2=\"true\" +ETCDEOF + + # Altes Datenverzeichnis entfernen (Neustart sauber) + rm -rf /var/lib/etcd/default + mkdir -p /var/lib/etcd/default + chown -R etcd:etcd /var/lib/etcd 2>/dev/null || chown -R root:root /var/lib/etcd + + systemctl enable etcd + " +} + +for name in sql1 sql2 sql3; do + configure_etcd "${CT_ID[$name]}" "$name" +done + +# etcd auf ALLEN Nodes gleichzeitig starten (Quorum-Anforderung!) +log "Starte etcd Cluster auf allen 3 Nodes gleichzeitig..." +for name in sql1 sql2 sql3; do + pct exec "${CT_ID[$name]}" -- systemctl start etcd & +done +wait +sleep 5 + +# Gesundheitscheck +log "Prüfe etcd Cluster-Gesundheit..." +pct exec 300 -- bash -c " + ETCDCTL_API=3 etcdctl \ + --endpoints=http://${CT_IP[sql1]}:2379,http://${CT_IP[sql2]}:2379,http://${CT_IP[sql3]}:2379 \ + endpoint health 2>/dev/null || \ + etcdctl --endpoints=http://127.0.0.1:2379 cluster-health 2>/dev/null || \ + echo 'etcd läuft (Health-Check-Befehl variiert je nach Version)' +" + +# ============================================================================= +step "PHASE 5: Patroni installieren und konfigurieren" +# ============================================================================= + +install_patroni() { + local id=$1 + local name=$2 + + log "Installiere Patroni auf CT $id ($name)..." + wait_for_apt $id + + pct exec $id -- bash -c " + export DEBIAN_FRONTEND=noninteractive + apt-get install -y -qq python3-pip python3-dev libpq-dev gcc + pip3 install --quiet 'patroni[etcd3]' psycopg2-binary + " + log "Patroni auf $name installiert" +} + +for name in sql1 sql2 sql3; do + install_patroni "${CT_ID[$name]}" "$name" & +done +wait + +# Patroni konfigurieren +configure_patroni() { + local id=$1 + local name=$2 + local ip=${CT_IP[$name]} + + log "Konfiguriere Patroni auf $name ($ip)..." + + pct exec $id -- bash -c " + mkdir -p /etc/patroni + chown postgres:postgres /etc/patroni + chmod 750 /etc/patroni + + cat > /etc/patroni/patroni.yml << 'PATRONIEOF' +scope: ${PATRONI_SCOPE} +namespace: ${PATRONI_NAMESPACE} +name: ${name} + +restapi: + listen: ${ip}:8008 + connect_address: ${ip}:8008 + +etcd3: + hosts: + - ${CT_IP[sql1]}:2379 + - ${CT_IP[sql2]}:2379 + - ${CT_IP[sql3]}:2379 + +bootstrap: + dcs: + ttl: 30 + loop_wait: 10 + retry_timeout: 10 + maximum_lag_on_failover: 1048576 + postgresql: + use_pg_rewind: true + use_slots: true + parameters: + wal_level: replica + hot_standby: 'on' + max_connections: 200 + max_wal_senders: 10 + max_replication_slots: 10 + wal_log_hints: 'on' + initdb: + - encoding: UTF8 + - data-checksums + pg_hba: + - host replication replicator 192.168.0.0/24 md5 + - host all all 0.0.0.0/0 md5 + users: + admin: + password: admin + options: + - createrole + - createdb + +postgresql: + listen: ${ip}:5432 + connect_address: ${ip}:5432 + data_dir: /var/lib/postgresql/${PG_VERSION}/patroni + bin_dir: /usr/lib/postgresql/${PG_VERSION}/bin + pgpass: /tmp/pgpass0 + authentication: + replication: + username: replicator + password: ${PG_REPLICATOR_PASS} + superuser: + username: postgres + password: ${PG_SUPERUSER_PASS} + rewind: + username: rewind_user + password: ${PG_REWIND_PASS} + +tags: + nofailover: false + noloadbalance: false + clonefrom: false + nosync: false +PATRONIEOF + + chown postgres:postgres /etc/patroni/patroni.yml + chmod 640 /etc/patroni/patroni.yml + + # Patroni Systemd Service + cat > /etc/systemd/system/patroni.service << 'SVCEOF' +[Unit] +Description=Patroni PostgreSQL HA +After=syslog.target network.target etcd.service +Wants=etcd.service + +[Service] +Type=simple +User=postgres +Group=postgres +ExecStart=/usr/local/bin/patroni /etc/patroni/patroni.yml +KillMode=process +TimeoutSec=30 +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target +SVCEOF + + systemctl daemon-reload + systemctl enable patroni + " +} + +for name in sql1 sql2 sql3; do + configure_patroni "${CT_ID[$name]}" "$name" +done + +# Patroni starten — sql1 zuerst (wird Leader), dann Replicas +log "Starte Patroni auf sql1 (Leader-Initialisierung)..." +pct exec "${CT_ID[sql1]}" -- systemctl start patroni +sleep 20 + +log "Starte Patroni auf sql2 und sql3 (Replicas)..." +pct exec "${CT_ID[sql2]}" -- systemctl start patroni & +pct exec "${CT_ID[sql3]}" -- systemctl start patroni & +wait +sleep 15 + +# Cluster-Status prüfen +log "Prüfe Patroni Cluster-Status..." +pct exec "${CT_ID[sql1]}" -- /usr/local/bin/patronictl -c /etc/patroni/patroni.yml list || true + +# ============================================================================= +step "PHASE 6: Datenbank-Benutzer und Datenbanken anlegen" +# ============================================================================= + +log "Lege Benutzer und Datenbanken an..." +pct exec "${CT_ID[sql1]}" -- bash -c " + # Warte bis PostgreSQL bereit ist + for i in \$(seq 1 30); do + sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/pg_isready -h ${CT_IP[sql1]} && break + sleep 2 + done + + # Benutzer nicolay + sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/psql -h ${CT_IP[sql1]} \ + -c \"CREATE USER ${USER_NICOLAY} WITH LOGIN PASSWORD '${PASS_NICOLAY}';\" 2>/dev/null || \ + sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/psql -h ${CT_IP[sql1]} \ + -c \"ALTER USER ${USER_NICOLAY} WITH PASSWORD '${PASS_NICOLAY}';\" + + # Datenbanken + sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/psql -h ${CT_IP[sql1]} \ + -c \"CREATE DATABASE ${USER_NICOLAY} OWNER ${USER_NICOLAY};\" 2>/dev/null || true + sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/psql -h ${CT_IP[sql1]} \ + -c \"CREATE DATABASE testdb OWNER ${USER_NICOLAY};\" 2>/dev/null || true + + echo 'Benutzer und Datenbanken angelegt' +" + +# ============================================================================= +step "PHASE 7: pgAdmin4 auf separatem LXC (CT 303) installieren" +# ============================================================================= + +# Basis-Setup pgAdmin-CT +log "Basis-Setup CT 303 (pgadmin)..." +wait_for_apt "${CT_ID[pgadmin]}" + +pct exec "${CT_ID[pgadmin]}" -- bash -c " + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + + # Benutzer + id ${USER_NICOLAY} &>/dev/null || useradd -m -s /bin/bash -G sudo ${USER_NICOLAY} + echo '${USER_NICOLAY}:${PASS_NICOLAY}' | chpasswd + echo '${USER_NICOLAY} ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/${USER_NICOLAY} + + # SSH + apt-get install -y -qq openssh-server curl wget gnupg lsb-release ca-certificates + systemctl enable ssh && systemctl start ssh + sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config + systemctl restart ssh + + # pgAdmin Repository + curl -fsSL https://www.pgadmin.org/static/packages_pgadmin_org.pub \ + | gpg --dearmor -o /usr/share/keyrings/pgadmin.gpg + echo 'deb [signed-by=/usr/share/keyrings/pgadmin.gpg] https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/\$(lsb_release -cs) pgadmin4 main' \ + > /etc/apt/sources.list.d/pgadmin4.list + apt-get update -qq + apt-get install -y -qq pgadmin4-web apache2 + + # pgAdmin Datenbank initialisieren + sudo -u www-data /usr/pgadmin4/venv/bin/python3 /usr/pgadmin4/web/setup.py setup-db + + # Admin-Benutzer anlegen + sudo -u www-data /usr/pgadmin4/venv/bin/python3 /usr/pgadmin4/web/setup.py add-user \ + ${PGADMIN_EMAIL} ${PGADMIN_PASS} --admin 2>/dev/null || \ + echo 'Benutzer bereits vorhanden' + + # Apache konfigurieren + a2enconf pgadmin4 + a2enmod wsgi 2>/dev/null || true + systemctl enable apache2 + systemctl restart apache2 +" + +log "pgAdmin4 installiert auf ${CT_IP[pgadmin]}" + +# Cluster-Servers in pgAdmin laden +log "Lade PostgreSQL-Server in pgAdmin..." +pct exec "${CT_ID[pgadmin]}" -- bash -c " + cat > /tmp/pgadmin_servers.json << 'JSONEOF' +{ + \"Servers\": { + \"1\": { + \"Name\": \"sql1 (Leader)\", + \"Group\": \"pg-cluster\", + \"Host\": \"${CT_IP[sql1]}\", + \"Port\": 5432, + \"MaintenanceDB\": \"postgres\", + \"Username\": \"${USER_NICOLAY}\", + \"SSLMode\": \"prefer\", + \"Comment\": \"Patroni Leader Node\" + }, + \"2\": { + \"Name\": \"sql2 (Replica)\", + \"Group\": \"pg-cluster\", + \"Host\": \"${CT_IP[sql2]}\", + \"Port\": 5432, + \"MaintenanceDB\": \"postgres\", + \"Username\": \"${USER_NICOLAY}\", + \"SSLMode\": \"prefer\", + \"Comment\": \"Patroni Replica Node\" + }, + \"3\": { + \"Name\": \"sql3 (Replica)\", + \"Group\": \"pg-cluster\", + \"Host\": \"${CT_IP[sql3]}\", + \"Port\": 5432, + \"MaintenanceDB\": \"postgres\", + \"Username\": \"${USER_NICOLAY}\", + \"SSLMode\": \"prefer\", + \"Comment\": \"Patroni Replica Node\" + } + } +} +JSONEOF + + sudo -u www-data /usr/pgadmin4/venv/bin/python3 /usr/pgadmin4/web/setup.py \ + load-servers /tmp/pgadmin_servers.json --user ${PGADMIN_EMAIL} +" + +# ============================================================================= +step "PHASE 8: Abschluss-Prüfung und Report" +# ============================================================================= + +echo "" +echo -e "${BOLD}${GREEN}╔══════════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}${GREEN}║ PostgreSQL HA Cluster — Installation fertig ║${NC}" +echo -e "${BOLD}${GREEN}╚══════════════════════════════════════════════════════════╝${NC}" +echo "" + +echo -e "${BOLD}Cluster-Status:${NC}" +pct exec "${CT_ID[sql1]}" -- /usr/local/bin/patronictl -c /etc/patroni/patroni.yml list 2>/dev/null || \ + echo " (patronictl nicht im PATH — manuell prüfen: pct exec 300 -- /usr/local/bin/patronictl -c /etc/patroni/patroni.yml list)" + +echo "" +echo -e "${BOLD}Zugriff:${NC}" +echo -e " PostgreSQL (Primary): ${CT_IP[sql1]}:5432" +echo -e " PostgreSQL (Replica): ${CT_IP[sql2]}:5432 / ${CT_IP[sql3]}:5432" +echo -e " pgAdmin4 Web: http://${CT_IP[pgadmin]}/pgadmin4/" +echo -e " Patroni REST sql1: http://${CT_IP[sql1]}:8008/" +echo -e " Patroni REST sql2: http://${CT_IP[sql2]}:8008/" +echo -e " Patroni REST sql3: http://${CT_IP[sql3]}:8008/" +echo "" +echo -e "${BOLD}Anmeldedaten:${NC}" +echo -e " pgAdmin: ${PGADMIN_EMAIL} / ${PGADMIN_PASS}" +echo -e " DB-User: ${USER_NICOLAY} / ${PASS_NICOLAY}" +echo -e " DB-Super: postgres / ${PG_SUPERUSER_PASS}" +echo -e " SSH (alle): ${USER_NICOLAY} / ${PASS_NICOLAY}" +echo "" +echo -e "${BOLD}Datenbanken:${NC}" +echo -e " nicolay, testdb" +echo "" +echo -e "${BOLD}Failover-Befehl (manuell):${NC}" +echo -e " pct exec 300 -- /usr/local/bin/patronictl -c /etc/patroni/patroni.yml failover ${PATRONI_SCOPE}" +echo "" diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..9fd5a86 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,2053 @@ +import { useState, useEffect, useCallback, createContext, useContext } from "react"; + +// ── Color palette — Dark Ocean (glassmorphism, WCAG 2.1 AA) ──── +const C = { + pageBg: '#04101e', + headerBg: 'linear-gradient(180deg, #020810 0%, #031120 50%, #04182e 100%)', + card: 'rgba(8,28,52,0.72)', + cardSoft: 'rgba(255,255,255,0.04)', + cardBorder: 'rgba(255,255,255,0.10)', + inputBg: 'rgba(255,255,255,0.08)', + inputBorder: 'rgba(255,255,255,0.32)', + inputFocus: '#22d3ee', + primary: '#22d3ee', // cyan-400 — ~8:1 on dark ✓ + primaryDark: '#0e7490', + primaryLight: 'rgba(34,211,238,0.14)', + secondary: '#2dd4bf', // teal-400 + secondaryLight: 'rgba(45,212,191,0.14)', + accent: '#818cf8', // indigo-400 + accentLight: 'rgba(129,140,248,0.14)', + // Status — WCAG AA konform auf dunklem Hintergrund (≥4,5:1) + ok: { bg: 'rgba(34,197,94,0.12)', text: '#4ade80', border: 'rgba(74,222,128,0.30)', badge: 'rgba(34,197,94,0.20)', dot: '#22c55e', stripe: 'rgba(34,197,94,0.08)' }, + low: { bg: 'rgba(245,158,11,0.12)', text: '#fcd34d', border: 'rgba(252,211,77,0.30)', badge: 'rgba(245,158,11,0.20)', dot: '#f59e0b', stripe: 'rgba(245,158,11,0.08)' }, + high: { bg: 'rgba(239,68,68,0.12)', text: '#fca5a5', border: 'rgba(252,165,165,0.30)', badge: 'rgba(239,68,68,0.20)', dot: '#ef4444', stripe: 'rgba(239,68,68,0.08)' }, + empty: { bg: 'transparent', text: 'rgba(240,249,255,0.50)', border: 'rgba(255,255,255,0.12)', badge: 'rgba(255,255,255,0.08)', dot: 'rgba(255,255,255,0.30)', stripe: 'transparent' }, + txt1: '#f0f9ff', // ~20:1 on dark ✓ + txt2: 'rgba(240,249,255,0.82)', // ~13:1 ✓ + txt3: 'rgba(240,249,255,0.60)', // ~8:1 ✓ + txt4: 'rgba(240,249,255,0.45)', // ~4.8:1 ✓ WCAG AA + shadow: '0 4px 24px rgba(0,0,0,0.50)', + shadowMd: '0 8px 40px rgba(0,0,0,0.60)', + shadowLg: '0 20px 64px rgba(0,0,0,0.70)', +}; +// Glassmorphism helper — spread into card/modal style objects +const GLASS = { backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)' }; + +// ── Data ─────────────────────────────────────────────────────── +const SOLLWERTE = { + pH: { min: 6.5, max: 6.8, unit: "", label: "pH-Wert", icon: "⚗️" }, + KH: { min: 4, max: 6, unit: "°dKH", label: "Karbonathärte", icon: "🪨" }, + GH: { min: 3, max: 10, unit: "°dGH", label: "Gesamthärte", icon: "💧" }, + CO2: { min: 20, max: 30, unit: "mg/l", label: "CO₂", icon: "🌿" }, + NO3: { min: 10, max: 25, unit: "mg/l", label: "Nitrat (NO₃)", icon: "🧪" }, + NO2: { min: 0, max: 0.1, unit: "mg/l", label: "Nitrit (NO₂)", icon: "☠️" }, + PO4: { min: 0.1, max: 0.4, unit: "mg/l", label: "Phosphat (PO₄)", icon: "🌱" }, + Fe: { min: 0.05, max: 0.2, unit: "mg/l", label: "Eisen (Fe)", icon: "⚙️" }, + K: { min: 5, max: 15, unit: "mg/l", label: "Kalium (K)", icon: "🔋" }, + Mg: { min: 7, max: 15, unit: "mg/l", label: "Magnesium (Mg)", icon: "✨" }, + Ca: { min: 20, max: 40, unit: "mg/l", label: "Calcium (Ca)", icon: "🦴" }, + NH4: { min: 0, max: 0.25, unit: "mg/l", label: "Ammonium (NH₄)", icon: "⚠️" }, + O2: { min: 6, max: 10, unit: "mg/l", label: "Sauerstoff (O₂)", icon: "💨" }, + Temp: { min: 22, max: 26, unit: "°C", label: "Temperatur", icon: "🌡️" }, + Leit: { min: 150, max: 500, unit: "μS/cm", label: "Leitfähigkeit", icon: "⚡" }, +}; +const PARAM_KEYS = Object.keys(SOLLWERTE); + +const DUENGER_DB = [ + { + id: "npk", + name: "Aqua Rebell Makro Basic NPK", + kurz: "NPK", + farbe: "#16a34a", icon: "🌿", typ: "Makro", + link: "https://www.aquasabi.de/Aqua-Rebell-Makro-Basic-NPK", + zweck: "NO₃ + PO₄ + K + Mg – Grundversorgung", + notiz: "Hebt gleichzeitig NO₃, PO₄, K und Mg an. Eisen/PO₄-Dünger zeitversetzt geben.", + pro1ml100L: { NO3: 0.25, PO4: 0.025, K: 0.325, Mg: 0.025 }, + }, + { + id: "gh-boost-n", + name: "Aqua Rebell Advanced GH Boost N", + kurz: "GH Boost N", + farbe: "#7c3aed", icon: "🧬", typ: "Makro", + link: "https://www.aquasabi.de/Aqua-Rebell-Advanced-GH-Boost-N", + zweck: "NO₃ (kaliumfrei) + Mg + etwas Ca", + notiz: "KEIN Kalium – beste Wahl wenn K bereits im Soll. Hebt Mg und leicht Ca mit an.", + pro1ml100L: { NO3: 0.748, Mg: 0.137, Ca: 0.016 }, + }, + { + id: "makro-nitrat", + name: "Aqua Rebell Makro Basic Nitrat", + kurz: "Makro Nitrat", + farbe: "#15803d", icon: "💚", typ: "Makro", + link: "https://www.aquasabi.de/Aqua-Rebell-Makro-Basic-Nitrat", + zweck: "NO₃ + K (KNO₃-basiert)", + notiz: "Enthält auch Kalium (NK-Dünger). Bei K-Überschuss lieber GH Boost N verwenden.", + pro1ml100L: { NO3: 0.109, K: 0.030 }, + }, + { + id: "makro-phosphat", + name: "Aqua Rebell Makro Basic Phosphat", + kurz: "Makro PO₄", + farbe: "#dc2626", icon: "🔴", typ: "Makro", + link: "https://www.aquasabi.de/Aqua-Rebell-Makro-Basic-Phosphat", + zweck: "PO₄ Einzelkomponente (rein)", + notiz: "Zeitversetzt zu Eisendünger geben (morgens Fe / abends PO₄). Nicht überdosieren.", + pro1ml100L: { PO4: 0.065 }, + }, + { + id: "makro-kalium", + name: "Aqua Rebell Makro Basic Kalium", + kurz: "Makro K", + farbe: "#d97706", icon: "🟡", typ: "Makro", + link: "https://www.aquasabi.de/Aqua-Rebell-Makro-Basic-Kalium", + zweck: "K Einzelkomponente (rein, keine Nebenwirkungen)", + notiz: "Reine K-Quelle ohne weitere Makronährstoffe. Zoobox: 10ml/100L = +2,5 mg/l K.", + pro1ml100L: { K: 0.25 }, + }, + { + id: "flowgrow", + name: "Aqua Rebell Mikro Spezial Flowgrow", + kurz: "Flowgrow", + farbe: "#0284c7", icon: "⚙️", typ: "Mikro", + link: "https://www.aquasabi.de/Aqua-Rebell-Mikro-Spezial-Flowgrow", + zweck: "Fe + Spurenelemente (schnell, täglich) + K + Mg", + notiz: "Sanft chelatiert – Fe sofort pflanzenaufnehmbar. Fe nach Zugabe im Test oft nicht messbar – normal! Morgens düngen.", + pro1ml100L: { Fe: 0.041, K: 0.006, Mg: 0.010 }, + }, + { + id: "basic-eisen", + name: "Aqua Rebell Mikro Basic Eisen", + kurz: "Basic Eisen", + farbe: "#b45309", icon: "🔩", typ: "Mikro", + link: "https://www.aquasabi.de/Aqua-Rebell-Mikro-Basic-Eisen", + zweck: "Fe + Spurenelemente (stark chelatiert, Wochendünger)", + notiz: "Stark chelatiert (EDTA/DTPA) – Fe dauerhaft nachweisbar. Ideal beim Wasserwechsel.", + pro1ml100L: { Fe: 0.040, K: 0.006 }, + }, + { + id: "spezial-eisen", + name: "Aqua Rebell Mikro Spezial Eisen", + kurz: "Spezial Eisen", + farbe: "#92400e", icon: "🟤", typ: "Mikro", + link: "https://www.aquasabi.de/Aqua-Rebell-Mikro-Spezial-Eisen", + zweck: "Extra Fe (hoch dosiert) + Mn bei Eisenmangel", + notiz: "Höherer Fe-Gehalt für Rotalgen-Prävention und intensive Rotfärbung. Nicht dauerhaft überdosieren.", + pro1ml100L: { Fe: 0.039 }, + }, +]; + +// ── Logic ────────────────────────────────────────────────────── +function berechneEmpfehlungen(istWerte, liter) { + const ist = {}; + PARAM_KEYS.forEach(k => { const v = parseFloat(istWerte[k]); ist[k] = isNaN(v) ? null : v; }); + const ergebnis = []; + for (const param of ["NO3", "PO4", "Fe", "K", "Mg", "Ca"]) { + if (ist[param] === null) continue; + const soll = SOLLWERTE[param]; + const ziel = (soll.min + soll.max) / 2; + const diff = ziel - ist[param]; + if (diff <= 0.001) continue; + const kandidaten = DUENGER_DB + .filter(d => d.pro1ml100L[param] !== undefined) + .map(d => { + const mlNoetig = (diff / d.pro1ml100L[param]) * (liter / 100); + const seiteneffekte = []; + let blockiert = false; + for (const [np, nv] of Object.entries(d.pro1ml100L)) { + if (np === param) continue; + const anhebung = nv * mlNoetig / (liter / 100); + if (anhebung < 0.001) continue; + const npIst = ist[np] ?? ((SOLLWERTE[np].min + SOLLWERTE[np].max) / 2); + const npNach = npIst + anhebung; + const npSoll = SOLLWERTE[np]; + if (!npSoll) continue; + const problem = npNach > npSoll.max; + if (problem) blockiert = true; + seiteneffekte.push({ par: np, von: +npIst.toFixed(3), nach: +npNach.toFixed(3), anhebung: +anhebung.toFixed(3), problem }); + } + return { duenger: d, mlNoetig, seiteneffekte, blockiert }; + }) + .sort((a, b) => { + if (a.blockiert !== b.blockiert) return a.blockiert ? 1 : -1; + return a.seiteneffekte.filter(s => s.problem).length - b.seiteneffekte.filter(s => s.problem).length; + }); + ergebnis.push({ param, ist: ist[param], ziel, diff, empfehlung: kandidaten[0], alternativ: kandidaten.slice(1).filter(k => !k.blockiert), alleKandidaten: kandidaten }); + } + return ergebnis; +} + +function getStatus(key, value) { + if (value === "" || value === null || value === undefined) return "leer"; + const v = parseFloat(value); + if (isNaN(v)) return "leer"; + const { min, max } = SOLLWERTE[key]; + if (v < min) return "niedrig"; + if (v > max) return "hoch"; + return "ok"; +} + +// map internal status names to C keys +function statusToC(status) { + if (status === "ok") return C.ok; + if (status === "niedrig") return C.low; + if (status === "hoch") return C.high; + return C.empty; +} + +// ── Responsive Breakpoint Hook ────────────────────────────────── +function useBreakpoint() { + const [width, setWidth] = useState(typeof window !== "undefined" ? window.innerWidth : 1024); + useEffect(() => { + const handler = () => setWidth(window.innerWidth); + window.addEventListener("resize", handler); + return () => window.removeEventListener("resize", handler); + }, []); + return { + isMobile: width < 640, + isTablet: width >= 640 && width < 1024, + isDesktop: width >= 1024, + width, + }; +} + +// ── StatusBadge ──────────────────────────────────────────────── +function StatusBadge({ status }) { + const cs = statusToC(status); + // WCAG 1.4.1: Icon + Text (nie nur Farbe) + // Alle Kontraste auf badge-Hintergrund geprüft (≥4,5:1): + // ok.text #166534 auf #dcfce7 → 8,2:1 ✓ + // low.text #9a3412 auf #ffedd5 → 7,1:1 ✓ + // high.text #991b1b auf #fee2e2 → 6,6:1 ✓ + // empty.text #475569 auf #f1f5f9 → 5,4:1 ✓ + const labels = { + ok: { icon: "✓", text: "OK" }, + niedrig: { icon: "↓", text: "Mangel" }, + hoch: { icon: "↑", text: "Überschuss" }, + leer: { icon: "–", text: "Offen" }, + }; + const l = labels[status] || labels.leer; + return ( + + + {l.text} + + ); +} + +// ── GaugeBar ─────────────────────────────────────────────────── +function GaugeBar({ paramKey, value, param, gaugeId }) { + if (!value || isNaN(parseFloat(value))) return null; + const v = parseFloat(value); + const { min, max } = param; + const pad = (max - min) * 0.5; + const lo = min - pad, total = max + pad - lo; + const pct = Math.min(100, Math.max(0, ((v - lo) / total) * 100)); + const minP = ((min - lo) / total) * 100; + const maxP = ((max - lo) / total) * 100; + const status = getStatus(paramKey, value); + const cs = statusToC(status); + // WCAG 1.4.1: Statusbeschreibung als Text, nicht nur Farbe + const statusText = status === "ok" ? "im Sollbereich" : status === "niedrig" ? "unter Sollbereich" : "über Sollbereich"; + const fmtV = v < 0.01 ? v.toFixed(3) : v < 10 ? v.toFixed(2) : v.toFixed(1); + return ( +
+ {/* Wert-Anzeige über der Gauge (WCAG 1.4.1: Wert auch als Text) */} +
+ + {fmtV} {param.unit || "pH"} + + + {statusText} + +
+ + {/* Gauge-Track */} +
+ {/* Sollbereich — grün hinterlegt mit Schraffur für Farbblinde */} +
+ {/* Sollbereich-Rahmen */} +
+ {/* Positionsmarker — Dreieck + Punkt (Form ≠ nur Farbe) */} +
+
+ + {/* Skala-Beschriftung */} +
+ {param.min}{param.unit} + ✓ Soll + {param.max}{param.unit} +
+
+ ); +} + +// ── ParamCard ────────────────────────────────────────────────── +// Statusklasse für CSS-Streifenmuster (WCAG 1.4.1: Farbe ≠ einziges Merkmal) +function statusClass(status) { + if (status === "ok") return "param-card status-ok"; + if (status === "niedrig") return "param-card status-low"; + if (status === "hoch") return "param-card status-high"; + return "param-card"; +} + +function ParamCard({ paramKey, value, onChange, showInfo, onToggleInfo }) { + const param = SOLLWERTE[paramKey]; + const status = getStatus(paramKey, value); + const cs = statusToC(status); + const inputId = `input-${paramKey}`; + return ( +
+
+ +
+ + +
+
+ + {showInfo && ( +
+ 📏 Sollbereich: {param.min}–{param.max} {param.unit || "pH"} +
+ )} + +
+ onChange(paramKey, e.target.value)} + placeholder={`z. B. ${((param.min + param.max) / 2).toFixed(param.min < 1 ? 2 : 1)}`} + step={["°C", "°dKH", "°dGH"].includes(param.unit) ? "0.1" : "0.01"} + aria-label={`${param.label} Messwert${param.unit ? " in " + param.unit : ""}, Sollbereich ${param.min} bis ${param.max}`} + aria-describedby={value ? `gauge-${paramKey}` : undefined} + style={{ + background: "rgba(255,255,255,0.07)", + border: `2px solid ${value ? cs.dot : 'rgba(255,255,255,0.28)'}`, + borderRadius: 10, + color: C.txt1, + fontSize: 17, + padding: "7px 11px", + width: "100%", + fontFamily: "'DM Mono', monospace", + fontWeight: 600, + }} + /> + {/* Einheit: #475569 auf cs.bg ≥ 4,5:1 ✓ */} + +
+ +
+ ); +} + +// ── SummaryBanner ────────────────────────────────────────────── +function SummaryBanner({ values, isDesktop }) { + let ok = 0, warn = 0, missing = 0; + PARAM_KEYS.forEach(k => { + const s = getStatus(k, values[k]); + if (s === "ok") ok++; + else if (s === "leer") missing++; + else warn++; + }); + const score = Math.round((ok / PARAM_KEYS.length) * 100); + const scoreCs = score >= 80 ? C.ok : score >= 50 ? C.low : C.high; + const barGradient = score >= 80 + ? `linear-gradient(90deg, ${C.secondary}, #34d399)` + : score >= 50 + ? `linear-gradient(90deg, ${C.accent}, #fbbf24)` + : `linear-gradient(90deg, #ef4444, #f97316)`; + + return ( +
+
+ {/* Score — Text + Farbe + Form (3 Merkmale, WCAG 1.4.1 ✓) */} +
+
{score}%
+
Wasserqualität
+
+ {/* Stats */} +
+ + + {ok} im Soll + + + + {warn} Abweichung{warn !== 1 ? "en" : ""} + + + + {missing} offen + +
+
+ {/* Progress bar */} +
+
+
+
+ ); +} + +// ── Formatter ────────────────────────────────────────────────── +function fmt(n) { + if (n === undefined || n === null) return "–"; + return n < 0.01 ? n.toFixed(3) : n < 10 ? n.toFixed(2) : n.toFixed(1); +} + +// ── EmpfehlungsKarte ─────────────────────────────────────────── +function EmpfehlungsKarte({ rec, liter }) { + const [showAlt, setShowAlt] = useState(false); + const [showDetail, setShowDetail] = useState(false); + const param = SOLLWERTE[rec.param]; + const emp = rec.empfehlung; + const d = emp.duenger; + const ml = emp.mlNoetig; + + return ( +
+
setShowDetail(s => !s)} style={{ padding: "13px 15px", cursor: "pointer" }}> +
+
+
+ {param.icon} {param.label} — Mangel +
+
+ {fmt(rec.ist)} {param.unit} + + {fmt(rec.ziel)} {param.unit} + Sollmitte +
+
+ +
+ + {/* Fertilizer pill */} +
+
+
+ {d.icon} + {d.name} + {emp.blockiert && ( + ⚠ Konflikt + )} +
+
{d.zweck}
+
+
+
{fmt(ml)} ml
+
für {liter} L
+
+
+ + {emp.seiteneffekte.length > 0 && ( +
+ {emp.seiteneffekte.map(s => ( + + {s.problem ? "⚠" : "+"} {SOLLWERTE[s.par]?.label || s.par}: {fmt(s.von)}→{fmt(s.nach)} {SOLLWERTE[s.par]?.unit} + + ))} +
+ )} +
+ + {showDetail && ( +
+ {/* Calculation */} +
+
BERECHNUNG
+
+ Fehlende Menge: {fmt(rec.diff)} mg/l {param.unit}
+ 1 ml {d.kurz} / 100 L hebt {rec.param} um: {d.pro1ml100L[rec.param]} mg/l
+ Formel: {fmt(rec.diff)} ÷ {d.pro1ml100L[rec.param]} × ({liter}÷100)
+ = {fmt(ml)} ml für {liter} L Wasser +
+
+ + {/* Note */} +
+ 💡 {d.notiz} +
+ + {emp.blockiert && ( +
+ ⚠️ Dieser Dünger würde einen anderen Parameter über den Sollwert heben. Erwäge eine Alternative oder reduziere die Menge. +
+ )} + + {/* Alternatives */} + {rec.alleKandidaten.length > 1 && ( +
+ + {showAlt && rec.alleKandidaten.slice(1).map(alt => ( +
+
+
{alt.duenger.icon} {alt.duenger.name}
+
{alt.duenger.zweck}
+ {alt.blockiert && ⚠ würde anderen Parameter überschreiten} + {alt.seiteneffekte.filter(s => !s.problem).map(s => ( + + +{SOLLWERTE[s.par]?.label}: +{fmt(s.anhebung)} {SOLLWERTE[s.par]?.unit} + + ))} +
+
+ {fmt(alt.mlNoetig)} ml +
+
+ ))} +
+ )} + + + 🔗 Aquasabi Shop → + +
+ )} +
+ ); +} + +// ── DuengerTab ───────────────────────────────────────────────── +function DuengerTab({ values, bp }) { + const [liter, setLiter] = useState(60); + const emp = berechneEmpfehlungen(values, liter); + const hoch = PARAM_KEYS.filter(k => getStatus(k, values[k]) === "hoch" && ["NO3", "PO4", "Fe", "K", "Mg", "Ca"].includes(k)); + const ohneWerte = ["NO3", "PO4", "Fe", "K", "Mg", "Ca"].every(k => !values[k]); + + const linksPanel = ( +
+ {/* Info card */} +
+
🧮 Ist → Soll Dünger-Rechner
+ Messwerte im Tab Messen eingeben → App berechnet die exakte Düngermenge für jeden Mangelwert und prüft Seiteneffekte auf andere Parameter. +
+ + {/* Volume */} +
+
🪣 Netto-Wasservolumen
+
+ setLiter(Math.max(1, parseInt(e.target.value) || 1))} + min={1} max={2000} + style={{ + background: C.inputBg, + border: `1.5px solid ${C.inputBorder}`, + borderRadius: 10, + color: C.primary, + fontSize: 22, + fontWeight: 900, + padding: "6px 12px", + width: "100%", + fontFamily: "'DM Mono', monospace", + }} + /> + L +
+ setLiter(parseInt(e.target.value))} + style={{ width: "100%", marginTop: 10, accentColor: C.primary }} + /> +
+ + {/* Table */} +
+
📊 Nährstoffgehalt pro 1 ml / 100 L
+
+ + + + + {["NO3", "PO4", "Fe", "K", "Mg", "Ca"].map(p => ( + + ))} + + + + {DUENGER_DB.map(d => ( + + + {["NO3", "PO4", "Fe", "K", "Mg", "Ca"].map(p => ( + + ))} + + ))} + +
Dünger{p}
{d.icon} {d.kurz} + {d.pro1ml100L[p] || "–"} +
+
+
Werte in mg/l · Quellen: Aquasabi / Zoobox / Garnelenhaus / Aqua-Rebell.de
+
+
+ ); + + const rechtsPanel = ( +
+ {ohneWerte && ( +
+
💧
+
Noch keine Nährstoffwerte gemessen.
+ Trage NO₃, PO₄, Fe, K, Mg oder Ca im Tab „Messen" ein. +
+ )} + {emp.length > 0 && ( + <> +
↓ {emp.length} Mangelwert{emp.length > 1 ? "e" : ""} erkannt
+ {emp.map(rec => )} + + )} + {!ohneWerte && emp.length === 0 && hoch.length === 0 && ( +
+ ✓ Alle Nährstoffe im Sollbereich!
+ Kein Dünger nötig. +
+ )} + {hoch.length > 0 && ( +
+
↑ Überschuss – Teilwasserwechsel empfohlen
+ {hoch.map(k => ( +
+ {SOLLWERTE[k].icon} {SOLLWERTE[k].label}: {values[k]} {SOLLWERTE[k].unit} → max. {SOLLWERTE[k].max} +
+ ))} +
25–50% Wasserwechsel reduziert Überschüsse.
+
+ )} +
+ ); + + if (bp.isDesktop) { + return ( +
+ {linksPanel} + {rechtsPanel} +
+ ); + } + return
{linksPanel}{rechtsPanel}
; +} + +// ── LogEntry ─────────────────────────────────────────────────── +function LogEntry({ entry, onDelete, isDesktop }) { + const [exp, setExp] = useState(false); + const okCount = PARAM_KEYS.filter(k => getStatus(k, entry.values[k]) === "ok").length; + const filledCount = PARAM_KEYS.filter(k => entry.values[k] !== "" && entry.values[k] !== undefined).length; + + return ( +
+
setExp(e => !e)} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "11px 14px", cursor: "pointer" }}> +
+
+ 📅 {new Date(entry.date).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })} +
+ {entry.note &&
{entry.note.slice(0, 50)}{entry.note.length > 50 ? "…" : ""}
} +
+
+ + {okCount}/{filledCount} OK + + +
+
+ {exp && ( +
+
+ {PARAM_KEYS.map(k => { + const v = entry.values[k]; + if (!v) return null; + const st = getStatus(k, v); + const cs = statusToC(st); + return ( +
+ {SOLLWERTE[k].icon} {k}: {v} {SOLLWERTE[k].unit} +
+ ); + })} +
+ {entry.note && ( +
+ 📝 {entry.note} +
+ )} + +
+ )} +
+ ); +} + +// ── API client ───────────────────────────────────────────────── +const JWT_KEY = "aqua_jwt"; +async function apiFetch(path, opts = {}) { + const token = localStorage.getItem(JWT_KEY); + const res = await fetch(`/api${path}`, { + ...opts, + headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), ...opts.headers }, + }); + if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || `HTTP ${res.status}`); } + return res.json(); +} + +// ── Auth context ──────────────────────────────────────────────── +const AuthCtx = createContext(null); +function useAuth() { return useContext(AuthCtx); } + +// ── Global CSS injection (shared) ────────────────────────────── +function GlobalStyles() { + useEffect(() => { + const s = document.createElement("style"); + s.id = "aqua-global"; + s.textContent = ` + * { box-sizing: border-box; } + body { + background: radial-gradient(ellipse at 50% 0%, #0a2040 0%, #04101e 60%, #020c18 100%); + min-height: 100vh; + } + *:focus-visible { outline: 3px solid #22d3ee; outline-offset: 3px; border-radius: 6px; } + button:focus-visible, a:focus-visible { outline: 3px solid #22d3ee; outline-offset: 3px; border-radius: 6px; } + input[type=number]:focus, textarea:focus, input[type=text]:focus, input[type=password]:focus { + outline: 3px solid #22d3ee; outline-offset: 0; border-color: #22d3ee !important; box-shadow: 0 0 0 3px rgba(34,211,238,0.22) !important; + } + .param-card { + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; + cursor: default; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + } + .param-card:hover { transform: translateY(-2px); box-shadow: 0 12px 40px rgba(0,0,0,0.55), 0 0 0 1px rgba(34,211,238,0.15) !important; } + .status-ok { background-image: repeating-linear-gradient(135deg, rgba(34,197,94,0.07) 0px, rgba(34,197,94,0.07) 2px, transparent 2px, transparent 12px); } + .status-low { background-image: repeating-linear-gradient(135deg, rgba(245,158,11,0.07) 0px, rgba(245,158,11,0.07) 2px, transparent 2px, transparent 12px); } + .status-high{ background-image: repeating-linear-gradient(135deg, rgba(239,68,68,0.07) 0px, rgba(239,68,68,0.07) 2px, transparent 2px, transparent 12px); } + .tab-pill { transition: all 0.2s ease; } + .tab-pill:hover { background: rgba(34,211,238,0.12) !important; color: #22d3ee !important; } + .tab-pill.active { background: rgba(34,211,238,0.18) !important; color: #22d3ee !important; box-shadow: 0 0 0 1px rgba(34,211,238,0.4), 0 2px 12px rgba(34,211,238,0.2) !important; } + .rec-card { + transition: box-shadow 0.18s, transform 0.18s; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + } + .rec-card:hover { transform: translateY(-1px); box-shadow: 0 8px 32px rgba(0,0,0,0.5) !important; } + .btn-primary { transition: all 0.2s ease; } + .btn-primary:hover { filter: brightness(1.12); transform: translateY(-1px); box-shadow: 0 8px 28px rgba(34,211,238,0.35) !important; } + .modal-overlay { position: fixed; inset: 0; background: rgba(2,8,16,0.75); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); z-index: 100; display: flex; align-items: center; justify-content: center; padding: 16px; } + input[type=range] { -webkit-appearance: none; height: 6px; border-radius: 3px; background: rgba(255,255,255,0.15); cursor: pointer; } + input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; border-radius: 50%; background: #22d3ee; cursor: pointer; box-shadow: 0 2px 8px rgba(34,211,238,0.5); border: 2px solid rgba(4,16,30,0.9); } + ::-webkit-scrollbar { width: 5px; height: 5px; } + ::-webkit-scrollbar-track { background: rgba(255,255,255,0.04); } + ::-webkit-scrollbar-thumb { background: rgba(34,211,238,0.25); border-radius: 3px; } + ::-webkit-scrollbar-thumb:hover { background: rgba(34,211,238,0.45); } + input[type=number]::-webkit-inner-spin-button, input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; } + select { color-scheme: dark; } + option { background: #0d1f36; color: #f0f9ff; } + `; + if (!document.getElementById("aqua-global")) document.head.appendChild(s); + return () => { const el = document.getElementById("aqua-global"); if (el) el.remove(); }; + }, []); + return null; +} + +// ── LoginScreen ───────────────────────────────────────────────── +function LoginScreen({ onLogin }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + if (!username || !password) { setError("Bitte alle Felder ausfüllen."); return; } + setLoading(true); setError(""); + try { + const data = await apiFetch("/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); + localStorage.setItem(JWT_KEY, data.token); + onLogin(data); + } catch (err) { + setError(err.message || "Anmeldung fehlgeschlagen."); + } finally { setLoading(false); } + } + + return ( +
+ {/* Header image card */} +
+
+
+
+ 🐠 +
Aquarium Logbuch
+
Ist→Soll · Dünger-Rechner · Logbuch
+
+
+ {/* Login form */} +
+
+ + setUsername(e.target.value)} + style={{ width: "100%", background: C.inputBg, border: `1.5px solid ${C.inputBorder}`, borderRadius: 10, color: C.txt1, fontSize: 15, padding: "10px 13px", fontFamily: "inherit" }} /> +
+
+ + setPassword(e.target.value)} + style={{ width: "100%", background: C.inputBg, border: `1.5px solid ${C.inputBorder}`, borderRadius: 10, color: C.txt1, fontSize: 15, padding: "10px 13px", fontFamily: "inherit" }} /> +
+ {error &&
{error}
} + +
+
+ ); +} + +// ── AquariumModal ─────────────────────────────────────────────── +function AquariumModal({ aquariums, current, onSelect, onCreate, onDelete, onClose }) { + const [newName, setNewName] = useState(""); + const [newVol, setNewVol] = useState(60); + const [creating, setCreating] = useState(false); + const [err, setErr] = useState(""); + + async function handleCreate(e) { + e.preventDefault(); + if (!newName.trim()) { setErr("Name erforderlich"); return; } + setCreating(true); + try { await onCreate(newName.trim(), newVol); setNewName(""); setNewVol(60); setErr(""); } + catch (e) { setErr(e.message); } + finally { setCreating(false); } + } + + return ( +
+
e.stopPropagation()} style={{ background: 'rgba(4,14,28,0.92)', ...GLASS, borderRadius: 20, border: '1px solid rgba(34,211,238,0.15)', padding: 24, width: "100%", maxWidth: 480, boxShadow: C.shadowLg, maxHeight: "85vh", overflowY: "auto" }}> +
+ 🐟 Aquarien + +
+ {aquariums.map(aq => ( +
{ onSelect(aq); onClose(); }}> + 🐠 {aq.name} + {aq.volume} L + {current?.id !== aq.id && ( + + )} +
+ ))} +
+
+ Neues Aquarium
+
+ setNewName(e.target.value)} + style={{ flex: 1, background: C.inputBg, border: `1.5px solid ${C.inputBorder}`, borderRadius: 10, color: C.txt1, fontSize: 13, padding: "8px 11px" }} /> + setNewVol(+e.target.value)} + style={{ width: 80, background: C.inputBg, border: `1.5px solid ${C.inputBorder}`, borderRadius: 10, color: C.txt1, fontSize: 13, padding: "8px 11px", textAlign: "right" }} /> +
+ {err &&
{err}
} + +
+
+
+ ); +} + +// ── AdminPanel ────────────────────────────────────────────────── +function AdminPanel({ onClose }) { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [newUser, setNewUser] = useState({ username: "", password: "", displayName: "", role: "user" }); + const [err, setErr] = useState(""); + const [ok, setOk] = useState(""); + + const loadUsers = useCallback(async () => { + try { setUsers(await apiFetch("/users")); } catch {} finally { setLoading(false); } + }, []); + useEffect(() => { loadUsers(); }, [loadUsers]); + + async function createUser(e) { + e.preventDefault(); setErr(""); setOk(""); + try { + await apiFetch("/users", { method: "POST", body: JSON.stringify(newUser) }); + setOk(`Benutzer "${newUser.username}" angelegt.`); + setNewUser({ username: "", password: "", displayName: "", role: "user" }); + loadUsers(); + } catch (e) { setErr(e.message); } + } + async function deleteUser(id, name) { + if (!window.confirm(`Benutzer "${name}" wirklich löschen?`)) return; + try { await apiFetch(`/users/${id}`, { method: "DELETE" }); loadUsers(); } catch (e) { setErr(e.message); } + } + + const inputStyle = { width: "100%", background: C.inputBg, border: `1.5px solid ${C.inputBorder}`, borderRadius: 10, color: C.txt1, fontSize: 13, padding: "8px 11px", fontFamily: "inherit" }; + + return ( +
+
e.stopPropagation()} style={{ background: 'rgba(4,14,28,0.92)', ...GLASS, borderRadius: 20, border: '1px solid rgba(34,211,238,0.15)', padding: 24, width: "100%", maxWidth: 560, boxShadow: C.shadowLg, maxHeight: "90vh", overflowY: "auto" }}> +
+ ⚙️ Benutzerverwaltung + +
+ + {/* User list */} + {loading ?
Lade…
: ( +
+ {users.map(u => ( +
+
+
{u.displayName || u.username}
+
@{u.username} · {u.role}
+
+ {u.role} + {u.id !== "admin-1" && ( + + )} +
+ ))} +
+ )} + + {/* Create user form */} +
+
+ Neuen Benutzer anlegen
+
+
+ + setNewUser(u => ({ ...u, username: e.target.value }))} style={inputStyle} /> +
+
+ + setNewUser(u => ({ ...u, password: e.target.value }))} style={inputStyle} /> +
+
+ + setNewUser(u => ({ ...u, displayName: e.target.value }))} style={inputStyle} /> +
+
+ + +
+
+ {err &&
{err}
} + {ok &&
{ok}
} + +
+
+
+ ); +} + +// ── ReminderBanner ────────────────────────────────────────────── +function ReminderBanner({ aquariumId }) { + const [reminder, setReminder] = useState(null); + const [showEdit, setShowEdit] = useState(false); + const [interval, setInterval_] = useState(7); + const [lastChange, setLastChange] = useState(new Date().toISOString().slice(0, 10)); + + const load = useCallback(async () => { + try { const r = await apiFetch(`/aquariums/${aquariumId}/reminder`); setReminder(r); if (r) { setInterval_(r.intervalDays); setLastChange(r.lastChange?.slice(0, 10) || new Date().toISOString().slice(0, 10)); } } + catch {} + }, [aquariumId]); + useEffect(() => { if (aquariumId) load(); }, [aquariumId, load]); + + if (!reminder?.enabled) return ( + + ); + + const last = new Date(reminder.lastChange); + const next = new Date(last); next.setDate(next.getDate() + reminder.intervalDays); + const today = new Date(); today.setHours(0,0,0,0); + const daysLeft = Math.ceil((next - today) / 86400000); + const due = daysLeft <= 0; + const soon = daysLeft <= 2 && daysLeft > 0; + + const bg = due ? C.high.bg : soon ? C.low.bg : C.ok.bg; + const border = due ? C.high.border : soon ? C.low.border : C.ok.border; + const text = due ? C.high.text : soon ? C.low.text : C.ok.text; + const msg = due ? `Wasserwechsel überfällig! (vor ${-daysLeft + 1} Tag${-daysLeft === 0 ? "" : "en"})` : soon ? `Wasserwechsel in ${daysLeft} Tag${daysLeft === 1 ? "" : "en"}` : `Nächster Wasserwechsel in ${daysLeft} Tagen`; + + async function markDone() { + const today = new Date().toISOString(); + try { await apiFetch(`/aquariums/${aquariumId}/reminder`, { method: "PUT", body: JSON.stringify({ ...reminder, lastChange: today }) }); load(); } + catch {} + } + + return ( +
+ {due ? "🚨" : soon ? "⏰" : "✅"} + {msg} + + + {showEdit && { setShowEdit(false); load(); }} />} +
+ ); +} + +function ReminderEditModal({ aquariumId, reminder, onClose }) { + const [interval_, setInterval_] = useState(reminder?.intervalDays ?? 7); + const [lastChange, setLastChange] = useState(reminder?.lastChange?.slice(0, 10) ?? new Date().toISOString().slice(0, 10)); + async function save(e) { + e.preventDefault(); + await apiFetch(`/aquariums/${aquariumId}/reminder`, { method: "PUT", body: JSON.stringify({ intervalDays: interval_, lastChange: new Date(lastChange).toISOString(), enabled: true }) }); + onClose(); + } + async function disable() { + await apiFetch(`/aquariums/${aquariumId}/reminder`, { method: "PUT", body: JSON.stringify({ ...(reminder||{}), enabled: false }) }); + onClose(); + } + return ( +
+
e.stopPropagation()} style={{ background: 'rgba(4,14,28,0.92)', ...GLASS, borderRadius: 18, border: '1px solid rgba(34,211,238,0.15)', padding: 24, width: "100%", maxWidth: 360, boxShadow: C.shadowLg }}> +
⏰ Wasserwechsel-Erinnerung
+ + setInterval_(+e.target.value)} + style={{ width: "100%", background: C.inputBg, border: `1.5px solid ${C.inputBorder}`, borderRadius: 10, color: C.txt1, fontSize: 18, fontWeight: 700, padding: "8px 12px", fontFamily: "'DM Mono', monospace", marginBottom: 12 }} /> + + setLastChange(e.target.value)} + style={{ width: "100%", background: C.inputBg, border: `1.5px solid ${C.inputBorder}`, borderRadius: 10, color: C.txt1, fontSize: 14, padding: "8px 12px", marginBottom: 16 }} /> +
+ + {reminder?.enabled && } + +
+
+
+ ); +} + +// ── WissenTab ─────────────────────────────────────────────────── +function WissenTab({ bp }) { + const [open, setOpen] = useState(null); + const sections = [ + { + id: "stickstoff", icon: "🔄", title: "Der Stickstoffkreislauf", color: "#22d3ee", + summary: "NH₄ → NO₂ → NO₃: Biologische Filterung durch Bakterien ist das Herzstück jedes Aquariums.", + content: [ + { head: "Ammonium / Ammoniak (NH₄/NH₃)", text: "Entsteht durch Fischkot, Futterreste und Pflanzenzerfäll. Bei pH < 7 liegt es als ungiftiges NH₄ vor. Ab pH 7 wandelt es sich zunehmend in giftiges NH₃ um – bereits 0,1 mg/l NH₃ können Fische schädigen." }, + { head: "Nitrit (NO₂) — Gefahr!", text: "Nitrosomonas-Bakterien wandeln NH₄ in NO₂ um. Nitrit hemmt den Sauerstofftransport im Blut (Methämoglobinämie). Im eingefahrenen Aquarium sollte NO₂ dauerhaft < 0,1 mg/l sein. Nitrit-Spitzen treten beim Einfahren auf (4–8 Wochen)." }, + { head: "Nitrat (NO₃) — Pflanzennahrung", text: "Nitrospira-Bakterien oxidieren NO₂ zu vergleichsweise harmlosem NO₃. Pflanzen verbrauchen es aktiv. Ohne Pflanzen steigt NO₃ stetig an und muss durch Wasserwechsel entfernt werden. Ziel: 10–25 mg/l." }, + { head: "Einfahrzeit", text: "Ein neues Aquarium braucht 4–8 Wochen zum biologischen Einfahren. In dieser Zeit sind NH₄ und NO₂ erhöht — noch keine Fische einsetzen! Filtermedien nie mit Leitungswasser spülen (Chlor tötet Bakterien)." }, + ] + }, + { + id: "ph-kh-co2", icon: "⚗️", title: "pH · KH · CO₂ — Das Dreieck", color: "#a78bfa", + summary: "Diese drei Werte hängen untrennbar zusammen. Wer einen verändert, beeinflusst automatisch die anderen.", + content: [ + { head: "Karbonathärte (KH) als Puffer", text: "KH (Bikarbonat) puffert den pH-Wert. Eine hohe KH (> 6 °dKH) verhindert pH-Stürze, eine zu niedrige KH (< 3 °dKH) führt zu instabilem pH. Für Pflanzenaquarien gilt: 4–6 °dKH ist ideal – hart genug zum Puffern, weich genug für CO₂-Effizienz." }, + { head: "CO₂ und pH", text: "CO₂ löst sich in Wasser zu Kohlensäure (H₂CO₃) und senkt den pH. Die Formel: Bei KH = 6 und CO₂ = 25 mg/l ergibt sich pH ≈ 6,7. Tagsüber verbrauchen Pflanzen CO₂ → pH steigt. Nachts produzieren alle Lebewesen CO₂ → pH fällt. Eine natürliche Tagesschwankung von ± 0,5 ist normal." }, + { head: "Ziel-CO₂ für Pflanzen", text: "Pflanzen wachsen optimal bei 20–30 mg/l CO₂. Mit KH und pH lässt sich CO₂ schätzen: CO₂ = 3 × KH × 10^(7,0 − pH). CO₂ ≥ 30 mg/l kann Fische schädigen (Hyperkapnie). Nachts CO₂-Anlage ausschalten, morgens früh einschalten." }, + { head: "Fischgerechter pH", text: "Die meisten Süßwasserfische tolerieren pH 6,5–7,5. Südamerika (Diskus, Tetras): pH 6,0–6,8. Afrika (Malawisee): pH 7,8–8,5. Brückscher Richtlinie: Keine pH-Änderung > 0,2 pro Stunde." }, + ] + }, + { + id: "makro", icon: "🌿", title: "Makronährstoffe für Pflanzen", color: "#4ade80", + summary: "NO₃, PO₄, K, Mg, Ca sind die Grundnahrung. Liebigs Minimum: Das knappste Element begrenzt das Wachstum.", + content: [ + { head: "Stickstoff (NO₃) — Aufbaustoff", text: "Baustein für Aminosäuren, Proteine und Chlorophyll. Mangel: langsames Wachstum, gelbliche Alttriebe. Überschuss: weiches, weichhäutiges Gewebe, anfälliger für Algen. Ziel: 10–25 mg/l. Abgebaut durch Pflanzen und Wasserwechsel." }, + { head: "Phosphat (PO₄) — Energieträger", text: "Teil von ATP (Zellenenergie) und DNA. Mangel: rötliche Blätter, sehr langsames Wachstum. Viele Aquarianer fürchten PO₄ als Algenursache — falsch! Algen entstehen bei Ungleichgewicht, nicht bei hohem PO₄. Ziel: 0,1–0,4 mg/l. Eisen zeitversetzt dosieren." }, + { head: "Kalium (K) — Wasserhaushalt", text: "Regelt den Wasserhaushalt der Pflanzenzellen. Mangel: Löcher in Blättern, gelbe Blattränder. Oft unterschätzt — besonders in Leitungswasser ist K niedrig. Ziel: 5–15 mg/l. Hat kaum Seiteneffekte bei Überdosierung." }, + { head: "Magnesium (Mg) & Calcium (Ca)", text: "Mg ist zentrales Atom im Chlorophyll. Mangel: Blätter vergilben zwischen den Blattadern (Interchlorose). Ca stabilisiert Zellwände. Ca:Mg-Verhältnis ca. 3:1 anstreben. GH = Summe aus Ca und Mg (grob). Für Garnelen: GH 6–8 °dGH empfohlen." }, + ] + }, + { + id: "mikro", icon: "⚙️", title: "Mikronährstoffe & Eisen", color: "#fb923c", + summary: "Spurenelemente und Eisen ermöglichen enzymatische Prozesse — in kleinen Mengen unverzichtbar.", + content: [ + { head: "Eisen (Fe) — sichtbarster Mikronahrstoff", text: "Kofaktor für Chlorophyll-Synthese. Mangel: Junge Blätter vergilben (Interchlorose). Fe ist schwer löslich — chelatiert (EDTA, DTPA, Huminat) bleibt es verfügbar. 0,05–0,2 mg/l. Morgens dosieren, da Fe im Tageslicht photodegradiert." }, + { head: "Mangan (Mn), Bor (B), Zink (Zn)", text: "Mn: Kofaktor bei Photosynthese. Zn: Enzymaktivierung. Bor: Zellwandbildung. Bei Verwendung vollständiger Mikro-Dünger (z.B. Flowgrow) sind diese meist ausreichend abgedeckt." }, + { head: "Chelate — Transport im Wasser", text: "Metalle bilden im Wasser schwer lösliche Verbindungen. Chelatoren (EDTA, DTPA, Gluconat) halten sie löslich und bioverfügbar. Sanft chelatierte Produkte (Huminat/Gluconat) sind schnell verfügbar aber kurzlebig → täglich dosieren. EDTA-Chelate sind stabiler → wöchentlich." }, + ] + }, + { + id: "fische", icon: "🐟", title: "Wasserqualität & Fischgesundheit", color: "#38bdf8", + summary: "Fische haben einen engen Toleranzbereich. Stressfreies Wasser ist die wichtigste Krankheitsprophylaxe.", + content: [ + { head: "Temperatur", text: "Tropische Fische: 24–28 °C. Kaltwater (Goldfish): 15–22 °C. Schwankungen > 2 °C/Tag stressen Fische stark. Beim Wasserwechsel: immer temperiertes Wasser verwenden (max. 1 °C Differenz). Zu warmes Wasser reduziert O₂-Gehalt." }, + { head: "Sauerstoff (O₂)", text: "Fische benötigen ≥ 6 mg/l O₂. Kritisch < 4 mg/l. Sauerstoff wird bei höherer Temperatur schlechter gelöst (20 °C: ~9 mg/l, 28 °C: ~7,8 mg/l). CO₂-Düngung nachts abschalten! Ausreichend Wasserbewegung an der Oberfläche." }, + { head: "Leitfähigkeit (μS/cm)", text: "Spiegelt den Gesamtmineralgehalt. Tropenarten: 150–400 μS/cm. Afrikanische Cichliden: 400–800 μS/cm. Garnelen (Caridina): 100–200 μS/cm, (Neocaridina): 250–450 μS/cm. Ein plötzlicher Anstieg kann auf Verschmutzung hinweisen." }, + { head: "Stress und Immunsystem", text: "Schlechte Wasserqualität stresst Fische dauerhaft → schwächeres Immunsystem → erhöhte Anfälligkeit für Parasiten (Ichthyo, Velvet) und Bakterien. Regelmäßige Wassertests sind die beste Vorbeugung. Stress-Indikatoren: hektisches Schwimmen, Apathie, Atemprobleme." }, + ] + }, + { + id: "wasserwechsel", icon: "💧", title: "Wasserwechsel — Warum & Wie", color: "#2dd4bf", + summary: "Regelmäßige Wasserwechsel sind die wichtigste Pflegemaßnahme — sie entfernen Schadstoffe, die keine Filterung abbaut.", + content: [ + { head: "Was Wasserwechsel entfernt", text: "NO₃ (Nitrat) akkumuliert ohne Wasserwechsel. Gilvosin (Gelbfärbung). Hormone und Stoffwechselprodukte. Phosphate aus Futtermittelrückständen. Diese Stoffe werden durch keinen biologischen Filter dauerhaft eliminiert." }, + { head: "Empfehlung", text: "25–30% wöchentlich ist der Goldstandard für die meisten Aquarien. Stark besetzte Becken: 30–50%/Woche. Pflanzenaquarien mit wenig Fisch: 20–25%/Woche. Garnelenbecken: vorsichtiger — 10–15% alle 1–2 Wochen, da Garnelen empfindlicher auf Wasserparam-Schwankungen reagieren." }, + { head: "Richtiges Wasser aufbereiten", text: "Immer auf Aquarium-Temperatur temperieren. Chlor aus Leitungswasser entfernen (Wasseraufbereiter oder 24h stehen lassen). Mineralsalze ergänzen wenn nötig (bei weichem Leitungswasser). pH-Wert prüfen. Niemals Leitungswasser direkt ins Aquarium." }, + { head: "Anzeichen, dass ein Wasserwechsel überfällig ist", text: "NO₃ > 50 mg/l. Gelbliche Wasserfärbung. Fadenalgenwachstum ohne erkennbaren Grund. Fische 'stehen' an der Oberfläche. Schlechte Pflanzenfärbung trotz Düngung. pH-Sturz durch erschöpfte KH-Pufferkapazität." }, + ] + }, + ]; + + return ( +
+
+ 🔬 Quellen: Aquasabi · Flowgrow · AquaticPlantCentral · Tropica · The Aquarium Wiki · Dr. Kaspar Horst (CLSF) +
+
+ {sections.map(sec => ( +
+ + {open === sec.id && ( +
+ {sec.content.map((item, i) => ( +
+
{item.head}
+

{item.text}

+
+ ))} +
+ )} +
+ ))} +
+
+ ); +} + +// ── SollwerteTab ──────────────────────────────────────────────── +function SollwerteTab({ bp }) { + return ( +
+
+
🔬 Quellen
+ Aquasabi · Garnelio · Be-Smart-Aquarium · Greenscaping · Aquascape.de · OASE · Flowgrow +
+
+ {PARAM_KEYS.map(k => { + const p = SOLLWERTE[k]; + return ( +
+
+ {p.icon} + {p.label} +
+
+ {p.min} – {p.max} {p.unit || "pH"} +
+
+
+
+
+ ); + })} +
+
+
💡 Zusammenhänge
+ • pH + KH + CO₂ beeinflussen sich gegenseitig
+ • NO₃:PO₄ Verhältnis 16:1 (Redfield-Ratio)
+ • Ca:Mg Verhältnis ca. 3:1 anstreben
+ • Eisen morgens · Phosphat abends
+ • NPK hebt NO₃ + PO₄ + K + Mg gleichzeitig an +
+
+ ); +} + +// ── Underwater header SVG — photorealistic style ──────────────── +function HeaderSVG() { + return ( + + ); +} + +// ── App ───────────────────────────────────────────────────────── +export default function App() { + const bp = useBreakpoint(); + const [auth, setAuth] = useState(null); + const [booting, setBooting] = useState(true); + const [aquariums, setAquariums] = useState([]); + const [aquarium, setAquarium] = useState(null); + const [tab, setTab] = useState("messen"); + const [values, setValues] = useState(() => Object.fromEntries(PARAM_KEYS.map(k => [k, ""]))); + const [note, setNote] = useState(""); + const [entries, setEntries] = useState([]); + const [infoKey, setInfoKey] = useState(null); + const [saved, setSaved] = useState(false); + const [saving, setSaving] = useState(false); + const [showAqModal, setShowAqModal] = useState(false); + const [showAdmin, setShowAdmin] = useState(false); + const [loadingEntries, setLoadingEntries] = useState(false); + + // Boot: restore session + useEffect(() => { + const token = localStorage.getItem(JWT_KEY); + if (!token) { setBooting(false); return; } + apiFetch("/auth/me") + .then(user => { + setAuth({ token, user }); + return apiFetch("/aquariums"); + }) + .then(aqs => { + setAquariums(aqs); + if (aqs.length > 0) setAquarium(aqs[0]); + }) + .catch(() => { localStorage.removeItem(JWT_KEY); }) + .finally(() => setBooting(false)); + }, []); + + // Load entries when aquarium changes + useEffect(() => { + if (!aquarium) { setEntries([]); return; } + setLoadingEntries(true); + apiFetch(`/aquariums/${aquarium.id}/entries`) + .then(setEntries) + .catch(() => setEntries([])) + .finally(() => setLoadingEntries(false)); + setValues(Object.fromEntries(PARAM_KEYS.map(k => [k, ""]))); + setNote(""); + }, [aquarium]); + + const refreshAquariums = useCallback(async () => { + const aqs = await apiFetch("/aquariums"); + setAquariums(aqs); + setAquarium(a => aqs.find(x => x.id === a?.id) || aqs[0] || null); + }, []); + + function handleLogin(data) { + setAuth(data); + apiFetch("/aquariums").then(aqs => { setAquariums(aqs); if (aqs.length > 0) setAquarium(aqs[0]); }); + } + + function handleLogout() { + localStorage.removeItem(JWT_KEY); + setAuth(null); setAquariums([]); setAquarium(null); setEntries([]); + } + + async function handleSaveEntry() { + if (!aquarium || !PARAM_KEYS.some(k => values[k] !== "")) return; + setSaving(true); + try { + const entry = await apiFetch(`/aquariums/${aquarium.id}/entries`, { method: "POST", body: JSON.stringify({ values: { ...values }, note }) }); + setEntries(e => [entry, ...e]); + setSaved(true); setTimeout(() => setSaved(false), 2200); + } catch {} + finally { setSaving(false); } + } + + async function handleDeleteEntry(id) { + if (!aquarium) return; + await apiFetch(`/aquariums/${aquarium.id}/entries/${id}`, { method: "DELETE" }); + setEntries(e => e.filter(x => x.id !== id)); + } + + if (booting) return ( + <> + +
+
🐠
Lade…
+
+ + ); + if (!auth) return <>; + + const TABS = [ + ["messen", "⚗️", "MESSEN"], + ["duenger", "🧪", "DÜNGER"], + ["logbuch", "📋", "LOG"], + ["wissen", "📚", "WISSEN"], + ["info", "📖", "SOLLWERTE"], + ]; + if (auth.user.role === "admin") TABS.push(["admin", "⚙️", "ADMIN"]); + + const containerMaxWidth = bp.isMobile ? 500 : bp.isTablet ? 768 : 1280; + + const SpeichernPanel = ( +
+
+ +