web: pick a photo's landing slot with two columns

The pictures pane now reads as a pool of every upload on the left, three
destination rows on the right. Each photo carries one button per
destination; picking the one it already sits in takes it off the landing
page without deleting the row (slot off), which is what the old select's
"not on the landing page" option did.
This commit is contained in:
2026-09-18 16:28:55 +07:00
parent 4d0d44170f
commit ee2c402438
4 changed files with 133 additions and 64 deletions
+82 -48
View File
@@ -19,15 +19,15 @@ import type { MsgKey } from './i18n/vi';
type State = 'loading' | 'guest' | 'forbidden' | 'ready';
type Tab = 'profile' | 'users' | 'pictures' | 'stats';
// Where a photo is drawn from. `strip` feeds the community reel and each live
// slot shows one photo of its set, picked at random per page load. `off` is the
// way off the landing without deleting the uploader's row.
const SLOTS: { id: PhotoSlot; key: MsgKey }[] = [
{ id: 'strip', key: 'adm.slotStrip' },
{ id: 'tester', key: 'adm.slotTester' },
{ id: 'creator', key: 'adm.slotCreator' },
{ id: 'qr', key: 'adm.slotQr' },
{ id: 'off', key: 'adm.slotOff' },
// Where a photo can be put. One destination each — the landing page draws the
// film strip from `strip` and one random photo per visit out of each live set.
// Clicking the destination a photo is already in takes it off the landing
// without deleting the uploader's row (slot `off`), which is the same switch
// the old picker carried.
const DESTINATIONS: { id: PhotoSlot; key: MsgKey }[] = [
{ id: 'strip', key: 'adm.pickStrip' },
{ id: 'tester', key: 'adm.pickTester' },
{ id: 'creator', key: 'adm.pickCreator' },
];
// The left column, in the order it reads.
@@ -245,45 +245,79 @@ export function Admin() {
</p>
) : null}
{shown.length === 0 ? <p className="hint adm-note">{t('adm.empty')}</p> : null}
<div className="adm-grid">
{shown.map((p) => (
<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>
{/* The labels the uploader's studio stored, when it
sent any — the same ones the reel draws. */}
{p.tag ? <span>{p.tag}</span> : null}
{p.title ? <span>{p.title}</span> : null}
{p.meta ? <span>{p.meta}</span> : null}
<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>
</div>
<label className="hint adm-slot">
{t('adm.slot')}
<select
data-key={`adm-slot-${p.id}`}
value={p.slot}
disabled={busy}
onChange={(e) => void setSlot(p.id, e.target.value as PhotoSlot)}
>
{SLOTS.map((s) => (
<option key={s.id} value={s.id}>{t(s.key)}</option>
))}
</select>
</label>
<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>
))}
<div className="adm-cols">
<div className="adm-col">
<h3 className="adm-row-title">{t('adm.pool')}</h3>
<div className="adm-grid">
{shown.map((p) => (
<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>
{/* The labels the uploader's studio stored, when it
sent any — the same ones the reel draws. */}
{p.tag ? <span>{p.tag}</span> : null}
{p.title ? <span>{p.title}</span> : null}
{p.meta ? <span>{p.meta}</span> : null}
<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>
</div>
{/* One button per destination. The one the photo is
already in reads pressed; pressing it sets `off`. */}
<div className="adm-choices">
{DESTINATIONS.map((d) => (
<button
key={d.id}
type="button"
className={`adm-choice${p.slot === d.id ? ' on' : ''}`}
data-key={`adm-pick-${d.id}-${p.id}`}
aria-pressed={p.slot === d.id}
disabled={busy}
onClick={() => void setSlot(p.id, p.slot === d.id ? 'off' : d.id)}
>
{t(d.key)}
</button>
))}
</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>
))}
</div>
</div>
<div className="adm-col">
{DESTINATIONS.map((d) => {
const inRow = shown.filter((p) => p.slot === d.id);
return (
<section className="adm-row" key={d.id} data-key={`adm-row-${d.id}`}>
<h3 className="adm-row-title">
{t(d.key)}
<span className="adm-row-count">{inRow.length}</span>
</h3>
{inRow.length === 0 ? (
<p className="hint adm-row-empty">{t('adm.rowEmpty')}</p>
) : (
<div className="adm-grid">
{inRow.map((p) => (
<figure className="adm-thumb" key={p.id} data-key={`adm-thumb-${p.id}`}>
<img src={api.photoUrl(p.id)} alt="" loading="lazy" />
<figcaption>#{p.id}</figcaption>
</figure>
))}
</div>
)}
</section>
);
})}
</div>
</div>
</>
) : null}