From ee2c402438166abaa0485ba9a68fe4993831bec2 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 18 Sep 2026 16:28:55 +0700 Subject: [PATCH] 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. --- docker/frontend/src/Admin.tsx | 130 ++++++++++++++++++----------- docker/frontend/src/i18n/en.ts | 13 ++- docker/frontend/src/i18n/vi.ts | 13 ++- docker/frontend/src/styles/app.css | 41 ++++++++- 4 files changed, 133 insertions(+), 64 deletions(-) diff --git a/docker/frontend/src/Admin.tsx b/docker/frontend/src/Admin.tsx index 15b4f48..230a98e 100644 --- a/docker/frontend/src/Admin.tsx +++ b/docker/frontend/src/Admin.tsx @@ -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() {

) : null} {shown.length === 0 ?

{t('adm.empty')}

: null} -
- {shown.map((p) => ( -
- -
- #{p.id} - {/* The labels the uploader's studio stored, when it - sent any — the same ones the reel draws. */} - {p.tag ? {p.tag} : null} - {p.title ? {p.title} : null} - {p.meta ? {p.meta} : null} - {t('adm.owner')}: {p.email} - {t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()} - {t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime} -
- - -
- ))} +
+
+

{t('adm.pool')}

+
+ {shown.map((p) => ( +
+ +
+ #{p.id} + {/* The labels the uploader's studio stored, when it + sent any — the same ones the reel draws. */} + {p.tag ? {p.tag} : null} + {p.title ? {p.title} : null} + {p.meta ? {p.meta} : null} + {t('adm.owner')}: {p.email} + {t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()} + {t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime} +
+ {/* One button per destination. The one the photo is + already in reads pressed; pressing it sets `off`. */} +
+ {DESTINATIONS.map((d) => ( + + ))} +
+ +
+ ))} +
+
+
+ {DESTINATIONS.map((d) => { + const inRow = shown.filter((p) => p.slot === d.id); + return ( +
+

+ {t(d.key)} + {inRow.length} +

+ {inRow.length === 0 ? ( +

{t('adm.rowEmpty')}

+ ) : ( +
+ {inRow.map((p) => ( +
+ +
#{p.id}
+
+ ))} +
+ )} +
+ ); + })} +
) : null} diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index f47df1f..39fa44e 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -164,13 +164,12 @@ export const en: Dict = { 'adm.title': 'Strip moderation', 'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.', 'adm.upload': 'ADD A PHOTO', - 'adm.slot': 'Shows up in', - 'adm.slotStrip': 'Community film strip', - 'adm.slotTester': 'Live tester (lp-tester)', - 'adm.slotCreator': 'Live recipe section (lp-sec)', - 'adm.slotQr': 'QR card (lp-qr)', - '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.pool': 'Uploaded photos', + 'adm.pickStrip': 'Film strip', + '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.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 55f0369..5beda9b 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -168,13 +168,12 @@ export const vi = { 'adm.title': 'Quản trị dải phim', 'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.', 'adm.upload': 'THÊM ẢNH', - 'adm.slot': 'Hiển thị ở', - 'adm.slotStrip': 'Dải phim cộng đồng', - 'adm.slotTester': 'Live tester (lp-tester)', - 'adm.slotCreator': 'Mục công thức live (lp-sec)', - 'adm.slotQr': 'Thẻ QR (lp-qr)', - '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.pool': 'Ảnh đã tải lên', + 'adm.pickStrip': 'Dải phim', + '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.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.', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 6387dd3..b850551 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -394,8 +394,45 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .adm-brand { display: flex; align-items: center; gap: 6px; font-weight: 650; letter-spacing: -0.01em; color: var(--text); text-decoration: none; } .adm-brand em { font-style: normal; font-weight: 400; color: var(--accent); } .adm-brand img { border-radius: 8px; } -.adm-slot { display: flex; flex-direction: column; gap: 3px; letter-spacing: 0.04em; } -.adm-slot select { width: 100%; } +/* Curating pictures: the pool of uploads on the left, the three landing + sections on the right. One picker button per destination under each photo. */ +.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; } +.adm-choice { + font: inherit; + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + text-align: left; + padding: 5px 8px; + cursor: pointer; + color: var(--text); + 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-row { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); padding: 10px; } +.adm-row-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 8px; + font-size: 11.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-dim); +} +.adm-row-count { font-family: var(--mono); color: var(--text); } +.adm-row-empty { margin: 0; } +.adm-thumb { margin: 0; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-sunken); } +.adm-thumb img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; } +.adm-thumb figcaption { padding: 3px 6px; font-family: var(--mono); font-size: 10.5px; color: var(--text-dim); } .adm-spacer { flex: 1 1 auto; } .adm-note { margin: 12px 0; } .adm-sub { margin: 0 0 14px; }