From d25649a26bc5408f8711e2ec4df70b966a6dde54 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 18 Sep 2026 20:38:46 +0700 Subject: [PATCH] web: each PICTURES column is an album shelf, a frame and a strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both columns now have the shape the curator asked for: a narrow shelf of albums down the left — one per account on the uploads side, one per landing section on the other — the frame that is up in the middle, and the open album's thumbnails as a strip across the bottom. The frame keeps its labels, the four section boxes, the look's QR code and the delete button under the picture, where before they sat beside it. Each column previews its own frame; both obey the same name, sort and rating filters. --- docker/frontend/src/Admin.tsx | 313 +++++++++++++++++------------ docker/frontend/src/i18n/en.ts | 1 + docker/frontend/src/i18n/vi.ts | 1 + docker/frontend/src/styles/app.css | 35 +++- 4 files changed, 215 insertions(+), 135 deletions(-) diff --git a/docker/frontend/src/Admin.tsx b/docker/frontend/src/Admin.tsx index 9a910ca..a1b4cf9 100644 --- a/docker/frontend/src/Admin.tsx +++ b/docker/frontend/src/Admin.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { api, type AdminPhoto, type AdminUser, type PhotoSlot, type Rating } from './api'; import { useI18n } from './i18n/I18nProvider'; import { Profile } from './ui/Profile'; @@ -56,6 +56,9 @@ export function Admin() { // landing section is open on the right, and the filters both columns obey. const [album, setAlbum] = useState('all'); const [section, setSection] = useState('strip'); + // Each column previews its own frame; both start on the newest of their album. + const [selUploads, setSelUploads] = useState(null); + const [selSections, setSelSections] = useState(null); const [nameQuery, setNameQuery] = useState(''); const [sort, setSort] = useState<'newest' | 'recipe' | 'rating'>('newest'); const [minStars, setMinStars] = useState(0); @@ -228,75 +231,123 @@ export function Admin() { [arrange, photos, section], ); - // 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} - + // A column: the album shelf down the left, the frame that is up in the middle + // — its picture, then everything a curator can do to it (labels, the four + // section boxes, the look's own QR code, the delete button) under the picture + // — and the open album's thumbnail strip across the bottom. + const column = ( + title: string, + key: string, + list: AdminPhoto[], + sel: number | null, + setSel: (id: number) => void, + shelf: ReactNode, + ) => { + const up = list.find((p) => p.id === sel) ?? list[0] ?? null; + return ( +
+

+ {title} + {list.length} +

+
+
+ {shelf} +
+
+ {up ? ( + <> + +
+ #{up.id} + {up.tag ? {up.tag} : null} + {up.title ? {up.title} : null} + {up.meta ? {up.meta} : null} + {up.recipeName ? ( + + {t('adm.recipe')}: {up.recipeName} + + ) : null} + + {t('adm.rating')}: {score(up).toFixed(1)} ({ratings[`photo:${up.id}`]?.n ?? 0}) + + + {t('adm.owner')}: {up.email} + + + {t('adm.uploaded')}: {new Date(up.createdAt).toLocaleString()} + + + {t('adm.size')}: {Math.round(up.bytes / 1024)} KB · {up.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. */} +
+ {up.hasPreset ? ( + + ) : ( + {t('adm.noPreset')} + )} +
+ + + ) : ( +

{t('adm.bigEmpty')}

+ )} +
+
+ {/* The strip: one thumbnail per frame the album holds, in the order the + filters and the sort chose. */} +
+ {list.map((p) => ( + + ))} + {list.length === 0 ?

{t('adm.rowEmpty')}

: null} +
-
- {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 (
@@ -378,52 +429,52 @@ export function Admin() {

{t('adm.slotHint')}

{photos.length === 0 ?

{t('adm.empty')}

: null}
- {/* 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.uploads')} - {photos.length} -

- setNameQuery(e.target.value)} - /> -
- setSort(e.target.value as 'newest' | 'recipe' | 'rating')} - > - - - - - -
-
+ data-key="adm-filter-name" + placeholder={t('adm.filterName')} + value={nameQuery} + onChange={(e) => setNameQuery(e.target.value)} + /> +
+ + +
))} -
-
- {albumPhotos.map(card)} -
- {albumPhotos.length === 0 ?

{t('adm.empty')}

: null} -
- -
-

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

-
+ , + )} + {column( + t('adm.sections'), + 'sections', + sectionPhotos, + selSections, + setSelSections, + <> {SECTIONS.map((d) => ( ))} -
-
- {sectionPhotos.map(card)} -
- {sectionPhotos.length === 0 ?

{t('adm.rowEmpty')}

: null} -
+ , + )}
) : null} diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 2ce98a2..8b9c34f 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -178,6 +178,7 @@ export const en: Dict = { 'adm.ratingAll': 'Any rating', 'adm.ratingAtLeast': '{n}★ and up', 'adm.recipe': 'Recipe', + 'adm.bigEmpty': 'Pick a frame 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 0664278..abe4d1d 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -182,6 +182,7 @@ 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 3749f53..23a4f09 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -496,7 +496,7 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .adm-filters .adm-input { min-width: 0; } /* 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-list { display: flex; flex-direction: column; gap: 4px; max-height: 46vh; overflow-y: auto; padding-right: 2px; } .adm-album { display: flex; align-items: center; @@ -516,6 +516,39 @@ 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; } +/* One album column: the shelf of albums down the left, the frame that is up + beside it, the album's strip of thumbnails across the bottom. */ +.adm-split { display: grid; grid-template-columns: minmax(150px, 200px) minmax(0, 1fr); gap: 12px; align-items: start; } +.adm-stage { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + padding: 10px; +} +.adm-stage-img { width: 100%; aspect-ratio: 4 / 3; object-fit: contain; border-radius: var(--radius-sm); background: var(--bg-sunken); } +/* The album's strip: thumbnails in one row, scrolled sideways when they + overflow the column 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; }