feat(library): the all-thumbs wall is cut into the days its frames were shot
This commit is contained in:
@@ -31,8 +31,20 @@ const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5034);
|
||||
const FRAMES = Number(process.env.FRAMES ?? 300);
|
||||
const FOLDER = 'WallRoll';
|
||||
// Every frame is the same bytes under a different name: what is being checked is
|
||||
// how many cards the wall will draw, not what is on them.
|
||||
// how many cards the wall will draw, not what is on them. The bytes carry no
|
||||
// EXIF, so a frame's date is its own `lastModified` — spread over the days below,
|
||||
// because a wall of one day is a wall with one header in it, and the headers are
|
||||
// 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 PER_DAY = Number(process.env.PER_DAY ?? 10);
|
||||
const DAY0 = Date.UTC(2026, 0, 1);
|
||||
// The day a frame is filed under, off its own name — the same rule on both sides
|
||||
// of the wire, so the check reads the wall the way the wall was told to sort it.
|
||||
const dayOfName = (name) => {
|
||||
const n = Number((name.match(/\d+/) ?? ['1'])[0]);
|
||||
return DAY0 + Math.floor(Math.max(0, n - 1) / PER_DAY) * DAY_MS;
|
||||
};
|
||||
|
||||
let failures = 0;
|
||||
function step(name, ok, detail = '') {
|
||||
@@ -125,14 +137,17 @@ await context.route('**/api/**', (route) => {
|
||||
// they are put back where the catalogue reads them, because a cloned handle is a
|
||||
// plain object with no `getFile`.
|
||||
await context.addInitScript(
|
||||
({ origin, names, folder }) => {
|
||||
({ origin, names, folder, days }) => {
|
||||
// `days` is the day of each frame, index-aligned with `names`: the init
|
||||
// script is cloned into the page and cannot close over the check's own scope.
|
||||
const dayOfNameIn = (name) => days[names.indexOf(name)] ?? Date.UTC(2026, 0, 1);
|
||||
const fileHandle = (name) => {
|
||||
const handle = { kind: 'file', name, __fake: true };
|
||||
Object.defineProperty(handle, 'getFile', {
|
||||
value: async () =>
|
||||
new File([await (await fetch(`${origin}/${name}`)).arrayBuffer()], name, {
|
||||
type: 'image/jpeg',
|
||||
lastModified: Date.UTC(2026, 0, 1),
|
||||
lastModified: dayOfNameIn(name),
|
||||
}),
|
||||
});
|
||||
return handle;
|
||||
@@ -155,6 +170,9 @@ await context.addInitScript(
|
||||
};
|
||||
const root = () => dirHandle(folder, { files: names });
|
||||
window.showDirectoryPicker = async () => root();
|
||||
// The day each frame was given, for the header audit below: the page's own
|
||||
// clock reads the label, so the day is read back here the same way.
|
||||
window.__dayOf = (name) => dayOfNameIn(name);
|
||||
|
||||
const revive = (row) => {
|
||||
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
|
||||
@@ -178,7 +196,7 @@ await context.addInitScript(
|
||||
};
|
||||
}
|
||||
},
|
||||
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, names, folder: FOLDER }
|
||||
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, names, folder: FOLDER, days: names.map(dayOfName) }
|
||||
);
|
||||
|
||||
const page = await context.newPage();
|
||||
@@ -208,6 +226,33 @@ await page
|
||||
const counted = await page.$eval('[data-key="lib-count"]', (e) => e.textContent?.trim() ?? '');
|
||||
step('the roll is read whole', counted.includes(String(FRAMES)), counted);
|
||||
|
||||
// The day the header of a card stands under, read off the sections themselves:
|
||||
// every header must name the day of every card filed under it. A day whose first
|
||||
// rows are above the window still gets its header where the eye lands (see
|
||||
// `dayRuns`), so the rule is "a header names its own cards" and not "one header
|
||||
// per day in the shelf".
|
||||
const dayAudit = () =>
|
||||
page.evaluate(() => {
|
||||
const key = (ts) => {
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
const out = { sections: 0, cards: 0, labelled: 0, bad: [] };
|
||||
for (const section of document.querySelectorAll('.adm-day')) {
|
||||
out.sections++;
|
||||
const own = (section.getAttribute('data-key') ?? '').replace('lib-day-', '');
|
||||
const head = section.querySelector('.adm-day-head')?.textContent?.trim() ?? '';
|
||||
if (head) out.labelled++;
|
||||
const names = [...section.querySelectorAll('[data-key^="lib-photo-"]')].map(
|
||||
(el) => (el.getAttribute('data-key') ?? '').split('/').pop()
|
||||
);
|
||||
const days = new Set(names.map((name) => key(window.__dayOf(name))));
|
||||
if (!names.length || days.size !== 1 || !days.has(own)) out.bad.push({ own, head, days: [...days], names: names.slice(0, 2) });
|
||||
}
|
||||
out.cards = document.querySelectorAll('.adm-card').length;
|
||||
return out;
|
||||
});
|
||||
|
||||
await page.click('[data-key="lib-view-grid"]');
|
||||
await page.waitForSelector('.adm-grid', { timeout: 15_000 });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.adm-card').length > 0, null, { timeout: 30_000 });
|
||||
@@ -216,6 +261,22 @@ const wall = await read();
|
||||
step('the wall draws a screenful of the shelf', wall.cards > 0 && wall.cards < FRAMES, `${wall.cards} of ${FRAMES} cards drawn`);
|
||||
step('and stands in for the rows it is not drawing', wall.wall, 'the shelf scrolls');
|
||||
|
||||
const days1 = await dayAudit();
|
||||
step('the wall is cut into the days its frames were shot', days1.sections > 1, `${days1.sections} headers over ${days1.cards} cards`);
|
||||
step('every header names the day of the cards under it', days1.bad.length === 0, JSON.stringify(days1.bad.slice(0, 2)));
|
||||
step('and every header carries its day in words', days1.labelled === days1.sections, `${days1.labelled} of ${days1.sections}`);
|
||||
|
||||
// The same wall, scrolled to where no day starts at the fold: the headers have to
|
||||
// move with it, which is the whole risk of cutting a virtualised wall into days.
|
||||
await page.evaluate(() => {
|
||||
const el = document.querySelector('.adm-grid');
|
||||
el.scrollTop = Math.round(el.scrollHeight * 0.7);
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
});
|
||||
await page.waitForTimeout(1500);
|
||||
const days2 = await dayAudit();
|
||||
step('a scrolled wall still files every card under its own day', days2.sections > 0 && days2.bad.length === 0, JSON.stringify(days2.bad.slice(0, 2)));
|
||||
|
||||
// The class of trigger, made to happen: one input the wall reads back off itself,
|
||||
// moving on every pass.
|
||||
await page.evaluate(() => {
|
||||
|
||||
@@ -360,9 +360,10 @@ export function Library() {
|
||||
}
|
||||
|
||||
import { useIsMobile } from './ui/useIsMobile';
|
||||
import { dayLabel, dayRuns } from './ui/dateGroups';
|
||||
|
||||
function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
const { t } = useI18n();
|
||||
const { t, lang } = useI18n();
|
||||
const [forceView, setForceViewState] = useState<'auto' | 'mobile' | 'desktop'>(() => {
|
||||
return (localStorage.getItem('rc_force_view') as 'auto' | 'mobile' | 'desktop') || 'auto';
|
||||
});
|
||||
@@ -2037,6 +2038,12 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
() => listed.slice(Math.min(wall.from, listed.length), Math.min(wall.to, listed.length)),
|
||||
[listed, wall.from, wall.to]
|
||||
);
|
||||
// The same slice, cut into the days it holds: a wall is read by the day a frame
|
||||
// was shot, and the line above each run is that day. Cut off the slice as it
|
||||
// stands rather than sorted into days of its own, so the sort chip keeps the
|
||||
// order it names; the first run may be a day that started above the window,
|
||||
// which is its own header drawn where the eye lands (see `dayRuns`).
|
||||
const paintedDays = useMemo(() => dayRuns(painted, (photo) => photo.taken || photo.addedAt), [painted]);
|
||||
// A tile the studio graded while this screen was dark: the copy in hand is the
|
||||
// negative's, and `onThumbWritten` hands over the one that replaces it. The
|
||||
// counter is what makes the rows look again — the list is unchanged, so nothing
|
||||
@@ -2139,7 +2146,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
// No card drawn: the wall opens on the first screenful, so aim at the row
|
||||
// and let the next pass find it. The row's step is read off a drawn card
|
||||
// when there is one, and guessed at when there is not.
|
||||
const first = el.querySelector<HTMLElement>(':scope > .adm-card');
|
||||
const first = el.querySelector<HTMLElement>('.adm-card');
|
||||
const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length);
|
||||
const pitch = first ? first.offsetHeight + WALL_GAP : 240;
|
||||
el.scrollTop = Math.max(0, Math.floor(idx / cols) * pitch - (el.clientHeight - pitch) / 2);
|
||||
@@ -2203,7 +2210,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
: null;
|
||||
const mid = el ? el.getBoundingClientRect().top + el.clientHeight / 2 : 0;
|
||||
let best: { id: string; d: number } | null = null;
|
||||
for (const card of el ? el.querySelectorAll<HTMLElement>(':scope > .adm-card') : []) {
|
||||
for (const card of el ? el.querySelectorAll<HTMLElement>('.adm-card') : []) {
|
||||
const box = card.getBoundingClientRect();
|
||||
const d = Math.abs(box.top + box.height / 2 - mid);
|
||||
if (!best || d < best.d) best = { id: card.dataset.key!.slice('lib-photo-'.length), d };
|
||||
@@ -3122,58 +3129,71 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
||||
of them is a dead screen. */}
|
||||
<div className="adm-grid" ref={wall.ref}>
|
||||
{wall.above > 0 ? <span className="adm-wall-gap" style={{ height: wall.above }} aria-hidden="true" /> : null}
|
||||
{painted.map((photo) => (
|
||||
<article
|
||||
className={`adm-card${selectedIds.has(photo.id) ? ' selected' : ''}`}
|
||||
key={photo.id}
|
||||
data-key={`lib-photo-${photo.id}`}
|
||||
onClick={(e) => handleSelectPhoto(photo, e)}
|
||||
onDoubleClick={() => {
|
||||
setUp(photo.id);
|
||||
scrolledFrameRef.current = null;
|
||||
setView('preview');
|
||||
}}
|
||||
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
|
||||
>
|
||||
{tile(photo)}
|
||||
{/* The score under the picture, the same five stars the
|
||||
stage gives: the wall is where a roll is gone
|
||||
through, and going back to the stage for every
|
||||
number is the trip this saves. The click stops at
|
||||
the star — a score is not a pick, and the card's
|
||||
own click is what selects the frame. */}
|
||||
<div
|
||||
className="lib-stars"
|
||||
data-key={`lib-stars-${photo.id}`}
|
||||
role="group"
|
||||
aria-label={t('lib.rating')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`lib-star${(photo.star ?? 0) >= n ? ' on' : ''}`}
|
||||
data-key={`lib-star-${photo.id}-${n}`}
|
||||
aria-pressed={(photo.star ?? 0) >= n}
|
||||
aria-label={t('lib.starHint', { n })}
|
||||
title={t('lib.starHint', { n })}
|
||||
onClick={() => void rate(photo, (photo.star ?? 0) === n ? 0 : n)}
|
||||
{paintedDays.map((day) => (
|
||||
<section className="adm-day" key={day.key} data-key={`lib-day-${day.key}`}>
|
||||
{/* The day above its frames, the way the phone's own grid
|
||||
reads a shelf: a card still says its date, and the band
|
||||
is what makes a roll scannable by the month it was shot. */}
|
||||
<h3 className="adm-day-head">
|
||||
<span>{dayLabel(day.at, lang)}</span>
|
||||
<span className="adm-day-line" aria-hidden="true" />
|
||||
</h3>
|
||||
<div className="adm-day-grid">
|
||||
{day.photos.map((photo) => (
|
||||
<article
|
||||
className={`adm-card${selectedIds.has(photo.id) ? ' selected' : ''}`}
|
||||
key={photo.id}
|
||||
data-key={`lib-photo-${photo.id}`}
|
||||
onClick={(e) => handleSelectPhoto(photo, e)}
|
||||
onDoubleClick={() => {
|
||||
setUp(photo.id);
|
||||
scrolledFrameRef.current = null;
|
||||
setView('preview');
|
||||
}}
|
||||
onContextMenu={(e) => handlePhotoContextMenu(photo, e)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
{tile(photo)}
|
||||
{/* The score under the picture, the same five stars the
|
||||
stage gives: the wall is where a roll is gone
|
||||
through, and going back to the stage for every
|
||||
number is the trip this saves. The click stops at
|
||||
the star — a score is not a pick, and the card's
|
||||
own click is what selects the frame. */}
|
||||
<div
|
||||
className="lib-stars"
|
||||
data-key={`lib-stars-${photo.id}`}
|
||||
role="group"
|
||||
aria-label={t('lib.rating')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDoubleClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`lib-star${(photo.star ?? 0) >= n ? ' on' : ''}`}
|
||||
data-key={`lib-star-${photo.id}-${n}`}
|
||||
aria-pressed={(photo.star ?? 0) >= n}
|
||||
aria-label={t('lib.starHint', { n })}
|
||||
title={t('lib.starHint', { n })}
|
||||
onClick={() => void rate(photo, (photo.star ?? 0) === n ? 0 : n)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
</article>
|
||||
</section>
|
||||
))}
|
||||
{wall.below > 0 ? <span className="adm-wall-gap" style={{ height: wall.below }} aria-hidden="true" /> : null}
|
||||
</div>
|
||||
@@ -4142,7 +4162,9 @@ function useWallWindow(count: number, on: boolean): { ref: RefObject<HTMLDivElem
|
||||
// single row is not nothing: it is the last row of a shelf scrolled to its
|
||||
// end, or the whole wall of a shelf of four frames. It has no average, and
|
||||
// the step it gets instead is its own height (below).
|
||||
const cards = now.querySelectorAll<HTMLElement>(':scope > .adm-card');
|
||||
// The cards are the day sections' own now: the header above a run is not a
|
||||
// card, and a row count taken with it would be a row count that drifts.
|
||||
const cards = now.querySelectorAll<HTMLElement>('.adm-card');
|
||||
const rowsDrawn = Math.floor(cards.length / cols);
|
||||
// The step from one row to the next, taken as the drawn run's own average.
|
||||
// The cards do NOT all stand the same height — a caption that wraps makes
|
||||
|
||||
@@ -1312,6 +1312,25 @@ form[data-key='immich-modal'] {
|
||||
.adm-note { margin: 12px 0; }
|
||||
.adm-sub { margin: 0 0 14px; }
|
||||
.adm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||
/* A day of the wall: the band that names it and the rows under it. It spans the
|
||||
whole grid — the wall is one column of days, not a day per cell — and the rows
|
||||
inside are the wall's own grid again, so the cards keep the columns they had
|
||||
when the wall drew them flat. */
|
||||
.adm-day { grid-column: 1 / -1; }
|
||||
.adm-day-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||
.adm-day-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 6px 0 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim, #6b7280);
|
||||
}
|
||||
/* The rule that carries the eye from the date to the edge of the wall. */
|
||||
.adm-day-line { flex: 1 1 auto; height: 1px; background: var(--border); }
|
||||
.adm-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { dayKey, dayLabel } from './dateGroups';
|
||||
import { go } from '../nav';
|
||||
import { getPhotoThumbs, readPhotoFile, remoteUrl, type LibraryDir, type LibraryFolder, type LibraryPhoto } from '../engine/library';
|
||||
import { readGps, readSpecs, specsLine } from '../engine/imageOps';
|
||||
@@ -30,21 +31,6 @@ interface MobileLibraryProps {
|
||||
onFetchYear?: (year: number) => Promise<void>;
|
||||
}
|
||||
|
||||
// Format timestamp into Immich style: "CN, 4 thg 10, 2026"
|
||||
function formatTimelineDate(timestamp: number, lang: string): string {
|
||||
const d = new Date(timestamp);
|
||||
if (lang === 'vi') {
|
||||
const days = ['CN', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7'];
|
||||
return `${days[d.getDay()]}, ${d.getDate()} thg ${d.getMonth() + 1}, ${d.getFullYear()}`;
|
||||
}
|
||||
return d.toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
// In-memory cache for local photo blob URLs
|
||||
const globalMobileThumbUrls = new Map<string, string>();
|
||||
|
||||
@@ -104,11 +90,6 @@ function useMobileThumbUrls(photos: LibraryPhoto[]): Record<string, string> {
|
||||
return urls;
|
||||
}
|
||||
|
||||
function dateKey(timestamp: number): string {
|
||||
const d = new Date(timestamp);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function MobileLibrary({
|
||||
folders,
|
||||
servers,
|
||||
@@ -563,7 +544,7 @@ export function MobileLibrary({
|
||||
const map = new Map<string, LibraryPhoto[]>();
|
||||
|
||||
for (const photo of visiblePhotos) {
|
||||
const key = dateKey(photo.taken || photo.addedAt || Date.now());
|
||||
const key = dayKey(photo.taken || photo.addedAt || Date.now());
|
||||
let arr = map.get(key);
|
||||
if (!arr) {
|
||||
arr = [];
|
||||
@@ -576,7 +557,7 @@ export function MobileLibrary({
|
||||
const firstTimestamp = items[0]?.taken || items[0]?.addedAt || Date.now();
|
||||
groups.push({
|
||||
key,
|
||||
label: formatTimelineDate(firstTimestamp, lang),
|
||||
label: dayLabel(firstTimestamp, lang),
|
||||
photos: items,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// The timeline's own reading of a date: one day per run of frames, the way both
|
||||
// screens split a shelf. Kept in one place because the wall and the phone's grid
|
||||
// have to cut the same days — two copies of "which day is this" is two answers
|
||||
// to the same question the first time either is touched.
|
||||
import type { Lang } from '../i18n/I18nProvider';
|
||||
|
||||
// The day a frame belongs to, on the reader's own clock: a frame shot at 23:50 is
|
||||
// that day's, not the next one's in UTC.
|
||||
export function dayKey(timestamp: number): string {
|
||||
const d = new Date(timestamp);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// "T2, 4 thg 10, 2026" — the line a day is read by, in either language.
|
||||
export function dayLabel(timestamp: number, lang: Lang): string {
|
||||
const d = new Date(timestamp);
|
||||
if (lang === 'vi') {
|
||||
const days = ['CN', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7'];
|
||||
return `${days[d.getDay()]}, ${d.getDate()} thg ${d.getMonth() + 1}, ${d.getFullYear()}`;
|
||||
}
|
||||
return d.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
export type DayRun<T> = { key: string; at: number; photos: T[] };
|
||||
|
||||
// The runs of one day, cut off the list as it stands rather than sorted into
|
||||
// days of their own: the wall carries the reader's sort, and a shelf ordered by
|
||||
// name is a shelf whose days interleave — a header is put where the day changes,
|
||||
// and the order of the frames is never touched.
|
||||
export function dayRuns<T>(photos: T[], at: (photo: T) => number): DayRun<T>[] {
|
||||
const runs: DayRun<T>[] = [];
|
||||
for (const photo of photos) {
|
||||
const time = at(photo);
|
||||
const key = dayKey(time);
|
||||
const last = runs[runs.length - 1];
|
||||
if (last?.key === key) last.photos.push(photo);
|
||||
else runs.push({ key, at: time, photos: [photo] });
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
Reference in New Issue
Block a user