mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(collections): share the saved place detail logic between desktop and phone
useCollectionPlaceForm reseeds the edit form when a different place opens, fetches a cover photo for a place without one, uploads and removes the custom cover and saves the edits for both the detail sheet and the phone sheet. The detail keeps editing the coordinates and keeps a late photo across an update of the same place; the sheet holds the last place through its exit and drops a late photo once the place object changes, as before.
This commit is contained in:
@@ -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<HTMLInputElement>(null)
|
||||
const [imgBusy, setImgBusy] = useState(false)
|
||||
const [navOpen, setNavOpen] = useState(false)
|
||||
const navBtnRef = useRef<HTMLButtonElement>(null)
|
||||
const navigationTargets = getNavigationTargets(place)
|
||||
const [name, setName] = useState(place.name)
|
||||
const [categoryId, setCategoryId] = useState<number | null>(place.category_id ?? null)
|
||||
const [description, setDescription] = useState(place.description ?? '')
|
||||
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
|
||||
const [labelIds, setLabelIds] = useState<number[]>(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<string | null>(null)
|
||||
const descRef = useRef<HTMLTextAreaElement>(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<HTMLInputElement>) => {
|
||||
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<CollectionLink>) => 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<HTMLInputElement>) => {
|
||||
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)
|
||||
|
||||
@@ -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> = {}): 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<NonNullable<Window['__addToast']>>;
|
||||
let placePhoto: MockInstance<typeof mapsApi.placePhoto>;
|
||||
|
||||
function setup(over: Partial<CollectionPlaceFormOptions> = {}) {
|
||||
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<HTMLInputElement>, target };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
addToast = vi.fn<NonNullable<Window['__addToast']>>();
|
||||
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<CollectionPlace>) });
|
||||
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<CollectionPlace>) });
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
onUploadImage?: (file: File) => Promise<void>;
|
||||
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<CollectionPlace | null>(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<number | null>(shown?.category_id ?? null);
|
||||
const [description, setDescription] = useState(shown?.description ?? '');
|
||||
const [links, setLinks] = useState<CollectionLink[]>(shown?.links ?? []);
|
||||
const [labelIds, setLabelIds] = useState<number[]>(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<string | null>(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<number | null>(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<HTMLInputElement>) => {
|
||||
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<CollectionLink>) =>
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<HTMLInputElement | null>(null)
|
||||
const [imgBusy, setImgBusy] = useState(false)
|
||||
// Hold the last place through the exit animation.
|
||||
const [held, setHeld] = useState<CollectionPlace | null>(place)
|
||||
if (place && place !== held) setHeld(place)
|
||||
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [address, setAddress] = useState('')
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [description, setDescription] = useState('')
|
||||
const [links, setLinks] = useState<CollectionLink[]>([])
|
||||
const [labelIds, setLabelIds] = useState<number[]>([])
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [fetchedPhoto, setFetchedPhoto] = useState<string | null>(null)
|
||||
const [navOpen, setNavOpen] = useState(false)
|
||||
const navBtnRef = useRef<HTMLButtonElement | null>(null)
|
||||
const heldId = held?.id
|
||||
|
||||
// Reseed the form + cover fetch when a different place is opened.
|
||||
const seededId = useRef<number | null>(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<HTMLInputElement>) => {
|
||||
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 =
|
||||
|
||||
Reference in New Issue
Block a user