feat(slideshow): admin ui for live slideshow
- per-event Live Slideshow card on the event detail page: generate/copy/ regenerate/disable the share link + live style (transition, timing, color filter, watermark). - shared SlideshowStyleFields, reused by the per-event card and the per-event- type preset section in the Edit Event Type modal. - global watermark default card on the Event Types page (Settings -> slideshow). - WatermarkSourcePicker: visible logo tiles with previews (light logo / dark-mode logo / favicon / event logo) instead of a blind dropdown. - watermark mode tri-state (inherit/on/off) + white-vs-original style. - supporting service methods + Event/EventType types.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* <SlideshowGlobalDefaultsCard>
|
||||
*
|
||||
* Global default for the Live Slideshow watermark (the white, semi-transparent
|
||||
* corner logo). Every event whose watermark mode is "Use global default"
|
||||
* (events.show_watermark = NULL) follows this; events can still override on/off
|
||||
* per event. Persisted via PUT /admin/settings/slideshow (app_settings,
|
||||
* type 'slideshow').
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { MonitorPlay, Save } from 'lucide-react';
|
||||
import { Button, Card } from '../common';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import {
|
||||
SLIDESHOW_WATERMARK_POSITIONS,
|
||||
SLIDESHOW_WATERMARK_STYLES,
|
||||
type SlideshowGlobalDefaults,
|
||||
} from '../../services/slideshow.service';
|
||||
import { WatermarkSourcePicker } from './WatermarkSourcePicker';
|
||||
|
||||
const DEFAULTS: SlideshowGlobalDefaults = {
|
||||
slideshow_watermark_enabled: false,
|
||||
slideshow_watermark_source: 'logo',
|
||||
slideshow_watermark_position: 'bottom-right',
|
||||
slideshow_watermark_opacity: 60,
|
||||
slideshow_watermark_style: 'white',
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
'w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm';
|
||||
const labelClass = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
|
||||
|
||||
export const SlideshowGlobalDefaultsCard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [val, setVal] = useState<SlideshowGlobalDefaults>(DEFAULTS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
settingsService.getSettingsByType('slideshow').then((s) => {
|
||||
if (cancelled || !s) return;
|
||||
setVal({
|
||||
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,
|
||||
slideshow_watermark_opacity: s.slideshow_watermark_opacity ?? DEFAULTS.slideshow_watermark_opacity,
|
||||
slideshow_watermark_style: s.slideshow_watermark_style ?? DEFAULTS.slideshow_watermark_style,
|
||||
});
|
||||
}).catch(() => { /* keep defaults */ });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await settingsService.updateSlideshowDefaults(val);
|
||||
toast.success(t('slideshow.defaultsSaved', 'Slideshow defaults saved'));
|
||||
} catch {
|
||||
toast.error(t('common.error', 'Something went wrong'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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')}
|
||||
</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.')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
checked={val.slideshow_watermark_enabled}
|
||||
onChange={(e) => setVal({ ...val, slideshow_watermark_enabled: e.target.checked })}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
{t('slideshow.watermarkToggle', 'Logo watermark')}
|
||||
</span>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('slideshow.watermarkDescription', 'Overlay a white, semi-transparent logo in a corner (like a TV station ident).')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{val.slideshow_watermark_enabled && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.watermarkSourceLabel', 'Logo')}</label>
|
||||
<WatermarkSourcePicker
|
||||
value={val.slideshow_watermark_source}
|
||||
onChange={(s) => setVal({ ...val, slideshow_watermark_source: s })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.watermarkPositionLabel', 'Position')}</label>
|
||||
<select
|
||||
value={val.slideshow_watermark_position}
|
||||
onChange={(e) => setVal({ ...val, slideshow_watermark_position: e.target.value as SlideshowGlobalDefaults['slideshow_watermark_position'] })}
|
||||
className={inputClass}
|
||||
>
|
||||
{SLIDESHOW_WATERMARK_POSITIONS.map((pos) => (
|
||||
<option key={pos} value={pos}>
|
||||
{t(`slideshow.watermarkPosition.${pos}`, pos)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.watermarkOpacityLabel', 'Opacity (%)')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={val.slideshow_watermark_opacity}
|
||||
onChange={(e) => setVal({ ...val, slideshow_watermark_opacity: Math.min(100, Math.max(0, parseInt(e.target.value, 10) || 0)) })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.watermarkStyleLabel', 'Logo style')}</label>
|
||||
<select
|
||||
value={val.slideshow_watermark_style}
|
||||
onChange={(e) => setVal({ ...val, slideshow_watermark_style: e.target.value as SlideshowGlobalDefaults['slideshow_watermark_style'] })}
|
||||
className={inputClass}
|
||||
>
|
||||
{SLIDESHOW_WATERMARK_STYLES.map((st) => (
|
||||
<option key={st} value={st}>
|
||||
{t(`slideshow.watermarkStyle.${st}`, st === 'original' ? 'Original colors' : 'White')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="md" leftIcon={<Save className="w-4 h-4" />} onClick={save} isLoading={saving}>
|
||||
{t('common.save', 'Save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SlideshowGlobalDefaultsCard;
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* <SlideshowSettingsCard>
|
||||
*
|
||||
* Per-event "Live Slideshow" ("Diashow") admin surface (migrations 137/138).
|
||||
* A token-only fullscreen kiosk link for live events that auto-picks-up new
|
||||
* uploads while it runs. Mounted once on the EventDetailsPage; admin can:
|
||||
* - Generate the slideshow link on demand (mints show_share_token)
|
||||
* - Copy / Regenerate (rotate, kills the old link) / Disable it
|
||||
* - Tune the LIVE style (transition, timing, color filter, logo watermark)
|
||||
* via the shared <SlideshowStyleFields>; a running projector picks the
|
||||
* changes up within a few seconds via the show page's settings poll.
|
||||
*
|
||||
* New events inherit their initial style from the event TYPE preset; this
|
||||
* card edits the per-event override. Settings save through
|
||||
* PATCH /api/admin/events/:id/slideshow; link actions through
|
||||
* POST .../slideshow/{generate,disable}.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { MonitorPlay, Copy, CheckCircle, RotateCw, Trash2, Save } from 'lucide-react';
|
||||
import { Button, Card } from '../common';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service';
|
||||
import { SlideshowStyleFields } from './SlideshowStyleFields';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Tri-state: null/undefined → inherit the global default; true → on; false → off.
|
||||
function watermarkMode(v: boolean | null | undefined): SlideshowStyle['watermark'] {
|
||||
if (v === null || v === undefined) return 'inherit';
|
||||
return v ? 'on' : 'off';
|
||||
}
|
||||
|
||||
function styleFromInitial(initial: SlideshowSettingsCardProps['initial']): SlideshowStyle {
|
||||
return {
|
||||
interval_ms: initial.show_interval_ms ?? DEFAULT_SLIDESHOW_STYLE.interval_ms,
|
||||
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<SlideshowSettingsCardProps> = ({
|
||||
eventId, slug, isArchived, eventLogoUrl, initial, onChanged,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [token, setToken] = useState<string | null>(initial.show_share_token ?? null);
|
||||
const [style, setStyle] = useState<SlideshowStyle>(() => styleFromInitial(initial));
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const link = token ? `${window.location.origin}/gallery/${slug}/show/${token}` : '';
|
||||
|
||||
const generate = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await eventsService.generateSlideshowLink(eventId);
|
||||
setToken(res.show_share_token);
|
||||
toast.success(t('slideshow.linkGenerated', 'Slideshow link generated'));
|
||||
onChanged?.();
|
||||
} catch {
|
||||
toast.error(t('common.error', 'Something went wrong'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disable = async () => {
|
||||
if (!confirm(t('slideshow.disableConfirm', 'Disable this slideshow link? The current link will stop working.'))) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await eventsService.disableSlideshowLink(eventId);
|
||||
setToken(null);
|
||||
toast.success(t('slideshow.linkDisabled', 'Slideshow link disabled'));
|
||||
onChanged?.();
|
||||
} catch {
|
||||
toast.error(t('common.error', 'Something went wrong'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
} catch {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = link;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await eventsService.updateSlideshowSettings(eventId, {
|
||||
show_interval_ms: style.interval_ms,
|
||||
show_transition: style.transition,
|
||||
show_transition_ms: style.transition_ms,
|
||||
// Tri-state → null (inherit global) / true / false.
|
||||
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'));
|
||||
onChanged?.();
|
||||
} catch {
|
||||
toast.error(t('common.error', 'Something went wrong'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
'w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm';
|
||||
const labelClass = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<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.adminTitle', 'Live Slideshow')}
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
|
||||
{t('slideshow.adminDescription', 'A separate fullscreen link for projectors at live events. It shows all published photos and automatically picks up new uploads while running.')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!token ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<MonitorPlay className="w-4 h-4" />}
|
||||
onClick={generate}
|
||||
isLoading={busy}
|
||||
disabled={isArchived}
|
||||
>
|
||||
{t('slideshow.generateLink', 'Generate slideshow link')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.linkLabel', 'Slideshow link')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" value={link} readOnly className={`flex-1 ${inputClass}`} />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={copied ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? t('events.copied', 'Copied') : t('events.copy', 'Copy')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
leftIcon={<RotateCw className="w-3.5 h-3.5" />}
|
||||
onClick={generate}
|
||||
disabled={busy || isArchived}
|
||||
>
|
||||
{t('slideshow.regenerate', 'Regenerate')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-red-600 dark:text-red-400"
|
||||
leftIcon={<Trash2 className="w-3.5 h-3.5" />}
|
||||
onClick={disable}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('slideshow.disable', 'Disable')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live style settings */}
|
||||
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<SlideshowStyleFields value={style} onChange={setStyle} eventLogoUrl={eventLogoUrl} />
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('slideshow.liveHint', 'Changes apply to a running slideshow within a few seconds — no need to regenerate the link.')}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="md"
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
onClick={saveSettings}
|
||||
isLoading={saving}
|
||||
>
|
||||
{t('slideshow.saveSettings', 'Save slideshow settings')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SlideshowSettingsCard;
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* <SlideshowStyleFields>
|
||||
*
|
||||
* Shared, controlled editor for a slideshow's visual style — transition,
|
||||
* timing, watermark and color filter. Used in two places:
|
||||
* - SlideshowSettingsCard (per-event live settings)
|
||||
* - EventTypeModal (per-event-type preset that new events inherit)
|
||||
*
|
||||
* Purely presentational: it owns no persistence, just renders the controls
|
||||
* for a SlideshowStyle value and calls onChange with the next value.
|
||||
*/
|
||||
import React from 'react';
|
||||
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 =
|
||||
'w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm';
|
||||
const labelClass = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
|
||||
|
||||
const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ value, onChange, eventLogoUrl }) => {
|
||||
const { t } = useTranslation();
|
||||
const set = (patch: Partial<SlideshowStyle>) => onChange({ ...value, ...patch });
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Transition + timing */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.transitionLabel', 'Transition')}</label>
|
||||
<select
|
||||
value={value.transition}
|
||||
onChange={(e) => set({ transition: e.target.value as SlideshowStyle['transition'] })}
|
||||
className={inputClass}
|
||||
>
|
||||
{SLIDESHOW_TRANSITIONS.map((tr) => (
|
||||
<option key={tr} value={tr}>
|
||||
{t(`slideshow.transition.${tr}`, titleCase(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(value.interval_ms / 1000)}
|
||||
onChange={(e) => set({ 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={value.transition_ms}
|
||||
onChange={(e) => set({ transition_ms: Math.min(5000, Math.max(100, parseInt(e.target.value, 10) || 800)) })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color filter */}
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.colorfilterLabel', 'Color filter')}</label>
|
||||
<select
|
||||
value={value.colorfilter}
|
||||
onChange={(e) => set({ colorfilter: e.target.value as SlideshowStyle['colorfilter'] })}
|
||||
className={inputClass}
|
||||
>
|
||||
{SLIDESHOW_COLORFILTERS.map((cf) => (
|
||||
<option key={cf} value={cf}>
|
||||
{t(`slideshow.colorfilter.${cf}`, titleCase(cf))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Watermark */}
|
||||
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<label className={labelClass}>{t('slideshow.watermarkToggle', 'Logo watermark')}</label>
|
||||
<select
|
||||
value={value.watermark}
|
||||
onChange={(e) => set({ watermark: e.target.value as SlideshowStyle['watermark'] })}
|
||||
className={inputClass}
|
||||
>
|
||||
{SLIDESHOW_WATERMARK_MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{t(`slideshow.watermarkMode.${m}`, m === 'inherit' ? 'Use global default' : m === 'on' ? 'On' : 'Off')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('slideshow.watermarkDescription', 'Overlay a white, semi-transparent logo in a corner (like a TV station ident).')}
|
||||
</p>
|
||||
|
||||
{value.watermark === 'on' && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.watermarkSourceLabel', 'Logo')}</label>
|
||||
<WatermarkSourcePicker
|
||||
value={value.watermark_source}
|
||||
onChange={(s) => set({ watermark_source: s })}
|
||||
eventLogoUrl={eventLogoUrl}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.watermarkPositionLabel', 'Position')}</label>
|
||||
<select
|
||||
value={value.watermark_position}
|
||||
onChange={(e) => set({ watermark_position: e.target.value as SlideshowStyle['watermark_position'] })}
|
||||
className={inputClass}
|
||||
>
|
||||
{SLIDESHOW_WATERMARK_POSITIONS.map((pos) => (
|
||||
<option key={pos} value={pos}>
|
||||
{t(`slideshow.watermarkPosition.${pos}`, pos)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.watermarkOpacityLabel', 'Opacity (%)')}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={value.watermark_opacity}
|
||||
onChange={(e) => set({ watermark_opacity: Math.min(100, Math.max(0, parseInt(e.target.value, 10) || 0)) })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.watermarkStyleLabel', 'Logo style')}</label>
|
||||
<select
|
||||
value={value.watermark_style}
|
||||
onChange={(e) => set({ watermark_style: e.target.value as SlideshowStyle['watermark_style'] })}
|
||||
className={inputClass}
|
||||
>
|
||||
{SLIDESHOW_WATERMARK_STYLES.map((st) => (
|
||||
<option key={st} value={st}>
|
||||
{t(`slideshow.watermarkStyle.${st}`, st === 'original' ? 'Original colors' : 'White')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SlideshowStyleFields;
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* <WatermarkSourcePicker>
|
||||
*
|
||||
* Visible picker for the slideshow watermark logo. Instead of a blind dropdown
|
||||
* the admin sees each branding asset (light logo, dark-mode logo, favicon) and
|
||||
* the event's own logo, and clicks the one to overlay. Previews render on a
|
||||
* transparency checkerboard so both light and dark marks are visible.
|
||||
*
|
||||
* URLs come from the public settings (branding assets) + an optional per-event
|
||||
* logo. A source with no configured logo still selects, but shows a "not set"
|
||||
* placeholder so the admin knows to upload one.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import type { SlideshowWatermarkSource } from '../../services/slideshow.service';
|
||||
|
||||
export interface WatermarkSourcePickerProps {
|
||||
value: SlideshowWatermarkSource;
|
||||
onChange: (s: SlideshowWatermarkSource) => void;
|
||||
/** Per-event hero logo, previewed for the 'event' source when available. */
|
||||
eventLogoUrl?: string | null;
|
||||
}
|
||||
|
||||
// Classic transparency checkerboard so white and dark logos both show up.
|
||||
const CHECKER: React.CSSProperties = {
|
||||
backgroundImage:
|
||||
'linear-gradient(45deg, #c8c8c8 25%, transparent 25%), linear-gradient(-45deg, #c8c8c8 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #c8c8c8 75%), linear-gradient(-45deg, transparent 75%, #c8c8c8 75%)',
|
||||
backgroundSize: '12px 12px',
|
||||
backgroundPosition: '0 0, 0 6px, 6px -6px, -6px 0',
|
||||
backgroundColor: '#f0f0f0',
|
||||
};
|
||||
|
||||
export const WatermarkSourcePicker: React.FC<WatermarkSourcePickerProps> = ({ value, onChange, eventLogoUrl }) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: ps } = usePublicSettings();
|
||||
|
||||
const options: Array<{ key: SlideshowWatermarkSource; label: string; url?: string | null }> = [
|
||||
{ key: 'logo', label: t('slideshow.watermarkSource.logo', 'Light logo'), url: ps?.branding_logo_url },
|
||||
{ key: 'logo_dark', label: t('slideshow.watermarkSource.logo_dark', 'Dark-mode logo'), url: ps?.branding_logo_url_dark },
|
||||
{ key: 'favicon', label: t('slideshow.watermarkSource.favicon', 'Favicon'), url: ps?.branding_favicon_url },
|
||||
{ key: 'event', label: t('slideshow.watermarkSource.event', 'Event logo'), url: eventLogoUrl },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((opt) => {
|
||||
const selected = value === opt.key;
|
||||
const resolved = opt.url ? buildResourceUrl(opt.url) : null;
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
onClick={() => onChange(opt.key)}
|
||||
title={opt.label}
|
||||
className={`w-24 rounded-lg border-2 overflow-hidden transition-all text-center ${
|
||||
selected
|
||||
? 'tile-selected'
|
||||
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
|
||||
}`}
|
||||
>
|
||||
<div className="h-14 flex items-center justify-center" style={CHECKER}>
|
||||
{resolved ? (
|
||||
<img src={resolved} alt="" className="max-h-12 max-w-[80%] object-contain" draggable={false} />
|
||||
) : (
|
||||
<span className="text-[10px] text-neutral-500">{t('slideshow.watermarkSource.notSet', 'Not set')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-700 dark:text-neutral-300 py-1 px-1 truncate">{opt.label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatermarkSourcePicker;
|
||||
@@ -62,6 +62,7 @@ import { Button, Input, Card, Loading, MarkdownContent, LocalizedDateInput } fro
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, PublishGalleryDialog, DuplicateEventDialog, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
|
||||
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
|
||||
import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard';
|
||||
import { SlideshowSettingsCard } from '../../components/admin/SlideshowSettingsCard';
|
||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
@@ -2158,6 +2159,27 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Live Slideshow ("Diashow") link + live display settings (migration 137) */}
|
||||
<SlideshowSettingsCard
|
||||
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()}
|
||||
/>
|
||||
|
||||
{/* Pre-event reminder override (migration 143). Hidden when
|
||||
the reminderEmails master flag is off — the override here
|
||||
would never fire since the cron itself no-ops. */}
|
||||
|
||||
@@ -18,6 +18,21 @@ import {
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service';
|
||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { SlideshowStyleFields } from '../../components/admin/SlideshowStyleFields';
|
||||
import { SlideshowGlobalDefaultsCard } from '../../components/admin/SlideshowGlobalDefaultsCard';
|
||||
import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service';
|
||||
|
||||
// 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
|
||||
const EMOJI_OPTIONS = [
|
||||
@@ -137,6 +152,9 @@ export const EventTypesPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Live Slideshow watermark default (per-event/type can override) */}
|
||||
<SlideshowGlobalDefaultsCard />
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-6">
|
||||
<div className="p-4 flex flex-col sm:flex-row gap-4">
|
||||
@@ -329,6 +347,12 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
||||
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 handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -357,15 +381,17 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
||||
if (form.slug_prefix !== eventType?.slug_prefix) updates.slug_prefix = form.slug_prefix;
|
||||
if (form.emoji !== eventType?.emoji) updates.emoji = form.emoji;
|
||||
if (form.theme_preset !== eventType?.theme_preset) updates.theme_preset = form.theme_preset;
|
||||
const originalStyle = JSON.stringify(parseSlideshowPreset(eventType?.slideshow_preset));
|
||||
if (JSON.stringify(slideshowStyle) !== originalStyle) updates.slideshow_preset = slideshowStyle;
|
||||
onSubmit(updates);
|
||||
} else {
|
||||
onSubmit(form);
|
||||
onSubmit({ ...form, slideshow_preset: slideshowStyle });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-lg">
|
||||
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
@@ -460,6 +486,19 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
||||
</select>
|
||||
</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. */}
|
||||
<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 */}
|
||||
{isEditing && (
|
||||
<label className="flex items-center gap-3 pt-2">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '../config/api';
|
||||
import type { SlideshowStyle } from './slideshow.service';
|
||||
|
||||
export interface EventType {
|
||||
id: number;
|
||||
@@ -12,6 +13,9 @@ export interface EventType {
|
||||
emoji: string;
|
||||
theme_preset: string;
|
||||
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;
|
||||
is_system: boolean;
|
||||
is_active: boolean;
|
||||
@@ -25,6 +29,7 @@ export interface CreateEventTypeData {
|
||||
emoji?: string;
|
||||
theme_preset?: string;
|
||||
theme_config?: Record<string, unknown>;
|
||||
slideshow_preset?: SlideshowStyle | null;
|
||||
display_order?: number;
|
||||
}
|
||||
|
||||
@@ -34,6 +39,7 @@ export interface UpdateEventTypeData {
|
||||
emoji?: string;
|
||||
theme_preset?: string;
|
||||
theme_config?: Record<string, unknown>;
|
||||
slideshow_preset?: SlideshowStyle | null;
|
||||
display_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
@@ -138,6 +138,36 @@ export const eventsService = {
|
||||
await api.delete(`/admin/events/${id}`);
|
||||
},
|
||||
|
||||
// Live Slideshow ("Diashow") — mint/rotate the share token (admin)
|
||||
async generateSlideshowLink(id: number): Promise<{ show_share_token: string; slideshow_url: string }> {
|
||||
const response = await api.post(`/admin/events/${id}/slideshow/generate`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Disable the slideshow link (null the token) (admin)
|
||||
async disableSlideshowLink(id: number): Promise<void> {
|
||||
await api.post(`/admin/events/${id}/slideshow/disable`);
|
||||
},
|
||||
|
||||
// Update live slideshow settings (display time / transition / style) (admin)
|
||||
async updateSlideshowSettings(
|
||||
id: number,
|
||||
settings: {
|
||||
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;
|
||||
}
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await api.patch(`/admin/events/${id}/slideshow`, settings);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Force archive event (admin)
|
||||
async archiveEvent(id: number): Promise<void> {
|
||||
await api.post(`/admin/events/${id}/archive`);
|
||||
|
||||
@@ -189,11 +189,16 @@ export const settingsService = {
|
||||
},
|
||||
|
||||
// Get settings by type
|
||||
async getSettingsByType(type: 'branding' | 'theme' | 'general'): Promise<Record<string, any>> {
|
||||
async getSettingsByType(type: 'branding' | 'theme' | 'general' | 'slideshow'): Promise<Record<string, any>> {
|
||||
const response = await api.get<Record<string, any>>(`/admin/settings/${type}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update global Live Slideshow defaults (watermark)
|
||||
async updateSlideshowDefaults(settings: Record<string, unknown>): Promise<void> {
|
||||
await api.put('/admin/settings/slideshow', settings);
|
||||
},
|
||||
|
||||
// Update branding settings
|
||||
async updateBranding(settings: BrandingSettings): Promise<void> {
|
||||
await api.put('/admin/settings/branding', settings);
|
||||
|
||||
@@ -67,6 +67,19 @@ export interface Event {
|
||||
// Client access (#172)
|
||||
client_access_enabled?: boolean;
|
||||
client_share_token?: string;
|
||||
// Live Slideshow / "Diashow" (migration 137). Token-only fullscreen kiosk
|
||||
// link minted on demand; null token = disabled. Settings drive the running
|
||||
// projector and can be changed live.
|
||||
show_share_token?: string | null;
|
||||
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';
|
||||
show_colorfilter?: 'none' | 'bw' | 'sepia' | 'warm' | 'cool' | 'vignette';
|
||||
// Default photo sort order
|
||||
default_photo_sort?: string;
|
||||
// Pre-event reminder override (migration 143)
|
||||
|
||||
Reference in New Issue
Block a user