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:
+179
-134
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user