feat(slideshow): per-event play order + category filter (#202)
The Live Slideshow already covers the core of #202 (fullscreen kiosk, live-appending new uploads, timing/transitions/watermark, per-event opt-in via the share link). This adds the two customization dimensions the reporter also asked for: - **Play order** (show_order): 'chronological' (upload order, default) or 'random' — the client shuffles the initial set (Fisher-Yates) so live-appended uploads keep working. - **Category filter** (show_category_id): restrict the slideshow to a single photo category (NULL = all photos, default). Enforced server-side on the slideshow /photos access and mirrored in the /session + /state photo_count, so the kiosk viewer can't widen the set. Per-event enable/disable (default off) is unchanged — it's the existing 'Generate/Disable slideshow link' flow (no token = no slideshow). - Migration 158: show_order (default 'chronological') + show_category_id. - Admin: Play-order dropdown + category picker in the Live Slideshow card (picker hidden for events without categories); EN + DE i18n. - Verified: migration (SQLite + PG); live API (category filter → 3/2/5 photos + matching count; order propagates) and the running kiosk requests exactly the filtered set; tsc clean, 136 backend tests pass.
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'];
|
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
|
||||||
// Allowed per-slide color filters.
|
// Allowed per-slide color filters.
|
||||||
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
|
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 = {
|
module.exports = {
|
||||||
validateHeroImageAnchor,
|
validateHeroImageAnchor,
|
||||||
getStoragePath,
|
getStoragePath,
|
||||||
@@ -321,6 +324,7 @@ module.exports = {
|
|||||||
mapEventForApi,
|
mapEventForApi,
|
||||||
hasCustomerContactColumns,
|
hasCustomerContactColumns,
|
||||||
deleteEventCascade,
|
deleteEventCascade,
|
||||||
|
SLIDESHOW_ORDERS,
|
||||||
SLIDESHOW_TRANSITIONS,
|
SLIDESHOW_TRANSITIONS,
|
||||||
SLIDESHOW_COLORFILTERS,
|
SLIDESHOW_COLORFILTERS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const { parseBooleanInput } = require('../../utils/parsers');
|
|||||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||||
const { requireFeatureFlag } = require('../../middleware/requireFeatureFlag');
|
const { requireFeatureFlag } = require('../../middleware/requireFeatureFlag');
|
||||||
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
|
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
|
// The watermark LOOK (source/position/opacity/style/size) is global-only
|
||||||
// (app_settings, Settings → Slideshow); events only carry the show_watermark
|
// (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').optional().isIn(SLIDESHOW_TRANSITIONS),
|
||||||
body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }),
|
body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }),
|
||||||
body('show_watermark').optional({ nullable: true }),
|
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) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -130,6 +132,23 @@ module.exports = (router) => {
|
|||||||
: formatBoolean(parseBooleanInput(req.body.show_watermark, false));
|
: formatBoolean(parseBooleanInput(req.body.show_watermark, false));
|
||||||
}
|
}
|
||||||
if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter;
|
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.
|
// Knex throws on an empty update; only write if something changed.
|
||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
@@ -141,7 +160,9 @@ module.exports = (router) => {
|
|||||||
show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade',
|
show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade',
|
||||||
show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800,
|
show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800,
|
||||||
show_watermark: updates.show_watermark ?? event.show_watermark ?? null,
|
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) {
|
} catch (error) {
|
||||||
errorResponse(res, error, 500, 'Failed to update slideshow settings');
|
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
|
// 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.
|
// guest filter in GET /:slug/photos so the live count matches the rendered set.
|
||||||
function slideshowPhotosQuery(eventId) {
|
function slideshowPhotosQuery(eventId, categoryId = null) {
|
||||||
return db('photos')
|
const q = db('photos')
|
||||||
.where('photos.event_id', eventId)
|
.where('photos.event_id', eventId)
|
||||||
.where(function() {
|
.where(function() {
|
||||||
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
|
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
|
||||||
@@ -252,6 +252,10 @@ function slideshowPhotosQuery(eventId) {
|
|||||||
.where(function() {
|
.where(function() {
|
||||||
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
|
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
|
// 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: event.show_transition || 'crossfade',
|
||||||
transition_ms: event.show_transition_ms || 800,
|
transition_ms: event.show_transition_ms || 800,
|
||||||
colorfilter: event.show_colorfilter || 'none',
|
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,
|
fit: g.fit,
|
||||||
watermark,
|
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.
|
// here so the kiosk's image requests are authorized with zero extra wiring.
|
||||||
setGalleryAuthCookies(res, sessionToken, event.slug);
|
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({
|
res.json({
|
||||||
token: sessionToken,
|
token: sessionToken,
|
||||||
@@ -382,7 +389,7 @@ router.get('/:slug/show/:token/state', handleAsync(async (req, res) => {
|
|||||||
throw new NotFoundError('Slideshow');
|
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({
|
res.json({
|
||||||
...(await slideshowSettings(event)),
|
...(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
|
// Apply sort option
|
||||||
if (sort === 'capture_date') {
|
if (sort === 'capture_date') {
|
||||||
// Sort by capture date, falling back to uploaded_at if capture date is null
|
// Sort by capture date, falling back to uploaded_at if capture date is null
|
||||||
|
|||||||
@@ -16,11 +16,13 @@
|
|||||||
* POST .../slideshow/{generate,disable}.
|
* POST .../slideshow/{generate,disable}.
|
||||||
*/
|
*/
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { MonitorPlay, Copy, CheckCircle, RotateCw, Trash2, Save } from 'lucide-react';
|
import { MonitorPlay, Copy, CheckCircle, RotateCw, Trash2, Save } from 'lucide-react';
|
||||||
import { Button, Card } from '../common';
|
import { Button, Card } from '../common';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
|
import { categoriesService } from '../../services/categories.service';
|
||||||
import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service';
|
import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service';
|
||||||
import { SlideshowStyleFields } from './SlideshowStyleFields';
|
import { SlideshowStyleFields } from './SlideshowStyleFields';
|
||||||
|
|
||||||
@@ -35,6 +37,8 @@ export interface SlideshowSettingsCardProps {
|
|||||||
show_transition_ms?: number;
|
show_transition_ms?: number;
|
||||||
show_watermark?: boolean | null;
|
show_watermark?: boolean | null;
|
||||||
show_colorfilter?: string;
|
show_colorfilter?: string;
|
||||||
|
show_order?: string;
|
||||||
|
show_category_id?: number | null;
|
||||||
};
|
};
|
||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
}
|
}
|
||||||
@@ -52,6 +56,8 @@ function styleFromInitial(initial: SlideshowSettingsCardProps['initial']): Slide
|
|||||||
transition_ms: initial.show_transition_ms ?? DEFAULT_SLIDESHOW_STYLE.transition_ms,
|
transition_ms: initial.show_transition_ms ?? DEFAULT_SLIDESHOW_STYLE.transition_ms,
|
||||||
watermark: watermarkMode(initial.show_watermark),
|
watermark: watermarkMode(initial.show_watermark),
|
||||||
colorfilter: (initial.show_colorfilter as SlideshowStyle['colorfilter']) ?? DEFAULT_SLIDESHOW_STYLE.colorfilter,
|
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}` : '';
|
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 () => {
|
const generate = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -129,6 +143,8 @@ export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
|
|||||||
// is global-only (Settings → Slideshow); we only send the mode here.
|
// is global-only (Settings → Slideshow); we only send the mode here.
|
||||||
show_watermark: style.watermark === 'inherit' ? null : style.watermark === 'on',
|
show_watermark: style.watermark === 'inherit' ? null : style.watermark === 'on',
|
||||||
show_colorfilter: style.colorfilter,
|
show_colorfilter: style.colorfilter,
|
||||||
|
show_order: style.order,
|
||||||
|
show_category_id: style.category_id,
|
||||||
});
|
});
|
||||||
toast.success(t('slideshow.settingsSaved', 'Slideshow settings saved'));
|
toast.success(t('slideshow.settingsSaved', 'Slideshow settings saved'));
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
@@ -208,7 +224,7 @@ export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
|
|||||||
|
|
||||||
{/* Live style settings */}
|
{/* Live style settings */}
|
||||||
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
<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>
|
</div>
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
<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.')}
|
{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_TRANSITIONS,
|
||||||
SLIDESHOW_COLORFILTERS,
|
SLIDESHOW_COLORFILTERS,
|
||||||
SLIDESHOW_WATERMARK_MODES,
|
SLIDESHOW_WATERMARK_MODES,
|
||||||
|
SLIDESHOW_ORDERS,
|
||||||
type SlideshowStyle,
|
type SlideshowStyle,
|
||||||
} from '../../services/slideshow.service';
|
} from '../../services/slideshow.service';
|
||||||
|
import type { PhotoCategory } from '../../services/categories.service';
|
||||||
|
|
||||||
export interface SlideshowStyleFieldsProps {
|
export interface SlideshowStyleFieldsProps {
|
||||||
value: SlideshowStyle;
|
value: SlideshowStyle;
|
||||||
onChange: (next: SlideshowStyle) => void;
|
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 =
|
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);
|
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 { t } = useTranslation();
|
||||||
const set = (patch: Partial<SlideshowStyle>) => onChange({ ...value, ...patch });
|
const set = (patch: Partial<SlideshowStyle>) => onChange({ ...value, ...patch });
|
||||||
|
|
||||||
@@ -92,6 +97,39 @@ export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ valu
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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)
|
{/* Watermark — MODE only. The look (logo/position/opacity/style/size)
|
||||||
lives in Settings → Slideshow, so it isn't duplicated here. */}
|
lives in Settings → Slideshow, so it isn't duplicated here. */}
|
||||||
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
|||||||
@@ -3476,6 +3476,13 @@
|
|||||||
"cool": "Kühl",
|
"cool": "Kühl",
|
||||||
"vignette": "Vignette"
|
"vignette": "Vignette"
|
||||||
},
|
},
|
||||||
|
"orderLabel": "Reihenfolge",
|
||||||
|
"order": {
|
||||||
|
"chronological": "Chronologisch",
|
||||||
|
"random": "Zufällig (mischen)"
|
||||||
|
},
|
||||||
|
"categoryLabel": "Nur Kategorie zeigen",
|
||||||
|
"categoryAll": "Alle Fotos",
|
||||||
"watermarkToggle": "Logo-Wasserzeichen anzeigen",
|
"watermarkToggle": "Logo-Wasserzeichen anzeigen",
|
||||||
"watermarkDescription": "Blendet ein weißes, halbtransparentes Logo in einer Ecke ein (wie ein Senderlogo im TV).",
|
"watermarkDescription": "Blendet ein weißes, halbtransparentes Logo in einer Ecke ein (wie ein Senderlogo im TV).",
|
||||||
"watermarkSourceLabel": "Logo",
|
"watermarkSourceLabel": "Logo",
|
||||||
|
|||||||
@@ -3604,6 +3604,13 @@
|
|||||||
"cool": "Cool",
|
"cool": "Cool",
|
||||||
"vignette": "Vignette"
|
"vignette": "Vignette"
|
||||||
},
|
},
|
||||||
|
"orderLabel": "Play order",
|
||||||
|
"order": {
|
||||||
|
"chronological": "Chronological",
|
||||||
|
"random": "Random (shuffle)"
|
||||||
|
},
|
||||||
|
"categoryLabel": "Show only category",
|
||||||
|
"categoryAll": "All photos",
|
||||||
"watermarkToggle": "Show logo watermark",
|
"watermarkToggle": "Show logo watermark",
|
||||||
"watermarkDescription": "Overlay a white, semi-transparent logo in a corner (like a TV station ident).",
|
"watermarkDescription": "Overlay a white, semi-transparent logo in a corner (like a TV station ident).",
|
||||||
"watermarkSourceLabel": "Logo",
|
"watermarkSourceLabel": "Logo",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const DEFAULT_SETTINGS: SlideshowSettings = {
|
|||||||
transition: 'crossfade',
|
transition: 'crossfade',
|
||||||
transition_ms: 800,
|
transition_ms: 800,
|
||||||
colorfilter: 'none',
|
colorfilter: 'none',
|
||||||
|
order: 'chronological',
|
||||||
fit: 'cover',
|
fit: 'cover',
|
||||||
watermark: null,
|
watermark: null,
|
||||||
};
|
};
|
||||||
@@ -65,6 +66,17 @@ function watermarkCorner(position: string): React.CSSProperties {
|
|||||||
|
|
||||||
type Phase = 'splash' | 'running' | 'ended';
|
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() {
|
export function SlideshowPage() {
|
||||||
const { slug = '', token = '' } = useParams<{ slug: string; token: string }>();
|
const { slug = '', token = '' } = useParams<{ slug: string; token: string }>();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -159,13 +171,15 @@ export function SlideshowPage() {
|
|||||||
storeGalleryToken(slug, session.token);
|
storeGalleryToken(slug, session.token);
|
||||||
setActiveGallerySlug(slug);
|
setActiveGallerySlug(slug);
|
||||||
setEventName(session.event.event_name || '');
|
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
|
// 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
|
// to running, so playback starts on an already-rasterised image instead
|
||||||
// of struggling on the first transition.
|
// of struggling on the first transition.
|
||||||
const data = await galleryService.getGalleryPhotos(slug);
|
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);
|
setPhotos(list);
|
||||||
await preloadDecode(list[0]);
|
await preloadDecode(list[0]);
|
||||||
void preloadDecode(list[1]);
|
void preloadDecode(list[1]);
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ export const eventsService = {
|
|||||||
show_transition_ms?: number;
|
show_transition_ms?: number;
|
||||||
show_watermark?: boolean | null;
|
show_watermark?: boolean | null;
|
||||||
show_colorfilter?: string;
|
show_colorfilter?: string;
|
||||||
|
show_order?: string;
|
||||||
|
show_category_id?: number | null;
|
||||||
}
|
}
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const response = await api.patch(`/admin/events/${id}/slideshow`, settings);
|
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.
|
// (admin Settings → Slideshow); 'on'/'off' = explicit override.
|
||||||
export type SlideshowWatermarkMode = 'inherit' | 'on' | 'off';
|
export type SlideshowWatermarkMode = 'inherit' | 'on' | 'off';
|
||||||
export const SLIDESHOW_WATERMARK_MODES: 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_TRANSITIONS: SlideshowTransition[] = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
|
||||||
export const SLIDESHOW_COLORFILTERS: SlideshowColorFilter[] = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
|
export const SLIDESHOW_COLORFILTERS: SlideshowColorFilter[] = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
|
||||||
@@ -37,6 +40,9 @@ export interface SlideshowStyle {
|
|||||||
transition_ms: number;
|
transition_ms: number;
|
||||||
watermark: SlideshowWatermarkMode;
|
watermark: SlideshowWatermarkMode;
|
||||||
colorfilter: SlideshowColorFilter;
|
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 = {
|
export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = {
|
||||||
@@ -45,6 +51,8 @@ export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = {
|
|||||||
transition_ms: 800,
|
transition_ms: 800,
|
||||||
watermark: 'inherit',
|
watermark: 'inherit',
|
||||||
colorfilter: 'none',
|
colorfilter: 'none',
|
||||||
|
order: 'chronological',
|
||||||
|
category_id: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Global slideshow defaults (admin Settings → Slideshow). The single source of
|
// Global slideshow defaults (admin Settings → Slideshow). The single source of
|
||||||
@@ -81,6 +89,10 @@ export interface SlideshowSettings {
|
|||||||
transition: SlideshowTransition;
|
transition: SlideshowTransition;
|
||||||
transition_ms: number;
|
transition_ms: number;
|
||||||
colorfilter: SlideshowColorFilter;
|
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;
|
fit: SlideshowFit;
|
||||||
watermark: SlideshowWatermark | null;
|
watermark: SlideshowWatermark | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user