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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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[] };
|
||||
|
||||
Reference in New Issue
Block a user