Listen mit eigenem Vorrat und Einkauf, Städte anderer auf der Reise-Karte, Design "Fabrik"
Vorrat & Einkauf: - Mehrere Listen (z. B. "Zuhause", "Urlaub"), jede mit eigenem Vorrat und eigener Einkaufsliste; Auswahl oben auf der Seite, gemerkt pro Browser - Beim Anlegen festlegen, wer die Liste sieht: alle Konten oder nur ausgewählte (Ersteller sieht sie immer); ändern/löschen nur Ersteller und Administratoren - Gekauft, aufgebraucht, Nachkaufen und Rezeptabgleich wirken innerhalb der gewählten Liste - Migration: bisheriger Vorrat und Einkauf werden zur Liste "Zuhause" (für alle sichtbar) Reisen: - Schalter "Städte anderer zeigen": Städte der übrigen Konten in je einer eigenen Farbe, einzelne Personen ein-/ausblendbar, nur lesend - Info-Karte zeigt, wer noch in einer Stadt bzw. einem Land war Design "Fabrik" (Industrie-Inventar à la Satisfactory): - Stahlplatten mit Schrauben, Titelleisten mit Fenstersymbol, gelb leuchtende Hauptbuttons, orange Mengen-Zähler, Warnstreifen, Riffelblech und drehende Zahnräder im Hintergrund - Schriften Roboto / Roboto Condensed (OFL), lokal eingebunden Doku: README verlinkt das zugehörige Repository P42-S.M. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f16c8c73c6
commit
b30acb652a
20 files changed
+1182
-82
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api, personName } from '../api.js';
|
||||
import { useAuth } from '../auth.jsx';
|
||||
import ProductInput from '../components/ProductInput.jsx';
|
||||
import RecipeCheck from '../components/RecipeCheck.jsx';
|
||||
import UnitSelect from '../components/UnitSelect.jsx';
|
||||
@@ -8,9 +9,10 @@ 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 LIST_KEY = 'p42.haushaltListe';
|
||||
|
||||
const LISTS = {
|
||||
vorrat: { title: 'Vorrat', lead: 'Was zu Hause ist', empty: 'Noch nichts im Vorrat eingetragen.' },
|
||||
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.' },
|
||||
};
|
||||
|
||||
@@ -18,12 +20,20 @@ function loadTab() {
|
||||
try { return localStorage.getItem(TAB_KEY) === 'einkauf' ? 'einkauf' : 'vorrat'; } catch { return 'vorrat'; }
|
||||
}
|
||||
|
||||
function loadListId() {
|
||||
try { return Number(localStorage.getItem(LIST_KEY)) || null; } catch { return null; }
|
||||
}
|
||||
|
||||
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 [listId, setListId] = useState(loadListId);
|
||||
// Dialog für Listen: null = zu, { list: null } = neue Liste, { list } = bearbeiten
|
||||
const [listDialog, setListDialog] = useState(null);
|
||||
const dialog = useRef(null);
|
||||
const listDialogRef = useRef(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -44,13 +54,14 @@ export default function Haushalt() {
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
// Jede Änderung liefert den aktuellen Stand beider Listen zurück
|
||||
// Jede Änderung liefert den aktuellen Stand aller Listen zurück
|
||||
async function run(method, url, body) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await api(method, url, body));
|
||||
return true;
|
||||
const next = await api(method, url, body);
|
||||
setData(next);
|
||||
return next;
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
// Bei Konflikten (z. B. gerade von jemand anderem gelöscht) frisch laden
|
||||
@@ -66,16 +77,50 @@ export default function Haushalt() {
|
||||
try { localStorage.setItem(TAB_KEY, next); } catch { /* ignorieren */ }
|
||||
}
|
||||
|
||||
function chooseList(id) {
|
||||
setListId(id);
|
||||
try { localStorage.setItem(LIST_KEY, String(id)); } catch { /* ignorieren */ }
|
||||
}
|
||||
|
||||
function openListDialog(list) {
|
||||
setListDialog({ list });
|
||||
// Erst nach dem Rendern öffnen, damit der Dialog mit den neuen Werten startet
|
||||
requestAnimationFrame(() => listDialogRef.current?.showModal());
|
||||
}
|
||||
|
||||
async function saveList(form) {
|
||||
const editing = listDialog?.list;
|
||||
try {
|
||||
const next = await api(editing ? 'PATCH' : 'POST', editing ? `/api/haushalt/listen/${editing.id}` : '/api/haushalt/listen', form);
|
||||
setData(next);
|
||||
if (next.created) chooseList(next.created);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteList(list) {
|
||||
const count = (data?.items || []).filter((i) => i.listId === list.id).length;
|
||||
const what = count ? ` mit ${count} ${count === 1 ? 'Eintrag' : 'Einträgen'}` : '';
|
||||
if (!window.confirm(`Liste „${list.name}“ samt Vorrat und Einkauf${what} löschen?`)) return false;
|
||||
return Boolean(await run('DELETE', `/api/haushalt/listen/${list.id}`));
|
||||
}
|
||||
|
||||
const items = data?.items || [];
|
||||
const lists = data?.lists || [];
|
||||
// Gewählte Liste (z. B. Zuhause, Urlaub); gibt es sie nicht (mehr), die erste sichtbare
|
||||
const current = lists.find((l) => l.id === listId) || lists[0] || null;
|
||||
const mine = current ? items.filter((i) => i.listId === current.id) : [];
|
||||
const byList = {
|
||||
vorrat: items.filter((i) => i.list === 'vorrat'),
|
||||
einkauf: items.filter((i) => i.list === 'einkauf'),
|
||||
vorrat: mine.filter((i) => i.list === 'vorrat'),
|
||||
einkauf: mine.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 }),
|
||||
add: (list, name, quantity, unit) => run('POST', '/api/haushalt/items', { list, name, quantity, unit, listId: current?.id }),
|
||||
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}`),
|
||||
@@ -86,7 +131,7 @@ export default function Haushalt() {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Vorrat & Einkauf</h1>
|
||||
<p className="lead">Gemeinsame Liste für alle Konten. Wird alle {REFRESH_SECONDS} Sekunden aktualisiert.</p>
|
||||
<p className="lead">Jede Liste hat ihren eigenen Vorrat und Einkauf – z. B. Zuhause und Urlaub. 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>
|
||||
@@ -97,12 +142,29 @@ export default function Haushalt() {
|
||||
{!data && !error && <p className="hint">Lade …</p>}
|
||||
|
||||
{data && (
|
||||
<ListSwitcher
|
||||
lists={lists}
|
||||
current={current}
|
||||
users={data.users}
|
||||
onChoose={chooseList}
|
||||
onNew={() => openListDialog(null)}
|
||||
onEdit={openListDialog}
|
||||
busy={busy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data && !current && (
|
||||
<p className="hint household-empty">Keine Liste, die du sehen kannst. Leg mit „+ Neue Liste“ eine an.</p>
|
||||
)}
|
||||
|
||||
{data && current && (
|
||||
<>
|
||||
<RecipeCheck
|
||||
products={data.products}
|
||||
find={find}
|
||||
busy={busy}
|
||||
onTakeOver={(body) => run('POST', '/api/haushalt/rezept/uebernehmen', body)}
|
||||
shoppingList={current}
|
||||
onTakeOver={(body) => run('POST', '/api/haushalt/rezept/uebernehmen', { ...body, listId: current?.id })}
|
||||
/>
|
||||
|
||||
<div className="segmented" role="tablist" aria-label="Liste wählen">
|
||||
@@ -138,6 +200,18 @@ export default function Haushalt() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{listDialog && (
|
||||
<ListDialog
|
||||
key={listDialog.list?.id ?? 'neu'}
|
||||
ref={listDialogRef}
|
||||
list={listDialog.list}
|
||||
users={data?.users || []}
|
||||
onSave={saveList}
|
||||
onDelete={deleteList}
|
||||
onClosed={() => setListDialog(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ProductsDialog ref={dialog} products={data?.products || []} onDelete={(name) => run('DELETE', `/api/haushalt/produkte?name=${encodeURIComponent(name)}`)} />
|
||||
</>
|
||||
);
|
||||
@@ -361,6 +435,145 @@ function ItemRow({ item, list, other, actions, busy }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Listen (je Vorrat + Einkauf): wählen, anlegen, freigeben ----------
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductsDialog({ ref, products, onDelete }) {
|
||||
const sorted = [...products].sort((a, b) => a.localeCompare(b, 'de-DE'));
|
||||
return (
|
||||
|
||||
@@ -33,6 +33,14 @@ function localTime(timezone) {
|
||||
}
|
||||
}
|
||||
|
||||
const OTHERS_KEY = 'p42.reisenAndere';
|
||||
const HIDDEN_KEY = 'p42.reisenAndereAus';
|
||||
const OTHER_COLORS = 6;
|
||||
|
||||
function loadFlag(key) {
|
||||
try { return localStorage.getItem(key) === '1'; } catch { return false; }
|
||||
}
|
||||
|
||||
export default function Reisen() {
|
||||
const [data, setData] = useState(null);
|
||||
const [shapes, setShapes] = useState(null);
|
||||
@@ -40,6 +48,9 @@ export default function Reisen() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [wide, setWide] = useState(() => window.matchMedia('(min-width: 760px)').matches);
|
||||
// Städte der anderen Konten einblenden; einzelne Personen lassen sich ausblenden
|
||||
const [showOthers, setShowOthers] = useState(() => loadFlag(OTHERS_KEY));
|
||||
const [hiddenPeople, setHiddenPeople] = useState(() => loadSet(HIDDEN_KEY));
|
||||
const map = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -71,6 +82,32 @@ export default function Reisen() {
|
||||
}, []);
|
||||
|
||||
const cities = data?.cities || [];
|
||||
// Farbe pro Person fest nach Reihenfolge (alphabetisch), damit sie beim Ein-/Ausblenden gleich bleibt
|
||||
const others = useMemo(
|
||||
() => (data?.others || []).map((person, i) => ({ ...person, color: (i % OTHER_COLORS) + 1 })),
|
||||
[data],
|
||||
);
|
||||
const otherCities = useMemo(
|
||||
() => (showOthers
|
||||
? others.filter((p) => !hiddenPeople.has(p.id)).flatMap((p) => p.cities.map((c) => ({ ...c, owner: p, color: p.color })))
|
||||
: []),
|
||||
[others, showOthers, hiddenPeople],
|
||||
);
|
||||
const allCities = useMemo(() => [...cities, ...otherCities], [cities, otherCities]);
|
||||
|
||||
function toggleOthers() {
|
||||
const next = !showOthers;
|
||||
setShowOthers(next);
|
||||
if (!next && selected?.type === 'city' && !cities.some((c) => c.id === selected.key)) setSelected(null);
|
||||
try { localStorage.setItem(OTHERS_KEY, next ? '1' : '0'); } catch { /* ignorieren */ }
|
||||
}
|
||||
|
||||
function togglePerson(id) {
|
||||
const next = new Set(hiddenPeople);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
setHiddenPeople(next);
|
||||
try { localStorage.setItem(HIDDEN_KEY, JSON.stringify([...next])); } catch { /* ignorieren */ }
|
||||
}
|
||||
// Besucht = von Hand markiert oder mindestens eine Stadt dort
|
||||
const visited = useMemo(
|
||||
() => new Set([...(data?.countries || []), ...cities.map((c) => c.country)]),
|
||||
@@ -83,10 +120,10 @@ export default function Reisen() {
|
||||
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);
|
||||
const city = allCities.find((c) => c.id === sel.key);
|
||||
if (city) map.current?.focusCity(city, wide);
|
||||
}
|
||||
}, [data, wide]);
|
||||
}, [allCities, wide]);
|
||||
|
||||
async function run(method, url, body) {
|
||||
setBusy(true);
|
||||
@@ -112,7 +149,7 @@ export default function Reisen() {
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCity = selected?.type === 'city' ? cities.find((c) => c.id === selected.key) : null;
|
||||
const selectedCity = selected?.type === 'city' ? allCities.find((c) => c.id === selected.key) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -129,6 +166,39 @@ export default function Reisen() {
|
||||
<CitySearch cities={cities} onAdd={addCity} busy={busy} />
|
||||
{error && <p className="error travel-error" role="alert">{error}</p>}
|
||||
|
||||
{data && (
|
||||
<div className="travel-others" role="group" aria-label="Städte anderer Konten">
|
||||
<button type="button" className={`chip${showOthers ? ' active' : ''}`} aria-pressed={showOthers} onClick={toggleOthers}>
|
||||
{showOthers ? '✓ ' : ''}Städte anderer zeigen
|
||||
</button>
|
||||
{showOthers && others.length === 0 && (
|
||||
<span className="hint travel-legend">Noch kein anderes Konto hat Städte eingetragen.</span>
|
||||
)}
|
||||
{showOthers && others.length > 0 && (
|
||||
<>
|
||||
<span className="travel-legend"><span className="legend-dot own" aria-hidden="true" />Du</span>
|
||||
{others.map((p) => {
|
||||
const on = !hiddenPeople.has(p.id);
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`chip travel-person${on ? ' active' : ''}`}
|
||||
style={{ '--dot': `var(--other-${p.color})` }}
|
||||
aria-pressed={on}
|
||||
onClick={() => togglePerson(p.id)}
|
||||
title={on ? `${p.name} ausblenden` : `${p.name} einblenden`}
|
||||
>
|
||||
<span className="legend-dot" aria-hidden="true" />
|
||||
{p.name} <small>{p.cities.length}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="travel-map card">
|
||||
{shapes ? (
|
||||
<WorldMap
|
||||
@@ -136,6 +206,7 @@ export default function Reisen() {
|
||||
countries={shapes}
|
||||
visited={visited}
|
||||
cities={cities}
|
||||
otherCities={otherCities}
|
||||
selected={selected}
|
||||
onSelect={(sel) => select(sel)}
|
||||
/>
|
||||
@@ -153,6 +224,7 @@ export default function Reisen() {
|
||||
selected={selected}
|
||||
city={selectedCity}
|
||||
cities={cities}
|
||||
otherCities={otherCities}
|
||||
manual={data?.countries || []}
|
||||
busy={busy}
|
||||
onSelect={select}
|
||||
@@ -447,15 +519,18 @@ function useInfo(params) {
|
||||
return info;
|
||||
}
|
||||
|
||||
function InfoCard({ selected, city, cities, manual, busy, onSelect, onClose, onMark, onRemoveCity }) {
|
||||
function InfoCard({ selected, city, cities, otherCities, manual, busy, onSelect, onClose, onMark, onRemoveCity }) {
|
||||
if (selected.type === 'city' && city) {
|
||||
return <CityInfo city={city} onSelect={onSelect} onClose={onClose} onRemove={onRemoveCity} busy={busy} />;
|
||||
// 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}
|
||||
@@ -479,7 +554,7 @@ function Extract({ info }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CountryInfo({ code, cities, manual, onSelect, onClose, onMark, busy }) {
|
||||
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 });
|
||||
@@ -521,11 +596,28 @@ function CountryInfo({ code, cities, manual, onSelect, onClose, onMark, busy })
|
||||
{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 CityInfo({ city, onSelect, onClose, onRemove, busy }) {
|
||||
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);
|
||||
@@ -552,9 +644,25 @@ function CityInfo({ city, onSelect, onClose, onRemove, busy }) {
|
||||
</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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user