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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user