feat(library): the all-thumbs wall is cut into the days its frames were shot
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user