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 {