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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user