diff --git a/client/src/components/Studio/StudioCanvas.tsx b/client/src/components/Studio/StudioCanvas.tsx index 11363b63d..6768cb70f 100644 --- a/client/src/components/Studio/StudioCanvas.tsx +++ b/client/src/components/Studio/StudioCanvas.tsx @@ -4,6 +4,7 @@ import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared' import { SpreadFold, SpreadView } from './SpreadView' import { PeerCursors } from './PeerCursors' import type { PeerCursor } from './useBookPresence' +import { elementId } from './bookIds' import { fontStack } from './bookFonts' import { useSpreadInteraction, type HandleId } from './useSpreadInteraction' import { useStudioStore } from '../../store/studioStore' @@ -224,7 +225,7 @@ export function StudioCanvas({ // Dropped on bare paper: a new frame, centred on the cursor. const w = Math.min(page.pageWidth, page.pageHeight) * 0.5 const h = w * 0.72 - const id = `p-${Math.random().toString(36).slice(2, 9)}` + const id = elementId('p') addElement(spreadIndex, { id, kind: 'photo', frame: { x: p.x - w / 2, y: p.y - h / 2, w, h }, diff --git a/client/src/components/Studio/StudioContentFilter.tsx b/client/src/components/Studio/StudioContentFilter.tsx new file mode 100644 index 000000000..bcd1633c6 --- /dev/null +++ b/client/src/components/Studio/StudioContentFilter.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef, useState } from 'react' +import { Check, ChevronDown, X } from 'lucide-react' +import { formatDate } from '../../utils/formatters' +import type { JourneySource, StudioT } from './StudioSidebar' +import type { PhotoFilter } from './photoFilter' + +/** + * Which day's pictures the content browser is showing. + * + * A row of its own under the tabs rather than a control inside the search box: + * the search answers "which words" and this answers "which day", and the two + * combine. It takes the panel's full width, so the entry in force reads as a + * heading over the grid rather than as one control among several, and a long + * entry name has somewhere to go. + * + * The popover is the top bar's format menu, hung from the left edge instead of + * the right: the side panel is 236px wide and a menu anchored right would open + * into the rail. + */ +export function StudioContentFilter({ + source, filter, setFilter, admitted, pageEntryId, t, locale, +}: { + source: JourneySource + filter: PhotoFilter + setFilter: (f: PhotoFilter) => void + /** How many pictures the current filter admits, for the chip's own count. */ + admitted: number + /** The entry the spread on the sheet was laid out from, when it has one. */ + pageEntryId: number | null + t: StudioT + locale: string +}) { + const [open, setOpen] = useState(false) + const box = useRef(null) + + useEffect(() => { + if (!open) return + const onDown = (e: PointerEvent) => { + if (!box.current?.contains(e.target as Node)) setOpen(false) + } + const onKey = (e: KeyboardEvent) => { + // Swallowed, as the format picker does it: the shell's Escape closes Studio. + if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) } + } + document.addEventListener('pointerdown', onDown) + document.addEventListener('keydown', onKey, true) + return () => { + document.removeEventListener('pointerdown', onDown) + document.removeEventListener('keydown', onKey, true) + } + }, [open]) + + const pick = (f: PhotoFilter) => { setFilter(f); setOpen(false) } + const withPhotos = source.entries.filter(e => e.photoIds.length > 0) + const looseCount = source.photos.filter(p => !p.entryIds.length).length + const nameOf = (e: { title: string | null; location: string | null } | undefined) => + e?.title || e?.location || t('journey.studio.untitled') + + const label = filter.kind === 'loose' ? t('journey.studio.filterLoose') + : filter.kind === 'recent' ? t('journey.studio.filterRecent') + : filter.kind === 'entry' ? nameOf(source.entries.find(e => e.id === filter.id)) + : t('journey.studio.filterAll') + + /* + * The page on the sheet belongs to a day, so its pictures are one press + * away. Offered only while the filter is not already there: a chip for the + * state you are in is a chip that does nothing. + */ + const pageEntry = pageEntryId != null ? withPhotos.find(e => e.id === pageEntryId) : undefined + const offerThisPage = pageEntry && !(filter.kind === 'entry' && filter.id === pageEntry.id) + + return ( +
+
+ + + {filter.kind !== 'all' && ( + + )} + + + {open && ( +
+ pick({ kind: 'all' })} + /> + pick({ kind: 'loose' })} + /> + {withPhotos.length > 0 &&
} + {withPhotos.map(e => ( + pick({ kind: 'entry', id: e.id })} + /> + ))} +
+ )} +
+ + {offerThisPage && ( + + )} +
+ ) +} + +/** One line of the menu: a name, a count, and a tick on the one in force. */ +function FilterItem({ on, label, dim, count, onPick }: { + on: boolean + label: string + dim?: string + count: number + onPick: () => void +}) { + return ( + + ) +} diff --git a/client/src/components/Studio/StudioSidebar.tsx b/client/src/components/Studio/StudioSidebar.tsx index 44ab7e916..6ddabbbbe 100644 --- a/client/src/components/Studio/StudioSidebar.tsx +++ b/client/src/components/Studio/StudioSidebar.tsx @@ -10,6 +10,7 @@ import { formatDate } from '../../utils/formatters' import { useToast } from '../shared/Toast' import { SpreadFold, SpreadView } from './SpreadView' import { photoSrc } from './bookRender' +import { elementId as uid } from './bookIds' import { formatBookCoords, formatBookDate, type CoordFormat } from './entryText' import { coordValue } from './resolveBindings' import { COVER_TEMPLATES, TEMPLATES, applyTemplate } from './templates' @@ -18,6 +19,8 @@ import { PanelHead as Head } from './StudioPanelHead' import { StudioElementsPanel } from './StudioElementsPanel' import { StudioTravelPanel } from './StudioTravelPanel' import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile' +import { StudioContentFilter } from './StudioContentFilter' +import { emptyKeyFor, matchingEntries, matchingPhotos, photosFor, type PhotoFilter } from './photoFilter' import type { StudioUploader } from './studioUpload' /** @@ -59,8 +62,6 @@ export interface JourneySource { /** The translator, with the parameters some of the strings here take. */ export type StudioT = (k: string, params?: Record) => string -const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}` - /** * How wide a page preview may be drawn. * @@ -320,19 +321,6 @@ function PagesPanel({ ) } -/** - * Which of the journey's pictures the browser shows. - * - * `recent` holds ids rather than a flag on the photo: what just arrived is a - * fact about this sitting, not about the picture, and the journey store has no - * reason to remember it. - */ -type PhotoFilter = - | { kind: 'all' } - | { kind: 'entry'; id: number } - | { kind: 'loose' } - | { kind: 'recent'; photoIds: number[] } - /** * The journey's own material. * @@ -361,98 +349,16 @@ function ContentPanel({ const [filter, setFilter] = useState({ kind: 'all' }) const toast = useToast() - /* - * Filtering, not a search index: a journey holds tens or hundreds of items, and - * a substring match over what is already in memory answers instantly. A photo - * matches on its own caption *and* on the entry it belongs to — most photos - * carry no words at all, so matching only captions would make the box look - * broken on exactly the journeys that need it. - */ - const q = query.trim().toLowerCase() - const entries = q - ? source.entries.filter(e => - [e.title, e.story, e.location, ...e.pros, ...e.cons] - .some(v => v && v.toLowerCase().includes(q))) - : source.entries - - /* - * The filter narrows first and the words second, so an entry filter with a - * search typed into it means "these pictures, with these words". Which entry - * a picture belongs to is read off the journal's own junction rather than - * off its words, which is what lets "not in an entry" mean exactly that. - */ - const byFilter = (() => { - switch (filter.kind) { - case 'entry': return source.photos.filter(p => p.entryIds.includes(filter.id)) - case 'loose': return source.photos.filter(p => !p.entryIds.length) - case 'recent': { - // In the order they were sent, which is the order they were chosen in, - // not wherever the gallery sorted them. - const byId = new Map(source.photos.map(p => [p.photoId, p] as const)) - return filter.photoIds.flatMap(id => { const p = byId.get(id); return p ? [p] : [] }) - } - default: return source.photos - } - })() - const photos = q - ? byFilter.filter(p => - (p.caption && p.caption.toLowerCase().includes(q)) - || (source.photoEntries[p.photoId] || '').includes(q)) - : byFilter - - const looseCount = source.photos.filter(p => !p.entryIds.length).length - const withPhotos = source.entries.filter(e => e.photoIds.length > 0) - const nameOf = (e: { title: string | null; location: string | null } | undefined) => - e?.title || e?.location || t('journey.studio.untitled') - const filterLabel = filter.kind === 'loose' ? t('journey.studio.filterLoose') - : filter.kind === 'recent' ? t('journey.studio.filterRecent') - : filter.kind === 'entry' ? nameOf(source.entries.find(e => e.id === filter.id)) - : t('journey.studio.filterAll') - - /* - * Why the grid is empty, which is four different answers. "No photos" on a - * journey with three hundred of them, because the filter is on a day with - * none, reads as the pictures having gone. - */ - const emptyKey = !source.photos.length ? 'journey.studio.noPhotos' - : byFilter.length ? 'journey.studio.noMatches' - : filter.kind === 'entry' ? 'journey.studio.noEntryPhotos' - : filter.kind === 'loose' ? 'journey.studio.noLoosePhotos' - : 'journey.studio.noMatches' - - const [menuOpen, setMenuOpen] = useState(false) - const menuBox = useRef(null) - useEffect(() => { - if (!menuOpen) return - const onDown = (e: PointerEvent) => { - if (!menuBox.current?.contains(e.target as Node)) setMenuOpen(false) - } - const onKey = (e: KeyboardEvent) => { - // Swallowed, as the format picker does it: the shell's Escape closes Studio. - if (e.key === 'Escape') { e.stopPropagation(); setMenuOpen(false) } - } - document.addEventListener('pointerdown', onDown) - document.addEventListener('keydown', onKey, true) - return () => { - document.removeEventListener('pointerdown', onDown) - document.removeEventListener('keydown', onKey, true) - } - }, [menuOpen]) - const pick = (f: PhotoFilter) => { setFilter(f); setMenuOpen(false) } + const entries = matchingEntries(source, query) + const admitted = photosFor(source, filter) + const photos = matchingPhotos(admitted, source, query) + const emptyKey = emptyKeyFor(source, filter, admitted) const addElement = useStudioStore(s => s.addElement) const active = useStudioStore(s => s.activeSpread) const doc = useStudioStore(s => s.doc) const spread = doc?.spreads[active] - /* - * The page on the sheet belongs to a day, so its pictures are one press - * away. Offered only while the filter is not already there: a chip for the - * state you are in is a chip that does nothing. - */ - const pageEntry = spread?.entryId != null ? withPhotos.find(e => e.id === spread.entryId) : undefined - const offerThisPage = pageEntry && !(filter.kind === 'entry' && filter.id === pageEntry.id) - const fileInput = useRef(null) const [upload, setUpload] = useState<{ done: number; total: number } | null>(null) /* A ref as well as the state: two drops in one second would both read the @@ -638,63 +544,15 @@ function ContentPanel({ panel has no room to the right of it. */} {tab === 'photos' && ( -
-
- - - {filter.kind !== 'all' && ( - - )} - - - {menuOpen && ( -
- pick({ kind: 'all' })} - /> - pick({ kind: 'loose' })} - /> - {withPhotos.length > 0 &&
} - {withPhotos.map(e => ( - pick({ kind: 'entry', id: e.id })} - /> - ))} -
- )} -
- - {offerThisPage && ( - - )} -
+ )}
@@ -878,31 +736,6 @@ function ContentPanel({ ) } -/** One line of the photo filter: a name, a count, and a tick on the one in force. */ -function FilterItem({ on, label, dim, count, onPick }: { - on: boolean - label: string - dim?: string - count: number - onPick: () => void -}) { - return ( - - ) -} - /** * Layouts for the current spread. * diff --git a/client/src/components/Studio/StudioTravelPanel.tsx b/client/src/components/Studio/StudioTravelPanel.tsx index 0f2a3629c..e52581995 100644 --- a/client/src/components/Studio/StudioTravelPanel.tsx +++ b/client/src/components/Studio/StudioTravelPanel.tsx @@ -7,6 +7,7 @@ import { PanelHead } from './StudioPanelHead' import { Section } from './StudioControls' import { TravelPreview } from './TravelPreview' import { formatBookCoords } from './entryText' +import { elementId as uid } from './bookIds' import { routeFor } from './travelRefresh' import { useMapSources } from './mapSources' import { fetchRoads } from './roadRoute' @@ -38,8 +39,6 @@ import { fetchRoads } from './roadRoute' * short version is that the print renderer must never depend on a fetch. */ -const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}` - /** * One row of the stops list: a point of the route or a stop switched off. * diff --git a/client/src/components/Studio/bookIds.ts b/client/src/components/Studio/bookIds.ts new file mode 100644 index 000000000..dcf46d9f4 --- /dev/null +++ b/client/src/components/Studio/bookIds.ts @@ -0,0 +1,31 @@ +/** + * Ids for the things a book is made of. + * + * Every panel that places an element mints one, and they were four copies of + * the same line: a prefix, `Math.random().toString(36)` and a slice. Copies of + * a line drift, and this one had already drifted in its slice length. + * + * `getRandomValues` rather than `Math.random`, for the reason + * `utils/randomId.ts` gives at greater length: TREK's own quickstart is plain + * http on a LAN address, where `crypto.randomUUID` is undefined but + * `getRandomValues` still works. A book document is shared over a websocket and + * merged by whoever saves last, so two editors minting the same id for two + * different elements is a collision that shows up as one of them losing their + * work. Short of Web Crypto entirely, the old arithmetic is still there. + * + * Seven characters, as before: 36^7 is about 78 billion, against a document + * that holds a few hundred elements. + */ +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' + +export function elementId(prefix: string): string { + const c = globalThis.crypto + if (c && typeof c.getRandomValues === 'function') { + const bytes = new Uint8Array(7) + c.getRandomValues(bytes) + let out = '' + for (const b of bytes) out += ALPHABET[b % ALPHABET.length] + return `${prefix}-${out}` + } + return `${prefix}-${Math.random().toString(36).slice(2, 9)}` +} diff --git a/client/src/components/Studio/photoFilter.ts b/client/src/components/Studio/photoFilter.ts new file mode 100644 index 000000000..e3bc69a7c --- /dev/null +++ b/client/src/components/Studio/photoFilter.ts @@ -0,0 +1,83 @@ +import type { JourneySource } from './StudioSidebar' + +/** + * Which of the journey's pictures the content browser shows. + * + * `recent` holds ids rather than a flag on the photo: what just arrived is a + * fact about this sitting, not about the picture, and the journey store has no + * reason to remember it. + */ +export type PhotoFilter = + | { kind: 'all' } + | { kind: 'entry'; id: number } + | { kind: 'loose' } + | { kind: 'recent'; photoIds: number[] } + +type Photo = JourneySource['photos'][number] + +/** + * The pictures a filter admits, before anything is searched. + * + * Which entry a picture belongs to is read off the journal's own junction + * rather than off its words, which is what lets "not in an entry" mean exactly + * that. Pure, and out here rather than inside the panel, because these four + * rules are the feature and a rendered component is an awkward place to prove + * them right. + */ +export function photosFor(source: JourneySource, filter: PhotoFilter): Photo[] { + switch (filter.kind) { + case 'entry': + return source.photos.filter(p => p.entryIds.includes(filter.id)) + case 'loose': + return source.photos.filter(p => !p.entryIds.length) + case 'recent': { + // In the order they were sent, which is the order they were chosen in, + // not wherever the gallery sorted them. + const byId = new Map(source.photos.map(p => [p.photoId, p] as const)) + return filter.photoIds.flatMap(id => { + const photo = byId.get(id) + return photo ? [photo] : [] + }) + } + default: + return source.photos + } +} + +/** + * The words, over what the filter left. + * + * A photo matches on its own caption and on the entry it belongs to: most + * photos carry no words at all, so matching only captions would make the + * search box look broken on exactly the journeys that need it. + */ +export function matchingPhotos(photos: Photo[], source: JourneySource, query: string): Photo[] { + const q = query.trim().toLowerCase() + if (!q) return photos + return photos.filter(p => + (p.caption && p.caption.toLowerCase().includes(q)) + || (source.photoEntries[p.photoId] || '').includes(q)) +} + +/** The entries the words admit, for the other tab. */ +export function matchingEntries(source: JourneySource, query: string): JourneySource['entries'] { + const q = query.trim().toLowerCase() + if (!q) return source.entries + return source.entries.filter(e => + [e.title, e.story, e.location, ...e.pros, ...e.cons] + .some(v => v && v.toLowerCase().includes(q))) +} + +/** + * Why the grid is empty, which is four different answers. + * + * "No photos" on a journey with three hundred of them, because the filter is + * on a day that has none, reads as the pictures having gone. + */ +export function emptyKeyFor(source: JourneySource, filter: PhotoFilter, admitted: Photo[]): string { + if (!source.photos.length) return 'journey.studio.noPhotos' + if (admitted.length) return 'journey.studio.noMatches' + if (filter.kind === 'entry') return 'journey.studio.noEntryPhotos' + if (filter.kind === 'loose') return 'journey.studio.noLoosePhotos' + return 'journey.studio.noMatches' +} diff --git a/client/src/pages/journeyStudio/useJourneyStudio.test.tsx b/client/src/pages/journeyStudio/useJourneyStudio.test.tsx index f8e049964..6675a6224 100644 --- a/client/src/pages/journeyStudio/useJourneyStudio.test.tsx +++ b/client/src/pages/journeyStudio/useJourneyStudio.test.tsx @@ -3,6 +3,14 @@ import type { JourneyStats } from '@trek/shared' import { useStudioStore } from '../../store/studioStore' import { useJourneyStore } from '../../store/journeyStore' import { journeyApi } from '../../api/client' +import { uploadStudioPhotos } from '../../components/Studio/studioUpload' + +vi.mock('../../components/Studio/studioUpload', () => ({ + uploadStudioPhotos: vi.fn(async () => ({ photoIds: [], failed: 0, skippedVideos: 0 })), +})) + +const toasts = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } +vi.mock('../../components/shared/Toast', () => ({ useToast: () => toasts })) /** * Studio's shell state (#1973), for the two things that cost work rather than @@ -59,7 +67,7 @@ function seedJourney() { beforeEach(() => { seedJourney() buildBook.mockClear() - useStudioStore.setState({ doc: null, selection: [], activeSpread: 0 }) + useStudioStore.setState({ doc: null, selection: [], activeSpread: 0, past: [], future: [] }) vi.spyOn(journeyApi, 'getBook').mockResolvedValue({ book: null } as never) vi.spyOn(journeyApi, 'saveBook').mockResolvedValue({} as never) vi.spyOn(journeyApi, 'stats').mockResolvedValue(stats() as never) @@ -147,3 +155,143 @@ describe('the auto layout input', () => { expect(input.path).toEqual([]) }) }) + + +/** + * The three things the shell does for the panels beside it (#2064): put + * pictures in, place the ones dropped on the sheet, and switch a stop off. + * + * All three end in the journey, not in the document, which is why they live + * here rather than in a panel: the store and the figures are the shell's, and + * a panel that reached for them itself would be a second path to the same + * state. + */ +describe('pictures and stops', () => { + beforeEach(() => { + vi.mocked(uploadStudioPhotos).mockResolvedValue({ photoIds: [], failed: 0, skippedVideos: 0 }) + toasts.success.mockClear(); toasts.error.mockClear(); toasts.info.mockClear() + }) + + const file = () => new File(['x'], 'p.jpg', { type: 'image/jpeg' }) + + it('says so when a picture did not arrive, and when a video was left out', async () => { + vi.mocked(uploadStudioPhotos).mockResolvedValue({ photoIds: [5], failed: 2, skippedVideos: 1 }) + const { result } = await mountStudio() + + await act(async () => { await result.current.uploadPhotos([file()], null) }) + + expect(toasts.error).toHaveBeenCalled() + expect(toasts.info).toHaveBeenCalled() + }) + + it('answers with nothing sent when the upload itself fails', async () => { + vi.mocked(uploadStudioPhotos).mockRejectedValue(new Error('offline')) + const { result } = await mountStudio() + + let outcome + await act(async () => { outcome = await result.current.uploadPhotos([file(), file()], null) }) + + expect(outcome).toEqual({ photoIds: [], failed: 2, skippedVideos: 0 }) + expect(toasts.error).toHaveBeenCalled() + }) + + /* + * A drop on the sheet is one undo step, however many pictures it carried: + * the first fills the frame under the pointer when there is one, the rest + * fan out from where they landed. + */ + it('places what was dropped on the page, as one undo step', async () => { + vi.mocked(uploadStudioPhotos).mockResolvedValue({ photoIds: [7, 8], failed: 0, skippedVideos: 0 }) + const { result } = await mountStudio() + const before = useStudioStore.getState().doc!.spreads[0].elements.length + + await act(async () => { await result.current.dropFiles([file(), file()], { x: 40, y: 30 }, null) }) + + const spread = useStudioStore.getState().doc!.spreads[0] + const added = spread.elements.slice(before) + expect(added.map(e => (e as { photoId?: number }).photoId)).toEqual([7, 8]) + expect(useStudioStore.getState().selection).toEqual(added.map(e => e.id)) + + /* + * One history entry for the whole drop, holding the page as it was, so a + * single press takes all of it back. Read off the history rather than by + * pressing undo here: the autosave watches the document and writes it back + * on the next render, which is right in the editor and would make this + * case about the save loop instead. + */ + const history = useStudioStore.getState().past + expect(history).toHaveLength(1) + expect(history[0].spreads[0].elements).toHaveLength(before) + }) + + it('fills the frame the drop landed on rather than covering it', async () => { + vi.mocked(uploadStudioPhotos).mockResolvedValue({ photoIds: [9], failed: 0, skippedVideos: 0 }) + const { result } = await mountStudio() + act(() => { + useStudioStore.getState().addElement(0, { + id: 'frame-1', kind: 'photo', frame: { x: 0, y: 0, w: 50, h: 50 }, + rotation: 0, opacity: 1, locked: false, photoId: null, + fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none', + } as never) + }) + const before = useStudioStore.getState().doc!.spreads[0].elements.length + + await act(async () => { await result.current.dropFiles([file()], { x: 10, y: 10 }, 'frame-1') }) + + const spread = useStudioStore.getState().doc!.spreads[0] + expect(spread.elements).toHaveLength(before) + expect((spread.elements.find(e => e.id === 'frame-1') as { photoId?: number }).photoId).toBe(9) + }) + + it('places nothing when the drop brought no picture with it', async () => { + const { result } = await mountStudio() + const before = useStudioStore.getState().doc!.spreads[0].elements.length + + await act(async () => { await result.current.dropFiles([file()], { x: 10, y: 10 }, null) }) + + expect(useStudioStore.getState().doc!.spreads[0].elements).toHaveLength(before) + }) + + /* + * Switching a stop off is an edit to the journal, and the figures are the + * server's sum: the hook writes the flag and then asks for them again rather + * than adjusting the numbers it holds. + */ + it('writes the flag and reads the figures back', async () => { + const updateEntry = vi.fn(async () => {}) + useJourneyStore.setState({ updateEntry } as never) + const fresh = { ...stats(), distance: 42 } + vi.mocked(journeyApi.stats).mockResolvedValue(fresh as never) + const { result } = await mountStudio() + + let ok + await act(async () => { ok = await result.current.setStopExcluded(3, true) }) + + expect(ok).toBe(true) + expect(updateEntry).toHaveBeenCalledWith(3, { stats_excluded: true }) + expect(result.current.stats?.distance).toBe(42) + }) + + it('says the change did not land, and leaves the figures alone', async () => { + useJourneyStore.setState({ updateEntry: vi.fn(async () => { throw new Error('403') }) } as never) + const { result } = await mountStudio() + const before = result.current.stats + + let ok + await act(async () => { ok = await result.current.setStopExcluded(3, true) }) + + expect(ok).toBe(false) + expect(toasts.error).toHaveBeenCalled() + expect(result.current.stats).toBe(before) + }) + + /* A viewer may open the book; the panels ask this before offering a control. */ + it('is read-only for a viewer', async () => { + useJourneyStore.setState({ + current: { ...useJourneyStore.getState().current, my_role: 'viewer' }, + } as never) + const { result } = await mountStudio() + + expect(result.current.canEdit).toBe(false) + }) +}) diff --git a/client/src/pages/journeyStudio/useJourneyStudio.ts b/client/src/pages/journeyStudio/useJourneyStudio.ts index f120c1d0a..5d0a05512 100644 --- a/client/src/pages/journeyStudio/useJourneyStudio.ts +++ b/client/src/pages/journeyStudio/useJourneyStudio.ts @@ -6,6 +6,7 @@ import { journeyApi } from '../../api/client' import { useJourneyStore, type GalleryPhoto, type JourneyEntry, type JourneyPhoto } from '../../store/journeyStore' import { useStudioStore } from '../../store/studioStore' import { useToast } from '../../components/shared/Toast' +import { elementId } from '../../components/Studio/bookIds' import { uploadStudioPhotos, type StudioUploader } from '../../components/Studio/studioUpload' import { useBookStore } from '../../components/Studio/useBookStore' import { useBookPresence } from '../../components/Studio/useBookPresence' @@ -729,7 +730,7 @@ export function useJourneyStudio() { : sp.elements if (target) placed.push(target.id) const fresh = (target ? photoIds.slice(1) : photoIds).map((photoId, i) => ({ - id: `p-${Math.random().toString(36).slice(2, 9)}`, kind: 'photo', + id: elementId('p'), kind: 'photo', frame: { x: at.x - w / 2 + i * 6, y: at.y - h / 2 + i * 6, w, h }, rotation: 0, opacity: 1, locked: false, photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none', diff --git a/client/src/store/journeyStore.test.ts b/client/src/store/journeyStore.test.ts index 635e3f2ce..82fdaee47 100644 --- a/client/src/store/journeyStore.test.ts +++ b/client/src/store/journeyStore.test.ts @@ -297,6 +297,57 @@ describe('journeyStore', () => { spy.mockRestore(); }); + /* + * A picture uploaded onto an entry is a gallery row as well: the entry only + * holds a link to it. Studio's content browser reads the gallery, so without + * this a picture added from there vanished until the journey was fetched + * again (#2064). + */ + it('FE-STORE-JOURNEY-013b: uploadPhotos puts the picture in the gallery the entry links to', async () => { + const entry = buildEntry({ id: 100, photos: [] }); + useJourneyStore.setState({ current: buildJourneyDetail({ id: 50, entries: [entry], gallery: [] }) }); + const uploaded = buildPhoto({ id: 91, entry_id: 100, photo_id: 700, caption: 'a caption' }); + const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockResolvedValue({ photos: [uploaded] } as any); + + await useJourneyStore.getState().uploadPhotos(100, [new File(['x'], 'p.jpg', { type: 'image/jpeg' })]); + + const gallery = useJourneyStore.getState().current?.gallery ?? []; + expect(gallery).toHaveLength(1); + // The gallery's own shape: the journey it belongs to, and no entry on it. + expect(gallery[0]).toMatchObject({ id: 91, photo_id: 700, caption: 'a caption', journey_id: 50 }); + expect('entry_id' in gallery[0]).toBe(false); + spy.mockRestore(); + }); + + it('FE-STORE-JOURNEY-013c: the gallery row the route answers with wins over a derived one', async () => { + useJourneyStore.setState({ + current: buildJourneyDetail({ id: 50, entries: [buildEntry({ id: 100, photos: [] })], gallery: [] }), + }); + const uploaded = buildPhoto({ id: 91, entry_id: 100, photo_id: 700 }); + const answered = { id: 91, journey_id: 50, photo_id: 700, caption: null, shared: 0, sort_order: 4, created_at: 7 }; + const spy = vi.spyOn(journeyApi, 'uploadPhotos') + .mockResolvedValue({ photos: [uploaded], gallery: [answered] } as any); + + await useJourneyStore.getState().uploadPhotos(100, [new File(['x'], 'p.jpg', { type: 'image/jpeg' })]); + + expect(useJourneyStore.getState().current?.gallery).toEqual([answered]); + spy.mockRestore(); + }); + + it('FE-STORE-JOURNEY-013d: a picture already in the gallery is not added to it twice', async () => { + const known = { id: 91, journey_id: 50, photo_id: 700, caption: null, shared: 0, sort_order: 0, created_at: 0 }; + useJourneyStore.setState({ + current: buildJourneyDetail({ id: 50, entries: [buildEntry({ id: 100, photos: [] })], gallery: [known] }), + }); + const spy = vi.spyOn(journeyApi, 'uploadPhotos') + .mockResolvedValue({ photos: [buildPhoto({ id: 91, entry_id: 100, photo_id: 700 })] } as any); + + await useJourneyStore.getState().uploadPhotos(100, [new File(['x'], 'p.jpg', { type: 'image/jpeg' })]); + + expect(useJourneyStore.getState().current?.gallery).toEqual([known]); + spy.mockRestore(); + }); + it('FE-STORE-JOURNEY-017: uploadPhotos returns failed files and merges only succeeded on network error', async () => { const entry = buildEntry({ id: 100, photos: [] }); const detail = buildJourneyDetail({ id: 50, entries: [entry] }); diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index 2c128b52e..83d0c2875 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -152,6 +152,29 @@ interface JourneyState { clear: () => void } +/** + * The gallery rows behind pictures that were uploaded onto an entry. + * + * A picture put on an entry is in the gallery too, because that is where the + * row lives: the entry only holds a link to it. The upload route answers with + * the entry's view of that row and, on the paths that have it, the gallery's + * as well; this derives the missing ones, so a picture added from anywhere + * shows up wherever the gallery is read rather than after the journey has been + * fetched again. Rows already in the gallery are skipped, since a retry can + * hand back a picture that is in it. + */ +function galleryRowsFor( + journey: JourneyDetail, + uploaded: JourneyPhoto[], + answered: GalleryPhoto[] | undefined, +): GalleryPhoto[] { + const known = new Set([...(journey.gallery || []), ...(answered || [])].map(p => p.id)) + const derived = uploaded + .filter(p => !known.has(p.id)) + .map(({ entry_id: _entry, ...rest }) => ({ ...rest, journey_id: journey.id })) + return [...(answered || []), ...derived] +} + export const useJourneyStore = create((set, get) => ({ journeys: [], current: null, @@ -269,22 +292,7 @@ export const useJourneyStore = create((set, get) => ({ const photos: JourneyPhoto[] = data.photos || [] set(s => { if (!s.current) return s - const journeyId = s.current.id - const known = new Set((s.current.gallery || []).map(p => p.id)) - /* - * A picture put on an entry is in the gallery too, because that is - * where the row lives: the entry only holds a link to it. The route - * answers with the entry's view of the row and, when it has one, the - * gallery's; the derived rows below are for the case where it does - * not, so that a picture added here shows up wherever the gallery is - * read without waiting for the journey to be fetched again. - */ - const gallery: GalleryPhoto[] = [ - ...(data.gallery || []), - ...photos - .filter(p => !known.has(p.id) && !(data.gallery || []).some(g => g.id === p.id)) - .map(({ entry_id: _entry, ...rest }) => ({ ...rest, journey_id: journeyId })), - ] + const gallery = galleryRowsFor(s.current, photos, data.gallery) return { current: { ...s.current, diff --git a/client/tests/unit/studio/canvasFileDrop.test.tsx b/client/tests/unit/studio/canvasFileDrop.test.tsx new file mode 100644 index 000000000..d3b5caa4f --- /dev/null +++ b/client/tests/unit/studio/canvasFileDrop.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { bookPageSetupSchema, bookSpreadSchema } from '@trek/shared' +import { fireEvent, render } from '../../helpers/render' +import { StudioCanvas } from '../../../src/components/Studio/StudioCanvas' +import { useStudioStore } from '../../../src/store/studioStore' + +/** + * Pictures let go over the sheet itself (#2064). + * + * A book is where you notice the photograph that never made it into the + * journal, and the shortest way to put it on a page is to drop it there. The + * canvas already took a drag from the content browser, which arrives as an id; + * a drag from the desktop arrives as files, and the two must not be confused + * for each other. + * + * A viewer gets neither: without a handler the browser keeps its default and + * opens the picture in a tab, which is better than a page that swallows a drop + * and does nothing with it. + */ + +const page = bookPageSetupSchema.parse({}) +const spread = bookSpreadSchema.parse({ id: 'sp-1', elements: [] }) + +const file = () => new File(['x'], 'shrine.jpg', { type: 'image/jpeg' }) + +/** A DataTransfer jsdom will hand back unchanged, files and types included. */ +const transfer = (files: File[], extraTypes: string[] = []) => ({ + types: [...(files.length ? ['Files'] : []), ...extraTypes], + files, + getData: () => '', + setData: () => {}, + dropEffect: '', + effectAllowed: '', +}) + +function mount(onDropFiles?: (files: File[], at: { x: number; y: number }, id: string | null) => void) { + render( + , + ) + return document.querySelector('.st-stage') as HTMLElement +} + +beforeEach(() => { + useStudioStore.setState({ selection: [] }) +}) + +describe('a file dropped on the sheet', () => { + it('says what letting go will do while the drag is over the page', () => { + const stage = mount(vi.fn()) + + fireEvent.dragOver(stage, { dataTransfer: transfer([file()]) }) + + expect(document.querySelector('.st-file-veil')?.textContent).toBe('let go to add them') + }) + + it('takes the veil away again when the drag leaves', () => { + const stage = mount(vi.fn()) + fireEvent.dragOver(stage, { dataTransfer: transfer([file()]) }) + + fireEvent.dragLeave(stage) + + expect(document.querySelector('.st-file-veil')).toBeNull() + }) + + it('hands the files over with the point they landed on', () => { + const onDropFiles = vi.fn() + const stage = mount(onDropFiles) + const dropped = [file(), file()] + + fireEvent.drop(stage, { dataTransfer: transfer(dropped) }) + + expect(onDropFiles).toHaveBeenCalledTimes(1) + const [files, at, target] = onDropFiles.mock.calls[0] + expect(files).toEqual(dropped) + expect(at).toEqual({ x: expect.any(Number), y: expect.any(Number) }) + expect(target).toBeNull() + }) + + /* + * The panel's own drag carries an id and means "this picture, from the + * journey". A file drag carrying it as well would be neither, so the id + * wins: it is the one the canvas can place without a round trip. + */ + it('leaves a drag from the content browser to the path that places an id', () => { + const onDropFiles = vi.fn() + const stage = mount(onDropFiles) + + fireEvent.drop(stage, { + dataTransfer: { ...transfer([file()], ['application/x-trek-photo']), getData: () => '42' }, + }) + + expect(onDropFiles).not.toHaveBeenCalled() + }) + + it('is not a drop target at all for a viewer', () => { + const stage = mount(undefined) + + fireEvent.dragOver(stage, { dataTransfer: transfer([file()]) }) + fireEvent.drop(stage, { dataTransfer: transfer([file()]) }) + + expect(document.querySelector('.st-file-veil')).toBeNull() + }) +}) diff --git a/client/tests/unit/studio/photoFilter.test.ts b/client/tests/unit/studio/photoFilter.test.ts new file mode 100644 index 000000000..66e6c60d2 --- /dev/null +++ b/client/tests/unit/studio/photoFilter.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest' +import type { JourneySource } from '../../../src/components/Studio/StudioSidebar' +import { + emptyKeyFor, matchingEntries, matchingPhotos, photosFor, type PhotoFilter, +} from '../../../src/components/Studio/photoFilter' + +/** + * Narrowing the content browser to one day's pictures (#2064). + * + * A journey holds hundreds of photographs and most pages are about one entry, + * so the browser's real question is "which day", not "which words". These are + * the four rules that answer it, tested against literal arrays rather than + * through a rendered panel: which entry a picture belongs to comes off the + * journal's own junction, not off anything written on the picture. + */ + +const source: JourneySource = { + entries: [ + { + id: 1, title: 'Reykjavík', story: 'walked all morning', location: 'Reykjavík', + date: '2026-06-03', lat: null, lng: null, mood: null, weather: null, + pros: [], cons: [], photoIds: [11, 12], + }, + { + id: 2, title: 'Dettifoss', story: null, location: 'Dettifoss', + date: '2026-06-10', lat: null, lng: null, mood: null, weather: null, + pros: ['loud'], cons: [], photoIds: [13], + }, + { + id: 3, title: 'A day with no pictures', story: null, location: null, + date: '2026-06-11', lat: null, lng: null, mood: null, weather: null, + pros: [], cons: [], photoIds: [], + }, + ], + photos: [ + { photoId: 11, caption: 'the harbour', entryIds: [1] }, + { photoId: 12, caption: null, entryIds: [1] }, + { photoId: 13, caption: null, entryIds: [2] }, + { photoId: 14, caption: 'nobody wrote about this one', entryIds: [] }, + ], + photoEntries: { 11: 'reykjavík reykjavík', 12: 'reykjavík reykjavík', 13: 'dettifoss dettifoss' }, +} + +const ids = (filter: PhotoFilter) => photosFor(source, filter).map(p => p.photoId) + +describe('which pictures a filter admits', () => { + it('admits everything by default', () => { + expect(ids({ kind: 'all' })).toEqual([11, 12, 13, 14]) + }) + + it('admits one entry\'s pictures', () => { + expect(ids({ kind: 'entry', id: 1 })).toEqual([11, 12]) + }) + + it('admits the pictures no entry holds', () => { + expect(ids({ kind: 'loose' })).toEqual([14]) + }) + + /* A picture can hang on two days; it belongs to both of their pages. */ + it('admits a picture linked to more than one entry under each of them', () => { + const shared: JourneySource = { + ...source, + photos: [{ photoId: 20, caption: null, entryIds: [1, 2] }], + } + expect(photosFor(shared, { kind: 'entry', id: 1 }).map(p => p.photoId)).toEqual([20]) + expect(photosFor(shared, { kind: 'entry', id: 2 }).map(p => p.photoId)).toEqual([20]) + }) + + it('keeps what was just uploaded in the order it was sent, not the gallery\'s', () => { + expect(ids({ kind: 'recent', photoIds: [13, 11] })).toEqual([13, 11]) + }) + + it('passes over an id the gallery no longer has', () => { + expect(ids({ kind: 'recent', photoIds: [13, 999] })).toEqual([13]) + }) +}) + +describe('the words, over what the filter left', () => { + it('matches a caption', () => { + expect(matchingPhotos(source.photos, source, 'harbour').map(p => p.photoId)).toEqual([11]) + }) + + /* Most photographs carry no words at all, so the entry's words count too. */ + it('matches on the entry a wordless picture belongs to', () => { + expect(matchingPhotos(source.photos, source, 'dettifoss').map(p => p.photoId)).toEqual([13]) + }) + + it('leaves the list alone when nothing was typed', () => { + expect(matchingPhotos(source.photos, source, ' ')).toHaveLength(4) + }) + + it('narrows within the filter rather than across it', () => { + const admitted = photosFor(source, { kind: 'entry', id: 1 }) + expect(matchingPhotos(admitted, source, 'dettifoss')).toEqual([]) + }) + + it('searches an entry by its title, its story, its place and its verdict', () => { + expect(matchingEntries(source, 'morning').map(e => e.id)).toEqual([1]) + expect(matchingEntries(source, 'loud').map(e => e.id)).toEqual([2]) + expect(matchingEntries(source, '')).toHaveLength(3) + }) +}) + +describe('why the grid is empty', () => { + it('says the journey has none when it has none', () => { + const bare = { ...source, photos: [] } + expect(emptyKeyFor(bare, { kind: 'all' }, [])).toBe('journey.studio.noPhotos') + }) + + it('says the words matched nothing when the filter did admit something', () => { + expect(emptyKeyFor(source, { kind: 'entry', id: 1 }, photosFor(source, { kind: 'entry', id: 1 }))) + .toBe('journey.studio.noMatches') + }) + + it('says the entry has none when the entry has none', () => { + expect(emptyKeyFor(source, { kind: 'entry', id: 3 }, [])).toBe('journey.studio.noEntryPhotos') + }) + + it('says every picture belongs to an entry when none is loose', () => { + expect(emptyKeyFor(source, { kind: 'loose' }, [])).toBe('journey.studio.noLoosePhotos') + }) + + it('falls back to the words for a filter with nothing of its own to say', () => { + expect(emptyKeyFor(source, { kind: 'recent', photoIds: [] }, [])).toBe('journey.studio.noMatches') + }) +}) diff --git a/client/tests/unit/studio/studioUpload.test.ts b/client/tests/unit/studio/studioUpload.test.ts new file mode 100644 index 000000000..c025ebb20 --- /dev/null +++ b/client/tests/unit/studio/studioUpload.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useJourneyStore } from '../../../src/store/journeyStore' +import { uploadStudioPhotos } from '../../../src/components/Studio/studioUpload' + +/** + * Pictures into the journey from inside Studio (#2064). + * + * The two things worth pinning are where a picture goes and what happens to + * what is not a picture. The target follows the caller: an entry when the + * content browser is filtered to one, the gallery otherwise, and both write + * through the journey store so the journal sees the upload the moment the + * panel does. A video is left out rather than refused, because a drop that + * carries a clip usually carries photographs as well and failing the lot over + * one file would be the wrong lesson. + */ + +vi.mock('../../../src/utils/convertHeic', () => ({ + // The real one decodes HEIC through a worker. What matters here is that the + // files reach the store, not how they were converted on the way. + normalizeImageFiles: vi.fn(async (files: File[]) => files), +})) + +const jpeg = (name = 'shrine.jpg') => new File(['x'], name, { type: 'image/jpeg' }) +const mp4 = () => new File(['x'], 'clip.mp4', { type: 'video/mp4' }) + +const uploadPhotos = vi.fn() +const uploadGalleryPhotos = vi.fn() + +beforeEach(() => { + uploadPhotos.mockReset().mockResolvedValue({ succeeded: [{ photo_id: 11 }, { photo_id: 12 }], failed: [] }) + uploadGalleryPhotos.mockReset().mockResolvedValue({ succeeded: [{ photo_id: 21 }], failed: [] }) + useJourneyStore.setState({ uploadPhotos, uploadGalleryPhotos } as never) +}) + +describe('uploadStudioPhotos', () => { + it('puts the pictures on the entry it was given, and answers with their ids', async () => { + const outcome = await uploadStudioPhotos(50, [jpeg(), jpeg('two.jpg')], 7) + + expect(uploadPhotos).toHaveBeenCalledTimes(1) + expect(uploadPhotos.mock.calls[0][0]).toBe(7) + expect(uploadGalleryPhotos).not.toHaveBeenCalled() + expect(outcome).toEqual({ photoIds: [11, 12], failed: 0, skippedVideos: 0 }) + }) + + it('puts them in the gallery when no entry is named', async () => { + const outcome = await uploadStudioPhotos(50, [jpeg()], null) + + expect(uploadGalleryPhotos.mock.calls[0][0]).toBe(50) + expect(uploadPhotos).not.toHaveBeenCalled() + expect(outcome.photoIds).toEqual([21]) + }) + + it('counts what did not arrive', async () => { + uploadGalleryPhotos.mockResolvedValue({ succeeded: [{ photo_id: 21 }], failed: [jpeg('lost.jpg')] }) + + expect(await uploadStudioPhotos(50, [jpeg(), jpeg('lost.jpg')], null)) + .toEqual({ photoIds: [21], failed: 1, skippedVideos: 0 }) + }) + + it('leaves a video out and sends the photographs beside it anyway', async () => { + const outcome = await uploadStudioPhotos(50, [jpeg(), mp4()], null) + + expect(uploadGalleryPhotos.mock.calls[0][1]).toHaveLength(1) + expect(outcome.skippedVideos).toBe(1) + expect(outcome.photoIds).toEqual([21]) + }) + + it('sends nothing at all when the drop was only videos', async () => { + const outcome = await uploadStudioPhotos(50, [mp4(), mp4()], null) + + expect(uploadGalleryPhotos).not.toHaveBeenCalled() + expect(outcome).toEqual({ photoIds: [], failed: 0, skippedVideos: 2 }) + }) + + it('reports progress to whoever asked for it', async () => { + const onProgress = vi.fn() + await uploadStudioPhotos(50, [jpeg()], null, onProgress) + + expect(uploadGalleryPhotos.mock.calls[0][2]).toEqual({ onProgress }) + }) +})