diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index aa042df..77e7d39 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -14,6 +14,8 @@ import { CameraView } from './ui/CameraView'; import { RecipeCreatePanel, type RecipeDraft } from './ui/RecipeCreatePanel'; import { useI18n } from './i18n/I18nProvider'; import { useIsMobile } from './ui/useIsMobile'; +import { Filmstrip } from './ui/Filmstrip'; +import { useThumbUrls } from './Library'; import { loadEngine, MARK_FACES } from './engine/assets'; import { renderPhoto, type RenderAssets } from './engine/exportEngine'; import { download, fileToBytes, readCapturedAt, readGps, readIso, readSpecs, resizedJpeg, scaledJpeg, type ShotSpecs } from './engine/imageOps'; @@ -21,7 +23,7 @@ import { developRaw, isRawName, isTiffName, tiffDevelop } from './engine/rawDeve import { sharePhoto, type ShareSite } from './engine/share'; import { heicToJpeg, isHeicName } from './engine/heicDevelop'; import { clearRaw, readRaw, storeRaw } from './engine/rawStore'; -import { getPhoto, loadEdit, makeTile, putPhotoThumb, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library'; +import { getPhoto, listPhotos, loadEdit, makeTile, normPath, putPhotoThumb, readPhotoFile, saveEdit, setRotation as savePhotoRotation, type LibraryPhoto } from './engine/library'; import { immichConfig, uploadToImmich, type ImmichServer } from './engine/immich'; import { offerInstall } from './pwa/install'; import { cameraSupported } from './engine/camera'; @@ -844,6 +846,43 @@ export function Workspace() { // `/app?lib=`). It is what the recipe is filed back against, so the frame // reopens in the studio at the look it was left at. const [libPhotoId, setLibPhotoId] = useState(null); + // The frames beside that one, in the folder it was opened from: the strip + // under the photo reads them, so a reader who wants the next frame of the + // roll does not walk back to the wall for it. + const [libFolder, setLibFolder] = useState([]); + + // Read out of the catalogue, not off the disk: one folder's rows are filed + // under the folder's own name, and the `dir` the frame carries is what makes + // the strip the frames beside it rather than every frame that folder was + // scanned for. A frame whose folder is a drive root arrives with no `dir` at + // all, and every one of its siblings with the same nothing — which is the + // same reading, once `normPath` has had both (see engine/library.ts). + useEffect(() => { + if (!libPhotoId) { + setLibFolder([]); + return; + } + let alive = true; + void (async () => { + const row = await getPhoto(libPhotoId); + if (!row) return; + const dir = normPath(row.dir ?? ''); + const rows = (await listPhotos(row.folder)).filter((p) => normPath(p.dir ?? '') === dir); + // The reader's own order — a file browser's — and not the catalogue's, + // which reads newest shutter first: a roll that came off the camera in + // order is the order it is looked through in. + rows.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + if (alive) setLibFolder(rows); + })(); + return () => { + alive = false; + }; + }, [libPhotoId]); + + // One tile per frame, made the way the shelf makes them and out of the same + // cache: a folder the wall has just drawn is a strip already pointing at the + // tiles it drew. + const libUrls = useThumbUrls(libFolder); // Recipes made in this session, guest ones included. A guest's copy lives // here and nowhere else, so reloading the page is what deletes it. const [created, setCreated] = useState([]); @@ -4817,6 +4856,15 @@ export function Workspace() { shotSpecs={shotSpecs} place={place} photoName={source?.name ?? null} + // The folder's own frames, under the photo: a click is the same + // opening the catalogue's wall makes, and the frame the stage holds + // is the one lit. Nothing until there is a folder to draw, and + // nothing for a folder of one, where a strip of one says nothing. + filmstrip={ + libPhotoId && libFolder.length > 1 ? ( + void openLibraryPhoto(id)} /> + ) : null + } onCropRectChange={setCropRect} onStageZoom={onStageZoom} canCompare={!!source} diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 5607537..32793f0 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -4247,7 +4247,7 @@ function getSyncThumbUrl(photo: LibraryPhoto): string | null { return null; } -function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?: number): Record { +export function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?: number): Record { const list = useMemo(() => (also && !photos.some((p) => p.id === also.id) ? [...photos, also] : photos), [photos, also]); // Synchronously resolve any thumb URL already available in memory to eliminate render flicker/flashing diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 518115f..de19f89 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -1065,6 +1065,42 @@ button.hint:disabled { cursor: default; } text-overflow: ellipsis; max-width: 100%; } +/* The folder's frames under the photo on the stage: the reader walks the roll + sideways and clicks one to open it here, rather than going back to the wall + for it. Positioned, because the frame the strip is scrolled to is measured + off the rail's own left edge (see ui/Filmstrip.tsx). */ +.studio-strip { + position: relative; + display: flex; + gap: 6px; + min-width: 0; + max-width: 100%; + overflow-x: auto; + padding-bottom: 4px; + scroll-behavior: smooth; +} +.strip-frame { + flex: 0 0 auto; + width: 64px; + height: 64px; + padding: 0; + line-height: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + overflow: hidden; + cursor: pointer; +} +/* The frame the stage is holding, marked the way the shelf marks the frame it + has open: its border, not its picture. */ +.strip-frame.active { border-color: var(--accent); } +.strip-frame img { display: block; width: 100%; height: 100%; object-fit: cover; } +/* A frame whose tile has not landed yet: the box stands at the size the tile + will be, so the roll does not shift under the reader as they come in. */ +.strip-frame-blank { display: block; width: 100%; height: 100%; background: var(--bg-sunken); } +@media (max-width: 860px) { + .strip-frame { width: 52px; height: 52px; } +} .stage .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; } .stage .toolbar .dims { font-family: var(--mono); white-space: nowrap; } .err { color: var(--danger); font-size: 12px; max-width: 520px; text-align: center; } diff --git a/docker/frontend/src/ui/Filmstrip.tsx b/docker/frontend/src/ui/Filmstrip.tsx new file mode 100644 index 0000000..5434331 --- /dev/null +++ b/docker/frontend/src/ui/Filmstrip.tsx @@ -0,0 +1,67 @@ +import { useEffect, useRef } from 'react'; +import type { LibraryPhoto } from '../engine/library'; + +// The frames beside the one on the stage, under the photo: the folder the +// catalogue handed over, in the order a file browser reads it. A click is the +// same opening the catalogue's own wall makes — the frame arrives with the look +// it was last left at — and the frame the stage is holding is the one lit and +// the one the rail is scrolled to, wherever the rail had been left. +// +// ponytail: every frame of the folder gets a tile, because the folder is one the +// reader just opened and a folder that is not a roll is not one they scroll. A +// directory holding thousands would want the wall's own windowing (Library.tsx's +// `drawn`); add it when such a folder is a real wait. +export function Filmstrip({ + frames, + urls, + activeId, + onPick, +}: { + frames: LibraryPhoto[]; + urls: Record; + activeId: string; + onPick: (id: string) => void; +}) { + const rail = useRef(null); + const at = frames.findIndex((p) => p.id === activeId); + + // The frame in hand centred rather than merely brought into view: a frame at + // the rail's edge is half a frame the reader cannot tell from the next one. + // Not `scrollIntoView`, which walks the stage itself off a phone's screen. + useEffect(() => { + const box = rail.current; + const el = box?.children[at] as HTMLElement | undefined; + if (!box || !el) return; + box.scrollLeft = el.offsetLeft - box.clientWidth / 2 + el.clientWidth / 2; + }, [at, frames]); + + return ( + // A wheel has no sideways axis, so a plain tick over the roll walks it along. +
{ + if (!e.deltaX) e.currentTarget.scrollLeft += e.deltaY; + }} + > + {frames.map((photo) => { + const url = urls[photo.id]; + return ( + + ); + })} +
+ ); +} diff --git a/docker/frontend/src/ui/ImageStage.tsx b/docker/frontend/src/ui/ImageStage.tsx index fe01183..310b953 100644 --- a/docker/frontend/src/ui/ImageStage.tsx +++ b/docker/frontend/src/ui/ImageStage.tsx @@ -703,6 +703,7 @@ export function ImageStage({ shotSpecs = null, place = null, photoName = null, + filmstrip = null, frameId = 'none', framePanX = 0, onFramePanXChange, @@ -804,6 +805,9 @@ export function ImageStage({ // The file the stage is holding, as the disk named it: the row under the photo // is where a reader checks that the frame they meant to open is the one open. photoName?: string | null; + // What stands under the photo on the stage, between it and the row that + // names the file: the folder's own frames, drawn by the app (see Filmstrip). + filmstrip?: ReactNode; frameId?: FrameId; framePanX?: number; onFramePanXChange?: (panX: number) => void; @@ -2579,6 +2583,7 @@ export function ImageStage({ {previewUrl ? ( <> + {filmstrip} {shotSpecs || place || dims || photoName ? (
{specsLine(shotSpecs ?? null, place, dims)}