diff --git a/docker/frontend/src/Admin.tsx b/docker/frontend/src/Admin.tsx index 505b3fe..9a910ca 100644 --- a/docker/frontend/src/Admin.tsx +++ b/docker/frontend/src/Admin.tsx @@ -10,9 +10,10 @@ import type { MsgKey } from './i18n/vi'; // the pane for the chosen section. // Profile — the signed-in account's own picture/email/password // User account — every account: its name, email, picture and contribution count -// Pictures — one album per user: click an album for its strip of -// thumbnails, click a thumbnail for the full frame and the -// landing sections it is allowed into +// Pictures — two columns of albums: the uploads on the left, one album +// per account; the landing on the right, one album per +// section. Every frame carries its own labels, section boxes, +// QR code and delete button under its preview // Stats — the visitor counter: views, clicks and their breakdowns // Close — leaves the frame and goes back to the landing page // The frame holds no privilege of its own — the API answers 403 unless the @@ -33,6 +34,10 @@ const DESTINATIONS: { id: PhotoSlot; key: MsgKey }[] = [ { id: 'qr', key: 'adm.pickQr' }, ]; +// Column 2 browses these as albums. QR is not one of them: its code belongs to +// the frame, so it is drawn under every preview instead of in a shelf of its own. +const SECTIONS = DESTINATIONS.filter((d) => d.id !== 'qr'); + // The left column, in the order it reads. const MENU: { id: Tab; key: MsgKey }[] = [ { id: 'profile', key: 'adm.tabProfile' }, @@ -47,11 +52,10 @@ export function Admin() { const [photos, setPhotos] = useState([]); const [users, setUsers] = useState([]); const [tab, setTab] = useState('pictures'); - // The album browser: which user's album is open ('all' is the opening view), - // which frame is up in the big box, and the three filters over the album and - // its strip. + // The two album browsers: which account's uploads are open on the left, which + // landing section is open on the right, and the filters both columns obey. const [album, setAlbum] = useState('all'); - const [sel, setSel] = useState(null); + const [section, setSection] = useState('strip'); const [nameQuery, setNameQuery] = useState(''); const [sort, setSort] = useState<'newest' | 'recipe' | 'rating'>('newest'); const [minStars, setMinStars] = useState(0); @@ -141,11 +145,10 @@ export function Admin() { }); }; - // From the users table: open that account's album and put its newest frame up - // in the big box, rather than filtering a flat pool of every upload. + // From the users table: open that account's album in the left column. const showOwner = (userId: number) => { setTab('pictures'); - openAlbum(userId); + setAlbum(userId); }; // Block/unblock and remove/restore share one route; the list is reloaded @@ -200,33 +203,100 @@ export function Admin() { }, [photos]); const shownAlbums = query ? albums.filter((a) => a.email.toLowerCase().includes(query)) : albums; - // The open album's strip: the name filter still narrows the list, the rating - // filter drops frames below the bar, and the sort orders what is left. - const strip = useMemo(() => { - const base = (album === 'all' ? photos : photos.filter((p) => p.userId === album)).filter( - (p) => !query || p.email.toLowerCase().includes(query), - ); - const list = minStars > 0 ? base.filter((p) => score(p) >= minStars) : base; - const sorted = [...list]; - if (sort === 'recipe') sorted.sort((a, b) => (a.recipeName ?? '').localeCompare(b.recipeName ?? '')); - else if (sort === 'rating') sorted.sort((a, b) => score(b) - score(a)); - else sorted.sort((a, b) => b.id - a.id); - return sorted; - // `score` reads `ratings`, which is in the deps. - }, [photos, album, minStars, sort, query, ratings]); + // What an album draws: the frames the name box kept, minus anything under the + // rating bar, in the chosen order. Both columns run it, so one frame sits + // under the same rules on either side. + const arrange = useCallback( + (base: AdminPhoto[]) => { + const named = base.filter((p) => !query || p.email.toLowerCase().includes(query)); + const sorted = [...(minStars > 0 ? named.filter((p) => score(p) >= minStars) : named)]; + if (sort === 'recipe') sorted.sort((a, b) => (a.recipeName ?? '').localeCompare(b.recipeName ?? '')); + else if (sort === 'rating') sorted.sort((a, b) => score(b) - score(a)); + else sorted.sort((a, b) => b.id - a.id); + return sorted; + // `score` reads `ratings`, which is in the deps. + }, + [query, minStars, sort, ratings], + ); - // The frame in the big box. `sel` names it, but a stale id (the photo was just - // deleted, or a filter moved on) falls back to the top of the strip so the box - // is never pointlessly empty. - const current = photos.find((p) => p.id === sel) ?? strip[0] ?? null; + const albumPhotos = useMemo( + () => arrange(album === 'all' ? photos : photos.filter((p) => p.userId === album)), + [arrange, album, photos], + ); + const sectionPhotos = useMemo( + () => arrange(photos.filter((p) => p.slots.includes(section))), + [arrange, photos, section], + ); - // Opening an album puts its newest frame up, so the big box fills on the same - // click that fills the strip. - const openAlbum = (id: number | 'all') => { - setAlbum(id); - const first = photos.filter((p) => id === 'all' || p.userId === id).sort((a, b) => b.id - a.id)[0]; - setSel(first ? first.id : null); - }; + // One frame, as both columns draw it: the picture, then everything a curator + // can do to it — labels, the four landing sections, the look's own QR code and + // the delete button — all of it under the preview. + const card = (p: AdminPhoto) => ( +
+ +
+ #{p.id} + {p.tag ? {p.tag} : null} + {p.title ? {p.title} : null} + {p.meta ? {p.meta} : null} + {p.recipeName ? ( + + {t('adm.recipe')}: {p.recipeName} + + ) : null} + + {t('adm.rating')}: {score(p).toFixed(1)} ({ratings[`photo:${p.id}`]?.n ?? 0}) + + + {t('adm.owner')}: {p.email} + + + {t('adm.uploaded')}: {new Date(p.createdAt).toLocaleString()} + + + {t('adm.size')}: {Math.round(p.bytes / 1024)} KB · {p.mime} + +
+
+ {DESTINATIONS.map((d) => ( + + ))} +
+ {/* The QR section's real artefact is the code, not the photo, so it is + drawn here under the frame — on white, because a scanner needs the + quiet zone and the dark theme has none. */} +
+ {p.hasPreset ? ( + + ) : ( + {t('adm.noPreset')} + )} +
+ +
+ ); return (
@@ -308,11 +378,16 @@ export function Admin() {

{t('adm.slotHint')}

{photos.length === 0 ?

{t('adm.empty')}

: null}
- {/* One album per account, plus ALL. The list scrolls on its - own, so the strip beside it stays put however many - accounts there are. */} + {/* Two columns of the same thing: frames grouped into albums. + Left is the uploads, one album per account; right is the + landing, one album per section. Both list their albums, + both draw the open album's frames with everything a + curator can do to one sitting under its picture. */}
-

{t('adm.albums')}

+

+ {t('adm.uploads')} + {photos.length} +

openAlbum('all')} + onClick={() => setAlbum('all')} > {t('adm.albumAll')} {photos.length} @@ -364,117 +439,42 @@ export function Admin() { type="button" className={`adm-album${album === a.userId ? ' on' : ''}`} data-key={`adm-album-${a.userId}`} - onClick={() => openAlbum(a.userId)} + onClick={() => setAlbum(a.userId)} > {a.email} {a.n} ))}
+
+ {albumPhotos.map(card)} +
+ {albumPhotos.length === 0 ?

{t('adm.empty')}

: null}
- {/* The frame that is up: the full picture, its labels and - the sections it may appear in — the same checkboxes the - flat pool carried, now on the chosen frame. */} -
- {current ? ( - <> - -
-
- #{current.id} - {current.tag ? {current.tag} : null} - {current.title ? {current.title} : null} - {current.meta ? {current.meta} : null} - {current.recipeName ? ( - - {t('adm.recipe')}: {current.recipeName} - - ) : null} - - {t('adm.rating')}: {score(current).toFixed(1)} ( - {ratings[`photo:${current.id}`]?.n ?? 0}) - - - {t('adm.owner')}: {current.email} - - - {t('adm.uploaded')}: {new Date(current.createdAt).toLocaleString()} - - - {t('adm.size')}: {Math.round(current.bytes / 1024)} KB · {current.mime} - -
-
- {DESTINATIONS.map((d) => ( - - ))} -
- {/* The QR section's real artefact is the code, not - the photo, so it is drawn here when the frame is - in that section and has a look to hand out. */} - {current.hasPreset && current.slots.includes('qr') ? ( -
- -
- ) : null} - -
- - ) : ( -

{t('adm.bigEmpty')}

- )} -
- - {/* The album's strip: one thumbnail per frame, in the order - the filters and the sort chose. */} -
- {strip.map((p) => ( +

+ {t('adm.sections')} + {sectionPhotos.length} +

+
+ {SECTIONS.map((d) => ( ))} - {strip.length === 0 ?

{t('adm.empty')}

: null}
+
+ {sectionPhotos.map(card)} +
+ {sectionPhotos.length === 0 ?

{t('adm.rowEmpty')}

: null}
diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index aa3101f..2ce98a2 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -166,7 +166,8 @@ 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.albums': 'User albums', + 'adm.uploads': 'Uploaded photos', + 'adm.sections': 'Landing sections', 'adm.albumAll': 'All photos', 'adm.filterName': 'Filter by user name', 'adm.sort': 'Sort', @@ -177,7 +178,6 @@ export const en: Dict = { 'adm.ratingAll': 'Any rating', 'adm.ratingAtLeast': '{n}★ and up', 'adm.recipe': 'Recipe', - 'adm.bigEmpty': 'Pick a thumbnail to view.', 'adm.pickStrip': 'Film strip', 'adm.pickTester': 'Live preset tester', 'adm.pickCreator': 'Custom recipe creator', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 385665b..0664278 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -170,7 +170,8 @@ 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.albums': 'Album người dùng', + 'adm.uploads': 'Ảnh đã tải lên', + 'adm.sections': 'Mục trên landing', 'adm.albumAll': 'Tất cả ảnh', 'adm.filterName': 'Lọc theo tên người dùng', 'adm.sort': 'Sắp xếp', @@ -181,7 +182,6 @@ export const vi = { 'adm.ratingAll': 'Mọi mức xếp hạng', 'adm.ratingAtLeast': 'Từ {n} sao', 'adm.recipe': 'Công thức', - 'adm.bigEmpty': 'Chọn một ảnh để xem.', 'adm.pickStrip': 'Dải phim', 'adm.pickTester': 'Live preset tester', 'adm.pickCreator': 'Custom recipe creator', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 16a6e03..3749f53 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -475,10 +475,10 @@ 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; } -/* Curating pictures: the albums on the left, the chosen frame and its strip of - thumbnails on the right. The album column is the narrow one — it is a list of - names — and scrolls on its own so the strip beside it never moves. */ -.adm-cols { display: grid; grid-template-columns: minmax(200px, 280px) minmax(0, 1fr); gap: 16px; align-items: start; } +/* Curating pictures: two album columns of the same shape — the uploads (one + album per account) and the landing (one album per section). Each column + lists its albums and then draws the open album's frames. */ +.adm-cols { display: grid; grid-template-columns: repeat(2, 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; } /* The filters over the album list: one name box, two pickers. */ @@ -494,7 +494,9 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } } .adm-filters { display: flex; gap: 6px; } .adm-filters .adm-input { min-width: 0; } -.adm-album-list { display: flex; flex-direction: column; gap: 4px; max-height: 62vh; overflow-y: auto; padding-right: 2px; } +/* A shelf of albums: it scrolls on its own so the frames below stay reachable + however many accounts or sections there are. */ +.adm-album-list { display: flex; flex-direction: column; gap: 4px; max-height: 38vh; overflow-y: auto; padding-right: 2px; } .adm-album { display: flex; align-items: center; @@ -514,39 +516,6 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .adm-album.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); } .adm-album-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .adm-album-count { font-family: var(--mono); font-size: 11px; } -/* The frame that is up: the picture on the left, everything about it on the - right — including the four landing sections it may appear in. */ -.adm-big { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(220px, 300px); - gap: 12px; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--bg); - padding: 10px; -} -.adm-big-img { width: 100%; aspect-ratio: 4 / 3; object-fit: contain; border-radius: var(--radius-sm); background: var(--bg-sunken); } -.adm-big-side { display: flex; flex-direction: column; gap: 8px; min-width: 0; } -.adm-qr-inline { border-radius: var(--radius-sm); } -/* The album's strip: thumbnails in one row, scrolled sideways when they - overflow the pane rather than wrapping into a wall of pictures. */ -.adm-filmstrip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; } -.adm-thumb { - flex: 0 0 132px; - margin: 0; - padding: 0; - font: inherit; - cursor: pointer; - border: 1px solid var(--border-soft); - border-radius: var(--radius-sm); - overflow: hidden; - background: var(--bg-sunken); - color: var(--text-dim); -} -.adm-thumb:hover { border-color: var(--accent-line); } -.adm-thumb.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } -.adm-thumb img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; } -.adm-thumb-cap { display: block; padding: 3px 6px; font-family: var(--mono); font-size: 10.5px; } /* 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; } @@ -569,7 +538,6 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .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; align-items: center;