mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
874 lines
37 KiB
TypeScript
874 lines
37 KiB
TypeScript
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<number, string>
|
|
}
|
|
|
|
/** The translator, with the parameters some of the strings here take. */
|
|
export type StudioT = (k: string, params?: Record<string, string | number>) => 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<boolean>
|
|
/** 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<number[]>
|
|
}) {
|
|
const [section, setSection] = useState<Section>('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 (
|
|
<>
|
|
<nav className="st-rail" aria-label={t('journey.studio.sections')}>
|
|
{SECTIONS.map(sec => (
|
|
<button type="button"
|
|
key={sec.id}
|
|
className={`st-rail-btn ${section === sec.id ? 'is-on' : ''}`}
|
|
onClick={() => setSection(sec.id)}
|
|
title={t(sec.labelKey)}
|
|
aria-label={t(sec.labelKey)}
|
|
>
|
|
<sec.icon size={18} strokeWidth={1.7} />
|
|
<span>{t(sec.labelKey)}</span>
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
<aside className="st-panel st-side">
|
|
{section === 'pages' && <PagesPanel page={page} pxPerMm={pxPerMm} bookView={bookView} t={t} />}
|
|
{section === 'content' && (
|
|
<ContentPanel source={source} page={page} t={t} locale={locale} canEdit={canEdit} onUpload={onUpload}
|
|
providers={providers} onBrowseProvider={onBrowseProvider} />
|
|
)}
|
|
{section === 'elements' && <StudioElementsPanel page={page} t={t} />}
|
|
{section === 'travel' && (
|
|
<StudioTravelPanel
|
|
page={page}
|
|
stats={stats}
|
|
path={path}
|
|
t={t}
|
|
locale={locale}
|
|
canEdit={canEdit}
|
|
onToggleStop={onToggleStop}
|
|
/>
|
|
)}
|
|
{section === 'templates' && <TemplatesPanel page={page} pxPerMm={pxPerMm} t={t} onOpenContent={() => setSection('content')} />}
|
|
</aside>
|
|
</>
|
|
)
|
|
}
|
|
|
|
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<HTMLDivElement>()
|
|
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<HTMLInputElement>(null)
|
|
const [importError, setImportError] = useState<string | null>(null)
|
|
|
|
return (
|
|
<>
|
|
<Head label={t('journey.studio.pages')} count={spreads.length} />
|
|
<div className="st-panel-scroll">
|
|
<div className="st-thumbs" ref={list.ref}>
|
|
{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 (
|
|
<div className="st-thumb-row" key={sp.id}>
|
|
<button type="button"
|
|
className={`st-thumb ${i === active ? 'is-active' : ''}`}
|
|
onClick={() => setActive(i)}
|
|
>
|
|
<div className="st-thumb-sheet" style={{ width: THUMB_W, height: page.pageHeight * pxPerMm * scale }}>
|
|
<div
|
|
style={{
|
|
position: 'absolute', left: 0, top: 0,
|
|
width: `${wMm}mm`, height: `${page.pageHeight}mm`,
|
|
transform: `scale(${scale})`, transformOrigin: 'top left',
|
|
}}
|
|
>
|
|
<SpreadView spread={sp} page={page} folios={foliosOf(spreads, i, page.pageNumbers.startAt)} />
|
|
</div>
|
|
{bookView && !single && <SpreadFold page={page} scaled={pxPerMm * scale} />}
|
|
</div>
|
|
<span className="st-thumb-label">
|
|
{sp.role === 'cover' ? t('journey.studio.cover')
|
|
: sp.role === 'back' ? t('journey.studio.backCover')
|
|
: folioLabel(foliosOf(spreads, i, page.pageNumbers.startAt))}
|
|
</span>
|
|
</button>
|
|
|
|
{/*
|
|
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 && (
|
|
<div className="st-thumb-actions">
|
|
<button type="button"
|
|
onClick={() => moveSpread(i, -1)}
|
|
disabled={!canEditSpread(i - 1)}
|
|
title={t('journey.studio.movePageUp')}
|
|
aria-label={t('journey.studio.movePageUp')}
|
|
>
|
|
<ChevronUp size={13} />
|
|
</button>
|
|
<button type="button"
|
|
onClick={() => moveSpread(i, 1)}
|
|
disabled={!canEditSpread(i + 1)}
|
|
title={t('journey.studio.movePageDown')}
|
|
aria-label={t('journey.studio.movePageDown')}
|
|
>
|
|
<ChevronDown size={13} />
|
|
</button>
|
|
<button type="button"
|
|
onClick={() => duplicateSpread(i)}
|
|
title={t('journey.studio.duplicatePage')}
|
|
aria-label={t('journey.studio.duplicatePage')}
|
|
>
|
|
<Copy size={12} />
|
|
</button>
|
|
<button type="button"
|
|
className="is-danger"
|
|
onClick={() => removeSpread(i)}
|
|
title={t('journey.studio.deletePage')}
|
|
aria-label={t('journey.studio.deletePage')}
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Insert between two spreads, where the new one will go — a
|
|
single "add" at the end cannot express "another page here". */}
|
|
{editable && (
|
|
<button type="button"
|
|
className="st-thumb-insert"
|
|
onClick={() => addSpread(i)}
|
|
title={t('journey.studio.addPageAfter')}
|
|
aria-label={t('journey.studio.addPageAfter')}
|
|
>
|
|
<Plus size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/*
|
|
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".
|
|
*/}
|
|
<div className="st-add-row">
|
|
<button type="button" className="st-add-page" onClick={() => addSpread(lastInner)}>
|
|
<Plus size={14} />
|
|
{t('journey.studio.addPage')}
|
|
</button>
|
|
<button type="button"
|
|
className="st-add-page is-import"
|
|
onClick={() => fileInput.current?.click()}
|
|
title={t('journey.studio.importSpreadHint')}
|
|
>
|
|
<FileDown size={14} />
|
|
{t('journey.studio.importSpread')}
|
|
</button>
|
|
</div>
|
|
<input
|
|
ref={fileInput}
|
|
type="file"
|
|
accept=".json,application/json"
|
|
hidden
|
|
onChange={async e => {
|
|
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 && <p className="st-add-error">{importError}</p>}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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<number[]>
|
|
}) {
|
|
const [tab, setTab] = useState<'photos' | 'text'>('photos')
|
|
const [query, setQuery] = useState('')
|
|
const [filter, setFilter] = useState<PhotoFilter>({ 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<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
|
|
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<HTMLDivElement>) => {
|
|
if (!e.dataTransfer.types.includes('Files')) return
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = 'copy'
|
|
setDropOver(true)
|
|
},
|
|
onDragLeave: (e: React.DragEvent<HTMLDivElement>) => {
|
|
// 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<HTMLDivElement>) => {
|
|
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 (
|
|
<>
|
|
<Head label={t('journey.studio.content')} />
|
|
|
|
<div className="st-search">
|
|
<Search size={14} />
|
|
<input
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
placeholder={t('journey.studio.searchContent')}
|
|
aria-label={t('journey.studio.searchContent')}
|
|
spellCheck={false}
|
|
/>
|
|
{query && (
|
|
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
|
|
<X size={13} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="st-tabs">
|
|
<button type="button" className={tab === 'photos' ? 'is-on' : ''} onClick={() => setTab('photos')}>
|
|
{t('journey.studio.photos')} <em>{photos.length}</em>
|
|
</button>
|
|
<button type="button" className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}>
|
|
{t('journey.studio.entries')} <em>{entries.length}</em>
|
|
</button>
|
|
</div>
|
|
|
|
{/*
|
|
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' && (
|
|
<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}>
|
|
{tab === 'photos' ? (
|
|
<>
|
|
{canEdit && (
|
|
<p className="st-hint st-upload-where">
|
|
{t(filter.kind === 'entry' ? 'journey.studio.uploadToEntry' : 'journey.studio.uploadToGallery')}
|
|
</p>
|
|
)}
|
|
<div className="st-photo-grid">
|
|
{/*
|
|
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 && (
|
|
<button type="button"
|
|
className="st-photo-cell is-upload"
|
|
onClick={() => fileInput.current?.click()}
|
|
disabled={!!upload}
|
|
title={t('journey.studio.uploadHint')}
|
|
>
|
|
<Upload size={18} strokeWidth={1.6} />
|
|
<span>
|
|
{upload
|
|
? t('journey.studio.uploading', { done: upload.done, total: upload.total })
|
|
: t('journey.studio.uploadPhotos')}
|
|
</span>
|
|
</button>
|
|
)}
|
|
{canEdit && onBrowseProvider && providers.map(p => (
|
|
<button type="button"
|
|
key={p.id}
|
|
className="st-photo-cell is-upload"
|
|
onClick={() => { void pickFromProvider(p.id) }}
|
|
disabled={!!upload}
|
|
title={t('journey.studio.fromProviderHint', { name: p.name })}
|
|
>
|
|
<ImagePlus size={18} strokeWidth={1.6} />
|
|
<span>{t('journey.studio.fromProvider', { name: p.name })}</span>
|
|
</button>
|
|
))}
|
|
{photos.map(p => (
|
|
<button type="button"
|
|
key={p.photoId}
|
|
className="st-photo-cell"
|
|
onClick={() => dropPhoto(p.photoId)}
|
|
title={t('journey.studio.addToPage')}
|
|
draggable
|
|
onDragStart={e => {
|
|
// HTML5 drag is the right tool for exactly this shape of
|
|
// interaction (one item, from a list, onto a target) and it
|
|
// gives us the thumbnail as the drag image for free. The
|
|
// canvas uses pointer events instead, because free transform
|
|
// needs a live position that a drop event cannot provide.
|
|
e.dataTransfer.setData('application/x-trek-photo', String(p.photoId))
|
|
e.dataTransfer.effectAllowed = 'copy'
|
|
}}
|
|
>
|
|
<img src={photoSrc(p.photoId, false)} alt="" loading="lazy" draggable={false} />
|
|
</button>
|
|
))}
|
|
{!photos.length && <p className="st-hint">{t(emptyKey)}</p>}
|
|
</div>
|
|
{canEdit && (
|
|
<input
|
|
ref={fileInput}
|
|
type="file"
|
|
multiple
|
|
accept="image/*,.heic,.heif"
|
|
hidden
|
|
onChange={e => {
|
|
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)
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="st-entries">
|
|
{entries.map(e => (
|
|
<div key={e.id} className="st-entry">
|
|
<div className="st-entry-head">{e.title || e.location || t('journey.studio.untitled')}</div>
|
|
{(e.date || e.location) && (
|
|
<div className="st-entry-meta">
|
|
{/* 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 && <span className="st-badge">{formatDate(e.date, locale) ?? e.date}</span>}
|
|
{e.location && <span className="st-badge is-quiet">{e.location}</span>}
|
|
</div>
|
|
)}
|
|
<div className="st-row" style={{ marginTop: 7 }}>
|
|
{e.title && (
|
|
<button type="button" className="st-chip" onClick={() => dropText(e.title!, 22, 700, e.id, 'entry.title')}>
|
|
{t('journey.studio.addTitle')}
|
|
</button>
|
|
)}
|
|
{e.story && (
|
|
<button type="button" className="st-chip" onClick={() => dropText(e.story!, 10, 400, e.id, 'entry.story')}>
|
|
{t('journey.studio.addStory')}
|
|
</button>
|
|
)}
|
|
{e.location && (
|
|
<button type="button" className="st-chip" onClick={() => dropText(e.location!, 8, 700, e.id, 'entry.location')}>
|
|
{t('journey.studio.addPlace')}
|
|
</button>
|
|
)}
|
|
{/*
|
|
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 && (
|
|
<button type="button"
|
|
className="st-chip"
|
|
onClick={() => dropText(
|
|
formatBookDate(e.date!, locale), 8, 700, e.id, 'entry.date',
|
|
{ width: page.pageWidth * 0.36, value: e.date! },
|
|
)}
|
|
>
|
|
{t('journey.studio.dateMark')}
|
|
</button>
|
|
)}
|
|
{e.lat != null && e.lng != null && (
|
|
<button type="button"
|
|
className="st-chip"
|
|
onClick={() => dropText(
|
|
formatBookCoords(e.lat!, e.lng!, 'dms'), 8, 700, e.id, 'entry.location',
|
|
{ format: 'dms', width: page.pageWidth * 0.36, value: coordValue(e.lat!, e.lng!) },
|
|
)}
|
|
>
|
|
{t('journey.studio.coordsMark')}
|
|
</button>
|
|
)}
|
|
{/*
|
|
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] && (
|
|
<button type="button"
|
|
className="st-chip"
|
|
onClick={() => dropMark('mood', e.mood!, t(MOOD_CONFIG[e.mood!].label))}
|
|
>
|
|
{t(MOOD_CONFIG[e.mood].label)}
|
|
</button>
|
|
)}
|
|
{e.weather && WEATHER_CONFIG[e.weather] && (
|
|
<button type="button"
|
|
className="st-chip"
|
|
onClick={() => dropMark('weather', e.weather!, t(WEATHER_CONFIG[e.weather!].label))}
|
|
>
|
|
{t(WEATHER_CONFIG[e.weather].label)}
|
|
</button>
|
|
)}
|
|
{(e.pros.length > 0 || e.cons.length > 0) && (
|
|
<button type="button" className="st-chip" onClick={() => dropProsCons(e.pros, e.cons)}>
|
|
{t('journey.studio.addProsCons')}
|
|
<em>{e.pros.length + e.cons.length}</em>
|
|
</button>
|
|
)}
|
|
{/*
|
|
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 && (
|
|
<button type="button"
|
|
className="st-chip"
|
|
onClick={() => { setFilter({ kind: 'entry', id: e.id }); setTab('photos') }}
|
|
>
|
|
<Camera size={12} />
|
|
{t('journey.studio.entryPhotos')}
|
|
<em>{e.photoIds.length}</em>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{!entries.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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<HTMLDivElement>()
|
|
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 (
|
|
<>
|
|
<Head label={t('journey.studio.templates')} count={templates.length} />
|
|
<div className="st-panel-scroll">
|
|
<StudioSavedLayouts page={page} width={CARD_W} pxPerMm={pxPerMm} t={t} />
|
|
{(
|
|
<div className="st-thumbs" ref={list.ref}>
|
|
{templates.map(tpl => {
|
|
const slots = tpl.build(page)
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={tpl.id}
|
|
className="st-thumb"
|
|
onClick={() => {
|
|
let empties = 0
|
|
commit(d => ({
|
|
...d,
|
|
spreads: d.spreads.map((sp, i) => {
|
|
if (i !== active) return sp
|
|
const next = applyTemplate(sp, tpl, page)
|
|
empties = next.elements.filter(e => e.kind === 'photo' && e.photoId == null).length
|
|
return next
|
|
}),
|
|
}))
|
|
// The layout left frames waiting for a picture — so put the
|
|
// pictures within reach instead of making the user go and
|
|
// find the panel that holds them.
|
|
if (empties > 0) onOpenContent()
|
|
}}
|
|
>
|
|
<div
|
|
className="st-thumb-sheet"
|
|
style={{ width: CARD_W, height: page.pageHeight * pxPerMm * scale }}
|
|
>
|
|
{slots.map((slot, i) => (
|
|
<span
|
|
key={i}
|
|
className={`st-tpl-slot is-${slot.kind}`}
|
|
style={{
|
|
left: slot.frame.x * pxPerMm * scale,
|
|
top: slot.frame.y * pxPerMm * scale,
|
|
width: slot.frame.w * pxPerMm * scale,
|
|
height: Math.max(2, slot.frame.h * pxPerMm * scale),
|
|
}}
|
|
/>
|
|
))}
|
|
{!single && (
|
|
<span
|
|
className="st-tpl-fold"
|
|
style={{ left: page.pageWidth * pxPerMm * scale }}
|
|
/>
|
|
)}
|
|
</div>
|
|
<span className="st-thumb-label">{t(tpl.labelKey)}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|