From 18c09bf4175e74596b752e8c3c272b672724c0b5 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 7 Oct 2026 17:35:20 +0700 Subject: [PATCH] feat(studio): add right-click context menu (Solo mode, Collapse all, Expand all) for DevelopPanels, pin Color Chrome sub-chips, and fix library single view sorting & time range select --- docker/frontend/src/App.tsx | 73 ++++- docker/frontend/src/Library.tsx | 348 ++++++++++++++--------- docker/frontend/src/engine/heicWorker.ts | 27 +- docker/frontend/src/engine/rawDevelop.ts | 145 ++++++++-- docker/frontend/src/styles/app.css | 29 +- docker/frontend/src/ui/DevelopPanels.tsx | 144 +++++++++- docker/frontend/vite.config.ts | 9 +- docker/recipescam-images.tar.gz.sha256 | 2 +- 8 files changed, 587 insertions(+), 190 deletions(-) diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 62a0d20..a6c660e 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -747,7 +747,8 @@ export function Workspace() { // The photo half of the session, back out of IndexedDB. It waits for the // account: which file may open depends on the tier, and the check reads it - // from the render — run it too early and a PRO's own RAW is turned away. + const openLibraryPhotoRef = useRef<((id: string) => Promise) | null>(null); + // When the stored session is empty but a RAW is still parked in OPFS, the RAW // is what "the last photo" means: develop it again rather than open on // nothing. A frame handed over by the catalogue (`/app?lib=`) wins over @@ -756,19 +757,22 @@ export function Workspace() { useEffect(() => { if (!authReady) return; let alive = true; - const wanted = new URLSearchParams(window.location.search).get('lib'); - if (wanted) openLibraryPhoto(wanted); - else + + const checkAndLoad = () => { + const wanted = new URLSearchParams(window.location.search).get('lib'); + if (wanted && openLibraryPhotoRef.current) { + void openLibraryPhotoRef.current(wanted); + return true; + } + return false; + }; + + if (!checkAndLoad()) { loadPhoto() .then(async (photo) => { if (!alive) return; const show = (row: { name: string; bytes: Uint8Array }) => adoptPhoto(row.bytes, row.name, new Blob([row.bytes as BlobPart], { type: 'image/jpeg' }), true); - // A session photo named like a RAW is a develop an older build left - // there — the RAW branch writes none any more — and it carries that - // build's colour, which is how a fixed develop kept opening olive. The - // parked RAW is the copy that is not stale, so it is read first; the - // session's is only shown when nothing is parked to develop. if (photo && !isRawName(photo.name)) return show(photo); const parked = await readRaw(); if (!alive) return; @@ -777,10 +781,17 @@ export function Workspace() { await adoptPhoto(jpeg, parked.name, new Blob([jpeg as BlobPart], { type: 'image/jpeg' }), true, parked.bytes); }) .catch(() => undefined); + } + + const onPop = () => { + checkAndLoad(); + }; + window.addEventListener('popstate', onPop); + return () => { alive = false; + window.removeEventListener('popstate', onPop); }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [authReady]); useEffect(() => { @@ -2091,6 +2102,10 @@ export function Workspace() { [loadFile, applyRecipe, clearPreview, t] ); + useEffect(() => { + openLibraryPhotoRef.current = openLibraryPhoto; + }, [openLibraryPhoto]); + // What the catalogue remembers about a frame is the look it was left at, filed // back on every change — debounced, because a slider drag fires this per frame // of the drag and IndexedDB is on the wrong side of that. @@ -3359,10 +3374,40 @@ export function Workspace() { }))} /> ), - // The two Color Chromes close WB: same switch, the other axis of the two - // tracks above it, so they are read under the pair they are read against — - // and on the tab whose colour they are. - wbFoot: , + wbFoot: ( +
+
+ COLOR CHROME + ({ + key: `cx-${opt.v}`, + label: opt.d, + active: (recipe.adjustments.colorChrome || 'none') === opt.v, + onClick: () => { + remember(); + setAdjustment({ colorChrome: opt.v as Recipe['adjustments']['colorChrome'] }); + }, + }))} + /> +
+
+ CHROME BLUE + ({ + key: `cxb-${opt.v}`, + label: opt.d, + active: (recipe.adjustments.colorChromeBlue || 'none') === opt.v, + onClick: () => { + remember(); + setAdjustment({ colorChromeBlue: opt.v as Recipe['adjustments']['colorChromeBlue'] }); + }, + }))} + /> +
+
+ ), // TONE CURVE closes TONE: the graph on the photo is the shape of the range // the panel's seven rows are, so it stands under them rather than in a row // of its own at the top of the tab — and it glows amber once the graph is diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 015815a..a4f63d6 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -80,6 +80,82 @@ const COLUMN_KEY = 'recipescam.library.column'; const OPEN_KEY = 'recipescam.library.open'; const DEEP_KEY = 'recipescam.library.deep'; const VIEW_KEY = 'recipescam.library.view'; +const SORT_BY_KEY = 'recipescam.library.sortBy'; +const SORT_DIR_KEY = 'recipescam.library.sortDir'; +const MIN_STAR_KEY = 'recipescam.library.minStar'; +const YEAR_KEY = 'recipescam.library.year'; +const FROM_KEY = 'recipescam.library.from'; +const TO_KEY = 'recipescam.library.to'; + +function TimeRangeSelectInput({ + from, + to, + onChange, + t, +}: { + from: string; + to: string; + onChange: (from: string, to: string) => void; + t: (key: any, params?: any) => string; +}) { + const isSet = from !== 'any' || to !== 'any'; + + return ( +
+ + - + + {isSet ? ( + + ) : null} +
+ ); +} + const COLUMN_MIN = 88; const COLUMN_MAX = 460; const COLUMN_DEFAULT = 118; @@ -247,12 +323,23 @@ function Catalogue() { // these hours — and in the order the reader picked. A way of looking, not part // of the roll, so none of it outlives the visit; the strip below the frame does // not follow it, because the strip is the shelf itself. - const [sortBy, setSortBy] = useState<'taken' | 'mtime' | 'type' | 'name' | 'star'>('taken'); - const [sortDir, setSortDir] = useState<'desc' | 'asc'>('desc'); - const [minStar, setMinStar] = useState(0); - const [year, setYear] = useState('all'); - const [from, setFrom] = useState('any'); - const [to, setTo] = useState('any'); + const [sortBy, setSortBy] = useState<'taken' | 'mtime' | 'type' | 'name' | 'star'>( + () => (recall(SORT_BY_KEY) as any) || 'taken' + ); + const [sortDir, setSortDir] = useState<'desc' | 'asc'>( + () => (recall(SORT_DIR_KEY) as any) || 'desc' + ); + const [minStar, setMinStar] = useState(() => Number(recall(MIN_STAR_KEY)) || 0); + const [year, setYear] = useState(() => recall(YEAR_KEY) || 'all'); + const [from, setFrom] = useState(() => recall(FROM_KEY) || 'any'); + const [to, setTo] = useState(() => recall(TO_KEY) || 'any'); + + useEffect(() => { keep(SORT_BY_KEY, sortBy); }, [sortBy]); + useEffect(() => { keep(SORT_DIR_KEY, sortDir); }, [sortDir]); + useEffect(() => { keep(MIN_STAR_KEY, String(minStar)); }, [minStar]); + useEffect(() => { keep(YEAR_KEY, year); }, [year]); + useEffect(() => { keep(FROM_KEY, from); }, [from]); + useEffect(() => { keep(TO_KEY, to); }, [to]); // How far the frame is magnified, and the point it is magnified about: the one // under the pointer, so the picture grows around what is being looked at. const [zoom, setZoom] = useState(1); @@ -934,25 +1021,6 @@ function Catalogue() { return photos.filter((p) => p.folder === rootName && (p.dir === dir || (deep && below(p)))); }, [deep, node, photos, rootName]); - const frame = shown.find((p) => p.id === up) ?? shown[0] ?? null; - // The strip draws the tiles around the one under the eye, not the whole shelf. - // A roll is thousands of frames and every tile drawn costs a ` + + {/* 2. Date taken / Sort By */} + + + {/* 3. Year */} + + + {/* 4. Time taken (From / To dropdown selects) */} + { setFrom(f); setTo(tStr); }} t={t} /> + + {/* 5. Rating (Cuối cùng) */} + + + {/* 6. Count */} + + {t('lib.count', { n: listed.length })} + + + {view === 'grid' ? ( <> - {/* What the shelf is read through. It holds every frame the - open node does — a filter is a way of looking, not a - second shelf — and the line at its end says how many - are left, so a filter that finds nothing says so. */} -
- - - - - {/* The two ends of the hours: 16:00 to 18:00 is the - afternoon the reader was out, and either end on its - own is "from 16:00" or "up to 18:00". */} - - - - {t('lib.count', { n: listed.length })} - -
{/* The wall is the shelf's own length, so the scrollbar on its right edge still reaches every frame; what is drawn is the rows around the eye, with a spacer standing in @@ -1699,9 +1782,13 @@ function Catalogue() { key={photo.id} data-key={`lib-photo-${photo.id}`} onClick={(e) => handleSelectPhoto(photo, e)} + onDoubleClick={() => { + setUp(photo.id); + setView('preview'); + }} onContextMenu={(e) => handlePhotoContextMenu(photo, e)} > - {tile(photo, () => void open(photo))} + {tile(photo)}
{photo.name} @@ -1710,17 +1797,6 @@ function Catalogue() { {edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''}
- ))} {wall.below > 0 ?