From 97836fde8a498fb28e3d6c1629cb7b64225650f1 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 18 Sep 2026 15:07:04 +0700 Subject: [PATCH] api+admin: let the curator take a photo off the landing, not just move it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uploads still land in the community strip with consent on, by the uploader's own tick — that default stays. What was missing is the curator's removal: the slot select only offered the other three live placements, so "off the strip" meant publishing the photo somewhere else or deleting the uploader's row. `off` is a fifth slot value. The reel draws slot === 'strip' and each live slot draws its own, so an `off` photo renders nowhere on the landing, while its owner still has it in MY PHOTOS. --- docker/backend/src/db.ts | 7 +++++-- docker/backend/test/security.mjs | 10 ++++++++++ docker/frontend/src/Admin.tsx | 6 ++++-- docker/frontend/src/api.ts | 5 +++-- docker/frontend/src/i18n/en.ts | 3 ++- docker/frontend/src/i18n/vi.ts | 3 ++- 6 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index ac6bcbb..1b3c801 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -83,8 +83,11 @@ CREATE INDEX IF NOT EXISTS idx_events_at ON events(at); // Where a curated photo is allowed to appear on the landing page: the community // strip, the live tester's preview, the creator lab's preview, or the QR card. -// One is picked at random out of its slot on every page load. -export const PHOTO_SLOTS = ['strip', 'tester', 'creator', 'qr'] as const; +// One is picked at random out of its slot on every page load. `off` is the +// curator's "take it off the landing, keep the row" — the reel and the three +// live slots all draw by exact slot, so an `off` photo shows up nowhere, while +// its owner still has it in MY PHOTOS. +export const PHOTO_SLOTS = ['strip', 'tester', 'creator', 'qr', 'off'] as const; export type PhotoSlot = (typeof PHOTO_SLOTS)[number]; export const isPhotoSlot = (v: unknown): v is PhotoSlot => typeof v === 'string' && (PHOTO_SLOTS as readonly string[]).includes(v); diff --git a/docker/backend/test/security.mjs b/docker/backend/test/security.mjs index 0634720..4b8ad0d 100644 --- a/docker/backend/test/security.mjs +++ b/docker/backend/test/security.mjs @@ -304,6 +304,16 @@ try { 'the slot is public, the owner is not', ((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slot === 'qr', ); + // `off` is the curator's removal: the reel draws slot === 'strip' and the + // live slots draw their own, so the row shows up nowhere — but the uploader + // keeps it in their folder. + const off = await patch(`/admin/photos/${id}`, { slot: 'off' }); + check('an admin takes a photo off the landing', off.status === 200 && off.body?.slot === 'off', JSON.stringify(off.body)); + check( + 'an off photo is on no landing slot', + ((await actor().req('/photos')).body?.photos ?? []).find((r) => r.id === id)?.slot === 'off', + ); + check('its owner still has it in the folder', ((await user.req('/photos/mine')).body?.photos ?? []).some((p) => p.id === id)); check('an unknown slot is refused', (await patch(`/admin/photos/${id}`, { slot: 'nope' })).status === 400); check('placing an unknown photo is a 404', (await patch('/admin/photos/999999', { slot: 'qr' })).status === 404); diff --git a/docker/frontend/src/Admin.tsx b/docker/frontend/src/Admin.tsx index f3c33a1..15b4f48 100644 --- a/docker/frontend/src/Admin.tsx +++ b/docker/frontend/src/Admin.tsx @@ -19,13 +19,15 @@ import type { MsgKey } from './i18n/vi'; type State = 'loading' | 'guest' | 'forbidden' | 'ready'; type Tab = 'profile' | 'users' | 'pictures' | 'stats'; -// The four places a photo can be drawn from. `strip` feeds the community reel; -// each live slot shows one photo of its set, picked at random per page load. +// 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' }, ]; // The left column, in the order it reads. diff --git a/docker/frontend/src/api.ts b/docker/frontend/src/api.ts index 9dd074e..02c94e1 100644 --- a/docker/frontend/src/api.ts +++ b/docker/frontend/src/api.ts @@ -32,8 +32,9 @@ 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. -export type PhotoSlot = 'strip' | 'tester' | 'creator' | 'qr'; +// 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'; // 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 diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 0428122..ea2492f 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -166,7 +166,8 @@ export const en: Dict = { 'adm.slotTester': 'Live tester (lp-tester)', 'adm.slotCreator': 'Live recipe section (lp-sec)', 'adm.slotQr': 'QR card (lp-qr)', - 'adm.slotHint': 'The three live slots each draw one photo out of their set at random, so several photos in the same slot rotate between visits.', + 'adm.slotOff': 'Not on the landing page', + 'adm.slotHint': 'The three live slots each draw one photo out of their set at random, so several photos in the same slot rotate between visits. "Not on the landing page" takes a photo off the reel 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.', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index c7c0298..01dc373 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -170,7 +170,8 @@ export const vi = { 'adm.slotTester': 'Live tester (lp-tester)', 'adm.slotCreator': 'Mục công thức live (lp-sec)', 'adm.slotQr': 'Thẻ QR (lp-qr)', - 'adm.slotHint': 'Ba vị trí live mỗi lần vào trang sẽ bốc ngẫu nhiên một ảnh trong nhóm, nên nhiều ảnh cùng một vị trí sẽ luân phiên nhau.', + 'adm.slotOff': 'Không hiện trên landing page', + 'adm.slotHint': 'Ba slot 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 slot sẽ luân phiên giữa các lần ghé. "Không hiện trên landing page" 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.',