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 = (