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()}
|
||||
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{/* 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 ${
|
||||
isActive ? 'text-primary-600' : 'text-neutral-400'
|
||||
isActive ? 'text-accent-dark' : 'text-neutral-400'
|
||||
}`} />
|
||||
{t(item.nameKey)}
|
||||
</NavLink>
|
||||
|
||||
@@ -54,6 +54,12 @@ interface ThemeCustomizerEnhancedProps {
|
||||
cssTemplates?: EnabledTemplate[];
|
||||
cssTemplateId?: number | null;
|
||||
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> = {
|
||||
@@ -116,7 +122,9 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
isApplying = false,
|
||||
cssTemplates,
|
||||
cssTemplateId,
|
||||
onCssTemplateChange
|
||||
onCssTemplateChange,
|
||||
forceColorMode,
|
||||
onForceColorModeChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
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 ${
|
||||
(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'
|
||||
}`}
|
||||
>
|
||||
@@ -888,6 +896,51 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<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-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>
|
||||
|
||||
{/*
|
||||
@@ -901,15 +954,41 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<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">
|
||||
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-1">
|
||||
{t('branding.colorGroupSurfaces', 'Surfaces')}
|
||||
</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">
|
||||
{[
|
||||
{ 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: 'elevatedColor', label: t('branding.elevatedColor', 'Elevated'), help: t('branding.elevatedColorHelp', 'Raised panels, placeholders'), fallback: '#f5f5f5' },
|
||||
{ key: 'surfaceBorderColor', label: t('branding.borderColor', 'Border'), help: t('branding.borderColorHelp', 'Dividers, grid lines'), fallback: '#e5e5e5' },
|
||||
{
|
||||
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 }) => (
|
||||
<div key={key}>
|
||||
<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 */}
|
||||
<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')}
|
||||
</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">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={key}>
|
||||
<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 */}
|
||||
<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')}
|
||||
</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">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={key}>
|
||||
<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 }));
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
// Preset configs don't carry a logoUrl, so a preset change inside the
|
||||
// customizer arrives here with newTheme.logoUrl=undefined. Keep the
|
||||
@@ -565,47 +580,6 @@ export const BrandingPage: React.FC = () => {
|
||||
</label>
|
||||
</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">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
@@ -789,6 +763,8 @@ export const BrandingPage: React.FC = () => {
|
||||
onPresetChange={handlePresetChange}
|
||||
showGalleryLayouts={true}
|
||||
hideActions={true}
|
||||
forceColorMode={brandingSettings.force_color_mode ?? null}
|
||||
onForceColorModeChange={handleForceColorModeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user