From 53cb1c97e99962997e4dc391e8b808e3616a357f Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 29 Sep 2026 08:02:05 +0700 Subject: [PATCH] web: a frame is scored under the picture, and the wall is read through the score MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalogue could say what a frame was and when it was shot, and nothing about whether it was any good. A reader who had been through a roll of two thousand had no way to say "these four", and the thumbnail wall drew every frame the open folder held in the one order it had: newest shutter first, always. The frame that is up now carries its own score — five stars under the picture, and the star the click lands on is the score it gets. The star it already has takes the score back, so a score that was given by mistake is taken off without a sixth control. It is the catalogue's own row and nothing on the disk is touched: the file is not written and not read, the browser only stores one more number against a frame it already holds, and the number is the reader's. The wall is read through three of them. The score is the first — four stars and up is the floor, any rating is the wall as it was — because that is what a reader who has just been through a roll wants next. The year the shutter fired in is the second, offered as the years the open folder actually holds and not a century of empty ones. The hours it fired in are the third, as a from and a to: 16:00 to 18:00 is the afternoon the reader was out, and either end alone is "from 16:00" or "up to 18:00". All three read the shutter time, on this machine's own clock — the same reading the frame's date line prints, so the afternoon is the afternoon and not an offset of it. Beside them, the order: the newest shutter first, the oldest first, or the most stars first. What the filters hold is the wall and only the wall. The strip under the picture is the shelf the open folder is, and a shelf that quietly loses three quarters of its frames is not a shelf any more; the frame that is up has to stay reachable while the wall is narrowed around it. That is also why the filters are drawn in the thumbnail view: they are a way of looking through the shelf, not a property of the roll, and nothing about them outlives the visit. Verified: library-check.mjs — 52 steps, all passed, five of them new. The frame that is up is scored from the row under it and the catalogue holds star 4 against it (aria-pressed on the fourth star true); the wall narrows to one tile at 4★, to none at 5★, and back to three with the rating cleared; the year 2016 holds the two JPEGs of three and 16:00–18:00 the same two, out of a roll whose frames are written years apart in different parts of the day (the JPEG in the afternoon of 15 Feb 2016, the RAW at seven in the morning of 1 Jan 2026); read by score, the scored frame comes first. Every earlier step still holds, including the two that count what a reading costs and the stand-in folder's two frames — the JPEG carrying the GPS tag and the RAW printing "26.4mm · f/2.8 · 1/320s". scan-nav-check.mjs, roll-walk-check.mjs — all passed. frontend tsc --noEmit clean, vite build clean. ponytail: the score is one number on the frame's own row — no rating table, no votes, no accounts; the catalogue is a per-browser shelf and so is the score. The filters live in the thumbnail view's own state, so a reload starts with the whole shelf again, which is what a filter is for. No text search: a roll of date folders is three levels deep at most, and the three that a reader actually digs with are the ones here — add the box when a folder of mixed names makes one worth typing into. Co-authored-by: PenguinHarness --- docker/frontend/scripts/library-check.mjs | 65 +++++++- docker/frontend/src/Library.tsx | 194 +++++++++++++++++++--- docker/frontend/src/engine/library.ts | 20 +++ docker/frontend/src/i18n/en.ts | 16 ++ docker/frontend/src/i18n/vi.ts | 16 ++ docker/frontend/src/styles/app.css | 20 +++ 6 files changed, 310 insertions(+), 21 deletions(-) diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index 46cec19..76e3570 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -198,8 +198,13 @@ await context.addInitScript( type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream', // A real handle reports one write time for one file, and the same one // on every read; a fresh `Date.now()` per call would make every frame - // look touched and hide what is being checked. - lastModified: Date.UTC(2026, 0, 1), + // look touched and hide what is being checked. The two frames are + // written years apart, in different parts of the day — the JPEG in the + // afternoon of 15 Feb 2016 and the RAW at seven in the morning of + // 1 Jan 2026 — because the shelf's own filters are read off the + // shutter time: a year, and an hour range. Both are UTC and read back + // on this machine's clock, which is UTC+7. + lastModified: name.endsWith('.JPG') ? Date.UTC(2016, 1, 15, 9, 30) : Date.UTC(2026, 0, 1), }); }, }); @@ -311,6 +316,8 @@ const catalogue = () => id: photo.id, bytes: photo.thumb?.size ?? 0, jpeg: head ? head[0] === 0xff && head[1] === 0xd8 : false, + // The score the reader gave it, 0 when they have given none. + star: photo.star ?? 0, }); } return { thumbs, edits: edits.map((e) => ({ id: e.photoId, filter: e.recipe?.baseFilter, knobs: Object.keys(e.recipe?.adjustments ?? {}).length })) }; @@ -693,6 +700,60 @@ await page.waitForTimeout(300); const walked = await page.$eval('[data-key="lib-strip"]', (el) => el.scrollLeft); step('a wheel tick walks the strip along', shelf.sw > shelf.cw && walked > 0, `${shelf.sw}px of tiles in ${shelf.cw}px, scrolled to ${walked}`); +// The reader's own score on a frame, and the wall read through it. The score is +// given from the row under the picture and nothing else has to be opened or +// visited to see it; the wall is then narrowed by that score, by the year the +// shutter fired and by the hours it fired in. The check's frames were written so +// that all three answer something: the JPEG in the afternoon of 15 Feb 2016, the +// RAW at seven in the morning of 1 Jan 2026. +await page.setViewportSize({ width: 1280, height: 720 }); +await page.click(`[data-key="lib-thumb-${jpgId}"]`); +await page.click('[data-key="lib-star-4"]'); +const starred = (await catalogue()).thumbs.find((t) => t.id === jpgId)?.star; +const lit = await page.$eval('[data-key="lib-star-4"]', (e) => e.getAttribute('aria-pressed')); +step('a frame is given a score from the row under it', starred === 4 && lit === 'true', `catalogue ${starred}, 4th star ${lit}`); + +await page.click('[data-key="lib-view-grid"]'); +await page.waitForSelector('[data-key^="lib-photo-"]', { timeout: 30_000 }); +// Each filter is set on its own and put back, so what the wall holds is read +// after one choice and never after two. +const wall = () => page.$$eval('[data-key^="lib-photo-"]', (els) => els.length); +const setFilter = async (key, value) => { + await page.selectOption(`[data-key="${key}"]`, value); + await page.waitForTimeout(150); +}; +await setFilter('lib-filter-star', '4'); +const fourUp = await wall(); +await setFilter('lib-filter-star', '5'); +const fiveUp = await wall(); +await setFilter('lib-filter-star', '0'); +step('the wall narrows to the frames that were given the stars', fourUp === 1 && fiveUp === 0 && (await wall()) === 3, `4★ ${fourUp}, 5★ ${fiveUp}, any 3`); + +await setFilter('lib-filter-year', '2016'); +const in2016 = await wall(); +await setFilter('lib-filter-year', 'all'); +step('and to the year the shutter fired in', in2016 === 2, `2016 → ${in2016} tiles of 3`); + +await setFilter('lib-filter-from', '16'); +await setFilter('lib-filter-to', '18'); +const afternoon = await wall(); +await setFilter('lib-filter-from', 'any'); +await setFilter('lib-filter-to', 'any'); +step('and to the hours it fired in', afternoon === 2, `16:00–18:00 → ${afternoon} tiles of 3`); + +// The order the wall is read in, and back to the shelf: what is left behind is +// the view the checks below read, so the filters go back to holding everything. +await setFilter('lib-sort', 'star'); +const ordered = await page.$$eval('[data-key^="lib-photo-"]', (els) => els.map((e) => e.dataset.key)); +step( + 'the wall reads in the order that was asked for', + ordered[0] === `lib-photo-${jpgId}`, + `${ordered.map((k) => k.replace('lib-photo-', '').split('/').pop()).join(', ')}` +); +await setFilter('lib-sort', 'newest'); +await page.click('[data-key="lib-view-preview"]'); +await page.waitForSelector(thumbs, { timeout: 30_000 }); + // A catalogue another tab has open — the tab that is still running yesterday's // build, holding the connection the new build used to ask for a version on. The // screen has to come up on it: the catalogue is opened as it is, with no version diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index c1bff86..6166bd2 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -17,6 +17,7 @@ import { removeFolder, renameFolder, scanSession, + setStar, startScan, stopScan, watchScan, @@ -34,8 +35,11 @@ import { // thumbnails, and the RAW itself is read only when a frame is opened in the // studio (`/app?lib=`). // -// ponytail: no search, rating or flag. A roll of date folders is three levels -// deep at most; add a filter once the column stops being scrollable in practice. +// ponytail: no search and no flag. A roll of date folders is three levels deep at +// most, and what a reader actually digs through a shelf with is the score they +// gave a frame, the year it was shot and the hours they shot it in — which are +// the three the thumbnail view filters on. Add the text box when a folder of +// mixed names makes one worth typing into. interface Node { // The tree's own key: a folder name, or `folder/sub/dir`. key: string; @@ -56,6 +60,9 @@ const COLUMN_DEFAULT = 118; // handful of ticks the thumbnail behind the preview runs out of pixels anyway. const ZOOM_MAX = 6; const ZOOM_STEP = 1.15; +// The hours a frame's own clock can read, and what the time filter offers: the +// whole day in the 24-hour form a camera writes into EXIF. +const HOURS = Array.from({ length: 24 }, (_, i) => i); // What the frame's own file says about itself: the line of numbers the camera // recorded, and where it was shot. Both sit in the EXIF at the head of the file, @@ -122,6 +129,16 @@ export function Library() { // the first, the way a node that is gone falls back to its folder. const [up, setUp] = useState(() => remembered(FRAME_KEY)); const [view, setView] = useState<'preview' | 'grid'>('preview'); + // How the thumbnail view reads the open folder: every frame it holds, or only + // the ones that were given this many stars, shot in this year, shot between + // 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 [order, setOrder] = useState<'newest' | 'oldest' | 'star'>('newest'); + const [minStar, setMinStar] = useState(0); + const [year, setYear] = useState('all'); + const [from, setFrom] = useState('any'); + const [to, setTo] = useState('any'); // 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); @@ -451,6 +468,42 @@ export function Library() { const frame = shown.find((p) => p.id === up) ?? shown[0] ?? null; const urls = useThumbUrls(shown); + + // The years the open folder holds, newest first: the filter offers the years + // there are frames to find in rather than a century of empty ones. + const years = useMemo( + () => [...new Set(photos.map((p) => new Date(p.taken).getFullYear()))].sort((a, b) => b - a), + [photos] + ); + + // What the thumbnail view draws, out of what the open node holds. A frame's + // hour is the hour the shutter fired in, on this machine's clock — the same + // reading the frame's own date line prints, so 16:00–18:00 is the afternoon + // the reader was out and not an offset of it. + const listed = useMemo(() => { + const rows = shown.filter((p) => { + if (minStar && (p.star ?? 0) < minStar) return false; + const at = new Date(p.taken); + if (year !== 'all' && at.getFullYear() !== Number(year)) return false; + if (from !== 'any' && at.getHours() < Number(from)) return false; + if (to !== 'any' && at.getHours() > Number(to)) return false; + return true; + }); + // `shown` is already newest shutter first, so the oldest is the same list + // read backwards; the score comes first in the third, and the shutter breaks + // its ties the way the shelf does. + if (order === 'star') return [...rows].sort((a, b) => (b.star ?? 0) - (a.star ?? 0) || b.taken - a.taken); + return order === 'oldest' ? [...rows].reverse() : rows; + }, [shown, minStar, year, from, to, order]); + + // A frame's score: click a star to give it, click the star it already has to + // take the score back. The catalogue holds it, so it is the same score the next + // visit reads — and the list on screen is told, rather than read back whole for + // one number. + const rate = useCallback(async (photo: LibraryPhoto, n: number) => { + await setStar(photo.id, n); + setPhotos((prev) => prev.map((p) => (p.id === photo.id ? { ...p, star: n } : p))); + }, []); const [shot, setShot] = useState(null); // Where the frame was shot, as the coordinates it carries — the fallback for a // file whose place the geocoder could not name. @@ -758,23 +811,105 @@ export function Library() {
{view === 'grid' ? ( -
- {shown.map((photo) => ( -
- {tile(photo, () => void open(photo))} -
- {photo.name} - - {new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })} - {edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''} - -
- -
- ))} -
+ <> + {/* 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 })} + +
+
+ {listed.map((photo) => ( +
+ {tile(photo, () => void open(photo))} +
+ {photo.name} + + {photo.star ? `★${photo.star} · ` : ''} + {new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })} + {edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''} + +
+ +
+ ))} +
+ ) : frame ? ( <> {urls[frame.id] ? ( @@ -811,6 +946,27 @@ export function Library() { + + {/* The frame's own score, and the only place one is + given: the star that is lit is the score it has, + and the star under the click is the score it gets — + the one it already has takes the score back. */} +
+ {[1, 2, 3, 4, 5].map((n) => ( + + ))} +
) : ( diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 0554403..1796b46 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -64,6 +64,10 @@ export interface LibraryPhoto { // about the file. A frame stored before this field existed has none, and is // read one last time. mtime?: number; + // The reader's own score for the frame, 0 or absent when they have not given + // it one. It belongs to the catalogue and not to the disk — nothing about the + // file changes — so a rescan carries it over, the way it carries `addedAt`. + star?: number; addedAt: number; } @@ -553,6 +557,9 @@ export async function scanFolder( taken, size: file.size, mtime: file.lastModified, + // The reader's own score is not the file's: a frame that is read again + // keeps the stars it was given. + star: seen?.star, addedAt: seen?.addedAt ?? Date.now(), }); progress.added++; @@ -743,6 +750,19 @@ export async function getPhoto(id: string): Promise { } } +// Score a frame, or take the score back with a 0. Nothing on the disk changes — +// the stars are the catalogue's own — so this is a read and a put, and a frame +// that is read again on the next scan carries its score over. +export async function setStar(id: string, star: number): Promise { + const photo = await getPhoto(id); + if (!photo) return; + try { + await ask(PHOTOS, 'readwrite', (s) => s.put({ ...photo, star })); + } catch { + // Private mode: the score lives no longer than the visit. + } +} + // The frame itself, at last: nothing is read from the disk until this runs, so // the catalogue costs thumbnails and no more. export async function readPhotoFile(photo: LibraryPhoto): Promise { diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index baee02c..faf9987 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -248,6 +248,22 @@ export const en: Dict = { 'lib.edited': 'edited', 'lib.size': '{mb} MB', 'lib.count': '{n} photos', + // What the thumbnail view is read through. The score a frame is given is the + // reader's own, so the words say so: "stars and up" is a floor, not a target. + 'lib.sort': 'SORT', + 'lib.sortNewest': 'Newest first', + 'lib.sortOldest': 'Oldest first', + 'lib.sortStars': 'Most stars', + 'lib.rating': 'RATING', + 'lib.ratingAll': 'Any rating', + 'lib.ratingAtLeast': '{n}★ and up', + 'lib.starHint': 'Give this frame {n} stars', + 'lib.year': 'YEAR', + 'lib.yearAll': 'Any year', + 'lib.hourFrom': 'Shot from', + 'lib.hourFromAny': 'Any hour', + 'lib.hourTo': 'Shot until', + 'lib.hourToAny': 'Any hour', 'lib.scanning': 'Scanning {done}/{total} — {added} new…', 'lib.scanned': 'Scanned {folder}: {added}/{total} new frames.', 'lib.empty': 'Nothing in this folder yet.', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 793a698..adbcb14 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -262,6 +262,22 @@ export const vi = { 'lib.edited': 'đã chỉnh', 'lib.size': '{mb} MB', 'lib.count': '{n} ảnh', + // Cách đọc bày ảnh: điểm sao là của người xem, nên chữ nói đúng thế — "từ n + // sao" là mức sàn, không phải mức để nhắm tới. + 'lib.sort': 'SẮP XẾP', + 'lib.sortNewest': 'Mới chụp trước', + 'lib.sortOldest': 'Cũ nhất trước', + 'lib.sortStars': 'Nhiều sao nhất', + 'lib.rating': 'XẾP HẠNG', + 'lib.ratingAll': 'Mọi mức sao', + 'lib.ratingAtLeast': 'Từ {n} sao', + 'lib.starHint': 'Cho ảnh này {n} sao', + 'lib.year': 'NĂM', + 'lib.yearAll': 'Mọi năm', + 'lib.hourFrom': 'Chụp từ', + 'lib.hourFromAny': 'Mọi giờ', + 'lib.hourTo': 'Chụp đến', + 'lib.hourToAny': 'Mọi giờ', 'lib.scanning': 'Đang quét {done}/{total} — thêm {added}…', 'lib.scanned': 'Đã quét {folder}: {added}/{total} ảnh mới.', 'lib.empty': 'Chưa có ảnh nào trong thư mục này.', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 6179fe4..e5180ae 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -1077,6 +1077,26 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } /* All thumbs: the wall scrolls down inside the stage, and its own scrollbar on the right edge is what reaches the frames below. */ .lib-root .adm-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 6px; } +/* What the thumbnail wall is read through: the four selects and the count of what + is left. One row above the wall, and it does not scroll with it. */ +.lib-filters { flex: 0 0 auto; align-items: center; margin-bottom: 8px; } +.lib-filters .adm-input { width: auto; min-width: 0; padding: 4px 8px; font-size: 12px; } +.lib-filters .hint { margin-left: auto; } +/* The frame's own score: five glyphs, lit up to the score it has. The star the + click lands on is the score it gives, so the row is the control. */ +.lib-stars { display: flex; gap: 1px; align-items: center; } +.lib-star { + border: 1px solid transparent; + background: none; + border-radius: 6px; + padding: 2px 3px; + font-size: 15px; + line-height: 1.1; + color: var(--text-dim); + cursor: pointer; +} +.lib-star:hover { border-color: var(--border); } +.lib-star.on { color: var(--accent); } /* One toolbar, not two rows of chrome: ADD FOLDER, the hint, and the pair of view icons. The hint gives up its width first and ellipsises — the whole sentence is still there under the pointer. */