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:
Paul Nothaft
2026-07-10 10:46:54 +02:00
parent df5aeaba41
commit b768a53c5b
11 changed files with 183 additions and 11 deletions
@@ -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">