diff --git a/README.md b/README.md index bc230b5..52df6c3 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,23 @@ share them with clients via private (optionally password-protected) links, collect accept/reject votes, ratings and tags, and let clients download originals. +## Screenshots + +The album view — drag-and-drop upload, justified gallery, and each client's +feedback (votes, stars, tags) overlaid on the thumbnails: + +![Album view with upload zone and feedback overlays](docs/screenshots/album-admin.jpg) + +What clients see on a share link — vote on favorites, filter by verdict, +download selects or the whole album as a ZIP: + +![Client gallery with verdict filters](docs/screenshots/share-client.jpg) + +The lightbox — accept/reject, star rating, tags, and keyboard-driven culling +(`P`/`X`/`U`, `1`–`5`): + +![Lightbox with voting and rating controls](docs/screenshots/lightbox.jpg) + ## Architecture ``` diff --git a/docs/screenshots/album-admin.jpg b/docs/screenshots/album-admin.jpg new file mode 100644 index 0000000..76ee47b Binary files /dev/null and b/docs/screenshots/album-admin.jpg differ diff --git a/docs/screenshots/lightbox.jpg b/docs/screenshots/lightbox.jpg new file mode 100644 index 0000000..14e19fc Binary files /dev/null and b/docs/screenshots/lightbox.jpg differ diff --git a/docs/screenshots/share-client.jpg b/docs/screenshots/share-client.jpg new file mode 100644 index 0000000..9d24ece Binary files /dev/null and b/docs/screenshots/share-client.jpg differ diff --git a/frontend/src/components/Gallery.jsx b/frontend/src/components/Gallery.jsx index 0d44b66..9e08a9b 100644 --- a/frontend/src/components/Gallery.jsx +++ b/frontend/src/components/Gallery.jsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { imgUrl } from '../api' // True justified layout: pack photos greedily into rows at their real aspect @@ -32,22 +32,17 @@ function layoutRows(photos, containerWidth, targetHeight, gap) { } export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) { + const containerRef = useRef(null) const [width, setWidth] = useState(0) - const observerRef = useRef(null) - // Callback ref instead of mount effect: the container div unmounts whenever - // the photo list is empty (e.g. a filter with no matches), so the observer - // must re-attach to each new element — a once-per-mount effect would leave - // the re-rendered gallery unobserved at width 0, rendering nothing. - const containerRef = useCallback((el) => { - observerRef.current?.disconnect() - observerRef.current = null - if (!el) return - observerRef.current = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width)) - observerRef.current.observe(el) + useEffect(() => { + const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width)) + observer.observe(containerRef.current) + return () => observer.disconnect() }, []) - if (photos.length === 0) return null + // The container renders even with zero photos — unmounting it would detach + // the observer and leave a later non-empty render stuck at width 0. const gap = 6 const targetHeight = width < 700 ? 170 : 240 const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : [] diff --git a/frontend/src/pages/AlbumPage.jsx b/frontend/src/pages/AlbumPage.jsx index 9559bb5..397d5c1 100644 --- a/frontend/src/pages/AlbumPage.jsx +++ b/frontend/src/pages/AlbumPage.jsx @@ -18,6 +18,67 @@ function fmtEta(seconds) { const UPLOAD_CONCURRENCY = 3 +// Expiry convention, in one place for the create form and the row editor: +// end of the chosen day in the photographer's local timezone — date-only +// strings would parse as UTC midnight and expire a day early. +const endOfDayIso = (day) => (day ? new Date(`${day}T23:59:59`).toISOString() : null) + +// ISO timestamp -> local yyyy-mm-dd for date inputs. +const localDate = (iso) => { + const d = new Date(iso) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +// Staged expiry editor: commits on blur/Enter, never per keystroke — typing +// a year fires change events with bogus intermediate dates (year 0002) that +// must not hit the live link. The ✕ clears explicitly; Safari's date input +// has no native clear control. +function ExpiryDate({ value, onCommit }) { + const current = value ? localDate(value) : '' + const [draft, setDraft] = useState(current) + useEffect(() => setDraft(current), [current]) + + const commit = () => { + if (draft === current) return + // A half-typed year (e.g. 0002) can survive until blur; don't persist it. + if (draft && draft.slice(0, 4) < '2000') { + setDraft(current) + return + } + onCommit(endOfDayIso(draft)) + } + + return ( + + ) +} + function UploadZone({ albumId, onUploaded }) { const [queue, setQueue] = useState([]) const [dragging, setDragging] = useState(false) @@ -198,11 +259,7 @@ function SharesPanel({ albumId }) { label: form.label, password: form.password || null, allow_download: form.allow_download, - // End of the chosen day in the photographer's local timezone — - // date-only strings would parse as UTC midnight and expire a day early. - expires_at: form.expires_at - ? new Date(`${form.expires_at}T23:59:59`).toISOString() - : null, + expires_at: endOfDayIso(form.expires_at), }, }) setForm({ label: '', password: '', allow_download: true, expires_at: '' }) @@ -221,18 +278,14 @@ function SharesPanel({ albumId }) { const update = async (shareId, patch) => { try { - await api(`/api/shares/${shareId}`, { method: 'PATCH', body: patch }) + const updated = await api(`/api/shares/${shareId}`, { method: 'PATCH', body: patch }) + setShares((list) => list.map((s) => (s.id === shareId ? updated : s))) setError(null) } catch (e) { setError(e.message) + // Re-sync the controlled inputs with what the server actually has. + load() } - load() - } - - // ISO timestamp -> local yyyy-mm-dd for the date input. - const localDate = (iso) => { - const d = new Date(iso) - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` } return ( @@ -262,21 +315,7 @@ function SharesPanel({ albumId }) { /> downloads - + update(s.id, { expires_at: iso })} /> {s.locked && (