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.icon}
{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 (
{param.icon}
{param.label}
onToggleInfo(paramKey)}
aria-label={`Sollwert-Info für ${param.label} ${showInfo ? "ausblenden" : "anzeigen"}`}
aria-expanded={showInfo}
style={{
background: showInfo ? C.primaryLight : "rgba(255,255,255,0.7)",
border: `1.5px solid ${showInfo ? C.primary : '#94a3b8'}`, // ≥3:1 auf cs.bg
cursor: "pointer",
color: showInfo ? C.primary : C.txt3,
fontSize: 11,
borderRadius: 6,
width: 24,
height: 24,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
fontWeight: 700,
}}
>ⓘ
{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 ✓ */}
{param.unit || "pH"}
);
}
// ── 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 ✓) */}
{/* 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 && (
setShowAlt(s => !s)}
style={{
background: C.cardSoft,
border: `1px solid ${C.cardBorder}`,
borderRadius: 8,
color: C.txt3,
fontSize: 11,
padding: "5px 11px",
cursor: "pointer",
fontWeight: 600,
}}
>
{showAlt ? "▲" : "▼"} {rec.alleKandidaten.length - 1} weitere Dünger
{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
Dünger
{["NO3", "PO4", "Fe", "K", "Mg", "Ca"].map(p => (
{p}
))}
{DUENGER_DB.map(d => (
{d.icon} {d.kurz}
{["NO3", "PO4", "Fe", "K", "Mg", "Ca"].map(p => (
{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}
)}
onDelete(entry.id)}
style={{
marginTop: 8,
background: "none",
border: `1px solid ${C.high.border}`,
color: C.high.text,
borderRadius: 7,
padding: "4px 11px",
fontSize: 10,
cursor: "pointer",
fontWeight: 600,
}}
>Löschen
)}
);
}
// ── 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 */}
);
}
// ── 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 && (
{ ev.stopPropagation(); if (window.confirm(`"${aq.name}" wirklich löschen?`)) onDelete(aq.id); }}
style={{ background: "none", border: "none", color: C.high.dot, cursor: "pointer", fontSize: 14, padding: "2px 6px" }} aria-label={`${aq.name} löschen`}>✕
)}
))}
);
}
// ── 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" && (
deleteUser(u.id, u.username)} style={{ background: "none", border: `1px solid ${C.high.border}`, borderRadius: 6, color: C.high.text, fontSize: 11, padding: "3px 8px", cursor: "pointer" }}>Löschen
)}
))}
)}
{/* Create user form */}
);
}
// ── 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 (
setShowEdit(true)} style={{ display: "block", width: "100%", textAlign: "left", background: C.cardSoft, border: `1px dashed ${C.cardBorder}`, borderRadius: 10, padding: "8px 14px", color: C.txt4, fontSize: 11, cursor: "pointer", marginBottom: 10 }}>
⏰ Wasserwechsel-Erinnerung einrichten →
{showEdit && { setShowEdit(false); load(); }} />}
);
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}
Erledigt
setShowEdit(true)} style={{ background: "none", border: `1px solid ${border}`, borderRadius: 8, padding: "4px 8px", color: text, fontSize: 11, cursor: "pointer" }}>⚙
{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 (
);
}
// ── 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 => (
setOpen(o => o === sec.id ? null : sec.id)} style={{ width: "100%", background: "none", border: "none", padding: "14px 16px", cursor: "pointer", textAlign: "left" }}
aria-expanded={open === sec.id} aria-controls={`wissen-${sec.id}`}>
{sec.icon}
{sec.title}
▾
{sec.summary}
{open === sec.id && (
{sec.content.map((item, i) => (
))}
)}
))}
);
}
// ── 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 (
{/* Caustic light filter — organic water-surface shimmer */}
{/* Soft glow blur */}
{/* Light shaft gradient */}
{/* Plant gradients — dark root to semi-transparent tip */}
{/* Substrate gradient */}
{/* Rock gradients */}
{/* Fish gradient */}
{/* ── Caustic light layer (organic shimmer over scene) ──── */}
{/* ── Diffuse light glow areas (simulates light pooling) ── */}
{/* ── Light shafts (blurred, angled) ───────────────────── */}
{/* ── Substrate (gravel band, wavy horizon) ────────────── */}
{/* Substrate surface highlight */}
{/* ── Rocks on substrate ────────────────────────────────── */}
{/* Rock cluster left */}
{/* Rock cluster right */}
{/* Mid rock */}
{/* ── Background plants (blurred, suggest depth) ─────────── */}
{/* Far background tufts */}
{/* ── Foreground plant cluster — left ────────────────────── */}
{/* Tall center stem — filled blade shape */}
{/* Left lean */}
{/* Short foreground blade */}
{/* Right side stem */}
{/* Wide broad leaf */}
{/* Extra narrow stems */}
{/* Moss/algae tuft at base */}
{/* ── Foreground plant cluster — right ───────────────────── */}
{/* ── Mid-ground vegetation tufts ──────────────────────────── */}
{/* ── Fish school — realistic silhouettes ───────────────── */}
{/* Fish 1 — lead fish, largest */}
{/* Body */}
{/* Fork tail */}
{/* Dorsal fin */}
{/* Eye */}
{/* Fish 2 */}
{/* Fish 3 */}
{/* Fish 4 — lower, angled */}
{/* Fish 5 — small straggler */}
{/* ── Floating micro-particles ──────────────────────────────── */}
{[
[180,45,1.8],[310,72,1.2],[490,38,1.6],[560,88,1.0],[740,52,1.5],
[870,34,1.2],[1020,68,1.8],[1100,44,1.1],[1250,60,1.4],[1330,80,1.0],
[420,100,0.9],[640,28,1.3],[780,110,1.0],[1060,30,1.6]
].map(([x,y,r],i) => (
))}
{/* ── Water surface shimmer (top edge) ─────────────────────── */}
);
}
// ── 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 (
<>
>
);
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 = (
📝 Notiz
{saved ? "✓ Gespeichert!" : saving ? "Speichern…" : "💾 Eintrag speichern"}
{ setValues(Object.fromEntries(PARAM_KEYS.map(k => [k, ""]))); setNote(""); setSaved(false); }}
style={{ flex: 1, background: C.card, border: `1.5px solid ${C.cardBorder}`, borderRadius: 14, color: C.txt3, fontSize: 13, fontWeight: 600, height: 52, cursor: "pointer" }}>
✕ Leeren
);
const ParamGrid = (
{PARAM_KEYS.map(k => (
{ setValues(v => ({ ...v, [key]: val })); setSaved(false); }}
showInfo={infoKey === k} onToggleInfo={key => setInfoKey(i => i === key ? null : key)} />
))}
);
const MessenContent = bp.isDesktop
? (
{ParamGrid}
{aquarium && }
{SpeichernPanel}
)
: (
{aquarium &&
}
{ParamGrid}
{SpeichernPanel}
);
return (
{/* ── Sticky Header ──────────────────────────────────── */}
{/* Photo background */}
{/* ── Aquarium selector row ─────────────────────────── */}
setShowAqModal(true)} aria-label="Aquarium wechseln"
style={{ display: "flex", alignItems: "center", gap: 8, background: "rgba(34,211,238,0.08)", border: "1.5px solid rgba(34,211,238,0.22)", borderRadius: 12, padding: bp.isDesktop ? "7px 16px" : "6px 12px", cursor: "pointer", width: "100%", maxWidth: 420, transition: "all 0.2s", backdropFilter: "blur(8px)" }}>
🐠
{aquarium?.name || "Aquarium wählen"}
{aquarium ? `${aquarium.volume} L · ${entries.length} Einträge` : "Tippen zum Auswählen"}
▾
{/* ── Tab bar ──────────────────────────────────────────── */}
{TABS.map(([k, icon, label]) => (
{ setTab(k); if (k === "admin") setShowAdmin(true); }}
style={{ flex: 1, padding: bp.isDesktop ? "8px 6px" : "7px 4px", background: "transparent", border: "none", cursor: "pointer", color: tab === k ? C.primary : "rgba(255,255,255,0.75)", fontSize: bp.isDesktop ? 12 : 10, fontFamily: "'DM Mono', monospace", borderRadius: 999, fontWeight: tab === k ? 800 : 500, whiteSpace: "nowrap" }}>
{icon} {bp.isMobile && label.length > 5 ? label.slice(0, 4) : label}
))}
{/* ── User bar ─────────────────────────────────────────── */}
{/* User info */}
👤
{auth.user.displayName || auth.user.username}
@{auth.user.username}
{auth.user.role === "admin" && (
ADMIN
)}
{/* Action buttons */}
{auth.user.role === "admin" && (
setShowAdmin(true)}
style={{ background: "rgba(34,211,238,0.10)", border: "1px solid rgba(34,211,238,0.28)", borderRadius: 8, color: "#22d3ee", fontSize: 11, fontWeight: 700, padding: "5px 12px", cursor: "pointer", whiteSpace: "nowrap", transition: "all 0.2s" }}>
⚙ Verwaltung
)}
→ Abmelden
{/* ── Tab content ─────────────────────────────────────── */}
{tab === "messen" && MessenContent}
{tab === "duenger" &&
}
{tab === "wissen" &&
}
{tab === "info" &&
}
{tab === "logbuch" && (
{entries.length} Einträge
{loadingEntries ?
Lade…
: entries.length === 0
? (
)
: entries.map(e =>
)
}
)}
{/* ── Modals ──────────────────────────────────────────── */}
{showAqModal && (
{ setAquarium(aq); setTab("messen"); }}
onCreate={async (name, volume) => { await apiFetch("/aquariums", { method: "POST", body: JSON.stringify({ name, volume }) }); await refreshAquariums(); }}
onDelete={async id => { await apiFetch(`/aquariums/${id}`, { method: "DELETE" }); await refreshAquariums(); }}
onClose={() => setShowAqModal(false)} />
)}
{showAdmin && setShowAdmin(false)} />}
{/* First-time: no aquarium */}
{!aquarium && !booting && (
🐠
Willkommen!
Lege dein erstes Aquarium an um loszulegen.
setShowAqModal(true)} className="btn-primary"
style={{ background: `linear-gradient(135deg, ${C.primaryDark}, ${C.primary})`, border: "none", borderRadius: 14, color: "#fff", fontSize: 15, fontWeight: 700, padding: "13px 28px", cursor: "pointer" }}>
+ Erstes Aquarium anlegen
)}
);
}