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:
@@ -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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user