feat(categories): per-event category ordering — global default + override (#782)

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.
This commit is contained in:
Luca
2026-07-10 16:24:17 +02:00
parent ed0fa3241b
commit 4698402b54
10 changed files with 573 additions and 140 deletions
@@ -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 AZ 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')
);
}
};
@@ -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');
}
};
+137 -12
View File
@@ -4,6 +4,7 @@ const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const router = express.Router(); const router = express.Router();
@@ -12,6 +13,7 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
try { try {
const categories = await db('photo_categories') const categories = await db('photo_categories')
.where('is_global', formatBoolean(true)) .where('is_global', formatBoolean(true))
.orderBy('display_order', 'asc')
.orderBy('name', 'asc'); .orderBy('name', 'asc');
res.json(categories); res.json(categories);
@@ -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) => { router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
const { eventId } = req.params; const categories = await getEventCategoriesOrdered(req.params.eventId);
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');
res.json(categories); res.json(categories);
} catch (error) { } catch (error) {
logger.error('Error fetching event categories:', 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' }); 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 // Create category
const insertResult = await db('photo_categories').insert({ const insertResult = await db('photo_categories').insert({
name, name,
slug: categorySlug, slug: categorySlug,
is_global, is_global,
event_id: is_global ? null : event_id event_id: is_global ? null : event_id,
display_order: nextOrder
}).returning('id'); }).returning('id');
const categoryId = insertResult[0]?.id || insertResult[0]; 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; module.exports = router;
+7 -4
View File
@@ -25,6 +25,7 @@ const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const secureImageService = require('../services/secureImageService'); const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver'); const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService'); const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync, errorResponse } = require('../utils/routeHelpers'); const { handleAsync, errorResponse } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors'); 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 // Fetch category details from photo_categories table
let categories = []; let categories = [];
if (usedCategoryIds.length > 0) { if (usedCategoryIds.length > 0) {
const categoryDetails = await db('photo_categories') // Resolved category order (#782): per-event override, else global
.whereIn('id', usedCategoryIds) // default, else name — restricted to categories that have photos.
.select('id', 'name', 'slug', 'is_global', 'hero_photo_id', 'allow_downloads') const categoryDetails = await getEventCategoriesOrdered(req.event.id, {
.orderBy('name', 'asc'); onlyIds: usedCategoryIds,
select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads'],
});
categories = categoryDetails.map(cat => ({ categories = categoryDetails.map(cat => ({
id: cat.id, id: cat.id,
+63
View File
@@ -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 };
@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; 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 { useTranslation } from 'react-i18next';
import { categoriesService, type PhotoCategory } from '../../services/categories.service'; import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { Button } from '../common'; import { Button } from '../common';
@@ -13,12 +13,36 @@ export const CategoryManager: React.FC = () => {
const [newCategoryName, setNewCategoryName] = useState(''); const [newCategoryName, setNewCategoryName] = useState('');
const [editingName, setEditingName] = useState(''); const [editingName, setEditingName] = useState('');
// Fetch global categories // Fetch global categories (ordered by the global default display_order)
const { data: categories = [], isLoading } = useQuery({ const { data: categories = [], isLoading } = useQuery({
queryKey: ['global-categories'], queryKey: ['global-categories'],
queryFn: categoriesService.getGlobalCategories, queryFn: categoriesService.getGlobalCategories,
}); });
// Local copy so the up/down reorder buttons feel instant; resynced when the
// query data changes.
const [ordered, setOrdered] = useState<PhotoCategory[]>(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 // Create category mutation
const createMutation = useMutationWithToast({ const createMutation = useMutationWithToast({
mutationFn: (name: string) => mutationFn: (name: string) =>
@@ -144,12 +168,12 @@ export const CategoryManager: React.FC = () => {
{/* Categories list */} {/* Categories list */}
<div className="space-y-2"> <div className="space-y-2">
{categories.length === 0 ? ( {ordered.length === 0 ? (
<p className="text-neutral-500 dark:text-neutral-400 text-center py-8"> <p className="text-neutral-500 dark:text-neutral-400 text-center py-8">
{t('categories.noCategoriesYet')} {t('categories.noCategoriesYet')}
</p> </p>
) : ( ) : (
categories.map((category) => ( ordered.map((category, index) => (
<div <div
key={category.id} key={category.id}
className="flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 transition-colors" className="flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 transition-colors"
@@ -189,9 +213,33 @@ export const CategoryManager: React.FC = () => {
</div> </div>
) : ( ) : (
<> <>
<div> <div className="flex items-center gap-2 min-w-0">
<p className="font-medium text-neutral-900 dark:text-neutral-100">{category.name}</p> {/* Global default order (#782). The gallery uses this order
<p className="text-sm text-neutral-500 dark:text-neutral-400">/{category.slug}</p> unless a specific event overrides it. */}
<div className="flex flex-col -space-y-1">
<button
onClick={() => handleMove(index, -1)}
disabled={index === 0 || reorderMutation.isPending}
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
title={t('categories.moveUp', 'Move up')}
aria-label={t('categories.moveUp', 'Move up')}
>
<ArrowUp className="w-4 h-4" />
</button>
<button
onClick={() => handleMove(index, 1)}
disabled={index === ordered.length - 1 || reorderMutation.isPending}
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
title={t('categories.moveDown', 'Move down')}
aria-label={t('categories.moveDown', 'Move down')}
>
<ArrowDown className="w-4 h-4" />
</button>
</div>
<div className="min-w-0">
<p className="font-medium text-neutral-900 dark:text-neutral-100 truncate">{category.name}</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400 truncate">/{category.slug}</p>
</div>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud } from 'lucide-react'; import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
import { categoriesService, type PhotoCategory } from '../../services/categories.service'; import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { photosService } from '../../services/photos.service'; import { photosService } from '../../services/photos.service';
import { Button, Card, AuthenticatedImage } from '../common'; import { Button, Card, AuthenticatedImage } from '../common';
@@ -17,7 +17,8 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
const [newCategoryName, setNewCategoryName] = useState(''); const [newCategoryName, setNewCategoryName] = useState('');
const [heroPickerCategoryId, setHeroPickerCategoryId] = useState<number | null>(null); const [heroPickerCategoryId, setHeroPickerCategoryId] = useState<number | null>(null);
// Fetch categories for this event // Fetch this event's categories (globals + event-specific), already resolved
// to the event's effective order by the backend (#782).
const { data: categories = [], isLoading } = useQuery({ const { data: categories = [], isLoading } = useQuery({
queryKey: ['event-categories', eventId], queryKey: ['event-categories', eventId],
queryFn: () => categoriesService.getEventCategories(eventId), queryFn: () => categoriesService.getEventCategories(eventId),
@@ -30,17 +31,21 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
enabled: heroPickerCategoryId !== null, enabled: heroPickerCategoryId !== null,
}); });
// Filter to show only event-specific categories // Combined list (globals + event-specific) in the resolved order, kept in
const eventCategories = categories.filter(cat => !cat.is_global); // local state so the up/down reorder buttons feel instant; resynced whenever
// the query data changes (e.g. after a reorder or reset persists).
const [ordered, setOrdered] = useState<PhotoCategory[]>(categories);
useEffect(() => {
setOrdered(categories);
}, [categories]);
// Create category mutation // The event is "customised" when it has its own per-event override.
const isCustomised = ordered.some((c) => c.override_position != null);
// Create category mutation (always event-specific)
const createMutation = useMutationWithToast({ const createMutation = useMutationWithToast({
mutationFn: (name: string) => mutationFn: (name: string) =>
categoriesService.createCategory({ categoriesService.createCategory({ name, is_global: false, event_id: eventId }),
name,
is_global: false,
event_id: eventId
}),
invalidateKeys: [['event-categories', eventId]], invalidateKeys: [['event-categories', eventId]],
successMessage: t('categories.categoryCreatedSuccess'), successMessage: t('categories.categoryCreatedSuccess'),
onSuccess: () => { onSuccess: () => {
@@ -71,9 +76,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
errorMessage: t('categories.failedToSetCoverPhoto'), errorMessage: t('categories.failedToSetCoverPhoto'),
}); });
// Toggle per-category download permission (#640). The backend AND's this // Toggle per-category download permission (#640). Event-specific only.
// with the event-level `allow_downloads`, so disabling at either level
// blocks downloads for this category's photos.
const downloadToggleMutation = useMutationWithToast({ const downloadToggleMutation = useMutationWithToast({
mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) => mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) =>
categoriesService.updateCategory(category.id, category.name, { allow_downloads: allow }), categoriesService.updateCategory(category.id, category.name, { allow_downloads: allow }),
@@ -85,6 +88,32 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
errorMessage: t('categories.failedToToggleDownloads', 'Failed to update download permission'), errorMessage: t('categories.failedToToggleDownloads', 'Failed to update download permission'),
}); });
// 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).
const reorderMutation = useMutationWithToast({
mutationFn: (orderedIds: number[]) => categoriesService.reorderCategories(eventId, orderedIds),
invalidateKeys: [['event-categories', eventId]],
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
});
// Revert this gallery to the global default order.
const resetMutation = useMutationWithToast({
mutationFn: () => categoriesService.resetEventOrder(eventId),
invalidateKeys: [['event-categories', eventId]],
successMessage: t('categories.orderReset', 'Reverted to the default order'),
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 — instant feedback
reorderMutation.mutate(next.map((c) => c.id));
};
const handleCreate = () => { const handleCreate = () => {
if (newCategoryName.trim()) { if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim()); createMutation.mutate(newCategoryName.trim());
@@ -105,6 +134,8 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
heroMutation.mutate({ categoryId, photoId: null }); heroMutation.mutate({ categoryId, photoId: null });
}; };
const busy = reorderMutation.isPending || resetMutation.isPending;
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center items-center py-4"> <div className="flex justify-center items-center py-4">
@@ -115,23 +146,38 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center gap-2">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.eventSpecificCategories')}</h3> <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.galleryOrder', 'Gallery order')}</h3>
{!addingModal.isOpen && ( <div className="flex items-center gap-2">
<Button {isCustomised && (
variant="outline" <Button
size="sm" variant="outline"
onClick={addingModal.open} size="sm"
leftIcon={<Plus className="w-3 h-3" />} onClick={() => resetMutation.mutate()}
> disabled={busy}
{t('common.add')} leftIcon={<RotateCcw className="w-3 h-3" />}
</Button> >
)} {t('categories.resetToDefault', 'Reset to default')}
</Button>
)}
{!addingModal.isOpen && (
<Button
variant="outline"
size="sm"
onClick={addingModal.open}
leftIcon={<Plus className="w-3 h-3" />}
>
{t('common.add')}
</Button>
)}
</div>
</div> </div>
{/* Hint about hero photo fallback */} {/* Explain the two ordering layers */}
<p className="text-xs text-neutral-500 dark:text-neutral-400 italic"> <p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
{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).')}
</p> </p>
{/* Add new category form */} {/* Add new category form */}
@@ -152,11 +198,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
onClick={handleCreate} onClick={handleCreate}
disabled={!newCategoryName.trim() || createMutation.isPending} disabled={!newCategoryName.trim() || createMutation.isPending}
> >
{createMutation.isPending ? ( {createMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : t('common.add')}
<Loader2 className="w-3 h-3 animate-spin" />
) : (
t('common.add')
)}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -171,14 +213,14 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
</div> </div>
)} )}
{/* Event categories list */} {/* Combined, reorderable category list (globals + event-specific) */}
{eventCategories.length === 0 ? ( {ordered.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic"> <p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
{t('categories.noEventSpecificCategories')} {t('categories.noEventSpecificCategories')}
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{eventCategories.map((category) => { {ordered.map((category, index) => {
const heroPhoto = category.hero_photo_id const heroPhoto = category.hero_photo_id
? photos.find(p => p.id === category.hero_photo_id) ? photos.find(p => p.id === category.hero_photo_id)
: null; : null;
@@ -187,7 +229,30 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
key={category.id} key={category.id}
className="flex items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md" className="flex items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md"
> >
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
{/* Reorder controls (#782). The gallery renders categories in
this order; changes here override the global default for
this event only. */}
<div className="flex flex-col -space-y-1">
<button
onClick={() => handleMove(index, -1)}
disabled={index === 0 || busy}
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
title={t('categories.moveUp', 'Move up')}
aria-label={t('categories.moveUp', 'Move up')}
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleMove(index, 1)}
disabled={index === ordered.length - 1 || busy}
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
title={t('categories.moveDown', 'Move down')}
aria-label={t('categories.moveDown', 'Move down')}
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
{/* Hero photo thumbnail */} {/* Hero photo thumbnail */}
<button <button
onClick={() => setHeroPickerCategoryId(category.id)} onClick={() => setHeroPickerCategoryId(category.id)}
@@ -207,49 +272,56 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
)} )}
</button> </button>
<span className="text-sm text-neutral-700 dark:text-neutral-300 truncate">{category.name}</span> <span className="text-sm text-neutral-700 dark:text-neutral-300 truncate">{category.name}</span>
{category.is_global && (
<span className="flex-shrink-0 text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-neutral-500 dark:text-neutral-400">
{t('categories.sharedBadge', 'Shared')}
</span>
)}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Per-category downloads toggle (#640). Green DownloadCloud {/* Download toggle + delete apply to event-specific categories
icon when on, struck-through outline when off. The only. Global categories are managed in Settings. */}
event-level `allow_downloads` AND's with this — if the {!category.is_global && (
whole event has downloads off, this toggle is cosmetic. */} <>
<button <button
onClick={() => downloadToggleMutation.mutate({ onClick={() => downloadToggleMutation.mutate({
category, category,
allow: category.allow_downloads === false, allow: category.allow_downloads === false,
})} })}
className={`p-1 transition-colors ${ className={`p-1 transition-colors ${
category.allow_downloads === false category.allow_downloads === false
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400' ? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
: 'text-green-600 dark:text-green-400 hover:text-neutral-400' : 'text-green-600 dark:text-green-400 hover:text-neutral-400'
}`} }`}
title={ title={
category.allow_downloads === false category.allow_downloads === false
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category') ? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category') : t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
} }
disabled={downloadToggleMutation.isPending} disabled={downloadToggleMutation.isPending}
> >
{downloadToggleMutation.isPending ? ( {downloadToggleMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" /> <Loader2 className="w-3 h-3 animate-spin" />
) : category.allow_downloads === false ? ( ) : category.allow_downloads === false ? (
<Download className="w-3 h-3" /> <Download className="w-3 h-3" />
) : ( ) : (
<DownloadCloud className="w-3 h-3" /> <DownloadCloud className="w-3 h-3" />
)} )}
</button> </button>
<button <button
onClick={() => handleDelete(category)} onClick={() => handleDelete(category)}
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors" className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title={t('categories.deleteCategoryTitle')} title={t('categories.deleteCategoryTitle')}
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
> >
{deleteMutation.isPending ? ( {deleteMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" /> <Loader2 className="w-3 h-3 animate-spin" />
) : ( ) : (
<X className="w-3 h-3" /> <X className="w-3 h-3" />
)} )}
</button> </button>
</>
)}
</div> </div>
</div> </div>
); );
@@ -257,41 +329,10 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
</div> </div>
)} )}
{/* Show available global categories */} {/* Hint about hero photo fallback */}
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700"> <p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p> {t('categories.categoryHeroHint')}
<div className="space-y-2"> </p>
{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 (
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md">
<button
onClick={() => setHeroPickerCategoryId(cat.id)}
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-accent-dark transition-colors flex items-center justify-center"
title={t('categories.setCoverPhoto')}
>
{heroPhoto ? (
<AuthenticatedImage
src={heroPhoto.thumbnail_url || heroPhoto.url}
alt={cat.name}
className="w-full h-full object-cover"
/>
) : cat.hero_photo_id ? (
<ImageIcon className="w-4 h-4 text-accent" />
) : (
<ImageIcon className="w-4 h-4 text-neutral-300" />
)}
</button>
<span className="text-sm text-neutral-600 dark:text-neutral-400">{cat.name}</span>
</div>
);
})}
</div>
</div>
{/* Hero Photo Picker Modal */} {/* Hero Photo Picker Modal */}
{heroPickerCategoryId !== null && ( {heroPickerCategoryId !== null && (
@@ -317,7 +358,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{photos.map((photo) => { {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; const isSelected = photo.id === currentCategory?.hero_photo_id;
return ( return (
<div <div
@@ -352,7 +393,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
</div> </div>
<div className="p-6 border-t border-neutral-200 dark:border-neutral-700 flex justify-between gap-3"> <div className="p-6 border-t border-neutral-200 dark:border-neutral-700 flex justify-between gap-3">
{categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && ( {ordered.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
<Button <Button
variant="outline" variant="outline"
onClick={() => handleRemoveHeroPhoto(heroPickerCategoryId)} onClick={() => handleRemoveHeroPhoto(heroPickerCategoryId)}
+9
View File
@@ -947,6 +947,15 @@
"coverPhotoRemoved": "Titelbild entfernt", "coverPhotoRemoved": "Titelbild entfernt",
"failedToSetCoverPhoto": "Titelbild konnte nicht festgelegt werden", "failedToSetCoverPhoto": "Titelbild konnte nicht festgelegt werden",
"categoryHeroHint": "Wenn kein Titelbild für eine Kategorie festgelegt ist, wird das Standard-Hero-Foto verwendet.", "categoryHeroHint": "Wenn kein Titelbild für eine Kategorie festgelegt ist, wird das Standard-Hero-Foto verwendet.",
"moveUp": "Nach oben",
"moveDown": "Nach unten",
"failedToReorder": "Kategorie-Reihenfolge konnte nicht aktualisiert werden",
"galleryOrder": "Galerie-Reihenfolge",
"resetToDefault": "Auf Standard zurücksetzen",
"orderReset": "Auf Standardreihenfolge zurückgesetzt",
"orderCustomisedHint": "Diese Galerie verwendet eine eigene Reihenfolge. Zurücksetzen, um der globalen Standardreihenfolge zu folgen (Einstellungen → Fotokategorien).",
"orderDefaultHint": "Mit den Pfeilen die Reihenfolge für diese Galerie festlegen. Andernfalls gilt die globale Standardreihenfolge (Einstellungen → Fotokategorien).",
"sharedBadge": "Geteilt",
"downloadsEnabled": "Downloads für diese Kategorie aktiviert", "downloadsEnabled": "Downloads für diese Kategorie aktiviert",
"downloadsDisabled": "Downloads für diese Kategorie deaktiviert", "downloadsDisabled": "Downloads für diese Kategorie deaktiviert",
"enableDownloadsTitle": "Klicken zum Aktivieren der Downloads für diese Kategorie", "enableDownloadsTitle": "Klicken zum Aktivieren der Downloads für diese Kategorie",
+9
View File
@@ -494,6 +494,15 @@
"coverPhotoRemoved": "Cover photo removed", "coverPhotoRemoved": "Cover photo removed",
"failedToSetCoverPhoto": "Failed to set cover photo", "failedToSetCoverPhoto": "Failed to set cover photo",
"categoryHeroHint": "If no cover photo is set for a category, the default hero photo will be used.", "categoryHeroHint": "If no cover photo is set for a category, the default hero photo will be used.",
"moveUp": "Move up",
"moveDown": "Move down",
"failedToReorder": "Failed to update category order",
"galleryOrder": "Gallery order",
"resetToDefault": "Reset to default",
"orderReset": "Reverted to the default order",
"orderCustomisedHint": "This gallery uses a custom order. Reset to follow the global default (Settings → Photo Categories).",
"orderDefaultHint": "Use the arrows to set the order for this gallery. Otherwise it follows the global default (Settings → Photo Categories).",
"sharedBadge": "Shared",
"downloadsEnabled": "Downloads enabled for this category", "downloadsEnabled": "Downloads enabled for this category",
"downloadsDisabled": "Downloads disabled for this category", "downloadsDisabled": "Downloads disabled for this category",
"enableDownloadsTitle": "Click to enable downloads for this category", "enableDownloadsTitle": "Click to enable downloads for this category",
@@ -10,6 +10,12 @@ export interface PhotoCategory {
// Per-category download permission (#640). Defaults true (server-side) so // Per-category download permission (#640). Defaults true (server-side) so
// categories created before migration 135 keep working. // categories created before migration 135 keep working.
allow_downloads?: boolean; allow_downloads?: boolean;
// Global default sort order (#782). Backfilled from the previous alphabetical
// order on migration, so existing galleries don't reshuffle.
display_order?: number;
// 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;
created_at: string; created_at: string;
} }
@@ -61,5 +67,31 @@ export const categoriesService = {
// Delete a category // Delete a category
async deleteCategory(id: number): Promise<void> { async deleteCategory(id: number): Promise<void> {
await api.delete(`/admin/categories/${id}`); await api.delete(`/admin/categories/${id}`);
},
// Set a per-event order override (#782). Sends the full ordered id list for
// this event — globals + event-specific — and returns the resolved order.
// Overrides the global default for this gallery only.
async reorderCategories(eventId: number, orderedIds: number[]): Promise<PhotoCategory[]> {
const response = await api.post<PhotoCategory[]>('/admin/categories/reorder', {
event_id: eventId,
orderedIds
});
return response.data;
},
// Clear an event's override — revert this gallery to the global default order.
async resetEventOrder(eventId: number): Promise<PhotoCategory[]> {
const response = await api.delete<PhotoCategory[]>(`/admin/categories/reorder/${eventId}`);
return response.data;
},
// Set the GLOBAL default order for shared categories (#782). Applies to every
// gallery that hasn't set its own override.
async reorderGlobalCategories(orderedIds: number[]): Promise<PhotoCategory[]> {
const response = await api.post<PhotoCategory[]>('/admin/categories/reorder-global', {
orderedIds
});
return response.data;
} }
}; };