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:
@@ -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';
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user