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. */