fix(branding): selected-state accent colors, force-mode actually flips galleries, compact color picker layout
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||||
|
import { applyForceColorMode } from '../utils/themeMigration';
|
||||||
|
|
||||||
interface GlobalThemeProviderProps {
|
interface GlobalThemeProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -18,14 +19,10 @@ export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ childr
|
|||||||
|
|
||||||
if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) {
|
if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) {
|
||||||
themeAppliedRef.current = true;
|
themeAppliedRef.current = true;
|
||||||
// Honor instance-wide force color mode: when set, override the
|
// Honor instance-wide force color mode: when set, applyForceColorMode
|
||||||
// theme's own colorMode so legacy themes can't render light against
|
// also swaps the surface/text tokens so the page actually flips
|
||||||
// a force-dark instance (or vice-versa).
|
// visually (not just the colorMode flag — see #397 follow-up).
|
||||||
const forced = settingsData.branding_force_color_mode;
|
setTheme(applyForceColorMode(settingsData.theme_config, settingsData.branding_force_color_mode));
|
||||||
const themeWithForce = forced
|
|
||||||
? { ...settingsData.theme_config, colorMode: forced }
|
|
||||||
: settingsData.theme_config;
|
|
||||||
setTheme(themeWithForce);
|
|
||||||
}
|
}
|
||||||
}, [settingsData, setTheme]);
|
}, [settingsData, setTheme]);
|
||||||
|
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={addLink}
|
onClick={addLink}
|
||||||
className="px-3 py-1 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700"
|
className="px-3 py-1 text-sm bg-accent-dark text-white rounded-md hover:bg-primary-700"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{t('email.editor.addLink')}
|
{t('email.editor.addLink')}
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
|||||||
onClick={() => handleLogicChange('AND')}
|
onClick={() => handleLogicChange('AND')}
|
||||||
className={`px-3 py-1 text-sm font-medium transition-colors ${
|
className={`px-3 py-1 text-sm font-medium transition-colors ${
|
||||||
filters.logic === 'AND' || !filters.logic
|
filters.logic === 'AND' || !filters.logic
|
||||||
? 'bg-primary-600 text-white'
|
? 'bg-accent-dark text-white'
|
||||||
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||||
}`}
|
}`}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
@@ -172,7 +172,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
|||||||
onClick={() => handleLogicChange('OR')}
|
onClick={() => handleLogicChange('OR')}
|
||||||
className={`px-3 py-1 text-sm font-medium transition-colors ${
|
className={`px-3 py-1 text-sm font-medium transition-colors ${
|
||||||
filters.logic === 'OR'
|
filters.logic === 'OR'
|
||||||
? 'bg-primary-600 text-white'
|
? 'bg-accent-dark text-white'
|
||||||
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||||
}`}
|
}`}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
|||||||
@@ -62,6 +62,45 @@ interface ThemeCustomizerEnhancedProps {
|
|||||||
onForceColorModeChange?: (mode: 'dark' | 'light' | null) => void;
|
onForceColorModeChange?: (mode: 'dark' | 'light' | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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="cursor-help text-neutral-400 dark:text-neutral-500" title={help}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||||
grid: <Grid3X3 className="w-5 h-5" />,
|
grid: <Grid3X3 className="w-5 h-5" />,
|
||||||
masonry: <Layers className="w-5 h-5" />,
|
masonry: <Layers className="w-5 h-5" />,
|
||||||
@@ -262,7 +301,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
onClick={() => handlePresetSelect(key)}
|
onClick={() => handlePresetSelect(key)}
|
||||||
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
|
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
|
||||||
selectedPreset === key
|
selectedPreset === key
|
||||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
? 'border-accent-dark bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -274,7 +313,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selectedPreset === key && (
|
{selectedPreset === key && (
|
||||||
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
|
<Check className="w-4 h-4 text-accent-dark flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-3">
|
<div className="flex items-center gap-2 mt-3">
|
||||||
@@ -351,7 +390,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
onClick={() => handleChange('galleryLayout', layout)}
|
onClick={() => handleChange('galleryLayout', layout)}
|
||||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||||
localTheme.galleryLayout === layout
|
localTheme.galleryLayout === layout
|
||||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
? 'border-accent-dark bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -370,7 +409,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{localTheme.galleryLayout === layout && (
|
{localTheme.galleryLayout === layout && (
|
||||||
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
|
<Check className="absolute top-2 right-2 w-4 h-4 text-accent-dark" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -698,7 +737,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
onClick={() => handleChange('headerStyle', style)}
|
onClick={() => handleChange('headerStyle', style)}
|
||||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||||
(localTheme.headerStyle || 'standard') === style
|
(localTheme.headerStyle || 'standard') === style
|
||||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
? 'border-accent-dark bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -714,7 +753,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(localTheme.headerStyle || 'standard') === style && (
|
{(localTheme.headerStyle || 'standard') === style && (
|
||||||
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
|
<Check className="absolute top-2 right-2 w-4 h-4 text-accent-dark" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -737,7 +776,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
onClick={() => handleChange('heroDividerStyle', divider)}
|
onClick={() => handleChange('heroDividerStyle', divider)}
|
||||||
className={`relative p-3 rounded-lg border-2 transition-all ${
|
className={`relative p-3 rounded-lg border-2 transition-all ${
|
||||||
(localTheme.heroDividerStyle || 'wave') === divider
|
(localTheme.heroDividerStyle || 'wave') === divider
|
||||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
? 'border-accent-dark bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -751,7 +790,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(localTheme.heroDividerStyle || 'wave') === divider && (
|
{(localTheme.heroDividerStyle || 'wave') === divider && (
|
||||||
<Check className="absolute top-1 right-1 w-3 h-3 text-primary-600" />
|
<Check className="absolute top-1 right-1 w-3 h-3 text-accent-dark" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -777,7 +816,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
onClick={() => handleChange('controlsStyle', 'classic')}
|
onClick={() => handleChange('controlsStyle', 'classic')}
|
||||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||||
(localTheme.controlsStyle || 'classic') === 'classic'
|
(localTheme.controlsStyle || 'classic') === 'classic'
|
||||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
? 'border-accent-dark bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -793,7 +832,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{(localTheme.controlsStyle || 'classic') === 'classic' && (
|
{(localTheme.controlsStyle || 'classic') === 'classic' && (
|
||||||
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
|
<Check className="absolute top-2 right-2 w-4 h-4 text-accent-dark" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -801,7 +840,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
onClick={() => handleChange('controlsStyle', 'sidebar')}
|
onClick={() => handleChange('controlsStyle', 'sidebar')}
|
||||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||||
localTheme.controlsStyle === 'sidebar'
|
localTheme.controlsStyle === 'sidebar'
|
||||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
? 'border-accent-dark bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -817,7 +856,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{localTheme.controlsStyle === 'sidebar' && (
|
{localTheme.controlsStyle === 'sidebar' && (
|
||||||
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
|
<Check className="absolute top-2 right-2 w-4 h-4 text-accent-dark" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -951,18 +990,29 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
* coverage only (per user language profile); other locales will
|
* coverage only (per user language profile); other locales will
|
||||||
* show the fallback until reviewed by a native speaker.
|
* 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.
|
||||||
|
*/}
|
||||||
<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-1">
|
<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')}
|
{t('branding.colorGroupSurfaces', 'Surfaces')}
|
||||||
</h4>
|
<span
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
className="cursor-help text-neutral-400 dark:text-neutral-500"
|
||||||
{t(
|
title={t(
|
||||||
'branding.colorGroupSurfacesHelp',
|
'branding.colorGroupSurfacesHelp',
|
||||||
'The neutral layers behind your content. Background sits furthest back; Surface and Elevated stack on top.'
|
'The neutral layers behind your content. Background sits furthest back; Surface and Elevated stack on top.'
|
||||||
)}
|
)}
|
||||||
</p>
|
>
|
||||||
|
<Info className="w-3.5 h-3.5" />
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@@ -990,41 +1040,32 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
fallback: '#e5e5e5',
|
fallback: '#e5e5e5',
|
||||||
},
|
},
|
||||||
].map(({ key, label, help, fallback }) => (
|
].map(({ key, label, help, fallback }) => (
|
||||||
<div key={key}>
|
<ColorPickerRow
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
key={key}
|
||||||
{label}
|
label={label}
|
||||||
</label>
|
help={help}
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{help}</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
||||||
onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)}
|
fallback={fallback}
|
||||||
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
onChange={(v) => handleChange(key as keyof ThemeConfig, v)}
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
|
||||||
onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)}
|
|
||||||
placeholder={fallback}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text */}
|
{/* Text */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-1">
|
<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')}
|
{t('branding.colorGroupText', 'Text')}
|
||||||
</h4>
|
<span
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
className="cursor-help text-neutral-400 dark:text-neutral-500"
|
||||||
{t(
|
title={t(
|
||||||
'branding.colorGroupTextHelp',
|
'branding.colorGroupTextHelp',
|
||||||
'Foreground text colours. Primary is for everything readers focus on; Secondary is for supporting copy.'
|
'Foreground text colours. Primary is for everything readers focus on; Secondary is for supporting copy.'
|
||||||
)}
|
)}
|
||||||
</p>
|
>
|
||||||
|
<Info className="w-3.5 h-3.5" />
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@@ -1040,41 +1081,32 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
fallback: '#737373',
|
fallback: '#737373',
|
||||||
},
|
},
|
||||||
].map(({ key, label, help, fallback }) => (
|
].map(({ key, label, help, fallback }) => (
|
||||||
<div key={key}>
|
<ColorPickerRow
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
key={key}
|
||||||
{label}
|
label={label}
|
||||||
</label>
|
help={help}
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{help}</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
||||||
onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)}
|
fallback={fallback}
|
||||||
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
onChange={(v) => handleChange(key as keyof ThemeConfig, v)}
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
|
||||||
onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)}
|
|
||||||
placeholder={fallback}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Accent */}
|
{/* Accent */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-1">
|
<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')}
|
{t('branding.colorGroupAccent', 'Accent')}
|
||||||
</h4>
|
<span
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
className="cursor-help text-neutral-400 dark:text-neutral-500"
|
||||||
{t(
|
title={t(
|
||||||
'branding.colorGroupAccentHelp',
|
'branding.colorGroupAccentHelp',
|
||||||
'Brand colours that highlight interactive elements. Use a strong colour pair — Accent is for outlines/text, Accent Dark is for filled buttons.'
|
'Brand colours that highlight interactive elements. Use a strong colour pair — Accent is for outlines/text, Accent Dark is for filled buttons.'
|
||||||
)}
|
)}
|
||||||
</p>
|
>
|
||||||
|
<Info className="w-3.5 h-3.5" />
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
@@ -1096,26 +1128,14 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
fallback: '#5C8762',
|
fallback: '#5C8762',
|
||||||
},
|
},
|
||||||
].map(({ key, label, help, fallback }) => (
|
].map(({ key, label, help, fallback }) => (
|
||||||
<div key={key}>
|
<ColorPickerRow
|
||||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
key={key}
|
||||||
{label}
|
label={label}
|
||||||
</label>
|
help={help}
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{help}</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
||||||
onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)}
|
fallback={fallback}
|
||||||
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
onChange={(v) => handleChange(key as keyof ThemeConfig, v)}
|
||||||
/>
|
/>
|
||||||
<Input
|
|
||||||
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
|
||||||
onChange={(e) => handleChange(key as keyof ThemeConfig, e.target.value)}
|
|
||||||
placeholder={fallback}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* primaryColor is kept in sync with accentDarkColor inside
|
{/* primaryColor is kept in sync with accentDarkColor inside
|
||||||
@@ -1273,14 +1293,14 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
onClick={() => onCssTemplateChange(null)}
|
onClick={() => onCssTemplateChange(null)}
|
||||||
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
|
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
|
||||||
!cssTemplateId
|
!cssTemplateId
|
||||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
? 'border-accent-dark bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{t('branding.noTemplate', 'No Template')}</span>
|
||||||
{!cssTemplateId && (
|
{!cssTemplateId && (
|
||||||
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
|
<Check className="w-4 h-4 text-accent-dark flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">
|
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">
|
||||||
@@ -1295,14 +1315,14 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
onClick={() => onCssTemplateChange(template.id)}
|
onClick={() => onCssTemplateChange(template.id)}
|
||||||
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
|
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
|
||||||
cssTemplateId === template.id
|
cssTemplateId === template.id
|
||||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
? 'border-accent-dark bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{template.name}</span>
|
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{template.name}</span>
|
||||||
{cssTemplateId === template.id && (
|
{cssTemplateId === template.id && (
|
||||||
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
|
<Check className="w-4 h-4 text-accent-dark flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">
|
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">
|
||||||
@@ -1326,7 +1346,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowCssInstructions(!showCssInstructions)}
|
onClick={() => setShowCssInstructions(!showCssInstructions)}
|
||||||
className="flex items-center gap-2 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
className="flex items-center gap-2 text-sm text-accent hover:opacity-80 font-medium"
|
||||||
>
|
>
|
||||||
<Info className="w-4 h-4" />
|
<Info className="w-4 h-4" />
|
||||||
{t('branding.cssInstructions.title', 'How to use Custom CSS')}
|
{t('branding.cssInstructions.title', 'How to use Custom CSS')}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
|
|||||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
import type { Photo } from '../../types';
|
import type { Photo } from '../../types';
|
||||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||||
|
import { applyForceColorMode } from '../../utils/themeMigration';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
interface GalleryViewProps {
|
interface GalleryViewProps {
|
||||||
@@ -342,10 +343,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Honor instance-wide force color mode (Branding > Force color mode).
|
// Honor instance-wide force color mode (Branding > Force color mode).
|
||||||
// The branding-level lock wins over per-event themes so a force-dark
|
// applyForceColorMode pins colorMode AND swaps surface/text tokens
|
||||||
// instance never accidentally renders a light gallery (and vice-versa).
|
// when the active theme doesn't natively support the locked mode,
|
||||||
if (themeToApply && settingsData.branding_force_color_mode) {
|
// so the gallery actually flips visually (#397 follow-up).
|
||||||
themeToApply = { ...themeToApply, colorMode: settingsData.branding_force_color_mode };
|
if (themeToApply) {
|
||||||
|
themeToApply = applyForceColorMode(themeToApply, settingsData.branding_force_color_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply theme with a small delay to ensure it overrides any global theme
|
// Apply theme with a small delay to ensure it overrides any global theme
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { GallerySkeleton } from '../components/gallery/GallerySkeleton';
|
|||||||
import { analyticsService } from '../services/analytics.service';
|
import { analyticsService } from '../services/analytics.service';
|
||||||
import { galleryService } from '../services';
|
import { galleryService } from '../services';
|
||||||
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||||
|
import { applyForceColorMode } from '../utils/themeMigration';
|
||||||
import { buildResourceUrl } from '../utils/url';
|
import { buildResourceUrl } from '../utils/url';
|
||||||
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
|
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
|
||||||
@@ -158,10 +159,11 @@ export const GalleryPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Honor instance-wide force color mode (Branding > Force color mode).
|
// Honor instance-wide force color mode (Branding > Force color mode).
|
||||||
// When set, override per-event/per-theme colorMode so no gallery can
|
// applyForceColorMode pins colorMode AND swaps surface/text tokens
|
||||||
// render light against a force-dark instance.
|
// when the active theme doesn't natively support the locked mode,
|
||||||
if (themeToApply && settingsData.branding_force_color_mode) {
|
// so the gallery actually flips visually (#397 follow-up).
|
||||||
themeToApply = { ...themeToApply, colorMode: settingsData.branding_force_color_mode };
|
if (themeToApply) {
|
||||||
|
themeToApply = applyForceColorMode(themeToApply, settingsData.branding_force_color_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply theme
|
// Apply theme
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
onClick={() => handleBrandingChange('logo_position', position)}
|
onClick={() => handleBrandingChange('logo_position', position)}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
brandingSettings.logo_position === position
|
brandingSettings.logo_position === position
|
||||||
? 'bg-primary-600 text-white'
|
? 'bg-accent-dark text-white'
|
||||||
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
|
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -665,7 +665,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
onClick={() => handleBrandingChange('watermark_position', position.value)}
|
onClick={() => handleBrandingChange('watermark_position', position.value)}
|
||||||
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
|
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
|
||||||
brandingSettings.watermark_position === position.value
|
brandingSettings.watermark_position === position.value
|
||||||
? 'bg-primary-600 text-white border-primary-600'
|
? 'bg-accent-dark text-white border-accent-dark'
|
||||||
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
|||||||
onClick={() => setFilter(s)}
|
onClick={() => setFilter(s)}
|
||||||
className={`text-xs px-3 py-1 rounded-full ${
|
className={`text-xs px-3 py-1 rounded-full ${
|
||||||
filter === s
|
filter === s
|
||||||
? 'bg-primary-600 text-white'
|
? 'bg-accent-dark text-white'
|
||||||
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200'
|
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { migrateThemeConfig } from '../themeMigration';
|
import { migrateThemeConfig, applyForceColorMode } from '../themeMigration';
|
||||||
import type { ThemeConfig } from '../../types/theme.types';
|
import type { ThemeConfig } from '../../types/theme.types';
|
||||||
|
|
||||||
describe('migrateThemeConfig — 8-token palette fill', () => {
|
describe('migrateThemeConfig — 8-token palette fill', () => {
|
||||||
@@ -89,3 +89,71 @@ describe('migrateThemeConfig — 8-token palette fill', () => {
|
|||||||
expect(migrated.accentDarkColor).toBe('#5C8762');
|
expect(migrated.accentDarkColor).toBe('#5C8762');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('applyForceColorMode', () => {
|
||||||
|
const lightTheme: ThemeConfig = {
|
||||||
|
primaryColor: '#5C8762',
|
||||||
|
accentColor: '#22c55e',
|
||||||
|
accentDarkColor: '#5C8762',
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
surfaceColor: '#ffffff',
|
||||||
|
elevatedColor: '#f5f5f5',
|
||||||
|
surfaceBorderColor: '#e5e5e5',
|
||||||
|
textColor: '#171717',
|
||||||
|
mutedTextColor: '#737373',
|
||||||
|
colorMode: 'light',
|
||||||
|
};
|
||||||
|
|
||||||
|
const lbmDark: ThemeConfig = {
|
||||||
|
primaryColor: '#014E4E',
|
||||||
|
accentColor: '#017C7C',
|
||||||
|
accentDarkColor: '#014E4E',
|
||||||
|
backgroundColor: '#0D0D0D',
|
||||||
|
surfaceColor: '#111414',
|
||||||
|
elevatedColor: '#182222',
|
||||||
|
surfaceBorderColor: '#1E2E2E',
|
||||||
|
textColor: '#EBEBEB',
|
||||||
|
mutedTextColor: '#4A6060',
|
||||||
|
colorMode: 'dark',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns the theme unchanged when no force mode is set', () => {
|
||||||
|
expect(applyForceColorMode(lightTheme, null)).toEqual(lightTheme);
|
||||||
|
expect(applyForceColorMode(lightTheme, undefined)).toEqual(lightTheme);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only pins colorMode when the theme already matches the forced mode', () => {
|
||||||
|
const result = applyForceColorMode(lbmDark, 'dark');
|
||||||
|
expect(result.colorMode).toBe('dark');
|
||||||
|
// LBM surfaces preserved.
|
||||||
|
expect(result.backgroundColor).toBe('#0D0D0D');
|
||||||
|
expect(result.surfaceColor).toBe('#111414');
|
||||||
|
expect(result.accentColor).toBe('#017C7C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps surface tokens when forcing a light theme to dark', () => {
|
||||||
|
const result = applyForceColorMode(lightTheme, 'dark');
|
||||||
|
expect(result.colorMode).toBe('dark');
|
||||||
|
// Surfaces flipped to dark defaults.
|
||||||
|
expect(result.backgroundColor).toBe('#0f0f0f');
|
||||||
|
expect(result.surfaceColor).toBe('#1a1a1a');
|
||||||
|
expect(result.elevatedColor).toBe('#242424');
|
||||||
|
expect(result.surfaceBorderColor).toBe('#2e2e2e');
|
||||||
|
expect(result.textColor).toBe('#e5e5e5');
|
||||||
|
expect(result.mutedTextColor).toBe('#a3a3a3');
|
||||||
|
// Brand identity preserved.
|
||||||
|
expect(result.accentColor).toBe('#22c55e');
|
||||||
|
expect(result.accentDarkColor).toBe('#5C8762');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps surface tokens when forcing a dark theme to light', () => {
|
||||||
|
const result = applyForceColorMode(lbmDark, 'light');
|
||||||
|
expect(result.colorMode).toBe('light');
|
||||||
|
expect(result.backgroundColor).toBe('#fafafa');
|
||||||
|
expect(result.surfaceColor).toBe('#ffffff');
|
||||||
|
expect(result.textColor).toBe('#171717');
|
||||||
|
// LBM accent colours survive the flip.
|
||||||
|
expect(result.accentColor).toBe('#017C7C');
|
||||||
|
expect(result.accentDarkColor).toBe('#014E4E');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,70 @@
|
|||||||
import type { ThemeConfig, HeaderStyleType, HeroDividerStyle, GalleryLayoutType } from '../types/theme.types';
|
import type { ThemeConfig, HeaderStyleType, HeroDividerStyle, GalleryLayoutType } from '../types/theme.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surface defaults for the two color modes — the same values applyTheme()
|
||||||
|
* falls back to when a theme has no explicit surface/elevated/border/text
|
||||||
|
* tokens. Exposed here so the force-color-mode helper can swap them
|
||||||
|
* wholesale when an admin locks the instance to a mode that the active
|
||||||
|
* theme doesn't natively support.
|
||||||
|
*/
|
||||||
|
const DARK_SURFACE_DEFAULTS = {
|
||||||
|
backgroundColor: '#0f0f0f',
|
||||||
|
surfaceColor: '#1a1a1a',
|
||||||
|
elevatedColor: '#242424',
|
||||||
|
surfaceBorderColor: '#2e2e2e',
|
||||||
|
textColor: '#e5e5e5',
|
||||||
|
mutedTextColor: '#a3a3a3',
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIGHT_SURFACE_DEFAULTS = {
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
surfaceColor: '#ffffff',
|
||||||
|
elevatedColor: '#f5f5f5',
|
||||||
|
surfaceBorderColor: '#e5e5e5',
|
||||||
|
textColor: '#171717',
|
||||||
|
mutedTextColor: '#737373',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an instance-wide force color mode lock to a theme config.
|
||||||
|
*
|
||||||
|
* If the theme already matches the locked mode (or no lock is set), only
|
||||||
|
* the colorMode flag is pinned. If the theme is locked to a mode it
|
||||||
|
* doesn't natively support (e.g. an admin set Force Dark but is opening
|
||||||
|
* a light gallery preset), the surface/text tokens are replaced with the
|
||||||
|
* matching mode's defaults — the user's accent/accentDark colours are
|
||||||
|
* preserved so brand identity survives the flip.
|
||||||
|
*
|
||||||
|
* Centralised here so GlobalThemeProvider, GalleryPage and GalleryView
|
||||||
|
* stay in sync (#397 follow-up: galleries did not visibly flip when
|
||||||
|
* Force Dark/Light was toggled because only colorMode was overridden,
|
||||||
|
* leaving the original light/dark surface colours in place).
|
||||||
|
*/
|
||||||
|
export function applyForceColorMode(
|
||||||
|
theme: ThemeConfig,
|
||||||
|
forced: 'dark' | 'light' | null | undefined
|
||||||
|
): ThemeConfig {
|
||||||
|
if (!forced) return theme;
|
||||||
|
|
||||||
|
const themeMode = theme.colorMode === 'auto'
|
||||||
|
? (typeof window !== 'undefined'
|
||||||
|
&& window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light')
|
||||||
|
: (theme.colorMode || 'light');
|
||||||
|
|
||||||
|
if (themeMode === forced) {
|
||||||
|
return { ...theme, colorMode: forced };
|
||||||
|
}
|
||||||
|
|
||||||
|
const surfaces = forced === 'dark' ? DARK_SURFACE_DEFAULTS : LIGHT_SURFACE_DEFAULTS;
|
||||||
|
return {
|
||||||
|
...theme,
|
||||||
|
...surfaces,
|
||||||
|
colorMode: forced,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fills in any missing 8-token CI palette fields on legacy themes that were
|
* Fills in any missing 8-token CI palette fields on legacy themes that were
|
||||||
* saved before the palette expanded from 4 → 8 explicit tokens.
|
* saved before the palette expanded from 4 → 8 explicit tokens.
|
||||||
|
|||||||
Reference in New Issue
Block a user