Merge pull request #790 from Luca-Timo/feat/category-reorder
feat(categories): per-event category ordering — global default + override (#782)
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Layered per-event category ordering (#782).
|
||||
*
|
||||
* Two ordering layers, resolved per event:
|
||||
* - GLOBAL default — photo_categories.display_order (migration 159),
|
||||
* set via POST /reorder-global; applies everywhere.
|
||||
* - PER-EVENT override — event_category_order (migration 160), set via
|
||||
* POST /reorder; overrides the default for one gallery.
|
||||
* - DELETE /reorder/:eventId clears an event's override.
|
||||
*
|
||||
* Verified against a real SQLite DB with the full core-migration set applied.
|
||||
*/
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('category ordering (#782)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let token;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
app = buildRouteApp('/api/admin/categories', require('../../src/routes/adminCategories'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
const auth = (r) => r.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
async function insertEvent(slug) {
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x',
|
||||
expires_at: new Date(Date.now() + 9e9).toISOString(),
|
||||
is_active: true, is_archived: false, slug, share_link: slug,
|
||||
event_name: slug, event_date: '2026-01-01',
|
||||
});
|
||||
return (await db('events').where({ slug }).first()).id;
|
||||
}
|
||||
|
||||
async function insertCat(name, { is_global = false, event_id = null, display_order = 0 } = {}) {
|
||||
const res = await db('photo_categories').insert({
|
||||
name,
|
||||
slug: name.toLowerCase().replace(/\s+/g, '-'),
|
||||
is_global: is_global ? 1 : 0,
|
||||
event_id,
|
||||
display_order,
|
||||
}).returning('id');
|
||||
return res[0]?.id ?? res[0];
|
||||
}
|
||||
|
||||
const getEvent = (eventId) => auth(request(app).get(`/api/admin/categories/event/${eventId}`)).expect(200);
|
||||
|
||||
describe('migration 159 backfill', () => {
|
||||
it('seeds display_order from alphabetical order, scoped per event', async () => {
|
||||
const eventId = await insertEvent('backfill-ev');
|
||||
await insertCat('Reception', { event_id: eventId });
|
||||
await insertCat('Ceremony', { event_id: eventId });
|
||||
await insertCat('Pre-Ceremony', { event_id: eventId });
|
||||
|
||||
// Re-run the migration: addColumn is guarded (no-op); the backfill loop
|
||||
// re-runs and assigns per-scope alphabetical order — what an upgrade does.
|
||||
await require('../../migrations/core/159_add_category_display_order').up(db);
|
||||
|
||||
const evCats = await db('photo_categories').where({ event_id: eventId }).orderBy('display_order', 'asc');
|
||||
expect(evCats.map((c) => c.name)).toEqual(['Ceremony', 'Pre-Ceremony', 'Reception']);
|
||||
expect(evCats.map((c) => c.display_order)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('global default order (POST /reorder-global)', () => {
|
||||
it('reverses the global order and every non-customised event follows it', async () => {
|
||||
const before = (await auth(request(app).get('/api/admin/categories/global')).expect(200)).body;
|
||||
expect(before.length).toBeGreaterThan(1);
|
||||
const reversedIds = before.map((c) => c.id).reverse();
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories/reorder-global'))
|
||||
.send({ orderedIds: reversedIds })
|
||||
.expect(200);
|
||||
expect(res.body.map((c) => c.id)).toEqual(reversedIds);
|
||||
|
||||
// A fresh event (no override) shows globals in the new global order.
|
||||
const eventId = await insertEvent('follows-global');
|
||||
const globalsInEvent = (await getEvent(eventId)).body.filter((c) => c.is_global).map((c) => c.id);
|
||||
expect(globalsInEvent).toEqual(reversedIds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-event override (POST /reorder)', () => {
|
||||
it('pins a custom order for one event without affecting another', async () => {
|
||||
const eventA = await insertEvent('override-a');
|
||||
const eventB = await insertEvent('override-b');
|
||||
const a1 = await insertCat('A-Ceremony', { event_id: eventA });
|
||||
const a2 = await insertCat('A-Reception', { event_id: eventA });
|
||||
|
||||
// Current resolved list for A (globals + A's two categories).
|
||||
const listA = (await getEvent(eventA)).body;
|
||||
// Put A-Reception first, then A-Ceremony, then the globals in their order.
|
||||
const globalsA = listA.filter((c) => c.is_global).map((c) => c.id);
|
||||
const desired = [a2, a1, ...globalsA];
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventA, orderedIds: desired })
|
||||
.expect(200);
|
||||
expect(res.body.map((c) => c.id)).toEqual(desired);
|
||||
// override_position is set on every row for a customised event.
|
||||
expect(res.body.every((c) => c.override_position != null)).toBe(true);
|
||||
|
||||
// Event B is untouched — no override, follows the global default.
|
||||
const listB = (await getEvent(eventB)).body;
|
||||
expect(listB.every((c) => c.override_position == null)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts global ids but rejects another event’s category', async () => {
|
||||
const eventId = await insertEvent('scope-ev');
|
||||
const own = await insertCat('Own', { event_id: eventId });
|
||||
const global = (await db('photo_categories').where('is_global', 1).first()).id;
|
||||
const foreign = await insertCat('Foreign', { event_id: await insertEvent('other-ev') });
|
||||
|
||||
// A global id is allowed (globals can be arranged per event).
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [own, global] })
|
||||
.expect(200);
|
||||
|
||||
// A foreign event's category is out of scope.
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [own, foreign] })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset (DELETE /reorder/:eventId)', () => {
|
||||
it('clears the override and reverts to the global default', async () => {
|
||||
const eventId = await insertEvent('reset-ev');
|
||||
const c1 = await insertCat('R-One', { event_id: eventId });
|
||||
const list = (await getEvent(eventId)).body;
|
||||
const globals = list.filter((c) => c.is_global).map((c) => c.id);
|
||||
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [c1, ...globals] })
|
||||
.expect(200);
|
||||
expect((await getEvent(eventId)).body.some((c) => c.override_position != null)).toBe(true);
|
||||
|
||||
const res = await auth(request(app).delete(`/api/admin/categories/reorder/${eventId}`)).expect(200);
|
||||
expect(res.body.every((c) => c.override_position == null)).toBe(true);
|
||||
expect(await db('event_category_order').where({ event_id: eventId }).first()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('event ownership (PR #790 review)', () => {
|
||||
let limitedToken;
|
||||
let foreignEventId;
|
||||
|
||||
beforeAll(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
// A non-super_admin role that DOES hold settings.view + settings.edit —
|
||||
// the exact case the review flagged (settings.edit is grantable).
|
||||
const roleRes = await db('roles').insert({ name: 'gallery-mgr', display_name: 'Gallery Mgr' }).returning('id');
|
||||
const roleId = roleRes[0]?.id ?? roleRes[0];
|
||||
const permIds = await db('permissions').whereIn('name', ['settings.view', 'settings.edit']).pluck('id');
|
||||
await db('role_permissions').insert(permIds.map((permission_id) => ({ role_id: roleId, permission_id })));
|
||||
|
||||
const a2 = await db('admin_users').insert({
|
||||
username: 'limited', email: 'limited@example.com',
|
||||
password_hash: await bcrypt.hash('x', 4), role_id: roleId,
|
||||
must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
limitedToken = mintAdminToken(a2[0]?.id ?? a2[0]);
|
||||
|
||||
// An event owned by a DIFFERENT admin (the seeded super_admin).
|
||||
const owner = (await db('admin_users').where({ username: 'tester' }).first()).id;
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x',
|
||||
expires_at: new Date(Date.now() + 9e9).toISOString(),
|
||||
is_active: true, is_archived: false, slug: 'owned-ev', share_link: 'owned-ev',
|
||||
event_name: 'Owned', event_date: '2026-01-01', created_by: owner,
|
||||
});
|
||||
foreignEventId = (await db('events').where({ slug: 'owned-ev' }).first()).id;
|
||||
});
|
||||
|
||||
const limitedAuth = (r) => r.set('Authorization', `Bearer ${limitedToken}`);
|
||||
|
||||
it('blocks a non-owner from reading, reordering or resetting another event', async () => {
|
||||
await limitedAuth(request(app).get(`/api/admin/categories/event/${foreignEventId}`)).expect(403);
|
||||
await limitedAuth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: foreignEventId, orderedIds: [1] }).expect(403);
|
||||
await limitedAuth(request(app).delete(`/api/admin/categories/reorder/${foreignEventId}`)).expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST / (create) appends to the end of its scope', () => {
|
||||
it('assigns display_order = max + 1 within the event', async () => {
|
||||
const eventId = await insertEvent('append-ev');
|
||||
await insertCat('First', { event_id: eventId, display_order: 1 });
|
||||
await insertCat('Second', { event_id: eventId, display_order: 2 });
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories'))
|
||||
.send({ name: 'Third', is_global: false, event_id: eventId })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.display_order).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Migration 159: 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 160: per-event category order override (#782).
|
||||
*
|
||||
* Builds on migration 159 (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,8 @@ const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
@@ -12,8 +14,9 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
|
||||
try {
|
||||
const categories = await db('photo_categories')
|
||||
.where('is_global', formatBoolean(true))
|
||||
.orderBy('display_order', 'asc')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching categories:', error);
|
||||
@@ -21,19 +24,12 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// Get categories for a specific event (global + event-specific)
|
||||
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
// 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'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
const categories = await db('photo_categories')
|
||||
.where(function() {
|
||||
this.where('is_global', formatBoolean(true))
|
||||
.orWhere('event_id', eventId);
|
||||
})
|
||||
.orderBy('is_global', 'desc')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
const categories = await getEventCategoriesOrdered(req.params.eventId);
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event categories:', error);
|
||||
@@ -81,12 +77,27 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
return res.status(400).json({ error: 'Category with this slug already exists' });
|
||||
}
|
||||
|
||||
// Append to the end of its scope so a new category doesn't jump to the
|
||||
// top of an admin-defined order (#782).
|
||||
const maxRow = await db('photo_categories')
|
||||
.where(function() {
|
||||
if (is_global) {
|
||||
this.where('is_global', formatBoolean(true));
|
||||
} else {
|
||||
this.where('event_id', event_id);
|
||||
}
|
||||
})
|
||||
.max('display_order as maxOrder')
|
||||
.first();
|
||||
const nextOrder = (maxRow?.maxOrder || 0) + 1;
|
||||
|
||||
// Create category
|
||||
const insertResult = await db('photo_categories').insert({
|
||||
name,
|
||||
slug: categorySlug,
|
||||
is_global,
|
||||
event_id: is_global ? null : event_id
|
||||
event_id: is_global ? null : event_id,
|
||||
display_order: nextOrder
|
||||
}).returning('id');
|
||||
|
||||
const categoryId = insertResult[0]?.id || insertResult[0];
|
||||
@@ -254,4 +265,133 @@ 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));
|
||||
|
||||
// Event ownership (event_id comes from the body, so requireEventOwnership —
|
||||
// which reads req.params — can't be used here). Mirror it: super_admins
|
||||
// bypass; other admins may only reorder events they own (ownerless
|
||||
// legacy/system events allowed).
|
||||
if (req.admin.roleName !== 'super_admin') {
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
if (event.created_by && event.created_by !== req.admin.id) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
}
|
||||
|
||||
// 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'), requireEventOwnership, 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;
|
||||
@@ -25,6 +25,7 @@ const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
@@ -587,10 +588,12 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
// Fetch category details from photo_categories table
|
||||
let categories = [];
|
||||
if (usedCategoryIds.length > 0) {
|
||||
const categoryDetails = await db('photo_categories')
|
||||
.whereIn('id', usedCategoryIds)
|
||||
.select('id', 'name', 'slug', 'is_global', 'hero_photo_id', 'allow_downloads')
|
||||
.orderBy('name', 'asc');
|
||||
// Resolved category order (#782): per-event override, else global
|
||||
// default, else name — restricted to categories that have photos.
|
||||
const categoryDetails = await getEventCategoriesOrdered(req.event.id, {
|
||||
onlyIds: usedCategoryIds,
|
||||
select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads'],
|
||||
});
|
||||
|
||||
categories = categoryDetails.map(cat => ({
|
||||
id: cat.id,
|
||||
|
||||
@@ -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 159);
|
||||
* 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 { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Plus, Edit2, Trash2, Loader2, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { Button } from '../common';
|
||||
@@ -13,12 +13,36 @@ export const CategoryManager: React.FC = () => {
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [editingName, setEditingName] = useState('');
|
||||
|
||||
// Fetch global categories
|
||||
// Fetch global categories (ordered by the global default display_order)
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
queryKey: ['global-categories'],
|
||||
queryFn: categoriesService.getGlobalCategories,
|
||||
});
|
||||
|
||||
// Local copy so the up/down reorder buttons feel instant; resynced when the
|
||||
// query data changes.
|
||||
const [ordered, setOrdered] = useState<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
|
||||
const createMutation = useMutationWithToast({
|
||||
mutationFn: (name: string) =>
|
||||
@@ -144,12 +168,12 @@ export const CategoryManager: React.FC = () => {
|
||||
|
||||
{/* Categories list */}
|
||||
<div className="space-y-2">
|
||||
{categories.length === 0 ? (
|
||||
{ordered.length === 0 ? (
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-center py-8">
|
||||
{t('categories.noCategoriesYet')}
|
||||
</p>
|
||||
) : (
|
||||
categories.map((category) => (
|
||||
ordered.map((category, index) => (
|
||||
<div
|
||||
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"
|
||||
@@ -189,9 +213,33 @@ export const CategoryManager: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">{category.name}</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">/{category.slug}</p>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Global default order (#782). The gallery uses this order
|
||||
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 className="flex gap-1">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
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 { photosService } from '../../services/photos.service';
|
||||
import { Button, Card, AuthenticatedImage } from '../common';
|
||||
@@ -17,7 +17,8 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
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({
|
||||
queryKey: ['event-categories', eventId],
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
@@ -30,17 +31,21 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
enabled: heroPickerCategoryId !== null,
|
||||
});
|
||||
|
||||
// Filter to show only event-specific categories
|
||||
const eventCategories = categories.filter(cat => !cat.is_global);
|
||||
// Combined list (globals + event-specific) in the resolved order, kept in
|
||||
// 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({
|
||||
mutationFn: (name: string) =>
|
||||
categoriesService.createCategory({
|
||||
name,
|
||||
is_global: false,
|
||||
event_id: eventId
|
||||
}),
|
||||
categoriesService.createCategory({ name, is_global: false, event_id: eventId }),
|
||||
invalidateKeys: [['event-categories', eventId]],
|
||||
successMessage: t('categories.categoryCreatedSuccess'),
|
||||
onSuccess: () => {
|
||||
@@ -71,9 +76,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
errorMessage: t('categories.failedToSetCoverPhoto'),
|
||||
});
|
||||
|
||||
// Toggle per-category download permission (#640). The backend AND's this
|
||||
// with the event-level `allow_downloads`, so disabling at either level
|
||||
// blocks downloads for this category's photos.
|
||||
// Toggle per-category download permission (#640). Event-specific only.
|
||||
const downloadToggleMutation = useMutationWithToast({
|
||||
mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) =>
|
||||
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'),
|
||||
});
|
||||
|
||||
// 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 = () => {
|
||||
if (newCategoryName.trim()) {
|
||||
createMutation.mutate(newCategoryName.trim());
|
||||
@@ -105,6 +134,8 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
heroMutation.mutate({ categoryId, photoId: null });
|
||||
};
|
||||
|
||||
const busy = reorderMutation.isPending || resetMutation.isPending;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-4">
|
||||
@@ -115,23 +146,38 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.eventSpecificCategories')}</h3>
|
||||
{!addingModal.isOpen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addingModal.open}
|
||||
leftIcon={<Plus className="w-3 h-3" />}
|
||||
>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.galleryOrder', 'Gallery order')}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCustomised && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={busy}
|
||||
leftIcon={<RotateCcw className="w-3 h-3" />}
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* Hint about hero photo fallback */}
|
||||
{/* Explain the two ordering layers */}
|
||||
<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', 'Use the arrows to set the order for this gallery. Otherwise it follows the global default (Settings → Photo Categories).')}
|
||||
</p>
|
||||
|
||||
{/* Add new category form */}
|
||||
@@ -152,11 +198,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
onClick={handleCreate}
|
||||
disabled={!newCategoryName.trim() || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
t('common.add')
|
||||
)}
|
||||
{createMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : t('common.add')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -171,14 +213,14 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event categories list */}
|
||||
{eventCategories.length === 0 ? (
|
||||
{/* Combined, reorderable category list (globals + event-specific) */}
|
||||
{ordered.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('categories.noEventSpecificCategories')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{eventCategories.map((category) => {
|
||||
{ordered.map((category, index) => {
|
||||
const heroPhoto = category.hero_photo_id
|
||||
? photos.find(p => p.id === category.hero_photo_id)
|
||||
: null;
|
||||
@@ -187,7 +229,30 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
key={category.id}
|
||||
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 */}
|
||||
<button
|
||||
onClick={() => setHeroPickerCategoryId(category.id)}
|
||||
@@ -207,49 +272,56 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
)}
|
||||
</button>
|
||||
<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 className="flex items-center gap-1">
|
||||
{/* Per-category downloads toggle (#640). Green DownloadCloud
|
||||
icon when on, struck-through outline when off. The
|
||||
event-level `allow_downloads` AND's with this — if the
|
||||
whole event has downloads off, this toggle is cosmetic. */}
|
||||
<button
|
||||
onClick={() => downloadToggleMutation.mutate({
|
||||
category,
|
||||
allow: category.allow_downloads === false,
|
||||
})}
|
||||
className={`p-1 transition-colors ${
|
||||
category.allow_downloads === false
|
||||
? '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'
|
||||
}`}
|
||||
title={
|
||||
category.allow_downloads === false
|
||||
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
|
||||
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
|
||||
}
|
||||
disabled={downloadToggleMutation.isPending}
|
||||
>
|
||||
{downloadToggleMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : category.allow_downloads === false ? (
|
||||
<Download className="w-3 h-3" />
|
||||
) : (
|
||||
<DownloadCloud className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
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')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
{/* Download toggle + delete apply to event-specific categories
|
||||
only. Global categories are managed in Settings. */}
|
||||
{!category.is_global && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => downloadToggleMutation.mutate({
|
||||
category,
|
||||
allow: category.allow_downloads === false,
|
||||
})}
|
||||
className={`p-1 transition-colors ${
|
||||
category.allow_downloads === false
|
||||
? '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'
|
||||
}`}
|
||||
title={
|
||||
category.allow_downloads === false
|
||||
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
|
||||
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
|
||||
}
|
||||
disabled={downloadToggleMutation.isPending}
|
||||
>
|
||||
{downloadToggleMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : category.allow_downloads === false ? (
|
||||
<Download className="w-3 h-3" />
|
||||
) : (
|
||||
<DownloadCloud className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
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')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -257,41 +329,10 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show available global categories */}
|
||||
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
|
||||
<div className="space-y-2">
|
||||
{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>
|
||||
{/* Hint about hero photo fallback */}
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('categories.categoryHeroHint')}
|
||||
</p>
|
||||
|
||||
{/* Hero Photo Picker Modal */}
|
||||
{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">
|
||||
{photos.map((photo) => {
|
||||
const currentCategory = categories.find(c => c.id === heroPickerCategoryId);
|
||||
const currentCategory = ordered.find(c => c.id === heroPickerCategoryId);
|
||||
const isSelected = photo.id === currentCategory?.hero_photo_id;
|
||||
return (
|
||||
<div
|
||||
@@ -337,7 +378,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
/>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 bg-accent-dark/150 text-white rounded-full p-1">
|
||||
<div className="absolute top-2 right-2 bg-accent-dark text-white rounded-full p-1">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
@@ -352,7 +393,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
</div>
|
||||
|
||||
<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
|
||||
variant="outline"
|
||||
onClick={() => handleRemoveHeroPhoto(heroPickerCategoryId)}
|
||||
|
||||
@@ -947,6 +947,15 @@
|
||||
"coverPhotoRemoved": "Titelbild entfernt",
|
||||
"failedToSetCoverPhoto": "Titelbild konnte nicht festgelegt werden",
|
||||
"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",
|
||||
"downloadsDisabled": "Downloads für diese Kategorie deaktiviert",
|
||||
"enableDownloadsTitle": "Klicken zum Aktivieren der Downloads für diese Kategorie",
|
||||
|
||||
@@ -494,6 +494,15 @@
|
||||
"coverPhotoRemoved": "Cover photo removed",
|
||||
"failedToSetCoverPhoto": "Failed to set cover photo",
|
||||
"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",
|
||||
"downloadsDisabled": "Downloads disabled 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
|
||||
// categories created before migration 135 keep working.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -61,5 +67,31 @@ export const categoriesService = {
|
||||
// Delete a category
|
||||
async deleteCategory(id: number): Promise<void> {
|
||||
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