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:
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 can’t 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user