|
|
|
@@ -1,4 +1,4 @@
|
|
|
|
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type RefObject } from 'react';
|
|
|
|
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties, type RefObject } from 'react';
|
|
|
|
|
import { useI18n } from './i18n/I18nProvider';
|
|
|
|
|
import { go } from './nav';
|
|
|
|
|
import { api, type User } from './api';
|
|
|
|
@@ -207,35 +207,20 @@ const TILE_STEP = 140;
|
|
|
|
|
// meets pictures rather than placeholders, few enough that the count does not
|
|
|
|
|
// matter: what is drawn is this many, whatever the shelf holds.
|
|
|
|
|
const STRIP_KEEP = 8;
|
|
|
|
|
// How many rows of the wall past each edge of its viewport are drawn. A row of
|
|
|
|
|
// cards is a couple of hundred pixels tall, so this is the row under the eye and
|
|
|
|
|
// two beyond it — enough that a flick lands on pictures.
|
|
|
|
|
const WALL_ROWS = 2;
|
|
|
|
|
// How many tiles the wall reads and decodes at once when it is the one asking.
|
|
|
|
|
// The wall's own frames come first and they come visibly: two at a time, the
|
|
|
|
|
// thread handed back between them (`breathe`), is a shelf that fills in front of
|
|
|
|
|
// the reader — a screenful of frames read end to end is a dead screen.
|
|
|
|
|
const WALL_LANES = 2;
|
|
|
|
|
// A folder is walked into with a screenful in sight, not the whole roll: the tiles
|
|
|
|
|
// asked for when a folder opens are the ones about to be drawn, and the wall mints
|
|
|
|
|
// the rest as it scrolls. A job over every frame of the roll is a decode of every
|
|
|
|
|
// frame of the roll — half an hour of disk for a folder nobody has scrolled past
|
|
|
|
|
// the first screen of.
|
|
|
|
|
// asked for when a folder opens are the ones about to be drawn. A job over every
|
|
|
|
|
// frame of the roll is a decode of every frame of the roll — half an hour of disk
|
|
|
|
|
// for a folder nobody has scrolled past the first screen of.
|
|
|
|
|
// Sixty and not four hundred: a tile is a decode and a JPEG encode on the main
|
|
|
|
|
// thread (`makeThumbs`, two lanes), and four hundred of them queued behind one
|
|
|
|
|
// folder opening is the reader waiting on tiles for frames three screens below
|
|
|
|
|
// the one they are looking at. The wall mints the rest as it scrolls, which is
|
|
|
|
|
// who the queue was for.
|
|
|
|
|
// the one they are looking at.
|
|
|
|
|
const THUMB_AHEAD = 60;
|
|
|
|
|
// What `.adm-grid` leaves between its rows. A spacer has to stand the same
|
|
|
|
|
// distance past the last row it replaces as the row itself would have.
|
|
|
|
|
const WALL_GAP = 12;
|
|
|
|
|
// How many windows the wall may write in a row before it is talking to itself.
|
|
|
|
|
// A window read off the window before it is a loop, and React answers the fifty
|
|
|
|
|
// turns of one with #185 and a blank screen; four is more than an honest change
|
|
|
|
|
// needs — a re-column takes two passes — and the count is handed back by the
|
|
|
|
|
// first scroll, resize or change of shelf the reader makes.
|
|
|
|
|
const WALL_CARRIES = 4;
|
|
|
|
|
// 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);
|
|
|
|
@@ -360,7 +345,13 @@ export function Library() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
import { useIsMobile } from './ui/useIsMobile';
|
|
|
|
|
import { bandRows, dayAt, dayKey, dayLabel, dayRuns, photoRow, rowAt, rowPhoto, rowTop, wallLayout, type WallSteps } from './ui/dateGroups';
|
|
|
|
|
import { dayLabel, dayRuns, monthKey, monthLabel } 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 };
|
|
|
|
|
|
|
|
|
|
function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
|
|
|
|
const { t, lang } = useI18n();
|
|
|
|
@@ -2022,39 +2013,117 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// The wall is the other list, and it draws its rows the way the strip draws its
|
|
|
|
|
// tiles: the ones around the eye, with a spacer standing in for the rest. A card
|
|
|
|
|
// is a card, an `<img>` and an object URL of its own, and twenty thousand of them
|
|
|
|
|
// is seven seconds of dead screen — the URL is only a tenth of it; the rest is
|
|
|
|
|
// the DOM and the number the browser formats for each date. The frame on the
|
|
|
|
|
// stage is given a picture wherever it sits — the stage is not the wall.
|
|
|
|
|
// Where each day of the shelf begins, cut by the same rule the bands are drawn
|
|
|
|
|
// by (`dayRuns`): the wall stands in rows for the days it is not drawing, so it
|
|
|
|
|
// is the shelf's own days it counts and not the window's.
|
|
|
|
|
const dayStarts = useMemo(() => {
|
|
|
|
|
const starts: number[] = [];
|
|
|
|
|
const at = (photo: (typeof listed)[number]) => photo.taken || photo.addedAt;
|
|
|
|
|
for (let i = 0; i < listed.length; i++) {
|
|
|
|
|
if (!i || dayKey(at(listed[i])) !== dayKey(at(listed[i - 1]))) starts.push(i);
|
|
|
|
|
// The wall is the other list, and it is drawn whole. It used to be drawn the way
|
|
|
|
|
// the strip is — the rows around the eye, with a spacer standing in for the rest
|
|
|
|
|
// — and what the reader sees in that is a shelf tearing itself open: the frames
|
|
|
|
|
// below the fold are not in the DOM to be scrolled to, so a day is drawn a couple
|
|
|
|
|
// of rows deep and grows as the eye walks down it, a frame scrolled back up to is
|
|
|
|
|
// a frame the wall has to build again, and the shelf's own end is a spacer that
|
|
|
|
|
// has to be right to the pixel or the scroll stops in a screen of nothing.
|
|
|
|
|
//
|
|
|
|
|
// What makes it safe to draw whole is that the wall now holds no state: no
|
|
|
|
|
// measure, no spacer, no model of where a row stands — the browser lays the grid
|
|
|
|
|
// out once and scrolls it, and there is no second answer to be at odds with the
|
|
|
|
|
// shelf drawn. `loading="lazy"` on the tiles (see `TileImg`) is what keeps the
|
|
|
|
|
// frames below the fold from being read before the eye is near them, and the
|
|
|
|
|
// tiles behind them are minted off the whole list, a couple of lanes at a time
|
|
|
|
|
// (`useThumbUrls`), so a day scrolled to has its pictures rather than a rack of
|
|
|
|
|
// empty cards.
|
|
|
|
|
//
|
|
|
|
|
// ponytail: the DOM is the whole shelf — twenty thousand frames is twenty
|
|
|
|
|
// thousand cards built up front, which is seconds of dead screen. When a roll
|
|
|
|
|
// that size is real, the wall goes back to a window, cut by the day rather than
|
|
|
|
|
// by the row: a day is a height the browser can be told in advance
|
|
|
|
|
// (`content-visibility` and the frames under it), which is what a window of rows
|
|
|
|
|
// could not be.
|
|
|
|
|
//
|
|
|
|
|
// The wall is read by the day a frame was shot, and the line above each run is
|
|
|
|
|
// that day. Cut off the list as it stands rather than sorted into days of its
|
|
|
|
|
// own, so the sort chip keeps the order it names.
|
|
|
|
|
const days = useMemo(() => dayRuns(listed, (photo) => photo.taken || photo.addedAt), [listed]);
|
|
|
|
|
// The wall's own box, and the rail is read off it: the scroll that reaches every
|
|
|
|
|
// 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.
|
|
|
|
|
const rail = useMemo(() => {
|
|
|
|
|
const months: RailMark[] = [];
|
|
|
|
|
const each: RailMark[] = [];
|
|
|
|
|
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) });
|
|
|
|
|
}
|
|
|
|
|
each.push({ key: day.key, at: day.key, label: String(new Date(day.at).getDate()), title: dayLabel(day.at, lang) });
|
|
|
|
|
}
|
|
|
|
|
return starts;
|
|
|
|
|
}, [listed]);
|
|
|
|
|
const wall = useWallWindow(listed.length, dayStarts, view === 'grid');
|
|
|
|
|
// Memoised like `drawn`, and for a reason of its own: `useThumbUrls` keys its
|
|
|
|
|
// effect on the array it is handed, so a fresh slice every render made that
|
|
|
|
|
// effect run every render — a `setUrls` per pass, a render per `setUrls`, and
|
|
|
|
|
// thousands of passes a second in which the tiles of the rows scrolled to never
|
|
|
|
|
// land at all.
|
|
|
|
|
const painted = useMemo(
|
|
|
|
|
() => 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]);
|
|
|
|
|
return months.length > 1 ? months : each;
|
|
|
|
|
}, [days, lang]);
|
|
|
|
|
const [railAt, setRailAt] = useState(0);
|
|
|
|
|
const railRef = useRef<HTMLElement>(null);
|
|
|
|
|
|
|
|
|
|
// Which entry the eye is on. The days are watched from the wall's own box and
|
|
|
|
|
// the one crossing its top band is the day being read; the entry that owns it is
|
|
|
|
|
// the month (or the day) its key begins with. Nothing is measured here and
|
|
|
|
|
// nothing is written back to the wall — the browser is the one that knows, and it
|
|
|
|
|
// says so. A flick can leave the band empty; the rail keeps the entry it had
|
|
|
|
|
// rather than blinking back to the top of the shelf.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (view !== 'grid') return;
|
|
|
|
|
const el = gridRef.current;
|
|
|
|
|
if (!el) return;
|
|
|
|
|
const nodes = [...el.querySelectorAll<HTMLElement>('.adm-day')];
|
|
|
|
|
if (!nodes.length) return;
|
|
|
|
|
const place = new Map(nodes.map((node, i) => [node.dataset.key ?? '', i]));
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
const io = new IntersectionObserver(
|
|
|
|
|
(entries) => {
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
const key = (entry.target as HTMLElement).dataset.key ?? '';
|
|
|
|
|
if (entry.isIntersecting) seen.add(key);
|
|
|
|
|
else seen.delete(key);
|
|
|
|
|
}
|
|
|
|
|
let best = -1;
|
|
|
|
|
for (const key of seen) {
|
|
|
|
|
const i = place.get(key) ?? -1;
|
|
|
|
|
if (i >= 0 && (best < 0 || i < best)) best = i;
|
|
|
|
|
}
|
|
|
|
|
if (best < 0) return;
|
|
|
|
|
const key = (nodes[best].dataset.key ?? '').replace('lib-day-', '');
|
|
|
|
|
const found = rail.findIndex((mark) => key.startsWith(mark.key));
|
|
|
|
|
setRailAt(found < 0 ? 0 : found);
|
|
|
|
|
},
|
|
|
|
|
{ root: el, rootMargin: '0px 0px -88% 0px' }
|
|
|
|
|
);
|
|
|
|
|
for (const node of nodes) io.observe(node);
|
|
|
|
|
return () => io.disconnect();
|
|
|
|
|
}, [rail, days, view]);
|
|
|
|
|
|
|
|
|
|
// The rail's own scroll: the entry being read is kept by the middle of the
|
|
|
|
|
// column, so a shelf of years is not a rail standing on its own top.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const el = railRef.current;
|
|
|
|
|
const item = el?.children[railAt] as HTMLElement | undefined;
|
|
|
|
|
if (!el || !item) return;
|
|
|
|
|
const want = Math.max(0, item.offsetTop - el.clientHeight / 2 + item.offsetHeight / 2);
|
|
|
|
|
if (Math.abs(el.scrollTop - want) > 8) el.scrollTop = want;
|
|
|
|
|
}, [railAt]);
|
|
|
|
|
|
|
|
|
|
// A press on an entry takes the wall to the day it stands for: the header is at
|
|
|
|
|
// a place the grid already knows, and the scroll is put where it stands — a jump
|
|
|
|
|
// and not a step, and the highlight follows the eye from there like any other
|
|
|
|
|
// scroll.
|
|
|
|
|
const jumpToTime = (key: string) => {
|
|
|
|
|
const el = gridRef.current;
|
|
|
|
|
const day = el?.querySelector<HTMLElement>(`[data-key="lib-day-${CSS.escape(key)}"]`);
|
|
|
|
|
if (!el || !day) return;
|
|
|
|
|
const box = el.getBoundingClientRect();
|
|
|
|
|
el.scrollTop = Math.max(0, el.scrollTop + day.getBoundingClientRect().top - box.top - 6);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
@@ -2073,7 +2142,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
|
|
|
|
}),
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
const urls = useThumbUrls(view === 'preview' ? drawn : painted, frame, tilesAt);
|
|
|
|
|
const urls = useThumbUrls(view === 'preview' ? drawn : listed, frame, tilesAt);
|
|
|
|
|
|
|
|
|
|
// Auto-scroll & focus tree sidebar to the active node selected in last session
|
|
|
|
|
const treeRef = useRef<HTMLElement>(null);
|
|
|
|
@@ -2127,20 +2196,16 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
|
|
|
|
}, [view, frame?.id, listed, node, strip.ref]);
|
|
|
|
|
|
|
|
|
|
// And the wall's half of it: the frame the strip was on, brought to the middle
|
|
|
|
|
// of the wall. The card is not in the DOM to begin with — the wall only paints
|
|
|
|
|
// the rows around its viewport — so the first passes go to where the row should
|
|
|
|
|
// be and the ones after that read the card itself, which is the only honest
|
|
|
|
|
// measure: a row stands as tall as its longest caption. The loop is bounded
|
|
|
|
|
// rather than driven by state, because the wall reports its window by
|
|
|
|
|
// re-rendering and a scroll set inside a render is a scroll the next render
|
|
|
|
|
// undoes.
|
|
|
|
|
// of the wall. The card is in the DOM — the wall draws the whole shelf — so this
|
|
|
|
|
// is one scroll of the grid and no model of where a row stands. The loop is for
|
|
|
|
|
// the pass the list has not landed on yet, when there is no card to measure and
|
|
|
|
|
// what has to run this again is the list itself, not a frame of waiting.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (view !== 'grid') return;
|
|
|
|
|
const id = centredRef.current;
|
|
|
|
|
const el = wall.ref.current;
|
|
|
|
|
const el = gridRef.current;
|
|
|
|
|
if (!id || !el) return;
|
|
|
|
|
const idx = listed.findIndex((p) => p.id === id);
|
|
|
|
|
if (idx < 0) {
|
|
|
|
|
if (!listed.some((p) => p.id === id)) {
|
|
|
|
|
centredRef.current = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
@@ -2153,29 +2218,8 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
|
|
|
|
const box = el.getBoundingClientRect();
|
|
|
|
|
const own = card.getBoundingClientRect();
|
|
|
|
|
el.scrollTop = Math.max(0, el.scrollTop + own.top - box.top + own.height / 2 - el.clientHeight / 2);
|
|
|
|
|
} else {
|
|
|
|
|
// No card drawn: the wall opens on the first screenful, so aim at the row
|
|
|
|
|
// the frame stands in and let the next pass find it. The shelf's own steps
|
|
|
|
|
// and the rows its days are counted in are the wall's, read off the same
|
|
|
|
|
// layout its window is measured against — a step of this effect's own is a
|
|
|
|
|
// second answer to a question the wall has already answered.
|
|
|
|
|
const first = el.querySelector<HTMLElement>('.adm-card');
|
|
|
|
|
const cols = Math.max(1, getComputedStyle(el).gridTemplateColumns.split(' ').length);
|
|
|
|
|
const steps = wall.steps.current;
|
|
|
|
|
if (steps.row > 0) {
|
|
|
|
|
const layout = wallLayout(dayStarts, listed.length, cols, steps);
|
|
|
|
|
const row = photoRow(dayStarts, cols, layout.order, idx);
|
|
|
|
|
el.scrollTop = Math.max(0, Math.round(rowTop(layout, row) - (el.clientHeight - steps.row) / 2));
|
|
|
|
|
} else {
|
|
|
|
|
// A step guessed at, for the one pass before the wall has drawn a card
|
|
|
|
|
// to measure: the row the frame stands in is counted over the shelf's own
|
|
|
|
|
// bands, not over its own index — every day spends a row above its first
|
|
|
|
|
// frame.
|
|
|
|
|
const pitch = first ? first.offsetHeight + WALL_GAP : 240;
|
|
|
|
|
const order = bandRows(dayStarts, listed.length, cols);
|
|
|
|
|
const row = photoRow(dayStarts, cols, order, idx);
|
|
|
|
|
el.scrollTop = Math.max(0, Math.round(row * pitch - (el.clientHeight - pitch) / 2));
|
|
|
|
|
}
|
|
|
|
|
centredRef.current = null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (--left > 0) requestAnimationFrame(settle);
|
|
|
|
|
else centredRef.current = null;
|
|
|
|
@@ -2187,7 +2231,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
|
|
|
|
// `up` is in the list for the frame the studio hands back: it is the one that
|
|
|
|
|
// moves when the shelf comes up on another frame, and a wall standing where it
|
|
|
|
|
// already was is a wall that would not follow it.
|
|
|
|
|
}, [view, listed, dayStarts, wall.ref, up]);
|
|
|
|
|
}, [view, listed, up]);
|
|
|
|
|
|
|
|
|
|
// This screen's own comings and goings: it never unmounts, so `here` is the
|
|
|
|
|
// only sign of the shelf being the screen up again (see `here` above).
|
|
|
|
@@ -2229,7 +2273,7 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
|
|
|
|
// looked up in `listed` too, because a pick made in another folder is not a
|
|
|
|
|
// frame this shelf can raise.
|
|
|
|
|
const openFromWall = () => {
|
|
|
|
|
const el = wall.ref.current;
|
|
|
|
|
const el = gridRef.current;
|
|
|
|
|
const picked =
|
|
|
|
|
lastSelectedId && selectedIds.has(lastSelectedId) && listed.some((p) => p.id === lastSelectedId)
|
|
|
|
|
? lastSelectedId
|
|
|
|
@@ -3147,81 +3191,100 @@ function Catalogue({ admin, user }: { admin: boolean; user: User | null }) {
|
|
|
|
|
|
|
|
|
|
{view === 'grid' ? (
|
|
|
|
|
<>
|
|
|
|
|
{/* The wall is the shelf's own length, so the scrollbar on
|
|
|
|
|
its right edge still reaches every frame; what is drawn
|
|
|
|
|
is the rows around the eye, with a spacer standing in
|
|
|
|
|
for the rows above and below. A card is a card, an
|
|
|
|
|
<img> and an object URL of its own, and twenty thousand
|
|
|
|
|
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}
|
|
|
|
|
{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)}
|
|
|
|
|
>
|
|
|
|
|
{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()}
|
|
|
|
|
{/* The wall is the shelf itself — every day of it, drawn — so
|
|
|
|
|
its scrollbar on the right edge reaches every frame and a
|
|
|
|
|
day scrolled to is a day that was already there. The rail
|
|
|
|
|
beside it is the shelf's time: the month the eye is in,
|
|
|
|
|
and a press on one takes the wall to it. */}
|
|
|
|
|
<div className="lib-wall">
|
|
|
|
|
<div className="adm-grid" data-key="lib-wall" ref={gridRef}>
|
|
|
|
|
{days.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)}
|
|
|
|
|
>
|
|
|
|
|
{[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>
|
|
|
|
|
</section>
|
|
|
|
|
))}
|
|
|
|
|
{wall.below > 0 ? <span className="adm-wall-gap" style={{ height: wall.below }} aria-hidden="true" /> : null}
|
|
|
|
|
{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>
|
|
|
|
|
</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. */}
|
|
|
|
|
<nav className="lib-rail" data-key="lib-rail" ref={railRef} aria-label={t('lib.rail')}>
|
|
|
|
|
{rail.map((mark, i) => (
|
|
|
|
|
<button
|
|
|
|
|
key={mark.at}
|
|
|
|
|
type="button"
|
|
|
|
|
className={`lib-rail-item${i === railAt ? ' on' : ''}`}
|
|
|
|
|
data-key={`lib-rail-${mark.at}`}
|
|
|
|
|
aria-current={i === railAt ? 'true' : undefined}
|
|
|
|
|
title={mark.title}
|
|
|
|
|
onClick={() => jumpToTime(mark.at)}
|
|
|
|
|
>
|
|
|
|
|
{mark.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</nav>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
) : frame ? (
|
|
|
|
@@ -4097,181 +4160,3 @@ function useStripWindow(count: number, on: boolean): { ref: RefObject<HTMLDivEle
|
|
|
|
|
}, [count, on]);
|
|
|
|
|
return { ref, from: win.from, to: win.to };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Which rows of the wall are drawn: the ones under its viewport and `WALL_ROWS`
|
|
|
|
|
// either side, with a spacer standing in for the rest so the scrollbar still spans
|
|
|
|
|
// the shelf. The wall holds every frame the open node does — a filter is a way of
|
|
|
|
|
// looking, not a second shelf — and a card is a card, an `<img>` and an object URL
|
|
|
|
|
// of its own; twenty thousand of them is a dead screen for as long as they take to
|
|
|
|
|
// build. `on` is the wall being up, since the element measured is its own.
|
|
|
|
|
function useWallWindow(
|
|
|
|
|
count: number,
|
|
|
|
|
dayStarts: number[],
|
|
|
|
|
on: boolean
|
|
|
|
|
): {
|
|
|
|
|
ref: RefObject<HTMLDivElement>;
|
|
|
|
|
from: number;
|
|
|
|
|
to: number;
|
|
|
|
|
above: number;
|
|
|
|
|
below: number;
|
|
|
|
|
// The steps the shelf is stacked in, which the wall reads off the cards it has
|
|
|
|
|
// drawn and keeps. Handed back as the ref itself, not its value: the settle
|
|
|
|
|
// effect aims a scroll with them on the pass after the one that measured them.
|
|
|
|
|
steps: { current: WallSteps };
|
|
|
|
|
} {
|
|
|
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
|
// The first window is a screenful: the wall has not been measured yet.
|
|
|
|
|
const [win, setWin] = useState(() => ({ from: 0, to: Math.min(count, 24), above: 0, below: 0 }));
|
|
|
|
|
// The window last written, because the reader of a `setWin` in a layout effect
|
|
|
|
|
// is not the state: a set made from a layout effect renders again whether or
|
|
|
|
|
// not the value moved, and this one is remeasured after every render — so a set
|
|
|
|
|
// it did not need is the screen's own render loop (React #185, "Maximum update
|
|
|
|
|
// depth exceeded"). The ref is the guard: nothing is set that the wall already
|
|
|
|
|
// stands at.
|
|
|
|
|
const winRef = useRef(win);
|
|
|
|
|
const measureRef = useRef<() => void>(() => {});
|
|
|
|
|
// The two steps the shelf is stacked in, taken once per column count and kept.
|
|
|
|
|
// They are refs rather than values read on every pass because the pass reads
|
|
|
|
|
// them off the rows the window drew — and a step that moves with the window is
|
|
|
|
|
// a window that moves with the step: the two trade places forever (see
|
|
|
|
|
// `measure`).
|
|
|
|
|
const stepsRef = useRef<WallSteps>({ band: 0, row: 0, gap: WALL_GAP });
|
|
|
|
|
const colsRef = useRef(0);
|
|
|
|
|
// The window over a scroll container is taken rather than kept, so it is taken
|
|
|
|
|
// after every render: the first pass draws a screenful, the next reads the row's
|
|
|
|
|
// step off it and lays out the spacers, and the pass after that leaves both
|
|
|
|
|
// alone. The listeners are for the two things no render can see coming — a
|
|
|
|
|
// scroll of the wall, and the window itself changing size.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!on) return;
|
|
|
|
|
const el = ref.current;
|
|
|
|
|
if (!el) return;
|
|
|
|
|
// The window is only ever set through here: a `setWin` the wall already
|
|
|
|
|
// stands at is a render bought for nothing, and this is measured after every
|
|
|
|
|
// render — that is the loop (React #185).
|
|
|
|
|
// And the second guard, for a reader the first one cannot see: a window
|
|
|
|
|
// written off the window before it is a window that can write itself, and
|
|
|
|
|
// whatever the shelf does between the two there are only so many turns of
|
|
|
|
|
// that before React gives up with #185 — a blank screen, this app having no
|
|
|
|
|
// error boundary between it and the floor. `ponytail:` past the count the
|
|
|
|
|
// wall stands still, one window behind, until something outside moves — the
|
|
|
|
|
// next scroll, resize, or count hands the pen straight back.
|
|
|
|
|
let carries = 0;
|
|
|
|
|
const show = (next: { from: number; to: number; above: number; below: number }) => {
|
|
|
|
|
const prev = winRef.current;
|
|
|
|
|
if (prev.from === next.from && prev.to === next.to && prev.above === next.above && prev.below === next.below) return;
|
|
|
|
|
if (carries >= WALL_CARRIES) return;
|
|
|
|
|
carries++;
|
|
|
|
|
winRef.current = next;
|
|
|
|
|
setWin(next);
|
|
|
|
|
};
|
|
|
|
|
const measure = () => {
|
|
|
|
|
// The window is measured off the wall that is up, not off the one this
|
|
|
|
|
// effect was set up on: the stage is drawn while a node is open, so a visit
|
|
|
|
|
// that loses its nodes and gets them back draws a new wall, and a window
|
|
|
|
|
// measured off the old node — a box of nothing, a scroll always nought — is
|
|
|
|
|
// a window that never moves again for the rest of the visit.
|
|
|
|
|
const now = ref.current;
|
|
|
|
|
if (!now) return;
|
|
|
|
|
// A wall that is not standing has no box to measure: the library is
|
|
|
|
|
// `display:none` while the studio is up, and a window read off it would be
|
|
|
|
|
// a window read off nothing — every card reports `offsetTop` 0 and every
|
|
|
|
|
// scroll 0 — which threw the shelf back to its top every time the studio
|
|
|
|
|
// handed back to it. It is read on the frame it comes back up, which is a
|
|
|
|
|
// box change.
|
|
|
|
|
if (!now.clientHeight) return;
|
|
|
|
|
// And a window that is nowhere on the shelf stands nowhere: outside a shelf
|
|
|
|
|
// that shrank under it — another folder, a filter, a shorter roll — or empty
|
|
|
|
|
// while the shelf is not, which is the first paint, before the catalogue has
|
|
|
|
|
// landed. This is the one reset, and it is written off the shelf's own
|
|
|
|
|
// length rather than off the cards the last window drew, which is what made
|
|
|
|
|
// the reset a window the window fed.
|
|
|
|
|
const held = winRef.current.to - winRef.current.from;
|
|
|
|
|
if (held <= 0 || winRef.current.from >= count || winRef.current.to > count) {
|
|
|
|
|
show({ from: 0, to: Math.min(count, 24), above: 0, below: 0 });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// How many columns is the grid's own business, read off its layout and not
|
|
|
|
|
// counted off the cards the window drew: the drawn run is a whole number of
|
|
|
|
|
// rows, those rows were read back into the count, and a count read off the
|
|
|
|
|
// run is a count the window feeds itself with. This is the same read the
|
|
|
|
|
// settle effect above scrolls the wall by.
|
|
|
|
|
const cols = Math.max(1, getComputedStyle(now).gridTemplateColumns.split(' ').length);
|
|
|
|
|
// The rows the window drew, counted the way the wall lays them out: a day's
|
|
|
|
|
// band is a row of its own (`grid-column: 1 / -1`) and its frames fill the
|
|
|
|
|
// rows under it. A row count taken off the cards alone is a count that
|
|
|
|
|
// leaves out every band — and the bands are what the rows are counted in.
|
|
|
|
|
const sections = [...now.querySelectorAll<HTMLElement>('.adm-day')];
|
|
|
|
|
// The two steps the shelf is stacked in, read off the drawn days: the band's
|
|
|
|
|
// own height, from a day's own box less the rows of frames it stands over,
|
|
|
|
|
// and the step from one row of frames to the next, which is a card and the
|
|
|
|
|
// grid's own gap. A band is a line of words and a row of frames is a
|
|
|
|
|
// picture: one step taken as their average is a step that is wrong for both,
|
|
|
|
|
// and the spacers standing in for the rows not drawn come out short or long
|
|
|
|
|
// by however much of the shelf is bands — which moves as the window does.
|
|
|
|
|
// Read the first time the wall stands at this many columns, and kept until
|
|
|
|
|
// it stands at another: a step re-read on every pass made the window depend
|
|
|
|
|
// on itself — `to` traded places with a second sum a row away, sixty times a
|
|
|
|
|
// second, until React gave up with #185 and the screen went blank. One step,
|
|
|
|
|
// one window. It is re-read when the wall re-columns, the only thing that
|
|
|
|
|
// changes a card's own height (a caption wraps at the width it is given); a
|
|
|
|
|
// resize that does not re-column keeps the step, and a spacer a few pixels
|
|
|
|
|
// out is a scrollbar a few pixels out, not a blank screen.
|
|
|
|
|
if (colsRef.current !== cols) {
|
|
|
|
|
const first = sections[0];
|
|
|
|
|
const card = first?.querySelector<HTMLElement>('.adm-card');
|
|
|
|
|
if (first && card) {
|
|
|
|
|
const row = card.offsetHeight + WALL_GAP;
|
|
|
|
|
const band = first.getBoundingClientRect().height - Math.ceil(first.querySelectorAll('.adm-card').length / cols) * row;
|
|
|
|
|
if (row > 0 && band > 0) {
|
|
|
|
|
colsRef.current = cols;
|
|
|
|
|
stepsRef.current = { band, row, gap: WALL_GAP };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// And a run with no steps to read — a shelf of one day whose only card is
|
|
|
|
|
// not in the DOM yet, a wall of four frames — has none to measure with. It
|
|
|
|
|
// is left to the pass after this one, which has the cards: returning here
|
|
|
|
|
// instead is a window that cannot grow, and there is a shelf it strands —
|
|
|
|
|
// the reset above puts a year of four frames at {0, 4}, and every pass after
|
|
|
|
|
// it reads that one row and leaves, so the shelf stands at four cards for
|
|
|
|
|
// the rest of the visit.
|
|
|
|
|
const steps = stepsRef.current;
|
|
|
|
|
if (!(steps.band > 0 && steps.row > 0) || !dayStarts.length) return;
|
|
|
|
|
// The shelf measured in those steps: the two spacers, the rows drawn, and
|
|
|
|
|
// the scrollbar between them are all read off the same layout, so the
|
|
|
|
|
// shelf's own height does not move with the window.
|
|
|
|
|
const layout = wallLayout(dayStarts, count, cols, steps);
|
|
|
|
|
const row0 = Math.max(0, rowAt(layout, now.scrollTop) - WALL_ROWS);
|
|
|
|
|
const row1 = Math.min(layout.rows, rowAt(layout, now.scrollTop + now.clientHeight) + WALL_ROWS);
|
|
|
|
|
const from = rowPhoto(dayStarts, count, cols, layout.order, row0);
|
|
|
|
|
const to = Math.max(from, rowPhoto(dayStarts, count, cols, layout.order, row1));
|
|
|
|
|
// What the two spacers stand for: the days above the window, and the days
|
|
|
|
|
// under it. A spacer's own height keeps the gap the grid would have left
|
|
|
|
|
// under the last day it stands in for, which is why the gap comes off both.
|
|
|
|
|
const first = dayAt(dayStarts, from);
|
|
|
|
|
const last = dayAt(dayStarts, Math.max(from, to - 1));
|
|
|
|
|
const above = first ? Math.max(0, layout.tops[first] - WALL_GAP) : 0;
|
|
|
|
|
const below = Math.max(0, layout.height - layout.ends[last] - WALL_GAP);
|
|
|
|
|
show({ from, to, above, below });
|
|
|
|
|
};
|
|
|
|
|
measureRef.current = measure;
|
|
|
|
|
const onOutside = () => {
|
|
|
|
|
carries = 0;
|
|
|
|
|
measure();
|
|
|
|
|
};
|
|
|
|
|
el.addEventListener('scroll', onOutside, { passive: true });
|
|
|
|
|
const ro = new ResizeObserver(onOutside);
|
|
|
|
|
ro.observe(el);
|
|
|
|
|
return () => {
|
|
|
|
|
measureRef.current = () => {};
|
|
|
|
|
el.removeEventListener('scroll', onOutside);
|
|
|
|
|
ro.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, [count, dayStarts, on]);
|
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
|
measureRef.current();
|
|
|
|
|
});
|
|
|
|
|
return { ref, from: win.from, to: win.to, above: win.above, below: win.below, steps: stepsRef };
|
|
|
|
|
}
|
|
|
|
|