diff --git a/backend/migrations/core/158_slideshow_order_category.js b/backend/migrations/core/158_slideshow_order_category.js new file mode 100644 index 00000000..73624704 --- /dev/null +++ b/backend/migrations/core/158_slideshow_order_category.js @@ -0,0 +1,37 @@ +/** + * Migration 158: per-event slideshow ordering + category filter (#202). + * + * - `show_order` — 'chronological' (default, upload order) | 'random' + * (client-side shuffle). Lets the Live Slideshow play + * photos in a varied order during an event. + * - `show_category_id`— optional FK into `photo_categories`. When set, the + * slideshow only shows photos in that category (NULL = + * all visible photos, the existing behaviour). + * + * Both additive + guarded. Defaults preserve today's behaviour (chronological, + * all photos), so existing slideshows are unchanged. + */ +exports.up = async function up(knex) { + const hasOrder = await knex.schema.hasColumn('events', 'show_order'); + if (!hasOrder) { + await knex.schema.alterTable('events', (t) => { + t.string('show_order', 20).defaultTo('chronological'); + }); + } + const hasCat = await knex.schema.hasColumn('events', 'show_category_id'); + if (!hasCat) { + await knex.schema.alterTable('events', (t) => { + t.integer('show_category_id').nullable(); + }); + } +}; + +exports.down = async function down(knex) { + for (const col of ['show_order', 'show_category_id']) { + // eslint-disable-next-line no-await-in-loop + if (await knex.schema.hasColumn('events', col)) { + // eslint-disable-next-line no-await-in-loop + await knex.schema.alterTable('events', (t) => t.dropColumn(col)); + } + } +}; diff --git a/backend/src/routes/adminEvents/helpers.js b/backend/src/routes/adminEvents/helpers.js index 01ef769d..9d42655f 100644 --- a/backend/src/routes/adminEvents/helpers.js +++ b/backend/src/routes/adminEvents/helpers.js @@ -307,6 +307,9 @@ async function deleteEventCascade(eventId, adminContext) { const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack']; // Allowed per-slide color filters. const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette']; +// Allowed slideshow play orders (#202). 'chronological' = upload order, +// 'random' = client-side shuffle. +const SLIDESHOW_ORDERS = ['chronological', 'random']; module.exports = { validateHeroImageAnchor, getStoragePath, @@ -321,6 +324,7 @@ module.exports = { mapEventForApi, hasCustomerContactColumns, deleteEventCascade, + SLIDESHOW_ORDERS, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS, }; diff --git a/backend/src/routes/adminEvents/slideshow.js b/backend/src/routes/adminEvents/slideshow.js index c22ccb57..d58ad206 100644 --- a/backend/src/routes/adminEvents/slideshow.js +++ b/backend/src/routes/adminEvents/slideshow.js @@ -13,7 +13,7 @@ const { parseBooleanInput } = require('../../utils/parsers'); const { requireEventOwnership } = require('../../middleware/ownership'); const { requireFeatureFlag } = require('../../middleware/requireFeatureFlag'); const { getFrontendBaseUrl } = require('../../utils/frontendUrl'); -const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers'); +const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS, SLIDESHOW_ORDERS } = require('./helpers'); // The watermark LOOK (source/position/opacity/style/size) is global-only // (app_settings, Settings → Slideshow); events only carry the show_watermark @@ -105,7 +105,9 @@ module.exports = (router) => { body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS), body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }), body('show_watermark').optional({ nullable: true }), - body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS) + body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS), + body('show_order').optional().isIn(SLIDESHOW_ORDERS), + body('show_category_id').optional({ nullable: true }).isInt({ min: 1 }) ], async (req, res) => { try { const errors = validationResult(req); @@ -130,6 +132,23 @@ module.exports = (router) => { : formatBoolean(parseBooleanInput(req.body.show_watermark, false)); } if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter; + if (req.body.show_order !== undefined) updates.show_order = req.body.show_order; + // Category filter (#202). null clears it (all photos). A non-null id must + // belong to this event or be a global category — otherwise ignore it so a + // stale/foreign id can't leak another event's category selection. + if (req.body.show_category_id !== undefined) { + if (req.body.show_category_id === null) { + updates.show_category_id = null; + } else { + const catId = parseInt(req.body.show_category_id, 10); + const cat = await db('photo_categories') + .where({ id: catId }) + .where(function () { this.where('event_id', event.id).orWhere('is_global', formatBoolean(true)); }) + .first(); + if (!cat) return res.status(400).json({ error: 'Category does not belong to this event' }); + updates.show_category_id = catId; + } + } // Knex throws on an empty update; only write if something changed. if (Object.keys(updates).length > 0) { @@ -141,7 +160,9 @@ module.exports = (router) => { show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade', show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800, show_watermark: updates.show_watermark ?? event.show_watermark ?? null, - show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none' + show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none', + show_order: updates.show_order ?? event.show_order ?? 'chronological', + show_category_id: 'show_category_id' in updates ? updates.show_category_id : (event.show_category_id ?? null) }); } catch (error) { errorResponse(res, error, 500, 'Failed to update slideshow settings'); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index ba62bf1e..cdb082df 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -243,8 +243,8 @@ router.get('/:slug/info', async (req, res) => { // Photos a slideshow may display: published, finished, non-hidden. Mirrors the // guest filter in GET /:slug/photos so the live count matches the rendered set. -function slideshowPhotosQuery(eventId) { - return db('photos') +function slideshowPhotosQuery(eventId, categoryId = null) { + const q = db('photos') .where('photos.event_id', eventId) .where(function() { this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status'); @@ -252,6 +252,10 @@ function slideshowPhotosQuery(eventId) { .where(function() { this.where('photos.visibility', 'visible').orWhereNull('photos.visibility'); }); + // Category filter (#202) — keep the /session + /state count in sync with the + // photos the kiosk actually renders. + if (categoryId) q.where('photos.category_id', categoryId); + return q; } // Resolve an active slideshow by slug + token. Returns the event row, or null @@ -324,6 +328,9 @@ async function slideshowSettings(event) { transition: event.show_transition || 'crossfade', transition_ms: event.show_transition_ms || 800, colorfilter: event.show_colorfilter || 'none', + // Play order (#202): 'chronological' | 'random'. The client shuffles when + // 'random' so live-appended uploads keep working. + order: event.show_order || 'chronological', fit: g.fit, watermark, }; @@ -356,7 +363,7 @@ router.get('/:slug/show/:token/session', handleAsync(async (req, res) => { // here so the kiosk's image requests are authorized with zero extra wiring. setGalleryAuthCookies(res, sessionToken, event.slug); - const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count'); + const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count'); res.json({ token: sessionToken, @@ -382,7 +389,7 @@ router.get('/:slug/show/:token/state', handleAsync(async (req, res) => { throw new NotFoundError('Slideshow'); } - const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count'); + const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count'); res.json({ ...(await slideshowSettings(event)), @@ -426,6 +433,13 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) }); } + // Live Slideshow category filter (#202). Enforced server-side so the kiosk + // viewer can't widen the set: when the event pins show_category_id, the + // slideshow only sees that category. NULL = all photos (unchanged). + if (req.accessLevel === 'slideshow' && req.event.show_category_id) { + photosQuery = photosQuery.where('photos.category_id', req.event.show_category_id); + } + // Apply sort option if (sort === 'capture_date') { // Sort by capture date, falling back to uploaded_at if capture date is null diff --git a/frontend/src/components/admin/SlideshowSettingsCard.tsx b/frontend/src/components/admin/SlideshowSettingsCard.tsx index ada57c3b..dc30f2dd 100644 --- a/frontend/src/components/admin/SlideshowSettingsCard.tsx +++ b/frontend/src/components/admin/SlideshowSettingsCard.tsx @@ -16,11 +16,13 @@ * POST .../slideshow/{generate,disable}. */ import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { MonitorPlay, Copy, CheckCircle, RotateCw, Trash2, Save } from 'lucide-react'; import { Button, Card } from '../common'; import { eventsService } from '../../services/events.service'; +import { categoriesService } from '../../services/categories.service'; import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service'; import { SlideshowStyleFields } from './SlideshowStyleFields'; @@ -35,6 +37,8 @@ export interface SlideshowSettingsCardProps { show_transition_ms?: number; show_watermark?: boolean | null; show_colorfilter?: string; + show_order?: string; + show_category_id?: number | null; }; onChanged?: () => void; } @@ -52,6 +56,8 @@ function styleFromInitial(initial: SlideshowSettingsCardProps['initial']): Slide transition_ms: initial.show_transition_ms ?? DEFAULT_SLIDESHOW_STYLE.transition_ms, watermark: watermarkMode(initial.show_watermark), colorfilter: (initial.show_colorfilter as SlideshowStyle['colorfilter']) ?? DEFAULT_SLIDESHOW_STYLE.colorfilter, + order: (initial.show_order as SlideshowStyle['order']) ?? DEFAULT_SLIDESHOW_STYLE.order, + category_id: initial.show_category_id ?? null, }; } @@ -68,6 +74,14 @@ export const SlideshowSettingsCard: React.FC = ({ const link = token ? `${window.location.origin}/gallery/${slug}/show/${token}` : ''; + // Event categories for the slideshow content filter (#202). Global + this + // event's own categories; empty for events without any → picker hides. + const { data: categories = [] } = useQuery({ + queryKey: ['event-categories', eventId], + queryFn: () => categoriesService.getEventCategories(eventId), + staleTime: 60_000, + }); + const generate = async () => { setBusy(true); try { @@ -129,6 +143,8 @@ export const SlideshowSettingsCard: React.FC = ({ // is global-only (Settings → Slideshow); we only send the mode here. show_watermark: style.watermark === 'inherit' ? null : style.watermark === 'on', show_colorfilter: style.colorfilter, + show_order: style.order, + show_category_id: style.category_id, }); toast.success(t('slideshow.settingsSaved', 'Slideshow settings saved')); onChanged?.(); @@ -208,7 +224,7 @@ export const SlideshowSettingsCard: React.FC = ({ {/* Live style settings */}
- +

{t('slideshow.liveHint', 'Changes apply to a running slideshow within a few seconds — no need to regenerate the link.')} diff --git a/frontend/src/components/admin/SlideshowStyleFields.tsx b/frontend/src/components/admin/SlideshowStyleFields.tsx index adcdc095..d31948a9 100644 --- a/frontend/src/components/admin/SlideshowStyleFields.tsx +++ b/frontend/src/components/admin/SlideshowStyleFields.tsx @@ -15,12 +15,17 @@ import { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS, SLIDESHOW_WATERMARK_MODES, + SLIDESHOW_ORDERS, type SlideshowStyle, } from '../../services/slideshow.service'; +import type { PhotoCategory } from '../../services/categories.service'; export interface SlideshowStyleFieldsProps { value: SlideshowStyle; onChange: (next: SlideshowStyle) => void; + /** Event categories for the content filter (#202). Omitted/empty → the + * category picker is hidden (e.g. events without any categories). */ + categories?: PhotoCategory[]; } const inputClass = @@ -29,7 +34,7 @@ const labelClass = 'block text-sm font-medium text-neutral-700 dark:text-neutral const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); -export const SlideshowStyleFields: React.FC = ({ value, onChange }) => { +export const SlideshowStyleFields: React.FC = ({ value, onChange, categories = [] }) => { const { t } = useTranslation(); const set = (patch: Partial) => onChange({ ...value, ...patch }); @@ -92,6 +97,39 @@ export const SlideshowStyleFields: React.FC = ({ valu + {/* Play order + content filter (#202) */} +

+
+ + +
+ {categories.length > 0 && ( +
+ + +
+ )} +
+ {/* Watermark — MODE only. The look (logo/position/opacity/style/size) lives in Settings → Slideshow, so it isn't duplicated here. */}
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 900d89c6..43eaf7b5 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3476,6 +3476,13 @@ "cool": "Kühl", "vignette": "Vignette" }, + "orderLabel": "Reihenfolge", + "order": { + "chronological": "Chronologisch", + "random": "Zufällig (mischen)" + }, + "categoryLabel": "Nur Kategorie zeigen", + "categoryAll": "Alle Fotos", "watermarkToggle": "Logo-Wasserzeichen anzeigen", "watermarkDescription": "Blendet ein weißes, halbtransparentes Logo in einer Ecke ein (wie ein Senderlogo im TV).", "watermarkSourceLabel": "Logo", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 6840b5db..f65fdd0e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3604,6 +3604,13 @@ "cool": "Cool", "vignette": "Vignette" }, + "orderLabel": "Play order", + "order": { + "chronological": "Chronological", + "random": "Random (shuffle)" + }, + "categoryLabel": "Show only category", + "categoryAll": "All photos", "watermarkToggle": "Show logo watermark", "watermarkDescription": "Overlay a white, semi-transparent logo in a corner (like a TV station ident).", "watermarkSourceLabel": "Logo", diff --git a/frontend/src/pages/gallery/SlideshowPage.tsx b/frontend/src/pages/gallery/SlideshowPage.tsx index 540491b3..d0277009 100644 --- a/frontend/src/pages/gallery/SlideshowPage.tsx +++ b/frontend/src/pages/gallery/SlideshowPage.tsx @@ -13,6 +13,7 @@ const DEFAULT_SETTINGS: SlideshowSettings = { transition: 'crossfade', transition_ms: 800, colorfilter: 'none', + order: 'chronological', fit: 'cover', watermark: null, }; @@ -65,6 +66,17 @@ function watermarkCorner(position: string): React.CSSProperties { type Phase = 'splash' | 'running' | 'ended'; +// Fisher–Yates shuffle for the 'random' play order (#202). Used once on the +// initial photo set; live-appended uploads keep landing at the end. +function shuffle(arr: T[]): T[] { + const a = [...arr]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + export function SlideshowPage() { const { slug = '', token = '' } = useParams<{ slug: string; token: string }>(); const { t } = useTranslation(); @@ -159,13 +171,15 @@ export function SlideshowPage() { storeGalleryToken(slug, session.token); setActiveGallerySlug(slug); setEventName(session.event.event_name || ''); - setSettings(session.settings || DEFAULT_SETTINGS); + const settings = session.settings || DEFAULT_SETTINGS; + setSettings(settings); // Load the list and DECODE the first slide (and the next) before we flip // to running, so playback starts on an already-rasterised image instead // of struggling on the first transition. const data = await galleryService.getGalleryPhotos(slug); - const list = data.photos || []; + // 'random' shuffles the initial set once; new uploads still append (#202). + const list = settings.order === 'random' ? shuffle(data.photos || []) : (data.photos || []); setPhotos(list); await preloadDecode(list[0]); void preloadDecode(list[1]); diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts index 6edd52e9..6d4e62f3 100644 --- a/frontend/src/services/events.service.ts +++ b/frontend/src/services/events.service.ts @@ -158,6 +158,8 @@ export const eventsService = { show_transition_ms?: number; show_watermark?: boolean | null; show_colorfilter?: string; + show_order?: string; + show_category_id?: number | null; } ): Promise> { const response = await api.patch(`/admin/events/${id}/slideshow`, settings); diff --git a/frontend/src/services/slideshow.service.ts b/frontend/src/services/slideshow.service.ts index 81bb028e..3cd40e97 100644 --- a/frontend/src/services/slideshow.service.ts +++ b/frontend/src/services/slideshow.service.ts @@ -18,6 +18,9 @@ export const SLIDESHOW_WATERMARK_STYLES: SlideshowWatermarkStyle[] = ['white', ' // (admin Settings → Slideshow); 'on'/'off' = explicit override. export type SlideshowWatermarkMode = 'inherit' | 'on' | 'off'; export const SLIDESHOW_WATERMARK_MODES: SlideshowWatermarkMode[] = ['inherit', 'on', 'off']; +// Play order (#202): 'chronological' = upload order; 'random' = client shuffle. +export type SlideshowOrder = 'chronological' | 'random'; +export const SLIDESHOW_ORDERS: SlideshowOrder[] = ['chronological', 'random']; export const SLIDESHOW_TRANSITIONS: SlideshowTransition[] = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack']; export const SLIDESHOW_COLORFILTERS: SlideshowColorFilter[] = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette']; @@ -37,6 +40,9 @@ export interface SlideshowStyle { transition_ms: number; watermark: SlideshowWatermarkMode; colorfilter: SlideshowColorFilter; + // Play order + optional category filter (#202). category_id null = all photos. + order: SlideshowOrder; + category_id: number | null; } export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = { @@ -45,6 +51,8 @@ export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = { transition_ms: 800, watermark: 'inherit', colorfilter: 'none', + order: 'chronological', + category_id: null, }; // Global slideshow defaults (admin Settings → Slideshow). The single source of @@ -81,6 +89,10 @@ export interface SlideshowSettings { transition: SlideshowTransition; transition_ms: number; colorfilter: SlideshowColorFilter; + // Play order the kiosk applies (#202): 'random' shuffles client-side so + // live-appended uploads keep working. The category filter is enforced + // server-side, so it isn't echoed here. + order: SlideshowOrder; fit: SlideshowFit; watermark: SlideshowWatermark | null; }