web: a photo can sit in every landing section at once

The picker was one dropdown, so a photo lived in exactly one place. The three
destinations are now independent checkboxes on the card, and the column holds
the set as a comma list — the landing page draws a photo in every section it
was ticked into, each still picking one of its own at random per visit.

Ticking nothing is what `off` used to be: the row is kept and the landing page
stops drawing it, which is what the old "not on the landing page" option did.
This commit is contained in:
2026-09-18 16:47:12 +07:00
parent ee2c402438
commit 260517c547
9 changed files with 148 additions and 83 deletions
+38 -21
View File
@@ -19,11 +19,10 @@ import type { MsgKey } from './i18n/vi';
type State = 'loading' | 'guest' | 'forbidden' | 'ready';
type Tab = 'profile' | 'users' | 'pictures' | 'stats';
// 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.
// Where a photo can be put. The boxes are independent — a photo may sit in all
// three sections at once, and each section draws one random photo per visit out
// of its own set. Unticking every box takes the photo off the landing without
// deleting the uploader's row, 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' },
@@ -102,10 +101,21 @@ export function Admin() {
await refreshPhotos();
});
const setSlot = (id: number, slot: PhotoSlot) =>
// One checkbox tick: the whole new set goes to the API in one call. The box
// flips under the curator's finger first — waiting for the round trip would
// snap it back for a frame and read as a lost click — and a failed write puts
// the old set back.
const setSlots = (id: number, next: PhotoSlot[]) =>
run(async () => {
await api.adminSetPhotoSlot(id, slot);
setPhotos((prev) => prev.map((p) => (p.id === id ? { ...p, slot } : p)));
const before = photos.find((p) => p.id === id)?.slots ?? [];
const show = (slots: PhotoSlot[]) => setPhotos((prev) => prev.map((p) => (p.id === id ? { ...p, slots } : p)));
show(next);
try {
await api.adminSetPhotoSlots(id, next);
} catch (err) {
show(before);
throw err;
}
});
const clear = () => {
@@ -263,21 +273,28 @@ export function Admin() {
<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`. */}
{/* One checkbox per destination, all three on one
line: tick as many as the photo should appear in,
untick the last one to take it off the landing. */}
<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)}
>
<label className="adm-choice" key={d.id}>
<input
type="checkbox"
data-key={`adm-pick-${d.id}-${p.id}`}
checked={p.slots.includes(d.id)}
disabled={busy}
onChange={() =>
void setSlots(
p.id,
p.slots.includes(d.id)
? p.slots.filter((x) => x !== d.id)
: [...p.slots, d.id],
)
}
/>
{t(d.key)}
</button>
</label>
))}
</div>
<button
@@ -295,7 +312,7 @@ export function Admin() {
</div>
<div className="adm-col">
{DESTINATIONS.map((d) => {
const inRow = shown.filter((p) => p.slot === d.id);
const inRow = shown.filter((p) => p.slots.includes(d.id));
return (
<section className="adm-row" key={d.id} data-key={`adm-row-${d.id}`}>
<h3 className="adm-row-title">