Merge pull request #788 from PicPeak/feat/slideshow-order-category

feat(slideshow): per-event play order + category filter (#202)
This commit is contained in:
Paul Nothaft
2026-07-10 15:35:45 +02:00
committed by GitHub
11 changed files with 183 additions and 11 deletions
@@ -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));
}
}
};
@@ -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,
};
+24 -3
View File
@@ -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');
+18 -4
View File
@@ -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
@@ -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<SlideshowSettingsCardProps> = ({
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<SlideshowSettingsCardProps> = ({
// 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<SlideshowSettingsCardProps> = ({
{/* Live style settings */}
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<SlideshowStyleFields value={style} onChange={setStyle} />
<SlideshowStyleFields value={style} onChange={setStyle} categories={categories} />
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('slideshow.liveHint', 'Changes apply to a running slideshow within a few seconds — no need to regenerate the link.')}
@@ -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<SlideshowStyleFieldsProps> = ({ value, onChange }) => {
export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ value, onChange, categories = [] }) => {
const { t } = useTranslation();
const set = (patch: Partial<SlideshowStyle>) => onChange({ ...value, ...patch });
@@ -92,6 +97,39 @@ export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ valu
</select>
</div>
{/* Play order + content filter (#202) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className={labelClass}>{t('slideshow.orderLabel', 'Play order')}</label>
<select
value={value.order}
onChange={(e) => set({ order: e.target.value as SlideshowStyle['order'] })}
className={inputClass}
>
{SLIDESHOW_ORDERS.map((o) => (
<option key={o} value={o}>
{t(`slideshow.order.${o}`, o === 'random' ? 'Random (shuffle)' : 'Chronological')}
</option>
))}
</select>
</div>
{categories.length > 0 && (
<div>
<label className={labelClass}>{t('slideshow.categoryLabel', 'Show only category')}</label>
<select
value={value.category_id ?? ''}
onChange={(e) => set({ category_id: e.target.value === '' ? null : parseInt(e.target.value, 10) })}
className={inputClass}
>
<option value="">{t('slideshow.categoryAll', 'All photos')}</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
</div>
{/* Watermark — MODE only. The look (logo/position/opacity/style/size)
lives in Settings → Slideshow, so it isn't duplicated here. */}
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
+7
View File
@@ -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",
+7
View File
@@ -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",
+16 -2
View File
@@ -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';
// FisherYates shuffle for the 'random' play order (#202). Used once on the
// initial photo set; live-appended uploads keep landing at the end.
function shuffle<T>(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]);
+2
View File
@@ -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<Record<string, unknown>> {
const response = await api.patch(`/admin/events/${id}/slideshow`, settings);
@@ -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;
}