From 0a36ca605662db5ff6d215ea7164e13b166b3121 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:03:49 +0200 Subject: [PATCH] feat(gallery): folders that contain photos instead of filtering them (#1160) (#1161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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=` 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 " (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 . 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 . 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 --- .../routes/galleryFolderCategories.test.js | 142 +++++++ .../core/185_add_category_is_folder.js | 39 ++ backend/src/routes/adminCategories.js | 23 +- backend/src/routes/adminEvents/crud.js | 6 +- backend/src/routes/gallery.js | 8 +- .../src/components/admin/AdminPhotoGrid.tsx | 2 + .../components/admin/BulkCategoryModal.tsx | 8 +- .../components/admin/EventCategoryManager.tsx | 44 ++- .../components/gallery/GalleryFolderTiles.tsx | 133 +++++++ .../src/components/gallery/GallerySidebar.tsx | 12 +- .../src/components/gallery/GalleryView.tsx | 362 ++++++++++++++++-- .../gallery/PhotoGridWithLayouts.tsx | 45 ++- .../gallery/__tests__/folders.test.ts | 255 ++++++++++++ frontend/src/components/gallery/folders.ts | 192 ++++++++++ .../gallery/layouts/BaseGalleryLayout.tsx | 15 + .../gallery/layouts/GalleryPremiumLayout.tsx | 10 +- .../gallery/layouts/GalleryStoryLayout.tsx | 29 +- frontend/src/i18n/locales/de.json | 19 +- frontend/src/i18n/locales/en.json | 19 +- frontend/src/i18n/locales/es.json | 9 +- frontend/src/i18n/locales/fr.json | 9 +- frontend/src/i18n/locales/nl.json | 9 +- frontend/src/i18n/locales/pt.json | 9 +- frontend/src/i18n/locales/ru.json | 9 +- frontend/src/i18n/locales/sl.json | 9 +- .../event-details/EventInformationCard.tsx | 2 +- .../pages/admin/event-details/OverviewTab.tsx | 2 +- .../event-details/PhotoStatisticsCard.tsx | 2 +- .../pages/admin/event-details/PhotosTab.tsx | 2 +- frontend/src/services/categories.service.ts | 7 +- frontend/src/services/events.service.ts | 2 +- frontend/src/types/index.ts | 7 + 32 files changed, 1363 insertions(+), 78 deletions(-) create mode 100644 backend/__tests__/routes/galleryFolderCategories.test.js create mode 100644 backend/migrations/core/185_add_category_is_folder.js create mode 100644 frontend/src/components/gallery/GalleryFolderTiles.tsx create mode 100644 frontend/src/components/gallery/__tests__/folders.test.ts create mode 100644 frontend/src/components/gallery/folders.ts diff --git a/backend/__tests__/routes/galleryFolderCategories.test.js b/backend/__tests__/routes/galleryFolderCategories.test.js new file mode 100644 index 00000000..aa5bcecc --- /dev/null +++ b/backend/__tests__/routes/galleryFolderCategories.test.js @@ -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); + }); +}); diff --git a/backend/migrations/core/185_add_category_is_folder.js b/backend/migrations/core/185_add_category_is_folder.js new file mode 100644 index 00000000..2a6a038c --- /dev/null +++ b/backend/migrations/core/185_add_category_is_folder.js @@ -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')); + } +}; diff --git a/backend/src/routes/adminCategories.js b/backend/src/routes/adminCategories.js index fd6a1ffc..d5ae2404 100644 --- a/backend/src/routes/adminCategories.js +++ b/backend/src/routes/adminCategories.js @@ -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); diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index c2767c92..12b372b6 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -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)), })), ); } diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 8822f847..42a97c28 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -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) })); } diff --git a/frontend/src/components/admin/AdminPhotoGrid.tsx b/frontend/src/components/admin/AdminPhotoGrid.tsx index 92ba9879..800b3c04 100644 --- a/frontend/src/components/admin/AdminPhotoGrid.tsx +++ b/frontend/src/components/admin/AdminPhotoGrid.tsx @@ -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 { diff --git a/frontend/src/components/admin/BulkCategoryModal.tsx b/frontend/src/components/admin/BulkCategoryModal.tsx index 58ae2720..b3fab0cd 100644 --- a/frontend/src/components/admin/BulkCategoryModal.tsx +++ b/frontend/src/components/admin/BulkCategoryModal.tsx @@ -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 = ({ {categories.map((category) => ( ))} diff --git a/frontend/src/components/admin/EventCategoryManager.tsx b/frontend/src/components/admin/EventCategoryManager.tsx index 78d136af..cfcea573 100644 --- a/frontend/src/components/admin/EventCategoryManager.tsx +++ b/frontend/src/components/admin/EventCategoryManager.tsx @@ -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 = ({ 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 = ({ even only. Global categories are managed in Settings. */} {!category.is_global && ( <> + + ))} + + ); + } + + return ( +
+ {/* 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). */} +

+ {t('gallery.folders', 'Folders')} +

+
+ {tiles.map(({ category, count, coverPhoto }) => ( + + ))} +
+
+ ); +}; diff --git a/frontend/src/components/gallery/GallerySidebar.tsx b/frontend/src/components/gallery/GallerySidebar.tsx index 4f7e971e..99d618fa 100644 --- a/frontend/src/components/gallery/GallerySidebar.tsx +++ b/frontend/src/components/gallery/GallerySidebar.tsx @@ -29,6 +29,13 @@ interface GallerySidebarProps { allowDownloads?: boolean; photoCounts?: Record; 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 = ({ allowDownloads = true, photoCounts = {}, totalPhotos, + downloadAllTotal, isMobile, galleryLayout, allowUploads, @@ -205,10 +213,10 @@ export const GallerySidebar: React.FC = ({ size="sm" leftIcon={} 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}) + / + + {openFolder.name} + + {allowDownloads && folderDownloadableIds.length > 0 && ( + + )} + + ) : ( + + ); + + 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. +
+
+ {buildFolderNav(true)} +
+ {/* 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 && ( + + )} +
+ )} = ({ slug, event, requiresP 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 = ({ 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 = ({ slug, event, requiresP {filterBarShown ? (
= ({ slug, event, requiresP
= ({ slug, event, requiresP {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`, })} @@ -1418,8 +1701,15 @@ export const GalleryView: React.FC = ({ slug, event, requiresP `-mt-6` bleed leaves a visible gap instead of gluing the filter bar to the hero image (issue #624). */}
- = ({ slug, event, requiresP open={showPeopleSheet} onClose={() => setShowPeopleSheet(false)} people={people} - photos={data?.photos || []} + photos={scopedPhotos} slug={slug} selectedPersonIds={selectedPersonIds} onToggle={togglePerson} diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx index 6b864071..9af80795 100644 --- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx +++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx @@ -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 = ({ photos, + suppressEmptyState = false, + eventPhotoCount, + onDownloadEverything, slug, categoryId, heroPhotoOverride, @@ -224,11 +239,15 @@ export const PhotoGridWithLayouts: React.FC = ({ }; if (photos.length === 0) { - return ( -
-

{t('gallery.noPhotosFound')}

-
- ); + // 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 ( +
+

{t('gallery.noPhotosFound')}

+
+ ); + } } // Get the current layout from theme @@ -237,6 +256,12 @@ export const PhotoGridWithLayouts: React.FC = ({ // 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 = ({ // 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 = ({ )} {/* Render the selected layout */} - + {skipEmptyLayoutChild ? null : } {/* Lightbox - skip for full-page layouts which have their own lightbox */} {selectedPhotoIndex !== null && !isFullPageLayout && ( diff --git a/frontend/src/components/gallery/__tests__/folders.test.ts b/frontend/src/components/gallery/__tests__/folders.test.ts new file mode 100644 index 00000000..e61b2eb2 --- /dev/null +++ b/frontend/src/components/gallery/__tests__/folders.test.ts @@ -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 & { 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 folder’s 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 caller’s 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 folder’s 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'); + }); +}); diff --git a/frontend/src/components/gallery/folders.ts b/frontend/src/components/gallery/folders.ts new file mode 100644 index 00000000..49530d71 --- /dev/null +++ b/frontend/src/components/gallery/folders.ts @@ -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 { + const ids = new Set(); + (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): 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=`, 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( + people: T[] | undefined, + scopedPhotos: Photo[] | undefined +): T[] { + const list = people || []; + if (list.length === 0) return list; + + const counts = new Map(); + (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()); +} diff --git a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx index 4f4422f9..e818d7b8 100644 --- a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx @@ -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. diff --git a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx index 282cd3fa..c45ec555 100644 --- a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx @@ -193,9 +193,12 @@ const PhotoCard: React.FC = ({ interface GalleryPremiumLayoutProps extends BaseGalleryLayoutProps { heroPhotoOverride?: Photo | null; + suppressEmptyState?: boolean; } export const GalleryPremiumLayout: React.FC = ({ + // #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 = ({ 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 (

{t('gallery.noPhotosFound')}

@@ -570,7 +576,7 @@ export const GalleryPremiumLayout: React.FC = ({ )} - {allowDownloads && ( + {allowDownloads && photos.length > 0 && ( diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 138f9cd1..8df2d6b9 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index d9e0487f..db07bf15 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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": { diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index e0e85261..95f61c23 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -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", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 62b6b5e4..f0940b45 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index 6eedf13e..9d9fab0e 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -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", diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 736cce3a..ffd3b267 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -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", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 442a6b68..7a3923a5 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -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": "Категории фото", diff --git a/frontend/src/i18n/locales/sl.json b/frontend/src/i18n/locales/sl.json index 3c4018d1..de357f3e 100644 --- a/frontend/src/i18n/locales/sl.json +++ b/frontend/src/i18n/locales/sl.json @@ -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", diff --git a/frontend/src/pages/admin/event-details/EventInformationCard.tsx b/frontend/src/pages/admin/event-details/EventInformationCard.tsx index e58ded5c..e524da5a 100644 --- a/frontend/src/pages/admin/event-details/EventInformationCard.tsx +++ b/frontend/src/pages/admin/event-details/EventInformationCard.tsx @@ -39,7 +39,7 @@ interface EventInformationCardProps { setShowNewPassword: (show: boolean) => void; feedbackSettings: FeedbackSettingsType; setFeedbackSettings: React.Dispatch>; - 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; diff --git a/frontend/src/pages/admin/event-details/OverviewTab.tsx b/frontend/src/pages/admin/event-details/OverviewTab.tsx index 64f4d4b7..a308c5bf 100644 --- a/frontend/src/pages/admin/event-details/OverviewTab.tsx +++ b/frontend/src/pages/admin/event-details/OverviewTab.tsx @@ -31,7 +31,7 @@ interface OverviewTabProps { setShowNewPassword: (show: boolean) => void; feedbackSettings: FeedbackSettingsType; setFeedbackSettings: React.Dispatch>; - 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; diff --git a/frontend/src/pages/admin/event-details/PhotoStatisticsCard.tsx b/frontend/src/pages/admin/event-details/PhotoStatisticsCard.tsx index 5e37bc31..4e7808df 100644 --- a/frontend/src/pages/admin/event-details/PhotoStatisticsCard.tsx +++ b/frontend/src/pages/admin/event-details/PhotoStatisticsCard.tsx @@ -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; } diff --git a/frontend/src/pages/admin/event-details/PhotosTab.tsx b/frontend/src/pages/admin/event-details/PhotosTab.tsx index 0e8b7b92..e198253f 100644 --- a/frontend/src/pages/admin/event-details/PhotosTab.tsx +++ b/frontend/src/pages/admin/event-details/PhotosTab.tsx @@ -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>; feedbackFilters: FeedbackFilters; diff --git a/frontend/src/services/categories.service.ts b/frontend/src/services/categories.service.ts index c3078b99..e4aaec9a 100644 --- a/frontend/src/services/categories.service.ts +++ b/frontend/src/services/categories.service.ts @@ -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 { const response = await api.put(`/admin/categories/${id}`, { name, ...patch }); return response.data; diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts index 22c1717f..48b75d6f 100644 --- a/frontend/src/services/events.service.ts +++ b/frontend/src/services/events.service.ts @@ -232,7 +232,7 @@ export const eventsService = { }, // Get event categories - async getEventCategories(eventId: number): Promise> { + async getEventCategories(eventId: number): Promise> { const response = await api.get(`/admin/categories/event/${eventId}`); return response.data || []; }, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index fd08d8a7..238a76ec 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 {