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:
@@ -89,7 +89,6 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
|||||||
body('emoji').optional().trim(),
|
body('emoji').optional().trim(),
|
||||||
body('theme_preset').optional().trim(),
|
body('theme_preset').optional().trim(),
|
||||||
body('theme_config').optional(),
|
body('theme_config').optional(),
|
||||||
body('slideshow_preset').optional(),
|
|
||||||
body('display_order').optional().isInt({ min: 0 })
|
body('display_order').optional().isInt({ min: 0 })
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -104,7 +103,6 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
|||||||
emoji,
|
emoji,
|
||||||
theme_preset,
|
theme_preset,
|
||||||
theme_config,
|
theme_config,
|
||||||
slideshow_preset,
|
|
||||||
display_order
|
display_order
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
@@ -114,7 +112,6 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
|||||||
emoji,
|
emoji,
|
||||||
theme_preset,
|
theme_preset,
|
||||||
theme_config,
|
theme_config,
|
||||||
slideshow_preset,
|
|
||||||
display_order
|
display_order
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,7 +150,6 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
|||||||
body('emoji').optional().trim(),
|
body('emoji').optional().trim(),
|
||||||
body('theme_preset').optional().trim(),
|
body('theme_preset').optional().trim(),
|
||||||
body('theme_config').optional(),
|
body('theme_config').optional(),
|
||||||
body('slideshow_preset').optional(),
|
|
||||||
body('display_order').optional().isInt({ min: 0 }),
|
body('display_order').optional().isInt({ min: 0 }),
|
||||||
body('is_active').optional().isBoolean()
|
body('is_active').optional().isBoolean()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const { hasColumnCached } = require('../utils/schemaCache');
|
|||||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||||
const { requireEventOwnership } = require('../middleware/ownership');
|
const { requireEventOwnership } = require('../middleware/ownership');
|
||||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||||
|
const { getAppSetting } = require('../utils/appSettings');
|
||||||
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
|
||||||
const downloadZipService = require('../services/downloadZipService');
|
const downloadZipService = require('../services/downloadZipService');
|
||||||
|
|
||||||
@@ -673,40 +674,26 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
|||||||
const calendarColumnsExist = await hasColumnCached('events', 'is_full_day');
|
const calendarColumnsExist = await hasColumnCached('events', 'is_full_day');
|
||||||
|
|
||||||
// Insert into database
|
// Insert into database
|
||||||
// Seed the new event's Live Slideshow settings from the event TYPE's preset
|
// Seed the new event's Live Slideshow display style from the PICPEAK-WIDE
|
||||||
// (migration 138). The admin configures "weddings fade slowly with our
|
// preset (app_settings, Settings → Slideshow). New events inherit it and the
|
||||||
// white logo, sepia" once on the type; every new wedding inherits it. The
|
// admin can still override per event. Watermark is left NULL = inherit the
|
||||||
// share token is NOT seeded — the link is still minted on demand. Guarded
|
// global watermark; the share token is minted on demand, not seeded. Guarded
|
||||||
// so un-migrated installs (mid-branch) don't reference missing columns.
|
// so un-migrated installs (mid-branch) don't reference missing columns.
|
||||||
let slideshowSeed = {};
|
let slideshowSeed = {};
|
||||||
if (await hasColumnCached('events', 'show_interval_ms')) {
|
if (await hasColumnCached('events', 'show_interval_ms')) {
|
||||||
try {
|
try {
|
||||||
const type = await eventTypeService.getEventTypeBySlugPrefix(event_type);
|
const intP = (v, min, max) => (Number.isFinite(+v) ? Math.min(max, Math.max(min, parseInt(v, 10))) : undefined);
|
||||||
const preset = type?.slideshow_preset
|
const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined);
|
||||||
? (typeof type.slideshow_preset === 'string' ? JSON.parse(type.slideshow_preset) : type.slideshow_preset)
|
const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000);
|
||||||
: null;
|
const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS);
|
||||||
if (preset && typeof preset === 'object') {
|
const tms = intP(await getAppSetting('slideshow_transition_ms', undefined), 100, 5000);
|
||||||
const intP = (v, min, max) => (Number.isFinite(+v) ? Math.min(max, Math.max(min, parseInt(v, 10))) : undefined);
|
const cf = oneOf(await getAppSetting('slideshow_colorfilter', undefined), SLIDESHOW_COLORFILTERS);
|
||||||
const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined);
|
if (i !== undefined) slideshowSeed.show_interval_ms = i;
|
||||||
// Tri-state watermark: 'on'/'off' seed an explicit override; 'inherit'
|
if (tr) slideshowSeed.show_transition = tr;
|
||||||
// (or unset) leaves the column NULL so the event follows the global.
|
if (tms !== undefined) slideshowSeed.show_transition_ms = tms;
|
||||||
let watermarkSeed;
|
if (cf) slideshowSeed.show_colorfilter = cf;
|
||||||
if (preset.watermark === 'on' || preset.watermark === true) watermarkSeed = formatBoolean(true);
|
|
||||||
else if (preset.watermark === 'off' || preset.watermark === false) watermarkSeed = formatBoolean(false);
|
|
||||||
const seed = {
|
|
||||||
show_interval_ms: intP(preset.interval_ms, 1000, 120000),
|
|
||||||
show_transition: oneOf(preset.transition, SLIDESHOW_TRANSITIONS),
|
|
||||||
show_transition_ms: intP(preset.transition_ms, 100, 5000),
|
|
||||||
show_watermark: watermarkSeed,
|
|
||||||
show_colorfilter: oneOf(preset.colorfilter, SLIDESHOW_COLORFILTERS),
|
|
||||||
};
|
|
||||||
// Only carry through fields the preset actually set.
|
|
||||||
for (const [k, v] of Object.entries(seed)) {
|
|
||||||
if (v !== undefined) slideshowSeed[k] = v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('Failed to seed slideshow settings from event type preset', { error: e.message });
|
logger.warn('Failed to seed slideshow settings from global preset', { error: e.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -302,6 +302,23 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r
|
|||||||
if (has('slideshow_fit')) {
|
if (has('slideshow_fit')) {
|
||||||
push('slideshow_fit', req.body.slideshow_fit === 'contain' ? 'contain' : 'cover');
|
push('slideshow_fit', req.body.slideshow_fit === 'contain' ? 'contain' : 'cover');
|
||||||
}
|
}
|
||||||
|
// Picpeak-wide display preset (default style new events inherit).
|
||||||
|
if (has('slideshow_interval_ms')) {
|
||||||
|
const n = Math.min(120000, Math.max(1000, Math.round(Number(req.body.slideshow_interval_ms) || 5000)));
|
||||||
|
push('slideshow_interval_ms', n);
|
||||||
|
}
|
||||||
|
if (has('slideshow_transition')) {
|
||||||
|
const allowed = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
|
||||||
|
push('slideshow_transition', allowed.includes(req.body.slideshow_transition) ? req.body.slideshow_transition : 'crossfade');
|
||||||
|
}
|
||||||
|
if (has('slideshow_transition_ms')) {
|
||||||
|
const n = Math.min(5000, Math.max(100, Math.round(Number(req.body.slideshow_transition_ms) || 800)));
|
||||||
|
push('slideshow_transition_ms', n);
|
||||||
|
}
|
||||||
|
if (has('slideshow_colorfilter')) {
|
||||||
|
const allowed = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
|
||||||
|
push('slideshow_colorfilter', allowed.includes(req.body.slideshow_colorfilter) ? req.body.slideshow_colorfilter : 'none');
|
||||||
|
}
|
||||||
if (has('slideshow_watermark_enabled')) push('slideshow_watermark_enabled', !!req.body.slideshow_watermark_enabled);
|
if (has('slideshow_watermark_enabled')) push('slideshow_watermark_enabled', !!req.body.slideshow_watermark_enabled);
|
||||||
if (has('slideshow_watermark_source')) {
|
if (has('slideshow_watermark_source')) {
|
||||||
const v = ['logo', 'logo_dark', 'favicon', 'event'].includes(req.body.slideshow_watermark_source) ? req.body.slideshow_watermark_source : 'logo';
|
const v = ['logo', 'logo_dark', 'favicon', 'event'].includes(req.body.slideshow_watermark_source) ? req.body.slideshow_watermark_source : 'logo';
|
||||||
|
|||||||
@@ -100,8 +100,7 @@ const createEventType = async (eventTypeData) => {
|
|||||||
emoji,
|
emoji,
|
||||||
theme_preset,
|
theme_preset,
|
||||||
theme_config,
|
theme_config,
|
||||||
display_order,
|
display_order
|
||||||
slideshow_preset
|
|
||||||
} = eventTypeData;
|
} = eventTypeData;
|
||||||
|
|
||||||
// Normalize slug_prefix
|
// Normalize slug_prefix
|
||||||
@@ -128,7 +127,6 @@ const createEventType = async (eventTypeData) => {
|
|||||||
emoji: emoji || '📷',
|
emoji: emoji || '📷',
|
||||||
theme_preset: theme_preset || 'default',
|
theme_preset: theme_preset || 'default',
|
||||||
theme_config: theme_config ? JSON.stringify(theme_config) : null,
|
theme_config: theme_config ? JSON.stringify(theme_config) : null,
|
||||||
slideshow_preset: slideshow_preset ? JSON.stringify(slideshow_preset) : null,
|
|
||||||
display_order: finalDisplayOrder,
|
display_order: finalDisplayOrder,
|
||||||
is_system: false,
|
is_system: false,
|
||||||
is_active: true,
|
is_active: true,
|
||||||
@@ -193,10 +191,6 @@ const updateEventType = async (id, updates) => {
|
|||||||
updateData.theme_config = updates.theme_config ? JSON.stringify(updates.theme_config) : null;
|
updateData.theme_config = updates.theme_config ? JSON.stringify(updates.theme_config) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updates.slideshow_preset !== undefined) {
|
|
||||||
updateData.slideshow_preset = updates.slideshow_preset ? JSON.stringify(updates.slideshow_preset) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.display_order !== undefined) {
|
if (updates.display_order !== undefined) {
|
||||||
updateData.display_order = updates.display_order;
|
updateData.display_order = updates.display_order;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,18 @@ import {
|
|||||||
SLIDESHOW_WATERMARK_POSITIONS,
|
SLIDESHOW_WATERMARK_POSITIONS,
|
||||||
SLIDESHOW_WATERMARK_STYLES,
|
SLIDESHOW_WATERMARK_STYLES,
|
||||||
SLIDESHOW_FITS,
|
SLIDESHOW_FITS,
|
||||||
|
SLIDESHOW_TRANSITIONS,
|
||||||
|
SLIDESHOW_COLORFILTERS,
|
||||||
type SlideshowGlobalDefaults,
|
type SlideshowGlobalDefaults,
|
||||||
} from '../../services/slideshow.service';
|
} from '../../services/slideshow.service';
|
||||||
import { WatermarkSourcePicker } from './WatermarkSourcePicker';
|
import { WatermarkSourcePicker } from './WatermarkSourcePicker';
|
||||||
|
|
||||||
const DEFAULTS: SlideshowGlobalDefaults = {
|
const DEFAULTS: SlideshowGlobalDefaults = {
|
||||||
slideshow_fit: 'cover',
|
slideshow_fit: 'cover',
|
||||||
|
slideshow_interval_ms: 5000,
|
||||||
|
slideshow_transition: 'crossfade',
|
||||||
|
slideshow_transition_ms: 800,
|
||||||
|
slideshow_colorfilter: 'none',
|
||||||
slideshow_watermark_enabled: false,
|
slideshow_watermark_enabled: false,
|
||||||
slideshow_watermark_source: 'logo',
|
slideshow_watermark_source: 'logo',
|
||||||
slideshow_watermark_position: 'bottom-right',
|
slideshow_watermark_position: 'bottom-right',
|
||||||
@@ -46,6 +52,10 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => {
|
|||||||
if (cancelled || !s) return;
|
if (cancelled || !s) return;
|
||||||
setVal({
|
setVal({
|
||||||
slideshow_fit: s.slideshow_fit ?? DEFAULTS.slideshow_fit,
|
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_enabled: s.slideshow_watermark_enabled ?? DEFAULTS.slideshow_watermark_enabled,
|
||||||
slideshow_watermark_source: s.slideshow_watermark_source ?? DEFAULTS.slideshow_watermark_source,
|
slideshow_watermark_source: s.slideshow_watermark_source ?? DEFAULTS.slideshow_watermark_source,
|
||||||
slideshow_watermark_position: s.slideshow_watermark_position ?? DEFAULTS.slideshow_watermark_position,
|
slideshow_watermark_position: s.slideshow_watermark_position ?? DEFAULTS.slideshow_watermark_position,
|
||||||
@@ -99,6 +109,65 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<label className="flex items-start gap-2">
|
<label className="flex items-start gap-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -3214,6 +3214,8 @@
|
|||||||
"contain": "Schwarze Balken (kein Beschnitt)"
|
"contain": "Schwarze Balken (kein Beschnitt)"
|
||||||
},
|
},
|
||||||
"fitHint": "„Füllen“ beschneidet das Bild zum Ausfüllen; „Schwarze Balken“ zeigt das ganze Foto – besser für Hochformat.",
|
"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",
|
"defaultsSaved": "Diashow-Vorgaben gespeichert",
|
||||||
"liveHint": "Änderungen werden in einer laufenden Diashow innerhalb weniger Sekunden übernommen – der Link muss nicht neu erstellt werden.",
|
"liveHint": "Änderungen werden in einer laufenden Diashow innerhalb weniger Sekunden übernommen – der Link muss nicht neu erstellt werden.",
|
||||||
"saveSettings": "Diashow-Einstellungen speichern",
|
"saveSettings": "Diashow-Einstellungen speichern",
|
||||||
|
|||||||
@@ -3238,6 +3238,8 @@
|
|||||||
"contain": "Black bars (no crop)"
|
"contain": "Black bars (no crop)"
|
||||||
},
|
},
|
||||||
"fitHint": "\"Fill\" crops to fill the screen; \"Black bars\" shows the whole photo — better for portrait images.",
|
"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",
|
"defaultsSaved": "Slideshow defaults saved",
|
||||||
"liveHint": "Changes apply to a running slideshow within a few seconds — no need to regenerate the link.",
|
"liveHint": "Changes apply to a running slideshow within a few seconds — no need to regenerate the link.",
|
||||||
"saveSettings": "Save slideshow settings",
|
"saveSettings": "Save slideshow settings",
|
||||||
|
|||||||
@@ -18,21 +18,6 @@ import {
|
|||||||
import { Button, Input, Card, Loading } from '../../components/common';
|
import { Button, Input, Card, Loading } from '../../components/common';
|
||||||
import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service';
|
import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service';
|
||||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
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
|
// Common emoji options for event types
|
||||||
const EMOJI_OPTIONS = [
|
const EMOJI_OPTIONS = [
|
||||||
@@ -336,7 +321,6 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isEditing = !!eventType;
|
const isEditing = !!eventType;
|
||||||
const slideshowEnabled = useFeatureEnabled('slideshow');
|
|
||||||
|
|
||||||
const [form, setForm] = useState<CreateEventTypeData>({
|
const [form, setForm] = useState<CreateEventTypeData>({
|
||||||
name: eventType?.name || '',
|
name: eventType?.name || '',
|
||||||
@@ -345,12 +329,6 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
|||||||
theme_preset: eventType?.theme_preset || 'default'
|
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 [errors, setErrors] = useState<Partial<Record<keyof CreateEventTypeData, string>>>({});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
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.slug_prefix !== eventType?.slug_prefix) updates.slug_prefix = form.slug_prefix;
|
||||||
if (form.emoji !== eventType?.emoji) updates.emoji = form.emoji;
|
if (form.emoji !== eventType?.emoji) updates.emoji = form.emoji;
|
||||||
if (form.theme_preset !== eventType?.theme_preset) updates.theme_preset = form.theme_preset;
|
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);
|
onSubmit(updates);
|
||||||
} else {
|
} else {
|
||||||
onSubmit({ ...form, ...(slideshowEnabled ? { slideshow_preset: slideshowStyle } : {}) });
|
onSubmit(form);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -484,22 +460,6 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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 */}
|
{/* Active toggle for editing */}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<label className="flex items-center gap-3 pt-2">
|
<label className="flex items-center gap-3 pt-2">
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import type { SlideshowStyle } from './slideshow.service';
|
|
||||||
|
|
||||||
export interface EventType {
|
export interface EventType {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -13,9 +12,6 @@ export interface EventType {
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
theme_preset: string;
|
theme_preset: string;
|
||||||
theme_config: string | null;
|
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;
|
display_order: number;
|
||||||
is_system: boolean;
|
is_system: boolean;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
@@ -29,7 +25,6 @@ export interface CreateEventTypeData {
|
|||||||
emoji?: string;
|
emoji?: string;
|
||||||
theme_preset?: string;
|
theme_preset?: string;
|
||||||
theme_config?: Record<string, unknown>;
|
theme_config?: Record<string, unknown>;
|
||||||
slideshow_preset?: SlideshowStyle | null;
|
|
||||||
display_order?: number;
|
display_order?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +34,6 @@ export interface UpdateEventTypeData {
|
|||||||
emoji?: string;
|
emoji?: string;
|
||||||
theme_preset?: string;
|
theme_preset?: string;
|
||||||
theme_config?: Record<string, unknown>;
|
theme_config?: Record<string, unknown>;
|
||||||
slideshow_preset?: SlideshowStyle | null;
|
|
||||||
display_order?: number;
|
display_order?: number;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_SOURCES: SlideshowWatermarkSource[] = ['logo', 'logo_dark', 'favicon', 'event'];
|
||||||
export const SLIDESHOW_WATERMARK_POSITIONS: SlideshowWatermarkPosition[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
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 editable style (the override). The picpeak-wide DEFAULT for these
|
||||||
// per-event-type preset editor. The per-event-type `slideshow_preset` JSON
|
// lives in the global Settings → Slideshow tab; new events inherit it and the
|
||||||
// uses exactly these keys; the per-event API maps them to `show_*` columns.
|
// per-event card maps these to `show_*` columns.
|
||||||
//
|
//
|
||||||
// NOTE: the watermark LOOK (logo/position/opacity/style/size) lives ONLY in the
|
// 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
|
// global tab — not duplicated here. Per-event carries just the `watermark` MODE
|
||||||
// carry just the `watermark` MODE (inherit/on/off), i.e. the override structure.
|
// (inherit/on/off), i.e. the override structure.
|
||||||
export interface SlideshowStyle {
|
export interface SlideshowStyle {
|
||||||
interval_ms: number;
|
interval_ms: number;
|
||||||
transition: SlideshowTransition;
|
transition: SlideshowTransition;
|
||||||
@@ -53,6 +53,11 @@ export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = {
|
|||||||
export interface SlideshowGlobalDefaults {
|
export interface SlideshowGlobalDefaults {
|
||||||
// How slides fill the screen (fill+crop vs letterbox/black bars).
|
// How slides fill the screen (fill+crop vs letterbox/black bars).
|
||||||
slideshow_fit: SlideshowFit;
|
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_enabled: boolean;
|
||||||
slideshow_watermark_source: SlideshowWatermarkSource;
|
slideshow_watermark_source: SlideshowWatermarkSource;
|
||||||
slideshow_watermark_position: SlideshowWatermarkPosition;
|
slideshow_watermark_position: SlideshowWatermarkPosition;
|
||||||
|
|||||||
Reference in New Issue
Block a user