diff --git a/client/src/components/Collections/CollectionPlaceDetail.tsx b/client/src/components/Collections/CollectionPlaceDetail.tsx index 426a82519..c7bf24169 100644 --- a/client/src/components/Collections/CollectionPlaceDetail.tsx +++ b/client/src/components/Collections/CollectionPlaceDetail.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' @@ -8,17 +8,14 @@ import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel import type { Category, TranslationFn } from '../../types' import MarkdownToolbar from '../Journey/MarkdownToolbar' import { NumericInput } from '../shared/NumericInput' -import { mapsApi } from '../../api/client' import { entityGradient } from '../../utils/gradients' import { getCategoryIcon } from '../shared/categoryIcons' import { NavigationMenu } from '../shared/NavigationMenu' import { navigationTargetLabel, getNavigationTargets, openNavigationTarget } from '../Planner/placeNavigation' import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel' -import { useToast } from '../shared/Toast' import { Tooltip } from '../shared/Tooltip' import PlaceRating from '../shared/StarRating' -import { normalizeImageFile } from '../../utils/convertHeic' -import { getApiErrorMessage } from '../../utils/apiError' +import { useCollectionPlaceForm } from './useCollectionPlaceForm' function linkHost(url: string): string { try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url } @@ -71,106 +68,22 @@ function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet: export default function CollectionPlaceDetail({ place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, onRate, t, }: CollectionPlaceDetailProps): React.ReactElement { - const toast = useToast() - const [editing, setEditing] = useState(false) const coverInputRef = useRef(null) - const [imgBusy, setImgBusy] = useState(false) const [navOpen, setNavOpen] = useState(false) const navBtnRef = useRef(null) const navigationTargets = getNavigationTargets(place) - const [name, setName] = useState(place.name) - const [categoryId, setCategoryId] = useState(place.category_id ?? null) - const [description, setDescription] = useState(place.description ?? '') - const [links, setLinks] = useState(place.links ?? []) - const [labelIds, setLabelIds] = useState(place.label_ids ?? []) - const [address, setAddress] = useState(place.address ?? '') - const [lat, setLat] = useState(place.lat != null ? String(place.lat) : '') - const [lng, setLng] = useState(place.lng != null ? String(place.lng) : '') - const [saving, setSaving] = useState(false) - // A higher-res photo pulled from the maps provider when the place has none of - // its own — the list avatar's little thumbnail is too low-res for the cover. - const [fetchedPhoto, setFetchedPhoto] = useState(null) const descRef = useRef(null) + const { + editing, setEditing, name, setName, categoryId, setCategoryId, description, setDescription, links, setLinks, setLink, + labelIds, toggleLabel, address, setAddress, lat, setLat, lng, setLng, saving, imgBusy, cover: banner, assignedLabels, + handleImagePick: handleCoverPick, handleImageRemove, cancelEdit: resetForm, save, + } = useCollectionPlaceForm({ place, labels, onSave, onUploadImage, t, normalizeLinkUrl, withCoordinates: true }) - // Reset only when a DIFFERENT place is opened (keyed on id, not on every field). - useEffect(() => { - setEditing(false) - setName(place.name) - setCategoryId(place.category_id ?? null) - setDescription(place.description ?? '') - setLinks(place.links ?? []) - setLabelIds(place.label_ids ?? []) - setAddress(place.address ?? '') - setLat(place.lat != null ? String(place.lat) : '') - setLng(place.lng != null ? String(place.lng) : '') - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [place.id]) - - // Fetch a cover photo when the place doesn't carry its own image. - useEffect(() => { - setFetchedPhoto(null) - if (place.image_url) return - const photoId = place.google_place_id || place.osm_id || (place.lat != null && place.lng != null ? `${place.lat},${place.lng}` : null) - if (!photoId) return - let cancelled = false - mapsApi.placePhoto(photoId, place.lat ?? undefined, place.lng ?? undefined, place.name) - .then(res => { if (!cancelled && res?.photoUrl) setFetchedPhoto(res.photoUrl) }) - .catch(() => {}) - return () => { cancelled = true } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [place.id]) - - const banner = place.image_url || fetchedPhoto - - const handleCoverPick = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - e.target.value = '' - if (!file || !onUploadImage) return - setImgBusy(true) - try { - await onUploadImage(await normalizeImageFile(file)) - } catch (err) { - toast.error(getApiErrorMessage(err, t('places.imageUploadError'))) - } finally { - setImgBusy(false) - } - } - - const handleImageRemove = async () => { - setImgBusy(true) - try { - await onSave({ image_url: null }) - } catch (err) { - toast.error(getApiErrorMessage(err, t('places.imageUploadError'))) - } finally { - setImgBusy(false) - } - } - - const setLink = (i: number, patch: Partial) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l))) - const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id]) - const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []); setAddress(place.address ?? ''); setLat(place.lat != null ? String(place.lat) : ''); setLng(place.lng != null ? String(place.lng) : '') } const coordPaste = (e: React.ClipboardEvent) => { const text = e.clipboardData.getData('text').trim() const match = text.match(/^(-?\d+(?:\.\d*)?)(?:\s*[,;]\s*|\s+)(-?\d+(?:\.\d*)?)$/) if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) } } - const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id)) - - const save = async () => { - const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url) - const latNum = lat.trim() ? Number(lat) : Number.NaN - const lngNum = lng.trim() ? Number(lng) : Number.NaN - setSaving(true) - try { - await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, address: address.trim() || null, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null }) - setEditing(false) - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setSaving(false) - } - } const dockStyle = anchorRect ? { left: anchorRect.left, width: anchorRect.width, transform: 'none' as const } : undefined const CatIcon = getCategoryIcon(place.category?.icon) diff --git a/client/src/components/Collections/useCollectionPlaceForm.test.ts b/client/src/components/Collections/useCollectionPlaceForm.test.ts new file mode 100644 index 000000000..7807a0f1d --- /dev/null +++ b/client/src/components/Collections/useCollectionPlaceForm.test.ts @@ -0,0 +1,254 @@ +// FE-COMP-COLPLACEHOOK-001 to FE-COMP-COLPLACEHOOK-013: the saved place detail logic +// behind both the desktop detail sheet and the phone sheet. +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { CollectionLabel, CollectionPlace } from '@trek/shared'; +import type React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest'; + +import { mapsApi } from '../../api/client'; +import { normalizeLinkUrl } from '../../pages/collections/collectionsModel'; +import { + parseCoordinate, + placePhotoLookupId, + useCollectionPlaceForm, + type CollectionPlaceFormOptions, +} from './useCollectionPlaceForm'; + +vi.mock('../../utils/convertHeic', () => ({ + normalizeImageFile: (f: File) => Promise.resolve(f), +})); + +const t = (k: string) => k; + +function place(over: Partial = {}): CollectionPlace { + return { + id: 1, + name: 'Louvre', + description: 'Museum', + links: [{ label: 'Site', url: 'https://louvre.example' }], + category_id: 3, + label_ids: [10], + address: 'Rue de Rivoli', + lat: 48.86, + lng: 2.33, + image_url: null, + google_place_id: 'g-1', + osm_id: null, + ...over, + } as CollectionPlace; +} + +const LABELS = [ + { id: 10, name: 'Art' }, + { id: 11, name: 'Food' }, +] as CollectionLabel[]; + +let addToast: Mock>; +let placePhoto: MockInstance; + +function setup(over: Partial = {}) { + const props: CollectionPlaceFormOptions = { + place: place(), + labels: LABELS, + onSave: vi.fn().mockResolvedValue(undefined), + onUploadImage: vi.fn().mockResolvedValue(undefined), + t, + normalizeLinkUrl, + ...over, + }; + const hook = renderHook((p: CollectionPlaceFormOptions) => useCollectionPlaceForm(p), { initialProps: props }); + return { ...hook, props }; +} + +function fileEvent(file: File | undefined) { + const target = { files: file ? [file] : [], value: 'C:\\fake\\a.png' }; + return { event: { target } as unknown as React.ChangeEvent, target }; +} + +beforeEach(() => { + addToast = vi.fn>(); + window.__addToast = addToast; + placePhoto = vi.spyOn(mapsApi, 'placePhoto').mockResolvedValue({ photoUrl: 'https://photo.example/p.jpg' }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + delete window.__addToast; +}); + +describe('collection place helpers', () => { + it('FE-COMP-COLPLACEHOOK-001: looks a photo up by Google id, OSM id, then coordinates', () => { + expect(placePhotoLookupId(place())).toBe('g-1'); + expect(placePhotoLookupId(place({ google_place_id: null, osm_id: 'node/5' }))).toBe('node/5'); + expect(placePhotoLookupId(place({ google_place_id: null }))).toBe('48.86,2.33'); + expect(placePhotoLookupId(place({ google_place_id: null, lat: null }))).toBeNull(); + }); + + it('FE-COMP-COLPLACEHOOK-002: a coordinate is a finite number or null', () => { + expect(parseCoordinate(' 12.5 ')).toBe(12.5); + expect(parseCoordinate(' ')).toBeNull(); + expect(parseCoordinate('abc')).toBeNull(); + }); +}); + +describe('useCollectionPlaceForm', () => { + it('FE-COMP-COLPLACEHOOK-003: seeds the form from the place and fetches a cover when it has none', async () => { + const { result } = setup(); + expect(result.current.name).toBe('Louvre'); + expect(result.current.lat).toBe('48.86'); + expect(result.current.assignedLabels).toEqual([LABELS[0]]); + await waitFor(() => expect(result.current.cover).toBe('https://photo.example/p.jpg')); + expect(placePhoto).toHaveBeenCalledWith('g-1', 48.86, 2.33, 'Louvre'); + }); + + it('FE-COMP-COLPLACEHOOK-013: the strict mode remount asks for the cover again and shows it', async () => { + const { result } = renderHook((p: CollectionPlaceFormOptions) => useCollectionPlaceForm(p), { + initialProps: { place: place(), labels: LABELS, onSave: vi.fn(), t, normalizeLinkUrl }, + reactStrictMode: true, + }); + await waitFor(() => expect(result.current.cover).toBe('https://photo.example/p.jpg')); + expect(placePhoto).toHaveBeenCalledTimes(2); + }); + + it('FE-COMP-COLPLACEHOOK-004: a place with its own image needs no lookup', () => { + const { result } = setup({ place: place({ image_url: '/uploads/places/own.jpg' }) }); + expect(result.current.cover).toBe('/uploads/places/own.jpg'); + expect(placePhoto).not.toHaveBeenCalled(); + }); + + it('FE-COMP-COLPLACEHOOK-005: an update of the same place keeps the edits, a different place reseeds them', () => { + const { result, rerender, props } = setup(); + act(() => { + result.current.setEditing(true); + result.current.setName('Changed'); + }); + rerender({ ...props, place: place({ description: 'Updated elsewhere' }) }); + expect(result.current.name).toBe('Changed'); + expect(result.current.editing).toBe(true); + rerender({ ...props, place: place({ id: 2, name: 'Orsay' }) }); + expect(result.current.name).toBe('Orsay'); + expect(result.current.editing).toBe(false); + expect(placePhoto).toHaveBeenCalledTimes(2); + }); + + it('FE-COMP-COLPLACEHOOK-006: the detail keeps a late photo across an update of the same place', async () => { + let answer: (v: { photoUrl: string }) => void = () => {}; + placePhoto.mockReturnValueOnce(new Promise<{ photoUrl: string }>((r) => (answer = r))); + const { result, rerender, props } = setup(); + rerender({ ...props, place: place({ status: 'visited' } as Partial) }); + await act(async () => answer({ photoUrl: 'https://photo.example/late.jpg' })); + expect(result.current.cover).toBe('https://photo.example/late.jpg'); + }); + + it('FE-COMP-COLPLACEHOOK-007: the sheet drops a late photo once the place object changes', async () => { + let answer: (v: { photoUrl: string }) => void = () => {}; + placePhoto.mockReturnValueOnce(new Promise<{ photoUrl: string }>((r) => (answer = r))); + const { result, rerender, props } = setup({ dropPhotoOnPlaceUpdate: true }); + rerender({ ...props, place: place({ status: 'visited' } as Partial) }); + await act(async () => answer({ photoUrl: 'https://photo.example/late.jpg' })); + expect(result.current.cover).toBeNull(); + }); + + it('FE-COMP-COLPLACEHOOK-008: the sheet holds the last place once it clears, the detail does not', () => { + const sheet = setup({ holdLastPlace: true }); + sheet.rerender({ ...sheet.props, place: null }); + expect(sheet.result.current.shown?.id).toBe(1); + + const detail = setup(); + detail.rerender({ ...detail.props, place: null }); + expect(detail.result.current.shown).toBeNull(); + }); + + it('FE-COMP-COLPLACEHOOK-009: saves the edits, with the coordinates only for the detail', async () => { + const detail = setup({ withCoordinates: true }); + act(() => { + detail.result.current.setEditing(true); + detail.result.current.setName(' '); + detail.result.current.setLinks([{ label: ' Map ', url: 'maps.example' }, { url: '' }]); + detail.result.current.toggleLabel(10); + detail.result.current.setLat('abc'); + }); + // toggleLabel reads the rendered selection, so each pick gets its own render. + act(() => detail.result.current.toggleLabel(11)); + await act(async () => { + await detail.result.current.save(); + }); + expect(detail.props.onSave).toHaveBeenCalledWith({ + name: 'Louvre', + description: 'Museum', + links: [{ label: 'Map', url: 'https://maps.example' }], + category_id: 3, + label_ids: [11], + address: 'Rue de Rivoli', + lat: null, + lng: 2.33, + }); + expect(detail.result.current.editing).toBe(false); + + const sheet = setup(); + await act(async () => { + await sheet.result.current.save(); + }); + expect(sheet.props.onSave).toHaveBeenCalledWith({ + name: 'Louvre', + description: 'Museum', + links: [{ label: 'Site', url: 'https://louvre.example' }], + category_id: 3, + label_ids: [10], + address: 'Rue de Rivoli', + }); + }); + + it('FE-COMP-COLPLACEHOOK-010: a failed save stays in edit mode and toasts', async () => { + const onSave = vi.fn().mockRejectedValue({ response: { data: { error: 'Locked' } } }); + const { result } = setup({ onSave }); + act(() => result.current.setEditing(true)); + await act(async () => { + await result.current.save(); + }); + expect(addToast).toHaveBeenCalledWith('Locked', 'error', undefined); + expect(result.current.editing).toBe(true); + expect(result.current.saving).toBe(false); + }); + + it('FE-COMP-COLPLACEHOOK-011: cancel puts the place back into the form', () => { + const { result } = setup(); + act(() => { + result.current.setEditing(true); + result.current.setDescription('scratch'); + result.current.setLink(0, { label: 'Other' }); + }); + expect(result.current.links[0].label).toBe('Other'); + act(() => result.current.cancelEdit()); + expect(result.current.description).toBe('Museum'); + expect(result.current.links).toEqual([{ label: 'Site', url: 'https://louvre.example' }]); + expect(result.current.editing).toBe(false); + }); + + it('FE-COMP-COLPLACEHOOK-012: uploads a picked cover, removes one, and toasts either failure', async () => { + const onUploadImage = vi.fn().mockRejectedValueOnce(new Error('too big')).mockResolvedValue(undefined); + const onSave = vi.fn().mockRejectedValueOnce(new Error('nope')); + const { result } = setup({ onUploadImage, onSave }); + const file = new File(['x'], 'a.png'); + + const picked = fileEvent(file); + await act(async () => { + await result.current.handleImagePick(picked.event); + }); + expect(picked.target.value).toBe(''); + expect(onUploadImage).toHaveBeenCalledWith(file); + expect(addToast).toHaveBeenCalledWith('places.imageUploadError', 'error', undefined); + expect(result.current.imgBusy).toBe(false); + + await act(async () => { + await result.current.handleImagePick(fileEvent(undefined).event); + }); + expect(onUploadImage).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.handleImageRemove(); + }); + expect(onSave).toHaveBeenCalledWith({ image_url: null }); + expect(addToast).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/components/Collections/useCollectionPlaceForm.ts b/client/src/components/Collections/useCollectionPlaceForm.ts new file mode 100644 index 000000000..7202c9ab9 --- /dev/null +++ b/client/src/components/Collections/useCollectionPlaceForm.ts @@ -0,0 +1,238 @@ +import type { CollectionLabel, CollectionLink, CollectionPlace } from '@trek/shared'; +import type React from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { mapsApi } from '../../api/client'; +import type { TranslationFn } from '../../types'; +import { getApiErrorMessage } from '../../utils/apiError'; +import { normalizeImageFile } from '../../utils/convertHeic'; +import { useToast } from '../shared/Toast'; +import { cleanListLinks } from './useListEditor'; + +export interface CollectionPlacePatch { + name?: string; + description?: string | null; + links?: CollectionLink[]; + category_id?: number | null; + label_ids?: number[]; + image_url?: string | null; + lat?: number | null; + lng?: number | null; + address?: string | null; +} + +export interface CollectionPlaceFormOptions { + place: CollectionPlace | null; + labels: CollectionLabel[]; + onSave: (patch: CollectionPlacePatch) => Promise; + onUploadImage?: (file: File) => Promise; + t: TranslationFn; + /** The collections model's link normaliser, injected so this hook needs nothing from pages/. */ + normalizeLinkUrl: (url: string) => string; + /** Keep showing the last place while it is cleared (a sheet holds it through its exit animation). */ + holdLastPlace?: boolean; + /** The form edits the coordinates too, and a save sends them (the desktop detail). */ + withCoordinates?: boolean; + /** + * Drop a cover photo still being fetched as soon as the place object changes, not + * only when a different place opens (the phone sheet). + */ + dropPhotoOnPlaceUpdate?: boolean; +} + +/** What the maps provider looks a place photo up by: its Google or OSM id, else its coordinates. */ +export function placePhotoLookupId(place: CollectionPlace): string | null { + return ( + place.google_place_id || + place.osm_id || + (place.lat != null && place.lng != null ? `${place.lat},${place.lng}` : null) + ); +} + +/** A coordinate field's number, or null when it is blank or not a number. */ +export function parseCoordinate(value: string): number | null { + const n = value.trim() ? Number(value) : Number.NaN; + return Number.isFinite(n) ? n : null; +} + +const coordText = (v: number | null | undefined) => (v != null ? String(v) : ''); + +/** + * The saved place detail behind both the desktop detail sheet and the phone sheet, + * which render their own markup over it: the edit form (reseeded whenever a different + * place opens), the cover photo fetched from the maps provider for a place without + * its own image, the custom cover upload and removal, and the save. + */ +export function useCollectionPlaceForm({ + place, + labels, + onSave, + onUploadImage, + t, + normalizeLinkUrl, + holdLastPlace = false, + withCoordinates = false, + dropPhotoOnPlaceUpdate = false, +}: CollectionPlaceFormOptions) { + const toast = useToast(); + const [held, setHeld] = useState(place); + if (holdLastPlace && place && place !== held) setHeld(place); + const shown = holdLastPlace ? held : place; + + const [imgBusy, setImgBusy] = useState(false); + const [editing, setEditing] = useState(false); + const [name, setName] = useState(shown?.name ?? ''); + const [categoryId, setCategoryId] = useState(shown?.category_id ?? null); + const [description, setDescription] = useState(shown?.description ?? ''); + const [links, setLinks] = useState(shown?.links ?? []); + const [labelIds, setLabelIds] = useState(shown?.label_ids ?? []); + const [address, setAddress] = useState(shown?.address ?? ''); + const [lat, setLat] = useState(coordText(shown?.lat)); + const [lng, setLng] = useState(coordText(shown?.lng)); + const [saving, setSaving] = useState(false); + // A higher res photo pulled from the maps provider when the place has none of + // its own; the list avatar's little thumbnail is too low res for the cover. + const [fetchedPhoto, setFetchedPhoto] = useState(null); + // Bumped to drop a photo still on its way; an object so a cleanup can hold on to it. + const photoSeq = useRef({ n: 0 }); + + const resetForm = useCallback((p: CollectionPlace) => { + setEditing(false); + setName(p.name); + setCategoryId(p.category_id ?? null); + setDescription(p.description ?? ''); + setLinks(p.links ?? []); + setLabelIds(p.label_ids ?? []); + setAddress(p.address ?? ''); + setLat(coordText(p.lat)); + setLng(coordText(p.lng)); + }, []); + + // Reseed the form and fetch the cover only when a DIFFERENT place opens (keyed on + // the id, not on every field). + const seededId = useRef(null); + const shownId = shown?.id; + useEffect(() => { + if (!shown || seededId.current === shown.id) return; + seededId.current = shown.id; + resetForm(shown); + setFetchedPhoto(null); + const seq = ++photoSeq.current.n; + if (shown.image_url) return; + const photoId = placePhotoLookupId(shown); + if (!photoId) return; + mapsApi + .placePhoto(photoId, shown.lat ?? undefined, shown.lng ?? undefined, shown.name) + .then((res) => { + if (seq === photoSeq.current.n && res?.photoUrl) setFetchedPhoto(res.photoUrl); + }) + .catch(() => {}); + }, [shown, shownId, resetForm]); + + // A photo still on its way belongs to the place object it was asked for (the sheet), + // or to the place id (the detail); either way it is dropped on unmount. + useEffect(() => { + if (!dropPhotoOnPlaceUpdate) return; + const seq = photoSeq.current; + return () => { + seq.n++; + }; + }, [shown, dropPhotoOnPlaceUpdate]); + // Forgetting the seeded id lets a remount (strict mode in dev) reseed and fetch again. + useEffect(() => { + const seq = photoSeq.current; + const seeded = seededId; + return () => { + seq.n++; + seeded.current = null; + }; + }, []); + + const cover = shown?.image_url || fetchedPhoto; + + const handleImagePick = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file || !shown || !onUploadImage) return; + setImgBusy(true); + try { + await onUploadImage(await normalizeImageFile(file)); + } catch (err) { + toast.error(getApiErrorMessage(err, t('places.imageUploadError'))); + } finally { + setImgBusy(false); + } + }; + + const handleImageRemove = async () => { + setImgBusy(true); + try { + await onSave({ image_url: null }); + } catch (err) { + toast.error(getApiErrorMessage(err, t('places.imageUploadError'))); + } finally { + setImgBusy(false); + } + }; + + const setLink = (i: number, patch: Partial) => + setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l))); + const toggleLabel = (id: number) => + setLabelIds(labelIds.includes(id) ? labelIds.filter((x) => x !== id) : [...labelIds, id]); + const cancelEdit = () => { + if (shown) resetForm(shown); + }; + const assignedLabels = labels.filter((l) => (shown?.label_ids ?? []).includes(l.id)); + + const save = async () => { + if (!shown) return; + const base = { + name: name.trim() || shown.name, + description: description.trim() || null, + links: cleanListLinks(links, normalizeLinkUrl), + category_id: categoryId, + label_ids: labelIds, + address: address.trim() || null, + }; + setSaving(true); + try { + await onSave(withCoordinates ? { ...base, lat: parseCoordinate(lat), lng: parseCoordinate(lng) } : base); + setEditing(false); + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))); + } finally { + setSaving(false); + } + }; + + return { + shown, + editing, + setEditing, + name, + setName, + categoryId, + setCategoryId, + description, + setDescription, + links, + setLinks, + setLink, + labelIds, + toggleLabel, + address, + setAddress, + lat, + setLat, + lng, + setLng, + saving, + imgBusy, + cover, + assignedLabels, + handleImagePick, + handleImageRemove, + cancelEdit, + save, + }; +} diff --git a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx index 196987701..87eb585f3 100644 --- a/client/src/mobile/screens/collections/MCollPlaceSheet.tsx +++ b/client/src/mobile/screens/collections/MCollPlaceSheet.tsx @@ -1,14 +1,11 @@ -import { useEffect, useRef, useState } from 'react' +import { useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { Camera, Check, Copy, ExternalLink, Loader2, MapPin, Navigation, Pencil, Trash2, X } from 'lucide-react' import type { CollectionLabel, CollectionLink, CollectionPlace, CollectionStatus } from '@trek/shared' import type { Category, TranslationFn } from '../../../types' -import { mapsApi } from '../../../api/client' -import { useToast } from '../../../components/shared/Toast' -import { normalizeImageFile } from '../../../utils/convertHeic' -import { getApiErrorMessage } from '../../../utils/apiError' +import { useCollectionPlaceForm } from '../../../components/Collections/useCollectionPlaceForm' import { normalizeLinkUrl, STATUS_ORDER } from '../../../pages/collections/collectionsModel' import MSheet from '../../components/MSheet' import PlaceRating from '../../../components/shared/StarRating' @@ -56,103 +53,15 @@ interface MCollPlaceSheetProps { export default function MCollPlaceSheet({ place, canEdit, canDelete, categories, labels, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, onRate, t, }: MCollPlaceSheetProps) { - const toast = useToast() const imageInputRef = useRef(null) - const [imgBusy, setImgBusy] = useState(false) - // Hold the last place through the exit animation. - const [held, setHeld] = useState(place) - if (place && place !== held) setHeld(place) - - const [editing, setEditing] = useState(false) - const [name, setName] = useState('') - const [address, setAddress] = useState('') - const [categoryId, setCategoryId] = useState(null) - const [description, setDescription] = useState('') - const [links, setLinks] = useState([]) - const [labelIds, setLabelIds] = useState([]) - const [saving, setSaving] = useState(false) - const [fetchedPhoto, setFetchedPhoto] = useState(null) const [navOpen, setNavOpen] = useState(false) const navBtnRef = useRef(null) - const heldId = held?.id - - // Reseed the form + cover fetch when a different place is opened. - const seededId = useRef(null) - useEffect(() => { - if (!held || seededId.current === held.id) return - seededId.current = held.id - setEditing(false) - setName(held.name) - setAddress(held.address ?? '') - setCategoryId(held.category_id ?? null) - setDescription(held.description ?? '') - setLinks(held.links ?? []) - setLabelIds(held.label_ids ?? []) - setFetchedPhoto(null) - if (held.image_url) return - const photoId = held.google_place_id || held.osm_id || (held.lat != null && held.lng != null ? `${held.lat},${held.lng}` : null) - if (!photoId) return - let cancelled = false - mapsApi.placePhoto(photoId, held.lat ?? undefined, held.lng ?? undefined, held.name) - .then(res => { if (!cancelled && res?.photoUrl) setFetchedPhoto(res.photoUrl) }) - .catch(() => {}) - return () => { cancelled = true } - }, [held, heldId]) - - const save = async () => { - if (!held) return - const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url) - setSaving(true) - try { - await onSave({ name: name.trim() || held.name, address: address.trim() || null, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds }) - setEditing(false) - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setSaving(false) - } - } - - const cancelEdit = () => { - if (!held) return - setEditing(false) - setName(held.name) - setAddress(held.address ?? '') - setCategoryId(held.category_id ?? null) - setDescription(held.description ?? '') - setLinks(held.links ?? []) - setLabelIds(held.label_ids ?? []) - } - - const cover = held?.image_url || fetchedPhoto - - const handleImagePick = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - e.target.value = '' - if (!file || !held || !onUploadImage) return - setImgBusy(true) - try { - await onUploadImage(await normalizeImageFile(file)) - } catch (err) { - toast.error(getApiErrorMessage(err, t('places.imageUploadError'))) - } finally { - setImgBusy(false) - } - } - - const handleImageRemove = async () => { - setImgBusy(true) - try { - await onSave({ image_url: null }) - } catch (err) { - toast.error(getApiErrorMessage(err, t('places.imageUploadError'))) - } finally { - setImgBusy(false) - } - } - - const assignedLabels = labels.filter(l => (held?.label_ids ?? []).includes(l.id)) - const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id]) + const { + shown: held, editing, setEditing, name, setName, address, setAddress, categoryId, setCategoryId, description, setDescription, + links, setLinks, labelIds, toggleLabel, saving, imgBusy, cover, assignedLabels, handleImagePick, handleImageRemove, cancelEdit, save, + } = useCollectionPlaceForm({ + place, labels, onSave, onUploadImage, t, normalizeLinkUrl, holdLastPlace: true, dropPhotoOnPlaceUpdate: true, + }) const navTargets = getNavigationTargets(held) const actionBtn =