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; }