Frontend: gemeinsamer Hook useLiveData, Komponenten nach Bereichen, große Seiten aufgeteilt
- useLiveData: Laden, Nachladen (sichtbarer Tab) und Speichern mit Fehlerbehandlung an einer Stelle; genutzt von Vorrat, Reisen, Kanban-Übersicht und Kanban-Board (Monitoring/Benutzer arbeiten anders) - components/<bereich>/: haushalt, reisen, kanban, monitoring, projekte, start, design (per git mv, Historie bleibt) - Reisen.jsx 783 → 227 Zeilen (CitySearch, VisitedList, InfoCard, ExportDialog, laender.js), Haushalt.jsx 634 → 193 (ListPanel, ListSwitcher, ListDialog, ProductsDialog), KanbanBoard.jsx 452 → 277 (board.js, TaskCard, TaskDialog) - storage.js für Einstellungen pro Browser; Städteliste in Reisen memoisiert - Verhalten unverändert (Browser-Durchlauf aller Seiten und Abläufe) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4aba48ce51
commit
fa58708e2d
41 files changed
+1264
-1241
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { isCalm, onMotionChange } from '../motion.js';
|
||||
import { isCalm, onMotionChange } from '../../motion.js';
|
||||
|
||||
// Hintergrund "Blackwall" nach Einzelbildern aus dem Referenzvideo (Cyberpunk 2077):
|
||||
// - Wand-Korridor: senkrechte Lichtlinien, die perspektivisch auf einen Fluchtpunkt am Horizont zulaufen
|
||||
File renamed without changes.
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { isCalm, onMotionChange } from '../motion.js';
|
||||
import { isCalm, onMotionChange } from '../../motion.js';
|
||||
|
||||
// Dezenter "digitaler Regen" im Hintergrund – Farbe kommt aus dem Stylesheet (--accent-rgb, --bright).
|
||||
// Pausiert in inaktiven Tabs, entfällt bei "Bewegung reduzieren" und lässt sich im Menü abschalten.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getTheme, onThemeChange } from '../theme.js';
|
||||
import { getTheme, onThemeChange } from '../../theme.js';
|
||||
import BlackwallField from './BlackwallField.jsx';
|
||||
import FabrikField from './FabrikField.jsx';
|
||||
import MatrixRain from './MatrixRain.jsx';
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import { personName } from '../../api.js';
|
||||
import { useAuth } from '../../auth.jsx';
|
||||
|
||||
// Liste anlegen oder bearbeiten: Name und wer sie sehen kann; löschen nur beim Bearbeiten
|
||||
export default function ListDialog({ ref, list, users, onSave, onDelete, onClosed }) {
|
||||
const { user } = useAuth();
|
||||
const ownerId = list ? list.createdBy?.id : user?.id;
|
||||
const [form, setForm] = useState(() => ({
|
||||
name: list?.name || '',
|
||||
everyone: list ? list.everyone : true,
|
||||
members: list?.members || [],
|
||||
}));
|
||||
const [error, setError] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Der Ersteller sieht seine Liste immer, deshalb steht er nicht zur Auswahl
|
||||
const choosable = users.filter((u) => u.id !== ownerId);
|
||||
const toggle = (id) => setForm((f) => ({ ...f, members: f.members.includes(id) ? f.members.filter((x) => x !== id) : [...f.members, id] }));
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const err = await onSave({ name: form.name, everyone: form.everyone, members: form.everyone ? [] : form.members });
|
||||
setBusy(false);
|
||||
if (err) setError(err);
|
||||
else ref.current.close();
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
setBusy(true);
|
||||
const done = await onDelete(list);
|
||||
setBusy(false);
|
||||
if (done) ref.current.close();
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog className="modal task-dialog" ref={ref} onClose={onClosed}>
|
||||
<form className="form" onSubmit={submit}>
|
||||
<h2>{list ? 'Liste bearbeiten' : 'Neue Liste'}</h2>
|
||||
{!list && <p className="hint">Die Liste bekommt einen eigenen Vorrat und eine eigene Einkaufsliste – z. B. für die Ferienwohnung im Urlaub.</p>}
|
||||
<label>
|
||||
Name
|
||||
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} maxLength={40} required autoFocus placeholder="z. B. Zuhause, Urlaub Italien, WG" />
|
||||
</label>
|
||||
<fieldset className="link-picker">
|
||||
<legend>Wer kann die Liste sehen?</legend>
|
||||
<ul>
|
||||
<li>
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="visibility" checked={form.everyone} onChange={() => setForm((f) => ({ ...f, everyone: true }))} />
|
||||
Alle Konten
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="visibility" checked={!form.everyone} onChange={() => setForm((f) => ({ ...f, everyone: false }))} />
|
||||
Nur ausgewählte Konten
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
{!form.everyone && (
|
||||
<>
|
||||
<p className="hint">
|
||||
{list && list.createdBy?.id !== user?.id ? `${personName(list.createdBy)} (angelegt)` : 'Du'} siehst die Liste immer. Zusätzlich:
|
||||
</p>
|
||||
{choosable.length === 0 ? (
|
||||
<p className="hint">Es gibt keine weiteren Konten.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{choosable.map((u) => (
|
||||
<li key={u.id}>
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={form.members.includes(u.id)} onChange={() => toggle(u.id)} />
|
||||
{u.name}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
{list && (
|
||||
<button className="btn ghost danger-btn list-delete" type="button" disabled={busy} onClick={remove}>Liste löschen</button>
|
||||
)}
|
||||
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
|
||||
<button className="btn" type="submit" disabled={busy}>{list ? 'Speichern' : 'Anlegen'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { personName } from '../../api.js';
|
||||
import { fmtItems, parseQty, round2, STEP } from '../../units.js';
|
||||
import ProductInput from './ProductInput.jsx';
|
||||
import UnitSelect from './UnitSelect.jsx';
|
||||
|
||||
// Die beiden Bereiche einer Liste: Vorrat (was da ist) und Einkaufen (was fehlt)
|
||||
export const LISTS = {
|
||||
vorrat: { title: 'Vorrat', lead: 'Was da ist', empty: 'Noch nichts im Vorrat eingetragen.' },
|
||||
einkauf: { title: 'Einkaufen', lead: 'Was besorgt werden muss', empty: 'Nichts zu besorgen.' },
|
||||
};
|
||||
|
||||
export default function ListPanel({ list, items, products, find, actions, busy, hidden }) {
|
||||
const { title, lead, empty } = LISTS[list];
|
||||
const other = list === 'vorrat' ? 'einkauf' : 'vorrat';
|
||||
// Einkauf: Abgehaktes rutscht nach unten und wandert erst mit "Einkauf beenden" in den Vorrat
|
||||
const open = items.filter((i) => !i.checked);
|
||||
const inCart = items.filter((i) => i.checked);
|
||||
|
||||
// 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">
|
||||
{open.map((item) => (
|
||||
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
|
||||
))}
|
||||
{inCart.length > 0 && (
|
||||
<li className="household-cart-head" aria-hidden="true">Im Wagen</li>
|
||||
)}
|
||||
{inCart.map((item) => (
|
||||
<ItemRow key={item.id} item={item} list={list} other={find(other, item.name)} actions={actions} busy={busy} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{inCart.length > 0 && (
|
||||
<div className="household-finish">
|
||||
<span className="hint">
|
||||
{inCart.length} {inCart.length === 1 ? 'Produkt' : 'Produkte'} im Wagen
|
||||
</span>
|
||||
<button className="btn" type="button" disabled={busy} onClick={actions.finish}>
|
||||
Einkauf beenden ({inCart.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</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${item.checked ? ' checked' : ''}`} style={{ '--vt': `item-${item.id}` }}>
|
||||
{isShopping && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="household-check"
|
||||
checked={item.checked}
|
||||
disabled={busy}
|
||||
onChange={() => actions.check(item, !item.checked)}
|
||||
aria-label={item.checked ? `${label} im Wagen – Haken entfernen` : `${label} abhaken (im Wagen)`}
|
||||
title={item.checked ? 'Im Wagen – zum Zurücknehmen klicken' : 'Abhaken: kommt mit „Einkauf beenden“ in den Vorrat'}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Listen (je Vorrat + Einkauf) wählen; zeigt, wer die gewählte Liste sieht
|
||||
|
||||
function visibleFor(list, users) {
|
||||
if (list.everyone) return 'alle Konten';
|
||||
const ids = new Set([list.createdBy?.id, ...list.members]);
|
||||
const names = users.filter((u) => ids.has(u.id)).map((u) => u.name);
|
||||
return names.length ? names.join(', ') : 'niemanden';
|
||||
}
|
||||
|
||||
export default function ListSwitcher({ lists, current, users, onChoose, onNew, onEdit, busy }) {
|
||||
return (
|
||||
<div className="shopping-lists">
|
||||
<ul className="chip-list" aria-label="Liste wählen">
|
||||
<li className="shopping-lists-label">Liste:</li>
|
||||
{lists.map((l) => (
|
||||
<li key={l.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`chip${l.id === current?.id ? ' active' : ''}`}
|
||||
aria-pressed={l.id === current?.id}
|
||||
onClick={() => onChoose(l.id)}
|
||||
title={`Sichtbar für ${visibleFor(l, users)}`}
|
||||
>
|
||||
{!l.everyone && <span aria-label="nicht für alle sichtbar">🔒 </span>}
|
||||
{l.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<button type="button" className="link-btn" onClick={onNew} disabled={busy}>+ Neue Liste</button>
|
||||
</li>
|
||||
</ul>
|
||||
{current && (
|
||||
<p className="hint shopping-visibility">
|
||||
Sichtbar für: {visibleFor(current, users)}
|
||||
{current.createdBy && ` · angelegt von ${current.createdBy.name}`}
|
||||
{current.canManage && (
|
||||
<>
|
||||
{' · '}
|
||||
<button type="button" className="link-btn" onClick={() => onEdit(current)} disabled={busy}>✎ Liste bearbeiten</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,28 @@
|
||||
// Vorschläge (Produktkatalog) aufräumen, z. B. Tippfehler; Einträge auf den Listen bleiben
|
||||
|
||||
export default 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>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { ceil2, fmtItems, fmtQty, parseQty } from '../units.js';
|
||||
import { api } from '../../api.js';
|
||||
import { ceil2, fmtItems, fmtQty, parseQty } from '../../units.js';
|
||||
import ProductInput from './ProductInput.jsx';
|
||||
import UnitSelect from './UnitSelect.jsx';
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { UNITS } from '../units.js';
|
||||
import { UNITS } from '../../units.js';
|
||||
|
||||
// Auswahl Anzahl / kg / Liter; compact für enge Listenzeilen ("Anz.")
|
||||
export default function UnitSelect({ value, onChange, label = 'Einheit', compact = false, ...props }) {
|
||||
File renamed without changes.
@@ -0,0 +1,40 @@
|
||||
import { initials, PRIORITIES } from './board.js';
|
||||
|
||||
// Karte einer Aufgabe im Board (ziehbar) und Anzeige des Bearbeiters
|
||||
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
export default 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import { fmtDate, personName } from '../../api.js';
|
||||
import { COLUMNS, PRIORITIES } from './board.js';
|
||||
|
||||
// Aufgabe anlegen oder bearbeiten (Titel, Beschreibung, Status, Priorität, Bearbeiter), löschen
|
||||
|
||||
export default 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Spalten, Prioritäten und Sortierung der Kanban-Boards (auch von der Übersicht genutzt)
|
||||
|
||||
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
|
||||
export 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' },
|
||||
];
|
||||
|
||||
export const initials = (name) => name.split(/\s+/).map((w) => w[0]).join('').slice(0, 2).toUpperCase();
|
||||
|
||||
// Position zwischen den Nachbarn an der Einfügestelle
|
||||
export 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;
|
||||
}
|
||||
File renamed without changes.
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { api } from '../../api.js';
|
||||
import {
|
||||
fmtBytes, fmtNum, fmtPct, fmtRate, fmtTemp, fmtUptime, level, maxTemp,
|
||||
} from '../serverStats.js';
|
||||
} from '../../serverStats.js';
|
||||
import LineChart from './LineChart.jsx';
|
||||
|
||||
// Große Ansicht eines P42-S.M.-Servers: aktuelle Werte (alle 5 s) + Verlauf bis 2 Tage (jede Minute neu)
|
||||
@@ -3,7 +3,7 @@ import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import { Placeholder } from '@tiptap/extensions';
|
||||
import { imgUrl, uploadImage } from '../api.js';
|
||||
import { imgUrl, uploadImage } from '../../api.js';
|
||||
|
||||
const imageFiles = (list) => [...(list || [])].filter((f) => f.type.startsWith('image/'));
|
||||
|
||||
File renamed without changes.
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { STATUS } from '../api.js';
|
||||
import { STATUS } from '../../api.js';
|
||||
|
||||
export default function StatusBadge({ status }) {
|
||||
const [label, tone] = STATUS[status] || [status, ''];
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
import { BY_CODE, people } from './laender.js';
|
||||
|
||||
// Stadt suchen (Open-Meteo über den Server) und hinzufügen; schon eingetragene sind markiert
|
||||
|
||||
export default 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../../auth.jsx';
|
||||
import { buildMapImage, download, svgToPng } from './mapExport.js';
|
||||
|
||||
// Karte als Bild herunterladen (nur eigene Orte): PNG mit beschrifteten Hauptstädten oder SVG mit allen Städten
|
||||
|
||||
const SIZES = [
|
||||
{ width: 4000, title: 'Mittel (4000 px breit)' },
|
||||
{ width: 8000, title: 'Groß (8000 px breit) – zum Hineinzoomen' },
|
||||
];
|
||||
|
||||
export default function ExportDialog({ ref, shapes, visited, cities }) {
|
||||
const { user } = useAuth();
|
||||
const [area, setArea] = useState(cities.length ? 'orte' : 'welt');
|
||||
const [format, setFormat] = useState('png');
|
||||
const [width, setWidth] = useState(8000);
|
||||
const [state, setState] = useState(null); // null | 'busy' | Fehlermeldung
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault();
|
||||
setState('busy');
|
||||
// Kurz Luft lassen, damit "Erstelle Bild …" erscheint, bevor der Browser rechnet
|
||||
await new Promise((resolve) => { setTimeout(resolve, 30); });
|
||||
try {
|
||||
const date = new Date();
|
||||
const caption = `Reisen · ${user.displayName} · ${visited.size} ${visited.size === 1 ? 'Land' : 'Länder'} · `
|
||||
+ `${cities.length} ${cities.length === 1 ? 'Stadt' : 'Städte'} · Stand ${date.toLocaleDateString('de-DE')}`
|
||||
+ ' · Karte: Natural Earth';
|
||||
const name = `reisekarte-${user.username.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${date.toISOString().slice(0, 10)}`;
|
||||
// SVG: klein und vollständig beschriftet (zoombar); PNG: nur Hauptstädte beschriftet
|
||||
const options = format === 'svg'
|
||||
? { shapes, visited, cities, area, caption, width: 2400, marks: 0.4, label: 'alle' }
|
||||
: { shapes, visited, cities, area, caption, width, marks: 1, label: 'hauptstaedte' };
|
||||
if (format === 'svg') {
|
||||
const { svg } = buildMapImage(options);
|
||||
download(new Blob([svg], { type: 'image/svg+xml' }), `${name}.svg`);
|
||||
} else {
|
||||
const out = buildMapImage(options);
|
||||
download(await svgToPng(out.svg, out.width, out.height), `${name}.png`);
|
||||
}
|
||||
setState(null);
|
||||
ref.current.close();
|
||||
} catch (err) {
|
||||
setState(err.message || 'Das Bild konnte nicht erstellt werden.');
|
||||
}
|
||||
}
|
||||
|
||||
const busy = state === 'busy';
|
||||
const capitals = cities.filter((c) => c.capital === true).length;
|
||||
const unknown = cities.filter((c) => c.capital == null).length;
|
||||
return (
|
||||
<dialog className="modal" ref={ref} onClose={() => setState(null)}>
|
||||
<form className="form" onSubmit={save}>
|
||||
<h2>Karte herunterladen</h2>
|
||||
<p className="hint">Deine Städte und besuchten Länder als Bild – Städte anderer Konten sind nicht dabei.</p>
|
||||
<fieldset className="link-picker">
|
||||
<legend>Ausschnitt</legend>
|
||||
<ul>
|
||||
<li>
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="area" checked={area === 'orte'} disabled={!cities.length} onChange={() => setArea('orte')} />
|
||||
Meine Orte (auf die Städte gezoomt)
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="area" checked={area === 'welt'} onChange={() => setArea('welt')} />
|
||||
Ganze Welt
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</fieldset>
|
||||
<fieldset className="link-picker">
|
||||
<legend>Format</legend>
|
||||
<ul>
|
||||
<li>
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="format" checked={format === 'png'} onChange={() => setFormat('png')} />
|
||||
PNG-Bild – beschriftet sind nur besuchte Hauptstädte ({capitals})
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<label className="checkbox">
|
||||
<input type="radio" name="format" checked={format === 'svg'} onChange={() => setFormat('svg')} />
|
||||
SVG – alle Städte beschriftet, beliebig scharf zoombar (z. B. zum Drucken)
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</fieldset>
|
||||
{format === 'png' && (
|
||||
<label>
|
||||
Größe
|
||||
<select value={width} onChange={(e) => setWidth(Number(e.target.value))}>
|
||||
{SIZES.map((s) => <option key={s.width} value={s.width}>{s.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{format === 'png' && unknown > 0 && (
|
||||
<p className="hint">Bei {unknown} {unknown === 1 ? 'Stadt' : 'Städten'} wird gerade noch nachgeschlagen, ob es eine Hauptstadt ist – kurz warten und die Seite neu laden.</p>
|
||||
)}
|
||||
{state && !busy && <p className="error">{state}</p>}
|
||||
<div className="form-actions">
|
||||
<button className="btn ghost" type="button" onClick={() => ref.current.close()}>Abbrechen</button>
|
||||
<button className="btn" type="submit" disabled={busy}>{busy ? 'Erstelle Bild …' : 'Herunterladen'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../../api.js';
|
||||
import { BY_CODE, countryName, num, people } from './laender.js';
|
||||
|
||||
// Info-Kasten unten links auf der Karte: Land oder Stadt mit Fakten und Wikipedia-Text
|
||||
|
||||
const languageName = new Intl.DisplayNames(['de'], { type: 'language' });
|
||||
const currencyName = new Intl.DisplayNames(['de'], { type: 'currency' });
|
||||
const safe = (fn, v) => { try { return fn(v); } catch { return v; } };
|
||||
|
||||
function localTime(timezone) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', { timeZone: timezone, hour: '2-digit', minute: '2-digit' }).format(new Date());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export default function InfoCard({ selected, city, cities, otherCities, manual, busy, onSelect, onClose, onMark, onRemoveCity }) {
|
||||
if (selected.type === 'city' && city) {
|
||||
// Wer war (sichtbar) noch in dieser Stadt?
|
||||
const same = [...cities, ...otherCities].filter((c) => c.geonameId === city.geonameId && c.id !== city.id);
|
||||
return <CityInfo city={city} same={same} 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)}
|
||||
otherCities={otherCities.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, otherCities, 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>
|
||||
)}
|
||||
{otherCities.length > 0 && (
|
||||
<div className="info-others">
|
||||
<span className="hint">Andere waren hier:</span>
|
||||
<span className="info-cities">
|
||||
{otherCities.map((c) => <OtherChip key={c.id} city={c} onSelect={onSelect} />)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function OtherChip({ city, onSelect, label = city.name }) {
|
||||
return (
|
||||
<button type="button" className="chip travel-person active" style={{ '--dot': `var(--other-${city.color})` }} onClick={() => onSelect({ type: 'city', key: city.id })}>
|
||||
<span className="legend-dot" aria-hidden="true" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CityInfo({ city, same, 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">
|
||||
{city.owner ? (
|
||||
<span className="badge travel-owner" style={{ '--dot': `var(--other-${city.color})` }}>Besucht von {city.owner.name}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="badge ok">Besucht</span>
|
||||
<button className="link-btn danger" type="button" disabled={busy} onClick={() => onRemove(city)}>Stadt entfernen</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{same.length > 0 && (
|
||||
<div className="info-others">
|
||||
<span className="hint">Auch hier:</span>
|
||||
<span className="info-cities">
|
||||
{same.map((c) => (c.owner
|
||||
? <OtherChip key={c.id} city={c} onSelect={onSelect} label={c.owner.name} />
|
||||
: <button key={c.id} type="button" className="chip" onClick={() => onSelect({ type: 'city', key: c.id })}>Du</button>))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { loadSet, saveSet } from '../../storage.js';
|
||||
import { BY_CODE, countryName } from './laender.js';
|
||||
|
||||
// Besuchte Orte: Kontinent → Land (ausklappbar) → Städte; auf-/zugeklappt wird pro Browser gemerkt
|
||||
|
||||
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';
|
||||
|
||||
const loadOpen = () => loadSet(OPEN_KEY);
|
||||
|
||||
export default function VisitedList({ visited, cities, manual, selected, onSelect }) {
|
||||
const [open, setOpen] = useState(loadOpen);
|
||||
|
||||
const save = (next) => {
|
||||
setOpen(next);
|
||||
saveSet(OPEN_KEY, next);
|
||||
};
|
||||
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);
|
||||
saveSet(CLOSED_KEY, next);
|
||||
};
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { geoArea, geoGraticule10, geoNaturalEarth1, geoPath } from 'd3-geo';
|
||||
import { select } from 'd3-selection';
|
||||
import 'd3-transition';
|
||||
import { zoom as d3zoom, zoomIdentity } from 'd3-zoom';
|
||||
import { isCalm } from '../motion.js';
|
||||
import { isCalm } from '../../motion.js';
|
||||
|
||||
const MAX_ZOOM = 60;
|
||||
const CITY_ZOOM = 9;
|
||||
@@ -0,0 +1,12 @@
|
||||
// Länderdaten (world-countries, ODbL) und Zahlenformate für die Reise-Seite
|
||||
import LAENDER from '../../data/laender.json';
|
||||
|
||||
export const BY_CODE = new Map(LAENDER.map((c) => [c.code, c]));
|
||||
export const BY_NUM = new Map(LAENDER.filter((c) => c.num).map((c) => [c.num, c]));
|
||||
|
||||
export const num = (n) => n.toLocaleString('de-DE');
|
||||
export const people = (n) => (n >= 1e6 ? `${(n / 1e6).toLocaleString('de-DE', { maximumFractionDigits: 1 })} Mio.` : num(n));
|
||||
|
||||
export function countryName(key) {
|
||||
return BY_CODE.get(key)?.name || (key.startsWith('X-') ? key.slice(2) : key);
|
||||
}
|
||||
File renamed without changes.
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LINK_SERVICES, useLinks } from '../links.jsx';
|
||||
import { LINK_SERVICES, useLinks } from '../../links.jsx';
|
||||
|
||||
// Dialog zum Anpassen der Links hinter den Schnellzugriff-Kacheln
|
||||
export default function LinksDialog({ ref }) {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { api } from '../../api.js';
|
||||
|
||||
const STATUS = {
|
||||
notfound: ['Nicht gefunden', 'warn'],
|
||||
Reference in new issue
Block a user