- React 18 + Vite Frontend - Node.js/Express Backend - Vollstaendige Logbuch-Funktionalitaet fuer Aquarien - Deploy-Script fuer aqualog CT 211 (192.168.0.246)
2054 lines
111 KiB
JavaScript
2054 lines
111 KiB
JavaScript
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: <span style={{ color: C.accent }}>{fmt(rec.diff)} mg/l {param.unit}</span><br />
|
||
1 ml {d.kurz} / 100 L hebt {rec.param} um: <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 }}>25–50% 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 (4–8 Wochen)." },
|
||
{ head: "Nitrat (NO₃) — Pflanzennahrung", text: "Nitrospira-Bakterien oxidieren NO₂ zu vergleichsweise harmlosem NO₃. Pflanzen verbrauchen es aktiv. Ohne Pflanzen steigt NO₃ stetig an und muss durch Wasserwechsel entfernt werden. Ziel: 10–25 mg/l." },
|
||
{ head: "Einfahrzeit", text: "Ein neues Aquarium braucht 4–8 Wochen zum biologischen Einfahren. In dieser Zeit sind NH₄ und NO₂ erhöht — noch keine Fische einsetzen! Filtermedien nie mit Leitungswasser spülen (Chlor tötet Bakterien)." },
|
||
]
|
||
},
|
||
{
|
||
id: "ph-kh-co2", icon: "⚗️", title: "pH · KH · CO₂ — Das Dreieck", color: "#a78bfa",
|
||
summary: "Diese drei Werte hängen untrennbar zusammen. Wer einen verändert, beeinflusst automatisch die anderen.",
|
||
content: [
|
||
{ head: "Karbonathärte (KH) als Puffer", text: "KH (Bikarbonat) puffert den pH-Wert. Eine hohe KH (> 6 °dKH) verhindert pH-Stürze, eine zu niedrige KH (< 3 °dKH) führt zu instabilem pH. Für Pflanzenaquarien gilt: 4–6 °dKH ist ideal – hart genug zum Puffern, weich genug für CO₂-Effizienz." },
|
||
{ head: "CO₂ und pH", text: "CO₂ löst sich in Wasser zu Kohlensäure (H₂CO₃) und senkt den pH. Die Formel: Bei KH = 6 und CO₂ = 25 mg/l ergibt sich pH ≈ 6,7. Tagsüber verbrauchen Pflanzen CO₂ → pH steigt. Nachts produzieren alle Lebewesen CO₂ → pH fällt. Eine natürliche Tagesschwankung von ± 0,5 ist normal." },
|
||
{ head: "Ziel-CO₂ für Pflanzen", text: "Pflanzen wachsen optimal bei 20–30 mg/l CO₂. Mit KH und pH lässt sich CO₂ schätzen: CO₂ = 3 × KH × 10^(7,0 − pH). CO₂ ≥ 30 mg/l kann Fische schädigen (Hyperkapnie). Nachts CO₂-Anlage ausschalten, morgens früh einschalten." },
|
||
{ head: "Fischgerechter pH", text: "Die meisten Süßwasserfische tolerieren pH 6,5–7,5. Südamerika (Diskus, Tetras): pH 6,0–6,8. Afrika (Malawisee): pH 7,8–8,5. Brückscher Richtlinie: Keine pH-Änderung > 0,2 pro Stunde." },
|
||
]
|
||
},
|
||
{
|
||
id: "makro", icon: "🌿", title: "Makronährstoffe für Pflanzen", color: "#4ade80",
|
||
summary: "NO₃, PO₄, K, Mg, Ca sind die Grundnahrung. Liebigs Minimum: Das knappste Element begrenzt das Wachstum.",
|
||
content: [
|
||
{ head: "Stickstoff (NO₃) — Aufbaustoff", text: "Baustein für Aminosäuren, Proteine und Chlorophyll. Mangel: langsames Wachstum, gelbliche Alttriebe. Überschuss: weiches, weichhäutiges Gewebe, anfälliger für Algen. Ziel: 10–25 mg/l. Abgebaut durch Pflanzen und Wasserwechsel." },
|
||
{ head: "Phosphat (PO₄) — Energieträger", text: "Teil von ATP (Zellenenergie) und DNA. Mangel: rötliche Blätter, sehr langsames Wachstum. Viele Aquarianer fürchten PO₄ als Algenursache — falsch! Algen entstehen bei Ungleichgewicht, nicht bei hohem PO₄. Ziel: 0,1–0,4 mg/l. Eisen zeitversetzt dosieren." },
|
||
{ head: "Kalium (K) — Wasserhaushalt", text: "Regelt den Wasserhaushalt der Pflanzenzellen. Mangel: Löcher in Blättern, gelbe Blattränder. Oft unterschätzt — besonders in Leitungswasser ist K niedrig. Ziel: 5–15 mg/l. Hat kaum Seiteneffekte bei Überdosierung." },
|
||
{ head: "Magnesium (Mg) & Calcium (Ca)", text: "Mg ist zentrales Atom im Chlorophyll. Mangel: Blätter vergilben zwischen den Blattadern (Interchlorose). Ca stabilisiert Zellwände. Ca:Mg-Verhältnis ca. 3:1 anstreben. GH = Summe aus Ca und Mg (grob). Für Garnelen: GH 6–8 °dGH empfohlen." },
|
||
]
|
||
},
|
||
{
|
||
id: "mikro", icon: "⚙️", title: "Mikronährstoffe & Eisen", color: "#fb923c",
|
||
summary: "Spurenelemente und Eisen ermöglichen enzymatische Prozesse — in kleinen Mengen unverzichtbar.",
|
||
content: [
|
||
{ head: "Eisen (Fe) — sichtbarster Mikronahrstoff", text: "Kofaktor für Chlorophyll-Synthese. Mangel: Junge Blätter vergilben (Interchlorose). Fe ist schwer löslich — chelatiert (EDTA, DTPA, Huminat) bleibt es verfügbar. 0,05–0,2 mg/l. Morgens dosieren, da Fe im Tageslicht photodegradiert." },
|
||
{ head: "Mangan (Mn), Bor (B), Zink (Zn)", text: "Mn: Kofaktor bei Photosynthese. Zn: Enzymaktivierung. Bor: Zellwandbildung. Bei Verwendung vollständiger Mikro-Dünger (z.B. Flowgrow) sind diese meist ausreichend abgedeckt." },
|
||
{ head: "Chelate — Transport im Wasser", text: "Metalle bilden im Wasser schwer lösliche Verbindungen. Chelatoren (EDTA, DTPA, Gluconat) halten sie löslich und bioverfügbar. Sanft chelatierte Produkte (Huminat/Gluconat) sind schnell verfügbar aber kurzlebig → täglich dosieren. EDTA-Chelate sind stabiler → wöchentlich." },
|
||
]
|
||
},
|
||
{
|
||
id: "fische", icon: "🐟", title: "Wasserqualität & Fischgesundheit", color: "#38bdf8",
|
||
summary: "Fische haben einen engen Toleranzbereich. Stressfreies Wasser ist die wichtigste Krankheitsprophylaxe.",
|
||
content: [
|
||
{ head: "Temperatur", text: "Tropische Fische: 24–28 °C. Kaltwater (Goldfish): 15–22 °C. Schwankungen > 2 °C/Tag stressen Fische stark. Beim Wasserwechsel: immer temperiertes Wasser verwenden (max. 1 °C Differenz). Zu warmes Wasser reduziert O₂-Gehalt." },
|
||
{ head: "Sauerstoff (O₂)", text: "Fische benötigen ≥ 6 mg/l O₂. Kritisch < 4 mg/l. Sauerstoff wird bei höherer Temperatur schlechter gelöst (20 °C: ~9 mg/l, 28 °C: ~7,8 mg/l). CO₂-Düngung nachts abschalten! Ausreichend Wasserbewegung an der Oberfläche." },
|
||
{ head: "Leitfähigkeit (μS/cm)", text: "Spiegelt den Gesamtmineralgehalt. Tropenarten: 150–400 μS/cm. Afrikanische Cichliden: 400–800 μS/cm. Garnelen (Caridina): 100–200 μS/cm, (Neocaridina): 250–450 μS/cm. Ein plötzlicher Anstieg kann auf Verschmutzung hinweisen." },
|
||
{ head: "Stress und Immunsystem", text: "Schlechte Wasserqualität stresst Fische dauerhaft → schwächeres Immunsystem → erhöhte Anfälligkeit für Parasiten (Ichthyo, Velvet) und Bakterien. Regelmäßige Wassertests sind die beste Vorbeugung. Stress-Indikatoren: hektisches Schwimmen, Apathie, Atemprobleme." },
|
||
]
|
||
},
|
||
{
|
||
id: "wasserwechsel", icon: "💧", title: "Wasserwechsel — Warum & Wie", color: "#2dd4bf",
|
||
summary: "Regelmäßige Wasserwechsel sind die wichtigste Pflegemaßnahme — sie entfernen Schadstoffe, die keine Filterung abbaut.",
|
||
content: [
|
||
{ head: "Was Wasserwechsel entfernt", text: "NO₃ (Nitrat) akkumuliert ohne Wasserwechsel. Gilvosin (Gelbfärbung). Hormone und Stoffwechselprodukte. Phosphate aus Futtermittelrückständen. Diese Stoffe werden durch keinen biologischen Filter dauerhaft eliminiert." },
|
||
{ head: "Empfehlung", text: "25–30% wöchentlich ist der Goldstandard für die meisten Aquarien. Stark besetzte Becken: 30–50%/Woche. Pflanzenaquarien mit wenig Fisch: 20–25%/Woche. Garnelenbecken: vorsichtiger — 10–15% alle 1–2 Wochen, da Garnelen empfindlicher auf Wasserparam-Schwankungen reagieren." },
|
||
{ head: "Richtiges Wasser aufbereiten", text: "Immer auf Aquarium-Temperatur temperieren. Chlor aus Leitungswasser entfernen (Wasseraufbereiter oder 24h stehen lassen). Mineralsalze ergänzen wenn nötig (bei weichem Leitungswasser). pH-Wert prüfen. Niemals Leitungswasser direkt ins Aquarium." },
|
||
{ head: "Anzeichen, dass ein Wasserwechsel überfällig ist", text: "NO₃ > 50 mg/l. Gelbliche Wasserfärbung. Fadenalgenwachstum ohne erkennbaren Grund. Fische 'stehen' an der Oberfläche. Schlechte Pflanzenfärbung trotz Düngung. pH-Sturz durch erschöpfte KH-Pufferkapazität." },
|
||
]
|
||
},
|
||
];
|
||
|
||
return (
|
||
<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>
|
||
);
|
||
}
|
||
|