Autosave city form, heart-style likes, social link handles, map polish

- Admin: allow editing a user's display name
- Map: zoom to ~500km around the most recently added city on load;
  marker popups show the photo and links, and fix the unreadable
  black-on-black "View details" button
- City form: autosave on every change (debounced for text, immediate
  for status/heart/participants) instead of an explicit Save button;
  a new city is created as soon as name, country, and location are set
- Replace the "Liked" checkbox with a heart toggle (outline vs. filled)
- Links: add a platform selector (Instagram/X/Facebook/TikTok/YouTube/
  LinkedIn) so a handle auto-generates the profile URL, alongside plain
  website links; links autosave too, no separate Save button

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Claudio Schaad 2026-07-20 21:24:47 +02:00
parent 3debb1ce1e
commit ce16de8504
8 changed files with 496 additions and 218 deletions

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import type { CityLink } from '@citytracker/shared';
interface LinkListEditorProps {
@ -7,75 +7,148 @@ interface LinkListEditorProps {
}
interface DraftLink {
url: string;
type: string;
label: string;
value: string;
}
export function LinkListEditor({ links, onSave }: LinkListEditorProps) {
const [draft, setDraft] = useState<DraftLink[]>(
links.map((l) => ({ url: l.url, label: l.label })),
);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
interface Platform {
key: string;
label: string;
prefix: string | null;
placeholder: string;
}
function updateLink(index: number, field: keyof DraftLink, value: string) {
setDraft((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l)));
setDirty(true);
const PLATFORMS: Platform[] = [
{ key: 'website', label: 'Website', prefix: null, placeholder: 'https://…' },
{ key: 'instagram', label: 'Instagram', prefix: 'https://instagram.com/', placeholder: 'username' },
{ key: 'twitter', label: 'X (Twitter)', prefix: 'https://x.com/', placeholder: 'username' },
{ key: 'facebook', label: 'Facebook', prefix: 'https://facebook.com/', placeholder: 'username' },
{ key: 'tiktok', label: 'TikTok', prefix: 'https://tiktok.com/@', placeholder: 'username' },
{ key: 'youtube', label: 'YouTube', prefix: 'https://youtube.com/@', placeholder: 'handle' },
{ key: 'linkedin', label: 'LinkedIn', prefix: 'https://linkedin.com/in/', placeholder: 'username' },
];
function platformFor(key: string): Platform {
return PLATFORMS.find((p) => p.key === key) ?? PLATFORMS[0];
}
function parseLinkForEditing(link: CityLink): DraftLink {
for (const platform of PLATFORMS) {
if (platform.prefix && link.url.startsWith(platform.prefix)) {
return { type: platform.key, label: link.label, value: link.url.slice(platform.prefix.length) };
}
}
return { type: 'website', label: link.label, value: link.url };
}
function buildUrl(draft: DraftLink): string | null {
const platform = platformFor(draft.type);
const value = draft.value.trim();
if (!value) return null;
if (!platform.prefix) return value;
return platform.prefix + value.replace(/^@+/, '');
}
function buildLabel(draft: DraftLink): string {
const trimmed = draft.label.trim();
if (trimmed) return trimmed;
const platform = platformFor(draft.type);
return platform.key === 'website' ? '' : platform.label;
}
function toPayload(draft: DraftLink[]): { url: string; label: string }[] {
return draft
.map((d) => {
const url = buildUrl(d);
return url ? { url, label: buildLabel(d) } : null;
})
.filter((l): l is { url: string; label: string } => l !== null);
}
const AUTOSAVE_DELAY_MS = 800;
export function LinkListEditor({ links, onSave }: LinkListEditorProps) {
const [draft, setDraft] = useState<DraftLink[]>(() => links.map(parseLinkForEditing));
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const lastSavedRef = useRef<string>(JSON.stringify(toPayload(links.map(parseLinkForEditing))));
function persist(nextDraft: DraftLink[], immediate = false) {
setDraft(nextDraft);
if (debounceRef.current) clearTimeout(debounceRef.current);
const run = async () => {
const payload = toPayload(nextDraft);
const serialized = JSON.stringify(payload);
if (serialized === lastSavedRef.current) return;
setStatus('saving');
try {
await onSave(payload);
lastSavedRef.current = serialized;
setStatus('saved');
} catch {
setStatus('error');
}
};
if (immediate) {
run();
} else {
debounceRef.current = setTimeout(run, AUTOSAVE_DELAY_MS);
}
}
function updateRow(index: number, patch: Partial<DraftLink>) {
persist(draft.map((d, i) => (i === index ? { ...d, ...patch } : d)));
}
function addLink() {
setDraft((prev) => [...prev, { url: '', label: '' }]);
setDirty(true);
persist([...draft, { type: 'website', label: '', value: '' }], true);
}
function removeLink(index: number) {
setDraft((prev) => prev.filter((_, i) => i !== index));
setDirty(true);
}
async function handleSave() {
setSaving(true);
try {
await onSave(draft.filter((l) => l.url.trim()));
setDirty(false);
} finally {
setSaving(false);
}
persist(draft.filter((_, i) => i !== index), true);
}
return (
<div className="link-editor">
{draft.map((link, index) => (
<div className="link-row" key={index}>
<input
placeholder="Label (optional)"
value={link.label}
onChange={(e) => updateLink(index, 'label', e.target.value)}
/>
<input
placeholder="https://…"
value={link.url}
onChange={(e) => updateLink(index, 'url', e.target.value)}
/>
<button type="button" className="btn btn-ghost" onClick={() => removeLink(index)}>
Remove
</button>
</div>
))}
{draft.map((row, index) => {
const platform = platformFor(row.type);
return (
<div className="link-row" key={index}>
<select value={row.type} onChange={(e) => updateRow(index, { type: e.target.value })}>
{PLATFORMS.map((p) => (
<option key={p.key} value={p.key}>
{p.label}
</option>
))}
</select>
<input
placeholder="Label (optional)"
value={row.label}
onChange={(e) => updateRow(index, { label: e.target.value })}
/>
<input
placeholder={platform.placeholder}
value={row.value}
onChange={(e) => updateRow(index, { value: e.target.value })}
/>
<button type="button" className="btn btn-ghost btn-small" onClick={() => removeLink(index)}>
Remove
</button>
</div>
);
})}
<div className="link-actions">
<button type="button" className="btn btn-small" onClick={addLink}>
+ Add link
</button>
{dirty && (
<button
type="button"
className="btn btn-primary btn-small"
onClick={handleSave}
disabled={saving}
>
{saving ? 'Saving…' : 'Save links'}
</button>
)}
<span className="save-status">
{status === 'saving' && 'Saving…'}
{status === 'saved' && 'Saved'}
{status === 'error' && 'Could not save'}
</span>
</div>
</div>
);

View file

@ -1,11 +1,13 @@
import 'leaflet/dist/leaflet.css';
import { MapContainer, TileLayer, Marker, Popup, useMapEvents } from 'react-leaflet';
import { useRef } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents } from 'react-leaflet';
import { useNavigate } from 'react-router-dom';
import type { City } from '@citytracker/shared';
import { cityMarkerIcon } from './CityMarker';
interface CityMapProps {
cities: City[];
focusCity?: City;
onMapClick?: (lat: number, lng: number) => void;
}
@ -18,7 +20,29 @@ function ClickHandler({ onMapClick }: { onMapClick?: (lat: number, lng: number)
return null;
}
export function CityMap({ cities, onMapClick }: CityMapProps) {
/** Roughly bounds a `radiusKm` circle around a point, for an initial "zoomed to this area" view. */
function boundsAroundKm(lat: number, lng: number, radiusKm: number): [[number, number], [number, number]] {
const latDelta = radiusKm / 111;
const lngDelta = radiusKm / (111 * Math.cos((lat * Math.PI) / 180) || 1);
return [
[lat - latDelta, lng - lngDelta],
[lat + latDelta, lng + lngDelta],
];
}
function InitialFocus({ focusCity }: { focusCity?: City }) {
const map = useMap();
const appliedRef = useRef(false);
if (focusCity && !appliedRef.current) {
appliedRef.current = true;
map.fitBounds(boundsAroundKm(focusCity.lat, focusCity.lng, 500));
}
return null;
}
export function CityMap({ cities, focusCity, onMapClick }: CityMapProps) {
const navigate = useNavigate();
return (
@ -28,13 +52,36 @@ export function CityMap({ cities, onMapClick }: CityMapProps) {
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<ClickHandler onMapClick={onMapClick} />
<InitialFocus focusCity={focusCity} />
{cities.map((city) => (
<Marker key={city.id} position={[city.lat, city.lng]} icon={cityMarkerIcon(city)}>
<Popup>
<div className="map-popup">
<strong>{city.name}</strong>
<div>{city.country}</div>
<button type="button" className="btn btn-small" onClick={() => navigate(`/cities/${city.id}`)}>
{city.photoFilename && (
<img
className="map-popup-photo"
src={`/uploads/${city.photoFilename}`}
alt={city.name}
/>
)}
{city.links.length > 0 && (
<ul className="map-popup-links">
{city.links.map((link) => (
<li key={link.id}>
<a href={link.url} target="_blank" rel="noreferrer">
{link.label || link.url}
</a>
</li>
))}
</ul>
)}
<button
type="button"
className="btn btn-primary btn-small"
onClick={() => navigate(`/cities/${city.id}`)}
>
View details
</button>
</div>

View file

@ -0,0 +1,19 @@
interface HeartToggleProps {
liked: boolean;
onToggle: () => void;
size?: 'small' | 'large';
}
export function HeartToggle({ liked, onToggle, size = 'large' }: HeartToggleProps) {
return (
<button
type="button"
className={`heart-toggle heart-toggle-${size} ${liked ? 'heart-toggle-liked' : ''}`}
onClick={onToggle}
aria-pressed={liked}
aria-label={liked ? 'Liked' : 'Not liked'}
>
{liked ? '♥' : '♡'}
</button>
);
}

View file

@ -1,10 +1,4 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type {
CreateCityInput,
UpdateCityInput,
SetParticipantsInput,
SetLinksInput,
} from '@citytracker/shared';
import * as citiesApi from '../api/cities';
import type { CityListFilters } from '../api/cities';
@ -35,22 +29,6 @@ function useInvalidateCities() {
};
}
export function useCreateCity() {
const invalidate = useInvalidateCities();
return useMutation({
mutationFn: (input: CreateCityInput) => citiesApi.createCity(input),
onSuccess: () => invalidate(),
});
}
export function useUpdateCity(id: number) {
const invalidate = useInvalidateCities();
return useMutation({
mutationFn: (input: UpdateCityInput) => citiesApi.updateCity(id, input),
onSuccess: () => invalidate(id),
});
}
export function useDeleteCity() {
const invalidate = useInvalidateCities();
return useMutation({
@ -59,22 +37,6 @@ export function useDeleteCity() {
});
}
export function useSetParticipants(id: number) {
const invalidate = useInvalidateCities();
return useMutation({
mutationFn: (input: SetParticipantsInput) => citiesApi.setParticipants(id, input),
onSuccess: () => invalidate(id),
});
}
export function useSetLinks(id: number) {
const invalidate = useInvalidateCities();
return useMutation({
mutationFn: (input: SetLinksInput) => citiesApi.setLinks(id, input),
onSuccess: () => invalidate(id),
});
}
export function useUploadPhoto(id: number) {
const invalidate = useInvalidateCities();
return useMutation({

View file

@ -65,6 +65,16 @@ export function AdminUsersPage() {
}
}
async function handleEditDisplayName(id: number, current: string | null) {
const next = window.prompt('Display name:', current ?? '');
if (next === null) return;
try {
await updateUser.mutateAsync({ id, input: { displayName: next.trim() || null } });
} catch {
window.alert('Could not update display name. Please try again.');
}
}
async function handleResetPassword(id: number) {
const newPassword = window.prompt('Enter a new password (min 8 characters):');
if (!newPassword) return;
@ -119,6 +129,13 @@ export function AdminUsersPage() {
</button>
</td>
<td>
<button
type="button"
className="btn btn-small"
onClick={() => handleEditDisplayName(u.id, u.displayName)}
>
Edit name
</button>
<button
type="button"
className="btn btn-small"

View file

@ -1,21 +1,15 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import type { CityStatus, GeocodeResult } from '@citytracker/shared';
import { useAuth } from '../context/AuthContext';
import {
useCity,
useCreateCity,
useUpdateCity,
useDeleteCity,
useSetParticipants,
useSetLinks,
useUploadPhoto,
useDeletePhoto,
} from '../hooks/useCities';
import { useCity, useDeleteCity, useUploadPhoto, useDeletePhoto } from '../hooks/useCities';
import * as citiesApi from '../api/cities';
import { CitySearchInput } from '../components/city/CitySearchInput';
import { LinkListEditor } from '../components/city/LinkListEditor';
import { ParticipantPicker } from '../components/city/ParticipantPicker';
import { PhotoUploader } from '../components/city/PhotoUploader';
import { HeartToggle } from '../components/ui/HeartToggle';
interface CityDetailPageProps {
mode: 'new' | 'edit';
@ -26,126 +20,211 @@ interface NavState {
lng?: number;
}
interface FormState {
name: string;
country: string;
countryCode: string | null;
lat: number | null;
lng: number | null;
status: CityStatus;
visitDateStart: string;
visitDateEnd: string;
notes: string;
liked: boolean;
participantIds: number[];
}
type SaveStatus = 'idle' | 'unsaved' | 'saving' | 'saved' | 'error';
const AUTOSAVE_DELAY_MS = 800;
function isReadyToSave(f: FormState): boolean {
return f.lat !== null && f.lng !== null && f.name.trim() !== '' && f.country.trim() !== '';
}
export function CityDetailPage({ mode }: CityDetailPageProps) {
const { id } = useParams();
const cityId = mode === 'edit' ? Number(id) : undefined;
const initialCityId = mode === 'edit' ? Number(id) : undefined;
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
const { data: city, isLoading } = useCity(cityId);
const createCity = useCreateCity();
const updateCity = useUpdateCity(cityId ?? -1);
const { data: city } = useCity(initialCityId);
const deleteCity = useDeleteCity();
const setParticipants = useSetParticipants(cityId ?? -1);
const setLinks = useSetLinks(cityId ?? -1);
const cityIdRef = useRef<number | undefined>(initialCityId);
const [cityId, setCityId] = useState<number | undefined>(initialCityId);
const uploadPhoto = useUploadPhoto(cityId ?? -1);
const deletePhotoMutation = useDeletePhoto(cityId ?? -1);
const navState = location.state as NavState | null;
const [name, setName] = useState('');
const [country, setCountry] = useState('');
const [countryCode, setCountryCode] = useState<string | null>(null);
const [lat, setLat] = useState<number | null>(navState?.lat ?? null);
const [lng, setLng] = useState<number | null>(navState?.lng ?? null);
const [status, setStatus] = useState<CityStatus>('visited');
const [visitDateStart, setVisitDateStart] = useState('');
const [visitDateEnd, setVisitDateEnd] = useState('');
const [notes, setNotes] = useState('');
const [liked, setLiked] = useState(false);
const [participantIds, setParticipantIds] = useState<number[]>(user ? [user.id] : []);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState<FormState>({
name: '',
country: '',
countryCode: null,
lat: navState?.lat ?? null,
lng: navState?.lng ?? null,
status: 'visited',
visitDateStart: '',
visitDateEnd: '',
notes: '',
liked: false,
participantIds: user ? [user.id] : [],
});
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const lastPersistedRef = useRef<string | null>(null);
const hydratedForIdRef = useRef<number | undefined>(undefined);
// Hydrate local form state from loaded city data exactly once per city id, so a background
// refetch triggered by our own autosave doesn't clobber whatever the user has typed since.
useEffect(() => {
if (city) {
setName(city.name);
setCountry(city.country);
setCountryCode(city.countryCode);
setLat(city.lat);
setLng(city.lng);
setStatus(city.status);
setVisitDateStart(city.visitDateStart ?? '');
setVisitDateEnd(city.visitDateEnd ?? '');
setNotes(city.notes);
setLiked(city.liked);
setParticipantIds(city.participants.map((p) => p.id));
if (city && hydratedForIdRef.current !== city.id) {
hydratedForIdRef.current = city.id;
const hydrated: FormState = {
name: city.name,
country: city.country,
countryCode: city.countryCode,
lat: city.lat,
lng: city.lng,
status: city.status,
visitDateStart: city.visitDateStart ?? '',
visitDateEnd: city.visitDateEnd ?? '',
notes: city.notes,
liked: city.liked,
participantIds: city.participants.map((p) => p.id),
};
setForm(hydrated);
lastPersistedRef.current = JSON.stringify(hydrated);
}
}, [city]);
function handleGeocodeSelect(result: GeocodeResult) {
setName(result.displayName.split(',')[0]);
setCountry(result.country ?? '');
setCountryCode(result.countryCode);
setLat(result.lat);
setLng(result.lng);
}
async function doSave(next: FormState) {
if (!isReadyToSave(next)) return;
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
const snapshot = JSON.stringify(next);
if (snapshot === lastPersistedRef.current) return;
if (lat === null || lng === null) {
setError('Please select a location by searching or clicking the map.');
return;
}
setSubmitting(true);
setSaveStatus('saving');
try {
if (mode === 'new') {
const created = await createCity.mutateAsync({
name,
country,
countryCode: countryCode ?? null,
lat,
lng,
status,
visitDateStart: visitDateStart || undefined,
visitDateEnd: visitDateEnd || undefined,
notes,
liked,
participantIds,
if (cityIdRef.current === undefined) {
const created = await citiesApi.createCity({
name: next.name,
country: next.country,
countryCode: next.countryCode,
lat: next.lat!,
lng: next.lng!,
status: next.status,
visitDateStart: next.visitDateStart || undefined,
visitDateEnd: next.visitDateEnd || undefined,
notes: next.notes,
liked: next.liked,
participantIds: next.participantIds,
});
cityIdRef.current = created.id;
hydratedForIdRef.current = created.id;
lastPersistedRef.current = snapshot;
setCityId(created.id);
setSaveStatus('saved');
queryClient.invalidateQueries({ queryKey: ['cities'] });
navigate(`/cities/${created.id}`, { replace: true });
} else if (cityId) {
await updateCity.mutateAsync({
name,
country,
countryCode,
lat,
lng,
status,
visitDateStart: visitDateStart || null,
visitDateEnd: visitDateEnd || null,
notes,
liked,
} else {
await citiesApi.updateCity(cityIdRef.current, {
name: next.name,
country: next.country,
countryCode: next.countryCode,
lat: next.lat!,
lng: next.lng!,
status: next.status,
visitDateStart: next.visitDateStart || null,
visitDateEnd: next.visitDateEnd || null,
notes: next.notes,
liked: next.liked,
});
await setParticipants.mutateAsync({ userIds: participantIds });
lastPersistedRef.current = snapshot;
setSaveStatus('saved');
queryClient.invalidateQueries({ queryKey: ['cities'] });
queryClient.invalidateQueries({ queryKey: ['city', cityIdRef.current] });
}
} catch {
setError('Could not save the city. Please check the form and try again.');
} finally {
setSubmitting(false);
setSaveStatus('error');
}
}
function scheduleSave(next: FormState, immediate = false) {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!isReadyToSave(next)) {
setSaveStatus('idle');
return;
}
setSaveStatus('unsaved');
if (immediate) {
doSave(next);
} else {
debounceRef.current = setTimeout(() => doSave(next), AUTOSAVE_DELAY_MS);
}
}
function updateField<K extends keyof FormState>(key: K, value: FormState[K], immediate = false) {
setForm((prev) => {
const next = { ...prev, [key]: value };
scheduleSave(next, immediate);
return next;
});
}
async function handleParticipantsChange(ids: number[]) {
setForm((prev) => ({ ...prev, participantIds: ids }));
if (cityIdRef.current !== undefined) {
try {
await citiesApi.setParticipants(cityIdRef.current, { userIds: ids });
queryClient.invalidateQueries({ queryKey: ['city', cityIdRef.current] });
queryClient.invalidateQueries({ queryKey: ['cities'] });
} catch {
window.alert('Could not update participants — at least one participant is required.');
}
}
}
function handleGeocodeSelect(result: GeocodeResult) {
setForm((prev) => {
const next: FormState = {
...prev,
name: result.displayName.split(',')[0],
country: result.country ?? '',
countryCode: result.countryCode,
lat: result.lat,
lng: result.lng,
};
scheduleSave(next, true);
return next;
});
}
async function handleDelete() {
if (!cityId) return;
if (!window.confirm(`Delete ${city?.name}? This cannot be undone.`)) return;
await deleteCity.mutateAsync(cityId);
if (!cityIdRef.current) return;
if (!window.confirm(`Delete ${form.name}? This cannot be undone.`)) return;
await deleteCity.mutateAsync(cityIdRef.current);
navigate('/', { replace: true });
}
if (mode === 'edit' && isLoading) {
return <div className="page-loading">Loading…</div>;
async function handleLinksSave(links: { url: string; label: string }[]) {
if (!cityId) return;
const updated = await citiesApi.setLinks(cityId, { links });
queryClient.setQueryData(['city', cityId], updated);
queryClient.invalidateQueries({ queryKey: ['cities'] });
}
const isEditing = cityId !== undefined;
return (
<div className="city-detail-page">
<h1>{mode === 'new' ? 'Add a city' : city?.name}</h1>
<form className="city-form" onSubmit={handleSubmit}>
{mode === 'new' && (
<h1>{isEditing ? form.name : 'Add a city'}</h1>
<div className="city-form">
{!isEditing && (
<label>
Search for a city
<CitySearchInput onSelect={handleGeocodeSelect} />
@ -154,46 +233,57 @@ export function CityDetailPage({ mode }: CityDetailPageProps) {
<div className="form-row">
<label>
Name
<input value={name} onChange={(e) => setName(e.target.value)} required />
<input
value={form.name}
onChange={(e) => updateField('name', e.target.value)}
required
/>
</label>
<label>
Country
<input value={country} onChange={(e) => setCountry(e.target.value)} required />
<input
value={form.country}
onChange={(e) => updateField('country', e.target.value)}
required
/>
</label>
</div>
{lat !== null && lng !== null && (
{form.lat !== null && form.lng !== null && (
<p className="coords-hint">
Location: {lat.toFixed(4)}, {lng.toFixed(4)}
Location: {form.lat.toFixed(4)}, {form.lng.toFixed(4)}
</p>
)}
<div className="form-row">
<label>
Status
<select value={status} onChange={(e) => setStatus(e.target.value as CityStatus)}>
<select
value={form.status}
onChange={(e) => updateField('status', e.target.value as CityStatus, true)}
>
<option value="visited">Visited</option>
<option value="planned">Planned</option>
</select>
</label>
<label className="like-toggle">
<input type="checkbox" checked={liked} onChange={(e) => setLiked(e.target.checked)} />
♥ Liked
</label>
<div className="heart-field">
<span>Liked</span>
<HeartToggle liked={form.liked} onToggle={() => updateField('liked', !form.liked, true)} />
</div>
</div>
<div className="form-row">
<label>
Visit start date
<input
type="date"
value={visitDateStart}
onChange={(e) => setVisitDateStart(e.target.value)}
value={form.visitDateStart}
onChange={(e) => updateField('visitDateStart', e.target.value)}
/>
</label>
<label>
Visit end date
<input
type="date"
value={visitDateEnd}
onChange={(e) => setVisitDateEnd(e.target.value)}
value={form.visitDateEnd}
onChange={(e) => updateField('visitDateEnd', e.target.value)}
/>
</label>
</div>
@ -201,32 +291,36 @@ export function CityDetailPage({ mode }: CityDetailPageProps) {
Notes
<textarea
rows={5}
value={notes}
onChange={(e) => setNotes(e.target.value)}
value={form.notes}
onChange={(e) => updateField('notes', e.target.value)}
placeholder="What did you like? Tips, restaurants, ..."
/>
</label>
<ParticipantPicker selectedIds={participantIds} onChange={setParticipantIds} />
{error && <p className="form-error">{error}</p>}
<ParticipantPicker selectedIds={form.participantIds} onChange={handleParticipantsChange} />
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? 'Saving…' : mode === 'new' ? 'Add city' : 'Save changes'}
<span className="save-status">
{saveStatus === 'saving' && 'Saving…'}
{saveStatus === 'unsaved' && 'Saving…'}
{saveStatus === 'saved' && 'All changes saved'}
{saveStatus === 'error' && 'Could not save — check your connection'}
{saveStatus === 'idle' &&
!isEditing &&
'Search for a city or click the map, then add a name and country to save'}
</span>
<button type="button" className="btn btn-ghost" onClick={() => navigate('/')}>
Back to map
</button>
<button type="button" className="btn btn-ghost" onClick={() => navigate(-1)}>
Cancel
</button>
{mode === 'edit' && (
{isEditing && (
<button type="button" className="btn btn-danger" onClick={handleDelete}>
Delete city
</button>
)}
</div>
</form>
</div>
{mode === 'edit' && cityId && city && (
{isEditing && cityId && city && (
<>
<section className="city-section">
<h2>Photo</h2>
@ -238,7 +332,7 @@ export function CityDetailPage({ mode }: CityDetailPageProps) {
</section>
<section className="city-section">
<h2>Links</h2>
<LinkListEditor links={city.links} onSave={(links) => setLinks.mutateAsync({ links })} />
<LinkListEditor key={cityId} links={city.links} onSave={handleLinksSave} />
</section>
</>
)}

View file

@ -30,6 +30,9 @@ export function MapDashboardPage() {
[allCities],
);
// Cities are returned newest-first, so the first entry is the most recently added one.
const latestCity = allCities?.[0];
function handleMapClick(lat: number, lng: number) {
navigate('/cities/new', { state: { lat, lng } });
}
@ -41,7 +44,7 @@ export function MapDashboardPage() {
{isLoading ? (
<div className="page-loading">Loading map…</div>
) : (
<CityMap cities={cities ?? []} onMapClick={handleMapClick} />
<CityMap cities={cities ?? []} focusCity={latestCity} onMapClick={handleMapClick} />
)}
<button type="button" className="btn btn-primary fab" onClick={() => navigate('/cities/new')}>
+ Add city

View file

@ -313,6 +313,19 @@ a {
gap: var(--spacing-1);
}
.map-popup-photo {
max-width: 100%;
max-height: 120px;
object-fit: cover;
border-radius: var(--radius);
}
.map-popup-links {
margin: 0;
padding-left: 1.1rem;
font-size: 0.8rem;
}
/* City form */
.city-detail-page {
@ -372,6 +385,27 @@ a {
gap: var(--spacing-2) !important;
}
.heart-toggle {
background: none;
border: none;
cursor: pointer;
padding: 0;
line-height: 1;
color: var(--color-text-muted);
}
.heart-toggle-small {
font-size: 1.2rem;
}
.heart-toggle-large {
font-size: 2rem;
}
.heart-toggle-liked {
color: #e64980;
}
.coords-hint {
margin: 0;
color: var(--color-text-muted);
@ -381,6 +415,7 @@ a {
.form-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
}
@ -389,6 +424,19 @@ a {
margin-left: auto;
}
.save-status {
font-size: 0.85rem;
color: var(--color-text-muted);
}
.heart-field {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-2);
font-size: 0.9rem;
}
.city-section {
background: var(--color-surface);
border: 1px solid var(--color-border);
@ -460,11 +508,21 @@ a {
.link-row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
}
.link-row select {
flex: 0 0 140px;
padding: var(--spacing-2);
border-radius: var(--radius);
border: 1px solid var(--color-border);
background: var(--color-bg);
}
.link-row input {
flex: 1;
flex: 1 1 140px;
min-width: 0;
padding: var(--spacing-2);
border-radius: var(--radius);
border: 1px solid var(--color-border);
@ -473,7 +531,8 @@ a {
.link-actions {
display: flex;
gap: var(--spacing-2);
align-items: center;
gap: var(--spacing-3);
}
/* Participant picker */
@ -541,6 +600,10 @@ a {
font-size: 0.9rem;
}
.users-table td .btn + .btn {
margin-left: var(--spacing-2);
}
.row-inactive {
opacity: 0.5;
}