diff --git a/client/src/components/city/LinkListEditor.tsx b/client/src/components/city/LinkListEditor.tsx index c18d13f..122d6f3 100644 --- a/client/src/components/city/LinkListEditor.tsx +++ b/client/src/components/city/LinkListEditor.tsx @@ -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( - 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(() => links.map(parseLinkForEditing)); + const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); + const debounceRef = useRef>(); + const lastSavedRef = useRef(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) { + 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 (
- {draft.map((link, index) => ( -
- updateLink(index, 'label', e.target.value)} - /> - updateLink(index, 'url', e.target.value)} - /> - -
- ))} + {draft.map((row, index) => { + const platform = platformFor(row.type); + return ( +
+ + updateRow(index, { label: e.target.value })} + /> + updateRow(index, { value: e.target.value })} + /> + +
+ ); + })}
- {dirty && ( - - )} + + {status === 'saving' && 'Saving…'} + {status === 'saved' && 'Saved'} + {status === 'error' && 'Could not save'} +
); diff --git a/client/src/components/map/CityMap.tsx b/client/src/components/map/CityMap.tsx index 2e423da..53ea4f9 100644 --- a/client/src/components/map/CityMap.tsx +++ b/client/src/components/map/CityMap.tsx @@ -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" /> + {cities.map((city) => (
{city.name}
{city.country}
-
diff --git a/client/src/components/ui/HeartToggle.tsx b/client/src/components/ui/HeartToggle.tsx new file mode 100644 index 0000000..db3d36d --- /dev/null +++ b/client/src/components/ui/HeartToggle.tsx @@ -0,0 +1,19 @@ +interface HeartToggleProps { + liked: boolean; + onToggle: () => void; + size?: 'small' | 'large'; +} + +export function HeartToggle({ liked, onToggle, size = 'large' }: HeartToggleProps) { + return ( + + ); +} diff --git a/client/src/hooks/useCities.ts b/client/src/hooks/useCities.ts index 5fb921a..dcc5c4c 100644 --- a/client/src/hooks/useCities.ts +++ b/client/src/hooks/useCities.ts @@ -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({ diff --git a/client/src/pages/AdminUsersPage.tsx b/client/src/pages/AdminUsersPage.tsx index 73be4e6..fc744a1 100644 --- a/client/src/pages/AdminUsersPage.tsx +++ b/client/src/pages/AdminUsersPage.tsx @@ -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() { +