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:
@@ -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">
|
||||
|
||||
@@ -173,7 +173,7 @@ export function Landing() {
|
||||
// The labels the uploader's studio stored — the tagline, the title and
|
||||
// the ISO/grain line. A contribution from before those existed keeps the
|
||||
// plain community defaults.
|
||||
...photos.filter((p) => p.slot === 'strip' && p.consent !== false).map((p) => ({
|
||||
...photos.filter((p) => p.slots.includes('strip') && p.consent !== false).map((p) => ({
|
||||
tag: p.tag ?? '#COMMUNITY',
|
||||
title: { en: p.title ?? 'Community Frame', vi: p.title ?? 'Khung cộng đồng' },
|
||||
meta: p.meta ?? `#${p.id}`,
|
||||
@@ -184,12 +184,13 @@ export function Landing() {
|
||||
[photos],
|
||||
);
|
||||
|
||||
// The three live slots. One random photo out of each set, drawn once per page
|
||||
// load so the preview does not change under the visitor's eyes on every
|
||||
// render; the built-in sample stands in while a slot has no curation.
|
||||
// The three live sections. One random photo out of each set, drawn once per
|
||||
// page load so the preview does not change under the visitor's eyes on every
|
||||
// render; the built-in sample stands in while a section has no curation. A
|
||||
// photo the curator put in several sections can turn up in each of them.
|
||||
const slotPhoto = useMemo(() => {
|
||||
const pick = (slot: PhotoSlot, fallback: string) => {
|
||||
const list = photos.filter((p) => p.slot === slot);
|
||||
const list = photos.filter((p) => p.slots.includes(slot));
|
||||
return list.length ? api.photoUrl(list[Math.floor(Math.random() * list.length)].id) : fallback;
|
||||
};
|
||||
return { tester: pick('tester', SAMPLE(1043)), creator: pick('creator', SAMPLE(1015)), qr: pick('qr', SAMPLE(1069)) };
|
||||
|
||||
@@ -31,10 +31,10 @@ export interface SavedRecipe {
|
||||
}
|
||||
|
||||
// Where a curated photo may appear on the landing page. `strip` is the
|
||||
// community reel; the rest are the three live slots, each of which shows one
|
||||
// random photo out of its set per page load. `off` keeps the row but takes it
|
||||
// off the landing entirely — the curator's removal.
|
||||
export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr' | 'off';
|
||||
// community reel; the rest are the three live sections, each of which shows one
|
||||
// random photo out of its set per page load. A photo may sit in several at
|
||||
// once; an empty set is the curator's removal from the landing.
|
||||
export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr';
|
||||
|
||||
// A strip contribution as the public sees it — the API never puts an email on
|
||||
// this shape. `tag`/`title`/`meta` are the frame's own labels (the amber
|
||||
@@ -43,7 +43,7 @@ export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr' | 'off';
|
||||
export interface Photo {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
slot: PhotoSlot;
|
||||
slots: PhotoSlot[];
|
||||
tag: string | null;
|
||||
title: string | null;
|
||||
meta: string | null;
|
||||
@@ -223,8 +223,11 @@ export const api = {
|
||||
adminListPhotos: () => call<{ photos: AdminPhoto[] }>('/admin/photos'),
|
||||
adminDeletePhoto: (id: number) => call<void>(`/admin/photos/${id}`, { method: 'DELETE' }),
|
||||
adminClearPhotos: () => call<{ removed: number }>('/admin/photos', { method: 'DELETE' }),
|
||||
adminSetPhotoSlot: (id: number, slot: PhotoSlot) =>
|
||||
call<{ id: number; slot: PhotoSlot }>(`/admin/photos/${id}`, { method: 'PATCH', body: JSON.stringify({ slot }) }),
|
||||
adminSetPhotoSlots: (id: number, slots: PhotoSlot[]) =>
|
||||
call<{ id: number; slots: PhotoSlot[] }>(`/admin/photos/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ slots }),
|
||||
}),
|
||||
|
||||
// Moderation of an account. Both flags are reversible; `adminDeleteUser` is
|
||||
// the final act and takes the account's photos and recipes with it.
|
||||
|
||||
@@ -169,7 +169,7 @@ export const en: Dict = {
|
||||
'adm.pickTester': 'Live preset tester',
|
||||
'adm.pickCreator': 'Custom recipe creator',
|
||||
'adm.rowEmpty': 'Nothing here yet.',
|
||||
'adm.slotHint': 'Pick a destination under a photo to put it there. The three live sections each draw one photo out of their set at random, so several photos in the same section rotate between visits. Picking the destination a photo already sits in takes it off the landing page without deleting it — its owner keeps it in MY PHOTOS.',
|
||||
'adm.slotHint': 'Tick the sections a photo should appear in — a photo can sit in several at once, and each section draws one of its own at random per visit. Unticking every box takes the photo off the landing page without deleting it — its owner keeps it in MY PHOTOS.',
|
||||
'adm.signIn': 'Sign in with an admin account',
|
||||
'adm.notAdmin': 'This account has no admin rights.',
|
||||
'adm.empty': 'No contributions yet.',
|
||||
|
||||
@@ -173,7 +173,7 @@ export const vi = {
|
||||
'adm.pickTester': 'Live preset tester',
|
||||
'adm.pickCreator': 'Custom recipe creator',
|
||||
'adm.rowEmpty': 'Chưa có gì ở đây.',
|
||||
'adm.slotHint': 'Bấm một đích dưới mỗi ảnh để đưa ảnh vào đó. Ba mục trực tiếp mỗi cái bốc ngẫu nhiên một ảnh trong nhóm của nó, nên nhiều ảnh cùng mục sẽ luân phiên giữa các lần ghé. Bấm đúng đích ảnh đang nằm sẽ gỡ ảnh khỏi landing mà không xoá — chủ ảnh vẫn giữ nó trong MY PHOTOS.',
|
||||
'adm.slotHint': 'Tích vào những mục ảnh sẽ xuất hiện — một ảnh có thể nằm cùng lúc ở nhiều mục, và mỗi mục bốc ngẫu nhiên một ảnh trong nhóm của nó mỗi lần ghé. Bỏ tích hết sẽ gỡ ảnh khỏi landing mà không xoá — chủ ảnh vẫn giữ nó trong MY PHOTOS.',
|
||||
'adm.signIn': 'Đăng nhập bằng tài khoản quản trị',
|
||||
'adm.notAdmin': 'Tài khoản này không có quyền quản trị.',
|
||||
'adm.empty': 'Chưa có ảnh đóng góp nào.',
|
||||
|
||||
@@ -399,24 +399,31 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||
.adm-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; align-items: start; }
|
||||
@media (max-width: 1100px) { .adm-cols { grid-template-columns: minmax(0, 1fr); } }
|
||||
.adm-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||
.adm-cols .adm-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
|
||||
.adm-choices { display: flex; flex-direction: column; gap: 4px; }
|
||||
/* Wide enough for the three chips to sit on one line: the picker is the point
|
||||
of the card, so the grid gives it the width it needs. */
|
||||
.adm-cols .adm-grid { grid-template-columns: repeat(auto-fill, minmax(450px, 1fr)); }
|
||||
/* 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; }
|
||||
.adm-choice {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
text-align: left;
|
||||
padding: 5px 8px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
color: var(--text-dim);
|
||||
background: var(--bg-sunken);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.adm-choice:hover:not(:disabled) { border-color: var(--accent-line); }
|
||||
.adm-choice.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
|
||||
.adm-choice:disabled { opacity: 0.5; cursor: default; }
|
||||
.adm-choice:hover { border-color: var(--accent-line); }
|
||||
.adm-choice:has(input:checked) { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
|
||||
.adm-choice:has(input:disabled) { opacity: 0.5; cursor: default; }
|
||||
.adm-choice input { margin: 0; accent-color: var(--accent); }
|
||||
.adm-row { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); padding: 10px; }
|
||||
.adm-row-title {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user