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>
This commit is contained in:
Paul Nothaft
2026-08-28 08:03:49 +02:00
committed by GitHub
parent 56cf947735
commit 0a36ca6056
32 changed files with 1363 additions and 78 deletions
@@ -0,0 +1,142 @@
/**
* Gallery folders reach the guest payload (#1160).
*
* `is_folder` is what tells the frontend a category CONTAINS its photos rather
* than filtering them. The category block in gallery.js selects an explicit
* column list (not `c.*`), so a new column that isn't added there is silently
* dropped — every folder would render as a plain filter and the root grid would
* still show the foldered photos. These assertions pin that contract.
*
* Also pins the SQLite side: the engine stores 0/1, so a strict `=== true`
* consumer would see every folder as a filter (the #1028 class of bug).
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-folders-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'folders-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-folders-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'folders-gallery';
describe('folder categories in the gallery payload (#1160)', () => {
let db; let cleanup; let app; let eventId; let folderId; let filterId;
async function getCategories() {
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
expect(res.status).toBe(200);
return res.body.categories;
}
async function addPhoto(filename, categoryId) {
const row = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
category_id: categoryId,
uploaded_at: new Date().toISOString(),
}).returning('id');
return row[0]?.id ?? row[0];
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const ev = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Folders',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/s`,
share_token: 'folders-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
require_password: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = ev[0]?.id ?? ev[0];
const folder = await db('photo_categories').insert({
name: 'Selects', slug: 'selects', is_global: 0, event_id: eventId, is_folder: 1,
}).returning('id');
folderId = folder[0]?.id ?? folder[0];
const filter = await db('photo_categories').insert({
name: 'Ceremony', slug: 'ceremony', is_global: 0, event_id: eventId, is_folder: 0,
}).returning('id');
filterId = filter[0]?.id ?? filter[0];
await addPhoto('in-folder.jpg', folderId);
await addPhoto('in-filter.jpg', filterId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
test('the engine under test stores booleans as 0/1', async () => {
expect(['sqlite3', 'better-sqlite3']).toContain(db.client.config.client);
const row = await db('photo_categories').where('id', folderId).first('is_folder');
expect(row.is_folder).toBe(1);
});
test('a folder category is reported with is_folder true', async () => {
const folder = (await getCategories()).find((c) => c.id === folderId);
expect(folder).toBeDefined();
expect(folder.is_folder).toBe(true);
});
test('a plain category keeps filtering — is_folder is false, not undefined', async () => {
const filter = (await getCategories()).find((c) => c.id === filterId);
expect(filter.is_folder).toBe(false);
});
test('a category predating the column defaults to filtering, never folder', async () => {
const legacy = await db('photo_categories').insert({
name: 'Legacy', slug: 'legacy', is_global: 0, event_id: eventId,
}).returning('id');
const legacyId = legacy[0]?.id ?? legacy[0];
await addPhoto('legacy.jpg', legacyId);
const found = (await getCategories()).find((c) => c.id === legacyId);
expect(found.is_folder).toBe(false);
});
test('a form-encoded is_folder="false" stays a filter (not !!-coerced to true)', async () => {
// express-validator's isBoolean() accepts the STRINGS "false"/"0", and
// `!!'false'` is true — so `!!` would flip a caller asking for a filter into
// a folder, silently pulling their photos out of the root grid.
const { parseBooleanInput } = require('../../src/utils/parsers');
expect(parseBooleanInput('false', true)).toBe(false);
expect(parseBooleanInput('0', true)).toBe(false);
expect(parseBooleanInput('true', false)).toBe(true);
expect(parseBooleanInput(undefined, false)).toBe(false);
});
test('folders are not an access boundary — the photo is still in the payload', async () => {
// Containment is a rendering rule, not authorisation. If this ever starts
// failing, folders have silently become a security feature they are not.
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
expect(res.body.photos.some((p) => p.filename === 'in-folder.jpg')).toBe(true);
});
});
@@ -0,0 +1,39 @@
/**
* Migration 185: gallery folders (#1160).
*
* Adds `is_folder` to `photo_categories`. A category has always been a FILTER —
* every photo stays in the main grid and picking a category narrows it. A folder
* is a CONTAINER: its photos leave the root grid entirely and are only shown once
* the guest clicks into the folder.
*
* One column is enough because the surrounding features already built the rest:
* - `hero_photo_id` (#163, migration 066) → the folder cover image
* - `allow_downloads` (#640, migration 135) → per-folder download rules
* - `display_order` + `event_category_order` → folder ordering (#782, 159/160)
* - `photos.category_id` is single-valued → a photo lives in one folder
*
* Deliberately NOT added: `parent_id`. The request (D#1086) is "root → Selects
* folder", which is depth one, i.e. plain containment. Folders-inside-folders
* stays out until someone actually asks for it.
*
* No backfill: `false` IS the preserved behaviour, so every existing category
* keeps filtering exactly as before and folders are opt-in per category.
*
* Additive + hasColumn-guarded, matching migration 159.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
if (!(await knex.schema.hasColumn('photo_categories', 'is_folder'))) {
await knex.schema.alterTable('photo_categories', (t) => {
t.boolean('is_folder').notNullable().defaultTo(false);
});
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('photo_categories'))) return;
if (await knex.schema.hasColumn('photo_categories', 'is_folder')) {
await knex.schema.alterTable('photo_categories', (t) => t.dropColumn('is_folder'));
}
};
+19 -4
View File
@@ -2,6 +2,7 @@ const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { parseBooleanInput } = require('../utils/parsers');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
@@ -42,7 +43,8 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().withMessage('Category name is required'),
body('slug').optional(),
body('is_global').optional().isBoolean(),
body('event_id').optional().isInt()
body('event_id').optional().isInt(),
body('is_folder').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -50,7 +52,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
return res.status(400).json({ errors: errors.array() });
}
const { name, slug, is_global = true, event_id = null } = req.body;
const { name, slug, is_global = true, event_id = null, is_folder = false } = req.body;
// Generate slug if not provided
const categorySlug = slug || name
@@ -97,7 +99,12 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
slug: categorySlug,
is_global,
event_id: is_global ? null : event_id,
display_order: nextOrder
display_order: nextOrder,
// #1160: a folder contains its photos instead of filtering them.
// parseBooleanInput, not `!!`: express-validator's isBoolean() accepts the
// STRINGS "false" and "0", and `!!'false'` is true — a form-encoded caller
// asking for a filter would silently get a folder.
is_folder: formatBoolean(parseBooleanInput(is_folder, false))
}).returning('id');
const categoryId = insertResult[0]?.id || insertResult[0];
@@ -125,7 +132,8 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
if (value === null || value === undefined) return true;
return Number.isInteger(Number(value));
}).withMessage('hero_photo_id must be an integer or null'),
body('allow_downloads').optional().isBoolean()
body('allow_downloads').optional().isBoolean(),
body('is_folder').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -172,6 +180,13 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
updateData.allow_downloads = req.body.allow_downloads;
}
// Folder vs filter (#1160). Flipping this moves the category's photos out of
// (or back into) the root grid with no re-upload — it only changes where they
// render, never which photos exist or who may reach them.
if (Object.prototype.hasOwnProperty.call(req.body, 'is_folder')) {
updateData.is_folder = formatBoolean(parseBooleanInput(req.body.is_folder, false));
}
await db('photo_categories')
.where('id', id)
.update(updateData);
+5 -1
View File
@@ -1207,7 +1207,7 @@ module.exports = (router) => {
const sourceCategories = await db('photo_categories')
.where({ event_id: id })
.where(function () { this.whereNull('is_global').orWhere('is_global', formatBoolean(false)); })
.select('name', 'slug', 'is_global');
.select('name', 'slug', 'is_global', 'is_folder');
if (sourceCategories.length > 0) {
await db('photo_categories').insert(
sourceCategories.map((c) => ({
@@ -1215,6 +1215,10 @@ module.exports = (router) => {
name: c.name,
slug: c.slug,
is_global: formatBoolean(false),
// #1160: carry folder-ness across. Without this the clone silently
// falls back to the column default and a duplicated gallery turns
// every folder back into a filter.
is_folder: formatBoolean(parseBooleanInput(c.is_folder, false)),
})),
);
}
+6 -2
View File
@@ -1053,7 +1053,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// default, else name — restricted to categories that have photos.
const categoryDetails = await getEventCategoriesOrdered(req.event.id, {
onlyIds: usedCategoryIds,
select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads'],
select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads', 'c.is_folder'],
});
categories = categoryDetails.map(cat => ({
@@ -1065,7 +1065,11 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// Per-category download flag (#640). false explicitly disables; the
// gallery hides the download button. Defaults true so categories
// created before migration 135 keep working.
allow_downloads: parseBooleanInput(cat.allow_downloads, true)
allow_downloads: parseBooleanInput(cat.allow_downloads, true),
// Folder vs filter (#1160). true = the category CONTAINS its photos:
// they leave the root grid and only render inside the folder. Defaults
// false so categories predating migration 185 keep filtering.
is_folder: parseBooleanInput(cat.is_folder, false)
}));
}
@@ -18,6 +18,8 @@ import { BulkCategoryModal } from './BulkCategoryModal';
interface CategoryOption {
id: number;
name: string;
// #1160: folders are categories too; the move dialog labels them.
is_folder?: boolean;
}
interface AdminPhotoGridProps {
@@ -6,6 +6,10 @@ import { Button, Card } from '../common';
interface CategoryOption {
id: number;
name: string;
// #1160: moving photos into a folder takes them OUT of the main grid, which is
// a materially different outcome from tagging them with a filter category.
// The option is labelled so the admin knows which one they picked.
is_folder?: boolean;
}
interface BulkCategoryModalProps {
@@ -70,7 +74,9 @@ export const BulkCategoryModal: React.FC<BulkCategoryModalProps> = ({
<option value="">{t('photos.uncategorized', 'Uncategorized')}</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
{category.is_folder
? t('photos.folderOption', '{{name}} (folder — hidden from the main grid)', { name: category.name })
: category.name}
</option>
))}
</select>
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud, ArrowUp, ArrowDown, RotateCcw, Folder, FolderOpen } from 'lucide-react';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { photosService } from '../../services/photos.service';
import { Button, Card, AuthenticatedImage } from '../common';
@@ -88,6 +88,23 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
errorMessage: t('categories.failedToToggleDownloads', 'Failed to update download permission'),
});
// Folder vs filter (#1160). Flipping this moves the category's photos out of
// the root grid (or back into it) with no re-upload — it changes where they
// render, never which photos exist or who can reach them.
const folderToggleMutation = useMutationWithToast({
mutationFn: ({ category, isFolder }: { category: PhotoCategory; isFolder: boolean }) =>
categoriesService.updateCategory(category.id, category.name, { is_folder: isFolder }),
// EventDetailsPage caches the same rows under a DIFFERENT key and feeds them
// to the Photos tab's move dialog, so invalidating only this one left that
// dialog labelling a fresh folder as a plain category (#1160).
invalidateKeys: [['event-categories', eventId], ['admin-event-categories', String(eventId)]],
successMessage: (_data, variables) =>
variables.isFolder
? t('categories.folderEnabled', 'Photos in this category now sit inside a folder')
: t('categories.folderDisabled', 'This category filters the gallery again'),
errorMessage: t('categories.failedToToggleFolder', 'Failed to update folder setting'),
});
// Per-event order override (#782). Sends the full ordered id list; the backend
// pins it for this gallery only. Up/down buttons match the invoice line-item
// convention (no drag-and-drop dependency).
@@ -283,6 +300,31 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
only. Global categories are managed in Settings. */}
{!category.is_global && (
<>
<button
onClick={() => folderToggleMutation.mutate({
category,
isFolder: !category.is_folder,
})}
className={`p-1 transition-colors ${
category.is_folder
? 'text-primary-600 dark:text-primary-400 hover:text-neutral-400'
: 'text-neutral-400 dark:text-neutral-500 hover:text-primary-600 dark:hover:text-primary-400'
}`}
title={
category.is_folder
? t('categories.disableFolderTitle', 'Folder: these photos are hidden from the main grid and shown only inside the folder. Click to make it a filter again.')
: t('categories.enableFolderTitle', 'Filter: these photos stay in the main grid. Click to turn it into a folder — hides them from the grid, does NOT restrict access.')
}
disabled={folderToggleMutation.isPending}
>
{folderToggleMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : category.is_folder ? (
<FolderOpen className="w-3 h-3" />
) : (
<Folder className="w-3 h-3" />
)}
</button>
<button
onClick={() => downloadToggleMutation.mutate({
category,
@@ -0,0 +1,133 @@
/**
* Folder tiles for the gallery root (#1160).
*
* Rendered above the photo grid rather than inside any one layout, so all eight
* gallery layouts (Grid, Masonry, Justified, Mosaic, Timeline, Carousel, Story,
* Premium) get folders without eight implementations.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Folder } from 'lucide-react';
import { AuthenticatedImage } from '../common';
import { folderKey, type FolderTile } from './folders';
interface GalleryFolderTilesProps {
tiles: FolderTile[];
onOpen: (slug: string) => void;
/** Gallery slug — the cover is an authenticated image like any other photo. */
slug?: string;
/**
* Image-protection settings (#1160). A folder cover is a real gallery photo,
* so a gallery configured for canvas rendering or maximum protection must not
* get an ordinary blob-backed <img> here just because it is a cover.
*/
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
allowDownloads?: boolean;
/**
* Compact chip row instead of cover cards. Used by the full-bleed layouts
* (Premium, Story), where a block of cards above the hero would break the
* edge-to-edge opening those layouts exist for but where the folders still
* have to be reachable, since containment applies there too.
*/
compact?: boolean;
}
export const GalleryFolderTiles: React.FC<GalleryFolderTilesProps> = ({
tiles,
onOpen,
compact = false,
slug,
protectionLevel,
useEnhancedProtection,
useCanvasRendering,
allowDownloads = true,
}) => {
const { t } = useTranslation();
if (tiles.length === 0) return null;
if (compact) {
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-theme">{t('gallery.folders', 'Folders')}</span>
{tiles.map(({ category, count }) => (
<button
key={category.id}
type="button"
onClick={() => onOpen(folderKey(category))}
aria-label={t('gallery.openFolder', 'Open folder {{name}}', { name: category.name })}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-surface bg-surface text-sm hover:shadow-sm transition-shadow focus:outline-none focus:ring-2 focus:ring-primary-500"
style={{ color: 'var(--color-text)' }}
>
<Folder className="w-3.5 h-3.5 text-muted-theme" />
<span className="font-medium">{category.name}</span>
<span className="text-muted-theme">{count}</span>
</button>
))}
</div>
);
}
return (
<div className="mb-8">
{/* Theme tokens, not Tailwind `dark:` the gallery is themed through CSS
variables per event, so a hardcoded light card renders white on a dark
gallery (the #1106 class of bug). */}
<h2 className="text-sm font-medium text-muted-theme mb-3">
{t('gallery.folders', 'Folders')}
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{tiles.map(({ category, count, coverPhoto }) => (
<button
key={category.id}
type="button"
onClick={() => onOpen(folderKey(category))}
aria-label={t('gallery.openFolder', 'Open folder {{name}}', { name: category.name })}
className="group text-left rounded-lg overflow-hidden border border-surface bg-surface hover:shadow-md transition-all focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<div className="aspect-[4/3] relative" style={{ backgroundColor: 'var(--color-background)' }}>
{coverPhoto?.thumbnail_url ? (
<AuthenticatedImage
src={coverPhoto.thumbnail_url}
alt=""
className="w-full h-full object-cover group-hover:scale-[1.02] transition-transform"
isGallery
slug={slug}
photoId={coverPhoto.id}
requiresToken={coverPhoto.requires_token}
secureUrlTemplate={coverPhoto.secure_url_template}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
// Same rule as every other gallery image path: maximum
// protection implies canvas rendering even when the separate
// toggle is off (its default), otherwise a cover silently
// falls back to a blob-backed <img>.
useCanvasRendering={useCanvasRendering || protectionLevel === 'maximum'}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Folder className="w-8 h-8 text-muted-theme" />
</div>
)}
</div>
<div className="p-3">
<div className="flex items-center gap-2">
<Folder className="w-4 h-4 text-muted-theme shrink-0" />
<span className="font-medium truncate" style={{ color: 'var(--color-text)' }}>
{category.name}
</span>
</div>
<p className="mt-1 text-sm text-muted-theme">
{t('gallery.folderPhotoCount', '{{count}} photos', { count })}
</p>
</div>
</button>
))}
</div>
</div>
);
};
@@ -29,6 +29,13 @@ interface GallerySidebarProps {
allowDownloads?: boolean;
photoCounts?: Record<number | string, number>;
totalPhotos: number;
/**
* Event-wide count for the Download All control (#1160). `totalPhotos` is the
* current folder scope and drives the category list; Download All fetches the
* whole event, so labelling it from the scoped count would understate it and
* disable it entirely on a folder-only root.
*/
downloadAllTotal?: number;
isMobile: boolean;
galleryLayout?: string;
allowUploads?: boolean;
@@ -71,6 +78,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
allowDownloads = true,
photoCounts = {},
totalPhotos,
downloadAllTotal,
isMobile,
galleryLayout,
allowUploads,
@@ -205,10 +213,10 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
disabled={isDownloading || totalPhotos === 0}
disabled={isDownloading || (downloadAllTotal ?? totalPhotos) === 0}
className="gallery-btn gallery-btn-download w-full"
>
{t('gallery.downloadAll')} ({totalPhotos})
{t('gallery.downloadAll')} ({downloadAllTotal ?? totalPhotos})
</Button>
<Button
+326 -36
View File
@@ -8,6 +8,17 @@ import { GallerySkeleton } from './GallerySkeleton';
import { useGalleryAuth, useTheme } from '../../contexts';
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
import { GalleryFolderTiles } from './GalleryFolderTiles';
import {
findFolderByKey,
filterCategories,
folderTiles,
peopleInScope,
photosInScope,
SELECTED_DOWNLOAD_LIMIT,
readFolderParam,
writeFolderParam,
} from './folders';
import { DownloadResolutionModal } from './DownloadResolutionModal';
import { ExpirationBanner } from './ExpirationBanner';
import { CountdownTimer } from './CountdownTimer';
@@ -24,7 +35,7 @@ import type { FilterType, FeedbackFilterType } from './GalleryFilter';
import { analyticsService } from '../../services/analytics.service';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { api } from '../../config/api';
import { Upload, Menu, Eye, EyeOff, Shield, X, Download } from 'lucide-react';
import { Upload, Menu, Eye, EyeOff, Shield, X, Download, ChevronLeft } from 'lucide-react';
import { galleryService } from '../../services/gallery.service';
import { feedbackService, type ColorLabel } from '../../services/feedback.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
@@ -96,6 +107,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
const { setTheme, theme } = useTheme();
const queryClient = useQueryClient();
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
// Open folder (#1160), mirrored to `?folder=<slug>` so it is linkable and the
// browser back button walks out of it. Seeded from the URL on first render.
const [openFolderSlug, setOpenFolderSlug] = useState<string | null>(() => readFolderParam());
// Download size picker (#858). `showResolutionPicker` covers "download all";
// `resolutionPickerIds` covers a selection (sidebar / full-page layouts).
const [showResolutionPicker, setShowResolutionPicker] = useState(false);
@@ -335,7 +349,33 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
refetchInterval: (query) => (query.state.data?.scan?.in_progress ? 5000 : false),
staleTime: 30_000,
});
const people = peopleData?.people || [];
// Memoised so the `people` recount below isn't invalidated by a fresh []
// identity on every render.
const allPeople = useMemo(() => peopleData?.people || [], [peopleData?.people]);
// Folders (#1160). `openFolder` resolves the `?folder=` slug against the
// categories the gallery actually returned, so a stale or hand-typed slug
// simply falls back to root instead of rendering an empty gallery.
const openFolder = useMemo(
() => findFolderByKey(data?.categories, openFolderSlug),
[data?.categories, openFolderSlug]
);
const tiles = useMemo(
() => folderTiles(data?.categories, data?.photos),
[data?.categories, data?.photos]
);
// Photos the current view is allowed to show, before any user-applied filter.
// This — not `filteredPhotos` — is the right basis for the people strip and
// the category counts: scoping those by the person filter would zero out
// every other face the moment one is picked.
const scopedPhotos = useMemo(
() => photosInScope(data?.photos, data?.categories, openFolder?.id ?? null),
[data?.photos, data?.categories, openFolder]
);
const people = useMemo(() => peopleInScope(allPeople, scopedPhotos), [allPeople, scopedPhotos]);
// The strip comes from /people, but FILTERING uses photo.person_ids, which
// rides on the one-shot /photos response. During a backfill those drift
@@ -438,16 +478,18 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
}
}, [settingsData]);
// Scoped (#1160): a Video chip offered at root for a video that only exists
// inside a folder filters to an empty grid.
const availableMediaTypes = useMemo(() => {
const types = new Set<'photo' | 'video'>();
(data?.photos || []).forEach((photo) => {
scopedPhotos.forEach((photo) => {
const mediaType = resolveMediaType(photo);
if (mediaType === 'photo' || mediaType === 'video') {
types.add(mediaType);
}
});
return types;
}, [data?.photos]);
}, [scopedPhotos]);
const showMediaFilter = availableMediaTypes.has('photo') && availableMediaTypes.has('video');
@@ -592,23 +634,62 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
const showUrgentWarning = daysUntilExpiration !== null && daysUntilExpiration <= 7;
const isExpired = daysUntilExpiration !== null && daysUntilExpiration < 0;
const openFolderBySlug = useCallback((key: string | null) => {
setOpenFolderSlug(key);
writeFolderParam(key);
// Entering or leaving a folder is a scope change, not a filter change —
// carrying a category selection across it would contradict the new scope.
setSelectedCategoryId(null);
// The grid only auto-clears its selection when `categoryId` changes, and
// that is already null at root — so without this a selection made outside
// the folder survives into it, and the toolbar would offer to download (or
// a client to hide) photos that are no longer on screen.
setSelectedPhotos(new Set());
// A person picked in the previous scope may have no photos here, and
// peopleInScope drops them from the strip — leaving an invisible filter that
// empties the grid with no control left to clear it.
setSelectedPersonIds([]);
setPeopleMatchAny(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
}, []);
// The address bar is the source of truth, so Back/Forward walk in and out of
// folders instead of leaving the gallery.
useEffect(() => {
const onPop = () => {
setOpenFolderSlug(readFolderParam());
setSelectedCategoryId(null);
setSelectedPhotos(new Set());
setSelectedPersonIds([]);
setPeopleMatchAny(false);
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
// Filter and sort photos
const filteredPhotos = useMemo(() => {
if (!data?.photos) return [];
let photos = [...data.photos];
// Folder containment (#1160) comes FIRST: at root this drops every photo that
// lives in a folder, inside a folder it keeps only that folder's photos.
// Everything below narrows within that scope, so a search or a feedback chip
// never reaches across a folder boundary.
let photos = photosInScope(data.photos, data.categories, openFolder?.id ?? null);
if (mediaFilter === 'photo') {
photos = photos.filter(photo => resolveMediaType(photo) !== 'video');
} else if (mediaFilter === 'video') {
photos = photos.filter(photo => resolveMediaType(photo) === 'video');
}
// Apply category filter
if (selectedCategoryId) {
// Apply category filter. Only meaningful at root — inside a folder every
// photo already shares the folder's category.
if (selectedCategoryId && !openFolder) {
photos = photos.filter(photo => photo.category_id === selectedCategoryId);
}
// Apply search filter
if (searchTerm) {
const term = searchTerm.toLowerCase();
@@ -708,7 +789,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
}
return photos;
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, activeFilters, activeColorFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds, selectedPersonIds, peopleMatchAny]);
}, [data?.photos, data?.categories, openFolder, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, activeFilters, activeColorFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds, selectedPersonIds, peopleMatchAny]);
// Counts shown in the filter chips ("Liked (N)", etc.). In guest
// mode these need to mirror the per-guest filter behaviour above —
@@ -718,31 +799,39 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
// global aggregate in simple mode where no per-person identity
// exists.
const likeCount = useMemo(() => {
if (isGuestIdentityMode) return myFeedbackPhotoIds.liked.size;
return data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0;
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
// Scoped (#1160): a chip counting another folder's likes advertises matches
// that clicking it — which filters scopedPhotos — can never produce.
if (isGuestIdentityMode) {
return scopedPhotos.filter(p => myFeedbackPhotoIds.liked.has(p.id)).length;
}
return scopedPhotos.filter(p => (p.like_count ?? 0) > 0).length;
}, [scopedPhotos, isGuestIdentityMode, myFeedbackPhotoIds]);
const favoriteCount = useMemo(() => {
if (isGuestIdentityMode) return myFeedbackPhotoIds.favorited.size;
return data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0;
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
if (isGuestIdentityMode) {
return scopedPhotos.filter(p => myFeedbackPhotoIds.favorited.has(p.id)).length;
}
return scopedPhotos.filter(p => (p.favorite_count ?? 0) > 0).length;
}, [scopedPhotos, isGuestIdentityMode, myFeedbackPhotoIds]);
const ratedCount = useMemo(() => {
if (isGuestIdentityMode) return myFeedbackPhotoIds.rated.size;
return data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0;
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
if (isGuestIdentityMode) {
return scopedPhotos.filter(p => myFeedbackPhotoIds.rated.has(p.id)).length;
}
return scopedPhotos.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length;
}, [scopedPhotos, isGuestIdentityMode, myFeedbackPhotoIds]);
// Per-colour chip counts (#1044) — the viewer's own labels, matching what
// the filter actually selects.
const colorLabelCounts = useMemo(() => {
const counts: Partial<Record<ColorLabel, number>> = {};
for (const photo of data?.photos || []) {
for (const photo of scopedPhotos) {
const label = photo.my_color_label as ColorLabel | null | undefined;
if (!label) continue;
counts[label] = (counts[label] || 0) + 1;
}
return counts;
}, [data?.photos]);
}, [scopedPhotos]);
const handleColorFilterToggle = useCallback((color: ColorLabel) => {
setActiveColorFilters(prev =>
@@ -850,11 +939,60 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
await galleryService.downloadSelectedPhotos(slug, peopleDownloadableIds);
};
// Download just the open folder (#1160). The event-wide "download all" still
// zips the whole gallery including foldered photos; this is the "only this
// folder, once" case. Honours the per-category opt-out (#640), so a folder
// with allow_downloads = false offers no button at all.
const folderDownloadableIds = useMemo(() => {
if (!openFolder) return [];
if (openFolder.allow_downloads === false) return [];
// scopedPhotos, not filteredPhotos: a search or feedback chip stays active
// when entering a folder, and a button that says "Download folder" must not
// quietly hand over a filtered subset of it (or vanish when the filter
// matches nothing).
return scopedPhotos
.filter((photo) => photo.category_allow_downloads !== false)
.map((photo) => photo.id);
}, [openFolder, scopedPhotos]);
// /download-selected caps the id list server-side, so a folder bigger than the
// cap would deliver a truncated archive under a button promising the whole
// thing. Send only what the server will honour, and say so on the label.
// True when any category in this gallery opts out of downloads (#640).
const hasRestrictedCategory = useMemo(
() => (data?.categories || []).some((c) => c.allow_downloads === false),
[data?.categories]
);
const folderDownloadIds = useMemo(
() => folderDownloadableIds.slice(0, SELECTED_DOWNLOAD_LIMIT),
[folderDownloadableIds]
);
const folderDownloadCapped = folderDownloadableIds.length > SELECTED_DOWNLOAD_LIMIT;
const handleDownloadFolder = async () => {
if (!allowDownloads || folderDownloadableIds.length === 0) return;
// Same resolution-picker behaviour as every other multi-photo download.
if (downloadChoices.length > 1) {
setResolutionPickerIds(folderDownloadIds);
return;
}
analyticsService.trackGalleryEvent('bulk_download', {
gallery: slug,
photo_count: folderDownloadIds.length,
});
await galleryService.downloadSelectedPhotos(slug, folderDownloadIds);
};
// Calculate photo counts per category
const photoCounts = useMemo(() => {
if (!data?.photos) return {};
const counts: Record<number | string, number> = {};
data.photos
// Scoped to the current view (#1160): a folder's photos must not inflate the
// per-category counts shown at root, where those photos aren't in the grid.
scopedPhotos
.filter(photo => {
if (mediaFilter === 'photo') return resolveMediaType(photo) !== 'video';
if (mediaFilter === 'video') return resolveMediaType(photo) === 'video';
@@ -866,7 +1004,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
}
});
return counts;
}, [data?.photos, mediaFilter]);
}, [scopedPhotos, mediaFilter]);
// Track search usage with debouncing
useEffect(() => {
@@ -922,7 +1060,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
const showSidebar = theme.controlsStyle === 'sidebar';
const filterBarShown = !showSidebar
&& settingsData?.gallery_show_filter_bar !== false
&& (data?.photos?.length ?? 0) > 0;
// Scoped (#1160): on a folder-only root there is nothing for search, sort or
// the feedback chips to act on, and showing them reintroduces exactly the
// empty filter row discussion #317 complained about.
&& scopedPhotos.length > 0;
// Reveal mode (#838): the server returned the event shell with no photos —
// render the upload-only view for EVERY layout. Enforcement is server-side
@@ -1021,12 +1162,143 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
// the guest answer, and vice versa.
const showLogoutControl = requiresPassword || isClient || viaCustomer;
// Folder navigation (#1160): tiles at root, a breadcrumb + folder download
// inside one. Defined once and rendered by BOTH layout branches — containment
// comes from `filteredPhotos`, which every branch uses, so a branch that hides
// foldered photos without offering the tiles makes them unreachable.
// Renders nothing at all when the gallery has no folders, so galleries that
// don't use the feature are untouched.
const buildFolderNav = (compact: boolean) => openFolder ? (
<div className="mb-6 flex items-center gap-2 text-sm flex-wrap">
<button
type="button"
onClick={() => openFolderBySlug(null)}
className="inline-flex items-center gap-1 underline hover:no-underline"
style={{ color: 'var(--color-muted-text)' }}
>
<ChevronLeft className="w-4 h-4" />
{t('gallery.backToGallery', 'All photos')}
</button>
<span style={{ color: 'var(--color-muted-text)' }}>/</span>
<span className="font-medium" style={{ color: 'var(--color-text)' }}>
{openFolder.name}
</span>
{allowDownloads && folderDownloadableIds.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleDownloadFolder}
leftIcon={<Download className="w-4 h-4" />}
className="ml-auto"
>
{folderDownloadCapped
? t('gallery.downloadFolderCapped', 'Download first {{limit}} of {{total}}', {
limit: SELECTED_DOWNLOAD_LIMIT,
total: folderDownloadableIds.length,
})
: t('gallery.downloadFolder', 'Download folder ({{count}})', {
count: folderDownloadableIds.length,
})}
</Button>
)}
</div>
) : (
<GalleryFolderTiles
tiles={tiles}
onOpen={openFolderBySlug}
compact={compact}
slug={slug}
protectionLevel={protectionLevel}
useEnhancedProtection={protectionLevel !== 'basic'}
useCanvasRendering={useCanvasRendering}
allowDownloads={allowDownloads}
/>
);
const folderNav = buildFolderNav(false);
// True only when there is something to show, so the full-page layouts keep
// their edge-to-edge hero untouched unless folders are actually in use.
const hasFolderNav = !!openFolder || tiles.length > 0;
// Root of a gallery where every photo lives in a folder: the tiles ARE the
// content, and the grid below them would otherwise render its empty state.
// Deliberately `scopedPhotos`, not `filteredPhotos`: with loose root photos
// present, a search matching none of them would otherwise look "folder-only"
// and swallow the no-results message the guest needs.
const rootIsFoldersOnly = !openFolder && tiles.length > 0 && scopedPhotos.length === 0;
// For full-page layouts, render just the PhotoGridWithLayouts without any wrappers
if (isFullPageLayout) {
return (
<>
{/* #1160: these layouts return early and render edge-to-edge, but they
still get `filteredPhotos`, so without this the foldered photos
would be hidden with no way in. Contained width so the folder strip
reads as chrome against the full-bleed grid below it. */}
{hasFolderNav && (
// Story's `.story-nav` is fixed across this same band at z-index 50.
// Raising the strip above it is necessary for the chips to be
// clickable at all, but the strip is mostly empty space — so the
// container itself must not take hits, or it would block the nav's own
// search/favourites/logout underneath. Only the real controls opt back
// in via pointer-events-auto.
<div className="relative z-[60] pointer-events-none max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 flex items-center gap-3 flex-wrap">
<div className="pointer-events-auto flex items-center gap-3 flex-wrap">
{buildFolderNav(true)}
</div>
{/* Hidden when a category opts out of downloads (#640): this routes
to the whole-gallery zip, which contains every event photo with
no per-category filter, so offering it here would hand a guest
the photos that opt-out is meant to withhold. Those galleries
keep the per-folder download, which enforces it.
These layouts have no header download button their only
gallery-wide download is select-all + download-selected, and
select-all is (correctly) scoped to what is on screen. Once
folders exist that leaves no single way to get everything, so
surface the event-wide zip here. Root only: inside a folder the
breadcrumb already offers that folder's download. */}
{!openFolder && allowDownloads && !hasRestrictedCategory && (
<Button
variant="outline"
size="sm"
onClick={handleDownloadAll}
disabled={downloadAllMutation.isPending}
leftIcon={<Download className="w-4 h-4" />}
// No ml-auto: the right of this band belongs to Story's fixed
// nav (logout, favourites), and pushing the button over there
// physically covers those controls.
className="pointer-events-auto"
>
{t('gallery.downloadEverything', 'Download all photos')}
</Button>
)}
</div>
)}
<PhotoGridWithLayouts
// Remount on a scope change (#1160): layout state such as the
// carousel's currentIndex is only meaningful for the photo set it was
// built against, and an index kept from a larger scope indexes past
// the end of a smaller folder.
key={openFolder ? `folder-${openFolder.id}` : 'root'}
photos={filteredPhotos}
// The hero, title, logout and download controls live inside this
// component for the full-bleed layouts, so a folder-only root must
// silence the empty message without unmounting the shell (#1160).
suppressEmptyState={rootIsFoldersOnly}
// Event-wide, so a layout's own Download All neither skips foldered
// photos nor truncates at the 500-id cap (#1160).
eventPhotoCount={data?.photos?.length || 0}
// Withheld when any category opts out of downloads (#640): the
// whole-gallery route serves a prebuilt zip that contains EVERY event
// photo with no per-category filter (gallery.js's own note on
// bumpEventDownloadCounts). Handing this to the layout there would
// turn a restricted photo into a downloadable one, so those galleries
// keep the id-based path, which enforces the opt-out.
onDownloadEverything={
allowDownloads && !hasRestrictedCategory ? handleDownloadAll : undefined
}
slug={slug}
people={peopleEnabled ? people : undefined}
onSelectPerson={togglePerson}
@@ -1122,7 +1394,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
<GallerySidebar
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(!sidebarOpen)}
categories={(data?.categories || []).filter(cat => photoCounts[cat.id] > 0)}
categories={filterCategories(data?.categories).filter(cat => photoCounts[cat.id] > 0)}
selectedCategoryId={selectedCategoryId}
onCategoryChange={setSelectedCategoryId}
searchTerm={searchTerm}
@@ -1139,7 +1411,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
isDownloading={downloadAllMutation.isPending}
allowDownloads={allowDownloads}
photoCounts={photoCounts}
totalPhotos={data?.photos.length || 0}
totalPhotos={scopedPhotos.length}
// Download All hits /download-all, which is event-wide — labelling or
// disabling it from the scoped count would show 0 on a folder-only
// root and refuse a perfectly valid download (#1160).
downloadAllTotal={data?.photos?.length || 0}
isMobile={isMobile}
galleryLayout={theme.galleryLayout}
allowUploads={data?.event?.allow_user_uploads || event?.allow_user_uploads || false}
@@ -1286,8 +1562,13 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
{filterBarShown ? (
<div className="mt-6">
<PhotoFilterBar
categories={data.categories}
photos={data.photos}
// Folders are navigation, not a filter (#1160) — they get tiles.
// Inside a folder the category chips are dead controls: the filter
// branch ignores `selectedCategoryId` there, so offering them would
// let a guest click a chip and see nothing happen.
categories={openFolder ? [] : filterCategories(data.categories)}
// Scoped, so a chip can't advertise a count the grid won't produce.
photos={scopedPhotos}
selectedCategoryId={selectedCategoryId}
onCategoryChange={setSelectedCategoryId}
searchTerm={searchTerm}
@@ -1320,7 +1601,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
<div className="mt-4">
<PeopleStrip
people={people}
photos={data.photos}
photos={scopedPhotos}
slug={slug}
selectedPersonIds={selectedPersonIds}
onToggle={togglePerson}
@@ -1379,8 +1660,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
<span className="text-sm sm:ml-auto" style={{ color: 'var(--color-muted-text)' }}>
{t('gallery.people.matchCount', {
count: filteredPhotos.length,
total: totalCount,
defaultValue: `${filteredPhotos.length} of ${totalCount} photos`,
// Scoped denominator (#1160): at a folder root this said
// "42 of 62" while only 42 exist in the view.
total: scopedPhotos.length,
defaultValue: `${filteredPhotos.length} of ${scopedPhotos.length} photos`,
})}
</span>
@@ -1418,8 +1701,15 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
`-mt-6` bleed leaves a visible gap instead of gluing the filter
bar to the hero image (issue #624). */}
<div className={filterBarShown && isHeroHeader ? "mt-12" : "mt-6"}>
<PhotoGridWithLayouts
photos={filteredPhotos}
{folderNav}
{/* A gallery whose photos ALL live in folders has an empty root grid,
and PhotoGridWithLayouts unconditionally renders "no photos found"
directly under the tiles that prove otherwise. Skip the grid when
the tiles are the entire content. */}
<PhotoGridWithLayouts
key={openFolder ? `folder-${openFolder.id}` : 'root'}
photos={filteredPhotos}
suppressEmptyState={rootIsFoldersOnly}
slug={slug}
people={peopleEnabled ? people : undefined}
onSelectPerson={togglePerson}
@@ -1505,7 +1795,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
open={showPeopleSheet}
onClose={() => setShowPeopleSheet(false)}
people={people}
photos={data?.photos || []}
photos={scopedPhotos}
slug={slug}
selectedPersonIds={selectedPersonIds}
onToggle={togglePerson}
@@ -83,10 +83,25 @@ interface PhotoGridWithLayoutsProps {
// show "In this photo: …". Undefined when the feature is off.
people?: GalleryPerson[];
onSelectPerson?: (personId: number) => void;
/**
* Suppress the "no photos found" message (#1160). A gallery whose photos all
* live in folders has an empty root grid while its folder tiles sit directly
* above printing "no photos" there contradicts the tiles. Only the message
* is suppressed: the full-page layouts render their hero, title, logout and
* download controls from inside this component, so unmounting it would strip
* the whole gallery shell.
*/
suppressEmptyState?: boolean;
/** #1160: event-wide count + whole-gallery download for layout chrome. */
eventPhotoCount?: number;
onDownloadEverything?: () => void;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
photos,
suppressEmptyState = false,
eventPhotoCount,
onDownloadEverything,
slug,
categoryId,
heroPhotoOverride,
@@ -224,11 +239,15 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
};
if (photos.length === 0) {
return (
<div className="text-center py-12">
<p className="text-muted-theme">{t('gallery.noPhotosFound')}</p>
</div>
);
// Suppressed (#1160): a folder-only root has folder tiles above proving the
// gallery isn't empty, so the message would contradict them.
if (!suppressEmptyState) {
return (
<div className="text-center py-12">
<p className="text-muted-theme">{t('gallery.noPhotosFound')}</p>
</div>
);
}
}
// Get the current layout from theme
@@ -237,6 +256,12 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
// Select the appropriate layout component
const layoutProps = {
photos,
// Forwarded so the full-bleed layouts, which render their OWN
// noPhotosFound return, don't contradict the folder tiles above them on a
// folder-only root (#1160).
suppressEmptyState,
eventPhotoCount,
onDownloadEverything,
slug,
// Face data (#1074) must reach the full-page layouts too — they render
// their OWN lightbox rather than the one below, so without this the
@@ -310,6 +335,14 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
// Gallery Premium and Gallery Story layouts have their own integrated hero/header
const isFullPageLayout = galleryLayout === 'gallery-premium' || galleryLayout === 'gallery-story';
// Folder-only root (#1160). The full-bleed layouts own the hero/logout chrome,
// so they are mounted even with an empty set. Every other layout is skipped
// instead: CarouselGalleryLayout returns before four of its useState calls, so
// driving one instance between empty and non-empty changes its hook count and
// React throws. Skipping only the child keeps this component's own HeroHeader
// and welcome message on screen.
const skipEmptyLayoutChild = photos.length === 0 && suppressEmptyState && !isFullPageLayout;
return (
<>
{/* Hero Header - shown when headerStyle is 'hero' (skip for full-page layouts with integrated hero) */}
@@ -400,7 +433,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
)}
{/* Render the selected layout */}
<LayoutComponent {...layoutProps} />
{skipEmptyLayoutChild ? null : <LayoutComponent {...layoutProps} />}
{/* Lightbox - skip for full-page layouts which have their own lightbox */}
{selectedPhotoIndex !== null && !isFullPageLayout && (
@@ -0,0 +1,255 @@
/**
* Gallery folders (#1160) the containment rule.
*
* The whole point of the feature: a foldered photo is ABSENT from the root grid
* and only appears inside its folder. A filter category keeps today's behaviour.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
filterCategories,
peopleInScope,
SELECTED_DOWNLOAD_LIMIT,
findFolderByKey,
folderKey,
folderCategoryIds,
folderTiles,
photosInScope,
readFolderParam,
writeFolderParam,
} from '../folders';
import type { Photo, PhotoCategory } from '../../../types';
const cat = (over: Partial<PhotoCategory> & { id: number; slug: string }): PhotoCategory => ({
name: over.slug,
is_global: false,
...over,
});
const photo = (id: number, category_id?: number | null): Photo =>
({ id, filename: `${id}.jpg`, category_id: category_id ?? null } as unknown as Photo);
const CATEGORIES: PhotoCategory[] = [
cat({ id: 1, slug: 'ceremony' }),
cat({ id: 2, slug: 'selects', is_folder: true }),
cat({ id: 3, slug: 'bw', is_folder: true }),
];
// 2 finals (one categorised, one loose), 3 in a folder, 1 in another folder.
const PHOTOS: Photo[] = [
photo(10, 1),
photo(11, null),
photo(20, 2),
photo(21, 2),
photo(22, 2),
photo(30, 3),
];
describe('photosInScope', () => {
it('drops every foldered photo from the root grid', () => {
const ids = photosInScope(PHOTOS, CATEGORIES, null).map((p) => p.id);
expect(ids).toEqual([10, 11]);
});
it('keeps uncategorised photos at root', () => {
expect(photosInScope(PHOTOS, CATEGORIES, null).map((p) => p.id)).toContain(11);
});
it('shows only that folders photos inside a folder', () => {
expect(photosInScope(PHOTOS, CATEGORIES, 2).map((p) => p.id)).toEqual([20, 21, 22]);
expect(photosInScope(PHOTOS, CATEGORIES, 3).map((p) => p.id)).toEqual([30]);
});
it('leaves a gallery without folders completely unchanged', () => {
const filtersOnly = [cat({ id: 1, slug: 'ceremony' })];
expect(photosInScope(PHOTOS, filtersOnly, null)).toHaveLength(PHOTOS.length);
});
// Callers sort the result in place. Returning `photos` itself on the
// no-folders fast path sorted the React Query cache for every other consumer.
it('never hands back the callers own array', () => {
const filtersOnly = [cat({ id: 1, slug: 'ceremony' })];
const out = photosInScope(PHOTOS, filtersOnly, null);
expect(out).not.toBe(PHOTOS);
out.sort((a, b) => b.id - a.id);
expect(PHOTOS.map((p) => p.id)).toEqual([10, 11, 20, 21, 22, 30]);
});
it('treats a category as a filter until is_folder is set', () => {
const asFilter = [cat({ id: 2, slug: 'selects' })];
expect(photosInScope(PHOTOS, asFilter, null)).toHaveLength(PHOTOS.length);
});
});
describe('folderTiles', () => {
it('builds one tile per non-empty folder with its count', () => {
const tiles = folderTiles(CATEGORIES, PHOTOS);
expect(tiles.map((t) => [t.category.slug, t.count])).toEqual([
['selects', 3],
['bw', 1],
]);
});
it('hides empty folders — a guest must not hit a dead end', () => {
const tiles = folderTiles([...CATEGORIES, cat({ id: 4, slug: 'empty', is_folder: true })], PHOTOS);
expect(tiles.map((t) => t.category.slug)).not.toContain('empty');
});
it('prefers the category hero as the cover, else the first photo', () => {
const withHero = [cat({ id: 2, slug: 'selects', is_folder: true, hero_photo_id: 22 })];
expect(folderTiles(withHero, PHOTOS)[0].coverPhoto?.id).toBe(22);
expect(folderTiles([CATEGORIES[1]], PHOTOS)[0].coverPhoto?.id).toBe(20);
});
it('falls back to the first photo when the hero left the folder', () => {
const staleHero = [cat({ id: 2, slug: 'selects', is_folder: true, hero_photo_id: 999 })];
expect(folderTiles(staleHero, PHOTOS)[0].coverPhoto?.id).toBe(20);
});
});
describe('findFolderByKey / folderKey', () => {
it('resolves an open folder', () => {
expect(findFolderByKey(CATEGORIES, 'selects-2')?.id).toBe(2);
});
it('falls back to root for an unknown key rather than emptying the gallery', () => {
expect(findFolderByKey(CATEGORIES, 'nope')).toBeNull();
});
it('refuses to open a filter category as a folder', () => {
expect(findFolderByKey(CATEGORIES, 'ceremony')).toBeNull();
});
// Regression: adminCategories slugs with `[^\w\s-]` stripping, and `\w` is
// ASCII-only — "Избранное" slugs to "". Keying on the slug made such a
// folder's photos unreachable: gone from the root grid, and the empty param
// neither wrote nor resolved.
it('keys a folder by id when its name slugs to nothing', () => {
const cyrillic = cat({ id: 7, slug: '', name: 'Избранное', is_folder: true });
expect(folderKey(cyrillic)).toBe('7');
expect(findFolderByKey([cyrillic], '7')?.id).toBe(7);
});
// A rename rewrites the slug, so a link already shared with a client must not
// silently dump them at the gallery root.
it('still resolves a link shared before the folder was renamed', () => {
const renamed = cat({ id: 2, slug: 'final-selects', is_folder: true });
expect(findFolderByKey([renamed], 'selects-2')?.id).toBe(2);
});
it('keeps the slug in the key so links stay readable', () => {
expect(folderKey(CATEGORIES[1])).toBe('selects-2');
});
// Regression: UNIQUE is (slug, event_id), so a global folder and an
// event-specific folder can share a slug. Keying on the slug alone made the
// second one unopenable — every lookup matched the first.
it('distinguishes two folders that share a slug across scopes', () => {
const globalSelects = cat({ id: 20, slug: 'selects', is_global: true, is_folder: true });
const eventSelects = cat({ id: 21, slug: 'selects', is_folder: true });
const both = [globalSelects, eventSelects];
expect(folderKey(globalSelects)).not.toBe(folderKey(eventSelects));
expect(findFolderByKey(both, folderKey(eventSelects))?.id).toBe(21);
expect(findFolderByKey(both, folderKey(globalSelects))?.id).toBe(20);
});
});
describe('filterCategories / folderCategoryIds', () => {
it('offers only filter categories to the filter UI', () => {
expect(filterCategories(CATEGORIES).map((c) => c.slug)).toEqual(['ceremony']);
});
it('collects folder ids', () => {
expect([...folderCategoryIds(CATEGORIES)]).toEqual([2, 3]);
});
});
describe('peopleInScope', () => {
// photo 10 -> Anna; 11 -> Anna+Ben; folder photos 20,21 -> Chris; 30 -> Ben
const withPeople: Photo[] = [
{ ...photo(10, 1), person_ids: [1] },
{ ...photo(11, null), person_ids: [1, 2] },
{ ...photo(20, 2), person_ids: [3] },
{ ...photo(21, 2), person_ids: [3] },
{ ...photo(22, 2), person_ids: [] },
{ ...photo(30, 3), person_ids: [2] },
] as unknown as Photo[];
const PEOPLE = [
{ id: 1, face_count: 99 },
{ id: 2, face_count: 99 },
{ id: 3, face_count: 99 },
];
it('recounts against the photos actually on screen', () => {
const atRoot = peopleInScope(PEOPLE, photosInScope(withPeople, CATEGORIES, null));
expect(atRoot).toEqual([
{ id: 1, face_count: 2 },
{ id: 2, face_count: 1 },
]);
});
it('drops a person whose photos all live in a folder — no dead chip at root', () => {
const atRoot = peopleInScope(PEOPLE, photosInScope(withPeople, CATEGORIES, null));
expect(atRoot.map((p) => p.id)).not.toContain(3);
});
it('counts only the folders photos while inside it', () => {
const inFolder = peopleInScope(PEOPLE, photosInScope(withPeople, CATEGORIES, 2));
expect(inFolder).toEqual([{ id: 3, face_count: 2 }]);
});
// PeopleStrip only shows the first 12 inline, so keeping /people's event-wide
// ordering after rescoping could push a folder's most-photographed person
// behind "Show all".
it('re-sorts by the recomputed scoped count', () => {
const people = [
{ id: 3, face_count: 99 }, // 2 in the folder
{ id: 1, face_count: 99 }, // 0 in the folder
{ id: 2, face_count: 99 }, // 0 in the folder
];
const inFolder = peopleInScope(people, photosInScope(withPeople, CATEGORIES, 2));
expect(inFolder.map((p) => p.id)).toEqual([3]);
const atRoot = peopleInScope(people, photosInScope(withPeople, CATEGORIES, null));
expect(atRoot.map((p) => [p.id, p.face_count])).toEqual([[1, 2], [2, 1]]);
});
it('is a no-op for a gallery without folders', () => {
const noFolders = [cat({ id: 1, slug: 'ceremony' })];
const scoped = peopleInScope(PEOPLE, photosInScope(withPeople, noFolders, null));
expect(scoped.map((p) => [p.id, p.face_count]).sort()).toEqual([[1, 2], [2, 2], [3, 2]]);
});
});
describe('SELECTED_DOWNLOAD_LIMIT', () => {
// Mirrors the server-side `.slice(0, 500)` in gallery.js's /download-selected
// and /download-jobs. If the backend cap moves and this doesn't, the folder
// button silently promises more than the archive will contain.
it('matches the cap the backend enforces', () => {
expect(SELECTED_DOWNLOAD_LIMIT).toBe(500);
});
});
describe('URL round-trip', () => {
const original = window.location.href;
beforeEach(() => window.history.replaceState({}, '', '/gallery/wed?token=abc&admin_preview=1'));
afterEach(() => window.history.replaceState({}, '', original));
it('reflects the open folder without dropping token or admin_preview', () => {
writeFolderParam('selects');
const params = new URLSearchParams(window.location.search);
expect(params.get('folder')).toBe('selects');
expect(params.get('token')).toBe('abc');
expect(params.get('admin_preview')).toBe('1');
expect(readFolderParam()).toBe('selects');
});
it('clears the param on the way back to root', () => {
writeFolderParam('selects');
writeFolderParam(null);
expect(readFolderParam()).toBeNull();
expect(new URLSearchParams(window.location.search).get('token')).toBe('abc');
});
});
+192
View File
@@ -0,0 +1,192 @@
/**
* Gallery folders (#1160).
*
* A category has always been a FILTER: its photos stay in the root grid and
* picking the category narrows that grid. A category flagged `is_folder` is a
* CONTAINER instead its photos are absent from the root grid entirely and only
* render once the guest opens the folder.
*
* Kept as pure functions so the containment rule is unit-testable without
* mounting the gallery, and so every layout shares one definition of "what is
* visible right now".
*
* NOTE: folders are organisational, not access control. A foldered photo is
* still served by the same per-photo auth as any other; hiding it from the root
* grid does not make its URL unreachable.
*/
import type { Photo, PhotoCategory } from '../../types';
export const FOLDER_QUERY_PARAM = 'folder';
/**
* Server-side cap on `/download-selected` and `/download-jobs`
* (gallery.js slices the id list to this). Mirrored here so the folder download
* can say what it will actually deliver instead of promising the whole folder
* and quietly handing back the first 500.
*/
export const SELECTED_DOWNLOAD_LIMIT = 500;
/** Ids of every category that contains (rather than filters) its photos. */
export function folderCategoryIds(categories: PhotoCategory[] | undefined): Set<number | string> {
const ids = new Set<number | string>();
(categories || []).forEach((c) => {
if (c.is_folder) ids.add(c.id);
});
return ids;
}
/**
* The URL key for a folder.
*
* Prefers the slug because it makes a shared link readable, but falls back to
* the id: `adminCategories` derives slugs with `[^\w\s-]` stripping, and `\w`
* is ASCII-only, so a perfectly valid name in a non-Latin script ("Избранное",
* "日本語") slugs to the empty string. An empty key would delete the query
* param on open and never resolve on read the folder's photos would be gone
* from the root grid with no way back to them.
*/
export function folderKey(category: Pick<PhotoCategory, 'id' | 'slug'>): string {
const slug = (category.slug || '').trim();
// The id is always appended: slugs are only unique per scope
// (UNIQUE(slug, event_id)), so a global folder and an event folder can share
// one. Keying on the slug alone made the second of the pair unopenable —
// every lookup resolved to the first match.
return slug ? `${slug}-${category.id}` : String(category.id);
}
/**
* The folder matching a `?folder=<key>`, or null at root / for an unknown key.
*
* Resolves on the trailing ID rather than the whole key: renaming a category
* rewrites its slug, so an already-shared `?folder=selects-11` would otherwise
* stop matching and silently dump the visitor at the gallery root. The slug is
* there to make the link readable, not to identify the folder.
*/
export function findFolderByKey(
categories: PhotoCategory[] | undefined,
key: string | null
): PhotoCategory | null {
if (!key) return null;
const list = categories || [];
const trailing = key.split('-').pop();
const id = trailing !== undefined && trailing !== '' ? Number(trailing) : NaN;
if (Number.isInteger(id)) {
const byId = list.find((c) => c.is_folder && Number(c.id) === id);
if (byId) return byId;
}
return list.find((c) => c.is_folder && folderKey(c) === key) || null;
}
/**
* The photos in scope right now.
*
* Root: everything except photos living in a folder (uncategorised photos always
* belong to root). Inside a folder: only that folder's photos.
*/
export function photosInScope(
photos: Photo[] | undefined,
categories: PhotoCategory[] | undefined,
openFolderId: number | string | null
): Photo[] {
const list = photos || [];
if (openFolderId !== null && openFolderId !== undefined) {
return list.filter((p) => p.category_id === openFolderId);
}
const folders = folderCategoryIds(categories);
// Always a NEW array, even on the no-folders fast path: callers sort the
// result in place, and handing back `data.photos` itself would sort the React
// Query cache and reorder it for every other consumer.
if (folders.size === 0) return [...list];
return list.filter((p) => !p.category_id || !folders.has(p.category_id));
}
export interface FolderTile {
category: PhotoCategory;
count: number;
coverPhoto: Photo | null;
}
/**
* Folder tiles for the root view, in the order the backend resolved (#782).
*
* Only folders that actually hold photos get a tile an empty folder would be a
* dead end for a guest. The cover is the category hero (#163) when it is still
* in the folder, else the folder's first photo.
*/
export function folderTiles(
categories: PhotoCategory[] | undefined,
photos: Photo[] | undefined
): FolderTile[] {
const list = photos || [];
return (categories || [])
.filter((c) => c.is_folder)
.map((category) => {
const contents = list.filter((p) => p.category_id === category.id);
const hero = category.hero_photo_id
? contents.find((p) => p.id === category.hero_photo_id) || null
: null;
return { category, count: contents.length, coverPhoto: hero || contents[0] || null };
})
.filter((tile) => tile.count > 0);
}
/**
* People, recounted against the photos actually on screen (#1160).
*
* `face_count` comes from /people and spans the whole event, which contradicts
* the grid once folders exist: inside a folder a face reads "12 photos" but
* clicking it yields only the ones in that folder, and at root a person whose
* photos ALL live in a folder shows up and filters down to nothing a dead
* chip. Recomputing from `photo.person_ids` (already what the filter itself
* uses) keeps the strip honest, and dropping the zeroes removes the dead chips.
*/
export function peopleInScope<T extends { id: number; face_count: number }>(
people: T[] | undefined,
scopedPhotos: Photo[] | undefined
): T[] {
const list = people || [];
if (list.length === 0) return list;
const counts = new Map<number, number>();
(scopedPhotos || []).forEach((photo) => {
const ids = (photo as Photo & { person_ids?: number[] }).person_ids || [];
ids.forEach((id) => counts.set(id, (counts.get(id) || 0) + 1));
});
// Re-sorted, not just recounted: /people orders by the EVENT-wide count, and
// PeopleStrip only shows the first 12 inline. Keeping that order after
// rescoping can push the folder's most-photographed person behind "Show all".
return list
.map((person) => ({ ...person, face_count: counts.get(person.id) || 0 }))
.filter((person) => person.face_count > 0)
.sort((a, b) => b.face_count - a.face_count);
}
/** Categories that still act as filters — the only ones the filter UI should offer. */
export function filterCategories(categories: PhotoCategory[] | undefined): PhotoCategory[] {
return (categories || []).filter((c) => !c.is_folder);
}
/** Read the open folder slug from the address bar. */
export function readFolderParam(): string | null {
if (typeof window === 'undefined') return null;
return new URLSearchParams(window.location.search).get(FOLDER_QUERY_PARAM);
}
/**
* Reflect the open folder in the address bar so a folder is linkable and the
* back button leaves it. Preserves every other param `token` and
* `admin_preview` (#868) both ride on gallery URLs.
*/
export function writeFolderParam(slug: string | null): void {
if (typeof window === 'undefined') return;
const url = new URL(window.location.href);
if (slug) {
url.searchParams.set(FOLDER_QUERY_PARAM, slug);
} else {
url.searchParams.delete(FOLDER_QUERY_PARAM);
}
window.history.pushState({ [FOLDER_QUERY_PARAM]: slug }, '', url.toString());
}
@@ -25,6 +25,21 @@ export interface BaseGalleryLayoutProps {
eventDate?: string | null;
expiresAt?: string | null;
allowDownloads?: boolean;
/** #1160: folder-only root — render the shell, skip the empty message. */
suppressEmptyState?: boolean;
/**
* Event-wide photo count (#1160), for stats a layout renders about the whole
* gallery. `photos` is only the current folder scope and is empty at a
* folder-only root.
*/
eventPhotoCount?: number;
/**
* Runs the whole-gallery download (#1160). A layout's own "Download All
* Photos" must use this rather than posting an id list: /download-selected
* caps at 500 server-side, so a large gallery would silently truncate, while
* /download-all has no such cap.
*/
onDownloadEverything?: () => void;
// Resolution picker choices (#858). More than one entry means the gallery
// offers a real choice, so bulk downloads must route through the modal
// instead of calling downloadSelectedPhotos directly.
@@ -193,9 +193,12 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
interface GalleryPremiumLayoutProps extends BaseGalleryLayoutProps {
heroPhotoOverride?: Photo | null;
suppressEmptyState?: boolean;
}
export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
// #1160: folder-only root — render the shell, skip the empty message.
suppressEmptyState = false,
photos,
slug,
onPhotoClick: _onPhotoClick,
@@ -477,7 +480,10 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
day: '2-digit'
}) : null;
if (photos.length === 0) {
// #1160: a folder-only root has no photos to show here, but the folder tiles
// above prove the gallery isn't empty — render the shell (hero, logout,
// controls) without the contradictory message.
if (photos.length === 0 && !suppressEmptyState) {
return (
<div className="text-center py-12">
<p className="text-gray-500">{t('gallery.noPhotosFound')}</p>
@@ -570,7 +576,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
<Heart className="w-4 h-4" />
</button>
)}
{allowDownloads && (
{allowDownloads && photos.length > 0 && (
<button
className="gallery-premium-nav-btn"
title={t('common.downloadAll', 'Download All')}
@@ -51,6 +51,9 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
eventName,
eventDate,
allowDownloads = true,
suppressEmptyState = false,
eventPhotoCount,
onDownloadEverything,
downloadChoices,
onPickResolution,
protectionLevel = 'standard',
@@ -139,7 +142,10 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
}));
}, [photos, searchQuery, t]);
const totalPhotos = photos.length;
// #1160: on a folder-only root this component renders its shell with an empty
// scope, so fall back to the event-wide count rather than announcing 0 Photos
// directly above folder tiles that hold them.
const totalPhotos = photos.length || eventPhotoCount || 0;
const stats = `${totalPhotos} ${t('gallery.photos', 'Photos')}`;
const handleToggleFavorite = useCallback(async (photoId: number) => {
@@ -237,22 +243,31 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
}, [selectedPhotoForFeedback, ratings, slug, savedIdentity, onFeedbackChange]);
const handleDownloadAll = useCallback(async () => {
// Whole-gallery path when available: posting ids would hit the server's
// 500-id cap and silently truncate a large gallery (#1160).
if (onDownloadEverything) {
onDownloadEverything();
return;
}
const ids = photos.map(p => p.id);
// #858: hand off to the resolution picker when the gallery offers a choice.
if (downloadChoices && downloadChoices.length > 1 && onPickResolution) {
onPickResolution(ids);
return;
}
toast.info(t('gallery.downloading', { count: photos.length }));
toast.info(t('gallery.downloading', { count: ids.length }));
try {
await galleryService.downloadSelectedPhotos(slug, ids);
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
} catch {
toast.error(t('gallery.downloadError'));
}
}, [photos, slug, t, downloadChoices, onPickResolution]);
}, [photos, onDownloadEverything, slug, t, downloadChoices, onPickResolution]);
if (photos.length === 0) {
// #1160: a folder-only root has no photos to show here, but the folder tiles
// above prove the gallery isn't empty — render the shell (hero, logout,
// controls) without the contradictory message.
if (photos.length === 0 && !suppressEmptyState) {
return (
<div className="text-center py-12">
<p className="text-gray-500">{t('gallery.noPhotosFound')}</p>
@@ -373,7 +388,11 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
<p className="story-footer-text">
{welcomeMessage || t('gallery.thankYouMessage', 'For being part of our story and making our special day unforgettable.')}
</p>
{allowDownloads && (
{/* Needs something to download: either the whole-gallery callback, or
photos in the current scope. On a folder-only root of a gallery with
a category download opt-out it has neither, and posting an empty id
list is a 400 (#1160). */}
{allowDownloads && (onDownloadEverything || photos.length > 0) && (
<button className="story-footer-btn" onClick={handleDownloadAll}>
{t('common.downloadAll', 'Download All Photos')}
</button>
+16 -3
View File
@@ -1086,7 +1086,14 @@
"downloadThese": "Diese {{count}} herunterladen"
},
"colorFilter": "Farbe",
"filterByColor": "Nur {{color}} anzeigen"
"filterByColor": "Nur {{color}} anzeigen",
"folders": "Ordner",
"openFolder": "Ordner {{name}} öffnen",
"folderPhotoCount": "{{count}} Fotos",
"backToGallery": "Alle Fotos",
"downloadFolder": "Ordner herunterladen ({{count}})",
"downloadFolderCapped": "Erste {{limit}} von {{total}} herunterladen",
"downloadEverything": "Alle Fotos herunterladen"
},
"categories": {
"title": "Fotokategorien",
@@ -1121,7 +1128,12 @@
"downloadsDisabled": "Downloads für diese Kategorie deaktiviert",
"enableDownloadsTitle": "Klicken zum Aktivieren der Downloads für diese Kategorie",
"disableDownloadsTitle": "Klicken zum Deaktivieren der Downloads für diese Kategorie",
"failedToToggleDownloads": "Aktualisierung der Download-Berechtigung fehlgeschlagen"
"failedToToggleDownloads": "Aktualisierung der Download-Berechtigung fehlgeschlagen",
"folderEnabled": "Fotos dieser Kategorie liegen jetzt in einem Ordner",
"folderDisabled": "Diese Kategorie filtert die Galerie wieder",
"failedToToggleFolder": "Ordner-Einstellung konnte nicht geändert werden",
"disableFolderTitle": "Ordner: Diese Fotos sind im Hauptraster ausgeblendet und nur im Ordner sichtbar. Klicken, um wieder einen Filter daraus zu machen.",
"enableFolderTitle": "Filter: Diese Fotos bleiben im Hauptraster. Klicken, um einen Ordner daraus zu machen — blendet sie aus, schränkt den Zugriff aber NICHT ein."
},
"events": {
"revealMode": "Reveal-Modus (Galerie bis zur Freigabe verbergen)",
@@ -4200,7 +4212,8 @@
"movedToCategory_one": "{{count}} Foto nach {{category}} verschoben",
"movedToCategory_other": "{{count}} Fotos nach {{category}} verschoben",
"moveToCategoryFailed": "Fotos konnten nicht in die Kategorie verschoben werden",
"moveToCategory": "In Kategorie verschieben"
"moveToCategory": "In Kategorie verschieben",
"folderOption": "{{name}} (Ordner — im Hauptraster ausgeblendet)"
},
"customer": {
"login": {
+16 -3
View File
@@ -627,7 +627,14 @@
"downloadThese": "Download these {{count}}"
},
"colorFilter": "Color",
"filterByColor": "Show only {{color}}"
"filterByColor": "Show only {{color}}",
"folders": "Folders",
"openFolder": "Open folder {{name}}",
"folderPhotoCount": "{{count}} photos",
"backToGallery": "All photos",
"downloadFolder": "Download folder ({{count}})",
"downloadFolderCapped": "Download first {{limit}} of {{total}}",
"downloadEverything": "Download all photos"
},
"categories": {
"title": "Photo Categories",
@@ -662,7 +669,12 @@
"downloadsDisabled": "Downloads disabled for this category",
"enableDownloadsTitle": "Click to enable downloads for this category",
"disableDownloadsTitle": "Click to disable downloads for this category",
"failedToToggleDownloads": "Failed to update download permission"
"failedToToggleDownloads": "Failed to update download permission",
"folderEnabled": "Photos in this category now sit inside a folder",
"folderDisabled": "This category filters the gallery again",
"failedToToggleFolder": "Failed to update folder setting",
"disableFolderTitle": "Folder: these photos are hidden from the main grid and shown only inside the folder. Click to make it a filter again.",
"enableFolderTitle": "Filter: these photos stay in the main grid. Click to turn it into a folder — hides them from the grid, does NOT restrict access."
},
"events": {
"revealMode": "Reveal mode (hide gallery until reveal)",
@@ -4200,7 +4212,8 @@
"movedToCategory_one": "{{count}} photos moved to {{category}}",
"movedToCategory_other": "{{count}} photos moved to {{category}}",
"moveToCategoryFailed": "Failed to move photos to category",
"moveToCategory": "Move to Category"
"moveToCategory": "Move to Category",
"folderOption": "{{name}} (folder — hidden from the main grid)"
},
"customer": {
"login": {
+8 -1
View File
@@ -320,7 +320,14 @@
"rated": "Valorado",
"commented": "Comentado",
"colorFilter": "Color",
"filterByColor": "Mostrar solo {{color}}"
"filterByColor": "Mostrar solo {{color}}",
"folders": "Carpetas",
"openFolder": "Abrir carpeta {{name}}",
"folderPhotoCount": "{{count}} fotos",
"backToGallery": "Todas las fotos",
"downloadFolder": "Descargar carpeta ({{count}})",
"downloadFolderCapped": "Descargar las primeras {{limit}} de {{total}}",
"downloadEverything": "Descargar todas las fotos"
},
"categories": {
"title": "Categorías de fotos",
+8 -1
View File
@@ -340,7 +340,14 @@
"downloadSelected_one": "Télécharger {{count}} photo",
"downloadSelected_other": "Télécharger {{count}} photos",
"colorFilter": "Couleur",
"filterByColor": "Afficher uniquement {{color}}"
"filterByColor": "Afficher uniquement {{color}}",
"folders": "Dossiers",
"openFolder": "Ouvrir le dossier {{name}}",
"folderPhotoCount": "{{count}} photos",
"backToGallery": "Toutes les photos",
"downloadFolder": "Télécharger le dossier ({{count}})",
"downloadFolderCapped": "Télécharger les {{limit}} premières sur {{total}}",
"downloadEverything": "Télécharger toutes les photos"
},
"categories": {
"title": "Catégories de photos",
+8 -1
View File
@@ -340,7 +340,14 @@
"photosCount_one": "{{count}} foto",
"photosCount_other": "{{count}} foto's",
"colorFilter": "Kleur",
"filterByColor": "Alleen {{color}} tonen"
"filterByColor": "Alleen {{color}} tonen",
"folders": "Mappen",
"openFolder": "Map {{name}} openen",
"folderPhotoCount": "{{count}} foto's",
"backToGallery": "Alle foto's",
"downloadFolder": "Map downloaden ({{count}})",
"downloadFolderCapped": "Eerste {{limit}} van {{total}} downloaden",
"downloadEverything": "Alle foto's downloaden"
},
"categories": {
"title": "Fotocategorieen",
+8 -1
View File
@@ -348,7 +348,14 @@
"photosCount_one": "{{count}} foto",
"photosCount_other": "{{count}} fotos",
"colorFilter": "Cor",
"filterByColor": "Mostrar apenas {{color}}"
"filterByColor": "Mostrar apenas {{color}}",
"folders": "Pastas",
"openFolder": "Abrir pasta {{name}}",
"folderPhotoCount": "{{count}} fotos",
"backToGallery": "Todas as fotos",
"downloadFolder": "Baixar pasta ({{count}})",
"downloadFolderCapped": "Baixar as primeiras {{limit}} de {{total}}",
"downloadEverything": "Baixar todas as fotos"
},
"categories": {
"title": "Categorias de Fotos",
+8 -1
View File
@@ -356,7 +356,14 @@
"photosCount_one": "{{count}} фото",
"photosCount_other": "{{count}} фото",
"colorFilter": "Цвет",
"filterByColor": "Показать только «{{color}}»"
"filterByColor": "Показать только «{{color}}»",
"folders": "Папки",
"openFolder": "Открыть папку {{name}}",
"folderPhotoCount": "{{count}} фото",
"backToGallery": "Все фото",
"downloadFolder": "Скачать папку ({{count}})",
"downloadFolderCapped": "Скачать первые {{limit}} из {{total}}",
"downloadEverything": "Скачать все фото"
},
"categories": {
"title": "Категории фото",
+8 -1
View File
@@ -340,7 +340,14 @@
"downloadSelected_one": "Prenesi {{count}} fotografijo",
"downloadSelected_other": "Prenesi {{count}} fotografij",
"colorFilter": "Barva",
"filterByColor": "Prikaži samo {{color}}"
"filterByColor": "Prikaži samo {{color}}",
"folders": "Mape",
"openFolder": "Odpri mapo {{name}}",
"folderPhotoCount": "{{count}} fotografij",
"backToGallery": "Vse fotografije",
"downloadFolder": "Prenesi mapo ({{count}})",
"downloadFolderCapped": "Prenesi prvih {{limit}} od {{total}}",
"downloadEverything": "Prenesi vse fotografije"
},
"categories": {
"title": "Kategorije fotografij",
@@ -39,7 +39,7 @@ interface EventInformationCardProps {
setShowNewPassword: (show: boolean) => void;
feedbackSettings: FeedbackSettingsType;
setFeedbackSettings: React.Dispatch<React.SetStateAction<FeedbackSettingsType>>;
categories: Array<{ id: number; name: string; slug: string }>;
categories: Array<{ id: number; name: string; slug: string; is_folder?: boolean }>;
photos: AdminPhoto[];
phoneFieldEnabled: boolean;
daysUntilExpiration: number | null;
@@ -31,7 +31,7 @@ interface OverviewTabProps {
setShowNewPassword: (show: boolean) => void;
feedbackSettings: FeedbackSettingsType;
setFeedbackSettings: React.Dispatch<React.SetStateAction<FeedbackSettingsType>>;
categories: Array<{ id: number; name: string; slug: string }>;
categories: Array<{ id: number; name: string; slug: string; is_folder?: boolean }>;
photos: AdminPhoto[];
phoneFieldEnabled: boolean;
daysUntilExpiration: number | null;
@@ -7,7 +7,7 @@ import type { EventDetailsTab } from './types';
interface PhotoStatisticsCardProps {
event: Event;
categories: Array<{ id: number; name: string; slug: string }>;
categories: Array<{ id: number; name: string; slug: string; is_folder?: boolean }>;
setActiveTab: (tab: EventDetailsTab) => void;
}
@@ -17,7 +17,7 @@ interface PhotosTabProps {
photos: AdminPhoto[];
photosLoading: boolean;
refetchPhotos: () => void;
categories: Array<{ id: number; name: string; slug: string }>;
categories: Array<{ id: number; name: string; slug: string; is_folder?: boolean }>;
photoFilters: PhotoFilterParams;
setPhotoFilters: React.Dispatch<React.SetStateAction<PhotoFilterParams>>;
feedbackFilters: FeedbackFilters;
+6 -1
View File
@@ -16,6 +16,10 @@ export interface PhotoCategory {
// Per-event override position (#782). Non-null on the /event/:id response when
// this gallery has customised its order; null means it follows the default.
override_position?: number | null;
// Folder vs filter (#1160). false (the default) is the historical behaviour:
// the category filters the root grid. true makes it a container — its photos
// leave the root grid and only render inside the folder.
is_folder?: boolean;
created_at: string;
}
@@ -24,6 +28,7 @@ export interface CreateCategoryData {
slug?: string;
is_global?: boolean;
event_id?: number;
is_folder?: boolean;
}
export const categoriesService = {
@@ -52,7 +57,7 @@ export const categoriesService = {
async updateCategory(
id: number,
name: string,
patch?: { allow_downloads?: boolean }
patch?: { allow_downloads?: boolean; is_folder?: boolean }
): Promise<PhotoCategory> {
const response = await api.put<PhotoCategory>(`/admin/categories/${id}`, { name, ...patch });
return response.data;
+1 -1
View File
@@ -232,7 +232,7 @@ export const eventsService = {
},
// Get event categories
async getEventCategories(eventId: number): Promise<Array<{ id: number; name: string; slug: string }>> {
async getEventCategories(eventId: number): Promise<Array<{ id: number; name: string; slug: string; is_folder?: boolean }>> {
const response = await api.get(`/admin/categories/event/${eventId}`);
return response.data || [];
},
+7
View File
@@ -249,6 +249,13 @@ export interface PhotoCategory {
slug: string;
is_global: boolean;
hero_photo_id?: number | null;
// Per-category download opt-out (#640). false hides the download affordance
// for this category — including a folder's own "download folder" button.
allow_downloads?: boolean;
// Folder vs filter (#1160). A filter category leaves its photos in the root
// grid and narrows it when picked; a folder CONTAINS them — they are absent
// from the root grid and only render once the guest opens the folder.
is_folder?: boolean;
}
export interface GalleryData {