P42 Dashboard: erster Stand

FastAPI-Backend und React-Frontend mit Konten, Sendungsverfolgung,
Projekt-Artikeln, Vorrat & Einkauf mit Rezeptabgleich, Reise-Weltkarte,
Kanban-Projekten mit Priorität, Server-Monitoring und den Designs
Matrix und Blackwall. Betrieb per Docker hinter nginx (siehe DEPLOY.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-14 14:08:11 +02:00
commit dd58b3d1f8
75 files changed
+11418

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
import { useState } from 'react';
import { api } from '../api.js';
import { useAuth } from '../auth.jsx';
export default function Account() {
const { user, setUser } = useAuth();
return (
<>
<div className="page-head">
<div>
<h1>Mein Konto</h1>
<p className="lead">
Angemeldet als <strong>{user.username}</strong>{user.isAdmin && ' · Administrator'}
</p>
</div>
</div>
<div className="settings-grid">
<ProfileForm user={user} onSaved={setUser} />
<PasswordForm />
</div>
</>
);
}
function ProfileForm({ user, onSaved }) {
const [name, setName] = useState(user.displayName);
const [msg, setMsg] = useState(null);
const [busy, setBusy] = useState(false);
async function submit(e) {
e.preventDefault();
setBusy(true);
setMsg(null);
try {
onSaved(await api('PATCH', '/api/auth/me', { displayName: name }));
setMsg({ ok: true, text: 'Gespeichert.' });
} catch (err) {
setMsg({ ok: false, text: err.message });
} finally {
setBusy(false);
}
}
return (
<form className="card form" onSubmit={submit}>
<h2>Anzeigename</h2>
<p className="hint">So erscheinst du als Autor bzw. letzter Bearbeiter bei Artikeln.</p>
<label>
Anzeigename
<input value={name} onChange={(e) => setName(e.target.value)} maxLength={60} required />
</label>
{msg && <p className={msg.ok ? 'success' : 'error'}>{msg.text}</p>}
<div className="form-actions">
<button className="btn" type="submit" disabled={busy}>Speichern</button>
</div>
</form>
);
}
function PasswordForm() {
const [form, setForm] = useState({ current: '', next: '', repeat: '' });
const [msg, setMsg] = useState(null);
const [busy, setBusy] = useState(false);
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
async function submit(e) {
e.preventDefault();
setMsg(null);
if (form.next !== form.repeat) return setMsg({ ok: false, text: 'Die neuen Passwörter stimmen nicht überein.' });
setBusy(true);
try {
await api('PATCH', '/api/auth/me', { currentPassword: form.current, newPassword: form.next });
setForm({ current: '', next: '', repeat: '' });
setMsg({ ok: true, text: 'Passwort geändert. Andere Geräte wurden abgemeldet.' });
} catch (err) {
setMsg({ ok: false, text: err.message });
} finally {
setBusy(false);
}
}
return (
<form className="card form" onSubmit={submit}>
<h2>Passwort ändern</h2>
<label>
Aktuelles Passwort
<input {...field('current')} type="password" autoComplete="current-password" required />
</label>
<label>
Neues Passwort <small>(mind. 8 Zeichen)</small>
<input {...field('next')} type="password" autoComplete="new-password" minLength={8} required />
</label>
<label>
Neues Passwort wiederholen
<input {...field('repeat')} type="password" autoComplete="new-password" required />
</label>
{msg && <p className={msg.ok ? 'success' : 'error'}>{msg.text}</p>}
<div className="form-actions">
<button className="btn" type="submit" disabled={busy}>Passwort ändern</button>
</div>
</form>
);
}
+389
View File
@@ -0,0 +1,389 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api, personName } from '../api.js';
import ProductInput from '../components/ProductInput.jsx';
import RecipeCheck from '../components/RecipeCheck.jsx';
import UnitSelect from '../components/UnitSelect.jsx';
import { fmtItems, parseQty, round2, STEP } from '../units.js';
// Geteilte Liste: regelmäßig nachladen, damit Änderungen der anderen ankommen
const REFRESH_SECONDS = 15;
const TAB_KEY = 'p42.haushaltTab';
const LISTS = {
vorrat: { title: 'Vorrat', lead: 'Was zu Hause ist', empty: 'Noch nichts im Vorrat eingetragen.' },
einkauf: { title: 'Einkaufen', lead: 'Was besorgt werden muss', empty: 'Nichts zu besorgen.' },
};
function loadTab() {
try { return localStorage.getItem(TAB_KEY) === 'einkauf' ? 'einkauf' : 'vorrat'; } catch { return 'vorrat'; }
}
export default function Haushalt() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const [tab, setTab] = useState(loadTab);
const dialog = useRef(null);
const load = useCallback(async () => {
try {
setData(await api('GET', '/api/haushalt'));
} catch (e) {
setError(e.message);
}
}, []);
useEffect(() => {
load();
const timer = setInterval(() => !document.hidden && load(), REFRESH_SECONDS * 1000);
const onVisible = () => !document.hidden && load();
document.addEventListener('visibilitychange', onVisible);
return () => {
clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, [load]);
// Jede Änderung liefert den aktuellen Stand beider Listen zurück
async function run(method, url, body) {
setBusy(true);
setError(null);
try {
setData(await api(method, url, body));
return true;
} catch (e) {
setError(e.message);
// Bei Konflikten (z. B. gerade von jemand anderem gelöscht) frisch laden
load();
return false;
} finally {
setBusy(false);
}
}
function chooseTab(next) {
setTab(next);
try { localStorage.setItem(TAB_KEY, next); } catch { /* ignorieren */ }
}
const items = data?.items || [];
const byList = {
vorrat: items.filter((i) => i.list === 'vorrat'),
einkauf: items.filter((i) => i.list === 'einkauf'),
};
// Alle Zeilen eines Produkts (ein Produkt kann in mehreren Einheiten auf einer Liste stehen)
const find = (list, name) => byList[list].filter((i) => i.name.toLocaleLowerCase('de-DE') === name.toLocaleLowerCase('de-DE'));
const actions = {
add: (list, name, quantity, unit) => run('POST', '/api/haushalt/items', { list, name, quantity, unit }),
update: (item, patch) => run('PATCH', `/api/haushalt/items/${item.id}`, patch),
move: (item) => run('POST', `/api/haushalt/items/${item.id}/move`),
remove: (item) => run('DELETE', `/api/haushalt/items/${item.id}`),
};
return (
<>
<div className="page-head">
<div>
<h1>Vorrat &amp; Einkauf</h1>
<p className="lead">Gemeinsame Liste für alle Konten. Wird alle {REFRESH_SECONDS} Sekunden aktualisiert.</p>
</div>
{data?.products.length > 0 && (
<button className="link-btn" type="button" onClick={() => dialog.current.showModal()}>✎ Vorschläge verwalten</button>
)}
</div>
{error && <p className="error household-error" role="alert">{error}</p>}
{!data && !error && <p className="hint">Lade …</p>}
{data && (
<>
<RecipeCheck
products={data.products}
find={find}
busy={busy}
onTakeOver={(body) => run('POST', '/api/haushalt/rezept/uebernehmen', body)}
/>
<div className="segmented" role="tablist" aria-label="Liste wählen">
{Object.entries(LISTS).map(([key, { title }]) => (
<button
key={key}
type="button"
role="tab"
id={`tab-${key}`}
aria-selected={tab === key}
aria-controls={`panel-${key}`}
onClick={() => chooseTab(key)}
>
{title} <span className="count">{byList[key].length}</span>
</button>
))}
</div>
<div className="household">
{Object.keys(LISTS).map((list) => (
<ListPanel
key={list}
list={list}
items={byList[list]}
products={data.products}
find={find}
actions={actions}
busy={busy}
hidden={tab !== list}
/>
))}
</div>
</>
)}
<ProductsDialog ref={dialog} products={data?.products || []} onDelete={(name) => run('DELETE', `/api/haushalt/produkte?name=${encodeURIComponent(name)}`)} />
</>
);
}
function ListPanel({ list, items, products, find, actions, busy, hidden }) {
const { title, lead, empty } = LISTS[list];
const other = list === 'vorrat' ? 'einkauf' : 'vorrat';
// Zusatzinfo in den Vorschlägen: steht das Produkt schon irgendwo?
function hint(name) {
const here = find(list, name);
if (here.length) return `schon da: ${fmtItems(here)}`;
const there = find(other, name);
if (there.length) return `${list === 'vorrat' ? 'auf Einkaufsliste' : 'im Vorrat'}: ${fmtItems(there)}`;
return null;
}
return (
<section
className={`card household-panel ${list}`}
id={`panel-${list}`}
role="tabpanel"
aria-labelledby={`title-${list}`}
data-hidden={hidden || undefined}
>
<header className="household-head">
<h2 id={`title-${list}`}>{title}</h2>
<span className="hint">{lead}</span>
</header>
<AddForm list={list} products={products} hint={hint} onAdd={actions.add} busy={busy} />
{items.length === 0 ? (
<p className="hint household-empty">{empty}</p>
) : (
<ul className="household-list">
{items.map((item) => (
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
))}
</ul>
)}
</section>
);
}
function AddForm({ list, products, hint, onAdd, busy }) {
const [name, setName] = useState('');
const [quantity, setQuantity] = useState('1');
const [unit, setUnit] = useState('stk');
const [invalid, setInvalid] = useState(false);
const nameInput = useRef(null);
async function submit(e) {
e.preventDefault();
const n = name.trim();
const q = parseQty(quantity, unit);
setInvalid(q == null);
if (!n || q == null) return;
if (await onAdd(list, n, q, unit)) {
setName('');
setQuantity('1');
nameInput.current?.focus();
}
}
return (
<form className="household-add" onSubmit={submit} noValidate>
<label className="sr-only" htmlFor={`add-${list}`}>Produkt</label>
<ProductInput
id={`add-${list}`}
inputRef={nameInput}
value={name}
onChange={setName}
products={products}
hint={hint}
placeholder={list === 'vorrat' ? 'Was ist da? z. B. Milch' : 'Was fehlt? z. B. Brot'}
maxLength={60}
required
/>
<div className="qty-unit">
<label className="sr-only" htmlFor={`qty-${list}`}>Menge</label>
<input
id={`qty-${list}`}
className="household-qty-input"
type="text"
inputMode="decimal"
value={quantity}
onChange={(e) => { setQuantity(e.target.value); setInvalid(false); }}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? `qty-error-${list}` : undefined}
/>
<UnitSelect id={`unit-${list}`} value={unit} onChange={setUnit} />
</div>
<button className="btn" type="submit" disabled={busy}>Hinzufügen</button>
{invalid && (
<p className="error household-add-error" id={`qty-error-${list}`}>
{unit === 'stk' ? 'Anzahl als ganze Zahl von 1 bis 999 eingeben.' : 'Menge von 0,01 bis 999 eingeben, z. B. 1,5.'}
</p>
)}
</form>
);
}
// Menge direkt eintippen; gespeichert wird beim Verlassen des Felds oder mit Enter
function QuantityInput({ item, label, onCommit, disabled }) {
const shown = String(item.quantity).replace('.', ',');
const [draft, setDraft] = useState(null);
function commit() {
if (draft == null) return;
const q = parseQty(draft, item.unit);
setDraft(null);
if (q != null && q !== item.quantity) onCommit(q);
}
return (
<input
className="stepper-value"
type="text"
inputMode={item.unit === 'stk' ? 'numeric' : 'decimal'}
value={draft ?? shown}
disabled={disabled}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') setDraft(null);
}}
aria-label={`Menge ${label}`}
/>
);
}
function ItemRow({ item, list, other, actions, busy }) {
const isShopping = list === 'einkauf';
const step = STEP[item.unit];
const last = item.quantity - step <= 0;
// Bei gleichem Produkt in mehreren Einheiten die Einheit mit ansagen
const label = item.unit === 'stk' ? item.name : `${item.name} (${item.unit})`;
return (
<li className="household-item">
{isShopping && (
<input
type="checkbox"
className="household-check"
checked={false}
disabled={busy}
onChange={() => actions.move(item)}
aria-label={`${label} gekauft – in den Vorrat übernehmen`}
title="Gekauft: in den Vorrat übernehmen"
/>
)}
<div className="household-name">
<strong>{item.name}</strong>
<small>
{other.length > 0 && `${isShopping ? 'im Vorrat' : 'auf Einkaufsliste'}: ${fmtItems(other)} · `}
✎ {personName(item.updatedBy)}
</small>
</div>
<div className="stepper" role="group" aria-label={`Menge ${label}`}>
<button
type="button"
disabled={busy || (isShopping && last)}
onClick={() => (last ? actions.move(item) : actions.update(item, { quantity: round2(item.quantity - step) }))}
aria-label={last && !isShopping ? `${label} aufgebraucht – auf die Einkaufsliste` : `${label} weniger`}
title={last && !isShopping ? 'Aufgebraucht: auf die Einkaufsliste' : 'Weniger'}
>
−
</button>
<QuantityInput key={`${item.quantity}-${item.unit}`} item={item} label={label} disabled={busy} onCommit={(quantity) => actions.update(item, { quantity })} />
<button
type="button"
disabled={busy || item.quantity + step > 999}
onClick={() => actions.update(item, { quantity: round2(item.quantity + step) })}
aria-label={`${label} mehr`}
title="Mehr"
>
+
</button>
</div>
<UnitSelect
value={item.unit}
onChange={(unit) => actions.update(item, { unit })}
disabled={busy}
compact
label={`Einheit ${item.name}`}
/>
<div className="household-actions">
{!isShopping && (
<button
className="link-btn"
type="button"
disabled={busy || other.length > 0}
onClick={() => actions.add('einkauf', item.name, 1, item.unit)}
aria-label={other.length ? `${label} steht schon auf der Einkaufsliste` : `${label} nachkaufen`}
title={other.length ? 'Steht schon auf der Einkaufsliste' : 'Auf die Einkaufsliste setzen, Vorrat bleibt'}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 4h2l2.4 11.2a2 2 0 0 0 2 1.6h7.7a2 2 0 0 0 2-1.5L21 8H6.2" /><circle cx="10" cy="20" r="1.2" /><circle cx="17" cy="20" r="1.2" />
</svg>
<span className="label">Nachkaufen</span>
</button>
)}
<button
className="link-btn danger"
type="button"
disabled={busy}
onClick={() => actions.remove(item)}
aria-label={`${label} entfernen`}
title="Entfernen"
>
✕
</button>
</div>
</li>
);
}
function ProductsDialog({ ref, products, onDelete }) {
const sorted = [...products].sort((a, b) => a.localeCompare(b, 'de-DE'));
return (
<dialog className="modal" ref={ref}>
<div className="form">
<h2>Vorschläge verwalten</h2>
<p className="hint">
Tippfehler oder doppelte Schreibweisen hier löschen. Einträge auf den Listen bleiben erhalten.
</p>
<ul className="product-list">
{sorted.map((name) => (
<li key={name}>
<span>{name}</span>
<button className="link-btn danger" type="button" onClick={() => onDelete(name)} aria-label={`Vorschlag ${name} löschen`}>
Löschen
</button>
</li>
))}
</ul>
<div className="form-actions">
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Schließen</button>
</div>
</div>
</dialog>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useRef } from 'react';
import { Link } from 'react-router-dom';
import LinksDialog from '../components/LinksDialog.jsx';
import Tracking from '../components/Tracking.jsx';
import { LINK_SERVICES, useLinks } from '../links.jsx';
const svg = {
fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', viewBox: '0 0 24 24',
};
const ICONS = {
jellyfin: <svg {...svg}><rect x="2" y="5" width="20" height="14" rx="2" /><path d="m10 9 5 3-5 3z" /></svg>,
audiobookshelf: <svg {...svg}><path d="M3 18v-6a9 9 0 0 1 18 0v6" /><path d="M21 19a2 2 0 0 1-2 2h-1v-6h3zM3 19a2 2 0 0 0 2 2h1v-6H3z" /></svg>,
homer: <svg {...svg}><rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" /></svg>,
projekte: <svg {...svg}><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></svg>,
haushalt: <svg {...svg}><path d="M3 4h2l2.4 11.2a2 2 0 0 0 2 1.6h7.7a2 2 0 0 0 2-1.5L21 8H6.2" /><circle cx="10" cy="20" r="1.2" /><circle cx="17" cy="20" r="1.2" /></svg>,
reisen: <svg {...svg}><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></svg>,
kanban: <svg {...svg}><rect x="3" y="4" width="5" height="16" rx="1" /><rect x="10" y="4" width="5" height="10" rx="1" /><rect x="17" y="4" width="4" height="7" rx="1" /></svg>,
monitoring: <svg {...svg}><rect x="3" y="4" width="18" height="6" rx="1.5" /><rect x="3" y="14" width="18" height="6" rx="1.5" /><path d="M7 7h.01M7 17h.01" /></svg>,
};
export default function Home() {
const { links } = useLinks();
const dialog = useRef(null);
const editLinks = () => dialog.current.showModal();
return (
<>
<Tracking />
<section className="tiles-section" aria-labelledby="tiles-title">
<div className="tiles-head">
<h2 id="tiles-title" className="section-title">Schnellzugriff</h2>
<button className="link-btn" type="button" onClick={editLinks}>✎ Links bearbeiten</button>
</div>
<div className="tiles">
{LINK_SERVICES.map(({ key, name, description }) =>
links[key] ? (
<a key={key} className="tile" href={links[key]} target="_blank" rel="noopener noreferrer">
{ICONS[key]}
<strong>{name}</strong>
<small>{description}</small>
</a>
) : (
// Ohne Link öffnet die Kachel direkt den Bearbeiten-Dialog
<button key={key} className="tile tile-empty" type="button" onClick={editLinks}>
{ICONS[key]}
<strong>{name}</strong>
<small>Link festlegen …</small>
</button>
),
)}
<Link className="tile" to="/projekte">
{ICONS.projekte}
<strong>P42 Projekte</strong>
<small>Übersicht aller Projekte</small>
</Link>
<Link className="tile" to="/haushalt">
{ICONS.haushalt}
<strong>Vorrat &amp; Einkauf</strong>
<small>Gemeinsame Einkaufsliste</small>
</Link>
<Link className="tile" to="/reisen">
{ICONS.reisen}
<strong>Reisen</strong>
<small>Meine Weltkarte</small>
</Link>
<Link className="tile" to="/kanban">
{ICONS.kanban}
<strong>Kanban</strong>
<small>Gemeinsame Aufgaben</small>
</Link>
<Link className="tile" to="/monitoring">
{ICONS.monitoring}
<strong>Server-Monitoring</strong>
<small>Status meiner Server</small>
</Link>
</div>
</section>
<LinksDialog ref={dialog} />
</>
);
}
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { api, fmtDate } from '../api.js';
import { useAuth } from '../auth.jsx';
import KanbanProjectDialog from '../components/KanbanProjectDialog.jsx';
import { COLUMNS } from './KanbanBoard.jsx';
const DONE_OPEN_KEY = 'p42.kanbanAbgeschlossenOffen';
// Übersicht: aktive Kanban-Projekte (je ein Board), darunter die abgeschlossenen
export default function Kanban() {
const { user } = useAuth();
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [projectForm, setProjectForm] = useState(null);
const [doneOpen, setDoneOpen] = useState(() => {
try { return localStorage.getItem(DONE_OPEN_KEY) === '1'; } catch { return false; }
});
const projectDialog = useRef(null);
useEffect(() => {
api('GET', '/api/kanban').then(setData).catch((e) => setError(e.message));
}, []);
// Von der Artikelseite: /kanban?artikel=<id> öffnet "Neues Projekt" mit dieser Verknüpfung
useEffect(() => {
const article = params.get('artikel');
if (!data || !article) return;
newProject([article]);
setParams({}, { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
function newProject(links = []) {
setProjectForm({ links, opened: Date.now() });
}
useEffect(() => {
if (projectForm) projectDialog.current?.showModal();
}, [projectForm]);
async function createProject(body) {
try {
const res = await api('POST', '/api/kanban/projekte', body);
navigate(`/kanban/${res.id}`);
return null;
} catch (e) {
return e.message;
}
}
async function reopen(project) {
try {
await api('PATCH', `/api/kanban/projekte/${project.id}`, { completed: false });
setData(await api('GET', '/api/kanban'));
} catch (e) {
setError(e.message);
}
}
async function remove(project) {
const n = Object.values(project.counts).reduce((a, b) => a + b, 0);
if (!window.confirm(`Kanban-Projekt „${project.name}“${n ? ` mit ${n} ${n === 1 ? 'Aufgabe' : 'Aufgaben'}` : ''} endgültig löschen?`)) return;
try {
setData(await api('DELETE', `/api/kanban/projekte/${project.id}`));
} catch (e) {
setError(e.message);
}
}
// Selbst steuern statt auf das (verzögerte) toggle-Ereignis zu warten: Zustand ist sofort gespeichert
function toggleDone(e) {
e.preventDefault();
const next = !doneOpen;
setDoneOpen(next);
try { localStorage.setItem(DONE_OPEN_KEY, next ? '1' : '0'); } catch { /* ignorieren */ }
}
const projects = data?.projects || [];
const active = projects.filter((p) => !p.completedAt);
const done = projects.filter((p) => p.completedAt).sort((a, b) => b.completedAt.localeCompare(a.completedAt));
return (
<>
<div className="page-head">
<div>
<h1>Kanban</h1>
<p className="lead">
{data
? `${active.length} ${active.length === 1 ? 'aktives Projekt' : 'aktive Projekte'}${done.length ? `, ${done.length} abgeschlossen` : ''} · gemeinsam für alle Konten`
: 'Lade …'}
</p>
</div>
{data && <button className="btn" type="button" onClick={() => newProject()}>+ Projekt</button>}
</div>
{error && <p className="error kanban-error" role="alert">{error}</p>}
{data && projects.length === 0 && (
<div className="notice">Noch keine Projekte. Leg mit „+ Projekt“ das erste an. Jedes Projekt bekommt ein eigenes Board.</div>
)}
{data && projects.length > 0 && active.length === 0 && (
<p className="hint">Alle Projekte sind abgeschlossen.</p>
)}
{active.length > 0 && (
<div className="grid kanban-projects">
{active.map((p) => <ProjectCard key={p.id} project={p} />)}
</div>
)}
{done.length > 0 && (
<details className="kanban-done" open={doneOpen}>
<summary onClick={toggleDone}><h2 className="section-title">Abgeschlossen <span className="count">{done.length}</span></h2></summary>
<div className="grid kanban-projects">
{done.map((p) => (
<ProjectCard key={p.id} project={p}>
<div className="kanban-project-actions">
<button className="link-btn" type="button" onClick={() => reopen(p)}>Wieder öffnen</button>
{(user.isAdmin || p.createdBy?.id === user.id) && (
<button className="link-btn danger" type="button" onClick={() => remove(p)}>Löschen</button>
)}
</div>
</ProjectCard>
))}
</div>
</details>
)}
<KanbanProjectDialog
key={projectForm?.opened ?? 'zu'}
ref={projectDialog}
initial={projectForm}
isNew
articles={data?.articles || []}
onSave={createProject}
/>
</>
);
}
function ProjectCard({ project, children }) {
const total = Object.values(project.counts).reduce((a, b) => a + b, 0);
return (
<article className={`card kanban-project-card${project.completedAt ? ' completed' : ''}`}>
{/* Der Titel-Link deckt die ganze Karte ab; Buttons darunter bleiben klickbar */}
<h3><Link className="kanban-project-link" to={`/kanban/${project.id}`}>{project.name}</Link></h3>
{project.completedAt && <span className="badge ok kanban-done-badge">Abgeschlossen {fmtDate(project.completedAt)}</span>}
{project.description && <p className="kanban-project-desc">{project.description}</p>}
{total > 0 ? (
<>
{/* Anteil je Status als Balken; die genauen Zahlen stehen darunter */}
<div className="status-bar" aria-hidden="true">
{COLUMNS.map((c) => project.counts[c.key] > 0 && (
<span key={c.key} className={c.key} style={{ flexGrow: project.counts[c.key] }} />
))}
</div>
<p className="kanban-project-counts">
{COLUMNS.filter((c) => project.counts[c.key]).map((c) => `${project.counts[c.key]} ${c.title}`).join(' · ')}
</p>
</>
) : (
<p className="kanban-project-counts">Noch keine Aufgaben</p>
)}
{project.links.length > 0 && (
<p className="kanban-project-links">▤ {project.links.map((l) => l.title).join(', ')}</p>
)}
{children}
</article>
);
}
+411
View File
@@ -0,0 +1,411 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api, fmtDate, personName } from '../api.js';
import { useAuth } from '../auth.jsx';
import KanbanProjectDialog from '../components/KanbanProjectDialog.jsx';
// Board eines Kanban-Projekts. Gemeinsam: regelmäßig nachladen, damit Änderungen der anderen ankommen
const REFRESH_SECONDS = 15;
const FILTER_KEY = 'p42.kanbanFilter';
export const PRIORITIES = [
{ key: 'hoch', title: 'Hoch', rank: 1 },
{ key: 'mittel', title: 'Mittel', rank: 2 },
{ key: 'niedrig', title: 'Niedrig', rank: 3 },
];
const RANK = Object.fromEntries(PRIORITIES.map((p) => [p.key, p.rank]));
// Innerhalb einer Spalte: zuerst nach Priorität, dann eigene Reihenfolge
const byPriority = (a, b) => (RANK[a.priority] ?? 2) - (RANK[b.priority] ?? 2) || a.position - b.position;
export const COLUMNS = [
{ key: 'offen', title: 'Offen' },
{ key: 'arbeit', title: 'In Arbeit' },
{ key: 'wartet', title: 'Wartet' },
{ key: 'erledigt', title: 'Erledigt' },
];
const initials = (name) => name.split(/\s+/).map((w) => w[0]).join('').slice(0, 2).toUpperCase();
function loadFilter() {
try { return localStorage.getItem(FILTER_KEY) || 'alle'; } catch { return 'alle'; }
}
// Position zwischen den Nachbarn an der Einfügestelle
function positionAt(cards, index) {
const before = cards[index - 1]?.position;
const after = cards[index]?.position;
if (before == null && after == null) return 1;
if (before == null) return after - 1;
if (after == null) return before + 1;
return (before + after) / 2;
}
export default function KanbanBoard() {
const { id } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [filter, setFilter] = useState(loadFilter);
const [editing, setEditing] = useState(null); // null | { task } | { status } (neu)
const [drag, setDrag] = useState(null); // { id, status, index } während Drag & Drop
const dialog = useRef(null);
const projectDialog = useRef(null);
const [projectForm, setProjectForm] = useState(null);
const load = useCallback(async () => {
try {
setData(await api('GET', `/api/kanban/projekte/${encodeURIComponent(id)}`));
} catch (e) {
setError(e.message);
}
}, [id]);
useEffect(() => {
if (data?.project) document.title = `${data.project.name} – Kanban`;
return () => { document.title = 'P42 Dashboard'; };
}, [data?.project?.name]);
useEffect(() => {
load();
const timer = setInterval(() => !document.hidden && load(), REFRESH_SECONDS * 1000);
const onVisible = () => !document.hidden && load();
document.addEventListener('visibilitychange', onVisible);
return () => {
clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
};
}, [load]);
async function run(method, url, body) {
setError(null);
try {
setData(await api(method, url, body));
return true;
} catch (e) {
setError(e.message);
load();
return false;
}
}
function chooseFilter(value) {
setFilter(value);
try { localStorage.setItem(FILTER_KEY, value); } catch { /* ignorieren */ }
}
// Erst rendern (Formular ist dann schon befüllt), dann öffnen – sonst ist das Feld kurz leer
function open(target) {
setEditing({ ...target, opened: Date.now() });
}
useEffect(() => {
if (editing) dialog.current?.showModal();
}, [editing]);
const users = data?.users || [];
const project = data?.project;
function editProject() {
setProjectForm({ name: project.name, description: project.description, links: project.links.map((l) => l.id), opened: Date.now() });
}
useEffect(() => {
if (projectForm) projectDialog.current?.showModal();
}, [projectForm]);
async function saveProject(body) {
try {
setData(await api('PATCH', `/api/kanban/projekte/${project.id}`, body));
return null;
} catch (e) {
return e.message;
}
}
const mayDelete = user.isAdmin || project?.createdBy?.id === user.id;
async function setCompleted(completed) {
await saveProject({ completed }).then((err) => err && setError(err));
}
async function deleteProject() {
const n = data.tasks.length;
if (!window.confirm(`Kanban-Projekt „${project.name}“${n ? ` mit ${n} ${n === 1 ? 'Aufgabe' : 'Aufgaben'}` : ''} endgültig löschen?`)) return;
try {
await api('DELETE', `/api/kanban/projekte/${project.id}`);
navigate('/kanban');
} catch (e) {
setError(e.message);
}
}
if (!data) {
return <><BackLink />{error ? <p className="error">{error}</p> : <p className="hint">Lade …</p>}</>;
}
const visible = (data?.tasks || []).filter((t) =>
filter === 'alle' ? true
: filter === 'meine' ? t.assignee === user.id
: filter === 'niemand' ? t.assignee == null
: t.assignee === Number(filter));
const byColumn = Object.fromEntries(COLUMNS.map((c) => [c.key, visible.filter((t) => t.status === c.key).sort(byPriority)]));
// ---------- Drag & Drop (Desktop) ----------
function onDragOver(e, status) {
if (!drag) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const cards = [...e.currentTarget.querySelectorAll('.task-card:not(.dragging)')];
let index = cards.findIndex((el) => {
const r = el.getBoundingClientRect();
return e.clientY < r.top + r.height / 2;
});
if (index < 0) index = cards.length;
if (drag.status !== status || drag.index !== index) setDrag((d) => ({ ...d, status, index }));
}
function onDrop(e, status) {
e.preventDefault();
if (!drag) return;
// Die Karte behält ihre Priorität: einsortiert wird nur zwischen Karten derselben Stufe
const moved = data.tasks.find((t) => t.id === drag.id);
const cards = byColumn[status].filter((t) => t.id !== drag.id);
const index = drag.index ?? cards.length;
const same = cards.filter((t) => t.priority === moved.priority);
const sameIndex = cards.slice(0, index).filter((t) => t.priority === moved.priority).length;
const position = positionAt(same, sameIndex);
const id = drag.id;
setDrag(null);
// Sofort anzeigen, der Server bestätigt mit dem neuen Stand
setData((d) => ({ ...d, tasks: d.tasks.map((t) => (t.id === id ? { ...t, status, position } : t)).sort((a, b) => a.position - b.position) }));
run('PATCH', `/api/kanban/aufgaben/${id}`, { status, position });
}
return (
<>
<BackLink />
{project.completedAt && (
<div className="notice board-completed">
Abgeschlossen am {fmtDate(project.completedAt)}. Aufgaben lassen sich weiter bearbeiten.
</div>
)}
<div className="page-head board-page-head">
<div>
<h1>{project.name}</h1>
{project.description && <p className="lead board-description">{project.description}</p>}
{project.links.length > 0 && (
<ul className="chip-list board-links" aria-label="Verknüpfte Artikel">
{project.links.map((a) => (
<li key={a.id}><Link className="chip" to={`/projekte/${a.id}`}>▤ {a.title}</Link></li>
))}
</ul>
)}
</div>
<div className="page-actions">
<button className="btn ghost" type="button" onClick={editProject}>Bearbeiten</button>
<button className="btn ghost" type="button" onClick={() => setCompleted(!project.completedAt)}>
{project.completedAt ? 'Wieder öffnen' : 'Abschließen'}
</button>
{mayDelete && <button className="btn ghost danger-btn" type="button" onClick={deleteProject}>Löschen</button>}
<label className="kanban-filter">
<span>Bearbeiter</span>
<select value={filter} onChange={(e) => chooseFilter(e.target.value)}>
<option value="alle">Alle</option>
<option value="meine">Mir zugeteilt</option>
<option value="niemand">Niemandem zugeteilt</option>
{users.filter((u) => u.id !== user.id).map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</label>
<button className="btn" type="button" onClick={() => open({ status: 'offen' })}>+ Neue Aufgabe</button>
</div>
</div>
{error && <p className="error kanban-error" role="alert">{error}</p>}
<div className="board">
{COLUMNS.map((col) => {
const cards = byColumn[col.key];
const target = drag?.status === col.key;
return (
<section
key={col.key}
className={`board-column ${col.key}${target ? ' drop-target' : ''}`}
aria-labelledby={`col-${col.key}`}
onDragOver={(e) => onDragOver(e, col.key)}
onDrop={(e) => onDrop(e, col.key)}
>
<header className="board-head">
<h2 id={`col-${col.key}`}>{col.title} <span className="count">{cards.length}</span></h2>
<button className="link-btn" type="button" onClick={() => open({ status: col.key })} aria-label={`Neue Aufgabe in ${col.title}`}>+</button>
</header>
<ul className="board-cards">
{cards.map((task) => {
// Gezogene Karte bleibt im DOM (sonst bricht der Browser das Ziehen ab), zählt aber nicht mit
const index = cards.filter((t) => t.id !== drag?.id).indexOf(task);
return (
<li key={task.id}>
{target && index >= 0 && drag.index === index && <div className="drop-line" aria-hidden="true" />}
<TaskCard
task={task}
users={users}
dragging={drag?.id === task.id}
onOpen={() => open({ task })}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(task.id));
// Erst im nächsten Frame markieren, sonst fehlt die Karte im Drag-Bild
requestAnimationFrame(() => setDrag({ id: task.id, status: null, index: null }));
}}
onDragEnd={() => setDrag(null)}
/>
</li>
);
})}
{target && drag.index >= cards.filter((t) => t.id !== drag.id).length && <li className="drop-line" aria-hidden="true" />}
{cards.length === 0 && !target && <li className="board-empty">Keine Aufgaben</li>}
</ul>
</section>
);
})}
</div>
<TaskDialog
key={`aufgabe-${editing?.opened ?? 'zu'}`}
ref={dialog}
target={editing}
users={users}
me={user}
onSave={(taskId, body) => run(taskId ? 'PATCH' : 'POST', taskId ? `/api/kanban/aufgaben/${taskId}` : `/api/kanban/projekte/${project.id}/aufgaben`, body)}
onDelete={(taskId) => run('DELETE', `/api/kanban/aufgaben/${taskId}`)}
/>
<KanbanProjectDialog
key={`projekt-${projectForm?.opened ?? 'zu'}`}
ref={projectDialog}
initial={projectForm}
articles={data.articles}
onSave={saveProject}
/>
</>
);
}
function BackLink() {
return <Link className="back" to="/kanban">← Alle Projekte</Link>;
}
function Assignee({ id, users }) {
const person = users.find((u) => u.id === id);
if (!person) return <span className="assignee none">Niemand</span>;
return (
<span className="assignee">
<span className="avatar" aria-hidden="true">{initials(person.name)}</span>
{person.name}
</span>
);
}
function TaskCard({ task, users, dragging, onOpen, onDragStart, onDragEnd }) {
return (
<button
type="button"
className={`task-card prio-card-${task.priority}${dragging ? ' dragging' : ''}`}
draggable
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onClick={onOpen}
>
<span className="task-top">
<strong className="task-title">{task.title}</strong>
{task.priority !== 'mittel' && (
<span className={`prio prio-${task.priority}`} title={`Priorität: ${PRIORITIES.find((p) => p.key === task.priority)?.title}`}>
{PRIORITIES.find((p) => p.key === task.priority)?.title}
</span>
)}
</span>
{task.description && <span className="task-desc">{task.description}</span>}
<span className="task-meta">
<Assignee id={task.assignee} users={users} />
</span>
</button>
);
}
function TaskDialog({ ref, target, users, me, onSave, onDelete }) {
const task = target?.task;
// Wird pro Öffnen neu erzeugt (key), daher direkt aus den Daten vorbelegen
const [form, setForm] = useState(() => (task
? { title: task.title, description: task.description, status: task.status, assignee: task.assignee ?? '', priority: task.priority || 'mittel' }
: { title: '', description: '', status: target?.status || 'offen', assignee: '', priority: 'mittel' }));
const [busy, setBusy] = useState(false);
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
const canDelete = task && (me.isAdmin || task.createdBy?.id === me.id);
async function submit(e) {
e.preventDefault();
setBusy(true);
const ok = await onSave(task?.id, {
title: form.title,
description: form.description,
status: form.status,
assignee: form.assignee === '' ? null : Number(form.assignee),
priority: form.priority,
});
setBusy(false);
if (ok) ref.current.close();
}
async function remove() {
if (!window.confirm(`Aufgabe „${task.title}“ löschen?`)) return;
setBusy(true);
const ok = await onDelete(task.id);
setBusy(false);
if (ok) ref.current.close();
}
return (
<dialog className="modal task-dialog" ref={ref}>
<form className="form" onSubmit={submit}>
<h2>{task ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'}</h2>
<label>
Titel
<input {...field('title')} maxLength={120} required autoFocus />
</label>
<label>
Beschreibung
<textarea {...field('description')} rows={6} maxLength={5000} placeholder="Was ist zu tun? Details, Links, Notizen …" />
</label>
<div className="task-dialog-row">
<label>
Status
<select {...field('status')}>
{COLUMNS.map((c) => <option key={c.key} value={c.key}>{c.title}</option>)}
</select>
</label>
<label>
Priorität
<select {...field('priority')}>
{PRIORITIES.map((p) => <option key={p.key} value={p.key}>{p.title}</option>)}
</select>
</label>
<label>
Bearbeiter
<select {...field('assignee')}>
<option value="">Niemand</option>
{users.map((u) => <option key={u.id} value={u.id}>{u.name}{u.id === me.id ? ' (ich)' : ''}</option>)}
</select>
</label>
</div>
{task && (
<p className="hint task-history">
Angelegt von {personName(task.createdBy)} am {fmtDate(task.createdAt)}
{task.updatedAt !== task.createdAt && <> · zuletzt geändert von {personName(task.updatedBy)} am {fmtDate(task.updatedAt)}</>}
</p>
)}
<div className="form-actions">
{canDelete && <button className="link-btn danger task-delete" type="button" disabled={busy} onClick={remove}>Löschen</button>}
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
<button className="btn" type="submit" disabled={busy}>{task ? 'Speichern' : 'Anlegen'}</button>
</div>
</form>
</dialog>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useState } from 'react';
import { useAuth } from '../auth.jsx';
// Anmeldung bzw. – beim allerersten Start – Anlegen des ersten Kontos (Administrator)
export default function Login() {
const { state, login, setup } = useAuth();
const isSetup = state === 'setup';
const [form, setForm] = useState({ username: '', displayName: '', password: '', repeat: '' });
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
async function submit(e) {
e.preventDefault();
setError(null);
if (isSetup && form.password !== form.repeat) return setError('Die Passwörter stimmen nicht überein.');
setBusy(true);
try {
if (isSetup) await setup({ username: form.username, displayName: form.displayName, password: form.password });
else await login(form.username, form.password);
} catch (err) {
setError(err.message);
setBusy(false);
}
}
return (
<div className="auth-page">
<form className="card auth-card form" onSubmit={submit}>
<div className="brand auth-brand">P42<span>.</span>Dashboard</div>
<h2>{isSetup ? 'Erstes Konto anlegen' : 'Anmelden'}</h2>
{isSetup && (
<p className="hint">
Dieses Konto wird Administrator und kann weitere Konten anlegen.
Bisherige Projekte und Links werden ihm zugeordnet.
</p>
)}
<label>
Benutzername
<input {...field('username')} autoComplete="username" autoCapitalize="none" required autoFocus />
</label>
{isSetup && (
<label>
Anzeigename <small>(wird bei Artikeln angezeigt)</small>
<input {...field('displayName')} placeholder="z. B. Elias" maxLength={60} />
</label>
)}
<label>
Passwort
<input {...field('password')} type="password" autoComplete={isSetup ? 'new-password' : 'current-password'} required />
</label>
{isSetup && (
<label>
Passwort wiederholen
<input {...field('repeat')} type="password" autoComplete="new-password" required />
</label>
)}
{error && <p className="error">{error}</p>}
<button className="btn full" type="submit" disabled={busy}>
{busy ? 'Bitte warten …' : isSetup ? 'Konto anlegen' : 'Anmelden'}
</button>
</form>
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../api.js';
const REFRESH_SECONDS = 30;
const TYPES = {
http: { label: 'Webseite (HTTP)', placeholder: 'https://jellyfin.example.de', hint: 'Online, wenn die Seite antwortet.' },
tcp: { label: 'Port (TCP)', placeholder: '192.168.0.10:22', hint: 'Online, wenn der Port Verbindungen annimmt.' },
};
const fmtTime = (iso) => new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
export default function Monitoring() {
const [monitors, setMonitors] = useState(null);
const [status, setStatus] = useState({});
const [checking, setChecking] = useState(false);
const [error, setError] = useState(null);
const [editing, setEditing] = useState(null); // null | {} (neu) | monitor
const dialog = useRef(null);
const check = useCallback(async () => {
setChecking(true);
try {
const results = await api('GET', '/api/monitors/status');
setStatus(Object.fromEntries(results.map((r) => [r.id, r])));
} catch (e) {
setError(e.message);
} finally {
setChecking(false);
}
}, []);
const load = useCallback(async () => {
try {
setMonitors(await api('GET', '/api/monitors'));
await check();
} catch (e) {
setError(e.message);
}
}, [check]);
useEffect(() => {
load();
const timer = setInterval(check, REFRESH_SECONDS * 1000);
return () => clearInterval(timer);
}, [load, check]);
// Erst rendern (Formular ist dann schon befüllt), dann öffnen
function openDialog(monitor) {
setEditing({ ...(monitor || {}), opened: Date.now() });
}
useEffect(() => {
if (editing) dialog.current?.showModal();
}, [editing]);
async function remove(m) {
if (!window.confirm(`„${m.name}“ aus dem Monitoring entfernen?`)) return;
try {
await api('DELETE', `/api/monitors/${m.id}`);
setMonitors((list) => list.filter((x) => x.id !== m.id));
} catch (e) {
setError(e.message);
}
}
const online = monitors ? monitors.filter((m) => status[m.id]?.ok).length : 0;
const lastCheck = Object.values(status).map((s) => s.checkedAt).sort().at(-1);
return (
<>
<div className="page-head">
<div>
<h1>Server-Monitoring</h1>
<p className="lead">
Deine Server – nur für dein Konto sichtbar. Wird alle {REFRESH_SECONDS} Sekunden geprüft.
</p>
</div>
<div className="page-actions">
{monitors?.length > 0 && (
<button className="btn ghost" type="button" onClick={check} disabled={checking}>
{checking ? 'Prüfe …' : 'Jetzt prüfen'}
</button>
)}
<button className="btn" type="button" onClick={() => openDialog()}>+ Server hinzufügen</button>
</div>
</div>
{error && <p className="error">{error}</p>}
{!monitors && !error && <p className="hint">Lade …</p>}
{monitors?.length === 0 && (
<div className="notice">
Noch keine Server eingetragen. Über „+ Server hinzufügen“ legst du fest, welche Webseiten oder Ports geprüft werden.
</div>
)}
{monitors?.length > 0 && (
<>
<p className="monitor-summary">
<span className={`dot ${online === monitors.length ? 'ok' : online === 0 ? 'bad' : 'warn'}`} />
{online} von {monitors.length} online
{lastCheck && <span className="monitor-summary-time">Stand {fmtTime(lastCheck)}</span>}
</p>
<div className="grid">
{monitors.map((m) => {
const s = status[m.id];
const state = !s ? 'unknown' : s.ok ? 'ok' : 'bad';
return (
<article className={`card monitor-card ${state}`} key={m.id}>
<div className="monitor-head">
<h3>{m.name}</h3>
<span className={`badge ${state === 'ok' ? 'ok' : state === 'bad' ? 'bad' : 'muted'}`}>
{state === 'ok' ? 'Online' : state === 'bad' ? 'Offline' : 'Wird geprüft …'}
</span>
</div>
<p className="monitor-target">{m.target}</p>
<dl className="monitor-facts">
<div><dt>Typ</dt><dd>{TYPES[m.type]?.label}</dd></div>
<div><dt>Antwortzeit</dt><dd>{s?.ms != null ? `${s.ms} ms` : '—'}</dd></div>
<div><dt>Ergebnis</dt><dd>{s?.detail || '—'}</dd></div>
</dl>
<div className="meta monitor-foot">
<span>{s ? `Geprüft ${fmtTime(s.checkedAt)}` : ''}</span>
<span className="monitor-actions">
<button className="link-btn" type="button" onClick={() => openDialog(m)}>Bearbeiten</button>
<button className="link-btn danger" type="button" onClick={() => remove(m)}>Entfernen</button>
</span>
</div>
</article>
);
})}
</div>
</>
)}
<MonitorDialog key={editing?.opened ?? 'zu'} ref={dialog} monitor={editing} onSaved={load} />
</>
);
}
function MonitorDialog({ ref, monitor, onSaved }) {
// Wird pro Öffnen neu erzeugt (key beim Aufrufer), daher direkt vorbelegen
const [form, setForm] = useState(() => ({ name: monitor?.name || '', type: monitor?.type || 'http', target: monitor?.target || '' }));
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const isNew = !monitor?.id;
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
async function submit(e) {
e.preventDefault();
setBusy(true);
setError(null);
try {
if (isNew) await api('POST', '/api/monitors', form);
else await api('PUT', `/api/monitors/${monitor.id}`, form);
ref.current.close();
await onSaved();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
const type = TYPES[form.type];
return (
<dialog className="modal" ref={ref}>
<form className="form" onSubmit={submit}>
<h2>{isNew ? 'Server hinzufügen' : 'Server bearbeiten'}</h2>
<label>
Name
<input {...field('name')} maxLength={60} placeholder="z. B. Jellyfin" required />
</label>
<label>
Prüfung
<select {...field('type')}>
{Object.entries(TYPES).map(([k, t]) => <option key={k} value={k}>{t.label}</option>)}
</select>
</label>
<label>
{form.type === 'http' ? 'Adresse' : 'Host und Port'}
<input {...field('target')} placeholder={type.placeholder} autoCapitalize="none" spellCheck={false} required />
</label>
<p className="hint">{type.hint}</p>
{error && <p className="error">{error}</p>}
<div className="form-actions">
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
<button className="btn" type="submit" disabled={busy}>Speichern</button>
</div>
</form>
</dialog>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Link } from 'react-router-dom';
export default function NotFound() {
return (
<div className="center">
<h1>404 – Seite nicht gefunden</h1>
<p><Link to="/">Zur Startseite</Link></p>
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useBlocker, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api, fmtDate, imgUrl, personName, STATUS, uploadImage } from '../api.js';
import { useAuth } from '../auth.jsx';
import Editor from '../components/Editor.jsx';
import Lightbox from '../components/Lightbox.jsx';
import StatusBadge from '../components/StatusBadge.jsx';
export default function Project() {
const { id } = useParams();
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const { user } = useAuth();
const [project, setProject] = useState(null);
const [loadError, setLoadError] = useState(null);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(null);
const [dirty, setDirty] = useState(false);
const [busy, setBusy] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState(null);
const [zoom, setZoom] = useState(null);
const editor = useRef(null);
const coverInput = useRef(null);
useEffect(() => {
setProject(null);
setLoadError(null);
api('GET', `/api/projects/${encodeURIComponent(id)}`)
.then((p) => {
setProject(p);
if (params.has('bearbeiten')) startEditing(p);
})
.catch((e) => setLoadError(e.message === 'Nicht gefunden.' ? 'Projekt nicht gefunden.' : e.message));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
useEffect(() => {
if (project) document.title = `${project.title} – P42 Projekte`;
return () => { document.title = 'P42 Dashboard'; };
}, [project]);
// Warnung beim Verlassen mit ungespeicherten Änderungen (innerhalb der App und beim Schließen)
const dirtyRef = useRef(false);
dirtyRef.current = dirty;
const blocker = useBlocker(({ currentLocation, nextLocation }) =>
dirtyRef.current && currentLocation.pathname !== nextLocation.pathname);
useEffect(() => {
if (blocker.state !== 'blocked') return;
if (window.confirm('Ungespeicherte Änderungen verwerfen?')) blocker.proceed();
else blocker.reset();
}, [blocker]);
useEffect(() => {
if (!dirty) return;
const warn = (e) => e.preventDefault();
window.addEventListener('beforeunload', warn);
return () => window.removeEventListener('beforeunload', warn);
}, [dirty]);
function startEditing(p = project) {
setDraft({ title: p.title, description: p.description, status: p.status, cover: p.cover });
setEditing(true);
setDirty(false);
setError(null);
}
function stopEditing() {
setEditing(false);
setDirty(false);
setDraft(null);
if (params.has('bearbeiten')) setParams({}, { replace: true });
}
const change = (field) => (e) => {
setDraft((d) => ({ ...d, [field]: e.target.value }));
setDirty(true);
};
async function changeCover(e) {
const file = e.target.files[0];
e.target.value = '';
if (!file) return;
setUploading(true);
try {
const cover = await uploadImage(file);
setDraft((d) => ({ ...d, cover }));
setDirty(true);
} catch (err) {
setError(err.message);
} finally {
setUploading(false);
}
}
async function save() {
setBusy(true);
setError(null);
try {
const updated = await api('PATCH', `/api/projects/${id}`, { ...draft, content: editor.current.getHTML() });
setProject(updated);
stopEditing();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
function cancel() {
if (dirty && !window.confirm('Änderungen verwerfen?')) return;
stopEditing();
}
async function remove() {
if (!window.confirm(`„${project.title}“ mit allen Bildern endgültig löschen?`)) return;
try {
await api('DELETE', `/api/projects/${id}`);
dirtyRef.current = false;
navigate('/projekte');
} catch (err) {
setError(err.message);
}
}
// Klick auf Bilder im Artikel vergrößert sie
function onArticleClick(e) {
if (e.target.tagName === 'IMG') setZoom(e.target.src);
}
if (loadError) return <><BackLink /><p className="error">{loadError}</p></>;
if (!project) return <><BackLink /><p className="hint">Lade Projekt …</p></>;
const cover = editing ? draft.cover : project.cover;
// Löschen dürfen nur der Autor und Administratoren
const canDelete = user.isAdmin || project.createdBy?.id === user.id;
return (
<div className={`article-page${editing ? ' editing' : ''}`}>
<BackLink />
{(cover || editing) && (
<figure className={`article-cover${cover ? '' : ' empty'}`}>
{cover && <img src={imgUrl(cover)} alt="" onClick={() => !editing && setZoom(imgUrl(cover))} />}
{editing && (
<div className="cover-actions">
<button className="btn ghost small" type="button" disabled={uploading} onClick={() => coverInput.current.click()}>
{uploading ? 'Lade …' : cover ? 'Titelbild ändern' : 'Titelbild wählen'}
</button>
{cover && (
<button className="btn ghost small" type="button" onClick={() => { setDraft((d) => ({ ...d, cover: null })); setDirty(true); }}>
Entfernen
</button>
)}
<input ref={coverInput} type="file" accept="image/*" hidden onChange={changeCover} />
</div>
)}
</figure>
)}
<div className="article-layout">
<article className="article-main">
{editing ? (
<>
<input className="title-input" value={draft.title} onChange={change('title')} maxLength={120} placeholder="Titel" aria-label="Titel" />
<textarea
className="lead-input"
value={draft.description}
onChange={change('description')}
rows={2}
maxLength={2000}
placeholder="Kurzbeschreibung (erscheint auch in der Übersicht)"
aria-label="Kurzbeschreibung"
/>
<Editor
ref={editor}
content={project.content}
onChange={() => setDirty(true)}
onUploading={setUploading}
onError={setError}
/>
</>
) : (
<>
<h1 className="article-title">{project.title}</h1>
<p className="article-byline">
Von <strong>{personName(project.createdBy)}</strong>
{project.updatedAt !== project.createdAt && (
<> · zuletzt bearbeitet von <strong>{personName(project.updatedBy)}</strong> am {fmtDate(project.updatedAt)}</>
)}
</p>
{project.description && <p className="article-lead">{project.description}</p>}
{project.content ? (
<ArticleBody html={project.content} onClick={onArticleClick} />
) : (
<p className="hint article-empty">Noch kein Artikeltext. Über „Bearbeiten“ kannst du ihn schreiben.</p>
)}
</>
)}
</article>
<aside className="article-aside">
<div className="card aside-card">
<dl className="aside-facts">
<div>
<dt>Status</dt>
<dd>
{editing ? (
<select value={draft.status} onChange={change('status')} aria-label="Status">
{Object.entries(STATUS).map(([k, [label]]) => <option key={k} value={k}>{label}</option>)}
</select>
) : (
<StatusBadge status={project.status} />
)}
</dd>
</div>
<div>
<dt>Geschrieben von</dt>
<dd>{personName(project.createdBy)}<span className="sub">{fmtDate(project.createdAt)}</span></dd>
</div>
<div>
<dt>Zuletzt bearbeitet von</dt>
<dd>{personName(project.updatedBy)}<span className="sub">{fmtDate(project.updatedAt)}</span></dd>
</div>
<div>
<dt>Kanban-Boards</dt>
<dd className="aside-boards">
{project.boards?.map((b) => <Link key={b.id} to={`/kanban/${b.id}`}>▦ {b.name}</Link>)}
<Link className="sub" to={`/kanban?artikel=${project.id}`}>+ Board zu diesem Artikel</Link>
</dd>
</div>
</dl>
{!editing && (
<button className="btn full" type="button" onClick={() => startEditing()}>Bearbeiten</button>
)}
{editing && (
<div className="edit-actions">
{error && <p className="error">{error}</p>}
<div className="edit-buttons">
<button className="btn ghost" type="button" onClick={cancel}>Abbrechen</button>
<button className="btn" type="button" onClick={save} disabled={busy || uploading}>
{busy ? 'Speichere …' : 'Speichern'}
</button>
</div>
{canDelete && <button className="link-btn danger" type="button" onClick={remove}>Projekt löschen</button>}
</div>
)}
</div>
</aside>
</div>
<Lightbox src={zoom} onClose={() => setZoom(null)} />
</div>
);
}
function BackLink() {
return <Link className="back" to="/projekte">← Alle Projekte</Link>;
}
// Artikeltext wird serverseitig bereinigt (nh3) und kann daher direkt eingesetzt werden
function ArticleBody({ html, onClick }) {
const ref = useRef(null);
useEffect(() => {
ref.current.querySelectorAll('a[href^="http"]').forEach((a) => { a.target = '_blank'; });
}, [html]);
return <div ref={ref} className="article-body" onClick={onClick} dangerouslySetInnerHTML={{ __html: html }} />;
}
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { api, fmtDate, imgUrl, personName } from '../api.js';
import StatusBadge from '../components/StatusBadge.jsx';
export default function Projects() {
const [projects, setProjects] = useState(null);
const [error, setError] = useState(null);
const dialog = useRef(null);
useEffect(() => {
api('GET', '/api/projects').then(setProjects).catch((e) => setError(e.message));
}, []);
return (
<>
<div className="page-head">
<div>
<h1>P42 Projekte</h1>
<p className="lead">Übersicht aller laufenden und abgeschlossenen Projekte.</p>
</div>
<button className="btn" type="button" onClick={() => dialog.current.showModal()}>+ Neues Projekt</button>
</div>
{error && <p className="error">{error}</p>}
{!projects && !error && <p className="hint">Lade Projekte …</p>}
{projects?.length === 0 && (
<div className="notice">Noch keine Projekte. Leg mit „+ Neues Projekt“ das erste an.</div>
)}
{projects?.length > 0 && (
<div className="grid projects-grid">
{projects.map((p) => (
<Link key={p.id} className="card project-card" to={`/projekte/${p.id}`}>
<div className="project-cover-wrap">
{p.cover
? <img className="project-cover" src={imgUrl(p.cover)} alt="" loading="lazy" />
: <div className="project-cover empty" />}
<StatusBadge status={p.status} />
</div>
<div className="project-body">
<h3>{p.title}</h3>
{p.description && <p className="project-teaser">{p.description}</p>}
<div className="meta project-meta">
<span>Von {personName(p.createdBy)}</span>
<span title="Zuletzt bearbeitet">✎ {personName(p.updatedBy)} · {fmtDate(p.updatedAt)}</span>
</div>
</div>
</Link>
))}
</div>
)}
<NewProjectDialog ref={dialog} />
</>
);
}
function NewProjectDialog({ ref }) {
const navigate = useNavigate();
const [title, setTitle] = useState('');
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
async function submit(e) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const project = await api('POST', '/api/projects', { title });
navigate(`/projekte/${project.id}?bearbeiten`);
} catch (err) {
setError(err.message);
setBusy(false);
}
}
return (
<dialog className="modal" ref={ref} onClose={() => { setTitle(''); setError(null); }}>
<form className="form" onSubmit={submit}>
<h2>Neues Projekt</h2>
<label>
Titel
<input value={title} onChange={(e) => setTitle(e.target.value)} required maxLength={120} autoFocus />
</label>
<p className="hint">Beschreibung, Bilder und Text schreibst du danach direkt im Artikel.</p>
{error && <p className="error">{error}</p>}
<div className="form-actions">
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
<button className="btn" type="submit" disabled={busy}>Projekt anlegen</button>
</div>
</form>
</dialog>
);
}
+560
View File
@@ -0,0 +1,560 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { feature } from 'topojson-client';
import { api } from '../api.js';
import WorldMap from '../components/WorldMap.jsx';
import LAENDER from '../data/laender.json';
const BY_CODE = new Map(LAENDER.map((c) => [c.code, c]));
const BY_NUM = new Map(LAENDER.filter((c) => c.num).map((c) => [c.num, c]));
// Gebiete auf der Karte ohne ISO-Nummer
const SPECIAL = {
Kosovo: 'XK',
Somaliland: 'X-Somaliland',
'N. Cyprus': 'X-Nordzypern',
'Indian Ocean Ter.': 'X-Indischer Ozean',
'Siachen Glacier': 'X-Siachen-Gletscher',
};
const languageName = new Intl.DisplayNames(['de'], { type: 'language' });
const currencyName = new Intl.DisplayNames(['de'], { type: 'currency' });
const num = (n) => n.toLocaleString('de-DE');
const people = (n) => (n >= 1e6 ? `${(n / 1e6).toLocaleString('de-DE', { maximumFractionDigits: 1 })} Mio.` : num(n));
const safe = (fn, v) => { try { return fn(v); } catch { return v; } };
function countryName(key) {
return BY_CODE.get(key)?.name || (key.startsWith('X-') ? key.slice(2) : key);
}
function localTime(timezone) {
try {
return new Intl.DateTimeFormat('de-DE', { timeZone: timezone, hour: '2-digit', minute: '2-digit' }).format(new Date());
} catch {
return null;
}
}
export default function Reisen() {
const [data, setData] = useState(null);
const [shapes, setShapes] = useState(null);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const [selected, setSelected] = useState(null);
const [wide, setWide] = useState(() => window.matchMedia('(min-width: 760px)').matches);
const map = useRef(null);
useEffect(() => {
api('GET', '/api/reisen').then(setData).catch((e) => setError(e.message));
fetch('/maps/countries-50m.json')
.then((r) => r.json())
.then((topo) => {
// Teile mit derselben Länder-Nummer (z. B. Ashmore- und Cartierinseln → Australien) zusammenführen
const byKey = new Map();
for (const f of feature(topo, topo.objects.countries).features) {
const key = BY_NUM.get(f.id)?.code || SPECIAL[f.properties.name] || `X-${f.properties.name}`;
const polygons = f.geometry.type === 'Polygon' ? [f.geometry.coordinates] : f.geometry.coordinates;
if (byKey.has(key)) byKey.get(key).feature.geometry.coordinates.push(...polygons);
else byKey.set(key, { key, name: countryName(key), feature: { ...f, geometry: { type: 'MultiPolygon', coordinates: polygons } } });
}
setShapes([...byKey.values()]);
})
.catch(() => setError('Die Weltkarte konnte nicht geladen werden.'));
const mq = window.matchMedia('(min-width: 760px)');
const onChange = () => setWide(mq.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
useEffect(() => {
const onKey = (e) => e.key === 'Escape' && setSelected(null);
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
const cities = data?.cities || [];
// Besucht = von Hand markiert oder mindestens eine Stadt dort
const visited = useMemo(
() => new Set([...(data?.countries || []), ...cities.map((c) => c.country)]),
[data, cities],
);
const visitedList = [...visited].sort((a, b) => countryName(a).localeCompare(countryName(b), 'de'));
const select = useCallback((sel, { zoom = true } = {}) => {
setSelected(sel);
if (!sel || !zoom) return;
if (sel.type === 'country') map.current?.focusCountry(sel.key, wide);
else {
const city = data?.cities.find((c) => c.id === sel.key);
if (city) map.current?.focusCity(city, wide);
}
}, [data, wide]);
async function run(method, url, body) {
setBusy(true);
setError(null);
try {
const next = await api(method, url, body);
setData(next);
return next;
} catch (e) {
setError(e.message);
return null;
} finally {
setBusy(false);
}
}
async function addCity(result) {
const next = await run('POST', '/api/reisen/staedte', result);
const city = next?.cities.find((c) => c.geonameId === result.geonameId);
if (city) {
setSelected({ type: 'city', key: city.id });
map.current?.focusCity(city, wide);
}
}
const selectedCity = selected?.type === 'city' ? cities.find((c) => c.id === selected.key) : null;
return (
<>
<div className="page-head">
<div>
<h1>Reisen</h1>
<p className="lead">
{data ? `${visited.size} ${visited.size === 1 ? 'Land' : 'Länder'} · ${cities.length} ${cities.length === 1 ? 'Stadt' : 'Städte'}` : 'Lade …'}
{' '}· Klick auf ein Land oder einen Punkt zeigt Infos.
</p>
</div>
</div>
<CitySearch cities={cities} onAdd={addCity} busy={busy} />
{error && <p className="error travel-error" role="alert">{error}</p>}
<div className="travel-map card">
{shapes ? (
<WorldMap
ref={map}
countries={shapes}
visited={visited}
cities={cities}
selected={selected}
onSelect={(sel) => select(sel)}
/>
) : (
<p className="hint travel-loading">Lade Weltkarte …</p>
)}
<div className="map-controls" role="group" aria-label="Kartenansicht">
<button type="button" onClick={() => map.current?.zoomBy(2)} aria-label="Hineinzoomen">+</button>
<button type="button" onClick={() => map.current?.zoomBy(0.5)} aria-label="Herauszoomen">−</button>
<button type="button" onClick={() => { setSelected(null); map.current?.reset(); }} aria-label="Ganze Welt zeigen">⌂</button>
</div>
{selected && (
<InfoCard
key={`${selected.type}-${selected.key}`}
selected={selected}
city={selectedCity}
cities={cities}
manual={data?.countries || []}
busy={busy}
onSelect={select}
onClose={() => setSelected(null)}
onMark={(code, mark) => run(mark ? 'PUT' : 'DELETE', `/api/reisen/laender/${encodeURIComponent(code)}`)}
onRemoveCity={async (city) => {
if (!window.confirm(`„${city.name}“ von der Karte entfernen?`)) return;
if (await run('DELETE', `/api/reisen/staedte/${city.id}`)) setSelected({ type: 'country', key: city.country });
}}
/>
)}
</div>
{data && (
<VisitedList
visited={visitedList}
cities={cities}
manual={data.countries}
selected={selected}
onSelect={select}
/>
)}
<p className="hint travel-credits">
Karte: Natural Earth / world-atlas · Länderdaten: world-countries (ODbL) · Orte: GeoNames über Open-Meteo (CC BY 4.0)
· Texte: Wikipedia (CC BY-SA 4.0) · Einwohner: Wikidata
</p>
</>
);
}
// ---------- Besuchte Orte: Kontinent → Land (ausklappbar) → Städte ----------
const CONTINENTS = ['Europa', 'Asien', 'Afrika', 'Nordamerika', 'Südamerika', 'Ozeanien', 'Antarktis'];
// Gebiete der Karte ohne Eintrag in den Länderdaten
const SPECIAL_CONTINENT = { 'X-Somaliland': 'Afrika', 'X-Nordzypern': 'Europa', 'X-Indischer Ozean': 'Ozeanien', 'X-Siachen-Gletscher': 'Asien' };
const continentOf = (code) => BY_CODE.get(code)?.continent || SPECIAL_CONTINENT[code] || 'Sonstige';
const OPEN_KEY = 'p42.reisenOffen';
const CLOSED_KEY = 'p42.reisenKontinenteZu';
function loadSet(key) {
try { return new Set(JSON.parse(localStorage.getItem(key)) || []); } catch { return new Set(); }
}
const loadOpen = () => loadSet(OPEN_KEY);
function VisitedList({ visited, cities, manual, selected, onSelect }) {
const [open, setOpen] = useState(loadOpen);
const save = (next) => {
setOpen(next);
try { localStorage.setItem(OPEN_KEY, JSON.stringify([...next])); } catch { /* ignorieren */ }
};
const toggle = (code) => {
const next = new Set(open);
if (next.has(code)) next.delete(code); else next.add(code);
save(next);
};
// Kontinente: standardmäßig offen, zugeklappte werden gemerkt
const [closed, setClosed] = useState(() => loadSet(CLOSED_KEY));
const saveClosed = (next) => {
setClosed(next);
try { localStorage.setItem(CLOSED_KEY, JSON.stringify([...next])); } catch { /* ignorieren */ }
};
const toggleContinent = (name) => {
const next = new Set(closed);
if (next.has(name)) next.delete(name); else next.add(name);
saveClosed(next);
};
// Wird eine Stadt (z. B. auf der Karte) gewählt, ihr Land aufklappen
const selectedCountry = selected?.type === 'city' ? cities.find((c) => c.id === selected.key)?.country : null;
useEffect(() => {
if (selectedCountry && !open.has(selectedCountry)) save(new Set(open).add(selectedCountry));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCountry]);
// Ein gewähltes Land oder eine gewählte Stadt macht ihren Kontinent sichtbar
const selectedCode = selected?.type === 'country' ? selected.key : selectedCountry;
const selectedContinent = selectedCode ? continentOf(selectedCode) : null;
useEffect(() => {
if (selectedContinent && closed.has(selectedContinent)) {
const next = new Set(closed);
next.delete(selectedContinent);
saveClosed(next);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedContinent, selected]);
if (!visited.length) {
return <p className="hint travel-empty">Noch keine Orte. Füge eine Stadt hinzu oder klick ein Land auf der Karte an und markiere es.</p>;
}
const groups = [...CONTINENTS, 'Sonstige']
.map((name) => ({ name, countries: visited.filter((code) => continentOf(code) === name) }))
.filter((g) => g.countries.length);
return (
<div className="visited-list">
{groups.map((group) => {
const cityCount = cities.filter((c) => continentOf(c.country) === group.name).length;
const contOpen = !closed.has(group.name);
const id = `cont-${group.name}`;
return (
<section key={group.name} className={`visited-continent${contOpen ? '' : ' closed'}`} aria-labelledby={id}>
<h2 className="section-title">
<button
type="button"
id={id}
className="continent-toggle"
aria-expanded={contOpen}
aria-controls={`${id}-list`}
onClick={() => toggleContinent(group.name)}
>
<span className="continent-arrow" aria-hidden="true">▸</span>
{group.name}
<small className="visited-count">
{group.countries.length} {group.countries.length === 1 ? 'Land' : 'Länder'} · {cityCount} {cityCount === 1 ? 'Stadt' : 'Städte'}
</small>
</button>
</h2>
{contOpen && (
<ul className="visited-countries" id={`${id}-list`}>
{group.countries.map((code) => {
const list = cities.filter((c) => c.country === code);
const isOpen = open.has(code) && list.length > 0;
const active = selected?.type === 'country' && selected.key === code;
return (
<li key={code} className={`visited-country${isOpen ? ' open' : ''}${active ? ' active' : ''}`}>
<div className="visited-row">
<button
type="button"
className="visited-toggle"
aria-expanded={isOpen}
aria-controls={`cities-${code}`}
disabled={!list.length}
onClick={() => toggle(code)}
aria-label={list.length ? `Städte in ${countryName(code)} ${isOpen ? 'zuklappen' : 'aufklappen'}` : `Keine Städte in ${countryName(code)}`}
>
<span aria-hidden="true">{list.length ? '▸' : '·'}</span>
</button>
<button type="button" className="visited-name" onClick={() => onSelect({ type: 'country', key: code })}>
<span className="visited-flag" aria-hidden="true">{BY_CODE.get(code)?.flag}</span>
{countryName(code)}
</button>
<span className="visited-meta">
{list.length ? `${list.length} ${list.length === 1 ? 'Stadt' : 'Städte'}` : manual.includes(code) ? 'markiert' : ''}
</span>
</div>
{isOpen && (
<ul className="chip-list visited-cities" id={`cities-${code}`}>
{list.map((c) => (
<li key={c.id}>
<button type="button" className={`chip${selected?.type === 'city' && selected.key === c.id ? ' active' : ''}`} onClick={() => onSelect({ type: 'city', key: c.id })}>
{c.name}
</button>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
)}
</section>
);
})}
</div>
);
}
// ---------- Stadt suchen und hinzufügen ----------
function CitySearch({ cities, onAdd, busy }) {
const [q, setQ] = useState('');
const [results, setResults] = useState([]);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const [state, setState] = useState('idle'); // idle | loading | error
const timer = useRef(null);
const request = useRef(0);
useEffect(() => {
clearTimeout(timer.current);
const query = q.trim();
if (query.length < 2) { setResults([]); setState('idle'); return undefined; }
// Erst suchen, wenn kurz nicht mehr getippt wird
timer.current = setTimeout(async () => {
const id = ++request.current;
setState('loading');
try {
const list = await api('GET', `/api/reisen/suche?q=${encodeURIComponent(query)}`);
if (id !== request.current) return;
setResults(list);
setActive(-1);
setState('idle');
} catch {
if (id === request.current) setState('error');
}
}, 300);
return () => clearTimeout(timer.current);
}, [q]);
const known = new Set(cities.map((c) => c.geonameId));
async function pick(result) {
if (known.has(result.geonameId)) return;
setOpen(false);
setQ('');
setResults([]);
await onAdd(result);
}
function onKeyDown(e) {
if (e.key === 'ArrowDown') { e.preventDefault(); setOpen(true); setActive((i) => Math.min(i + 1, results.length - 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)); }
else if (e.key === 'Enter') { e.preventDefault(); const r = results[active >= 0 ? active : 0]; if (r) pick(r); }
else if (e.key === 'Escape') setOpen(false);
}
const show = open && q.trim().length >= 2;
return (
<div className="city-search combo">
<label className="sr-only" htmlFor="city-search">Stadt hinzufügen</label>
<div className="prompt-input">
<input
id="city-search"
value={q}
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onKeyDown={onKeyDown}
placeholder="Stadt hinzufügen, in der du schon warst …"
role="combobox"
aria-autocomplete="list"
aria-expanded={show && results.length > 0}
aria-controls="city-results"
aria-activedescendant={show && active >= 0 ? `city-result-${active}` : undefined}
autoComplete="off"
spellCheck={false}
disabled={busy}
/>
</div>
{show && (
<ul className="combo-list" id="city-results" role="listbox">
{state === 'loading' && results.length === 0 && <li className="combo-info">Suche …</li>}
{state === 'error' && <li className="combo-info">Suche gerade nicht erreichbar.</li>}
{state === 'idle' && results.length === 0 && <li className="combo-info">Keine Stadt gefunden.</li>}
{results.map((r, i) => {
const added = known.has(r.geonameId);
return (
<li
key={r.geonameId}
id={`city-result-${i}`}
role="option"
aria-selected={i === active}
aria-disabled={added || undefined}
className={`${i === active ? 'active' : ''}${added ? ' added' : ''}`}
onMouseDown={(e) => { e.preventDefault(); pick(r); }}
>
<span>
{BY_CODE.get(r.country)?.flag} {r.name}
<small> {[r.admin1, r.countryName].filter(Boolean).join(', ')}</small>
</span>
<small>{added ? '✓ schon eingetragen' : r.population ? `${people(r.population)} Einw.` : ''}</small>
</li>
);
})}
</ul>
)}
</div>
);
}
// ---------- Info-Kasten unten links ----------
const infoCache = new Map();
function useInfo(params) {
const query = new URLSearchParams(params).toString();
const [info, setInfo] = useState(() => infoCache.get(query) || null);
useEffect(() => {
if (infoCache.has(query)) { setInfo(infoCache.get(query)); return; }
let alive = true;
api('GET', `/api/reisen/info?${query}`)
.then((d) => { if (!d.offline) infoCache.set(query, d); if (alive) setInfo(d); })
.catch(() => alive && setInfo({ offline: true }));
return () => { alive = false; };
}, [query]);
return info;
}
function InfoCard({ selected, city, cities, manual, busy, onSelect, onClose, onMark, onRemoveCity }) {
if (selected.type === 'city' && city) {
return <CityInfo city={city} onSelect={onSelect} onClose={onClose} onRemove={onRemoveCity} busy={busy} />;
}
if (selected.type === 'country') {
return (
<CountryInfo
code={selected.key}
cities={cities.filter((c) => c.country === selected.key)}
manual={manual.includes(selected.key)}
onSelect={onSelect}
onClose={onClose}
onMark={onMark}
busy={busy}
/>
);
}
return null;
}
function Extract({ info }) {
if (!info) return <p className="hint">Lade Infos …</p>;
if (info.offline) return <p className="hint">Infotext gerade nicht erreichbar.</p>;
if (!info.extract) return null;
return (
<>
<p className="info-extract">{info.extract}</p>
{info.url && <a className="info-more" href={info.url} target="_blank" rel="noopener noreferrer">Mehr bei Wikipedia ↗</a>}
</>
);
}
function CountryInfo({ code, cities, manual, onSelect, onClose, onMark, busy }) {
const c = BY_CODE.get(code);
const name = countryName(code);
const info = useInfo({ kind: 'land', name: c?.name || name, code });
const visited = manual || cities.length > 0;
const facts = [
(info?.capital || c?.capital?.length) && ['Hauptstadt', info?.capital || c.capital.join(', ')],
info?.population && ['Einwohner', `${people(info.population)}${info.populationYear ? ` (${info.populationYear})` : ''}`],
c?.area && ['Fläche', `${num(Math.round(c.area))} km²`],
c?.subregion && ['Region', c.subregion],
c?.languages?.length && ['Sprachen', c.languages.map((l) => safe((x) => languageName.of(x), l)).join(', ')],
c?.currencies?.length && ['Währung', c.currencies.map((x) => safe((y) => currencyName.of(y), x)).join(', ')],
].filter(Boolean);
return (
<aside className="info-card" aria-label={`Infos zu ${name}`}>
<header className="info-head">
<h2>{c?.flag} {name}</h2>
<button className="link-btn" type="button" onClick={onClose} aria-label="Infos schließen">✕</button>
</header>
{c?.official && c.official !== name && <p className="info-sub">{c.official}</p>}
<dl className="info-facts">
{facts.map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}
</dl>
<Extract info={info} />
<div className="info-visit">
<span className={`badge ${visited ? 'ok' : 'muted'}`}>{visited ? 'Besucht' : 'Noch nicht besucht'}</span>
{cities.length > 0 ? (
<span className="info-cities">
{cities.map((city) => (
<button key={city.id} type="button" className="chip" onClick={() => onSelect({ type: 'city', key: city.id })}>{city.name}</button>
))}
</span>
) : (
<button className="btn small" type="button" disabled={busy} onClick={() => onMark(code, !manual)}>
{manual ? 'Markierung entfernen' : 'Als besucht markieren'}
</button>
)}
</div>
{cities.length > 0 && manual && (
<button className="link-btn" type="button" disabled={busy} onClick={() => onMark(code, false)}>Zusätzliche Markierung entfernen</button>
)}
</aside>
);
}
function CityInfo({ city, onSelect, onClose, onRemove, busy }) {
const c = BY_CODE.get(city.country);
const info = useInfo({ kind: 'stadt', name: city.name, code: String(city.geonameId), lat: city.lat, lon: city.lon });
const time = city.timezone && localTime(city.timezone);
const facts = [
city.admin1 && ['Region', city.admin1],
city.population && ['Einwohner', people(city.population)],
city.elevation != null && ['Höhe', `${num(Math.round(city.elevation))} m`],
time && ['Ortszeit', `${time} Uhr`],
].filter(Boolean);
return (
<aside className="info-card" aria-label={`Infos zu ${city.name}`}>
<header className="info-head">
<h2>{city.name}</h2>
<button className="link-btn" type="button" onClick={onClose} aria-label="Infos schließen">✕</button>
</header>
<p className="info-sub">
<button type="button" className="chip" onClick={() => onSelect({ type: 'country', key: city.country })}>
{c?.flag} {countryName(city.country)}
</button>
</p>
<dl className="info-facts">
{facts.map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}
</dl>
<Extract info={info} />
<div className="info-visit">
<span className="badge ok">Besucht</span>
<button className="link-btn danger" type="button" disabled={busy} onClick={() => onRemove(city)}>Stadt entfernen</button>
</div>
</aside>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { useEffect, useRef, useState } from 'react';
import { api, fmtDate } from '../api.js';
import { useAuth } from '../auth.jsx';
// Benutzerverwaltung (nur für Administratoren)
export default function Users() {
const { user: me, setUser } = useAuth();
const [users, setUsers] = useState(null);
const [error, setError] = useState(null);
const dialog = useRef(null);
const [edit, setEdit] = useState(null); // { id, username, displayName }
const load = () => api('GET', '/api/users').then(setUsers).catch((e) => setError(e.message));
useEffect(() => { if (me.isAdmin) load(); }, [me.isAdmin]);
if (!me.isAdmin) return <p className="error">Diese Seite ist nur für Administratoren.</p>;
async function run(action) {
setError(null);
try {
await action();
await load();
} catch (e) {
setError(e.message);
}
}
async function saveEdit(e) {
e.preventDefault();
await run(async () => {
const updated = await api('PATCH', `/api/users/${edit.id}`, { username: edit.username, displayName: edit.displayName });
if (updated.id === me.id) setUser(updated);
setEdit(null);
});
}
const resetPassword = (u) => {
const pw = window.prompt(`Neues Passwort für ${u.displayName} (mind. 8 Zeichen):`);
if (pw) run(() => api('PATCH', `/api/users/${u.id}`, { password: pw }));
};
const toggleAdmin = (u) => run(() => api('PATCH', `/api/users/${u.id}`, { isAdmin: !u.isAdmin }));
const remove = (u) => {
if (window.confirm(`Konto „${u.displayName}“ löschen? Seine Links und Monitoring-Einträge werden gelöscht, Artikel bleiben erhalten.`)) {
run(() => api('DELETE', `/api/users/${u.id}`));
}
};
return (
<>
<div className="page-head">
<div>
<h1>Benutzer verwalten</h1>
<p className="lead">Konten anlegen, umbenennen, Passwörter zurücksetzen und Administratoren festlegen.</p>
</div>
<button className="btn" type="button" onClick={() => dialog.current.showModal()}>+ Neues Konto</button>
</div>
{error && <p className="error">{error}</p>}
{!users && !error && <p className="hint">Lade …</p>}
{users && (
<div className="user-list">
{users.map((u) => (
<article className="card user-row" key={u.id}>
{edit?.id === u.id ? (
<form className="user-edit" onSubmit={saveEdit}>
<input
value={edit.username}
onChange={(e) => setEdit((x) => ({ ...x, username: e.target.value }))}
aria-label="Benutzername"
placeholder="Benutzername"
autoCapitalize="none"
required
/>
<input
value={edit.displayName}
onChange={(e) => setEdit((x) => ({ ...x, displayName: e.target.value }))}
aria-label="Anzeigename"
placeholder="Anzeigename"
maxLength={60}
required
/>
<button className="btn small" type="submit">Speichern</button>
<button className="btn ghost small" type="button" onClick={() => setEdit(null)}>Abbrechen</button>
</form>
) : (
<div className="user-info">
<strong>{u.displayName}</strong>
<span className="hint">@{u.username} · seit {fmtDate(u.createdAt)}</span>
</div>
)}
<div className="user-badges">
{u.isAdmin && <span className="badge">Administrator</span>}
{u.id === me.id && <span className="badge muted">Du</span>}
</div>
<div className="user-actions">
{edit?.id !== u.id && (
<button className="btn ghost small" type="button" onClick={() => setEdit({ id: u.id, username: u.username, displayName: u.displayName })}>
Umbenennen
</button>
)}
{u.id !== me.id && (
<>
<button className="btn ghost small" type="button" onClick={() => resetPassword(u)}>Passwort zurücksetzen</button>
<button className="btn ghost small" type="button" onClick={() => toggleAdmin(u)}>
{u.isAdmin ? 'Admin entziehen' : 'Zum Admin machen'}
</button>
<button className="link-btn danger" type="button" onClick={() => remove(u)}>Löschen</button>
</>
)}
</div>
</article>
))}
</div>
)}
<NewUserDialog ref={dialog} onCreated={load} />
</>
);
}
function NewUserDialog({ ref, onCreated }) {
const empty = { username: '', displayName: '', password: '', isAdmin: false };
const [form, setForm] = useState(empty);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
async function submit(e) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await api('POST', '/api/users', form);
await onCreated();
ref.current.close();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
return (
<dialog className="modal" ref={ref} onClose={() => { setForm(empty); setError(null); }}>
<form className="form" onSubmit={submit}>
<h2>Neues Konto</h2>
<label>
Benutzername
<input {...field('username')} autoComplete="off" autoCapitalize="none" required />
</label>
<label>
Anzeigename
<input {...field('displayName')} maxLength={60} placeholder="optional, sonst Benutzername" />
</label>
<label>
Start-Passwort <small>(mind. 8 Zeichen, kann danach selbst geändert werden)</small>
<input {...field('password')} type="text" autoComplete="off" minLength={8} required />
</label>
<label className="checkbox">
<input type="checkbox" checked={form.isAdmin} onChange={(e) => setForm((f) => ({ ...f, isAdmin: e.target.checked }))} />
Administrator
</label>
{error && <p className="error">{error}</p>}
<div className="form-actions">
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
<button className="btn" type="submit" disabled={busy}>Konto anlegen</button>
</div>
</form>
</dialog>
);
}