refactor(studio): split the content panel and cover the new paths

Three things Sonar was right about. The content panel had grown a filter, a
menu and an upload into one function of thirty-two branches: the filtering
is four pure rules in photoFilter.ts, the row that drives it is its own
component, and what is left in the panel is the grid and the drop zone. The
gallery rows behind an entry upload move out of the store's setter into a
named function, since five levels of nesting is a place bugs hide. And the
ids for placed elements come from getRandomValues rather than Math.random,
in one module instead of four copies of the same line: two editors minting
the same id for two elements is one of them losing their work.

The new paths carry tests now, which is what the gate was really asking
for: the filter's rules, the ids, the upload helper, the store's derived
gallery rows, the canvas file drop, and the shell's upload, drop and stop
switch.
This commit is contained in:
Maurice
2026-09-03 10:38:36 +02:00
committed by Maurice
parent b383230928
commit 15f22058e9
13 changed files with 829 additions and 204 deletions
@@ -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 },
@@ -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<HTMLDivElement>(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 (
<div className="st-filter-row">
<div className="st-picker" ref={box}>
<span className={`st-chip st-filter-chip ${filter.kind !== 'all' ? 'is-on' : ''}`}>
<button type="button"
onClick={() => setOpen(o => !o)}
aria-haspopup="menu"
aria-expanded={open}
title={t('journey.studio.filterPhotos')}
>
<span className="st-filter-name">{label}</span>
<em>{admitted}</em>
<ChevronDown size={12} style={{ opacity: .5 }} />
</button>
{filter.kind !== 'all' && (
<button type="button" onClick={() => setFilter({ kind: 'all' })} aria-label={t('common.clear')}>
<X size={12} />
</button>
)}
</span>
{open && (
<div className="st-menu" role="menu">
<FilterItem
on={filter.kind === 'all'}
label={t('journey.studio.filterAll')}
count={source.photos.length}
onPick={() => pick({ kind: 'all' })}
/>
<FilterItem
on={filter.kind === 'loose'}
label={t('journey.studio.filterLoose')}
count={looseCount}
onPick={() => pick({ kind: 'loose' })}
/>
{withPhotos.length > 0 && <div className="st-menu-sep" />}
{withPhotos.map(e => (
<FilterItem
key={e.id}
on={filter.kind === 'entry' && filter.id === e.id}
label={nameOf(e)}
// The day, and the place when the title took the top line.
dim={[e.date ? formatDate(e.date, locale) ?? e.date : null, e.title ? e.location : null]
.filter(Boolean).join(', ')}
count={e.photoIds.length}
onPick={() => pick({ kind: 'entry', id: e.id })}
/>
))}
</div>
)}
</div>
{offerThisPage && (
<button type="button" className="st-chip" onClick={() => setFilter({ kind: 'entry', id: pageEntry.id })}>
{t('journey.studio.filterThisPage')}
</button>
)}
</div>
)
}
/** 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 (
<button type="button"
role="menuitemradio"
aria-checked={on}
className={`st-menu-item ${on ? 'is-active' : ''}`}
onClick={onPick}
>
<span className="st-menu-text">
<span className="st-menu-name">{label}</span>
{dim && <span className="st-menu-dim">{dim}</span>}
</span>
<span className="st-menu-dim st-filter-count">{count}</span>
{on && <Check size={14} />}
</button>
)
}
+16 -183
View File
@@ -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, string | number>) => 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<PhotoFilter>({ 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<HTMLDivElement>(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<HTMLInputElement>(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' && (
<div className="st-filter-row">
<div className="st-picker" ref={menuBox}>
<span className={`st-chip st-filter-chip ${filter.kind !== 'all' ? 'is-on' : ''}`}>
<button type="button"
onClick={() => setMenuOpen(o => !o)}
aria-haspopup="menu"
aria-expanded={menuOpen}
title={t('journey.studio.filterPhotos')}
>
<span className="st-filter-name">{filterLabel}</span>
<em>{byFilter.length}</em>
<ChevronDown size={12} style={{ opacity: .5 }} />
</button>
{filter.kind !== 'all' && (
<button type="button" onClick={() => setFilter({ kind: 'all' })} aria-label={t('common.clear')}>
<X size={12} />
</button>
)}
</span>
{menuOpen && (
<div className="st-menu" role="menu">
<FilterItem
on={filter.kind === 'all'}
label={t('journey.studio.filterAll')}
count={source.photos.length}
onPick={() => pick({ kind: 'all' })}
/>
<FilterItem
on={filter.kind === 'loose'}
label={t('journey.studio.filterLoose')}
count={looseCount}
onPick={() => pick({ kind: 'loose' })}
/>
{withPhotos.length > 0 && <div className="st-menu-sep" />}
{withPhotos.map(e => (
<FilterItem
key={e.id}
on={filter.kind === 'entry' && filter.id === e.id}
label={nameOf(e)}
// The day, and the place when the title took the top line.
dim={[e.date ? formatDate(e.date, locale) ?? e.date : null, e.title ? e.location : null]
.filter(Boolean).join(', ')}
count={e.photoIds.length}
onPick={() => pick({ kind: 'entry', id: e.id })}
/>
))}
</div>
)}
</div>
{offerThisPage && (
<button type="button" className="st-chip" onClick={() => setFilter({ kind: 'entry', id: pageEntry.id })}>
{t('journey.studio.filterThisPage')}
</button>
)}
</div>
<StudioContentFilter
source={source}
filter={filter}
setFilter={setFilter}
admitted={admitted.length}
pageEntryId={spread?.entryId ?? null}
t={t}
locale={locale}
/>
)}
<div className={`st-panel-scroll ${dropOver ? 'is-drop-over' : ''}`} {...dropZone}>
@@ -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 (
<button type="button"
role="menuitemradio"
aria-checked={on}
className={`st-menu-item ${on ? 'is-active' : ''}`}
onClick={onPick}
>
<span className="st-menu-text">
<span className="st-menu-name">{label}</span>
{dim && <span className="st-menu-dim">{dim}</span>}
</span>
<span className="st-menu-dim st-filter-count">{count}</span>
{on && <Check size={14} />}
</button>
)
}
/**
* Layouts for the current spread.
*
@@ -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.
*
+31
View File
@@ -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)}`
}
@@ -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'
}
@@ -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)
})
})
@@ -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',
+51
View File
@@ -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] });
+24 -16
View File
@@ -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<JourneyState>((set, get) => ({
journeys: [],
current: null,
@@ -269,22 +292,7 @@ export const useJourneyStore = create<JourneyState>((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,
@@ -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(
<StudioCanvas
spread={spread} spreadIndex={0} page={page} zoom={1} pxPerMm={2}
bookView={false} dropLabel="drop" fileDropLabel="let go to add them"
onDropFiles={onDropFiles}
/>,
)
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()
})
})
@@ -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')
})
})
@@ -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 })
})
})