web: each PICTURES column is an album shelf, a frame and a strip

Both columns now have the shape the curator asked for: a narrow shelf of
albums down the left — one per account on the uploads side, one per landing
section on the other — the frame that is up in the middle, and the open
album's thumbnails as a strip across the bottom.

The frame keeps its labels, the four section boxes, the look's QR code and
the delete button under the picture, where before they sat beside it. Each
column previews its own frame; both obey the same name, sort and rating
filters.
This commit is contained in:
2026-09-18 20:38:46 +07:00
parent f263413313
commit d25649a26b
4 changed files with 215 additions and 135 deletions
+179 -134
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { api, type AdminPhoto, type AdminUser, type PhotoSlot, type Rating } from './api';
import { useI18n } from './i18n/I18nProvider';
import { Profile } from './ui/Profile';
@@ -56,6 +56,9 @@ export function Admin() {
// landing section is open on the right, and the filters both columns obey.
const [album, setAlbum] = useState<number | 'all'>('all');
const [section, setSection] = useState<PhotoSlot>('strip');
// Each column previews its own frame; both start on the newest of their album.
const [selUploads, setSelUploads] = useState<number | null>(null);
const [selSections, setSelSections] = useState<number | null>(null);
const [nameQuery, setNameQuery] = useState('');
const [sort, setSort] = useState<'newest' | 'recipe' | 'rating'>('newest');
const [minStars, setMinStars] = useState(0);
@@ -228,75 +231,123 @@ export function Admin() {
[arrange, photos, section],
);
// One frame, as both columns draw it: the picture, then everything a curator
// can do to it — labels, the four landing sections, the look's own QR code and
// the delete button — all of it under the preview.
const card = (p: AdminPhoto) => (
<article className="adm-card" key={p.id} data-key={`adm-photo-${p.id}`}>
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
<div className="adm-meta">
<b>#{p.id}</b>
{p.tag ? <span>{p.tag}</span> : null}
{p.title ? <span>{p.title}</span> : null}
{p.meta ? <span>{p.meta}</span> : null}
{p.recipeName ? (
<span>
{t('adm.recipe')}: {p.recipeName}
</span>
) : null}
<span>
{t('adm.rating')}: {score(p).toFixed(1)} ({ratings[`photo:${p.id}`]?.n ?? 0})
</span>
<span>
{t('adm.owner')}: {p.email}
</span>
<span>
{t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()}
</span>
<span>
{t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime}
</span>
// A column: the album shelf down the left, the frame that is up in the middle
// — its picture, then everything a curator can do to it (labels, the four
// section boxes, the look's own QR code, the delete button) under the picture
// — and the open album's thumbnail strip across the bottom.
const column = (
title: string,
key: string,
list: AdminPhoto[],
sel: number | null,
setSel: (id: number) => void,
shelf: ReactNode,
) => {
const up = list.find((p) => p.id === sel) ?? list[0] ?? null;
return (
<div className="adm-col" data-key={`adm-col-${key}`}>
<h3 className="adm-row-title">
{title}
<span className="adm-row-count">{list.length}</span>
</h3>
<div className="adm-split">
<div className="adm-album-list" data-key={`adm-albums-${key}`}>
{shelf}
</div>
<section className="adm-stage" data-key={`adm-stage-${key}`}>
{up ? (
<>
<img className="adm-stage-img" data-key={`adm-stage-img-${key}`} src={api.photoUrl(up.id)} alt="" />
<div className="adm-meta">
<b>#{up.id}</b>
{up.tag ? <span>{up.tag}</span> : null}
{up.title ? <span>{up.title}</span> : null}
{up.meta ? <span>{up.meta}</span> : null}
{up.recipeName ? (
<span>
{t('adm.recipe')}: {up.recipeName}
</span>
) : null}
<span>
{t('adm.rating')}: {score(up).toFixed(1)} ({ratings[`photo:${up.id}`]?.n ?? 0})
</span>
<span>
{t('adm.owner')}: {up.email}
</span>
<span>
{t('adm.uploaded')}: {new Date(up.createdAt).toLocaleString()}
</span>
<span>
{t('adm.size')}: {Math.round(up.bytes / 1024)} KB · {up.mime}
</span>
</div>
<div className="adm-choices">
{DESTINATIONS.map((d) => (
<label className="adm-choice" key={d.id}>
<input
type="checkbox"
data-key={`adm-pick-${d.id}-${up.id}`}
checked={up.slots.includes(d.id)}
// A photo with no stored look has no file to hand out, so
// the QR box would only ever draw an empty card.
disabled={busy || (d.id === 'qr' && !up.hasPreset)}
title={d.id === 'qr' && !up.hasPreset ? t('adm.noPreset') : undefined}
onChange={() =>
void setSlots(
up.id,
up.slots.includes(d.id) ? up.slots.filter((x) => x !== d.id) : [...up.slots, d.id],
)
}
/>
{t(d.key)}
</label>
))}
</div>
{/* The QR section's real artefact is the code, not the photo,
so it is drawn here under the frame — on white, because a
scanner needs the quiet zone and the dark theme has none. */}
<div className="adm-qr" data-key={`adm-qr-${up.id}`}>
{up.hasPreset ? (
<QrCode url={new URL(api.presetUrl(up.id), window.location.origin).href} size={104} />
) : (
<span>{t('adm.noPreset')}</span>
)}
</div>
<button
type="button"
className="btn adm-danger"
data-key={`adm-del-${up.id}`}
disabled={busy}
onClick={() => void remove(up.id)}
>
{t('adm.delete')}
</button>
</>
) : (
<p className="hint adm-row-empty">{t('adm.bigEmpty')}</p>
)}
</section>
</div>
{/* The strip: one thumbnail per frame the album holds, in the order the
filters and the sort chose. */}
<div className="adm-filmstrip" data-key={`adm-strip-${key}`}>
{list.map((p) => (
<button
key={p.id}
type="button"
className={`adm-thumb${up?.id === p.id ? ' on' : ''}`}
data-key={`adm-thumb-${key}-${p.id}`}
onClick={() => setSel(p.id)}
>
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
<span className="adm-thumb-cap">#{p.id}</span>
</button>
))}
{list.length === 0 ? <p className="hint adm-row-empty">{t('adm.rowEmpty')}</p> : null}
</div>
</div>
<div className="adm-choices">
{DESTINATIONS.map((d) => (
<label className="adm-choice" key={d.id}>
<input
type="checkbox"
data-key={`adm-pick-${d.id}-${p.id}`}
checked={p.slots.includes(d.id)}
// A photo with no stored look has no file to hand out, so the QR
// box would only ever draw an empty card.
disabled={busy || (d.id === 'qr' && !p.hasPreset)}
title={d.id === 'qr' && !p.hasPreset ? t('adm.noPreset') : undefined}
onChange={() =>
void setSlots(p.id, p.slots.includes(d.id) ? p.slots.filter((x) => x !== d.id) : [...p.slots, d.id])
}
/>
{t(d.key)}
</label>
))}
</div>
{/* The QR section's real artefact is the code, not the photo, so it is
drawn here under the frame — on white, because a scanner needs the
quiet zone and the dark theme has none. */}
<div className="adm-qr" data-key={`adm-qr-${p.id}`}>
{p.hasPreset ? (
<QrCode url={new URL(api.presetUrl(p.id), window.location.origin).href} size={104} />
) : (
<span>{t('adm.noPreset')}</span>
)}
</div>
<button
type="button"
className="btn adm-danger"
data-key={`adm-del-${p.id}`}
disabled={busy}
onClick={() => void remove(p.id)}
>
{t('adm.delete')}
</button>
</article>
);
);
};
return (
<div className="adm">
@@ -378,52 +429,52 @@ export function Admin() {
<p className="hint adm-sub">{t('adm.slotHint')}</p>
{photos.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
<div className="adm-cols">
{/* Two columns of the same thing: frames grouped into albums.
Left is the uploads, one album per account; right is the
landing, one album per section. Both list their albums,
both draw the open album's frames with everything a
curator can do to one sitting under its picture. */}
<div className="adm-col">
<h3 className="adm-row-title">
{t('adm.uploads')}
<span className="adm-row-count">{photos.length}</span>
</h3>
<input
type="search"
className="adm-input"
data-key="adm-filter-name"
placeholder={t('adm.filterName')}
value={nameQuery}
onChange={(e) => setNameQuery(e.target.value)}
/>
<div className="adm-filters">
<select
{/* Two columns of the same shape: the uploads on the left, one
album per account; the landing on the right, one album per
section. Each column is an album shelf, the frame that is
up with its own controls, and a thumbnail strip. */}
{column(
t('adm.uploads'),
'uploads',
albumPhotos,
selUploads,
setSelUploads,
<>
<input
type="search"
className="adm-input"
data-key="adm-sort"
aria-label={t('adm.sort')}
value={sort}
onChange={(e) => setSort(e.target.value as 'newest' | 'recipe' | 'rating')}
>
<option value="newest">{t('adm.sortNewest')}</option>
<option value="recipe">{t('adm.sortRecipe')}</option>
<option value="rating">{t('adm.sortRating')}</option>
</select>
<select
className="adm-input"
data-key="adm-filter-rating"
aria-label={t('adm.rating')}
value={String(minStars)}
onChange={(e) => setMinStars(Number(e.target.value))}
>
<option value="0">{t('adm.ratingAll')}</option>
{[5, 4, 3, 2, 1].map((n) => (
<option key={n} value={String(n)}>
{t('adm.ratingAtLeast', { n })}
</option>
))}
</select>
</div>
<div className="adm-album-list" data-key="adm-albums">
data-key="adm-filter-name"
placeholder={t('adm.filterName')}
value={nameQuery}
onChange={(e) => setNameQuery(e.target.value)}
/>
<div className="adm-filters">
<select
className="adm-input"
data-key="adm-sort"
aria-label={t('adm.sort')}
value={sort}
onChange={(e) => setSort(e.target.value as 'newest' | 'recipe' | 'rating')}
>
<option value="newest">{t('adm.sortNewest')}</option>
<option value="recipe">{t('adm.sortRecipe')}</option>
<option value="rating">{t('adm.sortRating')}</option>
</select>
<select
className="adm-input"
data-key="adm-filter-rating"
aria-label={t('adm.rating')}
value={String(minStars)}
onChange={(e) => setMinStars(Number(e.target.value))}
>
<option value="0">{t('adm.ratingAll')}</option>
{[5, 4, 3, 2, 1].map((n) => (
<option key={n} value={String(n)}>
{t('adm.ratingAtLeast', { n })}
</option>
))}
</select>
</div>
<button
type="button"
className={`adm-album${album === 'all' ? ' on' : ''}`}
@@ -445,19 +496,15 @@ export function Admin() {
<span className="adm-album-count">{a.n}</span>
</button>
))}
</div>
<div className="adm-grid" data-key="adm-photos">
{albumPhotos.map(card)}
</div>
{albumPhotos.length === 0 ? <p className="hint adm-row-empty">{t('adm.empty')}</p> : null}
</div>
<div className="adm-col">
<h3 className="adm-row-title">
{t('adm.sections')}
<span className="adm-row-count">{sectionPhotos.length}</span>
</h3>
<div className="adm-album-list" data-key="adm-sections">
</>,
)}
{column(
t('adm.sections'),
'sections',
sectionPhotos,
selSections,
setSelSections,
<>
{SECTIONS.map((d) => (
<button
key={d.id}
@@ -467,15 +514,13 @@ export function Admin() {
onClick={() => setSection(d.id)}
>
<span className="adm-album-name">{t(d.key)}</span>
<span className="adm-album-count">{photos.filter((p) => p.slots.includes(d.id)).length}</span>
<span className="adm-album-count">
{photos.filter((p) => p.slots.includes(d.id)).length}
</span>
</button>
))}
</div>
<div className="adm-grid" data-key="adm-section-photos">
{sectionPhotos.map(card)}
</div>
{sectionPhotos.length === 0 ? <p className="hint adm-row-empty">{t('adm.rowEmpty')}</p> : null}
</div>
</>,
)}
</div>
</>
) : null}
+1
View File
@@ -178,6 +178,7 @@ export const en: Dict = {
'adm.ratingAll': 'Any rating',
'adm.ratingAtLeast': '{n}★ and up',
'adm.recipe': 'Recipe',
'adm.bigEmpty': 'Pick a frame to view.',
'adm.pickStrip': 'Film strip',
'adm.pickTester': 'Live preset tester',
'adm.pickCreator': 'Custom recipe creator',
+1
View File
@@ -182,6 +182,7 @@ export const vi = {
'adm.ratingAll': 'Mọi mức xếp hạng',
'adm.ratingAtLeast': 'Từ {n} sao',
'adm.recipe': 'Công thức',
'adm.bigEmpty': 'Chọn một ảnh để xem.',
'adm.pickStrip': 'Dải phim',
'adm.pickTester': 'Live preset tester',
'adm.pickCreator': 'Custom recipe creator',
+34 -1
View File
@@ -496,7 +496,7 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
.adm-filters .adm-input { min-width: 0; }
/* A shelf of albums: it scrolls on its own so the frames below stay reachable
however many accounts or sections there are. */
.adm-album-list { display: flex; flex-direction: column; gap: 4px; max-height: 38vh; overflow-y: auto; padding-right: 2px; }
.adm-album-list { display: flex; flex-direction: column; gap: 4px; max-height: 46vh; overflow-y: auto; padding-right: 2px; }
.adm-album {
display: flex;
align-items: center;
@@ -516,6 +516,39 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
.adm-album.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.adm-album-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.adm-album-count { font-family: var(--mono); font-size: 11px; }
/* One album column: the shelf of albums down the left, the frame that is up
beside it, the album's strip of thumbnails across the bottom. */
.adm-split { display: grid; grid-template-columns: minmax(150px, 200px) minmax(0, 1fr); gap: 12px; align-items: start; }
.adm-stage {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
padding: 10px;
}
.adm-stage-img { width: 100%; aspect-ratio: 4 / 3; object-fit: contain; border-radius: var(--radius-sm); background: var(--bg-sunken); }
/* The album's strip: thumbnails in one row, scrolled sideways when they
overflow the column rather than wrapping into a wall of pictures. */
.adm-filmstrip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
.adm-thumb {
flex: 0 0 132px;
margin: 0;
padding: 0;
font: inherit;
cursor: pointer;
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
overflow: hidden;
background: var(--bg-sunken);
color: var(--text-dim);
}
.adm-thumb:hover { border-color: var(--accent-line); }
.adm-thumb.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.adm-thumb img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
.adm-thumb-cap { display: block; padding: 3px 6px; font-family: var(--mono); font-size: 10.5px; }
/* The three destinations sit on one line under the photo, each an independent
checkbox — tick all three and the photo shows in all three sections. */
.adm-choices { display: flex; flex-wrap: wrap; gap: 5px; }