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:
parent
3debb1ce1e
commit
ce16de8504
8 changed files with 496 additions and 218 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
19
client/src/components/ui/HeartToggle.tsx
Normal file
19
client/src/components/ui/HeartToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue