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:
@@ -16,11 +16,13 @@ import { settingsService } from '../../services/settings.service';
|
||||
import {
|
||||
SLIDESHOW_WATERMARK_POSITIONS,
|
||||
SLIDESHOW_WATERMARK_STYLES,
|
||||
SLIDESHOW_FITS,
|
||||
type SlideshowGlobalDefaults,
|
||||
} from '../../services/slideshow.service';
|
||||
import { WatermarkSourcePicker } from './WatermarkSourcePicker';
|
||||
|
||||
const DEFAULTS: SlideshowGlobalDefaults = {
|
||||
slideshow_fit: 'cover',
|
||||
slideshow_watermark_enabled: false,
|
||||
slideshow_watermark_source: 'logo',
|
||||
slideshow_watermark_position: 'bottom-right',
|
||||
@@ -43,6 +45,7 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => {
|
||||
settingsService.getSettingsByType('slideshow').then((s) => {
|
||||
if (cancelled || !s) return;
|
||||
setVal({
|
||||
slideshow_fit: s.slideshow_fit ?? DEFAULTS.slideshow_fit,
|
||||
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,
|
||||
@@ -70,13 +73,33 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => {
|
||||
<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">
|
||||
<MonitorPlay className="w-5 h-5" />
|
||||
{t('slideshow.globalTitle', 'Global slideshow watermark')}
|
||||
{t('slideshow.globalTitle', 'Global slideshow settings')}
|
||||
</h2>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -159,6 +182,7 @@ export const SlideshowGlobalDefaultsCard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="md" leftIcon={<Save className="w-4 h-4" />} onClick={save} isLoading={saving}>
|
||||
{t('common.save', 'Save')}
|
||||
|
||||
@@ -3206,8 +3206,14 @@
|
||||
"on": "An",
|
||||
"off": "Aus"
|
||||
},
|
||||
"globalTitle": "Globales Diashow-Wasserzeichen",
|
||||
"globalDescription": "Standard-Logo-Wasserzeichen für jede Diashow. Einzelne Events können es überschreiben (an/aus).",
|
||||
"globalTitle": "Globale Diashow-Einstellungen",
|
||||
"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",
|
||||
"liveHint": "Änderungen werden in einer laufenden Diashow innerhalb weniger Sekunden übernommen – der Link muss nicht neu erstellt werden.",
|
||||
"saveSettings": "Diashow-Einstellungen speichern",
|
||||
|
||||
@@ -3230,8 +3230,14 @@
|
||||
"on": "On",
|
||||
"off": "Off"
|
||||
},
|
||||
"globalTitle": "Global slideshow watermark",
|
||||
"globalDescription": "Default logo watermark for every slideshow. Individual events can override this on or off.",
|
||||
"globalTitle": "Global slideshow settings",
|
||||
"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",
|
||||
"liveHint": "Changes apply to a running slideshow within a few seconds — no need to regenerate the link.",
|
||||
"saveSettings": "Save slideshow settings",
|
||||
|
||||
@@ -13,6 +13,7 @@ const DEFAULT_SETTINGS: SlideshowSettings = {
|
||||
transition: 'crossfade',
|
||||
transition_ms: 800,
|
||||
colorfilter: 'none',
|
||||
fit: 'cover',
|
||||
watermark: null,
|
||||
};
|
||||
|
||||
@@ -210,6 +211,7 @@ export function SlideshowPage() {
|
||||
transition: state.transition,
|
||||
transition_ms: state.transition_ms,
|
||||
colorfilter: state.colorfilter,
|
||||
fit: state.fit,
|
||||
watermark: state.watermark,
|
||||
};
|
||||
if (JSON.stringify(next) !== JSON.stringify({
|
||||
@@ -217,6 +219,7 @@ export function SlideshowPage() {
|
||||
transition: prev.transition,
|
||||
transition_ms: prev.transition_ms,
|
||||
colorfilter: prev.colorfilter,
|
||||
fit: prev.fit,
|
||||
watermark: prev.watermark,
|
||||
})) {
|
||||
setSettings(next);
|
||||
@@ -288,13 +291,13 @@ export function SlideshowPage() {
|
||||
const imgStyle = (buf: 0 | 1): React.CSSProperties => {
|
||||
const isActive = active === buf;
|
||||
const style: React.CSSProperties = {
|
||||
// Fill the whole viewport. `maxWidth/maxHeight` alone left the <img> at
|
||||
// the photo's intrinsic size (e.g. the 1920px preview), so it never
|
||||
// scaled up to the projector — leaving black bars all round. Pin to the
|
||||
// full container and let object-fit do the scaling.
|
||||
// Pin to the full viewport (maxWidth/maxHeight alone left the <img> at the
|
||||
// photo's intrinsic ≤1920px size, so it never scaled up to the projector).
|
||||
// `fit` decides the rest: 'cover' fills + crops, 'contain' letterboxes the
|
||||
// whole image with black bars (no crop — kinder to portrait photos).
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectFit: settings.fit === 'contain' ? 'contain' : 'cover',
|
||||
filter: imageFilter(settings.colorfilter),
|
||||
};
|
||||
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 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
|
||||
// admin uploads in Settings → Branding; 'event' is the event's own hero logo.
|
||||
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'
|
||||
// renders with exactly these.
|
||||
export interface SlideshowGlobalDefaults {
|
||||
// How slides fill the screen (fill+crop vs letterbox/black bars).
|
||||
slideshow_fit: SlideshowFit;
|
||||
slideshow_watermark_enabled: boolean;
|
||||
slideshow_watermark_source: SlideshowWatermarkSource;
|
||||
slideshow_watermark_position: SlideshowWatermarkPosition;
|
||||
@@ -70,6 +76,7 @@ export interface SlideshowSettings {
|
||||
transition: SlideshowTransition;
|
||||
transition_ms: number;
|
||||
colorfilter: SlideshowColorFilter;
|
||||
fit: SlideshowFit;
|
||||
watermark: SlideshowWatermark | null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user