mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(collections): share the list editor logic between desktop and phone
useListEditor seeds the form from the target, handles the cover upload and the Unsplash search, cleans the links and creates or updates the list for both the dialog and the sheet. The sheet keeps holding the last list through its exit and still hands over a list whose cover upload failed when it closes.
This commit is contained in:
@@ -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<HTMLInputElement>(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<CollectionLink[]>([])
|
||||
const [coverFile, setCoverFile] = useState<File | null>(null)
|
||||
const [coverPreview, setCoverPreview] = useState<string | null>(null)
|
||||
const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState<string | null>(null)
|
||||
const [coverQuery, setCoverQuery] = useState('')
|
||||
const [coverResults, setCoverResults] = useState<CoverSearchPhoto[]>([])
|
||||
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<number | null>(null)
|
||||
const objectUrl = useRef<string | null>(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<CollectionLink>) =>
|
||||
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 = (
|
||||
<DialogHeader
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// FE-COMP-LISTEDITORHOOK-001 to FE-COMP-LISTEDITORHOOK-011: the list create / edit logic
|
||||
// behind both the desktop dialog and the phone sheet.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import type { Collection } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest';
|
||||
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { tripsApi } from '../../api/client';
|
||||
import { normalizeLinkUrl } from '../../pages/collections/collectionsModel';
|
||||
import { useCollectionStore } from '../../store/collectionStore';
|
||||
import { cleanListLinks, useListEditor, type ListEditorOptions } from './useListEditor';
|
||||
|
||||
const t = (k: string) => 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<typeof useCollectionStore.getState>;
|
||||
const initialCollectionState = useCollectionStore.getState();
|
||||
let actions: {
|
||||
createCollection: Mock<CollectionState['createCollection']>;
|
||||
updateCollection: Mock<CollectionState['updateCollection']>;
|
||||
uploadCover: Mock<CollectionState['uploadCover']>;
|
||||
};
|
||||
let addToast: Mock<NonNullable<Window['__addToast']>>;
|
||||
let searchCovers: MockInstance<typeof tripsApi.searchCoverImages>;
|
||||
|
||||
function setup(over: Partial<ListEditorOptions> = {}) {
|
||||
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<CollectionState['createCollection']>().mockResolvedValue({ ...LIST, id: 9 }),
|
||||
updateCollection: vi.fn<CollectionState['updateCollection']>().mockResolvedValue(undefined),
|
||||
uploadCover: vi.fn<CollectionState['uploadCover']>().mockResolvedValue(undefined),
|
||||
};
|
||||
useCollectionStore.setState(actions);
|
||||
addToast = vi.fn<NonNullable<Window['__addToast']>>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
|
||||
const [held, setHeld] = useState<Collection | 'new' | null>(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<CollectionLink[]>([]);
|
||||
const [coverFile, setCoverFile] = useState<File | null>(null);
|
||||
const [coverPreview, setCoverPreview] = useState<string | null>(null);
|
||||
const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState<string | null>(null);
|
||||
const [coverQuery, setCoverQuery] = useState('');
|
||||
const [coverResults, setCoverResults] = useState<CoverSearchPhoto[]>([]);
|
||||
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<number | null>(null);
|
||||
const objectUrl = useRef<string | null>(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<CollectionLink>) =>
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null)
|
||||
|
||||
// Hold the last target through the exit animation.
|
||||
const [held, setHeld] = useState<Collection | 'new' | null>(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<CollectionLink[]>([])
|
||||
const [coverFile, setCoverFile] = useState<File | null>(null)
|
||||
const [coverPreview, setCoverPreview] = useState<string | null>(null)
|
||||
const [pendingUnsplashUrl, setPendingUnsplashUrl] = useState<string | null>(null)
|
||||
const [coverQuery, setCoverQuery] = useState('')
|
||||
const [coverResults, setCoverResults] = useState<CoverSearchPhoto[]>([])
|
||||
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<number | null>(null)
|
||||
const objectUrl = useRef<string | null>(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
|
||||
|
||||
Reference in New Issue
Block a user