feat(branding): inline force color mode with auto-save + clearer palette help text
This commit is contained in:
@@ -90,12 +90,15 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
|||||||
onClick={() => onClose()}
|
onClick={() => onClose()}
|
||||||
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
|
? 'bg-primary-50 dark:bg-primary-900/30 text-accent-dark'
|
||||||
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-100'
|
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
{/* Active icon/text use the theme's accent-dark token so the
|
||||||
|
sidebar follows the user's CI palette (LBM teal, custom
|
||||||
|
branding, etc.) instead of the legacy primary green. */}
|
||||||
<item.icon className={`w-5 h-5 mr-3 ${
|
<item.icon className={`w-5 h-5 mr-3 ${
|
||||||
isActive ? 'text-primary-600' : 'text-neutral-400'
|
isActive ? 'text-accent-dark' : 'text-neutral-400'
|
||||||
}`} />
|
}`} />
|
||||||
{t(item.nameKey)}
|
{t(item.nameKey)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ interface ThemeCustomizerEnhancedProps {
|
|||||||
cssTemplates?: EnabledTemplate[];
|
cssTemplates?: EnabledTemplate[];
|
||||||
cssTemplateId?: number | null;
|
cssTemplateId?: number | null;
|
||||||
onCssTemplateChange?: (templateId: number | null) => void;
|
onCssTemplateChange?: (templateId: number | null) => void;
|
||||||
|
// Force color mode is an instance-level branding setting (not part of the
|
||||||
|
// per-theme config), but it lives next to the per-theme Color Mode picker
|
||||||
|
// so the Branding admin can find both controls in one place. When these
|
||||||
|
// props are omitted (e.g. event-level theme editor), the section is hidden.
|
||||||
|
forceColorMode?: 'dark' | 'light' | null;
|
||||||
|
onForceColorModeChange?: (mode: 'dark' | 'light' | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||||
@@ -116,7 +122,9 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
isApplying = false,
|
isApplying = false,
|
||||||
cssTemplates,
|
cssTemplates,
|
||||||
cssTemplateId,
|
cssTemplateId,
|
||||||
onCssTemplateChange
|
onCssTemplateChange,
|
||||||
|
forceColorMode,
|
||||||
|
onForceColorModeChange
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||||
@@ -875,7 +883,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
}}
|
}}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
||||||
(localTheme.colorMode || 'light') === mode
|
(localTheme.colorMode || 'light') === mode
|
||||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
|
? 'border-accent-dark bg-primary-50 dark:bg-primary-900/30 text-accent-dark'
|
||||||
: 'border-neutral-300 dark:border-neutral-600 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
: 'border-neutral-300 dark:border-neutral-600 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -888,6 +896,51 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
{t('branding.colorModeHelp', 'Auto follows the visitor\'s system preference.')}
|
{t('branding.colorModeHelp', 'Auto follows the visitor\'s system preference.')}
|
||||||
</p>
|
</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-primary-50 dark:bg-primary-900/30 text-accent-dark'
|
||||||
|
: '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>
|
</div>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
@@ -901,15 +954,41 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Surfaces */}
|
{/* Surfaces */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3">
|
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-1">
|
||||||
{t('branding.colorGroupSurfaces', 'Surfaces')}
|
{t('branding.colorGroupSurfaces', 'Surfaces')}
|
||||||
</h4>
|
</h4>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
||||||
|
{t(
|
||||||
|
'branding.colorGroupSurfacesHelp',
|
||||||
|
'The neutral layers behind your content. Background sits furthest back; Surface and Elevated stack on top.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{[
|
{[
|
||||||
{ key: 'backgroundColor', label: t('branding.backgroundColor', 'Background'), help: t('branding.backgroundColorHelp', 'Page base'), fallback: '#fafafa' },
|
{
|
||||||
{ key: 'surfaceColor', label: t('branding.surfaceColor', 'Surface'), help: t('branding.surfaceColorHelp', 'Cards, navigation'), fallback: '#ffffff' },
|
key: 'backgroundColor',
|
||||||
{ key: 'elevatedColor', label: t('branding.elevatedColor', 'Elevated'), help: t('branding.elevatedColorHelp', 'Raised panels, placeholders'), fallback: '#f5f5f5' },
|
label: t('branding.backgroundColor', 'Background'),
|
||||||
{ key: 'surfaceBorderColor', label: t('branding.borderColor', 'Border'), help: t('branding.borderColorHelp', 'Dividers, grid lines'), fallback: '#e5e5e5' },
|
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 }) => (
|
].map(({ key, label, help, fallback }) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
@@ -937,13 +1016,29 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
|
|
||||||
{/* Text */}
|
{/* Text */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3">
|
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-1">
|
||||||
{t('branding.colorGroupText', 'Text')}
|
{t('branding.colorGroupText', 'Text')}
|
||||||
</h4>
|
</h4>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
||||||
|
{t(
|
||||||
|
'branding.colorGroupTextHelp',
|
||||||
|
'Foreground text colours. Primary is for everything readers focus on; Secondary is for supporting copy.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<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'), fallback: '#171717' },
|
{
|
||||||
{ key: 'mutedTextColor', label: t('branding.mutedTextColor', 'Secondary text'), help: t('branding.mutedTextColorHelp', 'Captions, labels, meta'), fallback: '#737373' },
|
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 }) => (
|
].map(({ key, label, help, fallback }) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
@@ -971,13 +1066,35 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
|
|
||||||
{/* Accent */}
|
{/* Accent */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3">
|
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-1">
|
||||||
{t('branding.colorGroupAccent', 'Accent')}
|
{t('branding.colorGroupAccent', 'Accent')}
|
||||||
</h4>
|
</h4>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
||||||
|
{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.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{[
|
{[
|
||||||
{ key: 'accentColor', label: t('branding.accentColor', 'Accent'), help: t('branding.accentColorHelp', 'Links, focus rings, hover'), fallback: '#22c55e' },
|
{
|
||||||
{ key: 'accentDarkColor', label: t('branding.accentDarkColor', 'Accent (filled)'), help: t('branding.accentDarkColorHelp', 'Primary CTA fill'), fallback: '#5C8762' },
|
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 }) => (
|
].map(({ key, label, help, fallback }) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
|
|||||||
@@ -129,6 +129,21 @@ export const BrandingPage: React.FC = () => {
|
|||||||
setBrandingSettings(prev => ({ ...prev, [key]: value }));
|
setBrandingSettings(prev => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force color mode is the only branding setting that auto-saves on click —
|
||||||
|
* users expect a toggle that takes effect immediately, not a setting they
|
||||||
|
* have to remember to click "Save" for. We keep all other branding fields
|
||||||
|
* on the bulk-save flow because typing in a text input shouldn't trigger
|
||||||
|
* a network round-trip per keystroke. Auto-save here invalidates the
|
||||||
|
* public-settings query so AdminDarkModeContext reapplies live without
|
||||||
|
* waiting for its 30-second poll.
|
||||||
|
*/
|
||||||
|
const handleForceColorModeChange = (value: 'dark' | 'light' | null) => {
|
||||||
|
const next = { ...brandingSettings, force_color_mode: value };
|
||||||
|
setBrandingSettings(next);
|
||||||
|
brandingMutation.mutate(next);
|
||||||
|
};
|
||||||
|
|
||||||
const handleThemeChange = (newTheme: ThemeConfig) => {
|
const handleThemeChange = (newTheme: ThemeConfig) => {
|
||||||
// Preset configs don't carry a logoUrl, so a preset change inside the
|
// Preset configs don't carry a logoUrl, so a preset change inside the
|
||||||
// customizer arrives here with newTheme.logoUrl=undefined. Keep the
|
// customizer arrives here with newTheme.logoUrl=undefined. Keep the
|
||||||
@@ -565,47 +580,6 @@ export const BrandingPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*
|
|
||||||
* Force color mode: instance-wide lock for dark or light theme.
|
|
||||||
* When set, the user-facing dark/light toggle is hidden in the
|
|
||||||
* admin header and any per-event/per-theme colorMode is overridden.
|
|
||||||
* Three states: none (user choice), dark, light. Mutually exclusive.
|
|
||||||
*/}
|
|
||||||
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
|
||||||
<h3 className="text-md font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
|
|
||||||
{t('branding.forceColorMode', 'Force color mode')}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mb-3">
|
|
||||||
{t(
|
|
||||||
'branding.forceColorModeHelp',
|
|
||||||
'Lock the entire admin and public site to dark or light. The user-facing toggle is hidden when active.'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div className="flex 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 = (brandingSettings.force_color_mode ?? null) === value;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={String(value)}
|
|
||||||
onClick={() => handleBrandingChange('force_color_mode', value)}
|
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
|
||||||
active
|
|
||||||
? 'bg-primary-600 text-white border-primary-600'
|
|
||||||
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-200 border-neutral-300 dark:border-neutral-600 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -789,6 +763,8 @@ export const BrandingPage: React.FC = () => {
|
|||||||
onPresetChange={handlePresetChange}
|
onPresetChange={handlePresetChange}
|
||||||
showGalleryLayouts={true}
|
showGalleryLayouts={true}
|
||||||
hideActions={true}
|
hideActions={true}
|
||||||
|
forceColorMode={brandingSettings.force_color_mode ?? null}
|
||||||
|
onForceColorModeChange={handleForceColorModeChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user