diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index bd14c07c..97c0faf2 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -697,10 +697,6 @@ router.post('/', adminAuth, requirePermission('events.create'), [ show_transition: oneOf(preset.transition, SLIDESHOW_TRANSITIONS), show_transition_ms: intP(preset.transition_ms, 100, 5000), show_watermark: watermarkSeed, - show_watermark_source: oneOf(preset.watermark_source, SLIDESHOW_WATERMARK_SOURCES), - show_watermark_position: oneOf(preset.watermark_position, SLIDESHOW_WATERMARK_POSITIONS), - show_watermark_opacity: intP(preset.watermark_opacity, 0, 100), - show_watermark_style: oneOf(preset.watermark_style, SLIDESHOW_WATERMARK_STYLES), show_colorfilter: oneOf(preset.colorfilter, SLIDESHOW_COLORFILTERS), }; // Only carry through fields the preset actually set. @@ -1908,12 +1904,9 @@ router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), r const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack']; // Allowed per-slide color filters. const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette']; -// Allowed watermark logo sources + corners. -const SLIDESHOW_WATERMARK_SOURCES = ['logo', 'logo_dark', 'favicon', 'event']; -const SLIDESHOW_WATERMARK_POSITIONS = ['top-left', 'top-right', 'bottom-left', 'bottom-right']; -// 'white' recolors the logo white; 'original' keeps its own colors (for boxed -// / colored logos that would otherwise whiten into a solid blob). -const SLIDESHOW_WATERMARK_STYLES = ['white', 'original']; +// The watermark LOOK (source/position/opacity/style/size) is global-only +// (app_settings, Settings → Slideshow); events only carry the show_watermark +// mode (NULL=inherit / true / false), so no per-event look enums live here. // Build the public slideshow URL for a freshly-minted/existing token. async function buildSlideshowUrl(slug, token) { @@ -2000,10 +1993,6 @@ router.patch('/:id/slideshow', adminAuth, requirePermission('events.edit'), requ body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS), body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }), body('show_watermark').optional({ nullable: true }), - body('show_watermark_source').optional().isIn(SLIDESHOW_WATERMARK_SOURCES), - body('show_watermark_position').optional().isIn(SLIDESHOW_WATERMARK_POSITIONS), - body('show_watermark_opacity').optional().isInt({ min: 0, max: 100 }), - body('show_watermark_style').optional().isIn(SLIDESHOW_WATERMARK_STYLES), body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS) ], async (req, res) => { try { @@ -2028,10 +2017,6 @@ router.patch('/:id/slideshow', adminAuth, requirePermission('events.edit'), requ ? null : formatBoolean(parseBooleanInput(req.body.show_watermark, false)); } - if (req.body.show_watermark_source !== undefined) updates.show_watermark_source = req.body.show_watermark_source; - if (req.body.show_watermark_position !== undefined) updates.show_watermark_position = req.body.show_watermark_position; - if (req.body.show_watermark_opacity !== undefined) updates.show_watermark_opacity = parseInt(req.body.show_watermark_opacity, 10); - if (req.body.show_watermark_style !== undefined) updates.show_watermark_style = req.body.show_watermark_style; if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter; // Knex throws on an empty update; only write if something changed. @@ -2043,11 +2028,7 @@ router.patch('/:id/slideshow', adminAuth, requirePermission('events.edit'), requ show_interval_ms: updates.show_interval_ms ?? event.show_interval_ms ?? 5000, show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade', show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800, - show_watermark: updates.show_watermark ?? event.show_watermark ?? false, - show_watermark_source: updates.show_watermark_source ?? event.show_watermark_source ?? 'logo', - show_watermark_position: updates.show_watermark_position ?? event.show_watermark_position ?? 'bottom-right', - show_watermark_opacity: updates.show_watermark_opacity ?? event.show_watermark_opacity ?? 60, - show_watermark_style: updates.show_watermark_style ?? event.show_watermark_style ?? 'white', + show_watermark: updates.show_watermark ?? event.show_watermark ?? null, show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none' }); } catch (error) { diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index a762e2de..d6197f44 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -317,6 +317,10 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r const v = ['white', 'original'].includes(req.body.slideshow_watermark_style) ? req.body.slideshow_watermark_style : 'white'; push('slideshow_watermark_style', v); } + if (has('slideshow_watermark_size')) { + const n = Math.min(40, Math.max(3, Math.round(Number(req.body.slideshow_watermark_size) || 12))); + push('slideshow_watermark_size', n); + } for (const u of updates) { await upsertAppSetting(u.setting_key, u.setting_value, u.setting_type); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 3475dc35..2d198270 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -264,9 +264,10 @@ async function resolveSlideshow(slug, token) { // from the chosen source so the kiosk renders it without knowing about // branding/event internals; null url = nothing to overlay. async function slideshowSettings(event) { - // Watermark cascade: per-event `show_watermark` overrides the global default, - // NULL inherits it. When inheriting, the source/position/opacity also come - // from the global settings; when overriding, from the event's own columns. + // Watermark: the LOOK (logo/position/opacity/style/size) is configured ONCE + // globally (Settings → Slideshow); it is NOT duplicated per event. The only + // per-event control is whether the watermark shows: `show_watermark` NULL + // inherits the global enabled flag, true/false force it on/off. const wm = event.show_watermark; const inherit = (wm === null || wm === undefined); const enabled = inherit @@ -274,18 +275,11 @@ async function slideshowSettings(event) { : (wm === true || wm === 1 || wm === '1'); let watermark = null; if (enabled) { - const source = inherit - ? (await getAppSetting('slideshow_watermark_source', 'logo')) - : (event.show_watermark_source || 'logo'); - const position = inherit - ? (await getAppSetting('slideshow_watermark_position', 'bottom-right')) - : (event.show_watermark_position || 'bottom-right'); - const opacity = inherit - ? (await getAppSetting('slideshow_watermark_opacity', 60)) - : (event.show_watermark_opacity ?? 60); - const style = inherit - ? (await getAppSetting('slideshow_watermark_style', 'white')) - : (event.show_watermark_style || 'white'); + const source = await getAppSetting('slideshow_watermark_source', 'logo'); + const position = await getAppSetting('slideshow_watermark_position', 'bottom-right'); + const opacity = await getAppSetting('slideshow_watermark_opacity', 60); + const style = await getAppSetting('slideshow_watermark_style', 'white'); + const size = await getAppSetting('slideshow_watermark_size', 12); // Resolve the chosen logo to a URL. Branding assets come from settings; // the event source uses the event's own hero logo. let url; @@ -299,7 +293,7 @@ async function slideshowSettings(event) { url = await getAppSetting('branding_logo_url', null); } if (url) { - watermark = { url, position: position || 'bottom-right', opacity: opacity ?? 60, style: style || 'white' }; + watermark = { url, position: position || 'bottom-right', opacity: opacity ?? 60, style: style || 'white', size: size ?? 12 }; } } return { diff --git a/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx b/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx index c6b3b903..5ad544d3 100644 --- a/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx +++ b/frontend/src/components/admin/SlideshowGlobalDefaultsCard.tsx @@ -26,6 +26,7 @@ const DEFAULTS: SlideshowGlobalDefaults = { slideshow_watermark_position: 'bottom-right', slideshow_watermark_opacity: 60, slideshow_watermark_style: 'white', + slideshow_watermark_size: 12, }; const inputClass = @@ -47,6 +48,7 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => { slideshow_watermark_position: s.slideshow_watermark_position ?? DEFAULTS.slideshow_watermark_position, slideshow_watermark_opacity: s.slideshow_watermark_opacity ?? DEFAULTS.slideshow_watermark_opacity, slideshow_watermark_style: s.slideshow_watermark_style ?? DEFAULTS.slideshow_watermark_style, + slideshow_watermark_size: s.slideshow_watermark_size ?? DEFAULTS.slideshow_watermark_size, }); }).catch(() => { /* keep defaults */ }); return () => { cancelled = true; }; @@ -142,6 +144,18 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => { ))} +
+ + setVal({ ...val, slideshow_watermark_size: Math.min(40, Math.max(3, parseInt(e.target.value, 10) || 12)) })} + className={inputClass} + /> +
)} diff --git a/frontend/src/components/admin/SlideshowSettingsCard.tsx b/frontend/src/components/admin/SlideshowSettingsCard.tsx index aee40c31..b568dd14 100644 --- a/frontend/src/components/admin/SlideshowSettingsCard.tsx +++ b/frontend/src/components/admin/SlideshowSettingsCard.tsx @@ -28,18 +28,12 @@ export interface SlideshowSettingsCardProps { eventId: number; slug: string; isArchived?: boolean; - /** The event's own hero logo, previewed for the 'event' watermark source. */ - eventLogoUrl?: string | null; initial: { show_share_token?: string | null; show_interval_ms?: number; show_transition?: string; show_transition_ms?: number; show_watermark?: boolean | null; - show_watermark_source?: string; - show_watermark_position?: string; - show_watermark_opacity?: number; - show_watermark_style?: string; show_colorfilter?: string; }; onChanged?: () => void; @@ -57,16 +51,12 @@ function styleFromInitial(initial: SlideshowSettingsCardProps['initial']): Slide transition: (initial.show_transition as SlideshowStyle['transition']) ?? DEFAULT_SLIDESHOW_STYLE.transition, transition_ms: initial.show_transition_ms ?? DEFAULT_SLIDESHOW_STYLE.transition_ms, watermark: watermarkMode(initial.show_watermark), - watermark_source: (initial.show_watermark_source as SlideshowStyle['watermark_source']) ?? DEFAULT_SLIDESHOW_STYLE.watermark_source, - watermark_position: (initial.show_watermark_position as SlideshowStyle['watermark_position']) ?? DEFAULT_SLIDESHOW_STYLE.watermark_position, - watermark_opacity: initial.show_watermark_opacity ?? DEFAULT_SLIDESHOW_STYLE.watermark_opacity, - watermark_style: (initial.show_watermark_style as SlideshowStyle['watermark_style']) ?? DEFAULT_SLIDESHOW_STYLE.watermark_style, colorfilter: (initial.show_colorfilter as SlideshowStyle['colorfilter']) ?? DEFAULT_SLIDESHOW_STYLE.colorfilter, }; } export const SlideshowSettingsCard: React.FC = ({ - eventId, slug, isArchived, eventLogoUrl, initial, onChanged, + eventId, slug, isArchived, initial, onChanged, }) => { const { t } = useTranslation(); @@ -135,12 +125,9 @@ export const SlideshowSettingsCard: React.FC = ({ show_interval_ms: style.interval_ms, show_transition: style.transition, show_transition_ms: style.transition_ms, - // Tri-state → null (inherit global) / true / false. + // Tri-state → null (inherit global) / true / false. The watermark LOOK + // is global-only (Settings → Slideshow); we only send the mode here. show_watermark: style.watermark === 'inherit' ? null : style.watermark === 'on', - show_watermark_source: style.watermark_source, - show_watermark_position: style.watermark_position, - show_watermark_opacity: style.watermark_opacity, - show_watermark_style: style.watermark_style, show_colorfilter: style.colorfilter, }); toast.success(t('slideshow.settingsSaved', 'Slideshow settings saved')); @@ -221,7 +208,7 @@ export const SlideshowSettingsCard: React.FC = ({ {/* Live style settings */}
- +

{t('slideshow.liveHint', 'Changes apply to a running slideshow within a few seconds — no need to regenerate the link.')} diff --git a/frontend/src/components/admin/SlideshowStyleFields.tsx b/frontend/src/components/admin/SlideshowStyleFields.tsx index 8132ed93..adcdc095 100644 --- a/frontend/src/components/admin/SlideshowStyleFields.tsx +++ b/frontend/src/components/admin/SlideshowStyleFields.tsx @@ -14,18 +14,13 @@ import { useTranslation } from 'react-i18next'; import { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS, - SLIDESHOW_WATERMARK_POSITIONS, SLIDESHOW_WATERMARK_MODES, - SLIDESHOW_WATERMARK_STYLES, type SlideshowStyle, } from '../../services/slideshow.service'; -import { WatermarkSourcePicker } from './WatermarkSourcePicker'; export interface SlideshowStyleFieldsProps { value: SlideshowStyle; onChange: (next: SlideshowStyle) => void; - /** Per-event hero logo, previewed for the 'event' watermark source. */ - eventLogoUrl?: string | null; } const inputClass = @@ -34,7 +29,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 = ({ value, onChange, eventLogoUrl }) => { +export const SlideshowStyleFields: React.FC = ({ value, onChange }) => { const { t } = useTranslation(); const set = (patch: Partial) => onChange({ ...value, ...patch }); @@ -97,7 +92,8 @@ export const SlideshowStyleFields: React.FC = ({ valu - {/* Watermark */} + {/* Watermark — MODE only. The look (logo/position/opacity/style/size) + lives in Settings → Slideshow, so it isn't duplicated here. */}

- {t('slideshow.watermarkDescription', 'Overlay a white, semi-transparent logo in a corner (like a TV station ident).')} + {t('slideshow.watermarkModeHint', 'The logo, position, opacity, style and size are configured under Settings → Slideshow.')}

- - {value.watermark === 'on' && ( -
-
- - set({ watermark_source: s })} - eventLogoUrl={eventLogoUrl} - /> -
-
-
- - -
-
- - set({ watermark_opacity: Math.min(100, Math.max(0, parseInt(e.target.value, 10) || 0)) })} - className={inputClass} - /> -
-
- - -
-
-
- )}
); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 48d49bfd..0ff8fc8e 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3195,6 +3195,8 @@ }, "watermarkOpacityLabel": "Deckkraft (%)", "watermarkStyleLabel": "Logo-Darstellung", + "watermarkSizeLabel": "Größe (% des Bildschirms)", + "watermarkModeHint": "Logo, Position, Deckkraft, Darstellung und Größe werden unter Einstellungen → Diashow konfiguriert.", "watermarkStyle": { "white": "Weiß (einfärben)", "original": "Originalfarben" diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 74f84f66..c0933563 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3219,6 +3219,8 @@ }, "watermarkOpacityLabel": "Opacity (%)", "watermarkStyleLabel": "Logo style", + "watermarkSizeLabel": "Size (% of screen)", + "watermarkModeHint": "The logo, position, opacity, style and size are configured under Settings → Slideshow.", "watermarkStyle": { "white": "White (recolor)", "original": "Original colors" diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 49cc26bf..9b5acba0 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -2166,17 +2166,12 @@ export const EventDetailsPage: React.FC = () => { eventId={event.id} slug={event.slug} isArchived={event.is_archived} - eventLogoUrl={event.hero_logo_url} initial={{ show_share_token: event.show_share_token, show_interval_ms: event.show_interval_ms, show_transition: event.show_transition, show_transition_ms: event.show_transition_ms, show_watermark: event.show_watermark, - show_watermark_source: event.show_watermark_source, - show_watermark_position: event.show_watermark_position, - show_watermark_opacity: event.show_watermark_opacity, - show_watermark_style: event.show_watermark_style, show_colorfilter: event.show_colorfilter, }} onChanged={() => refetchEvent()} diff --git a/frontend/src/pages/gallery/SlideshowPage.tsx b/frontend/src/pages/gallery/SlideshowPage.tsx index ee1fcb84..df7c8122 100644 --- a/frontend/src/pages/gallery/SlideshowPage.tsx +++ b/frontend/src/pages/gallery/SlideshowPage.tsx @@ -423,8 +423,10 @@ export function SlideshowPage() { ...watermarkCorner(settings.watermark!.position), width: 'auto', height: 'auto', - maxWidth: '16vw', - maxHeight: '14vh', + // Size = % of the viewport's shorter side (vmin), so the logo + // scales the same on any screen. Configured globally. + maxWidth: `${settings.watermark!.size ?? 12}vmin`, + maxHeight: `${settings.watermark!.size ?? 12}vmin`, opacity: Math.min(1, Math.max(0, (settings.watermark!.opacity ?? 60) / 100)), // 'white' recolors the logo white (dark/transparent marks); 'original' // leaves a boxed/colored logo as-is so it doesn't become a white blob. diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts index 7eeb42a4..6edd52e9 100644 --- a/frontend/src/services/events.service.ts +++ b/frontend/src/services/events.service.ts @@ -157,10 +157,6 @@ export const eventsService = { show_transition?: string; show_transition_ms?: number; show_watermark?: boolean | null; - show_watermark_source?: string; - show_watermark_position?: string; - show_watermark_opacity?: number; - show_watermark_style?: string; show_colorfilter?: string; } ): Promise> { diff --git a/frontend/src/services/slideshow.service.ts b/frontend/src/services/slideshow.service.ts index 4562699c..ada8605e 100644 --- a/frontend/src/services/slideshow.service.ts +++ b/frontend/src/services/slideshow.service.ts @@ -23,15 +23,15 @@ export const SLIDESHOW_WATERMARK_POSITIONS: SlideshowWatermarkPosition[] = ['top // 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. +// +// 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. export interface SlideshowStyle { interval_ms: number; transition: SlideshowTransition; transition_ms: number; watermark: SlideshowWatermarkMode; - watermark_source: SlideshowWatermarkSource; - watermark_position: SlideshowWatermarkPosition; - watermark_opacity: number; - watermark_style: SlideshowWatermarkStyle; colorfilter: SlideshowColorFilter; } @@ -40,21 +40,20 @@ export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = { transition: 'crossfade', transition_ms: 800, watermark: 'inherit', - watermark_source: 'logo', - watermark_position: 'bottom-right', - watermark_opacity: 60, - watermark_style: 'white', colorfilter: 'none', }; -// Global slideshow defaults (admin Settings → Slideshow). The per-event -// watermark mode 'inherit' falls back to these. +// Global slideshow defaults (admin Settings → Slideshow). The single source of +// truth for the watermark look; the per-event watermark mode 'inherit'/'on' +// renders with exactly these. export interface SlideshowGlobalDefaults { slideshow_watermark_enabled: boolean; slideshow_watermark_source: SlideshowWatermarkSource; slideshow_watermark_position: SlideshowWatermarkPosition; slideshow_watermark_opacity: number; slideshow_watermark_style: SlideshowWatermarkStyle; + // Logo size as a % of the viewport's shorter side. + slideshow_watermark_size: number; } // Resolved watermark the kiosk renders (logo URL already resolved server-side). @@ -63,6 +62,7 @@ export interface SlideshowWatermark { position: SlideshowWatermarkPosition; opacity: number; style: SlideshowWatermarkStyle; + size: number; } export interface SlideshowSettings { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index aec8c899..ce5895f7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -74,11 +74,9 @@ export interface Event { show_interval_ms?: number; show_transition?: 'crossfade' | 'cut' | 'slide' | 'kenburns' | 'dipwhite' | 'dipblack'; show_transition_ms?: number; - show_watermark?: boolean; - show_watermark_source?: 'branding' | 'event'; - show_watermark_position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; - show_watermark_opacity?: number; - show_watermark_style?: 'white' | 'original'; + // Watermark MODE only (null=inherit global / true / false). The look lives + // globally in Settings → Slideshow, not per event. + show_watermark?: boolean | null; show_colorfilter?: 'none' | 'bw' | 'sepia' | 'warm' | 'cool' | 'vignette'; // Default photo sort order default_photo_sort?: string;