/* ================================================ App — Main shell with view switching + API persistence ================================================ */ /* ---------- URL-Router ---------- */ // Abbildung Pfad ↔ App-State. Unterstützte Routen: // / -> Liste // /karte -> Karte // /einstellungen -> Einstellungen // /neu -> Liste + Wizard (neu) // /flaeche/{id} -> Detail einer Parzelle // /flaeche/{id}/bearbeiten -> Detail + Wizard (edit) function parseRoute(path) { const p = (path || "/").replace(/\/+$/, "") || "/"; if (p === "/" || p === "/flaechen") return { view: "list", selectedId: null, wizardMode: null }; if (p === "/karte") return { view: "map", selectedId: null, wizardMode: null }; if (p === "/einstellungen") return { view: "settings", selectedId: null, wizardMode: null }; if (p === "/neu") return { view: "list", selectedId: null, wizardMode: "new" }; const m = p.match(/^\/flaeche\/([^/]+?)(\/bearbeiten)?$/); if (m) { return { view: "detail", selectedId: decodeURIComponent(m[1]), wizardMode: m[2] ? "edit" : null, }; } return { view: "list", selectedId: null, wizardMode: null }; } function buildPath(view, selectedId, wizardMode) { if (wizardMode === "new") return "/neu"; if (view === "detail" && selectedId) { return `/flaeche/${encodeURIComponent(selectedId)}${wizardMode === "edit" ? "/bearbeiten" : ""}`; } if (view === "map") return "/karte"; if (view === "settings") return "/einstellungen"; return "/"; } const api = { async get(path) { const r = await fetch(path, { headers: { Accept: "application/json" } }); if (!r.ok) throw new Error(`GET ${path} failed: ${r.status}`); return r.json(); }, async send(method, path, body) { const r = await fetch(path, { method, headers: { "Content-Type": "application/json", Accept: "application/json" }, body: body ? JSON.stringify(body) : undefined, }); if (!r.ok && r.status !== 204) { const err = await r.text(); throw new Error(`${method} ${path} failed: ${r.status} ${err}`); } if (r.status === 204) return null; return r.json(); }, post(path, body) { return this.send("POST", path, body); }, put(path, body) { return this.send("PUT", path, body); }, del(path) { return this.send("DELETE", path); }, }; function App() { const [loaded, setLoaded] = React.useState(false); const [view, setView] = React.useState("list"); const [listLayout, setListLayout] = React.useState("cards"); const [mapStyle, setMapStyle] = React.useState("satellite"); const [dark, setDark] = React.useState(false); const [tweaksOpen, setTweaksOpen] = React.useState(false); const [parcels, setParcels] = React.useState([]); const [sorten, setSortenState] = React.useState([]); const [selected, setSelected] = React.useState(null); const [activeId, setActiveId] = React.useState(null); const [wizardOpen, setWizardOpen] = React.useState(false); const [editParcel, setEditParcel] = React.useState(null); // Keep window.SORTEN mirrored for helpers (getSorteFarbe, SortPill) React.useEffect(() => { window.SORTEN = sorten; }, [sorten]); React.useEffect(() => { document.documentElement.setAttribute("data-theme", dark ? "dark" : "light"); }, [dark]); /* ---------- Router: Pfad ↔ State ---------- */ // Wendet eine geparste Route auf den App-State an. Parcels müssen geladen // sein, damit /flaeche/:id und /flaeche/:id/bearbeiten die Parzelle finden. const applyRoute = React.useCallback((r, available) => { if (r.view === "detail" && r.selectedId) { const p = (available || []).find(x => x.id === r.selectedId); if (p) { setSelected(p); setView("list"); } else { // Parzelle nicht (mehr) vorhanden → zurück zur Liste setSelected(null); setView("list"); } } else { setSelected(null); setView(r.view === "detail" ? "list" : r.view); } if (r.wizardMode === "new") { setEditParcel(null); setWizardOpen(true); } else if (r.wizardMode === "edit" && r.selectedId) { const p = (available || []).find(x => x.id === r.selectedId); setEditParcel(p || null); setWizardOpen(!!p); } else { setWizardOpen(false); setEditParcel(null); } }, []); // Initial-Route anwenden, sobald Parcels geladen sind const initialPathRef = React.useRef(typeof window !== "undefined" ? window.location.pathname : "/"); const initialRouteApplied = React.useRef(false); // popstate (Browser Zurück/Vorwärts) → State aus URL ableiten React.useEffect(() => { function onPop() { applyRoute(parseRoute(window.location.pathname), parcels); } window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, [parcels, applyRoute]); // State → URL: bei jeder relevanten Änderung pushen, falls sich Pfad ändert React.useEffect(() => { if (!loaded || !initialRouteApplied.current) return; const wizardMode = wizardOpen ? (editParcel ? "edit" : "new") : null; const selectedId = selected ? selected.id : null; const v = selected ? "detail" : view; const target = buildPath(v, selectedId, wizardMode); if (target !== window.location.pathname) { window.history.pushState(null, "", target); } }, [loaded, view, selected, wizardOpen, editParcel]); // Initial load React.useEffect(() => { (async () => { try { const [p, s, cfg] = await Promise.all([ api.get("/api/parcels"), api.get("/api/sorten"), api.get("/api/settings"), ]); setParcels(p); setSortenState(s); window.SORTEN = s; if (cfg.map_style) setMapStyle(cfg.map_style); if (cfg.list_layout) setListLayout(cfg.list_layout); if (cfg.dark_mode) setDark(cfg.dark_mode === "1"); // Initial-Route erst anwenden, wenn Parcels bekannt sind. applyRoute(parseRoute(initialPathRef.current), p); initialRouteApplied.current = true; } catch (e) { console.error("Initial load failed", e); initialRouteApplied.current = true; } finally { setLoaded(true); } })(); }, [applyRoute]); function persistSetting(key, value) { api.put("/api/settings", { [key]: value }).catch(console.error); } function setMapStyleP(v) { setMapStyle(v); persistSetting("map_style", v); } function setListLayoutP(v) { setListLayout(v); persistSetting("list_layout", v); } function setDarkP(v) { setDark(v); persistSetting("dark_mode", v ? "1" : "0"); } async function deleteParcel(id) { await api.del(`/api/parcels/${encodeURIComponent(id)}`); setParcels(ps => ps.filter(x => x.id !== id)); setSelected(null); } async function saveParcel(p) { if (editParcel) { const updated = await api.put(`/api/parcels/${encodeURIComponent(p.id)}`, p); setParcels(ps => ps.map(x => x.id === updated.id ? updated : x)); setSelected(updated); } else { const created = await api.post("/api/parcels", p); setParcels(ps => [...ps, created]); setSelected(created); } setWizardOpen(false); setEditParcel(null); } async function setSorten(next) { // Diff-based CRUD against the server. const prev = sorten; setSortenState(next); window.SORTEN = next; try { // Deletions for (const p of prev) { if (!next.find(n => n.id === p.id)) { if (p.id != null) await api.del(`/api/sorten/${p.id}`); } } // Creations + updates for (const n of next) { if (n.id == null) { const created = await api.post("/api/sorten", { name: n.name, farbe: n.farbe }); // re-inject id after successful create setSortenState(curr => curr.map(x => x === n ? { ...x, id: created.id } : x)); } else { const before = prev.find(p => p.id === n.id); if (before && (before.name !== n.name || before.farbe !== n.farbe)) { await api.put(`/api/sorten/${n.id}`, { name: n.name, farbe: n.farbe }); } } } } catch (e) { console.error("Sorte sync failed", e); // Refresh from server as fallback const s = await api.get("/api/sorten"); setSortenState(s); window.SORTEN = s; } } const totalHa = parcels.reduce((s, p) => s + p.flaeche, 0); const viewMeta = { list: { title: "Flächen · Übersicht", sub: `${parcels.length} Parzellen · ${fmtNum(totalHa)} ha` }, map: { title: "Kartenansicht", sub: "Kaiserstuhl · Esri World Imagery" }, detail: { title: selected?.lage || "Detail", sub: selected?.lagebuch || "" }, settings: { title: "Einstellungen", sub: "Sorten · Darstellung · Standards" }, }; const currentView = selected ? "detail" : view; const meta = viewMeta[currentView]; if (!loaded) { return
Lade Vinea…
; } return (

{meta.title}

{meta.sub}
{currentView === "list" && (
)} {!selected && view !== "settings" && (
)} {view !== "settings" && ( )}
{selected ? ( setSelected(null)} onEdit={() => { setEditParcel(selected); setWizardOpen(true); }} mapStyle={mapStyle} /> ) : view === "settings" ? ( persistSetting(k === "mapStyle" ? "map_style" : k === "listLayout" ? "list_layout" : k, v)} /> ) : view === "list" ? ( setWizardOpen(true)} layout={listLayout} setLayout={setListLayoutP} mapStyle={mapStyle} /> ) : ( )}
{/* Mobile bottom navigation */} {wizardOpen && ( { setWizardOpen(false); setEditParcel(null); }} onDelete={async (id) => { await deleteParcel(id); setWizardOpen(false); setEditParcel(null); }} existingParcels={parcels} editParcel={editParcel} mapStyle={mapStyle} onSave={saveParcel} /> )}

Tweaks

Kartenstil
Listen-Layout
Dark Mode
setDarkP(!dark)} />
); } ReactDOM.createRoot(document.getElementById("root")).render();