web: a frame is scored under the picture, and the wall is read through the score
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 <noreply@penguin.local>
This commit is contained in:
+175
-19
@@ -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=<id>`).
|
||||
//
|
||||
// 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<string | null>(() => 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<Shot | null>(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() {
|
||||
|
||||
<section className="adm-stage" data-key="lib-stage">
|
||||
{view === 'grid' ? (
|
||||
<div className="adm-grid">
|
||||
{shown.map((photo) => (
|
||||
<article className="adm-card" key={photo.id} data-key={`lib-photo-${photo.id}`}>
|
||||
{tile(photo, () => void open(photo))}
|
||||
<div className="adm-meta">
|
||||
<b>{photo.name}</b>
|
||||
<span>
|
||||
{new Date(photo.taken).toLocaleDateString()} · {t('lib.size', { mb: (photo.size / 1048576).toFixed(1) })}
|
||||
{edited.has(photo.id) ? ` · ${t('lib.edited')}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="btn primary" data-key={`lib-open-${photo.id}`} onClick={() => void open(photo)}>
|
||||
{t('lib.open')}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{/* 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. */}
|
||||
<div className="chip-row lib-filters">
|
||||
<select
|
||||
className="adm-input"
|
||||
data-key="lib-sort"
|
||||
aria-label={t('lib.sort')}
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value as 'newest' | 'oldest' | 'star')}
|
||||
>
|
||||
<option value="newest">{t('lib.sortNewest')}</option>
|
||||
<option value="oldest">{t('lib.sortOldest')}</option>
|
||||
<option value="star">{t('lib.sortStars')}</option>
|
||||
</select>
|
||||
<select
|
||||
className="adm-input"
|
||||
data-key="lib-filter-star"
|
||||
aria-label={t('lib.rating')}
|
||||
value={String(minStar)}
|
||||
onChange={(e) => setMinStar(Number(e.target.value))}
|
||||
>
|
||||
<option value="0">{t('lib.ratingAll')}</option>
|
||||
{[5, 4, 3, 2, 1].map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{t('lib.ratingAtLeast', { n })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="adm-input"
|
||||
data-key="lib-filter-year"
|
||||
aria-label={t('lib.year')}
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value)}
|
||||
>
|
||||
<option value="all">{t('lib.yearAll')}</option>
|
||||
{years.map((y) => (
|
||||
<option key={y} value={String(y)}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* 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". */}
|
||||
<select
|
||||
className="adm-input"
|
||||
data-key="lib-filter-from"
|
||||
aria-label={t('lib.hourFrom')}
|
||||
value={from}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
>
|
||||
<option value="any">{t('lib.hourFromAny')}</option>
|
||||
{HOURS.map((h) => (
|
||||
<option key={h} value={String(h)}>
|
||||
{`${String(h).padStart(2, '0')}:00`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="adm-input"
|
||||
data-key="lib-filter-to"
|
||||
aria-label={t('lib.hourTo')}
|
||||
value={to}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
>
|
||||
<option value="any">{t('lib.hourToAny')}</option>
|
||||
{HOURS.map((h) => (
|
||||
<option key={h} value={String(h)}>
|
||||
{`${String(h).padStart(2, '0')}:00`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="hint" data-key="lib-listed">
|
||||
{t('lib.count', { n: listed.length })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="adm-grid">
|
||||
{listed.map((photo) => (
|
||||
<article className="adm-card" key={photo.id} data-key={`lib-photo-${photo.id}`}>
|
||||
{tile(photo, () => void open(photo))}
|
||||
<div className="adm-meta">
|
||||
<b>{photo.name}</b>
|
||||
<span>
|
||||
{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')}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" className="btn primary" data-key={`lib-open-${photo.id}`} onClick={() => void open(photo)}>
|
||||
{t('lib.open')}
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : frame ? (
|
||||
<>
|
||||
{urls[frame.id] ? (
|
||||
@@ -811,6 +946,27 @@ export function Library() {
|
||||
<button type="button" className="btn primary" data-key="lib-open" onClick={() => void open(frame)}>
|
||||
{t('lib.open')}
|
||||
</button>
|
||||
<span className="adm-spacer" />
|
||||
{/* 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. */}
|
||||
<div className="lib-stars" data-key="lib-stars" role="group" aria-label={t('lib.rating')}>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`lib-star${(frame.star ?? 0) >= n ? ' on' : ''}`}
|
||||
data-key={`lib-star-${n}`}
|
||||
aria-pressed={(frame.star ?? 0) >= n}
|
||||
aria-label={t('lib.starHint', { n })}
|
||||
title={t('lib.starHint', { n })}
|
||||
onClick={() => void rate(frame, (frame.star ?? 0) === n ? 0 : n)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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<LibraryPhoto | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<void> {
|
||||
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<File> {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user