From 4698402b5493cfbdb1e2b6d81c6f58829e17a703 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:00:33 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(categories):=20per-event=20category=20?= =?UTF-8?q?ordering=20=E2=80=94=20global=20default=20+=20override=20(#782)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Order a gallery's categories in the flow of the day instead of A–Z. Two layers, resolved per event: per-event override > global default > name. - migration 158: photo_categories.display_order (global default), backfilled from the current alphabetical order so existing galleries don't reshuffle. - migration 159: event_category_order (event_id, category_id, position) — the per-event override; no backfill, every event starts on the default. - utils/categoryOrder: shared resolution used by the admin event view and the public gallery; fails safe to the global default if the table is absent. - adminCategories: POST /reorder sets a per-event override (globals + event-specific, interleaved); DELETE /reorder/:eventId resets; POST /reorder-global sets the global default. Ordering endpoints + create append. - gallery renders the resolved order. - Settings → Photo Categories reorders the global default; an event's Categories tab reorders that gallery (one combined list + Reset to default). Up/down buttons — no drag-and-drop dependency. - en/de strings. --- .../core/158_add_category_display_order.js | 57 ++++ .../core/159_add_event_category_order.js | 46 +++ backend/src/routes/adminCategories.js | 151 +++++++++- backend/src/routes/gallery.js | 11 +- backend/src/utils/categoryOrder.js | 63 ++++ .../src/components/admin/CategoryManager.tsx | 64 ++++- .../components/admin/EventCategoryManager.tsx | 271 ++++++++++-------- frontend/src/i18n/locales/de.json | 9 + frontend/src/i18n/locales/en.json | 9 + frontend/src/services/categories.service.ts | 32 +++ 10 files changed, 573 insertions(+), 140 deletions(-) create mode 100644 backend/migrations/core/158_add_category_display_order.js create mode 100644 backend/migrations/core/159_add_event_category_order.js create mode 100644 backend/src/utils/categoryOrder.js diff --git a/backend/migrations/core/158_add_category_display_order.js b/backend/migrations/core/158_add_category_display_order.js new file mode 100644 index 00000000..f3a977ad --- /dev/null +++ b/backend/migrations/core/158_add_category_display_order.js @@ -0,0 +1,57 @@ +/** + * Migration 158: per-event category ordering (#782). + * + * Adds a `display_order` integer to `photo_categories` so photographers can + * arrange an event's categories in the flow of the day (Pre-Ceremony → + * Ceremony → Reception …) instead of the hard-coded A–Z order. Mirrors the + * `display_order` column + reorder pattern already used by `event_types`. + * + * Preserve existing galleries: backfill `display_order` from the CURRENT + * (alphabetical) order, scoped — globals numbered together, event-specific + * numbered per event — so nothing reshuffles on upgrade. A custom order is + * opt-in via the admin reorder controls. See feedback: migrations should pin + * previously-implicit defaults onto existing rows. + * + * Backfill runs in JS (not a SQL window function) to stay portable across + * SQLite (dev) and Postgres (prod). + * + * Additive + hasColumn-guarded. + */ +async function addColumn(knex, table, column, builder) { + if (!(await knex.schema.hasColumn(table, column))) { + await knex.schema.alterTable(table, builder); + } +} + +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('photo_categories'))) return; + + await addColumn(knex, 'photo_categories', 'display_order', (t) => { + t.integer('display_order').notNullable().defaultTo(0); + t.index('display_order'); + }); + + // Backfill from the current alphabetical order, per scope, so existing + // galleries render exactly as before until an admin reorders. + const cats = await knex('photo_categories') + .select('id', 'name', 'is_global', 'event_id') + .orderBy('name', 'asc'); + + const counters = {}; + for (const c of cats) { + const scope = c.is_global ? 'global' : `event:${c.event_id}`; + counters[scope] = (counters[scope] || 0) + 1; + await knex('photo_categories') + .where('id', c.id) + .update({ display_order: counters[scope] }); + } +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('photo_categories'))) return; + if (await knex.schema.hasColumn('photo_categories', 'display_order')) { + await knex.schema.alterTable('photo_categories', (t) => + t.dropColumn('display_order') + ); + } +}; diff --git a/backend/migrations/core/159_add_event_category_order.js b/backend/migrations/core/159_add_event_category_order.js new file mode 100644 index 00000000..a110a05f --- /dev/null +++ b/backend/migrations/core/159_add_event_category_order.js @@ -0,0 +1,46 @@ +/** + * Migration 159: per-event category order override (#782). + * + * Builds on migration 158 (photo_categories.display_order = the GLOBAL default + * order) by adding a per-event OVERRIDE layer. Global categories are shared + * across every event, so a single display_order can only express one order for + * them. This table lets a single gallery arrange its categories — globals AND + * event-specific, interleaved into the flow of the day — independently of the + * global default. + * + * Resolution (see adminCategories / gallery): + * 1. if the event has override rows -> use override.position; + * 2. else fall back to photo_categories.display_order (the global default); + * 3. else name. + * + * An event is either "using the default" (no rows here) or "customised" (a row + * per category it shows). No backfill: every existing event starts on the + * default order, so nothing reshuffles — a custom order is opt-in per event. + * + * Additive + hasTable-guarded. + */ +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('photo_categories'))) return; + if (await knex.schema.hasTable('event_category_order')) return; + + await knex.schema.createTable('event_category_order', (t) => { + t.increments('id').primary(); + t.integer('event_id').notNullable() + .references('id').inTable('events').onDelete('CASCADE'); + t.integer('category_id').notNullable() + .references('id').inTable('photo_categories').onDelete('CASCADE'); + t.integer('position').notNullable().defaultTo(0); + t.timestamp('created_at').defaultTo(knex.fn.now()); + + // At most one position per (event, category). + t.unique(['event_id', 'category_id']); + // Ordered reads are always scoped to one event. + t.index(['event_id', 'position']); + }); +}; + +exports.down = async function (knex) { + if (await knex.schema.hasTable('event_category_order')) { + await knex.schema.dropTable('event_category_order'); + } +}; diff --git a/backend/src/routes/adminCategories.js b/backend/src/routes/adminCategories.js index 99bd811f..75e91a49 100644 --- a/backend/src/routes/adminCategories.js +++ b/backend/src/routes/adminCategories.js @@ -4,6 +4,7 @@ const { db, logActivity } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +const { getEventCategoriesOrdered } = require('../utils/categoryOrder'); const logger = require('../utils/logger'); const router = express.Router(); @@ -12,8 +13,9 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req, try { const categories = await db('photo_categories') .where('is_global', formatBoolean(true)) + .orderBy('display_order', 'asc') .orderBy('name', 'asc'); - + res.json(categories); } catch (error) { logger.error('Error fetching categories:', error); @@ -21,19 +23,12 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req, } }); -// Get categories for a specific event (global + event-specific) +// Get categories for a specific event (global + event-specific), resolved to +// the event's effective order: per-event override, else global default, else +// name (#782). Each row carries `override_position` (null when not customised). router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => { try { - const { eventId } = req.params; - - const categories = await db('photo_categories') - .where(function() { - this.where('is_global', formatBoolean(true)) - .orWhere('event_id', eventId); - }) - .orderBy('is_global', 'desc') - .orderBy('name', 'asc'); - + const categories = await getEventCategoriesOrdered(req.params.eventId); res.json(categories); } catch (error) { logger.error('Error fetching event categories:', error); @@ -81,12 +76,27 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [ return res.status(400).json({ error: 'Category with this slug already exists' }); } + // Append to the end of its scope so a new category doesn't jump to the + // top of an admin-defined order (#782). + const maxRow = await db('photo_categories') + .where(function() { + if (is_global) { + this.where('is_global', formatBoolean(true)); + } else { + this.where('event_id', event_id); + } + }) + .max('display_order as maxOrder') + .first(); + const nextOrder = (maxRow?.maxOrder || 0) + 1; + // Create category const insertResult = await db('photo_categories').insert({ name, slug: categorySlug, is_global, - event_id: is_global ? null : event_id + event_id: is_global ? null : event_id, + display_order: nextOrder }).returning('id'); const categoryId = insertResult[0]?.id || insertResult[0]; @@ -254,4 +264,119 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, } }); +// Set a per-event category order override (#782). The client sends the full +// ordered id list for THIS event — globals + event-specific, interleaved — and +// we replace the event's override rows in one transaction. This overrides the +// global default order for this gallery only. +router.post('/reorder', adminAuth, requirePermission('settings.edit'), [ + body('event_id').isInt().withMessage('event_id must be an integer'), + body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'), + body('orderedIds.*').isInt().withMessage('Each id must be an integer') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const eventId = parseInt(req.body.event_id, 10); + const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10)); + + // Every id must be a category available to this event: a shared global OR + // one of the event's own categories. Anything else is out of scope. + const available = await db('photo_categories') + .where(function() { + this.where('is_global', formatBoolean(true)).orWhere('event_id', eventId); + }) + .pluck('id'); + const availableSet = new Set(available); + const invalid = orderedIds.filter((id) => !availableSet.has(id)); + if (invalid.length > 0) { + return res.status(400).json({ error: 'One or more categories are not available for this event' }); + } + + await db.transaction(async (trx) => { + await trx('event_category_order').where('event_id', eventId).del(); + await trx('event_category_order').insert( + orderedIds.map((id, i) => ({ event_id: eventId, category_id: id, position: i + 1 })) + ); + }); + + // Log activity after commit (avoids a SQLite in-transaction global write). + await logActivity('event_category_order_set', + { eventId, count: orderedIds.length }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json(await getEventCategoriesOrdered(eventId)); + } catch (error) { + logger.error('Error reordering categories:', error); + res.status(500).json({ error: 'Failed to reorder categories' }); + } +}); + +// Clear an event's override — revert this gallery to the global default order. +router.delete('/reorder/:eventId', adminAuth, requirePermission('settings.edit'), async (req, res) => { + try { + const eventId = parseInt(req.params.eventId, 10); + await db('event_category_order').where('event_id', eventId).del(); + + await logActivity('event_category_order_reset', + { eventId }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json(await getEventCategoriesOrdered(eventId)); + } catch (error) { + logger.error('Error resetting category order:', error); + res.status(500).json({ error: 'Failed to reset category order' }); + } +}); + +// Set the GLOBAL default order for shared (global) categories (#782). Applies +// to every gallery that hasn't set its own override. Rewrites display_order. +router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [ + body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'), + body('orderedIds.*').isInt().withMessage('Each id must be an integer') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10)); + + const globals = await db('photo_categories').where('is_global', formatBoolean(true)).pluck('id'); + const globalsSet = new Set(globals); + const invalid = orderedIds.filter((id) => !globalsSet.has(id)); + if (invalid.length > 0) { + return res.status(400).json({ error: 'One or more categories are not global' }); + } + + await db.transaction(async (trx) => { + for (let i = 0; i < orderedIds.length; i += 1) { + await trx('photo_categories').where('id', orderedIds[i]).update({ display_order: i + 1 }); + } + }); + + await logActivity('global_category_order_set', + { count: orderedIds.length }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + const categories = await db('photo_categories') + .where('is_global', formatBoolean(true)) + .orderBy('display_order', 'asc') + .orderBy('name', 'asc'); + res.json(categories); + } catch (error) { + logger.error('Error reordering global categories:', error); + res.status(500).json({ error: 'Failed to reorder global categories' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index cdb082df..13e206e6 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -25,6 +25,7 @@ const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit'); const secureImageService = require('../services/secureImageService'); const logger = require('../utils/logger'); const { resolvePhotoFilePath } = require('../services/photoResolver'); +const { getEventCategoriesOrdered } = require('../utils/categoryOrder'); const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService'); const { handleAsync, errorResponse } = require('../utils/routeHelpers'); const { NotFoundError } = require('../utils/errors'); @@ -587,10 +588,12 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) // Fetch category details from photo_categories table let categories = []; if (usedCategoryIds.length > 0) { - const categoryDetails = await db('photo_categories') - .whereIn('id', usedCategoryIds) - .select('id', 'name', 'slug', 'is_global', 'hero_photo_id', 'allow_downloads') - .orderBy('name', 'asc'); + // Resolved category order (#782): per-event override, else global + // 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'], + }); categories = categoryDetails.map(cat => ({ id: cat.id, diff --git a/backend/src/utils/categoryOrder.js b/backend/src/utils/categoryOrder.js new file mode 100644 index 00000000..f5ffa3af --- /dev/null +++ b/backend/src/utils/categoryOrder.js @@ -0,0 +1,63 @@ +/** + * Category order resolution (#782). + * + * Resolves an event's categories into their effective display order, layering: + * 1. per-event override — event_category_order.position, when the event has + * been customised; + * 2. the global default — photo_categories.display_order (migration 158); + * 3. name. + * + * Globals and event-specific categories are ordered together so a custom order + * can interleave them into the flow of the day. Shared by the admin event view + * and the public gallery so the two never diverge. + */ +const { db } = require('../database/db'); +const { formatBoolean } = require('./dbCompat'); +const { hasColumnCached } = require('./schemaCache'); + +/** + * @param {number|string} eventId + * @param {object} [opts] + * @param {number[]|null} [opts.onlyIds] restrict to these category ids (the + * public gallery only shows categories that actually have photos). + * @param {string[]|null} [opts.select] qualified columns to select (default + * `c.*`). Always aliased to the `photo_categories as c` table. + * @returns rows with an added `override_position` (null when not customised). + */ +async function getEventCategoriesOrdered(eventId, { onlyIds = null, select = null } = {}) { + const eid = parseInt(eventId, 10); + + const base = db('photo_categories as c').where(function () { + this.where('c.is_global', formatBoolean(true)).orWhere('c.event_id', eid); + }); + if (onlyIds) base.whereIn('c.id', onlyIds); + + // Fail safe: if the override table isn't present yet (half-applied migration), + // fall back to the global-default order so the public gallery never 500s. + const overrideReady = await hasColumnCached('event_category_order', 'position'); + if (!overrideReady) { + return base + .select(select || 'c.*') + .orderBy('c.is_global', 'desc') + .orderBy('c.display_order', 'asc') + .orderBy('c.name', 'asc'); + } + + const cols = select ? [...select] : ['c.*']; + cols.push('o.position as override_position'); + + return base + .leftJoin('event_category_order as o', function () { + this.on('o.category_id', 'c.id').andOnVal('o.event_id', '=', eid); + }) + .select(cols) + // Overridden categories first (in their pinned order), then the rest by the + // global default. CASE keeps NULL-ordering portable across SQLite + Postgres. + .orderByRaw('CASE WHEN o.position IS NULL THEN 1 ELSE 0 END ASC') + .orderBy('o.position', 'asc') + .orderBy('c.is_global', 'desc') + .orderBy('c.display_order', 'asc') + .orderBy('c.name', 'asc'); +} + +module.exports = { getEventCategoriesOrdered }; diff --git a/frontend/src/components/admin/CategoryManager.tsx b/frontend/src/components/admin/CategoryManager.tsx index efc1ee2e..786d28ad 100644 --- a/frontend/src/components/admin/CategoryManager.tsx +++ b/frontend/src/components/admin/CategoryManager.tsx @@ -1,6 +1,6 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react'; +import { Plus, Edit2, Trash2, Loader2, ArrowUp, ArrowDown } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { categoriesService, type PhotoCategory } from '../../services/categories.service'; import { Button } from '../common'; @@ -13,12 +13,36 @@ export const CategoryManager: React.FC = () => { const [newCategoryName, setNewCategoryName] = useState(''); const [editingName, setEditingName] = useState(''); - // Fetch global categories + // Fetch global categories (ordered by the global default display_order) const { data: categories = [], isLoading } = useQuery({ queryKey: ['global-categories'], queryFn: categoriesService.getGlobalCategories, }); + // Local copy so the up/down reorder buttons feel instant; resynced when the + // query data changes. + const [ordered, setOrdered] = useState(categories); + useEffect(() => { + setOrdered(categories); + }, [categories]); + + // Set the GLOBAL default order (#782). Applies to every gallery that hasn't + // set its own per-event override. + const reorderMutation = useMutationWithToast({ + mutationFn: (orderedIds: number[]) => categoriesService.reorderGlobalCategories(orderedIds), + invalidateKeys: [['global-categories']], + errorMessage: t('categories.failedToReorder', 'Failed to update category order'), + }); + + const handleMove = (index: number, dir: -1 | 1) => { + const target = index + dir; + if (target < 0 || target >= ordered.length) return; + const next = [...ordered]; + [next[index], next[target]] = [next[target], next[index]]; + setOrdered(next); // optimistic + reorderMutation.mutate(next.map((c) => c.id)); + }; + // Create category mutation const createMutation = useMutationWithToast({ mutationFn: (name: string) => @@ -144,12 +168,12 @@ export const CategoryManager: React.FC = () => { {/* Categories list */}
- {categories.length === 0 ? ( + {ordered.length === 0 ? (

{t('categories.noCategoriesYet')}

) : ( - categories.map((category) => ( + ordered.map((category, index) => (
{
) : ( <> -
-

{category.name}

-

/{category.slug}

+
+ {/* Global default order (#782). The gallery uses this order + unless a specific event overrides it. */} +
+ + +
+
+

{category.name}

+

/{category.slug}

+
- )} +
+

{t('categories.galleryOrder', 'Gallery order')}

+
+ {isCustomised && ( + + )} + {!addingModal.isOpen && ( + + )} +
- {/* Hint about hero photo fallback */} + {/* Explain the two ordering layers */}

- {t('categories.categoryHeroHint')} + {isCustomised + ? t('categories.orderCustomisedHint', 'This gallery uses a custom order. Reset to follow the global default (Settings → Photo Categories).') + : t('categories.orderDefaultHint', 'Drag the arrows to set the order for this gallery. Otherwise it follows the global default (Settings → Photo Categories).')}

{/* Add new category form */} @@ -152,11 +198,7 @@ export const EventCategoryManager: React.FC = ({ even onClick={handleCreate} disabled={!newCategoryName.trim() || createMutation.isPending} > - {createMutation.isPending ? ( - - ) : ( - t('common.add') - )} + {createMutation.isPending ? : t('common.add')}
)} - {/* Event categories list */} - {eventCategories.length === 0 ? ( + {/* Combined, reorderable category list (globals + event-specific) */} + {ordered.length === 0 ? (

{t('categories.noEventSpecificCategories')}

) : (
- {eventCategories.map((category) => { + {ordered.map((category, index) => { const heroPhoto = category.hero_photo_id ? photos.find(p => p.id === category.hero_photo_id) : null; @@ -187,7 +229,30 @@ export const EventCategoryManager: React.FC = ({ even key={category.id} className="flex items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md" > -
+
+ {/* Reorder controls (#782). The gallery renders categories in + this order; changes here override the global default for + this event only. */} +
+ + +
{/* Hero photo thumbnail */} {category.name} + {category.is_global && ( + + {t('categories.sharedBadge', 'Shared')} + + )}
- {/* Per-category downloads toggle (#640). Green DownloadCloud - icon when on, struck-through outline when off. The - event-level `allow_downloads` AND's with this — if the - whole event has downloads off, this toggle is cosmetic. */} - - + {/* Download toggle + delete apply to event-specific categories + only. Global categories are managed in Settings. */} + {!category.is_global && ( + <> + + + + )}
); @@ -257,41 +329,10 @@ export const EventCategoryManager: React.FC = ({ even
)} - {/* Show available global categories */} -
-

{t('categories.globalCategoriesAlwaysAvailable')}

-
- {categories - .filter(cat => cat.is_global) - .map(cat => { - const heroPhoto = cat.hero_photo_id - ? photos.find(p => p.id === cat.hero_photo_id) - : null; - return ( -
- - {cat.name} -
- ); - })} -
-
+ {/* Hint about hero photo fallback */} +

+ {t('categories.categoryHeroHint')} +

{/* Hero Photo Picker Modal */} {heroPickerCategoryId !== null && ( @@ -317,7 +358,7 @@ export const EventCategoryManager: React.FC = ({ even ) : (
{photos.map((photo) => { - const currentCategory = categories.find(c => c.id === heroPickerCategoryId); + const currentCategory = ordered.find(c => c.id === heroPickerCategoryId); const isSelected = photo.id === currentCategory?.hero_photo_id; return (
= ({ even
- {categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && ( + {ordered.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
{isSelected && ( -
+
)}