refactor(slideshow): replace per-event-type preset with a picpeak-wide one

The slideshow display preset (transition / interval / speed / color filter) was
set PER EVENT TYPE in the Edit Event Type dialog. Replace it with a single
picpeak-wide default in Settings -> Slideshow ("Default style for new
slideshows"). New events seed their show_* columns from this global preset
(was: from the event type's slideshow_preset); the per-event override is
unchanged.

- Removed event_types.slideshow_preset usage everywhere (EventTypeModal section,
  eventTypes.service types, eventTypeService whitelist, adminEventTypes
  validators/POST). The DB column from migration 138 is left inert.
- Global preset stored in app_settings (slideshow_interval_ms/transition/
  transition_ms/colorfilter), saved via PUT /admin/settings/slideshow.
- adminEvents create-seeding now reads the global preset (getAppSetting) instead
  of the event type.
- en/de: presetTitle + presetHint.
This commit is contained in:
Luca
2026-06-20 12:20:48 +02:00
parent b5c73e05bd
commit 5f1f4c2b3d
10 changed files with 118 additions and 92 deletions
@@ -17,12 +17,18 @@ import {
SLIDESHOW_WATERMARK_POSITIONS,
SLIDESHOW_WATERMARK_STYLES,
SLIDESHOW_FITS,
SLIDESHOW_TRANSITIONS,
SLIDESHOW_COLORFILTERS,
type SlideshowGlobalDefaults,
} from '../../services/slideshow.service';
import { WatermarkSourcePicker } from './WatermarkSourcePicker';
const DEFAULTS: SlideshowGlobalDefaults = {
slideshow_fit: 'cover',
slideshow_interval_ms: 5000,
slideshow_transition: 'crossfade',
slideshow_transition_ms: 800,
slideshow_colorfilter: 'none',
slideshow_watermark_enabled: false,
slideshow_watermark_source: 'logo',
slideshow_watermark_position: 'bottom-right',
@@ -46,6 +52,10 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => {
if (cancelled || !s) return;
setVal({
slideshow_fit: s.slideshow_fit ?? DEFAULTS.slideshow_fit,
slideshow_interval_ms: s.slideshow_interval_ms ?? DEFAULTS.slideshow_interval_ms,
slideshow_transition: s.slideshow_transition ?? DEFAULTS.slideshow_transition,
slideshow_transition_ms: s.slideshow_transition_ms ?? DEFAULTS.slideshow_transition_ms,
slideshow_colorfilter: s.slideshow_colorfilter ?? DEFAULTS.slideshow_colorfilter,
slideshow_watermark_enabled: s.slideshow_watermark_enabled ?? DEFAULTS.slideshow_watermark_enabled,
slideshow_watermark_source: s.slideshow_watermark_source ?? DEFAULTS.slideshow_watermark_source,
slideshow_watermark_position: s.slideshow_watermark_position ?? DEFAULTS.slideshow_watermark_position,
@@ -99,6 +109,65 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => {
</p>
</div>
{/* Default display style new slideshows inherit (override per event) */}
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('slideshow.presetTitle', 'Default style for new slideshows')}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{t('slideshow.presetHint', 'Applied to events created from now on; each event can still override it.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className={labelClass}>{t('slideshow.transitionLabel', 'Transition')}</label>
<select
value={val.slideshow_transition}
onChange={(e) => setVal({ ...val, slideshow_transition: e.target.value as SlideshowGlobalDefaults['slideshow_transition'] })}
className={inputClass}
>
{SLIDESHOW_TRANSITIONS.map((tr) => (
<option key={tr} value={tr}>{t(`slideshow.transition.${tr}`, tr)}</option>
))}
</select>
</div>
<div>
<label className={labelClass}>{t('slideshow.intervalLabel', 'Display time (sec)')}</label>
<input
type="number"
min={1}
max={120}
value={Math.round(val.slideshow_interval_ms / 1000)}
onChange={(e) => setVal({ ...val, slideshow_interval_ms: Math.min(120, Math.max(1, parseInt(e.target.value, 10) || 5)) * 1000 })}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>{t('slideshow.transitionSpeedLabel', 'Transition speed (ms)')}</label>
<input
type="number"
min={100}
max={5000}
step={100}
value={val.slideshow_transition_ms}
onChange={(e) => setVal({ ...val, slideshow_transition_ms: Math.min(5000, Math.max(100, parseInt(e.target.value, 10) || 800)) })}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>{t('slideshow.colorfilterLabel', 'Color filter')}</label>
<select
value={val.slideshow_colorfilter}
onChange={(e) => setVal({ ...val, slideshow_colorfilter: e.target.value as SlideshowGlobalDefaults['slideshow_colorfilter'] })}
className={inputClass}
>
{SLIDESHOW_COLORFILTERS.map((cf) => (
<option key={cf} value={cf}>{t(`slideshow.colorfilter.${cf}`, cf)}</option>
))}
</select>
</div>
</div>
</div>
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<label className="flex items-start gap-2">
<input
+2
View File
@@ -3214,6 +3214,8 @@
"contain": "Schwarze Balken (kein Beschnitt)"
},
"fitHint": "„Füllen“ beschneidet das Bild zum Ausfüllen; „Schwarze Balken“ zeigt das ganze Foto besser für Hochformat.",
"presetTitle": "Standard-Stil für neue Diashows",
"presetHint": "Gilt für ab jetzt erstellte Events; jedes Event kann ihn weiterhin überschreiben.",
"defaultsSaved": "Diashow-Vorgaben gespeichert",
"liveHint": "Änderungen werden in einer laufenden Diashow innerhalb weniger Sekunden übernommen der Link muss nicht neu erstellt werden.",
"saveSettings": "Diashow-Einstellungen speichern",
+2
View File
@@ -3238,6 +3238,8 @@
"contain": "Black bars (no crop)"
},
"fitHint": "\"Fill\" crops to fill the screen; \"Black bars\" shows the whole photo — better for portrait images.",
"presetTitle": "Default style for new slideshows",
"presetHint": "Applied to events created from now on; each event can still override it.",
"defaultsSaved": "Slideshow defaults saved",
"liveHint": "Changes apply to a running slideshow within a few seconds — no need to regenerate the link.",
"saveSettings": "Save slideshow settings",
+1 -41
View File
@@ -18,21 +18,6 @@ import {
import { Button, Input, Card, Loading } from '../../components/common';
import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { SlideshowStyleFields } from '../../components/admin/SlideshowStyleFields';
import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service';
import { useFeatureEnabled } from '../../contexts/FeatureFlagsContext';
// Parse a type's stored slideshow_preset (JSON string | object | null) into a
// full SlideshowStyle, falling back to defaults for any missing keys.
function parseSlideshowPreset(raw: EventType['slideshow_preset']): SlideshowStyle {
if (!raw) return { ...DEFAULT_SLIDESHOW_STYLE };
try {
const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
return { ...DEFAULT_SLIDESHOW_STYLE, ...obj };
} catch {
return { ...DEFAULT_SLIDESHOW_STYLE };
}
}
// Common emoji options for event types
const EMOJI_OPTIONS = [
@@ -336,7 +321,6 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
}) => {
const { t } = useTranslation();
const isEditing = !!eventType;
const slideshowEnabled = useFeatureEnabled('slideshow');
const [form, setForm] = useState<CreateEventTypeData>({
name: eventType?.name || '',
@@ -345,12 +329,6 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
theme_preset: eventType?.theme_preset || 'default'
});
// Slideshow preset new events of this type inherit. Edited via the shared
// <SlideshowStyleFields>; serialized into slideshow_preset on submit.
const [slideshowStyle, setSlideshowStyle] = useState<SlideshowStyle>(
() => parseSlideshowPreset(eventType?.slideshow_preset)
);
const [errors, setErrors] = useState<Partial<Record<keyof CreateEventTypeData, string>>>({});
const handleSubmit = (e: React.FormEvent) => {
@@ -379,11 +357,9 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
if (form.slug_prefix !== eventType?.slug_prefix) updates.slug_prefix = form.slug_prefix;
if (form.emoji !== eventType?.emoji) updates.emoji = form.emoji;
if (form.theme_preset !== eventType?.theme_preset) updates.theme_preset = form.theme_preset;
const originalStyle = JSON.stringify(parseSlideshowPreset(eventType?.slideshow_preset));
if (slideshowEnabled && JSON.stringify(slideshowStyle) !== originalStyle) updates.slideshow_preset = slideshowStyle;
onSubmit(updates);
} else {
onSubmit({ ...form, ...(slideshowEnabled ? { slideshow_preset: slideshowStyle } : {}) });
onSubmit(form);
}
};
@@ -484,22 +460,6 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
</select>
</div>
{/* Live Slideshow preset (migration 138). New events of this type
inherit these slideshow defaults; admins can still override
per event on the event detail page. Gated behind the
`slideshow` feature flag. */}
{slideshowEnabled && (
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('eventTypes.form.slideshowPreset', 'Slideshow preset')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{t('eventTypes.form.slideshowPresetHint', 'Default slideshow style for new events of this type.')}
</p>
<SlideshowStyleFields value={slideshowStyle} onChange={setSlideshowStyle} />
</div>
)}
{/* Active toggle for editing */}
{isEditing && (
<label className="flex items-center gap-3 pt-2">
@@ -4,7 +4,6 @@
*/
import { api } from '../config/api';
import type { SlideshowStyle } from './slideshow.service';
export interface EventType {
id: number;
@@ -13,9 +12,6 @@ export interface EventType {
emoji: string;
theme_preset: string;
theme_config: string | null;
// Live Slideshow preset new events of this type inherit (migration 138).
// Stored as a JSON string by the API; null = no preset.
slideshow_preset: string | null;
display_order: number;
is_system: boolean;
is_active: boolean;
@@ -29,7 +25,6 @@ export interface CreateEventTypeData {
emoji?: string;
theme_preset?: string;
theme_config?: Record<string, unknown>;
slideshow_preset?: SlideshowStyle | null;
display_order?: number;
}
@@ -39,7 +34,6 @@ export interface UpdateEventTypeData {
emoji?: string;
theme_preset?: string;
theme_config?: Record<string, unknown>;
slideshow_preset?: SlideshowStyle | null;
display_order?: number;
is_active?: boolean;
}
+10 -5
View File
@@ -24,13 +24,13 @@ export const SLIDESHOW_COLORFILTERS: SlideshowColorFilter[] = ['none', 'bw', 'se
export const SLIDESHOW_WATERMARK_SOURCES: SlideshowWatermarkSource[] = ['logo', 'logo_dark', 'favicon', 'event'];
export const SLIDESHOW_WATERMARK_POSITIONS: SlideshowWatermarkPosition[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
// Canonical editable style shape, shared by the per-event card and the
// per-event-type preset editor. The per-event-type `slideshow_preset` JSON
// uses exactly these keys; the per-event API maps them to `show_*` columns.
// Per-event editable style (the override). The picpeak-wide DEFAULT for these
// lives in the global Settings → Slideshow tab; new events inherit it and the
// per-event card maps these to `show_*` columns.
//
// NOTE: the watermark LOOK (logo/position/opacity/style/size) lives ONLY in the
// global Settings → Slideshow tab — it is not duplicated here. Per-event/type
// carry just the `watermark` MODE (inherit/on/off), i.e. the override structure.
// global tab — not duplicated here. Per-event carries just the `watermark` MODE
// (inherit/on/off), i.e. the override structure.
export interface SlideshowStyle {
interval_ms: number;
transition: SlideshowTransition;
@@ -53,6 +53,11 @@ export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = {
export interface SlideshowGlobalDefaults {
// How slides fill the screen (fill+crop vs letterbox/black bars).
slideshow_fit: SlideshowFit;
// Picpeak-wide display preset (default style new events inherit).
slideshow_interval_ms: number;
slideshow_transition: SlideshowTransition;
slideshow_transition_ms: number;
slideshow_colorfilter: SlideshowColorFilter;
slideshow_watermark_enabled: boolean;
slideshow_watermark_source: SlideshowWatermarkSource;
slideshow_watermark_position: SlideshowWatermarkPosition;