feat(studio): a strip of the folder's own frames under the photo

The studio is where a frame is graded, and the frame next to the one being
graded is the one the reader almost always wants next — a roll is shot in
order and looked through in order. Until now the only way to it was LIBRARY:
the stage had no way back to the folder the frame came from.

The strip stands between the photo and the row that names the file, on the
phone at the same place. It holds the folder the open frame was filed in —
the frames beside it, not every frame the folder was scanned for, which is
what the row's own `dir` is read for — in a file browser's order rather than
the catalogue's newest-shutter-first, because a roll off a camera is the
order it is looked through in. The frame on the stage is the lit one and the
one the rail is scrolled to, wherever the rail had been left; a click is the
same opening the catalogue's wall makes, so the frame arrives with the look
it was last left at.

The tiles are the shelf's own: `makeThumb` off the same store, and the URL
cache the wall keeps is lent to the strip through the hook that fills it.

  docker/frontend/src/ui/Filmstrip.tsx      the strip
  docker/frontend/src/ui/ImageStage.tsx     one optional slot for it
  docker/frontend/src/App.tsx               the folder, read off the catalogue
  docker/frontend/src/Library.tsx           the tile hook, exported
  docker/frontend/src/styles/app.css        the strip and its frames
This commit is contained in:
2026-10-15 17:16:43 +07:00
parent b5c8af4982
commit 065c9af1c1
5 changed files with 158 additions and 2 deletions
+49 -1
View File
@@ -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=<id>`). 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<string | null>(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<LibraryPhoto[]>([]);
// 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<Recipe[]>([]);
@@ -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 ? (
<Filmstrip frames={libFolder} urls={libUrls} activeId={libPhotoId} onPick={(id) => void openLibraryPhoto(id)} />
) : null
}
onCropRectChange={setCropRect}
onStageZoom={onStageZoom}
canCompare={!!source}
+1 -1
View File
@@ -4247,7 +4247,7 @@ function getSyncThumbUrl(photo: LibraryPhoto): string | null {
return null;
}
function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?: number): Record<string, string> {
export function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?: number): Record<string, string> {
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
+36
View File
@@ -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; }
+67
View File
@@ -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<string, string>;
activeId: string;
onPick: (id: string) => void;
}) {
const rail = useRef<HTMLDivElement>(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.
<div
className="studio-strip"
data-key="studio-strip"
ref={rail}
onWheel={(e) => {
if (!e.deltaX) e.currentTarget.scrollLeft += e.deltaY;
}}
>
{frames.map((photo) => {
const url = urls[photo.id];
return (
<button
key={photo.id}
type="button"
className={'strip-frame' + (photo.id === activeId ? ' active' : '')}
data-key={'strip-frame-' + photo.id}
title={photo.name}
aria-label={photo.name}
aria-current={photo.id === activeId}
onClick={() => onPick(photo.id)}
>
{url ? <img src={url} alt="" /> : <span className="strip-frame-blank" />}
</button>
);
})}
</div>
);
}
+5
View File
@@ -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 ? (
<div className="photo-meta-bar" data-key="photo-meta-bar">
{specsLine(shotSpecs ?? null, place, dims)}