Files
picpeak/backend
Paul Nothaft 0a36ca6056 feat(gallery): folders that contain photos instead of filtering them (#1160) (#1161)
* feat(gallery): folders that contain photos instead of filtering them (#1160)

A category has always been a filter: its photos stay in the root grid and
picking the category narrows that grid. D#1086 asked for the opposite — put
the selects in a bucket and get them OUT of the main grid, so the client sees
the 40 finals and clicks through for the other 200.

`photo_categories.is_folder` makes that a per-category choice. One column is
enough because the neighbouring features already built the substrate:
hero_photo_id (#163) is the folder cover, allow_downloads (#640) is per-folder
download rules, display_order + event_category_order (#782) is folder ordering,
and photos.category_id being single-valued is already folder semantics.

Deliberately no parent_id. "Root -> Selects folder" is depth one, i.e. plain
containment; folders-inside-folders waits until someone asks.

Containment lands in the one useMemo where the category filter was already
applied, and the tiles render above the grid rather than inside a layout, so
all eight gallery layouts inherit folders without eight implementations.
Scope drives the counts too, so root reports 40 photos and not 240.

`?folder=<slug>` carries the open folder, preserving token and admin_preview,
so a folder is linkable and Back walks out of it instead of leaving the gallery.

Defaults to false, so every existing gallery keeps filtering exactly as before.

Folders are organisational, not access control: a foldered photo is served by
the same per-photo auth as any other. A test pins that, so nobody later mistakes
containment for a security boundary.

* feat(gallery): download a folder on its own, and label folders when moving photos (#1160)

Downloads now cover both halves of the requirement:

  - the gallery-wide "download all" keeps zipping every photo including the
    foldered ones (verified: 62 files), so a folder never quietly removes
    photos from the client's one-click download;
  - inside a folder there is a "Download folder (n)" button that zips only
    that folder, once (verified: 20 files). It reuses /download-selected, so
    there is no new endpoint and no second zip-building path.

The button honours the per-category opt-out (#640) both ways: a folder with
allow_downloads = false renders no button, and individual photos that opted
out are excluded from the id list rather than silently 403-ing mid-zip.

Moving photos into a folder already worked — a folder IS a category, so the
existing bulk "move to category" flow does it. What was missing is that a
folder and a filter category looked identical in that dropdown while having
very different consequences, so folder options now read
"<name> (folder — hidden from the main grid)". Threading is_folder through to
the dialog needed the admin category prop types widened; the data was already
on the wire.

* fix(gallery): folders were unreachable in the full-bleed layouts (#1160)

Containment comes from `filteredPhotos`, which BOTH layout branches use, but the
tiles were only rendered in one. On a Premium or Story gallery the foldered
photos therefore disappeared from the grid with no tile to click — moving 200
selects into a folder effectively deleted them from the client's view. The
folder nav is now built once and rendered by both branches, so a branch can't
hide photos without also offering the way in.

Those two layouts are edge-to-edge by design, and a block of cover cards above
the hero wrecks the opening they exist for, so they get a compact chip row
(`Folders  [icon Selects 20]`) instead. It only renders when the gallery
actually has folders, leaving every existing full-bleed gallery byte-identical.

Also scopes the people strip to the photos on screen. `face_count` comes from
/people and spans the whole event, which contradicted the grid in two ways:
inside a folder a face read "12 photos" but filtered down to the handful in that
folder, and at root a person whose photos ALL lived in a folder showed up and
filtered to nothing — a dead chip. Recounted from `photo.person_ids`, which is
already what the filter itself uses, and zero-count people are dropped. No
backend change; the ids were on the wire already.

Verified in the running app: Premium renders the chip and navigates; the
lightbox counter inside a folder reads "1 / 20", not 1 / 62; the people strip
inside the folder drops from 12/11/4 to the one face actually present.

* fix(gallery): folder edge cases found in external review (#1160)

Seven issues, all verified against the code before fixing.

Unreachable folders (the serious one). `adminCategories` derives a slug with
`[^\w\s-]` stripping and `\w` is ASCII-only, so a valid name in a non-Latin
script slugs to the empty string — `Избранное` and `日本語` both do. Keying the
URL on the slug meant such a folder wrote no param and resolved to nothing: its
photos left the root grid with no way back to them. This repo ships ru and sl
locales, so that is a reachable state, not a hypothetical. Folders are now keyed
by `folderKey()` — slug when there is one, id otherwise.

Stale selection across a scope change. The grid clears its selection when
`categoryId` changes, which is already null at root, so a selection made outside
a folder survived into it and the toolbar would offer to download (or a client
to hide) photos no longer on screen. Cleared on both explicit navigation and
popstate.

Dead category chips inside a folder. The filter branch ignores
`selectedCategoryId` while a folder is open, so the chips did nothing when
clicked. They are no longer offered there.

"No photos found" beside folder tiles. A gallery whose photos all live in
folders rendered the tiles and then the grid's empty state directly under them,
claiming the gallery was empty while pointing at its contents.

Counts that contradicted the grid. The filter bar and both people surfaces were
still counting over every event photo, so a chip could advertise a total the
scoped grid would never produce. All now count over `scopedPhotos`.

`!!` on a validated boolean. express-validator's isBoolean() accepts the STRINGS
"false" and "0", and `!!'false'` is true — a form-encoded caller asking for a
filter would have silently got a folder. Uses the existing parseBooleanInput.

Duplicate-event dropped folder-ness. The category clone selected only name, slug
and is_global, so every folder in a duplicated gallery came back as a filter.

* fix(gallery): folder scoping gaps from external review round 2 (#1160)

Cache mutation, introduced by this branch. `photosInScope` returned the caller's
own array on the no-folders fast path, and `filteredPhotos` sorts in place — so
every gallery WITHOUT folders was reordering the React Query cache for every
other consumer of `data.photos`. The pre-branch code cloned; now it always does.

Colliding folder keys. UNIQUE is (slug, event_id), so a global folder and an
event folder can share a slug, and the gallery merges both scopes. Keying on the
slug alone meant the second folder resolved to the first and its photos could
not be opened. The id is now always part of the key.

"Download folder" downloaded a subset. Search, feedback, media and people
filters stay active when entering a folder, and the ids came from
`filteredPhotos` — so the button promised the folder and delivered whatever the
filter had left, or vanished when it matched nothing. Built from `scopedPhotos`.

Folder-only root misdetected. `rootIsFoldersOnly` tested `filteredPhotos`, so a
search matching none of the loose root photos looked folder-only and swallowed
the no-results message. Tests the unfiltered scope instead.

Empty state in the full-bleed layouts. The Premium/Story branch was missing the
folder-only guard the standard branch got, so a folder-only gallery printed
"no photos found" under its own folder chips.

Filter metadata still event-wide. `availableMediaTypes` and `colorLabelCounts`
counted over every photo, so the sidebar could offer a Video or colour chip for
something that only exists in another scope — always filtering to nothing. Both
derive from `scopedPhotos`, which moved above them for that reason.

* fix(gallery): honest folder downloads and scoped totals (#1160)

Silent truncation. /download-selected slices the id list to 500 server-side
(gallery.js:1776), so a folder larger than that delivered a truncated archive
under a button promising the whole thing. The limit is now mirrored client-side:
the request carries only what the server will honour and the label says
"Download first 500 of 620" instead of claiming the folder.

Gallery shell was being unmounted. Suppressing the folder-only empty state by
skipping PhotoGridWithLayouts took the hero, event title, logout and download
controls with it in the full-bleed layouts, since those render from inside that
component — a folder-only Premium gallery collapsed to a bare chip row. Replaced
with a suppressEmptyState prop so only the message goes.

Two more counts that could contradict the grid: the sidebar's total and the
people match-count denominator ("42 of 62" at a root that holds 42). Both scoped.
The client-access visible/total stat is deliberately left event-wide — that one
is a photographer-facing statistic about the gallery, not a filter affordance.

Stale admin cache. EventDetailsPage caches the same category rows under
'admin-event-categories' and hands them to the Photos tab's move dialog, so
toggling a folder left that dialog labelling it a plain category until remount.
Both keys are invalidated now.

Not changed, after challenging the review: select-all in the full-bleed layouts
stays scoped to the displayed photos. Wiring it to the full event would select
photos that are not on screen, contradicting containment and reviving the stale
selection bug. The reviewer withdrew the finding on that basis. The residual UX
gap — no one-click "everything" in Premium/Story once folders exist — is real
and noted on the PR.

* feat(gallery): one-click download-everything in the full-bleed layouts (#1160)

Premium and Story have no header download button — their only gallery-wide
download is select-all followed by download-selected, and select-all is
correctly scoped to what is on screen. Once folders exist that left no single
way to get the whole gallery. The folder strip now carries an event-wide
"Download all photos" that hits /download-all (which has always included
foldered photos), shown at the root only, since inside a folder the breadcrumb
already offers that folder's download.

Also lands the capped folder label that was written but never actually applied
in the previous commit — the edit silently didn't match, so a 510-photo folder
still advertised "Download folder (510)" while the request was capped to 500.
Caught by building a real 510-photo folder rather than trusting the reasoning:
it now reads "Download first 500 of 510". A unit test pins the client constant
to the backend's cap so the two can't drift apart unnoticed.

* fix(gallery): remount layouts on folder change, and stop scoped counts leaking into event-wide controls (#1160)

Carousel crash. Layout state is only meaningful for the photo set it was built
against, but the layout instance was reused across a folder change. In carousel
mode an index valid at root (31 of 42) indexes past the end of a smaller folder,
and CarouselGalleryLayout does `photos[currentIndex]` unguarded. The grid is now
keyed by the open folder, so a scope change remounts: verified live, 31/42 at
root becomes 1/20 on entering the folder instead of dereferencing undefined.
The key also avoids driving one instance between the empty and non-empty render
paths, which matters because that component's `photos.length === 0` early return
sits ABOVE four useState calls — a pre-existing conditional-hook hazard this
feature would otherwise have made reachable.

Nested empty state. suppressEmptyState only silenced PhotoGridWithLayouts' own
early return; the Premium and Story layouts have their own noPhotosFound return,
so a folder-only root still printed "no photos" under the tiles proving
otherwise. The flag is forwarded to them.

Download All was labelled from the wrong number. The sidebar's total is now the
folder scope (correct for the category list), but the same value labelled and
disabled Download All — which fetches the event-wide archive. On a folder-only
root that showed 0 and refused a valid download. Split into a separate
downloadAllTotal.

Feedback chip counts. likeCount, favoriteCount and ratedCount still counted over
every event photo while clicking them filters the scope, so a chip could promise
matches from another folder and deliver none.

* fix(gallery): premium crash, story Download All, and empty-mount hazard (#1160)

ReferenceError blanking the Premium gallery — my own bug from the previous
commit. The suppressEmptyState prop landed on the nested PhotoCard instead of
GalleryPremiumLayout (both destructure `allowDownloads = true`, and the patch hit
the first one), so the layout's guard referenced an identifier that was not in
its scope. A folder-only Premium root threw instead of rendering. Now declared
and destructured on the layout, and exercised: 62 photos all foldered renders
the tile, the hero and the download button with no message and no throw.

Story's footer "Download All Photos" built its id list from the `photos` prop,
which is now the folder scope — so it silently omitted every foldered photo
while still calling itself Download All. Layouts now receive an event-wide
downloadAllIds and prefer it. Premium's equivalent control is a select-all, not
a download, and stays scoped by the same reasoning as before.

Empty-array mounts. Suppressing the empty state meant the layout got mounted
with photos=[], and CarouselGalleryLayout returns before four of its useState
calls — driving one instance between empty and non-empty changes its hook count
and React throws. Only the full-bleed layouts, which own the hero and logout
chrome, are now mounted empty; every other layout renders nothing instead.

* fix(gallery): keep the shell and drop dead controls on folder-only roots (#1160)

Skipping the empty layout took the hero and welcome message with it. The early
return sat above both, so a gallery whose photos all live in folders lost its
configured hero and welcome copy at the root and only regained them after
opening a folder. Only the layout child is skipped now; the surrounding shell
renders as it always did.

The filter bar was gated on the event-wide photo count, so a folder-only root
still rendered search, sort and the feedback chips with nothing in scope for
them to act on — the same empty filter row discussion #317 asked us to remove.
Gated on the current scope.

Story's download toast counted `photos` while the request now carries the
event-wide id list, so it could announce "Downloading 0 photos" and then fetch
the whole gallery. Counts the ids it actually sends.

* fix(gallery): clear the person filter on scope change, and fix two folder-only shell details (#1160)

A person selected in one scope can have no photos in the next. peopleInScope
drops them from the strip, so the filter stayed active with nothing left to
clear it — and the full-bleed layouts have no people UI at all, leaving a guest
staring at an empty grid with a reload as the only way out. Cleared on both
folder navigation and popstate, alongside the category selection and the photo
selection already reset there.

Story's hero announced "0 Photos" on a folder-only root, since it derives that
stat from the scope it renders and the scope is empty by definition there.
Falls back to the event-wide count.

Premium's integrated Download All is a select-all over the current scope, so on
a folder-only root it was a visible control that did nothing when clicked. It is
hidden while the scope is empty rather than left dead.

* fix(gallery): uncapped Story download, protected folder covers, scoped people order (#1160)

The event-wide id list I added for Story's "Download All Photos" made it worse,
not better: /download-selected caps at 500 ids server-side, so a gallery larger
than that silently shipped a partial archive under a button promising all of it.
Replaced with an onDownloadEverything callback that runs the whole-gallery
/download-all path, which has no cap. eventPhotoCount now carries the number
Story needs for its hero stat, so no id list crosses the boundary at all.

Folder covers bypassed image protection. A cover is a real gallery photo, but it
was rendered through AuthenticatedImage's defaults while every photo tile passes
the gallery's protection settings — so on a gallery configured for canvas
rendering or maximum protection, each cover was an ordinary blob-backed <img>.
The tiles now receive and apply the same props as the grid.

People kept /people's event-wide ordering after their counts were rescoped, so a
folder's most-photographed person could sort behind someone with a single match
— and PeopleStrip only shows the first twelve inline. Sorted by the recomputed
count, with a test.

* fix(gallery): folder covers honour maximum protection (#1160)

Maximum protection implies canvas rendering even when the independent
use_canvas_rendering toggle is off, which is its default — every other gallery
image path spells that out as `useCanvasRendering || protectionLevel ===
'maximum'` (PhotoGrid, PhotoLightbox, HeroHeader, JustifiedGalleryLayout). The
folder cover forwarded the raw toggle, so on a maximum-protection gallery with
the toggle untouched the cover fell back to a blob-backed <img>. Matches the
convention now.

* fix(gallery): don't let download-everything bypass a category opt-out, and keep folder links alive across renames (#1160)

The whole-gallery route serves a prebuilt zip containing EVERY event photo with
no per-category filter — gallery.js says so itself, next to
bumpEventDownloadCounts, as a known pre-existing gap. Wiring Story's footer to
that route therefore converted a path that DID enforce the #640 opt-out into one
that doesn't, and because the callback was supplied unconditionally it affected
Story galleries with no folders at all.

The same reasoning applies to the download-everything button this branch added
to the full-bleed folder strip: it routes there too, so on a gallery with a
restricted category it would have handed over exactly the photos the opt-out
withholds. Both are now withheld whenever any category opts out; those galleries
keep the per-folder download, which enforces it. Verified both ways — the
control disappears with a restricted category present and returns once the
restriction is lifted.

Folder links also survived a rename badly: the key embeds the slug for
readability, and renaming a category rewrites that slug, so a URL already sent
to a client stopped matching and silently opened the gallery root. Resolution
now keys on the trailing category id, which does not move.

* fix(gallery): make folder navigation clickable in the Story layout (#1160)

Story renders `.story-nav` as `position: fixed` across the top of the viewport
at z-index 50, and the folder strip sits in exactly that band — so the nav
swallowed every click on the chips and the breadcrumb. A Story gallery whose
photos all live in folders had no way to reach them at all. Confirmed with
elementFromPoint at the chip's centre returning NAV.story-nav; the strip now
carries its own stacking context above it and the same probe returns the chip.

Story's footer download could also be offered with nothing to send: on a
folder-only root of a gallery that has a category download opt-out, the parent
deliberately withholds the whole-gallery callback and the scope is empty, so the
button would have posted an empty id list and taken a 400. It is only rendered
when one of the two actually exists.

* fix(gallery): stop the Story folder strip from blocking the layout's own nav (#1160)

The previous commit raised the whole folder strip above `.story-nav` so the
chips could be clicked, and thereby traded the bug for its mirror image: the
strip is mostly empty space, so as a solid z-60 container it swallowed the
clicks for Story's own search, favourites and logout sitting underneath.

The container no longer takes hits at all; only the chips, breadcrumb and
download button opt back in. The download button also loses its ml-auto, since
being pushed to the right put it physically on top of the nav's controls rather
than merely above them in stacking order.

Verified by hit-testing all three at once — folder chip, download button, and
Story's nav control each resolve to themselves under elementFromPoint, so none
is covering another.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-28 08:03:49 +02:00
..