import { useEffect, useMemo, useRef, useState } from 'react' import { Camera, Check, ChevronDown, ImagePlus, ChevronUp, Compass, Copy, FileDown, Files, ImageIcon, LayoutTemplate, Plus, Search, Shapes, Trash2, Upload, X, } from 'lucide-react' import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared' import { useElementSize } from '../../hooks/useElementSize' import { useStudioStore } from '../../store/studioStore' import { formatDate } from '../../utils/formatters' import { useToast } from '../shared/Toast' import { SpreadFold, SpreadView } from './SpreadView' import { folioLabel, foliosOf } from './bookSheets' import { photoSrc } from './bookRender' import { BOOK_COVER_TEMPLATES as COVER_TEMPLATES, BOOK_TEMPLATES as TEMPLATES, applyBookTemplate as applyTemplate, bookElementId as uid, } from '@trek/shared' import { formatBookCoords, formatBookDate, type CoordFormat } from './entryText' import { coordValue } from './resolveBindings' import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants' import { PanelHead as Head } from './StudioPanelHead' import { StudioElementsPanel } from './StudioElementsPanel' import { StudioTravelPanel } from './StudioTravelPanel' import { StudioSavedLayouts } from './StudioSavedLayouts' 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' /** * The left side of Studio: a narrow rail of sections and one wide panel showing * whichever is open. * * The shape is Canva's and it is the right one here for a plain reason — a book * has four completely different kinds of thing you reach for (which page, what * from the journey, what to draw, what arrangement), and putting them behind * tabs in a single 200px column would make each of them cramped. An icon rail * costs 52px and gives the panel the room a photo browser actually needs. */ type Section = 'pages' | 'content' | 'elements' | 'travel' | 'templates' export interface JourneySource { entries: { id: number title: string | null story: string | null location: string | null date: string | null /** Where the stop is, when the entry recorded one. Null on a written-only entry. */ lat: number | null lng: number | null mood: string | null weather: string | null pros: string[] cons: string[] /** The pictures attached to this entry, as trek_photos ids. */ photoIds: number[] }[] /** `entryIds` is empty for a picture that sits only in the gallery. */ photos: { photoId: number; caption?: string | null; entryIds: number[] }[] /** photoId to the words of the entry it belongs to, lower-cased. */ photoEntries: Record } /** The translator, with the parameters some of the strings here take. */ export type StudioT = (k: string, params?: Record) => string /** * How wide a page preview may be drawn. * * Page thumbnails and layout cards are drawn at a `scale()` rather than laid * out by CSS — the sheet inside them is sized in millimetres — so they need an * actual pixel width, and it is **measured** rather than derived. * * Deriving it was tried twice and was wrong twice. The panel width, the scroll * padding and the button's own border and padding are all knowable from * studio.css; the scrollbar is not. It appears only once the list is long * enough, takes a width that depends on the platform, and the arithmetic that * ignored it produced a card overhanging its own selection outline. Measuring * the row the cards sit in gets all four contributions at once and cannot drift * when any of them changes. */ const MIN_PREVIEW_W = 120 /** The button's border and padding, which the row's width still includes. */ const THUMB_CHROME = (2 + 3) * 2 export function StudioSidebar({ page, pxPerMm, bookView, source, stats, path, t, locale, canEdit, onUpload, onToggleStop, providers = [], onBrowseProvider, }: { page: BookPageSetup pxPerMm: number bookView: boolean source: JourneySource /** What the journey adds up to, for the travel elements. Null while loading or on failure. */ stats: JourneyStats | null /** The roads the trip took, for a map that draws them. Empty when it has none. */ path: [number, number][][] t: StudioT locale: string /** False for a viewer: no upload, no switching stops, nothing that writes. */ canEdit: boolean /** Pictures into the gallery, or into one entry. See studioUpload.ts. */ onUpload: StudioUploader /** Switch a stop on or off in the figures. Resolves false when it did not land. */ onToggleStop: (entryId: number, excluded: boolean) => Promise /** Photo providers connected for this user, each a way in next to the upload (#2271). */ providers?: { id: string; name: string }[] /** Opens the provider picker; answers the photo ids that landed, none when closed. */ onBrowseProvider?: (provider: string, entryId: number | null) => Promise }) { const [section, setSection] = useState
('pages') const SECTIONS: { id: Section; icon: typeof Files; labelKey: string }[] = [ { id: 'pages', icon: Files, labelKey: 'journey.studio.pages' }, { id: 'content', icon: ImageIcon, labelKey: 'journey.studio.content' }, { id: 'elements', icon: Shapes, labelKey: 'journey.studio.elements' }, { id: 'travel', icon: Compass, labelKey: 'journey.studio.travel' }, { id: 'templates', icon: LayoutTemplate, labelKey: 'journey.studio.templates' }, ] return ( <> ) } function PagesPanel({ page, pxPerMm, bookView, t, }: { page: BookPageSetup; pxPerMm: number; bookView: boolean; t: (k: string) => string }) { const doc = useStudioStore(s => s.doc) const active = useStudioStore(s => s.activeSpread) const setActive = useStudioStore(s => s.setActiveSpread) const addSpread = useStudioStore(s => s.addSpread) const insertSpread = useStudioStore(s => s.insertSpread) const duplicateSpread = useStudioStore(s => s.duplicateSpread) const removeSpread = useStudioStore(s => s.removeSpread) const moveSpread = useStudioStore(s => s.moveSpread) const canEditSpread = useStudioStore(s => s.canEditSpread) const spreads = doc?.spreads ?? [] const list = useElementSize() const THUMB_W = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME) /** Where a new spread would land: after the last inner one. */ const lastInner = spreads.reduce((last, sp, i) => (sp.role === 'inner' ? i : last), -1) const fileInput = useRef(null) const [importError, setImportError] = useState(null) return ( <>
{spreads.map((sp, i) => { const single = sp.role !== 'inner' const wMm = single ? page.pageWidth : page.pageWidth * 2 // The same component at a smaller scale, not a second drawing of the // page — a spread can never look different here than on the sheet. const scale = THUMB_W / (wMm * pxPerMm) const editable = canEditSpread(i) return (
{/* The actions sit on the thumbnail rather than in a menu bar of their own: what you are acting on is the page you are pointing at, and a toolbar somewhere else would need you to select first and act second. The cover and the back cover have no actions — a book has exactly one of each, and moving or deleting them is not a thing anyone means to do. */} {editable && (
)} {/* Insert between two spreads, where the new one will go — a single "add" at the end cannot express "another page here". */} {editable && ( )}
) })}
{/* Two ways to get a new page: an empty one, or somebody else's design. Side by side because they answer the same question — the import is not a setting buried in a menu, it is the other half of "add". */}
{ const file = e.target.files?.[0] // Cleared straight away so choosing the same file twice in a row // still fires a change event the second time. e.target.value = '' if (!file) return setImportError(null) // Checked before reading rather than after: the point of a limit on // a file someone else wrote is not to parse it in the first place. if (file.size > MAX_SPREAD_FILE_BYTES) { setImportError(t('journey.studio.importSpreadFailed')); return } try { const spread = importSpread(JSON.parse(await file.text()), page) if (!spread) { setImportError(t('journey.studio.importSpreadFailed')); return } insertSpread(lastInner, spread) } catch { setImportError(t('journey.studio.importSpreadFailed')) } }} /> {importError &&

{importError}

}
) } /** * The journey's own material. * * This is what separates a book maker from a drawing program: the pictures and * the words are already written, and the job is putting them on pages. Clicking * an item drops it on the current spread, centred, at a sensible size. * * Pictures can also come in here. A book is where you notice the one that * never made it into the journal, and going back to the journey to add it, then * finding the page again, was the one round trip Studio still asked for. What * comes in goes through the journey store like any other upload, so the journal * sees it the moment the panel does. */ function ContentPanel({ source, page, t, locale, canEdit, onUpload, providers = [], onBrowseProvider, }: { source: JourneySource page: BookPageSetup t: StudioT locale: string canEdit: boolean onUpload: StudioUploader providers?: { id: string; name: string }[] onBrowseProvider?: (provider: string, entryId: number | null) => Promise }) { const [tab, setTab] = useState<'photos' | 'text'>('photos') const [query, setQuery] = useState('') const [filter, setFilter] = useState({ kind: 'all' }) const toast = useToast() 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] 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 state before either had set it. */ const sending = useRef(false) const [dropOver, setDropOver] = useState(false) /** * Into the entry the browser is filtered to, otherwise into the gallery. The * filter is the one thing on screen that names an entry, so it is also the * one thing that can say where a picture belongs; the line under it says so * before the press. Afterwards the new pictures get a filter of their own, * because the gallery sorts them to its end, and twelve pictures that arrive * out of sight read as twelve that did not arrive. */ const send = async (files: File[]) => { if (sending.current || !files.length) return sending.current = true setUpload({ done: 0, total: files.length }) try { const entryId = filter.kind === 'entry' ? filter.id : null const sent = await onUpload(files, entryId, p => setUpload({ done: p.done, total: p.total })) if (sent.photoIds.length) { setFilter({ kind: 'recent', photoIds: sent.photoIds }) setQuery('') toast.success(t('journey.photosUploaded', { count: sent.photoIds.length })) } } finally { sending.current = false setUpload(null) } } /** * From a photo provider (#2271): into the same place an upload goes, and the * new pictures shown the same way, under their own filter. */ const pickFromProvider = async (provider: string) => { if (!onBrowseProvider) return const photoIds = await onBrowseProvider(provider, filter.kind === 'entry' ? filter.id : null) if (photoIds.length) { setFilter({ kind: 'recent', photoIds }) setQuery('') } } /* * The whole scrolling area takes a drop, not just the dashed cell: a drag * from the desktop arrives with the pointer wherever it arrives, and a target * seventy pixels square is one you miss. Only a file drag is claimed; the * panel's own photo drags carry another type and go to the sheet. */ const dropZone = canEdit && tab === 'photos' ? { onDragOver: (e: React.DragEvent) => { if (!e.dataTransfer.types.includes('Files')) return e.preventDefault() e.dataTransfer.dropEffect = 'copy' setDropOver(true) }, onDragLeave: (e: React.DragEvent) => { // Moving onto a child of the box is not leaving the box. if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropOver(false) }, onDrop: (e: React.DragEvent) => { const files = Array.from(e.dataTransfer.files ?? []) if (!files.length) return e.preventDefault() setDropOver(false) void send(files) }, } : {} const centre = (w: number, h: number) => { const W = spread && spread.role !== 'inner' ? page.pageWidth : page.pageWidth * 2 return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h } } const dropPhoto = (photoId: number) => { const side = Math.min(page.pageWidth, page.pageHeight) * 0.55 addElement(active, { id: uid('p'), kind: 'photo', frame: centre(side, side * 0.75), rotation: 0, opacity: 1, locked: false, photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none', } as BookElement) } /** * One piece of the entry, dropped on the page and still attached to it. * * `format` is what separates a place name from a set of coordinates: both * read the entry's location, and which of the two gets printed is a property * of the binding rather than a second source — see the contract. `width` * exists because a line of coordinates arriving inside a full-column * selection rectangle looks like a bug; only the short marks pass it. */ const dropText = ( value: string, size: number, weight: 400 | 700, entryId: number, kind: 'entry.title' | 'entry.story' | 'entry.location' | 'entry.date', opts: { format?: CoordFormat; width?: number; value?: string } = {}, ) => { const w = opts.width ?? page.pageWidth - 32 addElement(active, { id: uid('t'), kind: 'text', frame: centre(w, size * 0.4 + 10), rotation: 0, opacity: 1, locked: false, text: value, font: 'sans', size, weight, italic: false, align: 'left', leading: weight === 700 ? 1.1 : 1.55, tracking: weight === 700 ? -0.02 : 0, color: '#1a1a1a', binding: { source: kind, entryId, ...(opts.format ? { format: opts.format } : {}), // The fact behind the words, for the marks whose words are formatted — // see `value` in the contract. ...(opts.value ? { value: opts.value } : {}), }, overridden: false, } as BookElement) } const base = { rotation: 0, opacity: 1, locked: false, font: 'sans' as const, color: '#1a1a1a', accent: '#c2410c', textScale: 1, stale: false, } /** * A mood or a weather mark, drawn with the icon the journey page uses. * * `code` holds the key rather than the label, so the element carries what the * entry recorded and the renderer decides how to draw it — which is what lets * the icon and the palette come from one place. */ const dropMark = (variant: 'mood' | 'weather', code: string, label: string) => addElement(active, { ...base, id: uid('bd'), kind: 'badge', autoColor: true, // Set here as well as in the contract: this object is cast, not parsed. showIcon: true, showLabel: true, autoIconColor: true, iconColor: '#111111', frame: centre(page.pageWidth * 0.22, page.pageHeight * 0.062), variant, text: label, sub: '', code, style: 'plain', } as BookElement) /** Both columns of an entry's pros and cons, as one element. */ const dropProsCons = (pros: string[], cons: string[]) => { const rows = Math.max(pros.length, cons.length, 1) addElement(active, { ...base, id: uid('li'), kind: 'list', // Matches what ListView needs for `rows` lines at its largest line size, // so the element arrives filled rather than with a third of it empty. frame: centre(page.pageWidth * 0.72, Math.min(page.pageHeight * 0.6, 6 * (0.86 + 1.7 * rows))), items: [ ...pros.map(text => ({ text, tone: 'pro' as const })), ...cons.map(text => ({ text, tone: 'con' as const })), ], layout: 'columns', showMarks: true, proLabel: t('journey.editor.pros'), conLabel: t('journey.editor.cons'), } as BookElement) } return ( <>
setQuery(e.target.value)} placeholder={t('journey.studio.searchContent')} aria-label={t('journey.studio.searchContent')} spellCheck={false} /> {query && ( )}
{/* Which entry, as 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. The chip opens the same popover the bar uses for formats, hung from its left edge because the panel has no room to the right of it. */} {tab === 'photos' && ( )}
{tab === 'photos' ? ( <> {canEdit && (

{t(filter.kind === 'entry' ? 'journey.studio.uploadToEntry' : 'journey.studio.uploadToGallery')}

)}
{/* The first cell rather than a button above the grid: it is the size and shape of what it produces, and it stays where the eye already is. Disabled, not hidden, while a batch is on its way, so the count has somewhere to be read. */} {canEdit && ( )} {canEdit && onBrowseProvider && providers.map(p => ( ))} {photos.map(p => ( ))} {!photos.length &&

{t(emptyKey)}

}
{canEdit && ( { const files = Array.from(e.target.files ?? []) // Cleared straight away, as the spread import does, so the // same pictures can be chosen twice in a row. e.target.value = '' void send(files) }} /> )} ) : (
{entries.map(e => (
{e.title || e.location || t('journey.studio.untitled')}
{(e.date || e.location) && (
{/* Two facts, two badges. Joined by a dot they read as one string and the eye has to parse where the date ends. The date follows the app's language, like every other date in TREK — see utils/formatters.ts. */} {e.date && {formatDate(e.date, locale) ?? e.date}} {e.location && {e.location}}
)}
{e.title && ( )} {e.story && ( )} {e.location && ( )} {/* The day and the point. Both are set the way the rest of the book sets them — the long date, coordinates in degrees — so a mark placed here and one the auto layout placed read alike. */} {e.date && ( )} {e.lat != null && e.lng != null && ( )} {/* What the entry recorded besides its story. Each chip shows the value rather than the field name — "Sunny" tells you what you are about to place; "Weather" does not. */} {e.mood && MOOD_CONFIG[e.mood] && ( )} {e.weather && WEATHER_CONFIG[e.weather] && ( )} {(e.pros.length > 0 || e.cons.length > 0) && ( )} {/* Not a drop like the others: this one takes you to the entry's pictures. The words of a day and its pictures were two tabs apart with nothing joining them, and the day is how anyone thinks of the pictures. */} {e.photoIds.length > 0 && ( )}
))} {!entries.length &&

{t('journey.studio.noMatches')}

}
)}
) } /** * Layouts for the current spread. * * Each card draws the arrangement itself rather than naming it — "hero + story" * means nothing until you have seen it, and a diagram is faster to read than a * label anyway. */ function TemplatesPanel({ page, pxPerMm, t, onOpenContent, }: { page: BookPageSetup; pxPerMm: number; t: (k: string) => string; onOpenContent: () => void }) { const doc = useStudioStore(s => s.doc) const active = useStudioStore(s => s.activeSpread) const commit = useStudioStore(s => s.commit) const spread = doc?.spreads[active] const list = useElementSize() const CARD_W = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME) const doc2 = doc const cardIsSingle = (doc2?.spreads[active]?.role ?? 'inner') !== 'inner' // A cover card is one page wide, not two — scaling it as a spread would draw // the layout at half size in the left half of the card. const scale = useMemo( () => CARD_W / (page.pageWidth * (cardIsSingle ? 1 : 2) * pxPerMm), [CARD_W, page.pageWidth, pxPerMm, cardIsSingle], ) if (!spread) return null const single = spread.role !== 'inner' /* * The cover has its own set. It used to have none — the panel said covers * are designed by hand, which was true and unhelpful: the cover is the page * people care most about and the one they had no starting point for. */ const templates = single ? COVER_TEMPLATES : TEMPLATES return ( <>
{(
{templates.map(tpl => { const slots = tpl.build(page) return ( ) })}
)}
) }