Files
aqualog/src/App.jsx
Nicolay Braetter a1f6a21828 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)
2026-04-15 09:24:31 +02:00

2054 lines
111 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<span
role="status"
aria-label={`Status: ${l.text}`}
style={{
background: cs.badge,
color: cs.text,
border: `1.5px solid ${cs.border}`,
borderRadius: 999,
padding: "2px 9px",
fontSize: 10,
fontFamily: "'DM Mono', monospace",
fontWeight: 700,
whiteSpace: "nowrap",
letterSpacing: "0.02em",
display: "inline-flex",
alignItems: "center",
gap: 3,
}}
>
<span aria-hidden="true">{l.icon}</span>
{l.text}
</span>
);
}
// ── 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 (
<div style={{ marginTop: 8 }} id={gaugeId}>
{/* Wert-Anzeige über der Gauge (WCAG 1.4.1: Wert auch als Text) */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 4 }}>
<span style={{ fontSize: 10, color: cs.text, fontFamily: "'DM Mono', monospace", fontWeight: 700 }}>
{fmtV} {param.unit || "pH"}
</span>
<span style={{ fontSize: 10, color: C.txt3, fontFamily: "'DM Mono', monospace" }}>
{statusText}
</span>
</div>
{/* Gauge-Track */}
<div
role="img"
aria-label={`${param.label}: ${fmtV} ${param.unit || "pH"}, ${statusText}. Sollbereich: ${min}${max} ${param.unit || "pH"}`}
style={{ height: 10, borderRadius: 5, background: 'rgba(255,255,255,0.10)', position: "relative", overflow: "visible",
border: '1px solid rgba(255,255,255,0.16)',
}}
>
{/* Sollbereich — grün hinterlegt mit Schraffur für Farbblinde */}
<div style={{
position: "absolute",
left: `${minP}%`,
width: `${maxP - minP}%`,
height: "100%",
background: `linear-gradient(135deg, rgba(34,197,94,0.20) 25%, rgba(34,197,94,0.32) 25%, rgba(34,197,94,0.32) 50%, rgba(34,197,94,0.20) 50%, rgba(34,197,94,0.20) 75%, rgba(34,197,94,0.32) 75%)`,
backgroundSize: "8px 8px",
borderRadius: 4,
}} />
{/* Sollbereich-Rahmen */}
<div style={{
position: "absolute",
left: `${minP}%`,
width: `${maxP - minP}%`,
height: "100%",
border: `1.5px solid rgba(34,197,94,0.55)`,
borderRadius: 4,
boxSizing: "border-box",
}} />
{/* Positionsmarker — Dreieck + Punkt (Form ≠ nur Farbe) */}
<div style={{
position: "absolute",
left: `calc(${pct}% - 6px)`,
top: "50%",
transform: "translateY(-50%)",
width: 12,
height: 12,
borderRadius: "50%",
background: cs.dot,
border: "2.5px solid rgba(4,16,30,0.9)",
boxShadow: `0 0 0 1.5px ${cs.dot}`,
transition: "left 0.4s ease",
zIndex: 2,
}} />
</div>
{/* Skala-Beschriftung */}
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 3, fontFamily: "'DM Mono', monospace" }}>
<span style={{ fontSize: 9, color: C.txt4 }}>{param.min}{param.unit}</span>
<span style={{ fontSize: 9, color: '#4ade80', fontWeight: 600 }}> Soll</span>
<span style={{ fontSize: 9, color: C.txt4 }}>{param.max}{param.unit}</span>
</div>
</div>
);
}
// ── 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 (
<div
className={statusClass(status)}
role="group"
aria-label={`${param.label} ${status === "ok" ? "im Sollbereich" : status === "niedrig" ? "Mangelwert" : status === "hoch" ? "Überschuss" : "nicht gemessen"}`}
style={{
background: cs.bg, // ← Subtiler Statushintergrund
border: `1.5px solid ${cs.border}`,
borderLeft: `5px solid ${cs.dot}`, // ← Dicker Farbbalken links
borderRadius: 16,
padding: "14px 14px 12px 14px",
boxShadow: C.shadow,
position: "relative",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
<label htmlFor={inputId} style={{ display: "flex", alignItems: "center", gap: 7, cursor: "pointer" }}>
<span style={{ fontSize: 16 }} aria-hidden="true">{param.icon}</span>
<span style={{ color: C.txt1, fontSize: 13, fontWeight: 700 }}>{param.label}</span>
</label>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<StatusBadge status={status} />
<button
onClick={() => 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,
}}
></button>
</div>
</div>
{showInfo && (
<div
role="note"
style={{
background: C.primaryLight,
borderRadius: 8,
padding: "6px 10px",
color: C.txt2,
fontSize: 11,
marginBottom: 8,
borderLeft: `3px solid ${C.primary}`,
fontFamily: "'DM Mono', monospace",
}}
>
📏 Sollbereich: <strong>{param.min}{param.max} {param.unit || "pH"}</strong>
</div>
)}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input
id={inputId}
type="number"
value={value}
onChange={e => 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 ✓ */}
<span aria-hidden="true" style={{ color: C.txt3, fontSize: 12, whiteSpace: "nowrap", fontFamily: "'DM Mono', monospace", minWidth: 34, fontWeight: 600 }}>
{param.unit || "pH"}
</span>
</div>
<GaugeBar paramKey={paramKey} value={value} param={param} gaugeId={`gauge-${paramKey}`} />
</div>
);
}
// ── 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 (
<div
role="status"
aria-live="polite"
aria-label={`Wasserqualität: ${score}%, ${ok} Parameter OK, ${warn} Abweichungen, ${missing} nicht gemessen`}
style={{
background: C.card, ...GLASS,
borderRadius: 20,
boxShadow: C.shadowMd,
padding: isDesktop ? "20px 24px" : "14px 16px",
marginBottom: 14,
border: `1.5px solid ${scoreCs.border}`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: isDesktop ? 20 : 14, marginBottom: 12 }}>
{/* Score — Text + Farbe + Form (3 Merkmale, WCAG 1.4.1 ✓) */}
<div style={{
textAlign: "center",
flexShrink: 0,
background: scoreCs.badge,
borderRadius: 16,
padding: isDesktop ? "12px 18px" : "8px 12px",
border: `2px solid ${scoreCs.border}`,
}}>
<div style={{
fontSize: isDesktop ? 44 : 34,
fontWeight: 900,
color: scoreCs.text, // #166534/#9a3412/#991b1b — alle ≥6:1 auf badge ✓
fontFamily: "'DM Mono', monospace",
lineHeight: 1,
}}>{score}<span style={{ fontSize: isDesktop ? 22 : 16 }}>%</span></div>
<div style={{ fontSize: 10, color: scoreCs.text, marginTop: 2, fontWeight: 700, opacity: 0.8 }}>Wasserqualität</div>
</div>
{/* Stats */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 5 }}>
<span style={{
background: C.ok.badge,
color: C.ok.text,
borderRadius: 999,
padding: "3px 10px",
fontSize: 11,
fontWeight: 700,
display: "inline-flex",
alignItems: "center",
gap: 5,
alignSelf: "flex-start",
}}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: C.ok.dot, display: "inline-block" }} />
{ok} im Soll
</span>
<span style={{
background: C.low.badge,
color: C.low.text,
borderRadius: 999,
padding: "3px 10px",
fontSize: 11,
fontWeight: 700,
display: "inline-flex",
alignItems: "center",
gap: 5,
alignSelf: "flex-start",
}}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: C.low.dot, display: "inline-block" }} />
{warn} Abweichung{warn !== 1 ? "en" : ""}
</span>
<span style={{
background: C.empty.badge,
color: C.empty.text,
borderRadius: 999,
padding: "3px 10px",
fontSize: 11,
fontWeight: 700,
display: "inline-flex",
alignItems: "center",
gap: 5,
alignSelf: "flex-start",
}}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: C.empty.dot, display: "inline-block" }} />
{missing} offen
</span>
</div>
</div>
{/* Progress bar */}
<div style={{ height: 8, borderRadius: 4, background: 'rgba(255,255,255,0.10)', overflow: "hidden" }}>
<div style={{
height: "100%",
borderRadius: 4,
background: barGradient,
width: `${score}%`,
transition: "width 0.5s ease",
}} />
</div>
</div>
);
}
// ── 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 (
<div
className="rec-card"
style={{
background: C.card, ...GLASS,
borderRadius: 16,
border: `1px solid ${C.cardBorder}`,
borderLeft: `4px solid ${d.farbe}`,
boxShadow: C.shadow,
marginBottom: 12,
overflow: "hidden",
}}
>
<div onClick={() => setShowDetail(s => !s)} style={{ padding: "13px 15px", cursor: "pointer" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 10 }}>
<div>
<div style={{ color: C.txt3, fontSize: 10, fontFamily: "'DM Mono', monospace", marginBottom: 3, fontWeight: 600 }}>
{param.icon} {param.label} Mangel
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ color: C.high.text, fontFamily: "'DM Mono', monospace", fontSize: 14, fontWeight: 700 }}>{fmt(rec.ist)} {param.unit}</span>
<span style={{ color: C.txt4, fontSize: 12 }}></span>
<span style={{ color: C.ok.text, fontFamily: "'DM Mono', monospace", fontSize: 14, fontWeight: 700 }}>{fmt(rec.ziel)} {param.unit}</span>
<span style={{ color: C.txt4, fontSize: 9, background: C.empty.badge, borderRadius: 4, padding: "1px 5px" }}>Sollmitte</span>
</div>
</div>
<span style={{ color: C.txt4, fontSize: 12, transition: "transform 0.2s", display: "inline-block", transform: showDetail ? "rotate(180deg)" : "rotate(0deg)" }}></span>
</div>
{/* Fertilizer pill */}
<div style={{
background: `${d.farbe}10`,
border: `1px solid ${d.farbe}35`,
borderRadius: 12,
padding: "10px 12px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
}}>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 3 }}>
<span style={{ fontSize: 15 }}>{d.icon}</span>
<span style={{ color: C.txt1, fontSize: 12, fontWeight: 700 }}>{d.name}</span>
{emp.blockiert && (
<span style={{ background: C.high.badge, color: C.high.text, fontSize: 9, padding: "1px 6px", borderRadius: 6, fontWeight: 700 }}> Konflikt</span>
)}
</div>
<div style={{ color: C.txt3, fontSize: 10 }}>{d.zweck}</div>
</div>
<div style={{ textAlign: "right", flexShrink: 0 }}>
<div style={{ color: d.farbe, fontSize: 24, fontWeight: 900, fontFamily: "'DM Mono', monospace", lineHeight: 1 }}>{fmt(ml)}<span style={{ fontSize: 13 }}> ml</span></div>
<div style={{ color: C.txt4, fontSize: 9, fontFamily: "'DM Mono', monospace" }}>für {liter} L</div>
</div>
</div>
{emp.seiteneffekte.length > 0 && (
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 4 }}>
{emp.seiteneffekte.map(s => (
<span key={s.par} style={{
background: s.problem ? C.high.badge : C.empty.badge,
border: `1px solid ${s.problem ? C.high.border : C.empty.border}`,
color: s.problem ? C.high.text : C.txt3,
fontSize: 10,
padding: "2px 7px",
borderRadius: 6,
fontFamily: "'DM Mono', monospace",
}}>
{s.problem ? "⚠" : "+"} {SOLLWERTE[s.par]?.label || s.par}: {fmt(s.von)}{fmt(s.nach)} {SOLLWERTE[s.par]?.unit}
</span>
))}
</div>
)}
</div>
{showDetail && (
<div style={{ padding: "0 15px 13px", borderTop: `1px solid ${C.cardBorder}` }}>
{/* Calculation */}
<div style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 10, padding: "10px 12px", marginTop: 10 }}>
<div style={{ color: C.primary, fontSize: 10, fontWeight: 800, marginBottom: 6, fontFamily: "'DM Mono', monospace", letterSpacing: "0.05em" }}>BERECHNUNG</div>
<div style={{ color: C.txt2, fontSize: 11, lineHeight: 1.9, fontFamily: "'DM Mono', monospace" }}>
Fehlende Menge:&nbsp;<span style={{ color: C.accent }}>{fmt(rec.diff)} mg/l {param.unit}</span><br />
1 ml {d.kurz} / 100 L hebt {rec.param} um:&nbsp;<span style={{ color: C.txt2 }}>{d.pro1ml100L[rec.param]} mg/l</span><br />
Formel: {fmt(rec.diff)} ÷ {d.pro1ml100L[rec.param]} × ({liter}÷100)<br />
<span style={{ color: d.farbe, fontWeight: 800 }}>= {fmt(ml)} ml für {liter} L Wasser</span>
</div>
</div>
{/* Note */}
<div style={{
marginTop: 8,
background: C.primaryLight,
borderRadius: 9,
padding: "8px 11px",
color: C.txt2,
fontSize: 11,
borderLeft: `3px solid ${C.primary}`,
lineHeight: 1.6,
}}>
💡 {d.notiz}
</div>
{emp.blockiert && (
<div style={{
marginTop: 8,
background: C.high.bg,
borderRadius: 9,
padding: "8px 11px",
color: C.high.text,
fontSize: 11,
borderLeft: `3px solid ${C.high.dot}`,
lineHeight: 1.6,
}}>
Dieser Dünger würde einen anderen Parameter über den Sollwert heben. Erwäge eine Alternative oder reduziere die Menge.
</div>
)}
{/* Alternatives */}
{rec.alleKandidaten.length > 1 && (
<div style={{ marginTop: 10 }}>
<button
onClick={() => 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
</button>
{showAlt && rec.alleKandidaten.slice(1).map(alt => (
<div key={alt.duenger.id} style={{
marginTop: 6,
background: `${alt.duenger.farbe}0a`,
border: `1px solid ${alt.duenger.farbe}30`,
borderRadius: 10,
padding: "8px 11px",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}>
<div>
<div style={{ color: C.txt1, fontSize: 11, fontWeight: 700 }}>{alt.duenger.icon} {alt.duenger.name}</div>
<div style={{ color: C.txt3, fontSize: 10 }}>{alt.duenger.zweck}</div>
{alt.blockiert && <span style={{ color: C.high.text, fontSize: 9 }}> würde anderen Parameter überschreiten</span>}
{alt.seiteneffekte.filter(s => !s.problem).map(s => (
<span key={s.par} style={{ color: C.txt4, fontSize: 9, marginRight: 5 }}>
+{SOLLWERTE[s.par]?.label}: +{fmt(s.anhebung)} {SOLLWERTE[s.par]?.unit}
</span>
))}
</div>
<div style={{
color: alt.blockiert ? C.high.text : alt.duenger.farbe,
fontFamily: "'DM Mono', monospace",
fontSize: 15,
fontWeight: 800,
flexShrink: 0,
marginLeft: 10,
}}>
{fmt(alt.mlNoetig)} ml
</div>
</div>
))}
</div>
)}
<a href={d.link} target="_blank" rel="noopener noreferrer" style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
marginTop: 10,
color: d.farbe,
fontSize: 11,
textDecoration: "none",
fontWeight: 600,
borderBottom: `1.5px dashed ${d.farbe}60`,
paddingBottom: 1,
}}>
🔗 Aquasabi Shop
</a>
</div>
)}
</div>
);
}
// ── 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 = (
<div>
{/* Info card */}
<div style={{
background: C.primaryLight,
border: `1px solid rgba(34,211,238,0.25)`,
borderRadius: 12,
padding: "11px 14px",
marginBottom: 12,
color: C.txt2,
fontSize: 11,
lineHeight: 1.7,
}}>
<div style={{ color: C.primary, fontWeight: 800, marginBottom: 3, fontSize: 12 }}>🧮 Ist Soll Dünger-Rechner</div>
Messwerte im Tab <b>Messen</b> eingeben App berechnet die exakte Düngermenge für jeden Mangelwert und prüft Seiteneffekte auf andere Parameter.
</div>
{/* Volume */}
<div style={{
background: C.card,
border: `1px solid ${C.cardBorder}`,
borderRadius: 12,
padding: "12px 14px",
marginBottom: 12,
boxShadow: C.shadow,
}}>
<div style={{ color: C.txt2, fontSize: 11, marginBottom: 8, fontWeight: 700 }}>🪣 Netto-Wasservolumen</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<input
type="number"
value={liter}
onChange={e => 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",
}}
/>
<span style={{ color: C.txt3, fontSize: 14, fontFamily: "'DM Mono', monospace", fontWeight: 700 }}>L</span>
</div>
<input
type="range"
min={10} max={500}
value={Math.min(liter, 500)}
onChange={e => setLiter(parseInt(e.target.value))}
style={{ width: "100%", marginTop: 10, accentColor: C.primary }}
/>
</div>
{/* Table */}
<div style={{
background: C.card,
border: `1px solid ${C.cardBorder}`,
borderRadius: 12,
padding: "12px 14px",
boxShadow: C.shadow,
}}>
<div style={{ color: C.txt2, fontSize: 10, fontWeight: 800, marginBottom: 8, letterSpacing: "0.04em" }}>📊 Nährstoffgehalt pro 1 ml / 100 L</div>
<div style={{ overflowX: "auto", WebkitOverflowScrolling: "touch" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 10, fontFamily: "'DM Mono', monospace", minWidth: 320 }}>
<thead>
<tr style={{ borderBottom: `2px solid ${C.cardBorder}` }}>
<th style={{ textAlign: "left", color: C.txt3, padding: "4px 5px", fontWeight: 700 }}>Dünger</th>
{["NO3", "PO4", "Fe", "K", "Mg", "Ca"].map(p => (
<th key={p} style={{ textAlign: "right", color: C.txt3, padding: "4px 5px", fontWeight: 700 }}>{p}</th>
))}
</tr>
</thead>
<tbody>
{DUENGER_DB.map(d => (
<tr key={d.id} style={{ borderBottom: `1px solid ${C.cardBorder}` }}>
<td style={{ color: d.farbe, padding: "5px 5px", whiteSpace: "nowrap", fontWeight: 700 }}>{d.icon} {d.kurz}</td>
{["NO3", "PO4", "Fe", "K", "Mg", "Ca"].map(p => (
<td key={p} style={{ textAlign: "right", color: d.pro1ml100L[p] ? C.txt2 : C.txt4, padding: "5px 5px" }}>
{d.pro1ml100L[p] || ""}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div style={{ color: C.txt4, fontSize: 9, marginTop: 6 }}>Werte in mg/l · Quellen: Aquasabi / Zoobox / Garnelenhaus / Aqua-Rebell.de</div>
</div>
</div>
);
const rechtsPanel = (
<div>
{ohneWerte && (
<div style={{
textAlign: "center",
padding: "50px 20px",
color: C.txt4,
background: C.card,
borderRadius: 16,
border: `1px solid ${C.cardBorder}`,
boxShadow: C.shadow,
}}>
<div style={{ fontSize: 40, marginBottom: 10 }}>💧</div>
<div style={{ color: C.txt2, fontWeight: 600, marginBottom: 4 }}>Noch keine Nährstoffwerte gemessen.</div>
<span style={{ fontSize: 11, color: C.txt3 }}>Trage NO₃, PO₄, Fe, K, Mg oder Ca im Tab Messen" ein.</span>
</div>
)}
{emp.length > 0 && (
<>
<div style={{
color: C.low.text,
fontSize: 11,
fontFamily: "'DM Mono', monospace",
marginBottom: 10,
fontWeight: 700,
background: C.low.badge,
display: "inline-block",
borderRadius: 6,
padding: "3px 10px",
}}>↓ {emp.length} Mangelwert{emp.length > 1 ? "e" : ""} erkannt</div>
{emp.map(rec => <EmpfehlungsKarte key={rec.param} rec={rec} liter={liter} />)}
</>
)}
{!ohneWerte && emp.length === 0 && hoch.length === 0 && (
<div style={{
background: C.ok.bg,
border: `1px solid ${C.ok.border}`,
borderRadius: 14,
padding: "18px",
textAlign: "center",
color: C.ok.text,
fontSize: 13,
fontWeight: 700,
}}>
✓ Alle Nährstoffe im Sollbereich!<br />
<span style={{ fontSize: 11, fontWeight: 400, color: C.ok.text, opacity: 0.8 }}>Kein Dünger nötig.</span>
</div>
)}
{hoch.length > 0 && (
<div style={{
marginTop: 10,
background: C.high.bg,
border: `1px solid ${C.high.border}`,
borderRadius: 14,
padding: "12px 14px",
}}>
<div style={{ color: C.high.text, fontSize: 11, fontWeight: 800, marginBottom: 6 }}>↑ Überschuss Teilwasserwechsel empfohlen</div>
{hoch.map(k => (
<div key={k} style={{ color: C.txt2, fontSize: 11, marginBottom: 3, fontFamily: "'DM Mono', monospace" }}>
{SOLLWERTE[k].icon} {SOLLWERTE[k].label}: {values[k]} {SOLLWERTE[k].unit} → max. {SOLLWERTE[k].max}
</div>
))}
<div style={{ color: C.txt3, fontSize: 10, marginTop: 6 }}>2550% Wasserwechsel reduziert Überschüsse.</div>
</div>
)}
</div>
);
if (bp.isDesktop) {
return (
<div style={{ display: "grid", gridTemplateColumns: "340px 1fr", gap: 20, alignItems: "flex-start" }}>
{linksPanel}
{rechtsPanel}
</div>
);
}
return <div>{linksPanel}{rechtsPanel}</div>;
}
// ── 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 (
<div style={{
background: C.card, ...GLASS,
border: `1px solid ${C.cardBorder}`,
borderRadius: 14,
marginBottom: 8,
overflow: "hidden",
boxShadow: C.shadow,
transition: "box-shadow 0.2s, transform 0.18s",
}}>
<div onClick={() => setExp(e => !e)} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "11px 14px", cursor: "pointer" }}>
<div>
<div style={{ color: C.txt1, fontSize: 13, fontWeight: 700 }}>
📅 {new Date(entry.date).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}
</div>
{entry.note && <div style={{ color: C.txt3, fontSize: 10, marginTop: 2 }}>{entry.note.slice(0, 50)}{entry.note.length > 50 ? "" : ""}</div>}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{
background: C.ok.badge,
color: C.ok.text,
fontSize: 10,
fontFamily: "'DM Mono', monospace",
fontWeight: 700,
borderRadius: 999,
padding: "2px 9px",
}}>
{okCount}/{filledCount} OK
</span>
<span style={{ color: C.txt4, fontSize: 12, transition: "transform 0.2s", display: "inline-block", transform: exp ? "rotate(180deg)" : "rotate(0deg)" }}>▼</span>
</div>
</div>
{exp && (
<div style={{ padding: "0 14px 12px", borderTop: `1px solid ${C.cardBorder}` }}>
<div style={{
display: "grid",
gridTemplateColumns: isDesktop ? "repeat(5, 1fr)" : "repeat(3, 1fr)",
gap: 5,
marginTop: 10,
}}>
{PARAM_KEYS.map(k => {
const v = entry.values[k];
if (!v) return null;
const st = getStatus(k, v);
const cs = statusToC(st);
return (
<div key={k} style={{
background: cs.bg,
borderRadius: 8,
padding: "5px 8px",
fontSize: 10,
color: cs.text,
fontFamily: "'DM Mono', monospace",
border: `1px solid ${cs.border}`,
fontWeight: 600,
}}>
{SOLLWERTE[k].icon} {k}: {v} <span style={{ color: C.txt4, fontWeight: 400 }}>{SOLLWERTE[k].unit}</span>
</div>
);
})}
</div>
{entry.note && (
<div style={{ marginTop: 8, color: C.txt2, fontSize: 11, borderTop: `1px solid ${C.cardBorder}`, paddingTop: 8 }}>
📝 {entry.note}
</div>
)}
<button
onClick={() => 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</button>
</div>
)}
</div>
);
}
// ── 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 (
<div style={{ minHeight: "100vh", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", padding: 20, background: "radial-gradient(ellipse at 50% 0%, #0a2040 0%, #04101e 60%, #020c18 100%)" }}>
{/* Header image card */}
<div style={{ width: "100%", maxWidth: 420, borderRadius: "24px 24px 0 0", overflow: "hidden", position: "relative", height: 160 }}>
<div style={{ position: "absolute", inset: 0, backgroundImage: "url('/aquascape.jpg')", backgroundSize: "cover", backgroundPosition: "center 40%" }} />
<div style={{ position: "absolute", inset: 0, background: "linear-gradient(180deg, rgba(5,37,64,0.5) 0%, rgba(5,37,64,0.82) 100%)" }} />
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 8 }}>
<span style={{ fontSize: 42 }}>🐠</span>
<div style={{ color: "#fff", fontSize: 22, fontWeight: 800, letterSpacing: "0.01em" }}>Aquarium Logbuch</div>
<div style={{ color: "rgba(255,255,255,0.6)", fontSize: 11, fontFamily: "'DM Mono', monospace" }}>Ist→Soll · Dünger-Rechner · Logbuch</div>
</div>
</div>
{/* Login form */}
<form onSubmit={handleSubmit} style={{ width: "100%", maxWidth: 420, background: 'rgba(6,18,38,0.88)', ...GLASS, borderRadius: "0 0 24px 24px", border: '1px solid rgba(34,211,238,0.15)', borderTop: 'none', padding: "28px 28px 32px", boxShadow: C.shadowLg }}>
<div style={{ marginBottom: 16 }}>
<label htmlFor="login-user" style={{ display: "block", color: C.txt2, fontSize: 12, fontWeight: 700, marginBottom: 6 }}>Benutzername</label>
<input id="login-user" type="text" autoComplete="username" value={username} onChange={e => 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" }} />
</div>
<div style={{ marginBottom: 20 }}>
<label htmlFor="login-pw" style={{ display: "block", color: C.txt2, fontSize: 12, fontWeight: 700, marginBottom: 6 }}>Passwort</label>
<input id="login-pw" type="password" autoComplete="current-password" value={password} onChange={e => 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" }} />
</div>
{error && <div role="alert" style={{ background: C.high.bg, border: `1px solid ${C.high.border}`, borderRadius: 8, padding: "8px 12px", color: C.high.text, fontSize: 12, marginBottom: 14 }}>{error}</div>}
<button type="submit" disabled={loading} className="btn-primary"
style={{ width: "100%", background: `linear-gradient(135deg, ${C.primaryDark}, ${C.primary})`, border: "none", borderRadius: 14, color: "#041018", fontSize: 16, fontWeight: 800, height: 52, cursor: loading ? "wait" : "pointer", boxShadow: `0 4px 20px rgba(34,211,238,0.35)` }}>
{loading ? "Anmelden" : "Anmelden "}
</button>
</form>
</div>
);
}
// ── 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 (
<div className="modal-overlay" onClick={onClose}>
<div onClick={e => 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" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 18 }}>
<span style={{ color: C.txt1, fontSize: 17, fontWeight: 800 }}>🐟 Aquarien</span>
<button onClick={onClose} style={{ background: "none", border: "none", color: C.txt3, fontSize: 20, cursor: "pointer", lineHeight: 1 }}>✕</button>
</div>
{aquariums.map(aq => (
<div key={aq.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", borderRadius: 12, marginBottom: 6, background: current?.id === aq.id ? C.primaryLight : C.cardSoft, border: `1.5px solid ${current?.id === aq.id ? C.primary : C.cardBorder}`, cursor: "pointer" }}
onClick={() => { onSelect(aq); onClose(); }}>
<span style={{ flex: 1, color: C.txt1, fontWeight: current?.id === aq.id ? 700 : 500, fontSize: 13 }}>🐠 {aq.name}</span>
<span style={{ color: C.txt4, fontSize: 11, fontFamily: "'DM Mono', monospace" }}>{aq.volume} L</span>
{current?.id !== aq.id && (
<button onClick={ev => { 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`}>✕</button>
)}
</div>
))}
<form onSubmit={handleCreate} style={{ marginTop: 18, borderTop: `1px solid ${C.cardBorder}`, paddingTop: 16 }}>
<div style={{ color: C.txt2, fontSize: 12, fontWeight: 700, marginBottom: 10 }}>+ Neues Aquarium</div>
<div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
<input type="text" placeholder="Name" value={newName} onChange={e => 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" }} />
<input type="number" placeholder="Liter" value={newVol} onChange={e => 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" }} />
</div>
{err && <div style={{ color: C.high.text, fontSize: 11, marginBottom: 8 }}>{err}</div>}
<button type="submit" disabled={creating} style={{ width: "100%", background: `linear-gradient(135deg, ${C.primaryDark}, ${C.primary})`, border: "none", borderRadius: 10, color: "#fff", fontSize: 13, fontWeight: 700, padding: "9px", cursor: "pointer" }}>
{creating ? "Anlegen" : "Aquarium anlegen"}
</button>
</form>
</div>
</div>
);
}
// ── 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 (
<div className="modal-overlay" onClick={onClose}>
<div onClick={e => 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" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 }}>
<span style={{ color: C.txt1, fontSize: 17, fontWeight: 800 }}>⚙️ Benutzerverwaltung</span>
<button onClick={onClose} style={{ background: "none", border: "none", color: C.txt3, fontSize: 20, cursor: "pointer" }}>✕</button>
</div>
{/* User list */}
{loading ? <div style={{ color: C.txt3, textAlign: "center", padding: 20 }}>Lade…</div> : (
<div style={{ marginBottom: 20 }}>
{users.map(u => (
<div key={u.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 12px", borderRadius: 10, marginBottom: 6, background: C.cardSoft, border: `1px solid ${C.cardBorder}` }}>
<div style={{ flex: 1 }}>
<div style={{ color: C.txt1, fontSize: 13, fontWeight: 700 }}>{u.displayName || u.username}</div>
<div style={{ color: C.txt4, fontSize: 11, fontFamily: "'DM Mono', monospace" }}>@{u.username} · {u.role}</div>
</div>
<span style={{ background: u.role === "admin" ? C.primaryLight : C.ok.badge, color: u.role === "admin" ? C.primary : C.ok.text, fontSize: 10, padding: "2px 8px", borderRadius: 999, fontWeight: 700 }}>{u.role}</span>
{u.id !== "admin-1" && (
<button onClick={() => 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</button>
)}
</div>
))}
</div>
)}
{/* Create user form */}
<form onSubmit={createUser} style={{ borderTop: `1px solid ${C.cardBorder}`, paddingTop: 16 }}>
<div style={{ color: C.txt2, fontSize: 13, fontWeight: 700, marginBottom: 12 }}>+ Neuen Benutzer anlegen</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, marginBottom: 8 }}>
<div>
<label style={{ display: "block", color: C.txt3, fontSize: 11, fontWeight: 600, marginBottom: 4 }}>Benutzername</label>
<input type="text" value={newUser.username} onChange={e => setNewUser(u => ({ ...u, username: e.target.value }))} style={inputStyle} />
</div>
<div>
<label style={{ display: "block", color: C.txt3, fontSize: 11, fontWeight: 600, marginBottom: 4 }}>Passwort</label>
<input type="password" value={newUser.password} onChange={e => setNewUser(u => ({ ...u, password: e.target.value }))} style={inputStyle} />
</div>
<div>
<label style={{ display: "block", color: C.txt3, fontSize: 11, fontWeight: 600, marginBottom: 4 }}>Anzeigename</label>
<input type="text" value={newUser.displayName} onChange={e => setNewUser(u => ({ ...u, displayName: e.target.value }))} style={inputStyle} />
</div>
<div>
<label style={{ display: "block", color: C.txt3, fontSize: 11, fontWeight: 600, marginBottom: 4 }}>Rolle</label>
<select value={newUser.role} onChange={e => setNewUser(u => ({ ...u, role: e.target.value }))}
style={{ ...inputStyle, cursor: "pointer" }}>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</div>
</div>
{err && <div role="alert" style={{ color: C.high.text, fontSize: 12, marginBottom: 8, background: C.high.bg, padding: "6px 10px", borderRadius: 6 }}>{err}</div>}
{ok && <div role="status" style={{ color: C.ok.text, fontSize: 12, marginBottom: 8, background: C.ok.bg, padding: "6px 10px", borderRadius: 6 }}>{ok}</div>}
<button type="submit" style={{ width: "100%", background: `linear-gradient(135deg, ${C.primaryDark}, ${C.primary})`, border: "none", borderRadius: 10, color: "#fff", fontSize: 13, fontWeight: 700, padding: "10px", cursor: "pointer" }}>
Benutzer anlegen
</button>
</form>
</div>
</div>
);
}
// ── 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 (
<button onClick={() => 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 && <ReminderEditModal aquariumId={aquariumId} onClose={() => { setShowEdit(false); load(); }} />}
</button>
);
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 (
<div role="status" aria-live="polite" style={{ background: bg, border: `1.5px solid ${border}`, borderRadius: 12, padding: "10px 14px", marginBottom: 12, display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 18 }}>{due ? "🚨" : soon ? "" : ""}</span>
<span style={{ flex: 1, color: text, fontSize: 12, fontWeight: 600 }}>{msg}</span>
<button onClick={markDone} style={{ background: text, color: "#fff", border: "none", borderRadius: 8, padding: "4px 10px", fontSize: 11, fontWeight: 700, cursor: "pointer" }}>Erledigt</button>
<button onClick={() => setShowEdit(true)} style={{ background: "none", border: `1px solid ${border}`, borderRadius: 8, padding: "4px 8px", color: text, fontSize: 11, cursor: "pointer" }}>⚙</button>
{showEdit && <ReminderEditModal aquariumId={aquariumId} reminder={reminder} onClose={() => { setShowEdit(false); load(); }} />}
</div>
);
}
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 (
<div className="modal-overlay" onClick={onClose}>
<form onSubmit={save} onClick={e => 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 }}>
<div style={{ color: C.txt1, fontSize: 15, fontWeight: 800, marginBottom: 16 }}>⏰ Wasserwechsel-Erinnerung</div>
<label style={{ display: "block", color: C.txt2, fontSize: 12, fontWeight: 600, marginBottom: 4 }}>Intervall (Tage)</label>
<input type="number" min={1} max={90} value={interval_} onChange={e => 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 }} />
<label style={{ display: "block", color: C.txt2, fontSize: 12, fontWeight: 600, marginBottom: 4 }}>Letzter Wasserwechsel</label>
<input type="date" value={lastChange} onChange={e => 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 }} />
<div style={{ display: "flex", gap: 8 }}>
<button type="submit" style={{ flex: 2, background: `linear-gradient(135deg, ${C.primaryDark}, ${C.primary})`, border: "none", borderRadius: 10, color: "#fff", fontSize: 13, fontWeight: 700, padding: "10px", cursor: "pointer" }}>Speichern</button>
{reminder?.enabled && <button type="button" onClick={disable} style={{ flex: 1, background: "none", border: `1px solid ${C.cardBorder}`, borderRadius: 10, color: C.txt3, fontSize: 12, cursor: "pointer" }}>Deaktivieren</button>}
<button type="button" onClick={onClose} style={{ flex: 1, background: "none", border: `1px solid ${C.cardBorder}`, borderRadius: 10, color: C.txt3, fontSize: 12, cursor: "pointer" }}>Abbrechen</button>
</div>
</form>
</div>
);
}
// ── 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 (48 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: 1025 mg/l." },
{ head: "Einfahrzeit", text: "Ein neues Aquarium braucht 48 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: 46 °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 2030 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,57,5. Südamerika (Diskus, Tetras): pH 6,06,8. Afrika (Malawisee): pH 7,88,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: 1025 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,10,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: 515 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 68 °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,050,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: 2428 °C. Kaltwater (Goldfish): 1522 °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: 150400 μS/cm. Afrikanische Cichliden: 400800 μS/cm. Garnelen (Caridina): 100200 μS/cm, (Neocaridina): 250450 μ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: "2530% wöchentlich ist der Goldstandard für die meisten Aquarien. Stark besetzte Becken: 3050%/Woche. Pflanzenaquarien mit wenig Fisch: 2025%/Woche. Garnelenbecken: vorsichtiger 1015% alle 12 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 (
<div>
<div style={{ background: C.primaryLight, border: `1px solid rgba(34,211,238,0.20)`, borderRadius: 12, padding: "10px 14px", marginBottom: 16, color: C.txt2, fontSize: 11, lineHeight: 1.7 }}>
<strong>🔬 Quellen:</strong> Aquasabi · Flowgrow · AquaticPlantCentral · Tropica · The Aquarium Wiki · Dr. Kaspar Horst (CLSF)
</div>
<div style={{ display: "grid", gridTemplateColumns: bp.isMobile ? "1fr" : bp.isTablet ? "1fr 1fr" : "repeat(3, 1fr)", gap: 10 }}>
{sections.map(sec => (
<div key={sec.id} style={{ background: C.card, ...GLASS, border: `1.5px solid ${open === sec.id ? sec.color + "60" : C.cardBorder}`, borderLeft: `4px solid ${sec.color}`, borderRadius: 14, overflow: "hidden", boxShadow: C.shadow, transition: "border-color 0.2s, box-shadow 0.2s" }}>
<button onClick={() => 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}`}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 5 }}>
<span style={{ fontSize: 18 }} aria-hidden="true">{sec.icon}</span>
<span style={{ color: C.txt1, fontSize: 13, fontWeight: 700 }}>{sec.title}</span>
<span style={{ marginLeft: "auto", color: C.txt4, fontSize: 12, transition: "transform 0.2s", display: "inline-block", transform: open === sec.id ? "rotate(180deg)" : "none" }}></span>
</div>
<p style={{ color: C.txt3, fontSize: 11, lineHeight: 1.6, margin: 0 }}>{sec.summary}</p>
</button>
{open === sec.id && (
<div id={`wissen-${sec.id}`} style={{ borderTop: `1px solid ${C.cardBorder}`, padding: "12px 16px 14px" }}>
{sec.content.map((item, i) => (
<div key={i} style={{ marginBottom: i < sec.content.length - 1 ? 12 : 0 }}>
<div style={{ color: sec.color, fontSize: 11, fontWeight: 800, marginBottom: 3 }}>{item.head}</div>
<p style={{ color: C.txt2, fontSize: 11, lineHeight: 1.75, margin: 0 }}>{item.text}</p>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
);
}
// ── SollwerteTab ────────────────────────────────────────────────
function SollwerteTab({ bp }) {
return (
<div>
<div style={{ background: C.primaryLight, border: `1px solid rgba(34,211,238,0.20)`, borderRadius: 12, padding: "10px 14px", marginBottom: 12, color: C.txt2, fontSize: 11, lineHeight: 1.7 }}>
<div style={{ color: C.primary, fontWeight: 800, marginBottom: 3 }}>🔬 Quellen</div>
Aquasabi · Garnelio · Be-Smart-Aquarium · Greenscaping · Aquascape.de · OASE · Flowgrow
</div>
<div style={{ display: "grid", gridTemplateColumns: bp.isMobile ? "1fr" : bp.isTablet ? "1fr 1fr" : "repeat(3, 1fr)", gap: 9, marginBottom: 12 }}>
{PARAM_KEYS.map(k => {
const p = SOLLWERTE[k];
return (
<div key={k} style={{ background: C.card, ...GLASS, border: `1px solid ${C.cardBorder}`, borderRadius: 12, padding: "12px 14px", boxShadow: C.shadow }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 5 }}>
<span style={{ fontSize: 16 }}>{p.icon}</span>
<span style={{ fontWeight: 700, color: C.txt1, fontSize: 12 }}>{p.label}</span>
</div>
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 14, color: C.primary, fontWeight: 800 }}>
{p.min} {p.max} <span style={{ fontSize: 11, fontWeight: 400, color: C.txt3 }}>{p.unit || "pH"}</span>
</div>
<div style={{ marginTop: 6, height: 4, borderRadius: 2, background: "rgba(255,255,255,0.10)", position: "relative", overflow: "hidden" }}>
<div style={{ position: "absolute", left: "20%", width: "60%", height: "100%", background: `linear-gradient(90deg, ${C.secondary}, #34d399)`, borderRadius: 2 }} />
</div>
</div>
);
})}
</div>
<div style={{ background: C.secondaryLight, border: `1px solid rgba(45,212,191,0.25)`, borderRadius: 12, padding: "11px 14px", fontSize: 11, lineHeight: 1.9, color: C.txt2 }}>
<div style={{ color: C.secondary, fontWeight: 800, marginBottom: 4, fontSize: 12 }}>💡 Zusammenhänge</div>
pH + KH + CO₂ beeinflussen sich gegenseitig<br />
NO₃:PO₄ Verhältnis 16:1 (Redfield-Ratio)<br />
Ca:Mg Verhältnis ca. 3:1 anstreben<br />
Eisen morgens · Phosphat abends<br />
NPK hebt NO₃ + PO₄ + K + Mg gleichzeitig an
</div>
</div>
);
}
// ── Underwater header SVG — photorealistic style ────────────────
function HeaderSVG() {
return (
<svg aria-hidden="true" focusable="false" viewBox="0 0 1400 160"
preserveAspectRatio="xMidYMax slice"
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none", userSelect: "none" }}
xmlns="http://www.w3.org/2000/svg">
<defs>
{/* Caustic light filter — organic water-surface shimmer */}
<filter id="hdr-caustic" x="-5%" y="-5%" width="110%" height="110%">
<feTurbulence type="fractalNoise" baseFrequency="0.035 0.018" numOctaves="5" seed="12" result="noise"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.7 0 0 0 0 0.85 0 0 0 0 1 0 0 0 14 -7" in="noise" result="causticMask"/>
<feComposite in="SourceGraphic" in2="causticMask" operator="in"/>
</filter>
{/* Soft glow blur */}
<filter id="hdr-glow"><feGaussianBlur stdDeviation="6"/></filter>
<filter id="hdr-blur3"><feGaussianBlur stdDeviation="3"/></filter>
<filter id="hdr-blur8"><feGaussianBlur stdDeviation="8"/></filter>
{/* Light shaft gradient */}
<linearGradient id="hdr-shaft" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgba(180,220,255,0.18)"/>
<stop offset="70%" stopColor="rgba(180,220,255,0.06)"/>
<stop offset="100%" stopColor="rgba(180,220,255,0)"/>
</linearGradient>
{/* Plant gradients — dark root to semi-transparent tip */}
<linearGradient id="hdr-plant-a" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor="#112b18" stopOpacity="1"/>
<stop offset="40%" stopColor="#1e4a28" stopOpacity="0.85"/>
<stop offset="100%" stopColor="#2a6535" stopOpacity="0.35"/>
</linearGradient>
<linearGradient id="hdr-plant-b" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor="#0e2414" stopOpacity="1"/>
<stop offset="50%" stopColor="#1a4020" stopOpacity="0.75"/>
<stop offset="100%" stopColor="#265230" stopOpacity="0.25"/>
</linearGradient>
<linearGradient id="hdr-plant-c" x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor="#162e1c" stopOpacity="1"/>
<stop offset="60%" stopColor="#254838" stopOpacity="0.7"/>
<stop offset="100%" stopColor="#306040" stopOpacity="0.2"/>
</linearGradient>
{/* Substrate gradient */}
<linearGradient id="hdr-substrate" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#1a1810"/>
<stop offset="100%" stopColor="#0e0e08"/>
</linearGradient>
{/* Rock gradients */}
<radialGradient id="hdr-rock1" cx="40%" cy="35%" r="60%">
<stop offset="0%" stopColor="#2a2e38"/>
<stop offset="100%" stopColor="#12151c"/>
</radialGradient>
<radialGradient id="hdr-rock2" cx="55%" cy="30%" r="65%">
<stop offset="0%" stopColor="#242830"/>
<stop offset="100%" stopColor="#0f1218"/>
</radialGradient>
{/* Fish gradient */}
<linearGradient id="hdr-fish" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#7a8898" stopOpacity="0.55"/>
<stop offset="45%" stopColor="#a8bcc8" stopOpacity="0.45"/>
<stop offset="100%" stopColor="#8a9cac" stopOpacity="0.35"/>
</linearGradient>
</defs>
{/* ── Caustic light layer (organic shimmer over scene) ──── */}
<rect x="0" y="0" width="1400" height="160" fill="rgba(160,210,255,0.22)" filter="url(#hdr-caustic)"/>
{/* ── Diffuse light glow areas (simulates light pooling) ── */}
<g filter="url(#hdr-glow)" opacity="0.55">
<ellipse cx="350" cy="50" rx="200" ry="55" fill="rgba(190,225,255,0.09)"/>
<ellipse cx="820" cy="42" rx="240" ry="60" fill="rgba(190,225,255,0.1)"/>
<ellipse cx="1180" cy="55" rx="180" ry="50" fill="rgba(190,225,255,0.08)"/>
</g>
{/* ── Light shafts (blurred, angled) ───────────────────── */}
<g filter="url(#hdr-blur8)">
<polygon points="190,0 220,160 140,160" fill="url(#hdr-shaft)" opacity="0.9"/>
<polygon points="500,0 540,160 440,160" fill="url(#hdr-shaft)" opacity="1"/>
<polygon points="840,0 890,160 780,160" fill="url(#hdr-shaft)" opacity="0.85"/>
<polygon points="1150,0 1185,160 1095,160" fill="url(#hdr-shaft)" opacity="0.9"/>
<polygon points="1340,0 1370,160 1300,160" fill="url(#hdr-shaft)" opacity="0.7"/>
</g>
{/* ── Substrate (gravel band, wavy horizon) ────────────── */}
<path d="M0,148 C120,143 240,150 380,146 C520,142 640,149 780,145
C920,141 1040,148 1180,144 C1300,141 1360,147 1400,144
L1400,160 L0,160 Z"
fill="url(#hdr-substrate)" opacity="0.95"/>
{/* Substrate surface highlight */}
<path d="M0,148 C120,143 240,150 380,146 C520,142 640,149 780,145
C920,141 1040,148 1180,144 C1300,141 1360,147 1400,144"
stroke="rgba(100,130,100,0.18)" strokeWidth="1.5" fill="none"/>
{/* ── Rocks on substrate ────────────────────────────────── */}
<g opacity="0.85">
{/* Rock cluster left */}
<path d="M55,160 C48,152 38,147 45,142 C50,138 62,139 72,143 C80,147 82,155 78,160 Z"
fill="url(#hdr-rock1)"/>
<path d="M72,160 C68,154 65,149 72,145 C77,142 88,143 94,148 C99,153 97,159 92,160 Z"
fill="url(#hdr-rock2)"/>
<path d="M44,160 C40,156 33,151 38,146 C43,143 52,145 56,150 C59,154 57,160 53,160 Z"
fill="url(#hdr-rock2)" opacity="0.8"/>
{/* Rock cluster right */}
<path d="M1305,160 C1298,152 1290,147 1296,142 C1301,138 1313,140 1322,144 C1330,148 1331,156 1327,160 Z"
fill="url(#hdr-rock1)"/>
<path d="M1326,160 C1322,154 1318,149 1326,145 C1332,142 1342,144 1347,149 C1351,154 1349,160 1344,160 Z"
fill="url(#hdr-rock2)"/>
{/* Mid rock */}
<path d="M680,160 C674,155 668,150 674,146 C679,143 690,145 697,149 C703,153 701,159 696,160 Z"
fill="url(#hdr-rock1)" opacity="0.7"/>
</g>
{/* ── Background plants (blurred, suggest depth) ─────────── */}
<g filter="url(#hdr-blur3)" opacity="0.6">
{/* Far background tufts */}
<path d="M220,160 C218,148 214,136 219,122 C222,112 226,104 224,92 C229,108 231,118 229,130 C232,142 230,154 228,160 Z" fill="url(#hdr-plant-b)"/>
<path d="M240,160 C242,146 238,130 242,116 C245,105 249,96 246,82 C252,98 252,110 250,124 C252,138 249,152 247,160 Z" fill="url(#hdr-plant-b)"/>
<path d="M950,160 C948,147 944,133 948,120 C951,109 955,101 952,88 C958,104 958,116 956,130 C958,143 955,155 953,160 Z" fill="url(#hdr-plant-b)"/>
<path d="M968,160 C970,147 966,131 970,118 C973,107 977,98 974,85 C980,101 980,114 978,128 C980,141 977,153 975,160 Z" fill="url(#hdr-plant-b)"/>
</g>
{/* ── Foreground plant cluster — left ────────────────────── */}
<g>
{/* Tall center stem — filled blade shape */}
<path d="M18,160 C14,148 10,132 14,112 C16,97 20,84 17,66 C17,52 19,42 20,30
C22,42 23,53 22,67 C24,86 26,99 24,114 C26,133 24,149 22,160 Z"
fill="url(#hdr-plant-a)"/>
{/* Left lean */}
<path d="M30,160 C26,147 22,130 25,112 C27,98 30,87 27,72 C27,60 29,50 30,38
C33,50 34,61 32,73 C35,90 35,101 33,115 C35,132 34,148 32,160 Z"
fill="url(#hdr-plant-a)" opacity="0.9"/>
{/* Short foreground blade */}
<path d="M10,160 C7,151 4,140 7,128 C9,118 12,110 10,100
C13,111 14,120 13,130 C14,141 13,153 11,160 Z"
fill="url(#hdr-plant-c)" opacity="0.85"/>
{/* Right side stem */}
<path d="M44,160 C41,148 38,134 41,117 C43,104 47,93 44,79 C44,68 46,59 47,48
C50,60 51,70 49,80 C52,96 51,107 49,120 C51,136 50,150 48,160 Z"
fill="url(#hdr-plant-b)"/>
{/* Wide broad leaf */}
<path d="M58,160 C53,149 48,135 52,118 C55,105 60,95 56,80
C62,96 63,109 60,122 C63,138 62,152 60,160 Z"
fill="url(#hdr-plant-a)" opacity="0.75"/>
{/* Extra narrow stems */}
<path d="M70,160 C68,150 66,138 68,124 C70,113 73,104 71,92
C74,104 75,114 73,125 C75,139 74,152 72,160 Z"
fill="url(#hdr-plant-b)" opacity="0.7"/>
{/* Moss/algae tuft at base */}
<ellipse cx="35" cy="157" rx="28" ry="5" fill="#1a3820" opacity="0.6"/>
</g>
{/* ── Foreground plant cluster — right ───────────────────── */}
<g>
<path d="M1382,160 C1386,148 1390,132 1386,112 C1384,97 1380,84 1383,66
C1383,52 1381,42 1380,30
C1378,42 1377,53 1378,67 C1376,86 1374,99 1376,114
C1374,133 1376,149 1378,160 Z"
fill="url(#hdr-plant-a)"/>
<path d="M1370,160 C1374,147 1378,130 1375,112 C1373,98 1370,87 1373,72
C1373,60 1371,50 1370,38
C1367,50 1366,61 1368,73 C1365,90 1365,101 1367,115
C1365,132 1366,148 1368,160 Z"
fill="url(#hdr-plant-a)" opacity="0.9"/>
<path d="M1390,160 C1393,151 1396,140 1393,128 C1391,118 1388,110 1390,100
C1387,111 1386,120 1387,130 C1386,141 1387,153 1389,160 Z"
fill="url(#hdr-plant-c)" opacity="0.85"/>
<path d="M1356,160 C1359,148 1362,134 1359,117 C1357,104 1353,93 1356,79
C1356,68 1354,59 1353,48
C1350,60 1349,70 1351,80 C1348,96 1349,107 1351,120
C1349,136 1350,150 1352,160 Z"
fill="url(#hdr-plant-b)"/>
<path d="M1342,160 C1347,149 1352,135 1348,118 C1345,105 1340,95 1344,80
C1338,96 1337,109 1340,122 C1337,138 1338,152 1340,160 Z"
fill="url(#hdr-plant-a)" opacity="0.75"/>
<path d="M1330,160 C1332,150 1334,138 1332,124 C1330,113 1327,104 1329,92
C1326,104 1325,114 1327,125 C1325,139 1326,152 1328,160 Z"
fill="url(#hdr-plant-b)" opacity="0.7"/>
<ellipse cx="1365" cy="157" rx="28" ry="5" fill="#1a3820" opacity="0.6"/>
</g>
{/* ── Mid-ground vegetation tufts ──────────────────────────── */}
<g opacity="0.5" filter="url(#hdr-blur3)">
<path d="M430,160 C427,152 424,142 427,132 C429,124 432,117 430,108
C433,118 434,126 432,134 C434,144 433,154 431,160 Z" fill="url(#hdr-plant-b)"/>
<path d="M444,160 C447,151 444,139 447,129 C449,120 452,113 449,103
C453,114 454,123 452,132 C454,143 453,154 451,160 Z" fill="url(#hdr-plant-c)"/>
<path d="M810,160 C807,153 805,143 808,133 C810,125 813,118 811,109
C814,120 815,128 813,136 C815,146 814,155 812,160 Z" fill="url(#hdr-plant-b)"/>
<path d="M826,160 C829,151 826,140 829,130 C831,121 834,114 831,105
C835,116 836,125 834,134 C836,145 835,155 833,160 Z" fill="url(#hdr-plant-c)"/>
</g>
{/* ── Fish school — realistic silhouettes ───────────────── */}
<g opacity="0.6">
{/* Fish 1 — lead fish, largest */}
<g transform="translate(638,68)">
{/* Body */}
<path d="M0,0 C8,-5 22,-7 34,-4 C44,-1 50,2 50,5 C50,8 44,11 34,13
C22,15 8,13 0,8 C-3,6 -3,3 0,0 Z" fill="url(#hdr-fish)"/>
{/* Fork tail */}
<path d="M0,4 C-8,0 -16,-6 -18,-10 C-15,-8 -8,-4 0,0 Z" fill="url(#hdr-fish)" opacity="0.8"/>
<path d="M0,4 C-8,8 -16,14 -18,18 C-15,15 -8,10 0,8 Z" fill="url(#hdr-fish)" opacity="0.8"/>
{/* Dorsal fin */}
<path d="M18,-4 C20,-9 24,-12 28,-10 C26,-7 22,-4 20,0 Z" fill="rgba(160,185,200,0.4)"/>
{/* Eye */}
<circle cx="38" cy="4" r="2.2" fill="rgba(20,35,50,0.7)"/>
<circle cx="38.7" cy="3.3" r="0.7" fill="rgba(200,220,240,0.6)"/>
</g>
{/* Fish 2 */}
<g transform="translate(672,50)">
<path d="M0,0 C7,-4 18,-6 28,-3 C36,-1 41,2 41,5 C41,7 36,10 28,11
C18,13 7,11 0,7 C-2,5 -2,2 0,0 Z" fill="url(#hdr-fish)"/>
<path d="M0,4 C-6,-1 -13,-5 -15,-8 C-12,-6 -6,-2 0,1 Z" fill="url(#hdr-fish)" opacity="0.75"/>
<path d="M0,4 C-6,9 -13,13 -15,16 C-12,13 -6,9 0,7 Z" fill="url(#hdr-fish)" opacity="0.75"/>
<path d="M15,-3 C17,-8 20,-10 23,-8 C21,-5 18,-3 16,0 Z" fill="rgba(160,185,200,0.35)"/>
<circle cx="31" cy="4" r="1.9" fill="rgba(20,35,50,0.7)"/>
<circle cx="31.6" cy="3.4" r="0.6" fill="rgba(200,220,240,0.6)"/>
</g>
{/* Fish 3 */}
<g transform="translate(710,80)">
<path d="M0,0 C6,-3 16,-5 25,-3 C32,0 37,2 37,5 C37,7 32,9 25,10
C16,12 6,10 0,7 C-2,5 -2,2 0,0 Z" fill="url(#hdr-fish)"/>
<path d="M0,4 C-6,0 -11,-4 -13,-7 C-10,-5 -5,-1 0,1 Z" fill="url(#hdr-fish)" opacity="0.75"/>
<path d="M0,4 C-6,8 -11,12 -13,15 C-10,12 -5,9 0,7 Z" fill="url(#hdr-fish)" opacity="0.75"/>
<circle cx="28" cy="4" r="1.7" fill="rgba(20,35,50,0.7)"/>
<circle cx="28.6" cy="3.4" r="0.55" fill="rgba(200,220,240,0.6)"/>
</g>
{/* Fish 4 — lower, angled */}
<g transform="translate(690,96)">
<path d="M0,0 C5,-3 14,-4 22,-2 C28,0 33,3 33,5 C33,8 28,10 22,11
C14,12 5,10 0,7 C-2,5 -2,2 0,0 Z" fill="url(#hdr-fish)" opacity="0.9"/>
<path d="M0,4 C-5,1 -10,-3 -12,-5 C-9,-4 -4,0 0,1 Z" fill="url(#hdr-fish)" opacity="0.7"/>
<path d="M0,4 C-5,7 -10,11 -12,13 C-9,11 -4,8 0,7 Z" fill="url(#hdr-fish)" opacity="0.7"/>
<circle cx="25" cy="4" r="1.5" fill="rgba(20,35,50,0.65)"/>
</g>
{/* Fish 5 — small straggler */}
<g transform="translate(660,88)">
<path d="M0,0 C4,-2 11,-4 18,-2 C23,0 27,2 27,4 C27,6 23,8 18,9
C11,10 4,9 0,6 C-1.5,5 -1.5,2 0,0 Z" fill="url(#hdr-fish)" opacity="0.8"/>
<path d="M0,3 C-4,0 -8,-3 -9,-5 C-7,-3 -3,0 0,1 Z" fill="url(#hdr-fish)" opacity="0.65"/>
<path d="M0,3 C-4,6 -8,9 -9,11 C-7,9 -3,7 0,5 Z" fill="url(#hdr-fish)" opacity="0.65"/>
<circle cx="20" cy="3" r="1.3" fill="rgba(20,35,50,0.6)"/>
</g>
</g>
{/* ── Floating micro-particles ──────────────────────────────── */}
<g opacity="0.35">
{[
[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) => (
<circle key={i} cx={x} cy={y} r={r} fill="rgba(200,225,255,0.7)"/>
))}
</g>
{/* ── Water surface shimmer (top edge) ─────────────────────── */}
<g filter="url(#hdr-blur3)" opacity="0.18">
<path d="M0,3 C80,0 160,5 260,2 C360,-1 450,4 560,1 C660,-2 760,3 880,0
C990,-2 1100,3 1210,1 C1300,-1 1370,3 1400,1 L1400,0 L0,0 Z"
fill="rgba(180,220,255,0.9)"/>
</g>
</svg>
);
}
// ── 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 (
<>
<GlobalStyles />
<div style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: 'radial-gradient(ellipse at 50% 0%, #0a2040 0%, #04101e 60%, #020c18 100%)' }}>
<div style={{ textAlign: "center", color: C.txt2 }}><div style={{ fontSize: 40, marginBottom: 12 }}>🐠</div>Lade</div>
</div>
</>
);
if (!auth) return <><GlobalStyles /><LoginScreen onLogin={handleLogin} /></>;
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 = (
<div>
<div style={{ marginBottom: 12 }}>
<label style={{ color: C.txt3, fontSize: 11, display: "block", marginBottom: 5, fontWeight: 600 }}>📝 Notiz</label>
<textarea value={note} onChange={e => setNote(e.target.value)} placeholder="Wasserwechsel, Düngung, Beobachtungen…" rows={3}
style={{ width: "100%", background: C.inputBg, border: `1.5px solid ${C.inputBorder}`, borderRadius: 10, color: C.txt1, fontSize: 13, padding: "8px 11px", fontFamily: "inherit", resize: "none" }} />
</div>
<div style={{ display: "flex", gap: 8, marginBottom: 20 }}>
<button className="btn-primary" onClick={handleSaveEntry} disabled={saving}
style={{ flex: 2, background: saved ? `linear-gradient(135deg, #059669, ${C.secondary})` : `linear-gradient(135deg, ${C.primaryDark}, ${C.primary})`, border: "none", borderRadius: 14, color: "#041018", fontSize: 15, fontWeight: 800, height: 52, cursor: saving ? "wait" : "pointer", letterSpacing: "0.02em", boxShadow: saved ? "0 8px 24px rgba(45,212,191,0.35)" : `0 8px 24px rgba(34,211,238,0.30)` }}>
{saved ? "✓ Gespeichert!" : saving ? "Speichern…" : "💾 Eintrag speichern"}
</button>
<button onClick={() => { 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
</button>
</div>
</div>
);
const ParamGrid = (
<div style={{ display: "grid", gridTemplateColumns: bp.isMobile ? "1fr" : "1fr 1fr", gap: 10 }}>
{PARAM_KEYS.map(k => (
<ParamCard key={k} paramKey={k} value={values[k]}
onChange={(key, val) => { setValues(v => ({ ...v, [key]: val })); setSaved(false); }}
showInfo={infoKey === k} onToggleInfo={key => setInfoKey(i => i === key ? null : key)} />
))}
</div>
);
const MessenContent = bp.isDesktop
? (<div style={{ display: "grid", gridTemplateColumns: "1fr 360px", gap: 24, alignItems: "flex-start" }}>
<div>{ParamGrid}</div>
<div style={{ position: "sticky", top: 88 }}>
{aquarium && <ReminderBanner aquariumId={aquarium.id} />}
<SummaryBanner values={values} isDesktop />
{SpeichernPanel}
</div>
</div>)
: (<div>
{aquarium && <ReminderBanner aquariumId={aquarium.id} />}
<SummaryBanner values={values} isDesktop={false} />
{ParamGrid}
<div style={{ marginTop: 14 }}>{SpeichernPanel}</div>
</div>);
return (
<AuthCtx.Provider value={{ auth, setAuth }}>
<GlobalStyles />
<div style={{ minHeight: "100vh", background: "transparent", fontFamily: "'Outfit', 'Segoe UI', system-ui, sans-serif", color: C.txt1, maxWidth: containerMaxWidth, margin: "0 auto", paddingBottom: 60 }}>
{/* ── Sticky Header ──────────────────────────────────── */}
<div style={{ background: C.headerBg, position: "sticky", top: 0, zIndex: 10, boxShadow: "0 4px 32px rgba(0,0,0,0.7), 0 1px 0 rgba(34,211,238,0.15)", overflow: "hidden", borderBottom: "1px solid rgba(34,211,238,0.12)" }}>
{/* Photo background */}
<div style={{ position: "absolute", inset: 0, backgroundImage: "url('/aquascape.jpg')", backgroundSize: "cover", backgroundPosition: "center 45%", opacity: 0.38 }} />
<div style={{ position: "absolute", inset: 0, background: "linear-gradient(180deg, rgba(5,37,64,0.72) 0%, rgba(10,63,98,0.65) 55%, rgba(12,90,138,0.58) 100%)" }} />
<HeaderSVG />
{/* ── Aquarium selector row ─────────────────────────── */}
<div style={{ padding: bp.isDesktop ? "14px 28px 10px" : "12px 16px 10px", position: "relative", zIndex: 1 }}>
<button onClick={() => 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)" }}>
<span style={{ fontSize: bp.isDesktop ? 22 : 18 }}>🐠</span>
<div style={{ flex: 1, textAlign: "left" }}>
<div style={{ color: "#fff", fontSize: bp.isDesktop ? 18 : 14, fontWeight: 800, lineHeight: 1.2 }}>
{aquarium?.name || "Aquarium wählen"}
</div>
<div style={{ color: "rgba(255,255,255,0.6)", fontSize: 10, fontFamily: "'DM Mono', monospace" }}>
{aquarium ? `${aquarium.volume} L · ${entries.length} Einträge` : "Tippen zum Auswählen"}
</div>
</div>
<span style={{ color: "rgba(255,255,255,0.55)", fontSize: 12 }}></span>
</button>
</div>
{/* ── Tab bar ──────────────────────────────────────────── */}
<div style={{ padding: bp.isDesktop ? "0 28px" : "0 16px", position: "relative", zIndex: 1 }}>
<div style={{ display: "flex", background: "rgba(0,0,0,0.18)", borderRadius: 999, padding: "3px", gap: 2 }}>
{TABS.map(([k, icon, label]) => (
<button key={k} className={`tab-pill${tab === k ? " active" : ""}`}
onClick={() => { 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}
</button>
))}
</div>
</div>
{/* ── User bar ─────────────────────────────────────────── */}
<div style={{ background: "rgba(2,8,18,0.88)", borderTop: "1px solid rgba(34,211,238,0.10)", padding: bp.isDesktop ? "7px 28px" : "7px 16px", display: "flex", alignItems: "center", gap: 10, position: "relative", zIndex: 1 }}>
{/* User info */}
<span style={{ fontSize: 14 }} aria-hidden="true">👤</span>
<div style={{ flex: 1 }}>
<span style={{ color: "#e2eaf2", fontSize: 13, fontWeight: 700 }}>{auth.user.displayName || auth.user.username}</span>
<span style={{ color: "rgba(255,255,255,0.4)", fontSize: 11, fontFamily: "'DM Mono', monospace", marginLeft: 8 }}>@{auth.user.username}</span>
{auth.user.role === "admin" && (
<span style={{ marginLeft: 8, background: "rgba(34,211,238,0.18)", color: "#22d3ee", fontSize: 9, fontWeight: 700, padding: "2px 7px", borderRadius: 999, fontFamily: "'DM Mono', monospace", letterSpacing: "0.04em", border: "1px solid rgba(34,211,238,0.3)" }}>ADMIN</span>
)}
</div>
{/* Action buttons */}
{auth.user.role === "admin" && (
<button onClick={() => 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
</button>
)}
<button onClick={handleLogout}
style={{ background: "rgba(239,68,68,0.12)", border: "1px solid rgba(252,165,165,0.25)", borderRadius: 8, color: "#fca5a5", fontSize: 11, fontWeight: 700, padding: "5px 12px", cursor: "pointer", whiteSpace: "nowrap", transition: "all 0.2s" }}>
Abmelden
</button>
</div>
</div>
{/* ── Tab content ─────────────────────────────────────── */}
<div style={{ padding: bp.isDesktop ? "20px 28px 0" : "14px 14px 0" }}>
{tab === "messen" && MessenContent}
{tab === "duenger" && <DuengerTab values={values} bp={bp} />}
{tab === "wissen" && <WissenTab bp={bp} />}
{tab === "info" && <SollwerteTab bp={bp} />}
{tab === "logbuch" && (
<div>
<div style={{ color: C.txt3, fontSize: 11, marginBottom: 10, fontFamily: "'DM Mono', monospace", fontWeight: 600 }}>{entries.length} Einträge</div>
{loadingEntries ? <div style={{ textAlign: "center", color: C.txt3, padding: 30 }}>Lade</div>
: entries.length === 0
? (<div style={{ textAlign: "center", padding: "50px 20px", color: C.txt4, background: C.card, borderRadius: 16, border: `1px solid ${C.cardBorder}`, boxShadow: C.shadow }}>
<div style={{ fontSize: 40, marginBottom: 10 }}>📋</div>
<div style={{ color: C.txt2, fontWeight: 600 }}>Noch keine Messungen.</div>
</div>)
: entries.map(e => <LogEntry key={e.id} entry={e} onDelete={handleDeleteEntry} isDesktop={bp.isDesktop} />)
}
</div>
)}
</div>
{/* ── Modals ──────────────────────────────────────────── */}
{showAqModal && (
<AquariumModal aquariums={aquariums} current={aquarium}
onSelect={aq => { 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 && <AdminPanel onClose={() => setShowAdmin(false)} />}
{/* First-time: no aquarium */}
{!aquarium && !booting && (
<div style={{ textAlign: "center", padding: "60px 20px" }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>🐠</div>
<div style={{ color: C.txt1, fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Willkommen!</div>
<div style={{ color: C.txt3, marginBottom: 20, fontSize: 13 }}>Lege dein erstes Aquarium an um loszulegen.</div>
<button onClick={() => 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
</button>
</div>
)}
</div>
</AuthCtx.Provider>
);
}