refactor(frontend): decompose EventDetailsPage and ThemeCustomizerEnhanced

Move-code split, entry paths/exports unchanged:
- EventDetailsPage.tsx (2,697 -> 679) + pages/admin/event-details/* (16 files)
- ThemeCustomizerEnhanced.tsx (1,541 -> 349) + admin/theme-customizer/* (11)
Known ephemeral-UI delta: widget-local state (copied-link flags, unsaved
PIN input, modal selection) now resets when a tab unmounts.
This commit is contained in:
Paul Nothaft
2026-07-03 08:10:25 +02:00
parent fdf62dafef
commit b5eafc52bd
29 changed files with 4229 additions and 3402 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,305 @@
import React from 'react';
import { Palette, RotateCcw, Info } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../../common';
import { ThemeConfig } from '../../../types/theme.types';
import { ColorPickerRow } from './ColorPickerRow';
interface ColorCustomizationCardProps {
localTheme: ThemeConfig;
handleChange: (key: keyof ThemeConfig, newValue: any) => void;
handleColorModeSelect: (mode: 'light' | 'dark' | 'auto') => void;
forcedColorActive: boolean;
isBrandingContext: boolean;
hideGalleryColors: boolean;
forceColorMode?: 'dark' | 'light' | null;
onForceColorModeChange?: (mode: 'dark' | 'light' | null) => void;
onSyncFromBranding?: () => void;
}
export const ColorCustomizationCard: React.FC<ColorCustomizationCardProps> = ({
localTheme,
handleChange,
handleColorModeSelect,
forcedColorActive,
isBrandingContext,
hideGalleryColors,
forceColorMode,
onForceColorModeChange,
onSyncFromBranding
}) => {
const { t } = useTranslation();
return (
<Card className="p-6">
<div className="flex items-center justify-between gap-2 mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<Palette className="w-5 h-5" />
{t('branding.colors')}
</h3>
{/* "Sync from Branding" — caller-supplied so the customizer
doesn't have to know how to resolve the Branding theme.
Used in event create/edit to reset palette to site colours. */}
{onSyncFromBranding && (
<Button
type="button"
variant="outline"
size="sm"
leftIcon={<RotateCcw className="w-4 h-4" />}
onClick={onSyncFromBranding}
>
{t('branding.syncFromBranding', 'Sync from Branding')}
</Button>
)}
</div>
{/* Color Mode Selector */}
<div className="mb-6">
{forcedColorActive && (
<div className="mb-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 text-xs text-amber-800 dark:text-amber-300">
{isBrandingContext
? t('branding.forcedModeBrandingHint', 'Light/dark is locked site-wide by the Force control below — the per-theme mode picker is hidden because it would have no effect.')
: t('branding.forcedModeGalleryNote', 'A site-wide color lock is active, so this gallery follows the locked light/dark mode. Color and light/dark options are hidden here and cant be overridden per gallery.')}
</div>
)}
{!forcedColorActive && (<>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.colorMode', 'Color Mode')}
</label>
<div className="flex gap-2">
{(['light', 'dark', 'auto'] as const).map((mode) => (
<button
type="button"
key={mode}
onClick={() => handleColorModeSelect(mode)}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
(localTheme.colorMode || 'light') === mode
? 'border-accent-dark bg-accent-dark text-white'
: 'border-neutral-300 dark:border-neutral-600 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
{mode === 'light' ? t('branding.colorModeLight', 'Light') :
mode === 'dark' ? t('branding.colorModeDark', 'Dark') :
t('branding.colorModeAuto', 'Auto')}
</button>
))}
</div>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{t('branding.colorModeHelp', 'Auto follows the visitor\'s system preference.')}
</p>
</>)}
{/*
* Force color mode (instance-wide). Lives next to the per-theme
* Color Mode picker so the admin can find both controls in one
* place. The data flows through props from BrandingPage which
* persists it to branding settings; only renders when the
* onForceColorModeChange handler is provided (i.e. only on the
* Branding admin page, not in event-level theme editors).
*/}
{onForceColorModeChange && (
<div className="mt-5 pt-5 border-t border-neutral-200 dark:border-neutral-700">
<h4 className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('branding.forceColorMode', 'Force color mode')}
</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{t(
'branding.forceColorModeHelp',
'Lock the entire admin and public site to dark or light. The user-facing dark/light toggle is hidden whenever a lock is active. Per-event themes that try to override the colour mode are also forced to follow.'
)}
</p>
<div className="flex flex-wrap gap-2">
{([
{ value: null, label: t('branding.forceColorModeNone', 'No force (user choice)') },
{ value: 'dark', label: t('branding.forceColorModeDark', 'Force dark') },
{ value: 'light', label: t('branding.forceColorModeLight', 'Force light') },
] as const).map(({ value, label }) => {
const active = (forceColorMode ?? null) === value;
return (
<button
type="button"
key={String(value)}
onClick={() => onForceColorModeChange(value)}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
active
? 'border-accent-dark bg-accent-dark text-white'
: 'border-neutral-300 dark:border-neutral-600 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
{label}
</button>
);
})}
</div>
</div>
)}
</div>
{/*
* 8-token CI palette pickers, grouped by role.
* Each token writes directly to the same field name on ThemeConfig
* (kebab → camel mapping happens via handleChange's first arg).
* Translation keys fall back to inline strings — German/English
* coverage only (per user language profile); other locales will
* show the fallback until reviewed by a native speaker.
*/}
{/*
* 8-token CI palette pickers, grouped by role. Each picker label
* carries an Info icon whose `title` attribute renders the
* descriptive help text on hover (or long-press on touch). Keeping
* the help out of the static layout means every picker row is the
* same height so the four Surfaces and the two Accent rows align
* cleanly side-by-side.
*/}
{!hideGalleryColors && (
<div className="space-y-6">
{/* Surfaces */}
<div>
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3 flex items-center gap-1.5">
{t('branding.colorGroupSurfaces', 'Surfaces')}
<span
className="info-tooltip text-neutral-400 dark:text-neutral-500"
data-tooltip={t(
'branding.colorGroupSurfacesHelp',
'The neutral layers behind your content. Background sits furthest back; Surface and Elevated stack on top.'
)}
tabIndex={0}
>
<Info className="w-3.5 h-3.5" />
</span>
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
key: 'backgroundColor',
label: t('branding.backgroundColor', 'Background'),
help: t('branding.backgroundColorHelp', 'The page itself — body background of every gallery, admin page and CMS page.'),
fallback: '#fafafa',
},
{
key: 'surfaceColor',
label: t('branding.surfaceColor', 'Surface'),
help: t('branding.surfaceColorHelp', 'Cards, sidebar, header bar and navigation. The first layer above Background.'),
fallback: '#ffffff',
},
{
key: 'elevatedColor',
label: t('branding.elevatedColor', 'Elevated'),
help: t('branding.elevatedColorHelp', 'Panels that float above cards: image placeholders, hover/active rows, modal headers, code blocks.'),
fallback: '#f5f5f5',
},
{
key: 'surfaceBorderColor',
label: t('branding.borderColor', 'Border'),
help: t('branding.borderColorHelp', 'Dividers, table grid lines, card outlines, input borders.'),
fallback: '#e5e5e5',
},
].map(({ key, label, help, fallback }) => (
<ColorPickerRow
key={key}
label={label}
help={help}
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
fallback={fallback}
onChange={(v) => handleChange(key as keyof ThemeConfig, v)}
/>
))}
</div>
</div>
{/* Text */}
<div>
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3 flex items-center gap-1.5">
{t('branding.colorGroupText', 'Text')}
<span
className="info-tooltip text-neutral-400 dark:text-neutral-500"
data-tooltip={t(
'branding.colorGroupTextHelp',
'Foreground text colours. Primary is for everything readers focus on; Secondary is for supporting copy.'
)}
tabIndex={0}
>
<Info className="w-3.5 h-3.5" />
</span>
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
key: 'textColor',
label: t('branding.textColor', 'Primary text'),
help: t('branding.textColorHelp', 'Headlines, body copy, table cells, form input values, navigation labels — the main text colour.'),
fallback: '#171717',
},
{
key: 'mutedTextColor',
label: t('branding.mutedTextColor', 'Secondary text'),
help: t('branding.mutedTextColorHelp', 'Captions, helper text under inputs, table column headers, footer links, dates and metadata.'),
fallback: '#737373',
},
].map(({ key, label, help, fallback }) => (
<ColorPickerRow
key={key}
label={label}
help={help}
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
fallback={fallback}
onChange={(v) => handleChange(key as keyof ThemeConfig, v)}
/>
))}
</div>
</div>
{/* Accent */}
<div>
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3 flex items-center gap-1.5">
{t('branding.colorGroupAccent', 'Accent')}
<span
className="info-tooltip text-neutral-400 dark:text-neutral-500"
data-tooltip={t(
'branding.colorGroupAccentHelp',
'Brand colours that highlight interactive elements. Use a strong colour pair — Accent is for outlines/text, Accent Dark is for filled buttons.'
)}
tabIndex={0}
>
<Info className="w-3.5 h-3.5" />
</span>
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
key: 'accentColor',
label: t('branding.accentColor', 'Accent'),
help: t(
'branding.accentColorHelp',
'Links, icons, focus rings, hover states on primary buttons, active sidebar item underline. Should read clearly on both Background and Surface.'
),
fallback: '#22c55e',
},
{
key: 'accentDarkColor',
label: t('branding.accentDarkColor', 'Accent (filled)'),
help: t(
'branding.accentDarkColorHelp',
'Filled CTA buttons, active sidebar item background, badges and tags. Needs enough contrast for white text to be readable on top.'
),
fallback: '#5C8762',
},
].map(({ key, label, help, fallback }) => (
<ColorPickerRow
key={key}
label={label}
help={help}
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
fallback={fallback}
onChange={(v) => handleChange(key as keyof ThemeConfig, v)}
/>
))}
</div>
{/* primaryColor is kept in sync with accentDarkColor inside
handleChange() — no dedicated picker. */}
</div>
</div>
)}
</Card>
);
};
@@ -0,0 +1,46 @@
import React from 'react';
import { Info } from 'lucide-react';
import { Input } from '../../common';
/**
* Compact color-picker row used by the 8-token palette.
* Renders [Label + Info icon (tooltip)] / [color swatch + hex input].
* Help text is hidden in the static layout (lives on the Info icon's title
* attribute) so all rows are the same height — keeps the four Surfaces
* pickers and the two Accent pickers grid-aligned without forcing the user
* to read every help string up front.
*/
export const ColorPickerRow: React.FC<{
label: string;
help: string;
value: string;
fallback: string;
onChange: (value: string) => void;
}> = ({ label, help, value, fallback, onChange }) => (
<div>
<label className="flex items-center gap-1.5 text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{label}
<span
className="info-tooltip text-neutral-400 dark:text-neutral-500"
data-tooltip={help}
tabIndex={0}
>
<Info className="w-3.5 h-3.5" />
</span>
</label>
<div className="flex gap-2">
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={fallback}
className="flex-1"
/>
</div>
</div>
);
@@ -0,0 +1,85 @@
import React from 'react';
import { SlidersHorizontal, Menu, Check, Info } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card } from '../../common';
import { ThemeConfig } from '../../../types/theme.types';
interface ControlsStyleCardProps {
localTheme: ThemeConfig;
handleChange: (key: keyof ThemeConfig, newValue: any) => void;
}
export const ControlsStyleCard: React.FC<ControlsStyleCardProps> = ({ localTheme, handleChange }) => {
const { t } = useTranslation();
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<SlidersHorizontal className="w-5 h-5" />
{t('branding.controlsStyle', 'Controls Style')}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('branding.controlsStyleDescription', 'Choose how gallery filters and controls are displayed.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<button
type="button"
onClick={() => handleChange('controlsStyle', 'classic')}
className={`relative p-4 rounded-lg border-2 transition-all ${
(localTheme.controlsStyle || 'classic') === 'classic'
? 'tile-selected'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex flex-col items-center text-center">
<div className="mb-2 text-neutral-700 dark:text-neutral-300">
<SlidersHorizontal className="w-6 h-6" />
</div>
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">
{t('branding.controlsStyleOptions.classic', 'Classic')}
</span>
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
{t('branding.controlsStyleDescriptions.classic', 'Inline filter bar below header')}
</span>
</div>
{(localTheme.controlsStyle || 'classic') === 'classic' && (
<Check className="absolute top-2 right-2 w-4 h-4 text-accent-dark" />
)}
</button>
<button
type="button"
onClick={() => handleChange('controlsStyle', 'sidebar')}
className={`relative p-4 rounded-lg border-2 transition-all ${
localTheme.controlsStyle === 'sidebar'
? 'tile-selected'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex flex-col items-center text-center">
<div className="mb-2 text-neutral-700 dark:text-neutral-300">
<Menu className="w-6 h-6" />
</div>
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">
{t('branding.controlsStyleOptions.sidebar', 'Sidebar')}
</span>
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
{t('branding.controlsStyleDescriptions.sidebar', 'Menu button opens sidebar with filters')}
</span>
</div>
{localTheme.controlsStyle === 'sidebar' && (
<Check className="absolute top-2 right-2 w-4 h-4 text-accent-dark" />
)}
</button>
</div>
{/* Info about hero header */}
{localTheme.headerStyle === 'hero' && localTheme.controlsStyle !== 'sidebar' && (
<div className="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<p className="text-sm text-amber-800 dark:text-amber-200 flex items-center gap-2">
<Info className="w-4 h-4 flex-shrink-0" />
{t('branding.controlsStyleHeroWarning', 'Sidebar is recommended for hero headers to prevent controls appearing above the hero image.')}
</p>
</div>
)}
</Card>
);
};
@@ -0,0 +1,76 @@
import React from 'react';
import { FileCode, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card } from '../../common';
import type { EnabledTemplate } from '../../../services/cssTemplates.service';
interface CssTemplateCardProps {
cssTemplates: EnabledTemplate[];
cssTemplateId?: number | null;
onCssTemplateChange: (templateId: number | null) => void;
}
export const CssTemplateCard: React.FC<CssTemplateCardProps> = ({
cssTemplates,
cssTemplateId,
onCssTemplateChange
}) => {
const { t } = useTranslation();
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<FileCode className="w-5 h-5" />
{t('branding.cssTemplate', 'CSS Template')}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('branding.cssTemplateDescription', 'Select a pre-built CSS template to apply application-wide styling to this gallery. Templates can be managed in Settings > CSS Templates.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* No template option */}
<button
type="button"
onClick={() => onCssTemplateChange(null)}
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
!cssTemplateId
? 'tile-selected'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{t('branding.noTemplate', 'No Template')}</span>
{!cssTemplateId && (
<Check className="w-4 h-4 text-accent-dark flex-shrink-0" />
)}
</div>
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">
{t('branding.noTemplateDescription', 'Use only theme settings without a CSS template')}
</span>
</button>
{/* Template options */}
{cssTemplates.map((template) => (
<button
type="button"
key={template.id}
onClick={() => onCssTemplateChange(template.id)}
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
cssTemplateId === template.id
? 'tile-selected'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{template.name}</span>
{cssTemplateId === template.id && (
<Check className="w-4 h-4 text-accent-dark flex-shrink-0" />
)}
</div>
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">
{t('branding.templateSlot', 'Slot {{slot}}', { slot: template.slot_number })}
</span>
</button>
))}
</div>
</Card>
);
};
@@ -0,0 +1,125 @@
import React, { useState } from 'react';
import { Code, Info, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card } from '../../common';
import { ThemeConfig } from '../../../types/theme.types';
interface CustomCssCardProps {
localTheme: ThemeConfig;
customCss: string;
onCustomCssChange: (newCss: string) => void;
}
export const CustomCssCard: React.FC<CustomCssCardProps> = ({
localTheme,
customCss,
onCustomCssChange
}) => {
const { t } = useTranslation();
const [showCssInstructions, setShowCssInstructions] = useState(false);
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Code className="w-5 h-5" />
{t('branding.eventCustomCSS', 'Event-specific Custom CSS')}
</h3>
{/* Collapsible Instructions Panel */}
<div className="mb-4">
<button
type="button"
onClick={() => setShowCssInstructions(!showCssInstructions)}
className="flex items-center gap-2 text-sm text-accent hover:opacity-80 font-medium"
>
<Info className="w-4 h-4" />
{t('branding.cssInstructions.title', 'How to use Custom CSS')}
<ChevronDown className={`w-4 h-4 transition-transform ${showCssInstructions ? 'rotate-180' : ''}`} />
</button>
{showCssInstructions && (
<div className="mt-3 p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm space-y-4">
{/* Available CSS Variables */}
<div>
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
{t('branding.cssInstructions.variables', 'Theme CSS Variables')}
</h4>
<p className="text-neutral-600 dark:text-neutral-400 mb-2">
{t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')}
</p>
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
{`--color-background: ${localTheme.backgroundColor || '#fafafa'};
--color-surface: ${localTheme.surfaceColor || '#ffffff'};
--color-elevated: ${localTheme.elevatedColor || '#f5f5f5'};
--color-surface-border: ${localTheme.surfaceBorderColor || '#e5e5e5'};
--color-text: ${localTheme.textColor || '#171717'};
--color-muted-text: ${localTheme.mutedTextColor || '#737373'};
--color-accent: ${localTheme.accentColor || '#22c55e'};
--color-accent-dark: ${localTheme.accentDarkColor || localTheme.primaryColor || '#5C8762'};
--font-family: ${localTheme.fontFamily || 'Inter, sans-serif'};
--heading-font: ${localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'};`}
</code>
</div>
{/* Custom Gallery Layouts */}
<div>
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
{t('branding.cssInstructions.layouts', 'Custom Gallery Layouts')}
</h4>
<p className="text-neutral-600 dark:text-neutral-400 mb-2">
{t('branding.cssInstructions.layoutsDesc', 'Target gallery elements with these selectors:')}
</p>
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
{`.gallery-container { /* Main gallery wrapper */ }
.gallery-grid { /* Photo grid container */ }
.gallery-item { /* Individual photo card */ }
.gallery-header { /* Header section */ }
.gallery-hero { /* Hero image area */ }
.photo-overlay { /* Photo hover overlay */ }
.photo-actions { /* Like/favorite buttons */ }`}
</code>
</div>
{/* Glassmorphism Example */}
<div>
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
{t('branding.cssInstructions.glassEffect', 'Glassmorphism Effect')}
</h4>
<p className="text-neutral-600 dark:text-neutral-400 mb-2">
{t('branding.cssInstructions.glassEffectDesc', 'Create modern glass effects:')}
</p>
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
{`.glass-panel {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 16px;
}`}
</code>
</div>
{/* Tips */}
<div className="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
<Info className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-blue-800 dark:text-blue-200 text-xs">
<strong>{t('branding.cssInstructions.tip', 'Tip')}:</strong>{' '}
{t('branding.cssInstructions.tipText', 'Use CSS Templates from Settings > CSS Templates for pre-built designs like Apple Liquid Glass.')}
</div>
</div>
</div>
)}
</div>
<textarea
value={customCss}
onChange={(e) => onCustomCssChange(e.target.value)}
placeholder="/* Add custom CSS here */"
className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 dark:border-neutral-600 rounded-lg bg-neutral-50 dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
/>
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
{t('branding.customCSSHelp')}
</p>
</Card>
);
};
@@ -0,0 +1,373 @@
import React from 'react';
import { Layout, Check, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card, Input } from '../../common';
import { ThemeConfig, GalleryLayoutType } from '../../../types/theme.types';
import { layoutIcons } from './icons';
interface GalleryLayoutCardProps {
localTheme: ThemeConfig;
handleChange: (key: keyof ThemeConfig, newValue: any) => void;
updateGallerySettings: (key: string, value: any) => void;
isBetaLayout: boolean;
isThumbnailTooSmall: boolean;
thumbnailWidth: number;
thumbnailHeight: number;
minRecommendedThumbnailSize: number;
}
export const GalleryLayoutCard: React.FC<GalleryLayoutCardProps> = ({
localTheme,
handleChange,
updateGallerySettings,
isBetaLayout,
isThumbnailTooSmall,
thumbnailWidth,
thumbnailHeight,
minRecommendedThumbnailSize
}) => {
const { t } = useTranslation();
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Layout className="w-5 h-5" />
{t('branding.galleryLayout')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{(Object.keys(layoutIcons) as GalleryLayoutType[]).map((layout) => (
<button
type="button"
key={layout}
onClick={() => handleChange('galleryLayout', layout)}
className={`relative p-4 rounded-lg border-2 transition-all ${
localTheme.galleryLayout === layout
? 'tile-selected'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex flex-col items-center text-center">
<div className="mb-2 text-neutral-700 dark:text-neutral-300">
{layoutIcons[layout]}
</div>
<span className="font-medium text-sm capitalize text-neutral-900 dark:text-neutral-100">
{layout}
{(layout === 'gallery-premium' || layout === 'gallery-story') && (
<span className="ml-1 text-xs text-amber-600 dark:text-amber-400">(Beta)</span>
)}
</span>
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
{t(`branding.layoutDescriptions.${layout}`)}
</span>
</div>
{localTheme.galleryLayout === layout && (
<Check className="absolute top-2 right-2 w-4 h-4 text-accent-dark" />
)}
</button>
))}
</div>
{/* Warning: Beta theme with low thumbnail resolution */}
{isBetaLayout && isThumbnailTooSmall && (
<div className="mt-4 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
{t('branding.betaThumbnailWarningTitle')}
</p>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
{t('branding.betaThumbnailWarningText', { width: thumbnailWidth, height: thumbnailHeight, recommended: minRecommendedThumbnailSize })}
</p>
<a
href="/admin/settings"
className="inline-flex items-center gap-1 mt-2 text-sm font-medium text-amber-800 dark:text-amber-300 hover:underline"
onClick={(e) => {
e.preventDefault();
window.location.href = '/admin/settings';
}}
>
{t('branding.betaThumbnailWarningLink')}
</a>
</div>
</div>
</div>
)}
{/* Layout-specific settings */}
{localTheme.galleryLayout && (
<div className="mt-6 space-y-4 pt-6 border-t border-neutral-200 dark:border-neutral-700">
<h4 className="font-medium text-sm text-neutral-700 dark:text-neutral-300">{t('branding.layoutSettings')}</h4>
{/* Common settings */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.photoSpacing')}
</label>
<select
value={localTheme.gallerySettings?.spacing || 'normal'}
onChange={(e) => updateGallerySettings('spacing', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="tight">{t('branding.spacing.tight')}</option>
<option value="normal">{t('branding.spacing.normal')}</option>
<option value="relaxed">{t('branding.spacing.relaxed')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.photoAnimation')}
</label>
<select
value={localTheme.gallerySettings?.photoAnimation || 'fade'}
onChange={(e) => updateGallerySettings('photoAnimation', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="none">{t('branding.animation.none')}</option>
<option value="fade">{t('branding.animation.fade')}</option>
<option value="scale">{t('branding.animation.scale')}</option>
<option value="slide">{t('branding.animation.slide')}</option>
</select>
</div>
</div>
{/* Grid specific */}
{localTheme.galleryLayout === 'grid' && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.columns')}
</label>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.mobile')}</label>
<Input
type="number"
min="1"
max="4"
value={localTheme.gallerySettings?.gridColumns?.mobile || 2}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
mobile: parseInt(e.target.value)
})}
/>
</div>
<div>
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.tablet')}</label>
<Input
type="number"
min="2"
max="6"
value={localTheme.gallerySettings?.gridColumns?.tablet || 3}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
tablet: parseInt(e.target.value)
})}
/>
</div>
<div>
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.desktop')}</label>
<Input
type="number"
min="3"
max="8"
value={localTheme.gallerySettings?.gridColumns?.desktop || 4}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
desktop: parseInt(e.target.value)
})}
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.thumbnailScale', 'Thumbnail Scale')}
</label>
<select
value={localTheme.gallerySettings?.thumbnailScale || 'md'}
onChange={(e) => updateGallerySettings('thumbnailScale', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="xs">{t('branding.thumbnailScaleOptions.xs', 'XS — Most photos')}</option>
<option value="sm">{t('branding.thumbnailScaleOptions.sm', 'SM — More photos')}</option>
<option value="md">{t('branding.thumbnailScaleOptions.md', 'MD — Default')}</option>
<option value="lg">{t('branding.thumbnailScaleOptions.lg', 'LG — Larger photos')}</option>
<option value="xl">{t('branding.thumbnailScaleOptions.xl', 'XL — Largest photos')}</option>
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')}
</p>
</div>
</>
)}
{/* Carousel specific */}
{localTheme.galleryLayout === 'carousel' && (
<>
<div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={localTheme.gallerySettings?.carouselAutoplay || false}
onChange={(e) => updateGallerySettings('carouselAutoplay', e.target.checked)}
className="rounded"
/>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('branding.enableAutoplay')}</span>
</label>
</div>
{localTheme.gallerySettings?.carouselAutoplay && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.autoplayInterval')}
</label>
<Input
type="number"
min="2"
max="10"
value={(localTheme.gallerySettings?.carouselInterval || 5000) / 1000}
onChange={(e) => updateGallerySettings('carouselInterval', parseInt(e.target.value) * 1000)}
/>
</div>
)}
</>
)}
{/* Timeline specific */}
{localTheme.galleryLayout === 'timeline' && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.groupPhotosBy')}
</label>
<select
value={localTheme.gallerySettings?.timelineGrouping || 'day'}
onChange={(e) => updateGallerySettings('timelineGrouping', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="day">{t('branding.grouping.day')}</option>
<option value="week">{t('branding.grouping.week')}</option>
<option value="month">{t('branding.grouping.month')}</option>
</select>
</div>
)}
{/* Masonry specific */}
{localTheme.galleryLayout === 'masonry' && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.masonryMode', 'Layout Mode')}
</label>
<select
value={localTheme.gallerySettings?.masonryMode || 'columns'}
onChange={(e) => updateGallerySettings('masonryMode', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="columns">{t('branding.masonryModeOptions.columns', 'Columns (Pinterest-style)')}</option>
<option value="rows">{t('branding.masonryModeOptions.rows', 'Rows (Custom justified)')}</option>
<option value="flickr">{t('branding.masonryModeOptions.flickr', 'Flickr (Battle-tested justified)')}</option>
<option value="justified">{t('branding.masonryModeOptions.justified', 'Google Photos (Knuth-Plass algorithm)')}</option>
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{localTheme.gallerySettings?.masonryMode === 'columns'
? t('branding.masonryModeHint.columns', 'Pinterest-style vertical columns with varied heights')
: localTheme.gallerySettings?.masonryMode === 'flickr'
? t('branding.masonryModeHint.flickr', 'Flickr\'s open-source justified layout algorithm')
: localTheme.gallerySettings?.masonryMode === 'justified'
? t('branding.masonryModeHint.justified', 'Google Photos-style rows using Knuth-Plass algorithm for optimal breaks')
: t('branding.masonryModeHint.rows', 'Custom row-based justified layout')}
</p>
</div>
{/* Thumbnail scale - only for columns mode */}
{(!localTheme.gallerySettings?.masonryMode || localTheme.gallerySettings?.masonryMode === 'columns') && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.thumbnailScale', 'Thumbnail Scale')}
</label>
<select
value={localTheme.gallerySettings?.thumbnailScale || 'md'}
onChange={(e) => updateGallerySettings('thumbnailScale', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="xs">{t('branding.thumbnailScaleOptions.xs', 'XS — Most photos')}</option>
<option value="sm">{t('branding.thumbnailScaleOptions.sm', 'SM — More photos')}</option>
<option value="md">{t('branding.thumbnailScaleOptions.md', 'MD — Default')}</option>
<option value="lg">{t('branding.thumbnailScaleOptions.lg', 'LG — Larger photos')}</option>
<option value="xl">{t('branding.thumbnailScaleOptions.xl', 'XL — Largest photos')}</option>
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')}
</p>
</div>
)}
{/* Row-specific settings - show for all row-based modes */}
{['rows', 'flickr', 'justified'].includes(localTheme.gallerySettings?.masonryMode || '') && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.targetRowHeight', 'Target Row Height')}
</label>
<Input
type="number"
min="150"
max="400"
value={localTheme.gallerySettings?.masonryRowHeight || 250}
onChange={(e) => updateGallerySettings('masonryRowHeight', parseInt(e.target.value))}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('branding.targetRowHeightHint', 'Height in pixels (150-400). Photos will scale to fit rows.')}
</p>
</div>
{/* Last row behavior - only for rows and flickr modes */}
{['rows', 'flickr'].includes(localTheme.gallerySettings?.masonryMode || '') && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.lastRowBehavior', 'Last Row Alignment')}
</label>
<select
value={localTheme.gallerySettings?.masonryLastRowBehavior || 'left'}
onChange={(e) => updateGallerySettings('masonryLastRowBehavior', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="left">{t('branding.lastRowOptions.left', 'Left aligned')}</option>
<option value="center">{t('branding.lastRowOptions.center', 'Centered')}</option>
<option value="justify">{t('branding.lastRowOptions.justify', 'Justified (stretch)')}</option>
</select>
</div>
)}
</>
)}
</>
)}
{/* Mosaic specific */}
{localTheme.galleryLayout === 'mosaic' && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.thumbnailScale', 'Thumbnail Scale')}
</label>
<select
value={localTheme.gallerySettings?.thumbnailScale || 'md'}
onChange={(e) => updateGallerySettings('thumbnailScale', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="xs">{t('branding.thumbnailScaleOptions.xs', 'XS — Most photos')}</option>
<option value="sm">{t('branding.thumbnailScaleOptions.sm', 'SM — More photos')}</option>
<option value="md">{t('branding.thumbnailScaleOptions.md', 'MD — Default')}</option>
<option value="lg">{t('branding.thumbnailScaleOptions.lg', 'LG — Larger photos')}</option>
<option value="xl">{t('branding.thumbnailScaleOptions.xl', 'XL — Largest photos')}</option>
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')}
</p>
</div>
)}
</div>
)}
</Card>
);
};
@@ -0,0 +1,95 @@
import React from 'react';
import { ImageIcon, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card } from '../../common';
import { ThemeConfig, HeaderStyleType, HeroDividerStyle } from '../../../types/theme.types';
import { headerStyleIcons, dividerStylePreviews } from './icons';
interface HeaderStyleCardProps {
localTheme: ThemeConfig;
handleChange: (key: keyof ThemeConfig, newValue: any) => void;
}
export const HeaderStyleCard: React.FC<HeaderStyleCardProps> = ({ localTheme, handleChange }) => {
const { t } = useTranslation();
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<ImageIcon className="w-5 h-5" />
{t('branding.headerStyle', 'Header Style')}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('branding.headerStyleDescription', 'Choose how the gallery header appears. The header style is independent of the photo layout.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{(Object.keys(headerStyleIcons) as HeaderStyleType[]).map((style) => (
<button
type="button"
key={style}
onClick={() => handleChange('headerStyle', style)}
className={`relative p-4 rounded-lg border-2 transition-all ${
(localTheme.headerStyle || 'standard') === style
? 'tile-selected'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex flex-col items-center text-center">
<div className="mb-2 text-neutral-700 dark:text-neutral-300">
{headerStyleIcons[style]}
</div>
<span className="font-medium text-sm capitalize text-neutral-900 dark:text-neutral-100">
{t(`branding.headerStyleOptions.${style}`, style)}
</span>
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
{t(`branding.headerStyleDescriptions.${style}`, '')}
</span>
</div>
{(localTheme.headerStyle || 'standard') === style && (
<Check className="absolute top-2 right-2 w-4 h-4 text-accent-dark" />
)}
</button>
))}
</div>
{/* Divider Style - Only show when hero header is selected */}
{localTheme.headerStyle === 'hero' && (
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
<h4 className="font-medium text-sm text-neutral-700 dark:text-neutral-300 mb-3">
{t('branding.heroDividerStyle', 'Divider Style')}
</h4>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mb-4">
{t('branding.heroDividerDescription', 'Choose how the transition between the hero image and gallery content looks.')}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{(Object.keys(dividerStylePreviews) as HeroDividerStyle[]).map((divider) => (
<button
type="button"
key={divider}
onClick={() => handleChange('heroDividerStyle', divider)}
className={`relative p-3 rounded-lg border-2 transition-all ${
(localTheme.heroDividerStyle || 'wave') === divider
? 'tile-selected'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex flex-col items-center">
<div className="w-full mb-2 bg-neutral-800 rounded-t overflow-hidden">
<div className="h-8"></div>
{dividerStylePreviews[divider]}
</div>
<span className="text-xs font-medium capitalize text-neutral-900 dark:text-neutral-100">
{t(`branding.dividerOptions.${divider}`, divider)}
</span>
</div>
{(localTheme.heroDividerStyle || 'wave') === divider && (
<Check className="absolute top-1 right-1 w-3 h-3 text-accent-dark" />
)}
</button>
))}
</div>
</div>
)}
</Card>
);
};
@@ -0,0 +1,119 @@
import React from 'react';
import { Sparkles, Check, AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card } from '../../common';
import { GALLERY_THEME_PRESETS } from '../../../types/theme.types';
import { layoutIcons } from './icons';
interface ThemePresetsCardProps {
selectedPreset: string;
handlePresetSelect: (presetKey: string) => void;
showGalleryLayouts: boolean;
isBetaLayout: boolean;
isThumbnailTooSmall: boolean;
thumbnailWidth: number;
thumbnailHeight: number;
minRecommendedThumbnailSize: number;
}
export const ThemePresetsCard: React.FC<ThemePresetsCardProps> = ({
selectedPreset,
handlePresetSelect,
showGalleryLayouts,
isBetaLayout,
isThumbnailTooSmall,
thumbnailWidth,
thumbnailHeight,
minRecommendedThumbnailSize
}) => {
const { t } = useTranslation();
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Sparkles className="w-5 h-5" />
{t('branding.themePresets')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(GALLERY_THEME_PRESETS).map(([key, theme]) => (
<button
type="button"
key={key}
onClick={() => handlePresetSelect(key)}
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
selectedPreset === key
? 'tile-selected'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<div className="flex items-start justify-between mb-2">
<div>
<span className="font-medium text-sm block text-neutral-900 dark:text-neutral-100">{theme.name}</span>
{theme.description && (
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">{theme.description}</span>
)}
</div>
{selectedPreset === key && (
<Check className="w-4 h-4 text-accent-dark flex-shrink-0" />
)}
</div>
<div className="flex items-center gap-2 mt-3">
<div className="flex gap-1">
{/* Preview swatches: background, surface, accent-dark, accent
— gives a quick read of the preset's full palette. */}
<div
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.backgroundColor }}
/>
<div
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.surfaceColor || theme.config.backgroundColor }}
/>
<div
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.accentDarkColor || theme.config.primaryColor }}
/>
<div
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.accentColor }}
/>
</div>
{theme.config.galleryLayout && layoutIcons[theme.config.galleryLayout] && (
<div className="ml-auto text-neutral-400">
{layoutIcons[theme.config.galleryLayout]}
</div>
)}
</div>
</button>
))}
</div>
{/* Warning: Beta preset with low thumbnail resolution */}
{isBetaLayout && isThumbnailTooSmall && !showGalleryLayouts && (
<div className="mt-4 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
{t('branding.betaThumbnailWarningTitle')}
</p>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
{t('branding.betaThumbnailWarningText', { width: thumbnailWidth, height: thumbnailHeight, recommended: minRecommendedThumbnailSize })}
</p>
<a
href="/admin/settings"
className="inline-flex items-center gap-1 mt-2 text-sm font-medium text-amber-800 dark:text-amber-300 hover:underline"
onClick={(e) => {
e.preventDefault();
window.location.href = '/admin/settings';
}}
>
{t('branding.betaThumbnailWarningLink')}
</a>
</div>
</div>
</div>
)}
</Card>
);
};
@@ -0,0 +1,154 @@
import React from 'react';
import { Type } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Card } from '../../common';
import { ThemeConfig } from '../../../types/theme.types';
import type { FontDefinition } from '../../../services/fonts.service';
import { buildFontFamilyValue, resolveFontDropdownValue } from './fontUtils';
interface TypographyStyleCardProps {
localTheme: ThemeConfig;
handleChange: (key: keyof ThemeConfig, newValue: any) => void;
availableFonts?: FontDefinition[];
}
export const TypographyStyleCard: React.FC<TypographyStyleCardProps> = ({
localTheme,
handleChange,
availableFonts
}) => {
const { t } = useTranslation();
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Type className="w-5 h-5" />
{t('branding.typographyAndStyle')}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.bodyFont')}
</label>
<select
value={resolveFontDropdownValue(
localTheme.fontFamily,
availableFonts,
// Fallback when no fontFamily is saved yet: prefer the
// scanned Inter (with its real generic), else a bare CSS
// string when the backend hasn't loaded yet.
(availableFonts || []).find((f) => f.family === 'Inter')
? buildFontFamilyValue(
(availableFonts || []).find((f) => f.family === 'Inter')!
)
: "'Inter', sans-serif"
)}
onChange={(e) => handleChange('fontFamily', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="system-ui, sans-serif">System UI</option>
{(availableFonts || []).map((f) => (
<option key={f.family} value={buildFontFamilyValue(f)}>
{f.family}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.headingFont')}
</label>
<select
value={resolveFontDropdownValue(
localTheme.headingFontFamily,
availableFonts,
''
)}
onChange={(e) => handleChange('headingFontFamily', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="">{t('branding.sameAsBody')}</option>
<option value="system-ui, sans-serif">System UI</option>
{(availableFonts || []).map((f) => (
<option key={f.family} value={buildFontFamilyValue(f)}>
{f.family}
</option>
))}
</select>
</div>
</div>
{/* Row 1: Font Size & Border Radius */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.fontSize')}
</label>
<select
value={localTheme.fontSize || 'normal'}
onChange={(e) => handleChange('fontSize', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="small">{t('branding.fontSizes.small')}</option>
<option value="normal">{t('branding.fontSizes.normal')}</option>
<option value="large">{t('branding.fontSizes.large')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.borderRadius')}
</label>
<select
value={localTheme.borderRadius || 'md'}
onChange={(e) => handleChange('borderRadius', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="none">{t('branding.borderRadiusOptions.none')}</option>
<option value="sm">{t('branding.borderRadiusOptions.small')}</option>
<option value="md">{t('branding.borderRadiusOptions.medium')}</option>
<option value="lg">{t('branding.borderRadiusOptions.large')}</option>
</select>
</div>
</div>
{/* Row 2: Shadow Style & Background Pattern */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.shadowStyle')}
</label>
<select
value={localTheme.shadowStyle || 'normal'}
onChange={(e) => handleChange('shadowStyle', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="none">{t('branding.shadowOptions.none')}</option>
<option value="subtle">{t('branding.shadowOptions.subtle')}</option>
<option value="normal">{t('branding.shadowOptions.normal')}</option>
<option value="dramatic">{t('branding.shadowOptions.dramatic')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.backgroundPattern')}
</label>
<select
value={localTheme.backgroundPattern || 'none'}
onChange={(e) => handleChange('backgroundPattern', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="none">{t('branding.backgroundOptions.none')}</option>
<option value="dots">{t('branding.backgroundOptions.dots')}</option>
<option value="grid">{t('branding.backgroundOptions.grid')}</option>
<option value="waves">{t('branding.backgroundOptions.waves')}</option>
</select>
</div>
</div>
</div>
</Card>
);
};
@@ -0,0 +1,34 @@
import { extractFamilyName, type FontDefinition } from '../../../services/fonts.service';
/**
* Build the CSS font-family value for a scanned font, using the generic
* fallback the backend supplied (from each family's optional meta.json).
* Defaults to 'sans-serif' when the backend doesn't report one — keeps
* compatibility with backends that predate the generic field.
*/
export function buildFontFamilyValue(font: FontDefinition): string {
const generic = font.generic ?? 'sans-serif';
// Always quote the family name (covers multi-word like 'Playfair Display').
return `'${font.family}', ${generic}`;
}
/**
* Match a saved CSS font-family string against the available scanned families
* and return the canonical option value the dropdown renders. Handles both
* legacy unquoted strings ("Inter, sans-serif") and the new quoted format
* ("'Inter', sans-serif"), so events saved before this change still show the
* right option as selected.
*/
export function resolveFontDropdownValue(
saved: string | undefined,
available: FontDefinition[] | undefined,
fallback: string
): string {
if (!saved) return fallback;
const family = extractFamilyName(saved);
if (!family) return saved; // generic family like "system-ui, sans-serif"
const match = (available || []).find(
(f) => f.family.toLowerCase() === family.toLowerCase()
);
return match ? buildFontFamilyValue(match) : saved;
}
@@ -0,0 +1,50 @@
import React from 'react';
import { Layout, LayoutTemplate, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, Minimize2, EyeOff, Columns, Film } from 'lucide-react';
import { GalleryLayoutType, HeaderStyleType, HeroDividerStyle } from '../../../types/theme.types';
export const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
grid: <Grid3X3 className="w-5 h-5" />,
masonry: <Layers className="w-5 h-5" />,
carousel: <Play className="w-5 h-5" />,
timeline: <Clock className="w-5 h-5" />,
mosaic: <LayoutGrid className="w-5 h-5" />,
'gallery-premium': <Columns className="w-5 h-5" />,
'gallery-story': <Film className="w-5 h-5" />
};
export const headerStyleIcons: Record<HeaderStyleType, React.ReactNode> = {
hero: <Image className="w-5 h-5" />,
standard: <Layout className="w-5 h-5" />,
banner: <LayoutTemplate className="w-5 h-5" />,
minimal: <Minimize2 className="w-5 h-5" />,
none: <EyeOff className="w-5 h-5" />
};
export const dividerStylePreviews: Record<HeroDividerStyle, React.ReactNode> = {
wave: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<path d="M0,12 C12,18 37,6 50,12 C63,18 88,6 100,12 L100,24 L0,24 Z" fill="currentColor" className="text-neutral-300" />
</svg>
),
straight: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<rect x="0" y="12" width="100" height="12" fill="currentColor" className="text-neutral-300" />
</svg>
),
angle: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<path d="M0,24 L100,8 L100,24 Z" fill="currentColor" className="text-neutral-300" />
</svg>
),
curve: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<path d="M0,16 Q50,0 100,16 L100,24 L0,24 Z" fill="currentColor" className="text-neutral-300" />
</svg>
),
none: (
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
<rect x="0" y="0" width="100" height="24" fill="currentColor" className="text-neutral-100" />
<text x="50" y="16" textAnchor="middle" fontSize="10" fill="currentColor" className="text-neutral-400">No divider</text>
</svg>
)
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Download } from 'lucide-react';
import type { Event } from '../../../types';
import { Button, Card } from '../../../components/common';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { archiveService } from '../../../services/archive.service';
import { safeParseDate } from './utils';
interface ArchiveStatusCardProps {
event: Event;
id: string | undefined;
}
export const ArchiveStatusCard: React.FC<ArchiveStatusCardProps> = ({ event, id }) => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
return (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.archiveStatusTitle')}</h2>
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.archivedOn')}</p>
<p className="text-sm text-neutral-900 dark:text-neutral-100">
{event.archived_at && fmtDateTime(safeParseDate(event.archived_at)!)}
</p>
</div>
{event.archive_path && (
<Button
variant="outline"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={async () => {
try {
toast.info(t('events.downloadingArchive', { name: event.event_name }));
await archiveService.downloadArchive(Number(id), `${event.slug}-archive.zip`);
toast.success(t('events.downloadStarted'));
} catch {
toast.error(t('events.failedToDownloadArchive'));
}
}}
className="w-full justify-center"
>
{t('events.downloadArchive')}
</Button>
)}
</div>
</Card>
);
};
@@ -0,0 +1,35 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Card } from '../../../components/common';
import { EventCategoryManager } from '../../../components/admin';
interface CategoriesTabProps {
id: string | undefined;
}
export const CategoriesTab: React.FC<CategoriesTabProps> = ({ id }) => {
const { t } = useTranslation();
return (
<div>
<Card padding="md">
<div className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('events.photoCategories')}</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('events.organizeCategoriesInfo')}
</p>
</div>
<EventCategoryManager
eventId={parseInt(id!)}
/>
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
<p className="text-sm text-blue-800 dark:text-blue-300">
{t('events.categoriesTip')}
</p>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,149 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Shield, Key, Copy, CheckCircle } from 'lucide-react';
import type { Event } from '../../../types';
import { Button, Card } from '../../../components/common';
import { eventsService } from '../../../services/events.service';
interface ClientAccessCardProps {
event: Event;
refetchEvent: () => void;
}
export const ClientAccessCard: React.FC<ClientAccessCardProps> = ({ event, refetchEvent }) => {
const { t } = useTranslation();
const [copiedClientLink, setCopiedClientLink] = useState(false);
const [clientPin, setClientPin] = useState('');
return (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Shield className="w-5 h-5" />
{t('clientAccess.adminTitle')}
</h2>
<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={!!event?.client_access_enabled}
onChange={async (e) => {
try {
await eventsService.updateEvent(event.id, { client_access_enabled: e.target.checked });
refetchEvent();
} catch {
toast.error(t('common.error'));
}
}}
disabled={event?.is_archived}
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('clientAccess.enableToggle')}
</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('clientAccess.enableDescription')}
</p>
</div>
</label>
{event?.client_access_enabled && (
<>
{/* Set/Change PIN */}
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('clientAccess.pinLabel')}
</label>
<input
type="text"
value={clientPin}
onChange={(e) => setClientPin(e.target.value)}
placeholder={t('clientAccess.pinPlaceholder')}
className="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"
/>
</div>
<Button
variant="outline"
size="md"
leftIcon={<Key className="w-4 h-4" />}
onClick={async () => {
if (!clientPin.trim()) return;
try {
await eventsService.updateEvent(event.id, { client_password: clientPin });
setClientPin('');
toast.success(t('clientAccess.pinUpdated'));
refetchEvent();
} catch {
toast.error(t('common.error'));
}
}}
disabled={!clientPin.trim()}
>
{t('clientAccess.setPin')}
</Button>
</div>
{/* Client access link */}
{event?.client_share_token && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('clientAccess.linkLabel')}
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={`${window.location.origin}/gallery/${event.slug}/client-access?token=${event.client_share_token}`}
readOnly
className="flex-1 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"
/>
<Button
variant="outline"
size="md"
leftIcon={copiedClientLink ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
onClick={async () => {
const link = `${window.location.origin}/gallery/${event.slug}/client-access?token=${event.client_share_token}`;
try {
await navigator.clipboard.writeText(link);
} catch {
const textArea = document.createElement('textarea');
textArea.value = link;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
setCopiedClientLink(true);
setTimeout(() => setCopiedClientLink(false), 2000);
}}
>
{copiedClientLink ? t('events.copied') : t('events.copy')}
</Button>
</div>
<Button
variant="ghost"
size="sm"
className="mt-2 text-xs"
onClick={async () => {
try {
await eventsService.updateEvent(event.id, { regenerate_client_token: true });
toast.success(t('clientAccess.tokenRegenerated'));
refetchEvent();
} catch {
toast.error(t('common.error'));
}
}}
>
{t('clientAccess.regenerateToken')}
</Button>
</div>
)}
</>
)}
</div>
</Card>
);
};
@@ -0,0 +1,82 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Archive, Send, Copy } from 'lucide-react';
import type { Event } from '../../../types';
import { Button, Card } from '../../../components/common';
interface EventActionsCardProps {
event: Event;
onArchive: () => void;
isArchiving: boolean;
setShowPublishDialog: (show: boolean) => void;
isPublishing: boolean;
setShowDuplicateDialog: (show: boolean) => void;
isDuplicating: boolean;
}
export const EventActionsCard: React.FC<EventActionsCardProps> = ({
event,
onArchive,
isArchiving,
setShowPublishDialog,
isPublishing,
setShowDuplicateDialog,
isDuplicating
}) => {
const { t } = useTranslation();
return (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.actions')}</h2>
<div className="space-y-3">
{event.is_draft ? (
<>
<Button
variant="primary"
leftIcon={<Send className="w-4 h-4" />}
onClick={() => setShowPublishDialog(true)}
isLoading={isPublishing}
className="w-full justify-center"
>
{t('events.publishAndNotify')}
</Button>
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
{t('events.draftBanner')}
</p>
</>
) : (
<>
<Button
variant="outline"
leftIcon={<Archive className="w-4 h-4" />}
onClick={() => {
if (confirm(t('events.archiveConfirm'))) {
onArchive();
}
}}
isLoading={isArchiving}
className="w-full justify-center"
>
{t('events.archiveEvent')}
</Button>
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
{t('events.archivingInfo')}
</p>
</>
)}
{/* Duplicate (#626) visible in both draft and live mode.
Creates a new draft inheriting this gallery's config. */}
<Button
variant="outline"
leftIcon={<Copy className="w-4 h-4" />}
onClick={() => setShowDuplicateDialog(true)}
isLoading={isDuplicating}
className="w-full justify-center"
>
{t('events.duplicateEvent', 'Duplicate gallery')}
</Button>
</div>
</Card>
);
};
@@ -0,0 +1,271 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
ArrowLeft,
ExternalLink,
Calendar,
Archive,
Edit2,
Save,
X,
AlertTriangle,
MessageSquare,
Receipt,
Type,
Send
} from 'lucide-react';
import type { Event } from '../../../types';
import { Button, Card } from '../../../components/common';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { eventsService } from '../../../services/events.service';
import { buildShareLinkUrl } from '../../../utils/url';
import { isGalleryPublic } from '../../../utils/accessControl';
import type { FeedbackSettings as FeedbackSettingsType } from '../../../services/feedback.service';
import { safeParseDate } from './utils';
interface EventDetailsHeaderProps {
event: Event;
id: string | undefined;
isEditing: boolean;
setIsEditing: (editing: boolean) => void;
handleStartEdit: () => void;
handleSaveEdit: () => void;
isSaving: boolean;
feedbackSettings: FeedbackSettingsType;
setShowRenameDialog: (show: boolean) => void;
setShowPublishDialog: (show: boolean) => void;
isPublishing: boolean;
onExtendExpiration: (days: number) => void;
daysUntilExpiration: number | null;
isExpired: boolean;
isExpiring: boolean;
}
export const EventDetailsHeader: React.FC<EventDetailsHeaderProps> = ({
event,
id,
isEditing,
setIsEditing,
handleStartEdit,
handleSaveEdit,
isSaving,
feedbackSettings,
setShowRenameDialog,
setShowPublishDialog,
isPublishing,
onExtendExpiration,
daysUntilExpiration,
isExpired,
isExpiring
}) => {
const navigate = useNavigate();
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { flags } = useFeatureFlags();
return (
<>
{/* Page Header */}
<div className="mb-6">
<Button
variant="outline"
size="sm"
leftIcon={<ArrowLeft className="w-4 h-4" />}
onClick={() => navigate('/admin/events')}
className="mb-4"
>
{t('events.backToEvents')}
</Button>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{event.event_name}</h1>
<div className="flex items-center gap-4 mt-2 text-sm text-neutral-600 dark:text-neutral-400">
{event.event_date && (
<span className="flex items-center">
<Calendar className="w-4 h-4 mr-1" />
{format(safeParseDate(event.event_date)!, 'PPP')}
</span>
)}
<span className="capitalize">{event.event_type}</span>
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
isGalleryPublic(event.require_password)
? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300'
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300'
}`}
>
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
</span>
{event.is_draft ? (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-300">
{t('events.draft')}
</span>
) : null}
{event.is_archived ? (
<span className="text-neutral-500 dark:text-neutral-400 flex items-center">
<Archive className="w-4 h-4 mr-1" />
{t('events.archived')}
</span>
) : null}
</div>
</div>
<div className="flex gap-2 items-center">
{!event.is_archived && (
<>
{isEditing ? (
<>
<Button
variant="outline"
size="sm"
leftIcon={<X className="w-4 h-4" />}
onClick={() => setIsEditing(false)}
>
{t('common.cancel')}
</Button>
<Button
variant="primary"
size="sm"
leftIcon={<Save className="w-4 h-4" />}
onClick={handleSaveEdit}
isLoading={isSaving}
>
{t('events.saveChanges')}
</Button>
</>
) : (
<>
<Button
variant="outline"
size="sm"
leftIcon={<Edit2 className="w-4 h-4" />}
onClick={handleStartEdit}
>
{t('common.edit')}
</Button>
<Button
variant="outline"
size="sm"
leftIcon={<Type className="w-4 h-4" />}
onClick={() => setShowRenameDialog(true)}
>
{t('events.rename.button', 'Rename')}
</Button>
{feedbackSettings?.feedback_enabled && (
<Button
variant="outline"
size="sm"
leftIcon={<MessageSquare className="w-4 h-4" />}
onClick={() => navigate(`/admin/events/${id}/feedback`)}
>
{t('feedback.manage', 'Manage Feedback')}
</Button>
)}
{/* Create a draft invoice for this event pre-fills the
bill editor with the event snapshot + (when exactly
one is linked) the customer. Gated on the bills flag. */}
{flags.bills && (
<Button
variant="outline"
size="sm"
leftIcon={<Receipt className="w-4 h-4" />}
onClick={() => {
const accts = ((event as { customer_accounts?: Array<{ id: number }> }).customer_accounts) || [];
const params = new URLSearchParams({ eventId: String(event.id) });
if (event.event_name) params.set('eventName', event.event_name);
if (event.event_date) params.set('eventDate', String(event.event_date).slice(0, 10));
if (accts.length === 1) params.set('customerAccountId', String(accts[0].id));
navigate(`/admin/clients/bills/new?${params.toString()}`);
}}
>
{t('events.createInvoice', 'Create invoice')}
</Button>
)}
</>
)}
</>
)}
{event.share_link && !isEditing && (
<a
href={event.is_draft
? `${buildShareLinkUrl(event.share_link)}${buildShareLinkUrl(event.share_link).includes('?') ? '&' : '?'}preview=${eventsService.getPreviewToken() || ''}`
: buildShareLinkUrl(event.share_link)
}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-accent hover:opacity-80 border border-accent-dark rounded-lg hover:bg-accent-dark/15 transition-colors"
>
<ExternalLink className="w-4 h-4" />
{t('events.viewGallery')}
</a>
)}
</div>
</div>
</div>
{/* Draft Banner */}
{event.is_draft && !event.is_archived && (
<Card className="p-4 mb-6 border-2 border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 flex-shrink-0 text-yellow-600 dark:text-yellow-400" />
<div className="flex-1">
<p className="font-medium text-yellow-900 dark:text-yellow-200">
{t('events.draft')}
</p>
<p className="text-sm mt-1 text-yellow-700 dark:text-yellow-300">
{t('events.draftBanner')}
</p>
</div>
<Button
variant="primary"
size="sm"
leftIcon={<Send className="w-4 h-4" />}
onClick={() => setShowPublishDialog(true)}
isLoading={isPublishing}
>
{t('events.publishAndNotify')}
</Button>
</div>
</Card>
)}
{/* Expiration Warning */}
{!event.is_archived && (isExpired || isExpiring) && (
<Card className={`p-4 mb-6 border-2 ${isExpired ? 'border-red-500 bg-red-50' : 'border-orange-500 bg-orange-50'}`}>
<div className="flex items-start gap-3">
<AlertTriangle className={`w-5 h-5 flex-shrink-0 ${isExpired ? 'text-red-600' : 'text-orange-600'}`} />
<div className="flex-1">
<p className={`font-medium ${isExpired ? 'text-red-900' : 'text-orange-900'}`}>
{isExpired
? t('events.eventExpiredMessage')
: t('events.eventExpiresIn', { days: daysUntilExpiration })
}
</p>
<p className={`text-sm mt-1 ${isExpired ? 'text-red-700' : 'text-orange-700'}`}>
{isExpired
? t('events.guestsCannotAccessGallery')
: t('events.warningEmailsHaveBeenSent')}
</p>
</div>
{!isExpired && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (confirm(t('events.extendExpiration', { days: 7 }) + '?')) {
onExtendExpiration(7);
}
}}
>
{t('events.extendSevenDays')}
</Button>
)}
</div>
</Card>
)}
</>
);
};
@@ -0,0 +1,900 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import {
Download,
Upload,
Image,
Lock,
Eye,
EyeOff,
Shield,
Monitor,
Droplets,
MousePointer,
Layout,
Trash2
} from 'lucide-react';
import type { Event } from '../../../types';
import { Input, Card, Loading, MarkdownContent, LocalizedDateInput } from '../../../components/common';
import { HeroPhotoSelector, FocalPointPicker, FeedbackSettings } from '../../../components/admin';
import { CustomerAccountPicker } from '../../../components/admin/CustomerAccountPicker';
import { api } from '../../../config/api';
import { buildResourceUrl } from '../../../utils/url';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import type { AdminPhoto } from '../../../services/photos.service';
import type { FeedbackSettings as FeedbackSettingsType } from '../../../services/feedback.service';
import { ExternalFolderPicker } from './ExternalFolderPicker';
import { safeParseDate } from './utils';
import type { EditFormState } from './types';
interface EventInformationCardProps {
event: Event;
id: string | undefined;
isEditing: boolean;
editForm: EditFormState;
setEditForm: React.Dispatch<React.SetStateAction<EditFormState>>;
showNewPassword: boolean;
setShowNewPassword: (show: boolean) => void;
feedbackSettings: FeedbackSettingsType;
setFeedbackSettings: React.Dispatch<React.SetStateAction<FeedbackSettingsType>>;
categories: Array<{ id: number; name: string; slug: string }>;
photos: AdminPhoto[];
phoneFieldEnabled: boolean;
daysUntilExpiration: number | null;
}
export const EventInformationCard: React.FC<EventInformationCardProps> = ({
event,
id,
isEditing,
editForm,
setEditForm,
showNewPassword,
setShowNewPassword,
feedbackSettings,
setFeedbackSettings,
categories,
photos,
phoneFieldEnabled,
daysUntilExpiration
}) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const queryClient = useQueryClient();
const [logoUploading, setLogoUploading] = useState(false);
const handleEventLogoUpload = async (file: File) => {
if (!id) return;
setLogoUploading(true);
try {
const formData = new FormData();
formData.append('logo', file);
await api.post(`/admin/events/${id}/logo`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
toast.success(t('events.eventLogoUploaded', 'Event logo uploaded successfully'));
queryClient.invalidateQueries({ queryKey: ['event', id] });
} catch (error: any) {
toast.error(error?.response?.data?.error || t('events.eventLogoUploadFailed', 'Failed to upload event logo'));
} finally {
setLogoUploading(false);
}
};
const handleEventLogoRemove = async () => {
if (!id) return;
setLogoUploading(true);
try {
await api.delete(`/admin/events/${id}/logo`);
toast.success(t('events.eventLogoRemoved', 'Event logo removed successfully'));
queryClient.invalidateQueries({ queryKey: ['event', id] });
} catch (error: any) {
toast.error(error?.response?.data?.error || t('events.eventLogoRemoveFailed', 'Failed to remove event logo'));
} finally {
setLogoUploading(false);
}
};
return (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.eventInformation')}</h2>
{isEditing ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.welcomeMessageLabel')}
</label>
<textarea
value={editForm.welcome_message}
onChange={(e) => setEditForm(prev => ({ ...prev, welcome_message: e.target.value }))}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
rows={3}
placeholder={t('events.welcomeMessage')}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.hostName')}
</label>
<Input
type="text"
value={editForm.customer_name}
onChange={(e) => setEditForm(prev => ({ ...prev, customer_name: e.target.value }))}
placeholder={t('events.hostNamePlaceholder')}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.hostEmail')}
</label>
<Input
type="email"
value={editForm.customer_email}
onChange={(e) => setEditForm(prev => ({ ...prev, customer_email: e.target.value }))}
placeholder={t('events.hostEmailPlaceholder')}
/>
</div>
{phoneFieldEnabled && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.customerPhone', 'Customer Phone')} ({t('common.optional')})
</label>
<Input
type="tel"
value={editForm.customer_phone}
onChange={(e) => setEditForm(prev => ({ ...prev, customer_phone: e.target.value }))}
placeholder={t('events.customerPhonePlaceholder', '+1 555 555 1234')}
/>
</div>
)}
{/* Customer accounts (#354). Picker self-hides when the
customerPortal feature flag is off. */}
<CustomerAccountPicker
value={editForm.customer_accounts}
onChange={(next) => setEditForm((prev) => ({ ...prev, customer_accounts: next }))}
/>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.expirationDate')}
</label>
<LocalizedDateInput
value={editForm.expires_at}
onChange={(iso) => setEditForm(prev => ({ ...prev, expires_at: iso }))}
min={format(new Date(), 'yyyy-MM-dd')}
/>
</div>
{/* Hero Photo Selection */}
<HeroPhotoSelector
photos={photos || []}
currentHeroPhotoId={editForm.hero_photo_id}
onSelect={(photoId) => setEditForm(prev => ({ ...prev, hero_photo_id: photoId }))}
isEditing={isEditing}
/>
{/* Per-event social-share opt-in (#474). Toggle is
disabled when no hero photo is picked there's
nothing to surface as the cover. The help text
deliberately spells out the public-by-design
consequence so an admin doesn't flip this on for
a sensitive gallery without realising what they're
sharing with link-preview crawlers. */}
<div className="ml-6 mt-3">
<label className={`flex items-start gap-2 cursor-pointer ${editForm.hero_photo_id ? '' : 'opacity-60 cursor-not-allowed'}`}>
<input
type="checkbox"
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
checked={editForm.og_image_share_enabled === true}
disabled={!editForm.hero_photo_id}
onChange={(e) => setEditForm(prev => ({ ...prev, og_image_share_enabled: e.target.checked }))}
/>
<span className="text-sm">
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{t('events.ogShare.title', 'Use hero photo as social-share preview')}
</span>
<span className="block text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
{editForm.hero_photo_id
? t('events.ogShare.help', 'When this gallery URL is shared on WhatsApp, Facebook, Slack, etc., the link preview will show the hero photo above. The thumbnail is fetched unauthenticated by link-preview crawlers — anyone with the URL effectively makes this image public. Off by default; pick a hero you are comfortable surfacing publicly before enabling.')
: t('events.ogShare.heroRequired', 'Pick a hero photo above first — this option uses it as the WhatsApp / Facebook / Slack preview image.')}
</span>
</span>
</label>
</div>
{/* Hero Image Focal Point Picker (#162) */}
{editForm.hero_photo_id && (() => {
const heroPhoto = (photos || []).find((p) => p.id === editForm.hero_photo_id);
const heroImageUrl = heroPhoto?.thumbnail_url || heroPhoto?.url;
if (!heroImageUrl) return null;
return (
<div className="ml-6 mt-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.heroImageAnchor', 'Hero Image Crop Position')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">
{t('events.heroImageAnchorDescription', 'Click on the image to set the focal point for cropping.')}
</p>
<FocalPointPicker
imageUrl={heroImageUrl}
currentValue={editForm.hero_image_anchor}
onChange={(value) => setEditForm(prev => ({ ...prev, hero_image_anchor: value }))}
slug={event.slug}
/>
</div>
);
})()}
<div>
<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={editForm.require_password}
onChange={(e) => {
const checked = e.target.checked;
setEditForm(prev => ({
...prev,
require_password: checked,
new_password: checked ? prev.new_password : '',
confirm_new_password: checked ? prev.confirm_new_password : '',
}));
if (!checked) {
setShowNewPassword(false);
}
}}
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('events.requirePasswordToggle')}</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
</p>
</div>
</label>
{!editForm.require_password && (
<div className="mt-2 rounded-md border border-orange-200 dark:border-orange-800 bg-orange-50 dark:bg-orange-900/30 p-3 text-xs text-orange-800 dark:text-orange-300">
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
</div>
)}
</div>
{editForm.require_password && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.newPasswordLabel', 'New gallery password')}
</label>
<div className="relative">
<Input
type={showNewPassword ? 'text' : 'password'}
value={editForm.new_password}
onChange={(e) => setEditForm(prev => ({ ...prev, new_password: e.target.value }))}
placeholder={t('events.enterPassword')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
{showNewPassword ? (
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300" />
) : (
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300" />
)}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.confirmPassword')}
</label>
<Input
type={showNewPassword ? 'text' : 'password'}
value={editForm.confirm_new_password}
onChange={(e) => setEditForm(prev => ({ ...prev, confirm_new_password: e.target.value }))}
placeholder={t('events.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.sourceMode', 'Source Mode')}
</label>
<select
value={editForm.source_mode}
onChange={(e) => {
const mode = e.target.value as 'managed' | 'reference';
setEditForm(prev => ({
...prev,
source_mode: mode,
external_path: mode === 'reference'
? (prev.external_path || event.external_path || '')
: ''
}));
}}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
>
<option value="managed">{t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}</option>
<option value="reference">{t('events.sourceModeReference', 'Reference external folder')}</option>
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('events.sourceModeHelp', 'Use managed mode for direct uploads or reference an external folder that is mounted at /external-media in Docker.')}
</p>
</div>
{editForm.source_mode === 'reference' && (
<div className="mt-3">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('events.externalFolder', 'External Folder')}
</label>
<ExternalFolderPicker
value={editForm.external_path || ''}
onChange={(folder) => setEditForm(prev => ({ ...prev, external_path: folder }))}
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('events.externalFolderHint', 'These folders come from the /external-media mount inside the container. Ensure it is accessible to the backend process.')}
</p>
</div>
)}
{/* Photo Cap */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.photoCap', 'Photo Limit')}
</label>
<div className="flex items-center gap-2">
<input
type="number"
value={editForm.photo_cap}
onChange={(e) => setEditForm(prev => ({ ...prev, photo_cap: parseInt(e.target.value) || 0 }))}
min={0}
className="w-24 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
/>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{t('events.photoCapHelp', 'Maximum number of photos allowed. 0 = unlimited')}
</span>
</div>
</div>
{/* Default Photo Sort */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('photoSort.defaultSort', 'Default Photo Sort')}
</label>
<select
value={editForm.default_photo_sort}
onChange={(e) => setEditForm(prev => ({ ...prev, default_photo_sort: e.target.value }))}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
>
<option value="upload_date_desc">{t('photoSort.uploadDateNewest', 'Upload Date (Newest First)')}</option>
<option value="upload_date_asc">{t('photoSort.uploadDateOldest', 'Upload Date (Oldest First)')}</option>
<option value="capture_date_desc">{t('photoSort.captureDateNewest', 'Date Taken (Newest First)')}</option>
<option value="capture_date_asc">{t('photoSort.captureDateOldest', 'Date Taken (Oldest First)')}</option>
<option value="filename_asc">{t('photoSort.filenameAZ', 'Filename (A-Z)')}</option>
<option value="filename_desc">{t('photoSort.filenameZA', 'Filename (Z-A)')}</option>
</select>
</div>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.allow_user_uploads}
onChange={(e) => setEditForm(prev => ({ ...prev, allow_user_uploads: e.target.checked }))}
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('events.allowUserUploads')}</span>
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 ml-6">
{t('events.allowUserUploadsHelp')}
</p>
</div>
{editForm.allow_user_uploads && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.uploadCategory')}
</label>
<select
value={editForm.upload_category_id || ''}
onChange={(e) => setEditForm(prev => ({
...prev,
upload_category_id: e.target.value ? parseInt(e.target.value) : null
}))}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
>
<option value="">{t('events.selectCategory')}</option>
{categories?.map(category => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('events.uploadCategoryHelp')}
</p>
</div>
)}
{/* Feedback Settings */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('feedback.settings.title', 'Guest Feedback Settings')}</h3>
<FeedbackSettings
settings={feedbackSettings}
onChange={setFeedbackSettings}
/>
</div>
{/* Promotional Banner Override (#440) — three-way: inherit / custom / off */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
{t('events.promoBanner.title', 'Promotional Banner')}
</h3>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{t('events.promoBanner.help', 'Choose how this gallery handles the promotional banner. "Inherit" uses your global default; "Custom" overrides it for this event; "Off" hides it entirely.')}
</p>
<div className="space-y-2">
{(['inherit', 'custom', 'off'] as const).map((mode) => (
<label key={mode} className="flex items-center">
<input
type="radio"
name="promo_mode"
value={mode}
checked={editForm.promo_mode === mode}
onChange={() => setEditForm(prev => ({ ...prev, promo_mode: mode }))}
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">
{t(`events.promoBanner.mode_${mode}`, mode === 'inherit' ? 'Inherit global default' : mode === 'custom' ? 'Custom override for this event' : 'Off (hide for this event)')}
</span>
</label>
))}
</div>
{editForm.promo_mode === 'custom' && (
<div className="mt-3 space-y-2">
<textarea
value={editForm.promo_markdown}
onChange={(e) => setEditForm(prev => ({ ...prev, promo_markdown: e.target.value }))}
rows={5}
placeholder={t('events.promoBanner.placeholder', 'Markdown content (e.g. **Special offer:** [book your next session](https://example.com))')}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark font-mono text-sm"
/>
{editForm.promo_markdown.trim() && (
<div className="border border-neutral-200 dark:border-neutral-700 rounded-lg p-3 bg-neutral-50 dark:bg-neutral-900">
<p className="text-xs uppercase tracking-wide text-neutral-500 dark:text-neutral-400 mb-2">
{t('events.promoBanner.preview', 'Preview')}
</p>
<MarkdownContent source={editForm.promo_markdown} className="text-sm text-neutral-800 dark:text-neutral-200 prose-sm prose-a:text-primary-600 dark:prose-a:text-primary-400" />
</div>
)}
</div>
)}
</div>
{/* Download Protection Settings */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
<Shield className="w-4 h-4 text-accent" />
{t('events.downloadProtection', 'Download Protection')}
</h3>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.allow_downloads}
onChange={(e) => setEditForm(prev => ({ ...prev, allow_downloads: e.target.checked }))}
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
<Download className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.allowDownloads', 'Allow photo downloads')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.disable_right_click}
onChange={(e) => setEditForm(prev => ({ ...prev, disable_right_click: e.target.checked }))}
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
<MousePointer className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.disableRightClick', 'Block right-click menu')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.watermark_downloads}
onChange={(e) => setEditForm(prev => ({
...prev,
watermark_downloads: e.target.checked,
// Watermarking and presigned URLs are mutually
// exclusive — presigned URLs serve raw bytes from
// S3 without going through the watermark pipeline.
allow_presigned_download: e.target.checked ? false : prev.allow_presigned_download,
}))}
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
<Droplets className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.watermarkDownloads', 'Add watermark to downloads')}</span>
</label>
<label
className={`flex items-center ${editForm.watermark_downloads ? 'opacity-50 cursor-not-allowed' : ''}`}
title={editForm.watermark_downloads
? 'Disabled while watermarks are on — presigned URLs bypass the watermark pipeline.'
: 'When the backend uses STORAGE_BACKEND=s3, "Download All" returns a 5-minute presigned S3 URL instead of streaming through the backend. Saves bandwidth on huge galleries; bypasses watermarking.'
}
>
<input
type="checkbox"
checked={!!editForm.allow_presigned_download}
disabled={editForm.watermark_downloads}
onChange={(e) => setEditForm(prev => ({ ...prev, allow_presigned_download: e.target.checked }))}
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
<Download className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{t('events.allowPresignedDownload', 'Allow direct S3 download (no watermark, S3 mode only)')}
</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.enable_devtools_protection}
onChange={(e) => setEditForm(prev => ({ ...prev, enable_devtools_protection: e.target.checked }))}
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
<Monitor className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.enableDevtoolsProtection', 'Detect developer tools')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.use_canvas_rendering}
onChange={(e) => setEditForm(prev => ({ ...prev, use_canvas_rendering: e.target.checked }))}
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.useCanvasRendering', 'Canvas rendering (advanced protection)')}</span>
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
{t('events.protectionInfo', 'Protection features help prevent unauthorized downloads but cannot block all methods.')}
</p>
</div>
</div>
{/* Hero Logo Settings */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
<Layout className="w-4 h-4 text-accent" />
{t('events.heroLogoSettings', 'Hero Logo Settings')}
</h3>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={editForm.hero_logo_visible}
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_visible: e.target.checked }))}
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.heroLogoVisible', 'Display logo in hero section')}</span>
</label>
{editForm.hero_logo_visible && (
<>
<div className="ml-6">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.heroLogoSize', 'Logo Size')}
</label>
<select
value={editForm.hero_logo_size}
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_size: e.target.value as 'small' | 'medium' | 'large' | 'xlarge' }))}
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md shadow-sm focus:ring-primary-500 focus:border-accent-dark text-sm"
>
<option value="small">{t('events.heroLogoSizeSmall', 'Small')}</option>
<option value="medium">{t('events.heroLogoSizeMedium', 'Medium')}</option>
<option value="large">{t('events.heroLogoSizeLarge', 'Large')}</option>
<option value="xlarge">{t('events.heroLogoSizeXLarge', 'Extra Large')}</option>
</select>
</div>
<div className="ml-6">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.heroLogoPosition', 'Logo Position')}
</label>
<select
value={editForm.hero_logo_position}
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_position: e.target.value as 'top' | 'center' | 'bottom' }))}
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md shadow-sm focus:ring-primary-500 focus:border-accent-dark text-sm"
>
<option value="top">{t('events.heroLogoPositionTop', 'Top (above title)')}</option>
<option value="center">{t('events.heroLogoPositionCenter', 'Center (between title and dates)')}</option>
<option value="bottom">{t('events.heroLogoPositionBottom', 'Bottom (below dates)')}</option>
</select>
</div>
{/* Custom Event Logo Upload */}
<div className="ml-6 mt-3 pt-3 border-t border-neutral-100 dark:border-neutral-700">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('events.eventCustomLogo', 'Custom Event Logo')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">
{t('events.eventCustomLogoDescription', 'Upload a custom logo for this event. This overrides the global branding logo for this gallery only.')}
</p>
{event.hero_logo_url ? (
<div className="flex items-center gap-3">
<div className="w-16 h-16 border border-neutral-200 dark:border-neutral-600 rounded-md flex items-center justify-center bg-neutral-50 dark:bg-neutral-700 overflow-hidden">
<img
src={buildResourceUrl(event.hero_logo_url)}
alt={t('events.eventCustomLogo', 'Custom Event Logo')}
className="max-w-full max-h-full object-contain"
/>
</div>
<div className="flex flex-col gap-1">
<label className="cursor-pointer inline-flex items-center gap-1 text-xs text-accent hover:opacity-80">
<Upload className="w-3 h-3" />
{t('events.replaceLogo', 'Replace')}
<input
type="file"
className="hidden"
accept="image/png,image/jpeg,image/gif,image/svg+xml"
disabled={logoUploading}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleEventLogoUpload(file);
e.target.value = '';
}}
/>
</label>
<button
type="button"
onClick={handleEventLogoRemove}
disabled={logoUploading}
className="inline-flex items-center gap-1 text-xs text-red-600 hover:text-red-700"
>
<Trash2 className="w-3 h-3" />
{t('events.removeLogo', 'Remove')}
</button>
</div>
{logoUploading && <Loading size="sm" />}
</div>
) : (
<div className="flex items-center gap-2">
<label className={`cursor-pointer inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium border border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 rounded-md hover:bg-neutral-50 dark:hover:bg-neutral-700 ${logoUploading ? 'opacity-50 pointer-events-none' : ''}`}>
<Upload className="w-3.5 h-3.5" />
{t('events.uploadEventLogo', 'Upload Logo')}
<input
type="file"
className="hidden"
accept="image/png,image/jpeg,image/gif,image/svg+xml"
disabled={logoUploading}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleEventLogoUpload(file);
e.target.value = '';
}}
/>
</label>
{logoUploading && <Loading size="sm" />}
</div>
)}
</div>
</>
)}
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
{t('events.heroLogoInfo', 'These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.')}
</p>
</div>
</div>
</div>
) : (
<dl className="space-y-4">
<div>
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.sourceMode', 'Source Mode')}</dt>
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
{event.source_mode === 'reference' ? t('events.sourceModeReference', 'Reference external folder') : t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}
{event.source_mode === 'reference' && event.external_path ? (
<span className="text-neutral-500 dark:text-neutral-400 ml-2">/external-media/{event.external_path}</span>
) : null}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.welcomeMessage')}</dt>
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
{event.welcome_message || <span className="text-neutral-400">{t('events.noWelcomeMessageSet')}</span>}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.hostName')}</dt>
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
{event.customer_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.hostEmail')}</dt>
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">{event.customer_email}</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.adminEmail')}</dt>
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">{event.admin_email}</dd>
</div>
</div>
{phoneFieldEnabled && (
<div>
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
{t('events.customerPhone', 'Customer Phone')}
</dt>
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
{event.customer_phone || (
<span className="text-neutral-400">{t('common.notSet')}</span>
)}
</dd>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.created')}</dt>
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
{event.created_at && format(safeParseDate(event.created_at)!, 'PP')}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.expires')}</dt>
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
{event.expires_at ? (
<>
{format(safeParseDate(event.expires_at)!, 'PP')}
{!event.is_archived && daysUntilExpiration !== null && daysUntilExpiration > 0 && (
<span className="text-neutral-500 dark:text-neutral-400 ml-1">
{t('events.daysLeft', { count: daysUntilExpiration })}
</span>
)}
</>
) : (
<span className="text-neutral-500 dark:text-neutral-400">{t('events.neverExpires', 'Never')}</span>
)}
</dd>
</div>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.heroPhoto')}</dt>
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
{event.hero_photo_id ? (
<span className="text-accent">{t('events.heroPhotoSelected')}</span>
) : (
<span className="text-neutral-400">{t('events.noHeroPhotoSelected')}</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.userUploads')}</dt>
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
{event.allow_user_uploads ? (
<div className="space-y-1">
<span className="inline-flex items-center px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/40 rounded">
{t('common.yes')}
</span>
{event.upload_category_id && (
<p className="text-xs text-neutral-600 dark:text-neutral-400">
{t('events.uploadCategory')}: {categories.find(c => c.id === event.upload_category_id)?.name || 'N/A'}
</p>
)}
</div>
) : (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-700 rounded">
{t('common.no')}
</span>
)}
</dd>
</div>
{/* Download Protection Display */}
<div className="pt-3 mt-3 border-t border-neutral-200 dark:border-neutral-700">
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400 flex items-center gap-2">
<Shield className="w-4 h-4" />
{t('events.downloadProtection', 'Download Protection')}
</dt>
<dd className="mt-2 text-sm text-neutral-900 dark:text-neutral-100">
<div className="flex flex-wrap gap-2">
<span className={`inline-flex items-center px-2 py-1 text-xs font-medium rounded ${
event.protection_level === 'maximum' ? 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300' :
event.protection_level === 'enhanced' ? 'bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300' :
event.protection_level === 'standard' ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300' :
'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300'
}`}>
{event.protection_level || 'standard'}
</span>
{event.disable_right_click && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
<MousePointer className="w-3 h-3 mr-1" />
{t('events.rightClickBlocked', 'Right-click blocked')}
</span>
)}
{event.enable_devtools_protection && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
<Monitor className="w-3 h-3 mr-1" />
{t('events.devtoolsDetection', 'DevTools detection')}
</span>
)}
{!event.allow_downloads && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 rounded">
<Download className="w-3 h-3 mr-1" />
{t('events.downloadsDisabled', 'Downloads disabled')}
</span>
)}
{event.watermark_downloads && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
<Droplets className="w-3 h-3 mr-1" />
{t('events.watermarked', 'Watermarked')}
</span>
)}
</div>
</dd>
</div>
{/* Hero Logo Settings Display */}
<div className="pt-3 mt-3 border-t border-neutral-200 dark:border-neutral-700">
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400 flex items-center gap-2">
<Layout className="w-4 h-4" />
{t('events.heroLogoSettings', 'Hero Logo Settings')}
</dt>
<dd className="mt-2 text-sm text-neutral-900 dark:text-neutral-100">
<div className="flex flex-wrap gap-2">
{event.hero_logo_visible !== false ? (
<>
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 rounded">
<Image className="w-3 h-3 mr-1" />
{t('events.heroLogoVisibleLabel', 'Logo visible')}
</span>
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
{t('events.heroLogoSizeLabel', 'Size')}: {event.hero_logo_size || 'medium'}
</span>
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
{t('events.heroLogoPositionLabel', 'Position')}: {event.hero_logo_position || 'top'}
</span>
</>
) : (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
<Image className="w-3 h-3 mr-1" />
{t('events.heroLogoHidden', 'Logo hidden')}
</span>
)}
</div>
</dd>
</div>
</dl>
)}
</Card>
);
};
@@ -0,0 +1,77 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Image } from 'lucide-react';
import type { Event } from '../../../types';
import type { FeedbackSettings as FeedbackSettingsType } from '../../../services/feedback.service';
import type { EventDetailsTab } from './types';
interface EventTabsProps {
event: Event;
eventFeedbackSettings: FeedbackSettingsType | undefined;
activeTab: EventDetailsTab;
setActiveTab: (tab: EventDetailsTab) => void;
}
export const EventTabs: React.FC<EventTabsProps> = ({
event,
eventFeedbackSettings,
activeTab,
setActiveTab
}) => {
const { t } = useTranslation();
return (
<div className="mb-6 border-b border-neutral-200 dark:border-neutral-700">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('overview')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'overview'
? 'border-accent text-accent'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
{t('events.overview')}
</button>
<button
onClick={() => setActiveTab('photos')}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === 'photos'
? 'border-accent text-accent'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
<Image className="w-4 h-4" />
<span>{t('events.photos')}</span>
{event.photo_count !== undefined && event.photo_count > 0 && (
<span className="ml-1 px-2 py-0.5 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-full">
{event.photo_count}
</span>
)}
</button>
<button
onClick={() => setActiveTab('categories')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'categories'
? 'border-accent text-accent'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
{t('events.categories')}
</button>
{eventFeedbackSettings?.identity_mode === 'guest' && (
<button
onClick={() => setActiveTab('guests')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'guests'
? 'border-accent text-accent'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
{t('admin.events.tabs.guests', 'Guests')}
</button>
)}
</nav>
</div>
);
};
@@ -0,0 +1,118 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import type { Event } from '../../../types';
import { Card } from '../../../components/common';
import { ThemeCustomizerEnhanced, ThemeDisplay } from '../../../components/admin';
import { usePublicSettings } from '../../../hooks/usePublicSettings';
import type { EnabledTemplate } from '../../../services/cssTemplates.service';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../../types/theme.types';
import type { EditFormState } from './types';
interface EventThemeSectionProps {
event: Event;
isEditing: boolean;
editForm: EditFormState;
setEditForm: React.Dispatch<React.SetStateAction<EditFormState>>;
currentTheme: ThemeConfig | null;
setCurrentTheme: (theme: ThemeConfig | null) => void;
currentPresetName: string;
setCurrentPresetName: (name: string) => void;
setThemeChanged: (changed: boolean) => void;
cssTemplates: EnabledTemplate[];
}
export const EventThemeSection: React.FC<EventThemeSectionProps> = ({
event,
isEditing,
editForm,
setEditForm,
currentTheme,
setCurrentTheme,
currentPresetName,
setCurrentPresetName,
setThemeChanged,
cssTemplates
}) => {
const { t } = useTranslation();
const { data: publicSettings } = usePublicSettings();
return (
<>
{/* Theme & Style */}
{isEditing && !event.is_archived && (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('branding.themeAndStyle')}</h2>
<ThemeCustomizerEnhanced
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
forceColorMode={publicSettings?.branding_force_color_mode ?? null}
onChange={(theme) => {
setCurrentTheme(theme);
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(theme) }));
setThemeChanged(true);
}}
presetName={currentPresetName}
onPresetChange={(presetName) => {
setCurrentPresetName(presetName);
setThemeChanged(true);
if (presetName !== 'custom') {
const preset = GALLERY_THEME_PRESETS[presetName];
if (preset) {
setCurrentTheme(preset.config);
setEditForm(prev => ({ ...prev, color_theme: presetName }));
}
}
}}
onSyncFromBranding={() => {
// Reset only the 8 colour tokens to the site Branding —
// layout, header, typography all stay so the admin doesn't
// lose tweaks made for this specific event.
const branding = publicSettings?.theme_config as ThemeConfig | undefined;
if (!branding) {
toast.error(t('toast.brandingThemeMissing', 'No branding theme has been saved yet.'));
return;
}
const base = currentTheme || GALLERY_THEME_PRESETS.default.config;
const merged: ThemeConfig = {
...base,
primaryColor: branding.primaryColor,
accentColor: branding.accentColor,
accentDarkColor: branding.accentDarkColor,
backgroundColor: branding.backgroundColor,
surfaceColor: branding.surfaceColor,
elevatedColor: branding.elevatedColor,
surfaceBorderColor: branding.surfaceBorderColor,
textColor: branding.textColor,
mutedTextColor: branding.mutedTextColor,
colorMode: branding.colorMode ?? base.colorMode,
};
setCurrentTheme(merged);
setCurrentPresetName('custom');
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(merged) }));
setThemeChanged(true);
toast.success(t('toast.brandingPaletteSynced', 'Palette synced from Branding.'));
}}
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
cssTemplates={cssTemplates}
cssTemplateId={editForm.css_template_id}
onCssTemplateChange={(templateId) => setEditForm(prev => ({ ...prev, css_template_id: templateId }))}
/>
</Card>
)}
{/* Theme Display (when not editing) */}
{!isEditing && !event.is_archived && (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.galleryTheme')}</h2>
<ThemeDisplay
theme={event.color_theme || GALLERY_THEME_PRESETS.default.config}
presetName={event.color_theme && !event.color_theme.startsWith('{') ? event.color_theme : undefined}
showDetails={true}
/>
</Card>
)}
</>
);
};
@@ -0,0 +1,60 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FolderTreeNode } from './FolderTreeNode';
export const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => void }> = ({ value, onChange }) => {
const { t } = useTranslation();
// Seed expanded paths so the current selection (and the synthetic root) is visible on mount.
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
const set = new Set<string>(['']);
if (value) {
const parts = value.split('/').filter(Boolean);
let acc = '';
for (const seg of parts) {
acc = acc ? `${acc}/${seg}` : seg;
set.add(acc);
}
}
return set;
});
const toggleExpand = (p: string) => {
setExpandedPaths(prev => {
const next = new Set(prev);
if (next.has(p)) next.delete(p);
else next.add(p);
return next;
});
};
return (
<div className="mt-2 border border-neutral-200 dark:border-neutral-700 rounded-lg p-2">
<div className="flex items-center justify-between gap-2 mb-2 px-1">
<div className="text-xs text-neutral-600 dark:text-neutral-400 truncate">
{t('common.selected', 'Selected')}: /external-media/{value}
</div>
{value && (
<button
type="button"
className="text-xs underline text-neutral-700 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-neutral-100 flex-shrink-0"
onClick={() => onChange('')}
>
{t('events.clearSelection', 'Clear')}
</button>
)}
</div>
<div className="max-h-80 overflow-auto [color-scheme:light] dark:[color-scheme:dark]">
<FolderTreeNode
path=""
name="/external-media"
depth={0}
value={value}
onChange={onChange}
expandedPaths={expandedPaths}
toggleExpand={toggleExpand}
/>
</div>
</div>
);
};
@@ -0,0 +1,112 @@
import React from 'react';
import { ChevronRight, ChevronDown, Folder, FolderOpen, Loader2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { externalMediaService } from '../../../services/externalMedia.service';
export const FolderTreeNode: React.FC<{
path: string;
name: string;
depth: number;
value: string;
onChange: (p: string) => void;
expandedPaths: Set<string>;
toggleExpand: (p: string) => void;
}> = ({ path, name, depth, value, onChange, expandedPaths, toggleExpand }) => {
const { t } = useTranslation();
const isExpanded = expandedPaths.has(path);
const isSelected = value === path;
const { data, isLoading, isError } = useQuery({
queryKey: ['external-folder-children', path],
queryFn: () => externalMediaService.list(path),
enabled: isExpanded,
staleTime: 30_000
});
const dirs = (data?.entries || []).filter(e => e.type === 'dir');
const showEmpty = isExpanded && !isLoading && !isError && dirs.length === 0;
const indentStyle = { paddingLeft: depth * 16 + 4 };
const childIndentStyle = { paddingLeft: (depth + 1) * 16 + 4 };
const rowClass =
'flex items-center gap-1 py-1 pr-1 rounded ' +
(isSelected
? 'bg-accent-dark/15'
: 'hover:bg-neutral-50 dark:hover:bg-neutral-700');
return (
<div>
<div className={rowClass} style={indentStyle}>
<button
type="button"
onClick={() => toggleExpand(path)}
className="p-0.5 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200"
aria-label={isExpanded ? t('common.collapse', 'Collapse') : t('common.expand', 'Expand')}
>
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => onChange(path)}
className={
'flex items-center gap-1.5 flex-1 min-w-0 text-left text-sm ' +
(isSelected
? 'text-accent-dark font-medium'
: 'text-neutral-900 dark:text-neutral-100')
}
>
{isExpanded ? (
<FolderOpen className="w-4 h-4 flex-shrink-0 text-accent" />
) : (
<Folder className="w-4 h-4 flex-shrink-0 text-neutral-500" />
)}
<span className="truncate">{name}</span>
</button>
</div>
{isExpanded && (
<div>
{isLoading && (
<div
className="flex items-center gap-2 py-1 text-xs text-neutral-500 dark:text-neutral-400"
style={childIndentStyle}
>
<Loader2 className="w-3 h-3 animate-spin" />
<span>{t('common.loading', 'Loading...')}</span>
</div>
)}
{isError && (
<div
className="py-1 text-xs text-red-600 dark:text-red-400"
style={childIndentStyle}
>
{t('errors.somethingWentWrong', 'Something went wrong')}
</div>
)}
{showEmpty && (
<div
className="py-1 text-xs italic text-neutral-500 dark:text-neutral-400"
style={childIndentStyle}
>
{t('events.externalFolderEmpty', 'No subfolders')}
</div>
)}
{dirs.map(d => {
const childPath = path ? `${path}/${d.name}` : d.name;
return (
<FolderTreeNode
key={childPath}
path={childPath}
name={d.name}
depth={depth + 1}
value={value}
onChange={onChange}
expandedPaths={expandedPaths}
toggleExpand={toggleExpand}
/>
);
})}
</div>
)}
</div>
);
};
@@ -0,0 +1,199 @@
import React from 'react';
import type { Event } from '../../../types';
import { FeedbackModerationPanel } from '../../../components/admin';
import { EventReminderOverrideCard } from '../../../components/admin/EventReminderOverrideCard';
import { SlideshowSettingsCard } from '../../../components/admin/SlideshowSettingsCard';
import { ShortUrlsCard } from '../../../components/admin/ShortUrlsCard';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import type { AdminPhoto } from '../../../services/photos.service';
import type { FeedbackSettings as FeedbackSettingsType } from '../../../services/feedback.service';
import type { EnabledTemplate } from '../../../services/cssTemplates.service';
import { ThemeConfig } from '../../../types/theme.types';
import type { EditFormState, EventDetailsTab } from './types';
import { EventInformationCard } from './EventInformationCard';
import { ShareLinkCard } from './ShareLinkCard';
import { ClientAccessCard } from './ClientAccessCard';
import { EventActionsCard } from './EventActionsCard';
import { PhotoStatisticsCard } from './PhotoStatisticsCard';
import { EventThemeSection } from './EventThemeSection';
import { ArchiveStatusCard } from './ArchiveStatusCard';
interface OverviewTabProps {
event: Event;
id: string | undefined;
isEditing: boolean;
editForm: EditFormState;
setEditForm: React.Dispatch<React.SetStateAction<EditFormState>>;
showNewPassword: boolean;
setShowNewPassword: (show: boolean) => void;
feedbackSettings: FeedbackSettingsType;
setFeedbackSettings: React.Dispatch<React.SetStateAction<FeedbackSettingsType>>;
categories: Array<{ id: number; name: string; slug: string }>;
photos: AdminPhoto[];
phoneFieldEnabled: boolean;
daysUntilExpiration: number | null;
refetchEvent: () => void;
setActiveTab: (tab: EventDetailsTab) => void;
setShowPasswordReset: (show: boolean) => void;
setShowPublishDialog: (show: boolean) => void;
setShowDuplicateDialog: (show: boolean) => void;
onArchive: () => void;
isArchiving: boolean;
isPublishing: boolean;
isDuplicating: boolean;
currentTheme: ThemeConfig | null;
setCurrentTheme: (theme: ThemeConfig | null) => void;
currentPresetName: string;
setCurrentPresetName: (name: string) => void;
setThemeChanged: (changed: boolean) => void;
cssTemplates: EnabledTemplate[];
}
export const OverviewTab: React.FC<OverviewTabProps> = ({
event,
id,
isEditing,
editForm,
setEditForm,
showNewPassword,
setShowNewPassword,
feedbackSettings,
setFeedbackSettings,
categories,
photos,
phoneFieldEnabled,
daysUntilExpiration,
refetchEvent,
setActiveTab,
setShowPasswordReset,
setShowPublishDialog,
setShowDuplicateDialog,
onArchive,
isArchiving,
isPublishing,
isDuplicating,
currentTheme,
setCurrentTheme,
currentPresetName,
setCurrentPresetName,
setThemeChanged,
cssTemplates
}) => {
const { flags } = useFeatureFlags();
return (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Left Column - Main Details */}
<div className="space-y-6">
{/* Event Information */}
<EventInformationCard
event={event}
id={id}
isEditing={isEditing}
editForm={editForm}
setEditForm={setEditForm}
showNewPassword={showNewPassword}
setShowNewPassword={setShowNewPassword}
feedbackSettings={feedbackSettings}
setFeedbackSettings={setFeedbackSettings}
categories={categories}
photos={photos}
phoneFieldEnabled={phoneFieldEnabled}
daysUntilExpiration={daysUntilExpiration}
/>
{/* Share Link */}
<ShareLinkCard event={event} setShowPasswordReset={setShowPasswordReset} />
{/* Branded short URLs (#699). Sits between the canonical share-link
card and the Client Access card same "things you share with
the customer" cluster. */}
<ShortUrlsCard eventId={event.id} />
{/* Client Access (#172) */}
<ClientAccessCard event={event} refetchEvent={refetchEvent} />
{/* Live Slideshow ("Diashow") link + live display settings (migrations 138/139).
Gated behind the `slideshow` feature flag. */}
{flags.slideshow && (
<SlideshowSettingsCard
eventId={event.id}
slug={event.slug}
isArchived={event.is_archived}
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_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. */}
{flags.reminderEmails && (
<EventReminderOverrideCard
eventId={event.id}
initial={{
event_reminder_disabled: event.event_reminder_disabled,
event_reminder_offset_days: event.event_reminder_offset_days,
event_reminder_body_override: event.event_reminder_body_override,
}}
onSaved={() => refetchEvent()}
/>
)}
{/* Actions */}
{!event.is_archived && (
<EventActionsCard
event={event}
onArchive={onArchive}
isArchiving={isArchiving}
setShowPublishDialog={setShowPublishDialog}
isPublishing={isPublishing}
setShowDuplicateDialog={setShowDuplicateDialog}
isDuplicating={isDuplicating}
/>
)}
</div>
{/* Right Column - Statistics, Theme, and Actions */}
<div className="space-y-6">
{/* Photo Statistics */}
<PhotoStatisticsCard event={event} categories={categories} setActiveTab={setActiveTab} />
{/* Theme & Style / Theme Display */}
<EventThemeSection
event={event}
isEditing={isEditing}
editForm={editForm}
setEditForm={setEditForm}
currentTheme={currentTheme}
setCurrentTheme={setCurrentTheme}
currentPresetName={currentPresetName}
setCurrentPresetName={setCurrentPresetName}
setThemeChanged={setThemeChanged}
cssTemplates={cssTemplates}
/>
{/* Feedback Moderation Panel */}
{!event.is_archived && feedbackSettings?.feedback_enabled && (
<FeedbackModerationPanel
eventId={parseInt(id!)}
compact={true}
maxItems={3}
/>
)}
{/* Archive Status */}
{event.is_archived ? (
<ArchiveStatusCard event={event} id={id} />
) : null}
</div>
</div>
);
};
@@ -0,0 +1,78 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Image } from 'lucide-react';
import type { Event } from '../../../types';
import { Button, Card } from '../../../components/common';
import type { EventDetailsTab } from './types';
interface PhotoStatisticsCardProps {
event: Event;
categories: Array<{ id: number; name: string; slug: string }>;
setActiveTab: (tab: EventDetailsTab) => void;
}
export const PhotoStatisticsCard: React.FC<PhotoStatisticsCardProps> = ({
event,
categories,
setActiveTab
}) => {
const { t } = useTranslation();
return (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.photoStatistics')}</h2>
<div className="space-y-3">
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.totalPhotos')}</span>
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{event.photo_count || 0}</span>
</div>
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.totalSize')}</span>
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{event.total_size ? `${(event.total_size / (1024 * 1024)).toFixed(1)} MB` : '0 MB'}
</span>
</div>
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.categories')}</span>
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{categories.length}</span>
</div>
{event.total_views !== undefined && (
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.totalViews')}</span>
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{event.total_views || 0}</span>
</div>
)}
{event.total_downloads !== undefined && (
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.totalDownloads')}</span>
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{event.total_downloads || 0}</span>
</div>
)}
{event.unique_visitors !== undefined && (
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.uniqueVisitors')}</span>
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{event.unique_visitors || 0}</span>
</div>
)}
</div>
<div className="mt-4">
<Button
variant="outline"
size="sm"
leftIcon={<Image className="w-4 h-4" />}
onClick={() => setActiveTab('photos')}
className="w-full justify-center"
>
{t('events.managePhotos')}
</Button>
</div>
</Card>
);
};
@@ -0,0 +1,210 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Upload, X } from 'lucide-react';
import type { Event } from '../../../types';
import { Button, Card, Loading } from '../../../components/common';
import { AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PhotoUploadModal, PhotoFilterPanel, PhotoExportMenu } from '../../../components/admin';
import { externalMediaService } from '../../../services/externalMedia.service';
import { AdminPhoto, type PhotoFilters as PhotoFilterParams, type FeedbackFilters, type FilterSummary } from '../../../services/photos.service';
import { ExternalFolderPicker } from './ExternalFolderPicker';
interface PhotosTabProps {
event: Event;
id: string | undefined;
photos: AdminPhoto[];
photosLoading: boolean;
refetchPhotos: () => void;
categories: Array<{ id: number; name: string; slug: string }>;
photoFilters: PhotoFilterParams;
setPhotoFilters: React.Dispatch<React.SetStateAction<PhotoFilterParams>>;
feedbackFilters: FeedbackFilters;
setFeedbackFilters: React.Dispatch<React.SetStateAction<FeedbackFilters>>;
filterSummary: FilterSummary | undefined;
showMediaFilter: boolean;
}
export const PhotosTab: React.FC<PhotosTabProps> = ({
event,
id,
photos,
photosLoading,
refetchPhotos,
categories,
photoFilters,
setPhotoFilters,
feedbackFilters,
setFeedbackFilters,
filterSummary,
showMediaFilter
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
const [showExternalImport, setShowExternalImport] = useState(false);
const [externalPath, setExternalPath] = useState<string>('');
const [importing, setImporting] = useState<boolean>(false);
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
const [selectedPhotoIds, setSelectedPhotoIds] = useState<number[]>([]);
return (
<div>
{/* Photo Upload Modal */}
<PhotoUploadModal
isOpen={showPhotoUpload}
onClose={() => setShowPhotoUpload(false)}
eventId={parseInt(id!)}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
queryClient.invalidateQueries({ queryKey: ['admin-event-photos', id] });
toast.success(t('toast.uploadSuccess'));
refetchPhotos();
}}
/>
{/* Photo Filters */}
<PhotoFilters
categories={categories}
selectedCategory={photoFilters.category_id}
searchTerm={photoFilters.search ?? ''}
sortBy={photoFilters.sort ?? 'date'}
sortOrder={photoFilters.order ?? 'desc'}
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
mediaType={photoFilters.media_type || 'all'}
onMediaTypeChange={(mediaType) => setPhotoFilters(prev => ({
...prev,
media_type: mediaType === 'all' ? undefined : mediaType
}))}
showMediaFilter={showMediaFilter}
/>
{/* Feedback Filter Panel for Export */}
<PhotoFilterPanel
filters={feedbackFilters}
onChange={setFeedbackFilters}
summary={filterSummary || null}
isLoading={photosLoading}
/>
{/* Actions Bar */}
<div className="mb-4 flex flex-wrap justify-between items-center gap-4">
<div className="flex items-center gap-3">
<Button
variant="primary"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowPhotoUpload(true)}
>
{t('events.uploadPhotos')}
</Button>
{event.source_mode === 'reference' && (
<Button
variant="outline"
size="sm"
onClick={() => setShowExternalImport(true)}
>
{t('events.importExternal', 'Import from External Folder')}
</Button>
)}
</div>
<PhotoExportMenu
eventId={parseInt(id!)}
selectedPhotoIds={selectedPhotoIds}
filters={feedbackFilters}
/>
</div>
{/* Photo Grid */}
{photosLoading ? (
<div className="flex items-center justify-center py-12">
<Loading size="lg" text={t('events.loadingPhotos')} />
</div>
) : (
<AdminPhotoGrid
photos={photos}
eventId={parseInt(id!)}
onPhotoClick={(photo, index) => setSelectedPhoto({ photo, index })}
onPhotosDeleted={() => {
refetchPhotos();
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
}}
onSelectionChange={setSelectedPhotoIds}
categories={categories}
/>
)}
{/* Photo Viewer */}
{selectedPhoto && (
<AdminPhotoViewer
photos={photos}
initialIndex={selectedPhoto.index}
eventId={parseInt(id!)}
onClose={() => setSelectedPhoto(null)}
onPhotoDeleted={() => {
refetchPhotos();
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
setSelectedPhoto(null);
}}
categories={categories}
/>
)}
{/* External Import Modal */}
{showExternalImport && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-2xl w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('events.importExternal', 'Import from External Folder')}</h2>
<button onClick={() => setShowExternalImport(false)} className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300">
<X className="w-5 h-5" />
</button>
</div>
<div className="mb-3 text-sm text-neutral-700 dark:text-neutral-300">
{t('events.externalImportInfo', 'All pictures from the selected folder will be imported.')}
</div>
<div className="mb-2 text-sm text-neutral-700 dark:text-neutral-300">
{t('events.selectExternalFolder', 'Select external folder under /external-media')}
</div>
<ExternalFolderPicker value={externalPath || event.external_path || ''} onChange={setExternalPath} />
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowExternalImport(false)}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
isLoading={importing}
onClick={async () => {
try {
setImporting(true);
const selected = externalPath || event.external_path || '';
if (!selected) {
toast.error(t('errors.somethingWentWrong', 'Something went wrong'));
return;
}
await externalMediaService.importEvent(parseInt(id!), selected, { recursive: true });
toast.success(t('toast.saveSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
queryClient.invalidateQueries({ queryKey: ['admin-event-photos', id] });
setShowExternalImport(false);
} catch (e: any) {
toast.error(e?.response?.data?.error || 'Import failed');
} finally {
setImporting(false);
}
}}
>
{t('events.importFromSelectedFolder', 'Import from selected folder')}
</Button>
</div>
</Card>
</div>
)}
</div>
);
};
@@ -0,0 +1,117 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Copy, CheckCircle, Key, Mail } from 'lucide-react';
import type { Event } from '../../../types';
import { Button, Card } from '../../../components/common';
import { eventsService } from '../../../services/events.service';
import { buildShareLinkUrl } from '../../../utils/url';
import { isGalleryPublic } from '../../../utils/accessControl';
interface ShareLinkCardProps {
event: Event;
setShowPasswordReset: (show: boolean) => void;
}
export const ShareLinkCard: React.FC<ShareLinkCardProps> = ({ event, setShowPasswordReset }) => {
const { t } = useTranslation();
const [copiedLink, setCopiedLink] = useState(false);
const handleCopyLink = async () => {
try {
// Check if share_link exists
if (!event.share_link) {
toast.error(t('errors.noShareLink', 'No share link available'));
return;
}
const shareUrl = buildShareLinkUrl(event.share_link);
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(shareUrl);
} else {
// Fallback for non-HTTPS contexts or older browsers
const textArea = document.createElement('textarea');
textArea.value = shareUrl;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (!successful) {
throw new Error('Copy failed');
}
}
setCopiedLink(true);
setTimeout(() => setCopiedLink(false), 2000);
toast.success(t('toast.linkCopied'));
} catch (err) {
console.error('Copy failed:', err);
toast.error(t('errors.copyFailed', 'Failed to copy link. Please copy manually.'));
}
};
return (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.shareLink')}</h2>
<div className="flex items-center gap-2">
<input
type="text"
value={buildShareLinkUrl(event.share_link)}
readOnly
className="flex-1 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"
/>
<Button
variant="outline"
size="md"
leftIcon={copiedLink ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
onClick={handleCopyLink}
>
{copiedLink ? t('events.copied') : t('events.copy')}
</Button>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-2">
{isGalleryPublic(event.require_password)
? t('events.shareWithGuestsPublic', 'Anyone with this link can view the gallery. No password is required.')
: t('events.shareWithGuests')}
</p>
{!event.is_archived && (
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700 space-y-2">
<Button
variant="outline"
size="sm"
leftIcon={<Key className="w-4 h-4" />}
onClick={() => setShowPasswordReset(true)}
className="w-full justify-center"
>
{t('events.resetGalleryPassword')}
</Button>
<Button
variant="outline"
size="sm"
leftIcon={<Mail className="w-4 h-4" />}
onClick={async () => {
try {
await eventsService.resendCreationEmail(event.id);
toast.success(t('events.creationEmailResent'));
} catch {
toast.error(t('events.failedToResendEmail'));
}
}}
className="w-full justify-center"
>
{t('events.resendCreationEmail')}
</Button>
</div>
)}
</Card>
);
};
@@ -0,0 +1,94 @@
export type EventDetailsTab = 'overview' | 'photos' | 'categories' | 'guests';
export type EditFormState = {
welcome_message: string;
color_theme: string;
css_template_id: number | null;
expires_at: string;
allow_user_uploads: boolean;
upload_category_id: number | null;
hero_photo_id: number | null;
customer_name: string;
customer_email: string;
customer_phone: string;
source_mode: 'managed' | 'reference';
external_path: string;
require_password: boolean;
new_password: string;
confirm_new_password: string;
// Download protection settings
protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
disable_right_click: boolean;
allow_downloads: boolean;
watermark_downloads: boolean;
allow_presigned_download: boolean;
enable_devtools_protection: boolean;
use_canvas_rendering: boolean;
// Hero logo settings
hero_logo_visible: boolean;
hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge';
hero_logo_position: 'top' | 'center' | 'bottom';
// Hero image anchor position (#162) keyword or "X% Y%" focal point
hero_image_anchor: string;
// Photo cap
photo_cap: number;
// Default photo sort
default_photo_sort: string;
// Per-event promotional override (#440). Three-way mode:
// inherit → use the global branding_promo_markdown
// custom → render this event's promo_markdown
// off → no promo for this event regardless of global
promo_mode: 'inherit' | 'custom' | 'off';
promo_markdown: string;
// Customer accounts assigned to this event (#354). Hydrated from
// the GET /admin/events/:id response and sent back as a flat id
// array on save.
customer_accounts: Array<{ id: number; email: string; displayName: string | null }>;
// Per-event opt-in for hero photo as social-share preview (#474).
og_image_share_enabled: boolean;
};
export const INITIAL_EDIT_FORM: EditFormState = {
welcome_message: '',
color_theme: '',
css_template_id: null,
expires_at: '',
allow_user_uploads: false,
upload_category_id: null,
hero_photo_id: null,
customer_name: '',
customer_email: '',
customer_phone: '',
source_mode: 'managed',
external_path: '',
require_password: true,
new_password: '',
confirm_new_password: '',
// Download protection settings
protection_level: 'standard',
disable_right_click: true,
allow_downloads: true,
watermark_downloads: false,
allow_presigned_download: false,
enable_devtools_protection: true,
use_canvas_rendering: false,
// Hero logo settings
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top',
// Hero image anchor position (#162)
hero_image_anchor: 'center',
// Photo cap
photo_cap: 0,
// Default photo sort
default_photo_sort: 'upload_date_desc',
// Per-event promotional override (#440)
promo_mode: 'inherit',
promo_markdown: '',
// Customer accounts (#354) — hydrated from event response.
customer_accounts: [],
// Per-event social-share opt-in (#474). Default false everywhere
// so a freshly opened editor never displays "on" against the saved
// (off) state.
og_image_share_enabled: false,
};
@@ -0,0 +1,19 @@
import { parseISO, isValid } from 'date-fns';
// Helper to safely parse dates that might be strings, Date objects, or timestamps
export const safeParseDate = (dateValue: unknown): Date | null => {
if (!dateValue) {
return null;
}
if (dateValue instanceof Date) {
return dateValue;
}
if (typeof dateValue === 'number') {
return new Date(dateValue);
}
if (typeof dateValue === 'string') {
const parsed = parseISO(dateValue);
return isValid(parsed) ? parsed : new Date(dateValue);
}
return null;
};