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:
@@ -5,8 +5,13 @@
|
||||
// node holds is a card (the wall is drawn whole — no window over it, so no card
|
||||
// arriving late and no spacer standing in for one), every card sits under the band
|
||||
// of its own day, the last frame is reachable by scrolling with nothing left under
|
||||
// it, and the rail down the wall's right edge — the shelf's own time — reads the
|
||||
// day the eye is in and takes the wall to the day it is pressed for.
|
||||
// it, and the rail down the wall's right edge — the shelf's own time, by the year
|
||||
// and the month and nothing finer — scrolls under the reader's hand, reads the month
|
||||
// the eye is in and takes the wall to the month or the year it is pressed for.
|
||||
//
|
||||
// The wall is also the one screen that reads the whole shelf, so what it mints is
|
||||
// watched here too: a roll of years is a rail of months, and a rail whose every entry
|
||||
// is in the box it is read through.
|
||||
//
|
||||
// It is drawn whole for a reason this check keeps an eye on: the wall used to be a
|
||||
// window over the shelf, and a window measured off the window before it cost a
|
||||
@@ -31,8 +36,8 @@ const APP_PORT = Number(process.env.APP_PORT ?? 5033);
|
||||
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5034);
|
||||
const FRAMES = Number(process.env.FRAMES ?? 300);
|
||||
const FOLDER = 'WallRoll';
|
||||
// The second roll: a shelf of one month, which the rail reads by the day rather
|
||||
// than by the month — the other half of what the rail is (`rail` in Library.tsx),
|
||||
// The second roll: a shelf of a single month, which the rail reads with one head and
|
||||
// one month under it — the other half of what the rail is (`rail` in Library.tsx),
|
||||
// and the shape a folder per event has. Its frames are two days of twenty.
|
||||
const SMALL_FOLDER = 'OneDayRoll';
|
||||
const SMALL_EACH = 20;
|
||||
@@ -44,7 +49,11 @@ const SMALL_DAYS = 2;
|
||||
// half of what this check is for.
|
||||
const names = Array.from({ length: FRAMES }, (_, i) => `frame-${String(i + 1).padStart(4, '0')}.jpg`);
|
||||
const DAY_MS = 86400000;
|
||||
const DAY0 = Date.UTC(2026, 0, 1);
|
||||
// The roll is spread over years, a shooting day every forty days or so: a shelf of
|
||||
// months is what the rail is for, and a rail that holds three of them is a rail that
|
||||
// never has to be scrolled — the years are what make it longer than the wall is tall.
|
||||
const DAY_SPREAD = 40;
|
||||
const DAY0 = Date.UTC(2018, 0, 1);
|
||||
// The days the roll was shot on, and they are not equal: a roll is an afternoon
|
||||
// of three frames and a wedding of four hundred, and the wall's own model of its
|
||||
// rows is the thing that has to survive that. A shelf of equal days is a shelf a
|
||||
@@ -73,7 +82,7 @@ const dayIndex = (n) => {
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * DAY_MS;
|
||||
const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * DAY_SPREAD * DAY_MS;
|
||||
|
||||
// The second roll's own names and days: `one-0001` onwards, twenty to a day, over
|
||||
// the fourth and fifth of March 2026 — one month, two days, which is the shelf the
|
||||
@@ -100,7 +109,9 @@ const keyOf = (ts) => {
|
||||
const d = new Date(ts);
|
||||
return `${monthOf(ts)}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
const yearOf = (ts) => String(new Date(ts).getFullYear());
|
||||
const MONTHS = [...new Set(names.map((name) => monthOf(dayOf(name))))];
|
||||
const YEARS = [...new Set(names.map((name) => yearOf(dayOf(name))))];
|
||||
const SMALL_DAY_KEYS = [...new Set(smallNames.map((name) => keyOf(dayOf(name))))];
|
||||
// The days the big roll is cut into, which is what the wall draws bands for.
|
||||
const BIG_DAYS = (() => {
|
||||
@@ -359,7 +370,7 @@ step('every header names the day of the cards under it', days1.bad.length === 0,
|
||||
// What the rail is read for: the entries it holds, the entry the eye is in, and where
|
||||
// a press on one takes the wall. The day at the fold is read off the sections
|
||||
// themselves — the first one crossing the wall's top band, which is the rule the rail
|
||||
// itself reads by — and the entry the wall says it is in is read off the accent.
|
||||
// itself reads by — and the month the wall says it is in is read off the accent.
|
||||
const railOf = () =>
|
||||
page.evaluate(() => {
|
||||
const grid = document.querySelector('.adm-grid');
|
||||
@@ -377,16 +388,23 @@ const railOf = () =>
|
||||
}
|
||||
const on = document.querySelector('.lib-rail-item.on');
|
||||
return {
|
||||
kinds: items.map((b) => b.getAttribute('data-kind') ?? ''),
|
||||
keys: items.map((b) => (b.getAttribute('data-key') ?? '').replace('lib-rail-', '')),
|
||||
// The day a press on the entry sends the wall to, which the entry carries.
|
||||
ats: items.map((b) => b.getAttribute('data-at') ?? ''),
|
||||
labels: items.map((b) => b.textContent.trim()),
|
||||
onKind: on?.getAttribute('data-kind') ?? null,
|
||||
on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null,
|
||||
fold,
|
||||
scroll: Math.round(rail?.scrollTop ?? 0),
|
||||
tops: items.map((b) => b.offsetTop),
|
||||
heights: items.map((b) => b.offsetHeight),
|
||||
box: rail?.clientHeight ?? 0,
|
||||
whole: rail?.scrollHeight ?? 0,
|
||||
};
|
||||
});
|
||||
const monthsOf = (rail) => rail.keys.filter((_, i) => rail.kinds[i] === 'month');
|
||||
const yearsOf = (rail) => rail.keys.filter((_, i) => rail.kinds[i] === 'year');
|
||||
const stops = [0.15, 0.35, 0.55, 0.75, 0.95];
|
||||
const seenMonths = new Set();
|
||||
let scrolledBad = [];
|
||||
@@ -401,13 +419,13 @@ for (const at of stops) {
|
||||
const audit = await dayAudit();
|
||||
scrolledBad = scrolledBad.concat(audit.bad);
|
||||
const rail = await railOf();
|
||||
// The entry the rail says the eye is in has to own the day at the fold — the month
|
||||
// its key begins with — and the entry has to be inside the rail's own box: a rail
|
||||
// read for the whole shelf and standing on its own top is a rail that says nothing.
|
||||
if (!rail.fold || !rail.on || rail.fold.slice(0, 7) !== rail.on.slice(0, 7)) {
|
||||
railBad.push({ at, fold: rail.fold, on: rail.on });
|
||||
// The month the rail says the eye is in has to own the day at the fold, and the
|
||||
// entry has to be inside the rail's own box: a rail read for the whole shelf and
|
||||
// standing on its own top is a rail that says nothing.
|
||||
if (!rail.fold || !rail.on || rail.onKind !== 'month' || rail.fold.slice(0, 7) !== rail.on.slice(0, 7)) {
|
||||
railBad.push({ at, fold: rail.fold, on: rail.on, kind: rail.onKind });
|
||||
} else {
|
||||
seenMonths.add(rail.on.slice(0, 7));
|
||||
seenMonths.add(rail.on);
|
||||
const i = rail.keys.indexOf(rail.on);
|
||||
const y = (rail.tops[i] ?? 0) - rail.scroll;
|
||||
if (y < -2 || y + (rail.heights[i] ?? 0) > rail.box + 2) railBad.push({ at, on: rail.on, y, box: rail.box });
|
||||
@@ -419,33 +437,72 @@ step(
|
||||
JSON.stringify(scrolledBad.slice(0, 2))
|
||||
);
|
||||
const rail1 = await railOf();
|
||||
const railMonths = [...new Set(rail1.keys.map((k) => k.slice(0, 7)))].sort();
|
||||
step('the rail holds the months of the shelf', railMonths.join() === [...MONTHS].sort().join(), `${rail1.keys.join(' ')}`);
|
||||
const months1 = monthsOf(rail1);
|
||||
// The rail carries the shelf's own order — the reader's sort, months down the wall as
|
||||
// the wall holds them — so what is compared here is the set of months, not the order
|
||||
// twice over.
|
||||
step(
|
||||
'the rail holds the shelf by the year and the month, and by nothing finer',
|
||||
[...months1].sort().join() === [...MONTHS].sort().join() &&
|
||||
[...yearsOf(rail1)].sort().join() === [...YEARS].sort().join() &&
|
||||
rail1.kinds.every((k) => k === 'year' || k === 'month'),
|
||||
`${yearsOf(rail1).length} years, ${months1.length} months of ${MONTHS.length}`
|
||||
);
|
||||
step(
|
||||
'and the entry it reads follows the eye down the shelf',
|
||||
railBad.length === 0 && seenMonths.size === MONTHS.length,
|
||||
railBad.length === 0 && seenMonths.size >= 3,
|
||||
`${seenMonths.size} of ${MONTHS.length} months read, ${JSON.stringify(railBad.slice(0, 2))}`
|
||||
);
|
||||
|
||||
// And the press: the month's own first day is where the wall has to stand, its header
|
||||
// just under the wall's top edge, and the entry pressed is the one read afterwards.
|
||||
const jumped = rail1.keys[1];
|
||||
await page.click(`[data-key="lib-rail-${jumped}"]`);
|
||||
await page.waitForTimeout(700);
|
||||
const landed = await page.evaluate((key) => {
|
||||
const grid = document.querySelector('.adm-grid');
|
||||
const section = grid.querySelector(`[data-key="lib-day-${key}"]`);
|
||||
const box = grid.getBoundingClientRect();
|
||||
const on = document.querySelector('.lib-rail-item.on');
|
||||
return {
|
||||
top: section ? Math.round(section.getBoundingClientRect().top - box.top) : null,
|
||||
on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null,
|
||||
};
|
||||
}, jumped);
|
||||
step(
|
||||
'a press on a month takes the wall to that day',
|
||||
landed.top !== null && Math.abs(landed.top - 6) <= 6 && landed.on === jumped,
|
||||
`header at ${landed.top}, entry ${landed.on}`
|
||||
'the rail is longer than the wall is tall, and scrolls under the reader',
|
||||
rail1.whole > rail1.box,
|
||||
`${rail1.whole}px of rail in ${rail1.box}px of box`
|
||||
);
|
||||
|
||||
// And the press: the shelf's own first day of the month is where the wall has to
|
||||
// stand, its header just under the wall's top edge, the entry pressed is the month
|
||||
// read afterwards, and the rail brings that entry into its own box.
|
||||
const press = async (key) => {
|
||||
await page.click(`[data-key="lib-rail-${key}"]`);
|
||||
await page.waitForTimeout(700);
|
||||
return page.evaluate((k) => {
|
||||
const grid = document.querySelector('.adm-grid');
|
||||
const rail = document.querySelector('.lib-rail');
|
||||
const button = document.querySelector(`[data-key="lib-rail-${k}"]`);
|
||||
const at = button?.getAttribute('data-at') ?? null;
|
||||
const section = at ? grid.querySelector(`[data-key="lib-day-${at}"]`) : null;
|
||||
const on = document.querySelector('.lib-rail-item.on');
|
||||
const y = button ? button.offsetTop - rail.scrollTop : null;
|
||||
return {
|
||||
at,
|
||||
top: section ? Math.round(section.getBoundingClientRect().top - grid.getBoundingClientRect().top) : null,
|
||||
cards: section ? section.querySelectorAll('.adm-card').length : 0,
|
||||
on: on ? (on.getAttribute('data-key') ?? '').replace('lib-rail-', '') : null,
|
||||
onKind: on?.getAttribute('data-kind') ?? null,
|
||||
seen: y !== null && y >= -2 && y + (button?.offsetHeight ?? 0) <= rail.clientHeight + 2,
|
||||
};
|
||||
}, key);
|
||||
};
|
||||
const jumped = months1[Math.floor(months1.length / 2)];
|
||||
const landed = await press(jumped);
|
||||
step(
|
||||
'a press on a month stands the wall on that month, and the rail on its entry',
|
||||
landed.top !== null &&
|
||||
Math.abs(landed.top - 6) <= 6 &&
|
||||
landed.on === jumped &&
|
||||
landed.onKind === 'month' &&
|
||||
landed.seen,
|
||||
`header at ${landed.top} of ${landed.at}, entry ${landed.on}, in rail ${landed.seen}`
|
||||
);
|
||||
|
||||
// The year is a head and not a month of the shelf: a press on it stands the wall on
|
||||
// the first month the shelf holds of that year, and the eye then reads that month.
|
||||
const yearRow = yearsOf(rail1)[1];
|
||||
const overYear = await press(yearRow);
|
||||
step(
|
||||
'a press on a year stands the wall on the first month it holds',
|
||||
overYear.top !== null && Math.abs(overYear.top - 6) <= 6 && overYear.at?.slice(0, 4) === yearRow && overYear.on === overYear.at?.slice(0, 7),
|
||||
`year ${yearRow}: header at ${overYear.top} of ${overYear.at}, entry ${overYear.on}`
|
||||
);
|
||||
|
||||
// The shelf walked to its own end, a screen at a time — a jump straight to the bottom
|
||||
@@ -483,6 +540,21 @@ const walked = await page.evaluate(async () => {
|
||||
step('the shelf is walked to its own end', walked.reached, `stopped at ${walked.top} of ${walked.room}`);
|
||||
step('and stands on nothing there', walked.gap !== null && walked.gap <= 16, `${walked.gap}px of nothing under the last card`);
|
||||
|
||||
// What the wall mints, read at the far end of the shelf: the tiles are the frames of
|
||||
// the days around the eye, and a shelf walked end to end is not a shelf of tiles held
|
||||
// end to end. A frame the wall holds no tile for draws nothing at all — a folder off
|
||||
// the disk is a file only this page can open — so the count of blob-backed images is
|
||||
// the count of tiles the wall is holding.
|
||||
const held = await page.evaluate(() => ({
|
||||
cards: document.querySelectorAll('.adm-grid .adm-card').length,
|
||||
tiles: document.querySelectorAll('.adm-grid img[src^="blob:"]').length,
|
||||
}));
|
||||
step(
|
||||
'the wall mints the tiles around the eye and not the whole shelf',
|
||||
held.tiles > 0 && held.tiles < held.cards / 2,
|
||||
`${held.tiles} tiles held of ${held.cards} cards`
|
||||
);
|
||||
|
||||
// A wall resized under its own eye: the class of input that cost the screen once, when
|
||||
// the wall measured itself off itself. It re-columns, the rail re-reads, and the reader
|
||||
// is still standing in the library after it.
|
||||
@@ -518,32 +590,17 @@ await page.waitForFunction(
|
||||
await page.click('[data-key="lib-view-grid"]');
|
||||
await page.waitForTimeout(1500);
|
||||
const rail3 = await railOf();
|
||||
const smallMonth = SMALL_DAY_KEYS[0].slice(0, 7);
|
||||
step(
|
||||
'a shelf of one month is read by the day',
|
||||
[...rail3.keys].sort().join() === [...SMALL_DAY_KEYS].sort().join() && rail3.keys.length === SMALL_DAY_KEYS.length,
|
||||
'a shelf of one month is still read by the year and the month',
|
||||
rail3.kinds.join() === 'year,month' && yearsOf(rail3).length === 1 && monthsOf(rail3).join() === smallMonth,
|
||||
`${rail3.labels.join(' ')} / ${rail3.keys.join(' ')}`
|
||||
);
|
||||
const plainDay = SMALL_DAY_KEYS[0];
|
||||
await page.click(`[data-key="lib-rail-${plainDay}"]`);
|
||||
await page.waitForTimeout(700);
|
||||
const small = await page.evaluate((key) => {
|
||||
const grid = document.querySelector('.adm-grid');
|
||||
const section = grid.querySelector(`[data-key="lib-day-${key}"]`);
|
||||
const box = grid.getBoundingClientRect();
|
||||
const on = document.querySelector('.lib-rail-item.on');
|
||||
return {
|
||||
top: section ? Math.round(section.getBoundingClientRect().top - box.top) : null,
|
||||
cards: section ? section.querySelectorAll('.adm-card').length : 0,
|
||||
on: on?.textContent.trim() ?? null,
|
||||
};
|
||||
}, plainDay);
|
||||
const overMonth = await press(smallMonth);
|
||||
step(
|
||||
'a press on a day takes the wall to it, whole',
|
||||
small.top !== null &&
|
||||
Math.abs(small.top - 6) <= 6 &&
|
||||
small.cards === SMALL_EACH &&
|
||||
small.on === String(Number(plainDay.slice(8, 10))),
|
||||
`header at ${small.top}, ${small.cards} cards, entry ${small.on}`
|
||||
'and a press on it stands the wall on that month, whole',
|
||||
overMonth.top !== null && Math.abs(overMonth.top - 6) <= 6 && overMonth.cards === SMALL_EACH && overMonth.on === smallMonth,
|
||||
`header at ${overMonth.top}, ${overMonth.cards} cards, entry ${overMonth.on}`
|
||||
);
|
||||
|
||||
await page.screenshot({ path: process.env.SHOT ?? '/tmp/wall-check.png' }).catch(() => {});
|
||||
|
||||
@@ -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