Merge pull request #788 from PicPeak/feat/slideshow-order-category
feat(slideshow): per-event play order + category filter (#202)
This commit is contained in:
@@ -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