feat(slideshow): add image fit setting (fill vs black bars)

object-fit was hardcoded to 'cover', which crops portrait photos heavily. Add a
global `slideshow_fit` setting (Settings -> Slideshow): 'cover' fills + crops,
'contain' shows the whole image with black bars (no crop). Default 'cover'
(unchanged). Stored in app_settings (no migration), resolved server-side into
the slideshow settings + /state poll so a running projector picks it up live.
This commit is contained in:
Luca
2026-06-20 12:06:34 +02:00
parent 759784a4d1
commit b5c73e05bd
7 changed files with 65 additions and 11 deletions
+3
View File
@@ -299,6 +299,9 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r
const push = (key, value) => updates.push({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'slideshow' }); const push = (key, value) => updates.push({ setting_key: key, setting_value: JSON.stringify(value), setting_type: 'slideshow' });
const has = (k) => Object.prototype.hasOwnProperty.call(req.body, k); const has = (k) => Object.prototype.hasOwnProperty.call(req.body, k);
if (has('slideshow_fit')) {
push('slideshow_fit', req.body.slideshow_fit === 'contain' ? 'contain' : 'cover');
}
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';
+5
View File
@@ -301,11 +301,16 @@ async function slideshowSettings(event) {
watermark = { url, position: position || 'bottom-right', opacity: opacity ?? 60, style: style || 'white', size: size ?? 12 }; watermark = { url, position: position || 'bottom-right', opacity: opacity ?? 60, style: style || 'white', size: size ?? 12 };
} }
} }
// Image fit is a global setting (Settings → Slideshow): 'cover' fills + crops,
// 'contain' shows the whole image with black bars (no crop).
const fitRaw = await getAppSetting('slideshow_fit', 'cover');
const fit = fitRaw === 'contain' ? 'contain' : 'cover';
return { return {
interval_ms: event.show_interval_ms || 5000, interval_ms: event.show_interval_ms || 5000,
transition: event.show_transition || 'crossfade', transition: event.show_transition || 'crossfade',
transition_ms: event.show_transition_ms || 800, transition_ms: event.show_transition_ms || 800,
colorfilter: event.show_colorfilter || 'none', colorfilter: event.show_colorfilter || 'none',
fit,
watermark, watermark,
}; };
} }
@@ -16,11 +16,13 @@ import { settingsService } from '../../services/settings.service';
import { import {
SLIDESHOW_WATERMARK_POSITIONS, SLIDESHOW_WATERMARK_POSITIONS,
SLIDESHOW_WATERMARK_STYLES, SLIDESHOW_WATERMARK_STYLES,
SLIDESHOW_FITS,
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_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',
@@ -43,6 +45,7 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => {
settingsService.getSettingsByType('slideshow').then((s) => { settingsService.getSettingsByType('slideshow').then((s) => {
if (cancelled || !s) return; if (cancelled || !s) return;
setVal({ setVal({
slideshow_fit: s.slideshow_fit ?? DEFAULTS.slideshow_fit,
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,
@@ -70,13 +73,33 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => {
<Card padding="md" className="mb-6"> <Card padding="md" className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2"> <h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
<MonitorPlay className="w-5 h-5" /> <MonitorPlay className="w-5 h-5" />
{t('slideshow.globalTitle', 'Global slideshow watermark')} {t('slideshow.globalTitle', 'Global slideshow settings')}
</h2> </h2>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4"> <p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
{t('slideshow.globalDescription', 'Default logo watermark for every slideshow. Individual events can override this on or off.')} {t('slideshow.globalDescription', 'Defaults for every slideshow. Events can override the watermark on or off.')}
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
{/* Image fit */}
<div>
<label className={labelClass}>{t('slideshow.fitLabel', 'Image fit')}</label>
<select
value={val.slideshow_fit}
onChange={(e) => setVal({ ...val, slideshow_fit: e.target.value as SlideshowGlobalDefaults['slideshow_fit'] })}
className={inputClass}
>
{SLIDESHOW_FITS.map((f) => (
<option key={f} value={f}>
{t(`slideshow.fit.${f}`, f === 'contain' ? 'Black bars (no crop)' : 'Fill screen (crop)')}
</option>
))}
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('slideshow.fitHint', '"Fill" crops to fill the screen; "Black bars" shows the whole photo — better for portrait images.')}
</p>
</div>
<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
type="checkbox" type="checkbox"
@@ -159,6 +182,7 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => {
</div> </div>
</div> </div>
)} )}
</div>
<Button variant="outline" size="md" leftIcon={<Save className="w-4 h-4" />} onClick={save} isLoading={saving}> <Button variant="outline" size="md" leftIcon={<Save className="w-4 h-4" />} onClick={save} isLoading={saving}>
{t('common.save', 'Save')} {t('common.save', 'Save')}
+8 -2
View File
@@ -3206,8 +3206,14 @@
"on": "An", "on": "An",
"off": "Aus" "off": "Aus"
}, },
"globalTitle": "Globales Diashow-Wasserzeichen", "globalTitle": "Globale Diashow-Einstellungen",
"globalDescription": "Standard-Logo-Wasserzeichen für jede Diashow. Einzelne Events können es überschreiben (an/aus).", "globalDescription": "Vorgaben für jede Diashow. Events können das Wasserzeichen überschreiben (an/aus).",
"fitLabel": "Bildanpassung",
"fit": {
"cover": "Bildschirm füllen (beschneiden)",
"contain": "Schwarze Balken (kein Beschnitt)"
},
"fitHint": "„Füllen“ beschneidet das Bild zum Ausfüllen; „Schwarze Balken“ zeigt das ganze Foto besser für Hochformat.",
"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",
+8 -2
View File
@@ -3230,8 +3230,14 @@
"on": "On", "on": "On",
"off": "Off" "off": "Off"
}, },
"globalTitle": "Global slideshow watermark", "globalTitle": "Global slideshow settings",
"globalDescription": "Default logo watermark for every slideshow. Individual events can override this on or off.", "globalDescription": "Defaults for every slideshow. Events can override the watermark on or off.",
"fitLabel": "Image fit",
"fit": {
"cover": "Fill screen (crop)",
"contain": "Black bars (no crop)"
},
"fitHint": "\"Fill\" crops to fill the screen; \"Black bars\" shows the whole photo — better for portrait images.",
"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",
+8 -5
View File
@@ -13,6 +13,7 @@ const DEFAULT_SETTINGS: SlideshowSettings = {
transition: 'crossfade', transition: 'crossfade',
transition_ms: 800, transition_ms: 800,
colorfilter: 'none', colorfilter: 'none',
fit: 'cover',
watermark: null, watermark: null,
}; };
@@ -210,6 +211,7 @@ export function SlideshowPage() {
transition: state.transition, transition: state.transition,
transition_ms: state.transition_ms, transition_ms: state.transition_ms,
colorfilter: state.colorfilter, colorfilter: state.colorfilter,
fit: state.fit,
watermark: state.watermark, watermark: state.watermark,
}; };
if (JSON.stringify(next) !== JSON.stringify({ if (JSON.stringify(next) !== JSON.stringify({
@@ -217,6 +219,7 @@ export function SlideshowPage() {
transition: prev.transition, transition: prev.transition,
transition_ms: prev.transition_ms, transition_ms: prev.transition_ms,
colorfilter: prev.colorfilter, colorfilter: prev.colorfilter,
fit: prev.fit,
watermark: prev.watermark, watermark: prev.watermark,
})) { })) {
setSettings(next); setSettings(next);
@@ -288,13 +291,13 @@ export function SlideshowPage() {
const imgStyle = (buf: 0 | 1): React.CSSProperties => { const imgStyle = (buf: 0 | 1): React.CSSProperties => {
const isActive = active === buf; const isActive = active === buf;
const style: React.CSSProperties = { const style: React.CSSProperties = {
// Fill the whole viewport. `maxWidth/maxHeight` alone left the <img> at // Pin to the full viewport (maxWidth/maxHeight alone left the <img> at the
// the photo's intrinsic size (e.g. the 1920px preview), so it never // photo's intrinsic ≤1920px size, so it never scaled up to the projector).
// scaled up to the projector — leaving black bars all round. Pin to the // `fit` decides the rest: 'cover' fills + crops, 'contain' letterboxes the
// full container and let object-fit do the scaling. // whole image with black bars (no crop — kinder to portrait photos).
width: '100%', width: '100%',
height: '100%', height: '100%',
objectFit: 'cover', objectFit: settings.fit === 'contain' ? 'contain' : 'cover',
filter: imageFilter(settings.colorfilter), filter: imageFilter(settings.colorfilter),
}; };
if (settings.transition === 'kenburns' && isActive) { if (settings.transition === 'kenburns' && isActive) {
@@ -2,6 +2,10 @@ import { api } from '../config/api';
export type SlideshowTransition = 'crossfade' | 'cut' | 'slide' | 'kenburns' | 'dipwhite' | 'dipblack'; export type SlideshowTransition = 'crossfade' | 'cut' | 'slide' | 'kenburns' | 'dipwhite' | 'dipblack';
export type SlideshowColorFilter = 'none' | 'bw' | 'sepia' | 'warm' | 'cool' | 'vignette'; export type SlideshowColorFilter = 'none' | 'bw' | 'sepia' | 'warm' | 'cool' | 'vignette';
// How each slide fills the screen: 'cover' = fill, crop to aspect; 'contain' =
// whole image with black bars (no crop — best for mixed portrait/landscape).
export type SlideshowFit = 'cover' | 'contain';
export const SLIDESHOW_FITS: SlideshowFit[] = ['cover', 'contain'];
// Which logo the watermark overlays. The first three are branding assets the // Which logo the watermark overlays. The first three are branding assets the
// admin uploads in Settings → Branding; 'event' is the event's own hero logo. // admin uploads in Settings → Branding; 'event' is the event's own hero logo.
export type SlideshowWatermarkSource = 'logo' | 'logo_dark' | 'favicon' | 'event'; export type SlideshowWatermarkSource = 'logo' | 'logo_dark' | 'favicon' | 'event';
@@ -47,6 +51,8 @@ export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = {
// truth for the watermark look; the per-event watermark mode 'inherit'/'on' // truth for the watermark look; the per-event watermark mode 'inherit'/'on'
// renders with exactly these. // renders with exactly these.
export interface SlideshowGlobalDefaults { export interface SlideshowGlobalDefaults {
// How slides fill the screen (fill+crop vs letterbox/black bars).
slideshow_fit: SlideshowFit;
slideshow_watermark_enabled: boolean; slideshow_watermark_enabled: boolean;
slideshow_watermark_source: SlideshowWatermarkSource; slideshow_watermark_source: SlideshowWatermarkSource;
slideshow_watermark_position: SlideshowWatermarkPosition; slideshow_watermark_position: SlideshowWatermarkPosition;
@@ -70,6 +76,7 @@ export interface SlideshowSettings {
transition: SlideshowTransition; transition: SlideshowTransition;
transition_ms: number; transition_ms: number;
colorfilter: SlideshowColorFilter; colorfilter: SlideshowColorFilter;
fit: SlideshowFit;
watermark: SlideshowWatermark | null; watermark: SlideshowWatermark | null;
} }