Merge pull request #788 from PicPeak/feat/slideshow-order-category
feat(slideshow): per-event play order + category filter (#202)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user