feat(library): read the wall's time by the year and the month, and mint only the frames near the eye

This commit is contained in:
2026-10-13 09:54:47 +07:00
parent 5d9b4c3058
commit ddbde7d426
4 changed files with 244 additions and 99 deletions
+96 -31
View File
@@ -221,6 +221,10 @@ const WALL_LANES = 2;
// folder opening is the reader waiting on tiles for frames three screens below
// the one they are looking at.
const THUMB_AHEAD = 60;
// What the wall mints before it has been looked at: the head of the shelf, which is
// the order it drew the days in. A first paint with no tiles at all is a rack of
// empty cards, and the first look lands a frame later.
const THUMB_HEAD = 120;
// 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);
@@ -345,13 +349,14 @@ export function Library() {
}
import { useIsMobile } from './ui/useIsMobile';
import { dayLabel, dayRuns, monthKey, monthLabel } from './ui/dateGroups';
import { dayLabel, dayRuns, monthKey, monthLabel, yearKey } from './ui/dateGroups';
// One entry of the rail down the wall's right edge. `key` is what the entry
// stands for — a month (`2026-10`) or a day (`2026-10-04`) — and `at` is the day
// key the wall is sent to when it is pressed, which is the first day of the
// month for a month's entry.
type RailMark = { key: string; at: string; label: string; title: string };
// One entry of the rail down the wall's right edge: a year (`2026`) with the
// months under it (`2026-10`). `at` is the day key the wall is sent to when the
// entry is pressed — the shelf's own first day of that month, and for a year the
// first day of its first month on the shelf. The rail names the time by the year
// and the month and stops there: a day is what the wall's own bands are for.
type RailMark = { kind: 'year' | 'month'; key: string; at: string; label: string; title: string };
function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
const { t, lang } = useI18n();
@@ -2045,22 +2050,31 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
// frame is the grid's, since the grid is the whole shelf.
const gridRef = useRef<HTMLDivElement>(null);
// The rail down the wall's right edge: the shelf's time, one entry a month, or a
// day each when the shelf has not a month's worth of months. It is cut off the
// same runs the wall is drawn in, and an entry is looked up by the day key it
// stands for, so an entry and the header it jumps to are one cut of the shelf and
// not two.
// The rail down the wall's right edge: the shelf's own time, a year and the
// months under it — and nothing finer, since the day a frame belongs to is what
// the wall's bands already say. It is cut off the same runs the wall is drawn in,
// so an entry and the header it jumps to are one cut of the shelf, not two.
const rail = useMemo(() => {
const months: RailMark[] = [];
const each: RailMark[] = [];
const all: RailMark[] = [];
let year = '';
let month = '';
for (const day of days) {
const month = monthKey(day.at);
if (months[months.length - 1]?.key !== month) {
months.push({ key: month, at: day.key, label: monthLabel(day.at, lang), title: dayLabel(day.at, lang) });
const key = monthKey(day.at);
const yr = yearKey(day.at);
// The year stands over its months, and the first day the shelf holds of it
// is where a press on the year lands.
if (yr !== year) {
year = yr;
month = '';
all.push({ kind: 'year', key: yr, at: day.key, label: yr, title: yr });
}
if (key !== month) {
month = key;
const label = monthLabel(day.at, lang);
all.push({ kind: 'month', key, at: day.key, label, title: `${label} ${year}` });
}
each.push({ key: day.key, at: day.key, label: String(new Date(day.at).getDate()), title: dayLabel(day.at, lang) });
}
return months.length > 1 ? months : each;
return all;
}, [days, lang]);
const [railAt, setRailAt] = useState(0);
const railRef = useRef<HTMLElement>(null);
@@ -2093,7 +2107,11 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
}
if (best < 0) return;
const key = (nodes[best].dataset.key ?? '').replace('lib-day-', '');
const found = rail.findIndex((mark) => key.startsWith(mark.key));
// The month the day belongs to is the entry, not the year over it: the
// year is a head, and a head lit for every day of it says less than the
// month does.
let found = rail.findIndex((mark) => mark.kind === 'month' && mark.key === key.slice(0, 7));
if (found < 0) found = rail.findIndex((mark) => mark.kind === 'year' && mark.key === key.slice(0, 4));
setRailAt(found < 0 ? 0 : found);
},
{ root: el, rootMargin: '0px 0px -88% 0px' }
@@ -2142,7 +2160,49 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
}),
[]
);
const urls = useThumbUrls(view === 'preview' ? drawn : listed, frame, tilesAt);
// Which days the eye is near, and the only ones whose tiles are read. A shelf of
// twenty thousand frames is twenty thousand files to open and decode; held all at
// once that is the memory of the machine, and the wall ever shows a screen of it.
// Nothing here is measured — the browser says which day bands its own box holds,
// a screen above and a screen below, and leaves the rest alone.
const [seenDays, setSeenDays] = useState<ReadonlySet<string>>(() => new Set());
useEffect(() => {
if (view !== 'grid') return;
const el = gridRef.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
setSeenDays((prev) => {
const next = new Set(prev);
let touched = false;
for (const entry of entries) {
const key = (entry.target as HTMLElement).dataset.key ?? '';
if (entry.isIntersecting) {
if (!next.has(key)) {
next.add(key);
touched = true;
}
} else if (next.delete(key)) {
touched = true;
}
}
return touched ? next : prev;
});
},
{ root: el, rootMargin: '100% 0px 100% 0px' }
);
for (const node of el.querySelectorAll('.adm-day')) io.observe(node);
return () => io.disconnect();
}, [days, view]);
// What the wall mints for: the frames of the days around the eye, and the head of
// the shelf until the first look has landed.
const nearby = useMemo(() => {
if (view !== 'grid') return drawn;
const seen = days.filter((day) => seenDays.has(`lib-day-${day.key}`));
return seen.length ? seen.flatMap((day) => day.photos) : listed.slice(0, THUMB_HEAD);
}, [view, days, seenDays, listed, drawn]);
const urls = useThumbUrls(nearby, frame, tilesAt);
// Auto-scroll & focus tree sidebar to the active node selected in last session
const treeRef = useRef<HTMLElement>(null);
@@ -3265,18 +3325,22 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
</section>
))}
</div>
{/* The rail beside the wall: the shelf's own time, one
press away from any month of it, and the entry the eye
is in said in the accent. It is read off the days the
wall was cut into (`rail`), so an entry and the header
over the frames it names are one day and not two. */}
{/* The rail beside the wall: the shelf's own time by the
year and the month, one press away from any of them,
and the month the eye is in said in the accent. It
scrolls on its own where the shelf is years long, and
it is read off the days the wall was cut into (`rail`),
so an entry and the header over the frames it names are
one cut of the shelf and not two. */}
<nav className="lib-rail" data-key="lib-rail" ref={railRef} aria-label={t('lib.rail')}>
{rail.map((mark, i) => (
<button
key={mark.at}
key={`${mark.kind}-${mark.key}`}
type="button"
className={`lib-rail-item${i === railAt ? ' on' : ''}`}
data-key={`lib-rail-${mark.at}`}
className={`lib-rail-item lib-rail-${mark.kind}${i === railAt ? ' on' : ''}`}
data-key={`lib-rail-${mark.key}`}
data-kind={mark.kind}
data-at={mark.at}
aria-current={i === railAt ? 'true' : undefined}
title={mark.title}
onClick={() => jumpToTime(mark.at)}
@@ -4071,9 +4135,10 @@ function useThumbUrls(photos: LibraryPhoto[], also?: LibraryPhoto | null, stamp?
}
}
if (Object.keys(syncUrls).length > 0) {
setUrls((prev) => ({ ...prev, ...syncUrls }));
}
// The record is the list in hand and nothing else: a day scrolled away is a day
// whose tiles are let go here, so what a long shelf holds is the screen around
// the eye rather than every frame it has ever drawn.
setUrls(syncUrls);
if (idsToFetch.length === 0) return;
+24 -5
View File
@@ -1490,16 +1490,23 @@ form[data-key='immich-modal'] {
.lib-rail {
position: relative;
flex: 0 0 auto;
width: 86px;
width: 92px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
/* A shelf of years is longer than the wall is tall, so the rail scrolls under
the reader's own hand — a thin bar of its own rather than the platform's. */
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
padding: 2px 0;
border-left: 1px solid var(--border);
}
.lib-rail::-webkit-scrollbar { display: none; }
/* An entry of the rail: the month or the day, said in the dim voice until the eye
is in it, and in the accent — with the rule that carries the eye — once it is. */
.lib-rail::-webkit-scrollbar { width: 8px; }
.lib-rail::-webkit-scrollbar-track { background: transparent; }
.lib-rail::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.lib-rail::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
/* An entry of the rail: the year standing over its months, and a month said in the
dim voice until the eye is in it — in the accent, with the rule that carries the
eye, once it is. */
.lib-rail-item {
display: block;
width: 100%;
@@ -1516,6 +1523,18 @@ form[data-key='immich-modal'] {
cursor: pointer;
}
.lib-rail-item:hover { color: var(--text); background: var(--bg-sunken); }
/* The year is a head and not a month of the shelf: it stands on its own line, above
the months it holds, and its own press takes the wall to the first of them. */
.lib-rail-year {
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border);
color: var(--text);
font-weight: 700;
letter-spacing: 0.04em;
}
.lib-rail-year:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
.lib-rail-month { padding-left: 14px; }
.lib-rail-item.on { color: var(--accent); border-left-color: var(--accent); font-weight: 600; }
/* What the thumbnail wall is read through: the selects and the count of what is
left. One row above the wall, and it does not scroll with it. */
+10 -6
View File
@@ -21,18 +21,22 @@ export function dayLabel(timestamp: number, lang: Lang): string {
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
}
// The month a day is filed under, and the line it is read by in the rail down the
// wall's right edge. Built on `dayKey`'s own spelling — a month is the first seven
// characters of the day — so the rail's entries and the days it jumps to are cut
// by one rule and not two.
// The month a day is filed under, and the way the rail down the wall's right edge
// is cut. Built on `dayKey`'s own spelling — a month is the first seven characters
// of the day, a year its first four — so the rail's entries and the days it jumps
// to are cut by one rule and not two.
export function monthKey(timestamp: number): string {
return dayKey(timestamp).slice(0, 7);
}
// "thg 10, 2026" / "Oct 2026".
export function yearKey(timestamp: number): string {
return dayKey(timestamp).slice(0, 4);
}
// "thg 10" / "Oct" — the month alone, since the year stands over it.
export function monthLabel(timestamp: number, lang: Lang): string {
const d = new Date(timestamp);
return lang === 'vi' ? `thg ${d.getMonth() + 1}, ${d.getFullYear()}` : d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
return lang === 'vi' ? `thg ${d.getMonth() + 1}` : d.toLocaleDateString(undefined, { month: 'short' });
}
export type DayRun<T> = { key: string; at: number; photos: T[] };