Reisen: Wunschliste, Besuche mit Fotos, Zeitleiste und Statistik

- Wunschliste (Spalte travel_cities.wish): eigene Punktform auf der Karte,
  zählt nicht als besucht, für andere Konten unsichtbar; "War ich da ✓"
- Besuche je Stadt (Tabellen travel_visits, travel_visit_photos): Zeitraum,
  Begleitung, Notizen, Fotos (Upload ohne Metadaten); nur für das eigene
  Konto; ein Besuch macht aus einem Wunsch einen Besuch
- Fotos von Besuchen zählen beim Aufräumen verwaister Bilder als benutzt;
  beim Entfernen eines Fotos, Besuchs oder einer Stadt werden die Dateien
  gelöscht
- Zeitleiste "Wann war ich wo?" nach Jahren, Fotos mit Lightbox
- Statistik: Länder (Welt, je Kontinent), Landfläche, äußerste Orte,
  Kilometer entlang der Besuche
- Ansichten unter der Karte als Tabs: Besuchte Orte, Zeitleiste,
  Statistik, Wunschliste
- Migration Schritt 4, Tests für Wunschliste, Besuche, Fotos und Aufräumen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
guyg1474andClaude Opus 5.5 committed 2026-10-15 19:25:07 +02:00
1 parent 9835f5e69e
commit bbf740603e
16 files changed
+695 -32

No files matched your search

@@ -0,0 +1,99 @@
import { useRef, useState } from 'react';
import { imgUrl, uploadImage } from '../../api.js';
/**
* Besuch einer Stadt eintragen oder bearbeiten: Zeitraum, Begleitung, Notizen, Fotos.
* Fotos werden beim Hochladen auf dem Server von Metadaten (GPS usw.) befreit.
* onSave(body) / onDelete() liefern bei Erfolg den neuen Stand (truthy).
*/
export default function VisitDialog({ ref, city, visit, onSave, onDelete, busy }) {
const today = new Date().toISOString().slice(0, 10);
const [form, setForm] = useState(() => ({
dateFrom: visit?.dateFrom || today,
dateTo: visit?.dateTo || '',
companions: visit?.companions || '',
notes: visit?.notes || '',
photos: visit?.photos || [],
}));
const [uploading, setUploading] = useState(0);
const [error, setError] = useState(null);
const fileInput = useRef(null);
const field = (name) => ({ value: form[name], onChange: (e) => setForm((f) => ({ ...f, [name]: e.target.value })) });
async function addPhotos(files) {
setError(null);
for (const file of files) {
setUploading((n) => n + 1);
try {
const name = await uploadImage(file);
setForm((f) => ({ ...f, photos: [...f.photos, name] }));
} catch (e) {
setError(`${file.name}: ${e.message}`);
} finally {
setUploading((n) => n - 1);
}
}
}
async function submit(e) {
e.preventDefault();
if (await onSave({ ...form, dateTo: form.dateTo || null })) ref.current.close();
}
async function remove() {
if (!window.confirm(`Besuch in ${city.name} löschen?`)) return;
if (await onDelete()) ref.current.close();
}
return (
<dialog className="modal task-dialog" ref={ref}>
<form className="form" onSubmit={submit}>
<h2>{visit ? 'Besuch bearbeiten' : 'Besuch eintragen'} – {city.name}</h2>
<div className="task-dialog-row">
<label>
Von
<input type="date" {...field('dateFrom')} required max={today} />
</label>
<label>
Bis
<input type="date" {...field('dateTo')} min={form.dateFrom} aria-describedby="bis-hint" />
</label>
</div>
<p className="hint visit-hint" id="bis-hint">„Bis“ leer lassen für einen einzelnen Tag.</p>
<label>
Begleitung <small>(optional)</small>
<input {...field('companions')} maxLength={200} placeholder="z. B. Anna, Ben" />
</label>
<label>
Notizen <small>(optional)</small>
<textarea {...field('notes')} rows={4} maxLength={5000} placeholder="Was war schön? Tipps fürs nächste Mal …" />
</label>
<fieldset className="link-picker">
<legend>Fotos <small>({form.photos.length})</small></legend>
{form.photos.length > 0 && (
<ul className="visit-photos">
{form.photos.map((p) => (
<li key={p}>
<img src={imgUrl(p)} alt="" loading="lazy" />
<button type="button" className="link-btn danger" onClick={() => setForm((f) => ({ ...f, photos: f.photos.filter((x) => x !== p) }))} aria-label="Foto entfernen">✕</button>
</li>
))}
</ul>
)}
<input ref={fileInput} type="file" accept="image/jpeg,image/png,image/webp,image/gif" multiple hidden
onChange={(e) => { addPhotos([...e.target.files]); e.target.value = ''; }} />
<button className="btn ghost" type="button" onClick={() => fileInput.current.click()} disabled={uploading > 0}>
{uploading ? `Lade ${uploading} Foto${uploading === 1 ? '' : 's'} hoch …` : '+ Fotos hinzufügen'}
</button>
<p className="hint">Standort, Kamera und Aufnahmezeit werden beim Hochladen aus den Fotos entfernt.</p>
</fieldset>
{error && <p className="error">{error}</p>}
<div className="form-actions">
{visit && <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 || uploading > 0}>{visit ? 'Speichern' : 'Eintragen'}</button>
</div>
</form>
</dialog>
);
}