diff --git a/client/src/components/Collections/ListEditorModal.tsx b/client/src/components/Collections/ListEditorModal.tsx index ca57fff5b..26197f5ce 100644 --- a/client/src/components/Collections/ListEditorModal.tsx +++ b/client/src/components/Collections/ListEditorModal.tsx @@ -1,23 +1,12 @@ -import React, { useEffect, useId, useRef, useState } from 'react' +import React, { useId } from 'react' import { Bookmark, ImagePlus, Trash2, Search, Loader2 } from 'lucide-react' import { DeleteButton, DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, fs } from '../shared/DialogShell' import { AddRowButton, EditorField, INPUT, TEXTAREA } from '../shared/dialogParts' import { Tooltip } from '../shared/Tooltip' -import { useCollectionStore } from '../../store/collectionStore' -import { useToast } from '../shared/Toast' -import { tripsApi } from '../../api/client' -import { getApiErrorMessage } from '../../utils/apiError' import { normalizeLinkUrl } from '../../pages/collections/collectionsModel' import type { TranslationFn } from '../../types' -import type { Collection, CollectionLink } from '@trek/shared' - -interface CoverSearchPhoto { - id: string - url: string - thumb: string - description?: string | null - photographer?: string | null -} +import type { Collection } from '@trek/shared' +import { useListEditor } from './useListEditor' const SWATCHES = ['#6366f1', '#ec4899', '#14b8a6', '#f97316', '#8b5cf6', '#ef4444', '#3b82f6', '#22c55e'] @@ -41,124 +30,14 @@ interface ListEditorModalProps { * the list then uploads the cover to the new id; on edit it patches + re-uploads. */ export default function ListEditorModal({ target, onClose, onCreated, onRequestDelete, t }: ListEditorModalProps): React.ReactElement | null { - const createCollection = useCollectionStore(s => s.createCollection) - const updateCollection = useCollectionStore(s => s.updateCollection) - const uploadCover = useCollectionStore(s => s.uploadCover) - const toast = useToast() - const fileRef = useRef(null) const labelId = useId() - - const editing = target && target !== 'new' ? target : null - const [name, setName] = useState('') - const [color, setColor] = useState('#6366f1') - const [description, setDescription] = useState('') - const [links, setLinks] = useState([]) - const [coverFile, setCoverFile] = useState(null) - const [coverPreview, setCoverPreview] = useState(null) - const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState(null) - const [coverQuery, setCoverQuery] = useState('') - const [coverResults, setCoverResults] = useState([]) - const [searchingCover, setSearchingCover] = useState(false) - const coverSeq = useRef(0) - const [saving, setSaving] = useState(false) - // Remembers a freshly created list id so a retry after a cover-upload failure - // updates it instead of creating a duplicate list. - const [createdId, setCreatedId] = useState(null) - const objectUrl = useRef(null) - - const dropObjectUrl = () => { if (objectUrl.current) { URL.revokeObjectURL(objectUrl.current); objectUrl.current = null } } - - // (Re)seed the form whenever the target changes. - useEffect(() => { - if (!target) return - setName(editing?.name ?? '') - setColor(editing?.color ?? '#6366f1') - setDescription(editing?.description ?? '') - setLinks(editing?.links ?? []) - setCoverFile(null) - dropObjectUrl() - setCoverPreview(editing?.cover_image ?? null) - setPendingUnsplashUrl(null) - setCoverQuery('') - setCoverResults([]) - setCreatedId(null) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [target]) - - // Revoke the last preview blob on unmount. - useEffect(() => () => dropObjectUrl(), []) + const { + editing, fileRef, name, setName, color, setColor, description, setDescription, links, setLinks, setLink, + coverPreview, coverQuery, setCoverQuery, coverResults, searchingCover, saving, pickCover, searchCover, pickUnsplash, save, + } = useListEditor({ target, onClose, onCreated, t, defaultColor: '#6366f1', normalizeLinkUrl }) if (!target) return null - const pickCover = (file: File | undefined) => { - if (!file) return - dropObjectUrl() - const url = URL.createObjectURL(file) - objectUrl.current = url - setCoverFile(file) - setCoverPreview(url) - setPendingUnsplashUrl(null) // an uploaded file wins over a picked Unsplash photo - } - - const searchCover = async () => { - const query = coverQuery.trim() || name.trim() - if (!query) return - const seq = ++coverSeq.current - setSearchingCover(true) - try { - const data = await tripsApi.searchCoverImages(query) - if (seq !== coverSeq.current) return - setCoverResults(data.photos || []) - } catch { - if (seq === coverSeq.current) setCoverResults([]) - } finally { - if (seq === coverSeq.current) setSearchingCover(false) - } - } - - const pickUnsplash = (photo: CoverSearchPhoto) => { - if (!photo.url) return - dropObjectUrl() - setCoverFile(null) - setPendingUnsplashUrl(photo.url) - setCoverPreview(photo.url) - } - - const setLink = (i: number, patch: Partial) => - setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l))) - - const save = async () => { - const trimmed = name.trim() - // Enter fires while the create is still in flight, and `createdId` is only - // set once it comes back — without this the second one creates a second list. - if (!trimmed || saving) return - // Normalise + keep only links with a url; drop blank rows. - const cleanLinks = links - .map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })) - .filter(l => l.url) - const payload = { name: trimmed, color, description: description.trim() || null, links: cleanLinks, ...(pendingUnsplashUrl ? { cover_image: pendingUnsplashUrl } : {}) } - setSaving(true) - try { - // A prior create that failed at the cover step left `createdId` set — reuse - // it so a retry updates that list instead of creating a duplicate. - let id = editing?.id ?? createdId - if (id != null) { - await updateCollection(id, payload) - } else { - const created = await createCollection(payload) - id = created?.id ?? null - setCreatedId(id) - } - if (id != null && coverFile) await uploadCover(id, coverFile) - if (!editing && id != null) onCreated(id) - onClose() - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setSaving(false) - } - } - // The list's own colour runs through the dialog: the head band, its tile and the cover. const header = ( k; + +const LIST = { + id: 4, + name: 'Tokyo', + color: '#ec4899', + description: 'Food spots', + links: [{ label: 'Blog', url: 'https://a.example' }], + cover_image: '/uploads/covers/tokyo.jpg', + is_owner: true, +} as Collection; + +type CollectionState = ReturnType; +const initialCollectionState = useCollectionStore.getState(); +let actions: { + createCollection: Mock; + updateCollection: Mock; + uploadCover: Mock; +}; +let addToast: Mock>; +let searchCovers: MockInstance; + +function setup(over: Partial = {}) { + const props: ListEditorOptions = { + target: 'new', + onClose: vi.fn(), + onCreated: vi.fn(), + t, + defaultColor: '#6366f1', + normalizeLinkUrl, + ...over, + }; + const hook = renderHook((p: ListEditorOptions) => useListEditor(p), { initialProps: props }); + return { ...hook, props }; +} + +beforeEach(() => { + resetAllStores(); + useCollectionStore.setState(initialCollectionState, true); + actions = { + createCollection: vi.fn().mockResolvedValue({ ...LIST, id: 9 }), + updateCollection: vi.fn().mockResolvedValue(undefined), + uploadCover: vi.fn().mockResolvedValue(undefined), + }; + useCollectionStore.setState(actions); + addToast = vi.fn>(); + window.__addToast = addToast; + searchCovers = vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({ + photos: [{ id: 'p1', url: 'https://img.example/full.jpg', thumb: 'https://img.example/t.jpg' }], + }); + URL.createObjectURL = vi.fn(() => 'blob:cover'); + URL.revokeObjectURL = vi.fn(); +}); + +const { createObjectURL, revokeObjectURL } = URL; + +afterEach(() => { + URL.createObjectURL = createObjectURL; + URL.revokeObjectURL = revokeObjectURL; + vi.restoreAllMocks(); + delete window.__addToast; +}); + +describe('cleanListLinks', () => { + it('FE-COMP-LISTEDITORHOOK-001: trims labels, normalises urls and drops rows without one', () => { + expect( + cleanListLinks( + [ + { label: ' Blog ', url: 'a.example' }, + { label: '', url: ' ' }, + { label: ' ', url: 'https://b.example' }, + ], + normalizeLinkUrl + ) + ).toEqual([ + { label: 'Blog', url: 'https://a.example' }, + { label: undefined, url: 'https://b.example' }, + ]); + }); +}); + +describe('useListEditor', () => { + it('FE-COMP-LISTEDITORHOOK-002: a new list starts empty in the shell default colour', () => { + const { result } = setup({ defaultColor: '#6366F1' }); + expect(result.current.editing).toBeNull(); + expect(result.current.name).toBe(''); + expect(result.current.color).toBe('#6366F1'); + expect(result.current.coverPreview).toBeNull(); + }); + + it('FE-COMP-LISTEDITORHOOK-003: editing seeds the form from the list', () => { + const { result } = setup({ target: LIST }); + expect(result.current.editing).toBe(LIST); + expect(result.current.name).toBe('Tokyo'); + expect(result.current.color).toBe('#ec4899'); + expect(result.current.description).toBe('Food spots'); + expect(result.current.links).toEqual(LIST.links); + expect(result.current.coverPreview).toBe('/uploads/covers/tokyo.jpg'); + }); + + it('FE-COMP-LISTEDITORHOOK-004: only the sheet keeps editing the last list once the target clears', () => { + const dialog = setup({ target: LIST }); + dialog.rerender({ ...dialog.props, target: null }); + expect(dialog.result.current.editing).toBeNull(); + + const sheet = setup({ target: LIST, holdLastTarget: true }); + sheet.rerender({ ...sheet.props, target: null }); + expect(sheet.result.current.editing).toBe(LIST); + }); + + it('FE-COMP-LISTEDITORHOOK-005: creates the list with cleaned links, uploads the cover and hands the id over', async () => { + const { result, props } = setup(); + const file = new File(['x'], 'c.png', { type: 'image/png' }); + act(() => { + result.current.setName(' Rome '); + result.current.setDescription(' '); + result.current.setLinks([{ label: ' Map ', url: 'maps.example' }, { url: '' }]); + result.current.pickCover(file); + }); + expect(result.current.coverPreview).toBe('blob:cover'); + await act(async () => { + await result.current.save(); + }); + expect(actions.createCollection).toHaveBeenCalledWith({ + name: 'Rome', + color: '#6366f1', + description: null, + links: [{ label: 'Map', url: 'https://maps.example' }], + }); + expect(actions.uploadCover).toHaveBeenCalledWith(9, file); + expect(props.onCreated).toHaveBeenCalledWith(9); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + + it('FE-COMP-LISTEDITORHOOK-006: editing patches the list with a picked Unsplash cover and hands nothing over', async () => { + const { result, props } = setup({ target: LIST }); + act(() => result.current.pickUnsplash({ id: 'p', url: 'https://img.example/u.jpg', thumb: '' })); + await act(async () => { + await result.current.save(); + }); + expect(actions.updateCollection).toHaveBeenCalledWith(4, { + name: 'Tokyo', + color: '#ec4899', + description: 'Food spots', + links: [{ label: 'Blog', url: 'https://a.example' }], + cover_image: 'https://img.example/u.jpg', + }); + expect(actions.uploadCover).not.toHaveBeenCalled(); + expect(props.onCreated).not.toHaveBeenCalled(); + }); + + it('FE-COMP-LISTEDITORHOOK-007: a blank name saves nothing', async () => { + const { result } = setup(); + await act(async () => { + await result.current.save(); + }); + expect(actions.createCollection).not.toHaveBeenCalled(); + }); + + it('FE-COMP-LISTEDITORHOOK-008: a retry after a failed cover upload updates the list it already created', async () => { + actions.uploadCover.mockRejectedValueOnce({ response: { data: { error: 'Too big' } } }); + const { result, props } = setup(); + act(() => { + result.current.setName('Rome'); + result.current.pickCover(new File(['x'], 'c.png')); + }); + await act(async () => { + await result.current.save(); + }); + expect(addToast).toHaveBeenCalledWith('Too big', 'error', undefined); + expect(props.onClose).not.toHaveBeenCalled(); + await act(async () => { + await result.current.save(); + }); + expect(actions.createCollection).toHaveBeenCalledTimes(1); + expect(actions.updateCollection).toHaveBeenCalledWith(9, expect.objectContaining({ name: 'Rome' })); + }); + + it('FE-COMP-LISTEDITORHOOK-009: closing hands over a list a failed cover upload left behind', async () => { + actions.uploadCover.mockRejectedValueOnce(new Error('nope')); + const { result, props } = setup(); + act(() => result.current.close()); + expect(props.onCreated).not.toHaveBeenCalled(); + act(() => { + result.current.setName('Rome'); + result.current.pickCover(new File(['x'], 'c.png')); + }); + await act(async () => { + await result.current.save(); + }); + act(() => result.current.close()); + expect(props.onCreated).toHaveBeenCalledWith(9); + expect(props.onClose).toHaveBeenCalledTimes(2); + }); + + it('FE-COMP-LISTEDITORHOOK-010: the cover search falls back to the name and keeps only the latest answer', async () => { + const { result } = setup(); + await act(async () => { + await result.current.searchCover(); + }); + expect(searchCovers).not.toHaveBeenCalled(); + act(() => result.current.setName('Rome')); + await act(async () => { + await result.current.searchCover(); + }); + expect(searchCovers).toHaveBeenCalledWith('Rome'); + expect(result.current.coverResults).toHaveLength(1); + expect(result.current.searchingCover).toBe(false); + + searchCovers.mockRejectedValueOnce(new Error('down')); + act(() => result.current.setCoverQuery(' beach ')); + await act(async () => { + await result.current.searchCover(); + }); + expect(searchCovers).toHaveBeenLastCalledWith('beach'); + expect(result.current.coverResults).toEqual([]); + }); + + it('FE-COMP-LISTEDITORHOOK-011: an upload wins over a picked photo and revokes the old preview blob', () => { + const { result, unmount } = setup(); + act(() => result.current.pickCover(new File(['x'], 'a.png'))); + act(() => result.current.pickUnsplash({ id: 'p', url: 'https://img.example/u.jpg', thumb: '' })); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:cover'); + expect(result.current.coverPreview).toBe('https://img.example/u.jpg'); + act(() => result.current.pickCover(new File(['x'], 'b.png'))); + expect(result.current.coverPreview).toBe('blob:cover'); + act(() => result.current.setLink(0, { url: 'x' })); + unmount(); + expect(URL.revokeObjectURL).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/components/Collections/useListEditor.ts b/client/src/components/Collections/useListEditor.ts new file mode 100644 index 000000000..b384c12d3 --- /dev/null +++ b/client/src/components/Collections/useListEditor.ts @@ -0,0 +1,207 @@ +import type { Collection, CollectionLink } from '@trek/shared'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { tripsApi } from '../../api/client'; +import { useCollectionStore } from '../../store/collectionStore'; +import type { TranslationFn } from '../../types'; +import { getApiErrorMessage } from '../../utils/apiError'; +import { useToast } from '../shared/Toast'; + +export interface CoverSearchPhoto { + id: string; + url: string; + thumb: string; + description?: string | null; + photographer?: string | null; +} + +export interface ListEditorOptions { + /** null = closed, 'new' = create, a Collection = edit that list. */ + target: Collection | 'new' | null; + onClose: () => void; + onCreated: (id: number) => void; + t: TranslationFn; + /** The colour a new list starts with. */ + defaultColor: string; + /** Keep editing the last target while it is cleared (a sheet holds it through its exit animation). */ + holdLastTarget?: boolean; + /** The collections model's link normaliser, injected so this hook needs nothing from pages/. */ + normalizeLinkUrl: (url: string) => string; +} + +/** Links with a trimmed label and a normalised url; rows without a url are dropped. */ +export function cleanListLinks(links: CollectionLink[], normalizeLinkUrl: (url: string) => string): CollectionLink[] { + return links.map((l) => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter((l) => l.url); +} + +/** + * Create or edit a list (name, colour, cover from an upload or an Unsplash search, + * description and links): the logic behind both the desktop dialog and the phone sheet, + * which render their own markup over it. On create it makes the list, then uploads the + * cover to the new id; on edit it patches and uploads again. + */ +export function useListEditor({ + target, + onClose, + onCreated, + t, + defaultColor, + holdLastTarget = false, + normalizeLinkUrl, +}: ListEditorOptions) { + const createCollection = useCollectionStore((s) => s.createCollection); + const updateCollection = useCollectionStore((s) => s.updateCollection); + const uploadCover = useCollectionStore((s) => s.uploadCover); + const toast = useToast(); + const fileRef = useRef(null); + + const [held, setHeld] = useState(target); + if (holdLastTarget && target && target !== held) setHeld(target); + const shown = holdLastTarget ? held : target; + const editing = shown && shown !== 'new' ? shown : null; + + const [name, setName] = useState(''); + const [color, setColor] = useState(defaultColor); + const [description, setDescription] = useState(''); + const [links, setLinks] = useState([]); + const [coverFile, setCoverFile] = useState(null); + const [coverPreview, setCoverPreview] = useState(null); + const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState(null); + const [coverQuery, setCoverQuery] = useState(''); + const [coverResults, setCoverResults] = useState([]); + const [searchingCover, setSearchingCover] = useState(false); + const coverSeq = useRef(0); + const [saving, setSaving] = useState(false); + // Remembers a freshly created list id so a retry after a cover upload failure + // updates it instead of creating a duplicate list. + const [createdId, setCreatedId] = useState(null); + const objectUrl = useRef(null); + + const dropObjectUrl = useCallback(() => { + if (objectUrl.current) { + URL.revokeObjectURL(objectUrl.current); + objectUrl.current = null; + } + }, []); + + // (Re)seed the form whenever the target changes. + useEffect(() => { + if (!target) return; + const edit = target !== 'new' ? target : null; + setName(edit?.name ?? ''); + setColor(edit?.color ?? defaultColor); + setDescription(edit?.description ?? ''); + setLinks(edit?.links ?? []); + setCoverFile(null); + dropObjectUrl(); + setCoverPreview(edit?.cover_image ?? null); + setPendingUnsplashUrl(null); + setCoverQuery(''); + setCoverResults([]); + setCreatedId(null); + }, [target, defaultColor, dropObjectUrl]); + + // Revoke the last preview blob on unmount. + useEffect(() => () => dropObjectUrl(), [dropObjectUrl]); + + // A create that only failed at the cover step already produced the list: + // hand it over on the way out instead of leaving it behind unnoticed. + const close = () => { + if (createdId != null) onCreated(createdId); + onClose(); + }; + + const pickCover = (file: File | undefined) => { + if (!file) return; + dropObjectUrl(); + const url = URL.createObjectURL(file); + objectUrl.current = url; + setCoverFile(file); + setCoverPreview(url); + setPendingUnsplashUrl(null); // an uploaded file wins over a picked Unsplash photo + }; + + const searchCover = async () => { + const query = coverQuery.trim() || name.trim(); + if (!query) return; + const seq = ++coverSeq.current; + setSearchingCover(true); + try { + const data = await tripsApi.searchCoverImages(query); + if (seq !== coverSeq.current) return; + setCoverResults(data.photos || []); + } catch { + if (seq === coverSeq.current) setCoverResults([]); + } finally { + if (seq === coverSeq.current) setSearchingCover(false); + } + }; + + const pickUnsplash = (photo: CoverSearchPhoto) => { + if (!photo.url) return; + dropObjectUrl(); + setCoverFile(null); + setPendingUnsplashUrl(photo.url); + setCoverPreview(photo.url); + }; + + const setLink = (i: number, patch: Partial) => + setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l))); + + const save = async () => { + const trimmed = name.trim(); + // Enter fires while the create is still in flight, and `createdId` is only + // set once it comes back; without this the second one creates a second list. + if (!trimmed || saving) return; + const payload = { + name: trimmed, + color, + description: description.trim() || null, + links: cleanListLinks(links, normalizeLinkUrl), + ...(pendingUnsplashUrl ? { cover_image: pendingUnsplashUrl } : {}), + }; + setSaving(true); + try { + let id = editing?.id ?? createdId; + if (id != null) { + await updateCollection(id, payload); + } else { + const created = await createCollection(payload); + id = created?.id ?? null; + setCreatedId(id); + } + if (id != null && coverFile) await uploadCover(id, coverFile); + if (!editing && id != null) onCreated(id); + onClose(); + } catch (err) { + toast.error(getApiErrorMessage(err, t('common.error'))); + } finally { + setSaving(false); + } + }; + + return { + editing, + fileRef, + name, + setName, + color, + setColor, + description, + setDescription, + links, + setLinks, + setLink, + coverPreview, + coverQuery, + setCoverQuery, + coverResults, + searchingCover, + saving, + close, + pickCover, + searchCover, + pickUnsplash, + save, + }; +} diff --git a/client/src/mobile/screens/collections/MCollEditSheet.tsx b/client/src/mobile/screens/collections/MCollEditSheet.tsx index 3076ff4bb..6ed1cd6cc 100644 --- a/client/src/mobile/screens/collections/MCollEditSheet.tsx +++ b/client/src/mobile/screens/collections/MCollEditSheet.tsx @@ -1,25 +1,13 @@ -import { useEffect, useRef, useState } from 'react' import { Image, Loader2, Search, Trash2 } from 'lucide-react' -import type { Collection, CollectionLink } from '@trek/shared' +import type { Collection } from '@trek/shared' import type { TranslationFn } from '../../../types' -import { tripsApi } from '../../../api/client' -import { useCollectionStore } from '../../../store/collectionStore' -import { useToast } from '../../../components/shared/Toast' -import { getApiErrorMessage } from '../../../utils/apiError' +import { useListEditor } from '../../../components/Collections/useListEditor' import { normalizeLinkUrl } from '../../../pages/collections/collectionsModel' import MSheet from '../../components/MSheet' import MCollLinksEditor from './MCollLinksEditor' import { listCoverGradient, SWATCH_COLORS } from './collectionsMobileModel' import { CancelPill, PrimaryPill, SheetFooter, SheetHeader, TEXTAREA_CLS } from './MCollSheetKit' -interface CoverSearchPhoto { - id: string - url: string - thumb: string - description?: string | null - photographer?: string | null -} - // Scrims over the cover photo / Unsplash thumbs — fixed dark overlays in both themes. const COVER_OVERLAY = 'absolute inset-0 flex items-center justify-center gap-[6px] bg-[rgba(0,0,0,.28)] text-[0.78125rem] font-bold text-white' // theme-lint-disable @@ -41,129 +29,10 @@ interface MCollEditSheetProps { * description and links. Deleting goes through the shared confirm flow. */ export default function MCollEditSheet({ target, onClose, onCreated, onRequestDelete, t }: MCollEditSheetProps) { - const createCollection = useCollectionStore(s => s.createCollection) - const updateCollection = useCollectionStore(s => s.updateCollection) - const uploadCover = useCollectionStore(s => s.uploadCover) - const toast = useToast() - const fileRef = useRef(null) - - // Hold the last target through the exit animation. - const [held, setHeld] = useState(target) - if (target && target !== held) setHeld(target) - const editing = held && held !== 'new' ? held : null - - const [name, setName] = useState('') - const [color, setColor] = useState(SWATCH_COLORS[0]) - const [description, setDescription] = useState('') - const [links, setLinks] = useState([]) - const [coverFile, setCoverFile] = useState(null) - const [coverPreview, setCoverPreview] = useState(null) - const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState(null) - const [coverQuery, setCoverQuery] = useState('') - const [coverResults, setCoverResults] = useState([]) - const [searchingCover, setSearchingCover] = useState(false) - const coverSeq = useRef(0) - const [saving, setSaving] = useState(false) - // A create that failed at the cover step keeps its id so a retry updates it. - const [createdId, setCreatedId] = useState(null) - const objectUrl = useRef(null) - - const dropObjectUrl = () => { - if (objectUrl.current) { URL.revokeObjectURL(objectUrl.current); objectUrl.current = null } - } - - // (Re)seed the form whenever the sheet opens on a target. - useEffect(() => { - if (!target) return - const edit = target !== 'new' ? target : null - setName(edit?.name ?? '') - setColor(edit?.color ?? SWATCH_COLORS[0]) - setDescription(edit?.description ?? '') - setLinks(edit?.links ?? []) - setCoverFile(null) - dropObjectUrl() - setCoverPreview(edit?.cover_image ?? null) - setPendingUnsplashUrl(null) - setCoverQuery('') - setCoverResults([]) - setCreatedId(null) - }, [target]) - - useEffect(() => () => dropObjectUrl(), []) - - // A create that only failed at the cover step already produced the list — - // hand it over on the way out instead of leaving it behind unnoticed. - const close = () => { - if (createdId != null) onCreated(createdId) - onClose() - } - - const pickCover = (file: File | undefined) => { - if (!file) return - dropObjectUrl() - const url = URL.createObjectURL(file) - objectUrl.current = url - setCoverFile(file) - setCoverPreview(url) - setPendingUnsplashUrl(null) - } - - const searchCover = async () => { - const query = coverQuery.trim() || name.trim() - if (!query) return - const seq = ++coverSeq.current - setSearchingCover(true) - try { - const data = await tripsApi.searchCoverImages(query) - if (seq !== coverSeq.current) return - setCoverResults(data.photos || []) - } catch { - if (seq === coverSeq.current) setCoverResults([]) - } finally { - if (seq === coverSeq.current) setSearchingCover(false) - } - } - - const pickUnsplash = (photo: CoverSearchPhoto) => { - if (!photo.url) return - dropObjectUrl() - setCoverFile(null) - setPendingUnsplashUrl(photo.url) - setCoverPreview(photo.url) - } - - const save = async () => { - const trimmed = name.trim() - if (!trimmed || saving) return - const cleanLinks = links - .map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })) - .filter(l => l.url) - const payload = { - name: trimmed, - color, - description: description.trim() || null, - links: cleanLinks, - ...(pendingUnsplashUrl ? { cover_image: pendingUnsplashUrl } : {}), - } - setSaving(true) - try { - let id = editing?.id ?? createdId - if (id != null) { - await updateCollection(id, payload) - } else { - const created = await createCollection(payload) - id = created?.id ?? null - setCreatedId(id) - } - if (id != null && coverFile) await uploadCover(id, coverFile) - if (!editing && id != null) onCreated(id) - onClose() - } catch (err) { - toast.error(getApiErrorMessage(err, t('common.error'))) - } finally { - setSaving(false) - } - } + const { + editing, fileRef, name, setName, color, setColor, description, setDescription, links, setLinks, + coverPreview, coverQuery, setCoverQuery, coverResults, searchingCover, saving, close, pickCover, searchCover, pickUnsplash, save, + } = useListEditor({ target, onClose, onCreated, t, defaultColor: SWATCH_COLORS[0], holdLastTarget: true, normalizeLinkUrl }) const label = 'mb-[5px] font-geist text-[0.6875rem] font-bold text-m-muted' const canDeleteList = editing != null && editing.is_owner !== false