Merge pull request #400 from Luca-Timo/feat/darkmode-color-improvement

feat(branding): 8-token CI palette + force color mode + dark-mode consistency
This commit is contained in:
Paul Nothaft
2026-05-06 20:26:09 +02:00
committed by GitHub
92 changed files with 1748 additions and 578 deletions
@@ -104,4 +104,59 @@ describe('publicSiteService', () => {
expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png'); expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png');
expect(payload.branding.colors.primary).toBe('#5C8762'); expect(payload.branding.colors.primary).toBe('#5C8762');
}); });
it('exposes the 8-token CI palette through branding.colors', async () => {
const publicSiteRows = buildPublicSiteRows({});
const brandingRows = buildBrandingRows({
themeConfig: {
// LBM CI palette (charcoal + teal).
primaryColor: '#014E4E',
accentColor: '#017C7C',
accentDarkColor: '#014E4E',
backgroundColor: '#0D0D0D',
surfaceColor: '#111414',
elevatedColor: '#182222',
surfaceBorderColor: '#1E2E2E',
textColor: '#EBEBEB',
mutedTextColor: '#4A6060'
}
});
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
// Legacy 4 colors still mapped.
expect(payload.branding.colors.primary).toBe('#014E4E');
expect(payload.branding.colors.accent).toBe('#017C7C');
expect(payload.branding.colors.background).toBe('#0D0D0D');
expect(payload.branding.colors.text).toBe('#EBEBEB');
// 8-token CI palette additions.
expect(payload.branding.colors.accentDark).toBe('#014E4E');
expect(payload.branding.colors.surface).toBe('#111414');
expect(payload.branding.colors.elevated).toBe('#182222');
expect(payload.branding.colors.border).toBe('#1E2E2E');
expect(payload.branding.colors.mutedText).toBe('#4A6060');
});
it('falls back accentDark to legacy primaryColor when the new key is absent', async () => {
const publicSiteRows = buildPublicSiteRows({});
const brandingRows = buildBrandingRows({
themeConfig: {
primaryColor: '#5C8762',
accentColor: '#22c55e',
backgroundColor: '#fafafa',
textColor: '#171717'
// accentDarkColor intentionally omitted to simulate a legacy theme.
}
});
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.branding.colors.accentDark).toBe('#5C8762');
});
}); });
+11 -2
View File
@@ -219,9 +219,17 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_header, logo_display_header,
logo_display_hero, logo_display_hero,
logo_display_mode, logo_display_mode,
hide_powered_by hide_powered_by,
force_color_mode
} = req.body; } = req.body;
// Normalize force_color_mode: only 'dark' | 'light' | null are valid.
const normalizedForceColorMode = force_color_mode === 'dark'
? 'dark'
: force_color_mode === 'light'
? 'light'
: null;
// Get current watermark settings hash for change detection // Get current watermark settings hash for change detection
const oldSettingsHash = await watermarkService.getSettingsHash(); const oldSettingsHash = await watermarkService.getSettingsHash();
@@ -243,7 +251,8 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_header, logo_display_header,
logo_display_hero, logo_display_hero,
logo_display_mode, logo_display_mode,
hide_powered_by hide_powered_by,
force_color_mode: normalizedForceColorMode
}; };
// Handle favicon deletion if empty string or null is provided // Handle favicon deletion if empty string or null is provided
+8
View File
@@ -65,6 +65,14 @@ router.get('/', async (req, res) => {
branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false, branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false,
branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text', branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text',
branding_hide_powered_by: settingsObject.branding_hide_powered_by === true, branding_hide_powered_by: settingsObject.branding_hide_powered_by === true,
// Force a specific color mode site-wide. When set, the user toggle
// is hidden and the value overrides per-theme/system preference.
// Allowed values: 'dark' | 'light' | null (null = no force).
branding_force_color_mode: settingsObject.branding_force_color_mode === 'dark'
? 'dark'
: settingsObject.branding_force_color_mode === 'light'
? 'light'
: null,
theme_config: settingsObject.theme_config || null, theme_config: settingsObject.theme_config || null,
default_language: settingsObject.general_default_language || 'en', default_language: settingsObject.general_default_language || 'en',
enable_analytics: settingsObject.general_enable_analytics !== false, enable_analytics: settingsObject.general_enable_analytics !== false,
+64 -19
View File
@@ -162,29 +162,52 @@ function darkenColor(hex, amount = 0.15) {
// Wrap HTML body in the styled email template with header, footer, and logo // Wrap HTML body in the styled email template with header, footer, and logo
async function wrapEmailHtml(htmlBody, subject, language = 'en') { async function wrapEmailHtml(htmlBody, subject, language = 'en') {
// Get branding settings for logo and email colors // Email colour palette. The two original settings (email_primary_color and
// email_secondary_color) keep their existing semantics so emails sent by
// upgraded instances render byte-for-byte identically until an admin
// touches the new fields. The six new tokens unlock full email theming
// (body bg, container card, list panel, body text, muted text, button text)
// and default to the previously hard-coded literals when absent.
let logoUrl = ''; let logoUrl = '';
let companyName = 'PicPeak'; let companyName = 'PicPeak';
let primaryColor = '#5C8762'; let primaryColor = '#5C8762';
let secondaryColor = '#f9f9f9'; let secondaryColor = '#f9f9f9';
let bodyBgColor = '#f5f5f5'; // outer wrapper + body background
let containerBgColor = '#ffffff'; // email card
let listBgColor = '#f9f9f9'; // <ul> info panel inside content
let bodyTextColor = '#333333'; // paragraph text + <strong>
let mutedTextColor = '#666666'; // footer text
let buttonTextColor = '#ffffff'; // CTA text on primary button
try { try {
const brandingSettings = await db('app_settings') const brandingSettings = await db('app_settings')
.whereIn('setting_key', [ .whereIn('setting_key', [
'branding_logo_url', 'branding_company_name', 'branding_logo_url', 'branding_company_name',
'email_primary_color', 'email_secondary_color' 'email_primary_color', 'email_secondary_color',
'email_body_bg_color', 'email_container_bg_color',
'email_list_bg_color', 'email_body_text_color',
'email_muted_text_color', 'email_button_text_color'
]) ])
.select('setting_key', 'setting_value'); .select('setting_key', 'setting_value');
const readSetting = (val, fallback) => {
if (!val) return fallback;
try { return JSON.parse(val); } catch (e) { return val; }
};
brandingSettings.forEach(setting => { brandingSettings.forEach(setting => {
const val = setting.setting_value; const val = setting.setting_value;
if (setting.setting_key === 'branding_logo_url' && val) { switch (setting.setting_key) {
try { logoUrl = JSON.parse(val); } catch (e) { logoUrl = val; } case 'branding_logo_url': logoUrl = readSetting(val, logoUrl); break;
} else if (setting.setting_key === 'branding_company_name' && val) { case 'branding_company_name': companyName = readSetting(val, companyName); break;
try { companyName = JSON.parse(val); } catch (e) { companyName = val; } case 'email_primary_color': primaryColor = readSetting(val, primaryColor); break;
} else if (setting.setting_key === 'email_primary_color' && val) { case 'email_secondary_color': secondaryColor = readSetting(val, secondaryColor); break;
try { primaryColor = JSON.parse(val); } catch (e) { primaryColor = val; } case 'email_body_bg_color': bodyBgColor = readSetting(val, bodyBgColor); break;
} else if (setting.setting_key === 'email_secondary_color' && val) { case 'email_container_bg_color': containerBgColor = readSetting(val, containerBgColor); break;
try { secondaryColor = JSON.parse(val); } catch (e) { secondaryColor = val; } case 'email_list_bg_color': listBgColor = readSetting(val, listBgColor); break;
case 'email_body_text_color': bodyTextColor = readSetting(val, bodyTextColor); break;
case 'email_muted_text_color': mutedTextColor = readSetting(val, mutedTextColor); break;
case 'email_button_text_color': buttonTextColor = readSetting(val, buttonTextColor); break;
default: break;
} }
}); });
} catch (error) { } catch (error) {
@@ -211,17 +234,17 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
margin: 0; margin: 0;
padding: 0; padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f5f5f5; background-color: ${bodyBgColor};
color: #333; color: ${bodyTextColor};
} }
.email-wrapper { .email-wrapper {
background-color: #f5f5f5; background-color: ${bodyBgColor};
padding: 40px 20px; padding: 40px 20px;
} }
.email-container { .email-container {
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
background-color: #ffffff; background-color: ${containerBgColor};
border-radius: 8px; border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden; overflow: hidden;
@@ -250,7 +273,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
margin-bottom: 15px; margin-bottom: 15px;
} }
.email-content ul { .email-content ul {
background-color: #f9f9f9; background-color: ${listBgColor};
padding: 20px 20px 20px 40px; padding: 20px 20px 20px 40px;
border-radius: 5px; border-radius: 5px;
margin: 20px 0; margin: 20px 0;
@@ -262,7 +285,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
display: inline-block; display: inline-block;
padding: 12px 30px; padding: 12px 30px;
background-color: ${primaryColor}; background-color: ${primaryColor};
color: white !important; color: ${buttonTextColor} !important;
text-decoration: none; text-decoration: none;
border-radius: 5px; border-radius: 5px;
font-weight: 500; font-weight: 500;
@@ -284,7 +307,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
opacity: 0.8; opacity: 0.8;
} }
.email-footer p { .email-footer p {
color: #666; color: ${mutedTextColor};
font-size: 14px; font-size: 14px;
margin: 5px 0; margin: 5px 0;
} }
@@ -296,7 +319,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
color: ${hoverColor}; color: ${hoverColor};
} }
strong { strong {
color: #333; color: ${bodyTextColor};
} }
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
.email-wrapper { .email-wrapper {
@@ -565,12 +588,34 @@ async function processTemplate(template, variables, language = 'en') {
}; };
const ci18n = clientAccessI18n[language] || clientAccessI18n.en; const ci18n = clientAccessI18n[language] || clientAccessI18n.en;
// The client-access CTA used to be a hard-coded #5C8762 fill; now it
// mirrors the configurable email_primary_color so the brand colour is
// consistent across every button in the email. Defaults match the
// historical literal so unchanged installs render identically.
let cli_primary = '#5C8762';
let cli_buttonText = '#ffffff';
try {
const rows = await db('app_settings')
.whereIn('setting_key', ['email_primary_color', 'email_button_text_color'])
.select('setting_key', 'setting_value');
rows.forEach((row) => {
if (!row.setting_value) return;
let parsed;
try { parsed = JSON.parse(row.setting_value); } catch (e) { parsed = row.setting_value; }
if (row.setting_key === 'email_primary_color') cli_primary = parsed || cli_primary;
if (row.setting_key === 'email_button_text_color') cli_buttonText = parsed || cli_buttonText;
});
} catch (e) {
// Non-fatal — fall back to literals so the email still renders.
logger.warn('Failed to read email colours for client-access block', { error: e.message });
}
htmlBody += ` htmlBody += `
<div style="margin-top: 24px; padding: 20px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;"> <div style="margin-top: 24px; padding: 20px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
<strong style="font-size: 15px;">&#128274; ${ci18n.label}</strong> <strong style="font-size: 15px;">&#128274; ${ci18n.label}</strong>
<p style="margin: 10px 0 8px;">${ci18n.desc}</p> <p style="margin: 10px 0 8px;">${ci18n.desc}</p>
<p style="margin: 8px 0;"> <p style="margin: 8px 0;">
<a href="${processedVariables.client_link}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">${ci18n.link}</a> <a href="${processedVariables.client_link}" style="display: inline-block; padding: 10px 20px; background-color: ${cli_primary}; color: ${cli_buttonText}; text-decoration: none; border-radius: 6px; font-weight: 600;">${ci18n.link}</a>
</p> </p>
<p style="margin: 8px 0;">${ci18n.pin}: <strong>${processedVariables.client_password}</strong></p> <p style="margin: 8px 0;">${ci18n.pin}: <strong>${processedVariables.client_password}</strong></p>
<p style="color: #856404; font-size: 12px; margin: 8px 0 0;">&#9888;&#65039; ${ci18n.warning}</p> <p style="color: #856404; font-size: 12px; margin: 8px 0 0;">&#9888;&#65039; ${ci18n.warning}</p>
+16 -1
View File
@@ -87,11 +87,19 @@ async function fetchBrandingContext() {
supportEmail: null, supportEmail: null,
logoUrl: null, logoUrl: null,
footerText: null, footerText: null,
// 8-token CI palette mirrored from frontend ThemeConfig.
// primary/accent are kept as legacy aliases (primary == accent-dark);
// new tokens are surface, elevated, border, mutedText, accentDark.
colors: { colors: {
primary: '#16a34a', primary: '#16a34a',
accent: '#0f766e', accent: '#0f766e',
accentDark: '#16a34a',
background: '#f4fbf6', background: '#f4fbf6',
text: '#0f172a' surface: '#ffffff',
elevated: '#f5f5f5',
border: '#e5e5e5',
text: '#0f172a',
mutedText: '#737373'
} }
}; };
@@ -117,10 +125,17 @@ async function fetchBrandingContext() {
try { try {
const themeConfig = typeof parsed === 'string' ? JSON.parse(parsed) : parsed; const themeConfig = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
if (themeConfig && typeof themeConfig === 'object') { if (themeConfig && typeof themeConfig === 'object') {
// Legacy 4 colors
context.colors.primary = themeConfig.primaryColor || context.colors.primary; context.colors.primary = themeConfig.primaryColor || context.colors.primary;
context.colors.accent = themeConfig.accentColor || context.colors.accent; context.colors.accent = themeConfig.accentColor || context.colors.accent;
context.colors.background = themeConfig.backgroundColor || context.colors.background; context.colors.background = themeConfig.backgroundColor || context.colors.background;
context.colors.text = themeConfig.textColor || context.colors.text; context.colors.text = themeConfig.textColor || context.colors.text;
// 8-token CI palette additions
context.colors.accentDark = themeConfig.accentDarkColor || themeConfig.primaryColor || context.colors.accentDark;
context.colors.surface = themeConfig.surfaceColor || context.colors.surface;
context.colors.elevated = themeConfig.elevatedColor || context.colors.elevated;
context.colors.border = themeConfig.surfaceBorderColor || context.colors.border;
context.colors.mutedText = themeConfig.mutedTextColor || context.colors.mutedText;
} }
} catch (error) { } catch (error) {
logger.warn('Failed to parse theme configuration for public site', { error: error.message }); logger.warn('Failed to parse theme configuration for public site', { error: error.message });
@@ -18,6 +18,7 @@ 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;
// Instance-wide force color mode is enforced inside ThemeContext.applyTheme.
setTheme(settingsData.theme_config); setTheme(settingsData.theme_config);
} }
}, [settingsData, setTheme]); }, [settingsData, setTheme]);
@@ -133,7 +133,7 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
onClick={() => setTab(k)} onClick={() => setTab(k)}
className={`px-3 py-2 text-sm font-medium border-b-2 transition ${ className={`px-3 py-2 text-sm font-medium border-b-2 transition ${
tab === k tab === k
? 'border-primary-500 text-primary-600 dark:text-primary-400' ? 'border-accent text-accent'
: 'border-transparent text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100' : 'border-transparent text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100'
}`} }`}
> >
@@ -245,7 +245,7 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
type="checkbox" type="checkbox"
checked={mergeSelection.includes(guest.id)} checked={mergeSelection.includes(guest.id)}
onChange={() => toggleMergeSelection(guest.id)} onChange={() => toggleMergeSelection(guest.id)}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500" className="w-4 h-4 text-accent rounded focus:ring-primary-500"
/> />
</td> </td>
)} )}
@@ -278,7 +278,7 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
<button <button
type="button" type="button"
onClick={() => setSelectedGuest(guest)} onClick={() => setSelectedGuest(guest)}
className="p-1 text-neutral-500 hover:text-primary-600" className="p-1 text-neutral-500 hover:text-accent"
title={t('admin.guests.view', 'View details')} title={t('admin.guests.view', 'View details')}
> >
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
@@ -286,7 +286,7 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
<div className="relative group"> <div className="relative group">
<button <button
type="button" type="button"
className="p-1 text-neutral-500 hover:text-primary-600" className="p-1 text-neutral-500 hover:text-accent"
title={t('admin.guests.export', 'Export')} title={t('admin.guests.export', 'Export')}
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
+16 -13
View File
@@ -23,7 +23,7 @@ interface AdminHeaderProps {
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => { export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout } = useAdminAuth(); const { user, logout } = useAdminAuth();
const { isDark, toggle: toggleDarkMode } = useAdminDarkMode(); const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode();
const { t } = useTranslation(); const { t } = useTranslation();
const { format } = useLocalizedDate(); const { format } = useLocalizedDate();
const { formatTimeAgo } = useLocalizedTimeAgo(); const { formatTimeAgo } = useLocalizedTimeAgo();
@@ -116,14 +116,17 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
{/* Language Selector */} {/* Language Selector */}
<LanguageSelector /> <LanguageSelector />
{/* Dark Mode Toggle */} {/* Dark Mode Toggle — hidden entirely when an admin has locked
<button the instance to a specific mode via Branding > Force color mode. */}
onClick={toggleDarkMode} {!forcedMode && (
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors" <button
title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')} onClick={toggleDarkMode}
> className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />} title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')}
</button> >
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
)}
{/* Notifications */} {/* Notifications */}
<div className="relative" ref={notificationRef}> <div className="relative" ref={notificationRef}>
@@ -146,7 +149,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
{unreadCount > 0 && ( {unreadCount > 0 && (
<button <button
onClick={() => markAllAsReadMutation.mutate()} onClick={() => markAllAsReadMutation.mutate()}
className="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 flex items-center gap-1" className="text-xs text-accent hover:opacity-80 flex items-center gap-1"
title={t('admin.markAllRead')} title={t('admin.markAllRead')}
> >
<CheckCircle className="w-3 h-3" /> <CheckCircle className="w-3 h-3" />
@@ -175,7 +178,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<div <div
key={notification.id} key={notification.id}
className={`px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-700 cursor-pointer border-l-4 ${ className={`px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-700 cursor-pointer border-l-4 ${
notification.isRead ? 'border-transparent opacity-75' : 'border-primary-500' notification.isRead ? 'border-transparent opacity-75' : 'border-accent-dark'
}`} }`}
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
@@ -200,7 +203,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<div className="px-4 py-2 border-t border-neutral-100 dark:border-neutral-700 text-center"> <div className="px-4 py-2 border-t border-neutral-100 dark:border-neutral-700 text-center">
<button <button
onClick={() => setShowNotifications(false)} onClick={() => setShowNotifications(false)}
className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300" className="text-sm text-accent hover:opacity-80"
> >
{t('admin.close')} {t('admin.close')}
</button> </button>
@@ -220,7 +223,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{user?.username}</p> <p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{user?.username}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{user?.email}</p> <p className="text-xs text-neutral-500 dark:text-neutral-400">{user?.email}</p>
</div> </div>
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center"> <div className="w-8 h-8 bg-accent-dark rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-white" /> <User className="w-5 h-5 text-white" />
</div> </div>
</button> </button>
@@ -19,7 +19,7 @@ export const AdminLayout: React.FC = () => {
return ( return (
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-950 flex items-center justify-center"> <div className="min-h-screen bg-neutral-50 dark:bg-neutral-950 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="w-16 h-16 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div> <div className="w-16 h-16 border-4 border-accent-dark border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-neutral-600">Loading...</p> <p className="text-neutral-600">Loading...</p>
</div> </div>
</div> </div>
@@ -284,7 +284,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
> >
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center ${ <div className={`w-6 h-6 rounded border-2 flex items-center justify-center ${
selectedPhotos.has(photo.id) selectedPhotos.has(photo.id)
? 'bg-primary-600 border-primary-600' ? 'bg-accent-dark border-accent-dark'
: 'bg-white/90 border-white' : 'bg-white/90 border-white'
}`}> }`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />} {selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
@@ -419,7 +419,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
)} )}
{commentCount > 0 && ( {commentCount > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${commentCount} comments`}> <div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${commentCount} comments`}>
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" /> <MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{commentCount}</span> <span className="text-xs font-medium text-neutral-700">{commentCount}</span>
</div> </div>
)} )}
@@ -266,7 +266,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
</span> </span>
<button <button
onClick={() => setShowCategoryMenu(!showCategoryMenu)} onClick={() => setShowCategoryMenu(!showCategoryMenu)}
className="text-xs text-primary-400 hover:text-primary-300" className="text-xs text-accent hover:text-accent-dark"
> >
Change Change
</button> </button>
@@ -393,7 +393,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
<div className="space-y-2"> <div className="space-y-2">
<button <button
onClick={() => setExpandedComments(!expandedComments)} onClick={() => setExpandedComments(!expandedComments)}
className="text-xs text-primary-400 hover:text-primary-300 mb-2" className="text-xs text-accent hover:text-accent-dark mb-2"
> >
{expandedComments ? 'Hide' : 'Show'} Comments ({comments.length}) {expandedComments ? 'Hide' : 'Show'} Comments ({comments.length})
</button> </button>
@@ -90,12 +90,17 @@ 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-accent-dark text-white'
: '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'
}`} }`}
> >
{/* Selected item: solid accent-dark fill with white text/icon
for unambiguous high-contrast selection — matches the
.tile-selected pattern used in the customizer. The accent
-dark token defaults to the legacy primary green so users
who haven't set CI colours yet see no migration regression. */}
<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-white' : 'text-neutral-400'
}`} /> }`} />
{t(item.nameKey)} {t(item.nameKey)}
</NavLink> </NavLink>
@@ -136,7 +141,7 @@ const StorageInfo: React.FC = () => {
? Math.round((storageInfo.total_used / limitInUse) * 100) ? Math.round((storageInfo.total_used / limitInUse) * 100)
: 0; : 0;
const isOverSoftLimit = limitInUse && storageInfo.total_used >= limitInUse; const isOverSoftLimit = limitInUse && storageInfo.total_used >= limitInUse;
const progressBarClass = isOverSoftLimit ? 'bg-red-600' : 'bg-primary-600'; const progressBarClass = isOverSoftLimit ? 'bg-red-600' : 'bg-accent-dark';
const containerClass = isOverSoftLimit const containerClass = isOverSoftLimit
? 'bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800' ? 'bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800'
: 'bg-neutral-100 dark:bg-neutral-800'; : 'bg-neutral-100 dark:bg-neutral-800';
@@ -184,7 +184,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
onClick={() => handleChange('backup_destination_type', type.id)} onClick={() => handleChange('backup_destination_type', type.id)}
className={`p-4 rounded-lg border-2 transition-all ${ className={`p-4 rounded-lg border-2 transition-all ${
formData.backup_destination_type === type.id formData.backup_destination_type === type.id
? 'border-primary bg-primary-50 dark:bg-primary-900/30' ? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500' : 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`} }`}
> >
@@ -385,7 +385,7 @@ export const BackupHistory = () => {
onClick={() => setCurrentPage(pageNum)} onClick={() => setCurrentPage(pageNum)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${ className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === pageNum currentPage === pageNum
? 'z-10 bg-primary-50 dark:bg-primary-900/30 border-primary text-primary' ? 'z-10 bg-accent-dark/15 border-primary text-primary'
: 'bg-white dark:bg-neutral-800 border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700' : 'bg-white dark:bg-neutral-800 border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700'
}`} }`}
> >
@@ -25,13 +25,13 @@ export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<div className="p-6"> <div className="p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">Confirm Bulk Archive</h2> <h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Confirm Bulk Archive</h2>
<button <button
onClick={onClose} onClick={onClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors" className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
disabled={isLoading} disabled={isLoading}
> >
<X className="w-5 h-5 text-neutral-500" /> <X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
</button> </button>
</div> </div>
@@ -64,7 +64,7 @@ export const BulkCategoryModal: React.FC<BulkCategoryModalProps> = ({
id="category-select" id="category-select"
value={selectedCategoryId ?? ''} value={selectedCategoryId ?? ''}
onChange={(e) => setSelectedCategoryId(e.target.value === '' ? null : Number(e.target.value))} onChange={(e) => setSelectedCategoryId(e.target.value === '' ? null : Number(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 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" 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 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
disabled={isLoading} disabled={isLoading}
> >
<option value="">{t('photos.uncategorized', 'Uncategorized')}</option> <option value="">{t('photos.uncategorized', 'Uncategorized')}</option>
+39 -48
View File
@@ -143,8 +143,10 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<button <button
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={`p-2 rounded hover:bg-neutral-100 transition-colors ${ className={`p-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors ${
active ? 'bg-primary-100 text-primary-700' : 'text-neutral-700' active
? 'bg-accent-dark/15 text-accent-dark'
: 'text-neutral-700 dark:text-neutral-200'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`} } ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={title} title={title}
type="button" type="button"
@@ -172,44 +174,32 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
}); });
}; };
// Reusable view-mode chip — three states (edit/preview/split). Shared
// styling block extracted as a const so the dark variants stay in sync.
const viewModeChipClass = (mode: typeof viewMode) =>
`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === mode
? 'bg-accent-dark/15 text-accent-dark'
: 'text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700'
}`;
return ( return (
<div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''}`}> <div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-white dark:bg-neutral-900' : ''}`}>
<div className="border border-neutral-300 rounded-lg overflow-hidden h-full flex flex-col"> <div className="border border-neutral-300 dark:border-neutral-700 rounded-lg overflow-hidden h-full flex flex-col bg-white dark:bg-neutral-900">
{/* Top Toolbar */} {/* Top Toolbar */}
<div className="border-b border-neutral-200 bg-neutral-50"> <div className="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
{/* View Mode Controls */} {/* View Mode Controls */}
<div className="flex items-center justify-between p-2 border-b border-neutral-200"> <div className="flex items-center justify-between p-2 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button onClick={() => setViewMode('edit')} className={viewModeChipClass('edit')}>
onClick={() => setViewMode('edit')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'edit'
? 'bg-primary-100 text-primary-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
>
<Edit3 className="w-4 h-4 inline-block mr-1" /> <Edit3 className="w-4 h-4 inline-block mr-1" />
Edit Edit
</button> </button>
<button <button onClick={() => setViewMode('preview')} className={viewModeChipClass('preview')}>
onClick={() => setViewMode('preview')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'preview'
? 'bg-primary-100 text-primary-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
>
<Eye className="w-4 h-4 inline-block mr-1" /> <Eye className="w-4 h-4 inline-block mr-1" />
Preview Preview
</button> </button>
<button <button onClick={() => setViewMode('split')} className={viewModeChipClass('split')}>
onClick={() => setViewMode('split')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'split'
? 'bg-primary-100 text-primary-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
>
<Columns className="w-4 h-4 inline-block mr-1" /> <Columns className="w-4 h-4 inline-block mr-1" />
Split Split
</button> </button>
@@ -295,7 +285,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<Heading6 className="w-4 h-4" /> <Heading6 className="w-4 h-4" />
</MenuButton> </MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" /> <div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleBold().run()} onClick={() => editor.chain().focus().toggleBold().run()}
@@ -329,7 +319,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<Code2 className="w-4 h-4" /> <Code2 className="w-4 h-4" />
</MenuButton> </MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" /> <div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton <MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()} onClick={() => editor.chain().focus().toggleBulletList().run()}
@@ -355,7 +345,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<Quote className="w-4 h-4" /> <Quote className="w-4 h-4" />
</MenuButton> </MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" /> <div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton <MenuButton
onClick={() => setShowLinkDialog(true)} onClick={() => setShowLinkDialog(true)}
@@ -372,7 +362,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<Minus className="w-4 h-4" /> <Minus className="w-4 h-4" />
</MenuButton> </MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" /> <div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton <MenuButton
onClick={() => editor.chain().focus().setTextAlign('left').run()} onClick={() => editor.chain().focus().setTextAlign('left').run()}
@@ -406,7 +396,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<AlignJustify className="w-4 h-4" /> <AlignJustify className="w-4 h-4" />
</MenuButton> </MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" /> <div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton <MenuButton
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()} onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
@@ -415,7 +405,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
<RemoveFormatting className="w-4 h-4" /> <RemoveFormatting className="w-4 h-4" />
</MenuButton> </MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" /> <div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton <MenuButton
onClick={() => editor.chain().focus().undo().run()} onClick={() => editor.chain().focus().undo().run()}
@@ -438,14 +428,14 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
{/* Link Dialog */} {/* Link Dialog */}
{showLinkDialog && ( {showLinkDialog && (
<div className="p-3 bg-primary-50 border-b border-primary-200 flex items-center gap-2"> <div className="p-3 bg-accent-dark/15 border-b border-accent-dark/30 flex items-center gap-2">
<input <input
type="url" type="url"
value={linkUrl} value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)} onChange={(e) => setLinkUrl(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addLink()} onKeyPress={(e) => e.key === 'Enter' && addLink()}
placeholder="Enter URL..." placeholder="Enter URL..."
className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500" className="flex-1 px-3 py-1 border border-accent-dark/30 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus autoFocus
/> />
<Button size="sm" onClick={addLink}>Add Link</Button> <Button size="sm" onClick={addLink}>Add Link</Button>
@@ -460,21 +450,22 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
{/* Editor Content Area */} {/* Editor Content Area */}
<div className="flex-1 flex overflow-hidden"> <div className="flex-1 flex overflow-hidden">
{/* Editor */} {/* Editor — prose-invert in dark mode flips the prose typography
palette without us having to override every prose-* class. */}
{viewMode !== 'preview' && ( {viewMode !== 'preview' && (
<div className={`${viewMode === 'split' ? 'w-1/2 border-r border-neutral-200' : 'w-full'} overflow-auto`}> <div className={`${viewMode === 'split' ? 'w-1/2 border-r border-neutral-200 dark:border-neutral-700' : 'w-full'} overflow-auto bg-white dark:bg-neutral-900`}>
<EditorContent <EditorContent
editor={editor} editor={editor}
className="min-h-[400px] p-4 prose prose-neutral max-w-none focus:outline-none [&_.ProseMirror]:min-h-[400px] [&_.ProseMirror]:outline-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_br.hard-break]:display-block [&_.ProseMirror_br.hard-break]:content-[''] [&_.ProseMirror_br.hard-break]:margin-[0.5em_0] [&_.ProseMirror_pre]:bg-neutral-100 [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_code]:bg-neutral-100 [&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:text-sm [&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0" className="min-h-[400px] p-4 prose prose-neutral dark:prose-invert max-w-none focus:outline-none [&_.ProseMirror]:min-h-[400px] [&_.ProseMirror]:outline-none [&_.ProseMirror]:text-neutral-900 dark:[&_.ProseMirror]:text-neutral-100 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 dark:[&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-500 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_br.hard-break]:display-block [&_.ProseMirror_br.hard-break]:content-[''] [&_.ProseMirror_br.hard-break]:margin-[0.5em_0] [&_.ProseMirror_pre]:bg-neutral-100 dark:[&_.ProseMirror_pre]:bg-neutral-800 [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_code]:bg-neutral-100 dark:[&_.ProseMirror_code]:bg-neutral-800 [&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:text-sm [&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0"
/> />
</div> </div>
)} )}
{/* Preview */} {/* Preview */}
{viewMode !== 'edit' && ( {viewMode !== 'edit' && (
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} overflow-auto bg-neutral-50 p-4`}> <div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} overflow-auto bg-neutral-50 dark:bg-neutral-800 p-4`}>
<div <div
className="prose prose-neutral max-w-none" className="prose prose-neutral dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: getPreviewContent() }} dangerouslySetInnerHTML={{ __html: getPreviewContent() }}
/> />
</div> </div>
@@ -482,12 +473,12 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
</div> </div>
{/* Status Bar */} {/* Status Bar */}
<div className="flex items-center justify-between px-4 py-2 bg-neutral-50 border-t border-neutral-200 text-sm text-neutral-600"> <div className="flex items-center justify-between px-4 py-2 bg-neutral-50 dark:bg-neutral-800 border-t border-neutral-200 dark:border-neutral-700 text-sm text-neutral-600 dark:text-neutral-300">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span>{wordCount} words</span> <span>{wordCount} words</span>
<span>{charCount} characters</span> <span>{charCount} characters</span>
</div> </div>
<div className="text-xs text-neutral-500"> <div className="text-xs text-neutral-500 dark:text-neutral-400">
Press Shift+Enter for line break, Enter for new paragraph Press Shift+Enter for line break, Enter for new paragraph
</div> </div>
</div> </div>
@@ -496,7 +487,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
{/* Help Modal */} {/* Help Modal */}
{showHelp && ( {showHelp && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[80vh] overflow-auto"> <div className="bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-lg max-w-2xl w-full max-h-[80vh] overflow-auto">
<div className="p-6"> <div className="p-6">
<h2 className="text-xl font-semibold mb-4">Editor Help & Keyboard Shortcuts</h2> <h2 className="text-xl font-semibold mb-4">Editor Help & Keyboard Shortcuts</h2>
@@ -93,7 +93,7 @@ export const CategoryManager: React.FC = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center items-center py-8"> <div className="flex justify-center items-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary-600" /> <Loader2 className="w-6 h-6 animate-spin text-accent" />
</div> </div>
); );
} }
@@ -205,7 +205,7 @@ export const CategoryManager: React.FC = () => {
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
onClick={() => startEdit(category)} onClick={() => startEdit(category)}
className="p-1.5 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/30 rounded transition-colors" className="p-1.5 text-neutral-600 dark:text-neutral-400 hover:text-accent dark:hover:text-accent hover:bg-accent-dark/15 rounded transition-colors"
title={t('common.edit')} title={t('common.edit')}
> >
<Edit2 className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
@@ -108,7 +108,7 @@ export const CssTemplateEditor: React.FC = () => {
onClick={() => setActiveSlot(slot)} onClick={() => setActiveSlot(slot)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${ className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeSlot === slot activeSlot === slot
? 'border-primary-600 text-primary-600' ? 'border-accent text-accent'
: 'border-transparent text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:border-neutral-300 dark:hover:border-neutral-600' : 'border-transparent text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:border-neutral-300 dark:hover:border-neutral-600'
}`} }`}
> >
@@ -138,7 +138,7 @@ export const CssTemplateEditor: React.FC = () => {
value={activeTemplate.name} value={activeTemplate.name}
onChange={(e) => updateLocalTemplate({ name: e.target.value })} onChange={(e) => updateLocalTemplate({ name: e.target.value })}
maxLength={50} maxLength={50}
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 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" 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 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
/> />
</div> </div>
@@ -149,7 +149,7 @@ export const CssTemplateEditor: React.FC = () => {
type="checkbox" type="checkbox"
checked={activeTemplate.is_enabled} checked={activeTemplate.is_enabled}
onChange={(e) => updateLocalTemplate({ is_enabled: e.target.checked })} onChange={(e) => updateLocalTemplate({ is_enabled: e.target.checked })}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500" className="rounded border-neutral-300 text-accent focus:ring-primary-500"
/> />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300"> <span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('cssTemplates.enableTemplate', 'Enable this template')} {t('cssTemplates.enableTemplate', 'Enable this template')}
@@ -169,7 +169,7 @@ export const CssTemplateEditor: React.FC = () => {
<textarea <textarea
value={activeTemplate.css_content} value={activeTemplate.css_content}
onChange={(e) => updateLocalTemplate({ css_content: e.target.value })} onChange={(e) => updateLocalTemplate({ css_content: e.target.value })}
className="w-full h-96 px-4 py-3 font-mono text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-neutral-900 text-green-400" className="w-full h-96 px-4 py-3 font-mono text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark bg-neutral-900 text-green-400"
spellCheck={false} spellCheck={false}
placeholder="/* Enter your custom CSS here */" placeholder="/* Enter your custom CSS here */"
/> />
@@ -27,7 +27,7 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200 dark:border-neutral-700"> <div className="flex items-center justify-between p-6 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Mail className="w-6 h-6 text-primary-600" /> <Mail className="w-6 h-6 text-accent" />
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Email Preview</h2> <h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Email Preview</h2>
</div> </div>
<button <button
@@ -146,7 +146,7 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
disabled={disabled} disabled={disabled}
className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-600 transition-colors ${ className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-600 transition-colors ${
active active
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300' ? 'bg-accent-dark/15 text-accent-dark'
: 'text-neutral-700 dark:text-neutral-300' : 'text-neutral-700 dark:text-neutral-300'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`} } ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={title} title={title}
@@ -304,7 +304,7 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
onClick={() => setShowVariables(!showVariables)} onClick={() => setShowVariables(!showVariables)}
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${ className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${
showVariables showVariables
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300' ? 'bg-accent-dark/15 text-accent-dark'
: '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'
}`} }`}
type="button" type="button"
@@ -322,7 +322,7 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
className="w-full text-left px-3 py-1.5 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors" className="w-full text-left px-3 py-1.5 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
type="button" type="button"
> >
<code className="text-primary-600 dark:text-primary-400">{`{{${variable}}}`}</code> <code className="text-accent">{`{{${variable}}}`}</code>
</button> </button>
))} ))}
</div> </div>
@@ -348,19 +348,19 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
{/* Link Dialog */} {/* Link Dialog */}
{showLinkDialog && ( {showLinkDialog && (
<div className="p-3 bg-primary-50 dark:bg-primary-900/20 border-b border-primary-200 dark:border-primary-800 flex items-center gap-2"> <div className="p-3 bg-accent-dark/15 border-b border-accent-dark/30 flex items-center gap-2">
<input <input
type="url" type="url"
value={linkUrl} value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)} onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addLink()} onKeyDown={(e) => e.key === 'Enter' && addLink()}
placeholder={t('email.editor.enterUrl')} placeholder={t('email.editor.enterUrl')}
className="flex-1 px-3 py-1 text-sm border border-primary-300 dark:border-primary-700 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500" className="flex-1 px-3 py-1 text-sm border border-accent-dark/30 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus autoFocus
/> />
<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:opacity-90"
type="button" type="button"
> >
{t('email.editor.addLink')} {t('email.editor.addLink')}
@@ -102,7 +102,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center items-center py-4"> <div className="flex justify-center items-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-primary-600" /> <Loader2 className="w-5 h-5 animate-spin text-accent" />
</div> </div>
); );
} }
@@ -185,7 +185,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
{/* Hero photo thumbnail */} {/* Hero photo thumbnail */}
<button <button
onClick={() => setHeroPickerCategoryId(category.id)} onClick={() => setHeroPickerCategoryId(category.id)}
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-primary-400 transition-colors flex items-center justify-center" className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-accent-dark transition-colors flex items-center justify-center"
title={t('categories.setCoverPhoto')} title={t('categories.setCoverPhoto')}
> >
{heroPhoto ? ( {heroPhoto ? (
@@ -195,7 +195,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : category.hero_photo_id ? ( ) : category.hero_photo_id ? (
<ImageIcon className="w-4 h-4 text-primary-400" /> <ImageIcon className="w-4 h-4 text-accent" />
) : ( ) : (
<ImageIcon className="w-4 h-4 text-neutral-300" /> <ImageIcon className="w-4 h-4 text-neutral-300" />
)} )}
@@ -234,7 +234,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md"> <div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md">
<button <button
onClick={() => setHeroPickerCategoryId(cat.id)} onClick={() => setHeroPickerCategoryId(cat.id)}
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-primary-400 transition-colors flex items-center justify-center" className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-accent-dark transition-colors flex items-center justify-center"
title={t('categories.setCoverPhoto')} title={t('categories.setCoverPhoto')}
> >
{heroPhoto ? ( {heroPhoto ? (
@@ -244,7 +244,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : cat.hero_photo_id ? ( ) : cat.hero_photo_id ? (
<ImageIcon className="w-4 h-4 text-primary-400" /> <ImageIcon className="w-4 h-4 text-accent" />
) : ( ) : (
<ImageIcon className="w-4 h-4 text-neutral-300" /> <ImageIcon className="w-4 h-4 text-neutral-300" />
)} )}
@@ -288,7 +288,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
onClick={() => handleSelectHeroPhoto(heroPickerCategoryId, photo.id)} onClick={() => handleSelectHeroPhoto(heroPickerCategoryId, photo.id)}
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${ className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
isSelected isSelected
? 'border-primary-500 ring-2 ring-primary-500 ring-offset-2' ? 'border-accent-dark ring-2 ring-primary-500 ring-offset-2'
: 'border-transparent hover:border-neutral-300' : 'border-transparent hover:border-neutral-300'
}`} }`}
> >
@@ -300,7 +300,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
/> />
</div> </div>
{isSelected && ( {isSelected && (
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1"> <div className="absolute top-2 right-2 bg-accent-dark/150 text-white rounded-full p-1">
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
</div> </div>
)} )}
@@ -158,11 +158,11 @@ export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
</div> </div>
{renameResult.newShareLink && ( {renameResult.newShareLink && (
<div className="p-3 bg-neutral-50 rounded-lg"> <div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
<p className="text-sm font-medium text-neutral-700 mb-1"> <p className="text-sm font-medium text-neutral-700 dark:text-neutral-200 mb-1">
{t('events.rename.newLink', 'New Gallery Link')} {t('events.rename.newLink', 'New Gallery Link')}
</p> </p>
<p className="text-sm text-neutral-900 break-all">{renameResult.newShareLink}</p> <p className="text-sm text-neutral-900 dark:text-neutral-100 break-all">{renameResult.newShareLink}</p>
</div> </div>
)} )}
@@ -198,7 +198,7 @@ export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
// Renaming in progress // Renaming in progress
<div className="space-y-4 py-8"> <div className="space-y-4 py-8">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<Loader2 className="w-10 h-10 text-primary-600 animate-spin" /> <Loader2 className="w-10 h-10 text-accent animate-spin" />
<p className="text-neutral-700 font-medium">{renameStatus}</p> <p className="text-neutral-700 font-medium">{renameStatus}</p>
</div> </div>
</div> </div>
@@ -258,7 +258,7 @@ export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
type="checkbox" type="checkbox"
checked={resendEmail} checked={resendEmail}
onChange={(e) => setResendEmail(e.target.checked)} onChange={(e) => setResendEmail(e.target.checked)}
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500" className="mt-1 w-4 h-4 text-accent border-neutral-300 rounded focus:ring-primary-500"
/> />
<div> <div>
<span className="text-sm font-medium text-neutral-700 flex items-center gap-1"> <span className="text-sm font-medium text-neutral-700 flex items-center gap-1">
@@ -94,28 +94,28 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
{!hasPending ? ( {!hasPending ? (
<div className="text-center py-8"> <div className="text-center py-8">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" /> <CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
<p className="text-neutral-600">{t('feedback.noPendingComments', 'No comments pending moderation')}</p> <p className="text-neutral-600 dark:text-neutral-300">{t('feedback.noPendingComments', 'No comments pending moderation')}</p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{pendingComments.slice(0, showAll ? undefined : maxItems).map((item) => ( {pendingComments.slice(0, showAll ? undefined : maxItems).map((item) => (
<div key={item.id} className="border border-neutral-200 rounded-lg p-4 hover:bg-neutral-50"> <div key={item.id} className="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 hover:bg-neutral-50 dark:hover:bg-neutral-800">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="w-10 h-10 bg-neutral-100 rounded-full flex items-center justify-center"> <div className="w-10 h-10 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-neutral-600" /> <User className="w-5 h-5 text-neutral-600 dark:text-neutral-300" />
</div> </div>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="font-medium text-neutral-900"> <span className="font-medium text-neutral-900 dark:text-neutral-100">
{item.guest_name || t('feedback.anonymous', 'Anonymous')} {item.guest_name || t('feedback.anonymous', 'Anonymous')}
</span> </span>
<span className="text-neutral-500"></span> <span className="text-neutral-500 dark:text-neutral-400"></span>
<span className="text-neutral-500"> <span className="text-neutral-500 dark:text-neutral-400">
{format( {format(
typeof item.created_at === 'string' typeof item.created_at === 'string'
? parseISO(item.created_at) ? parseISO(item.created_at)
@@ -191,7 +191,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
{pendingComments.length > maxItems && !showAll && ( {pendingComments.length > maxItems && !showAll && (
<button <button
onClick={() => setShowAll(true)} onClick={() => setShowAll(true)}
className="w-full text-center py-2 text-sm text-primary-600 hover:text-primary-700 font-medium" className="w-full text-center py-2 text-sm text-accent hover:opacity-80 font-medium"
> >
{t('feedback.showAll', 'Show all {{count}} pending comments', { count: pendingComments.length })} {t('feedback.showAll', 'Show all {{count}} pending comments', { count: pendingComments.length })}
</button> </button>
@@ -203,7 +203,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
<div className="mt-4 pt-4 border-t border-neutral-200"> <div className="mt-4 pt-4 border-t border-neutral-200">
<a <a
href={`/admin/events/${eventId}/feedback`} href={`/admin/events/${eventId}/feedback`}
className="text-sm text-primary-600 hover:text-primary-700 font-medium flex items-center gap-1" className="text-sm text-accent hover:opacity-80 font-medium flex items-center gap-1"
> >
<MessageSquare className="w-4 h-4" /> <MessageSquare className="w-4 h-4" />
{t('feedback.viewAllFeedback', 'View all feedback & settings')} {t('feedback.viewAllFeedback', 'View all feedback & settings')}
@@ -61,7 +61,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox" type="checkbox"
checked={settings.feedback_enabled} checked={settings.feedback_enabled}
onChange={() => handleToggle('feedback_enabled')} onChange={() => handleToggle('feedback_enabled')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500" className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/> />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300"> <span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('feedback.settings.enableFeedback', 'Enable feedback')} {t('feedback.settings.enableFeedback', 'Enable feedback')}
@@ -80,7 +80,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
<label <label
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${ className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
(settings.identity_mode || 'simple') === 'simple' (settings.identity_mode || 'simple') === 'simple'
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' ? 'border-accent-dark bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800' : 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`} }`}
> >
@@ -90,7 +90,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
value="simple" value="simple"
checked={(settings.identity_mode || 'simple') === 'simple'} checked={(settings.identity_mode || 'simple') === 'simple'}
onChange={() => onChange({ ...settings, identity_mode: 'simple' })} onChange={() => onChange({ ...settings, identity_mode: 'simple' })}
className="mt-0.5 w-4 h-4 text-primary-600 focus:ring-primary-500" className="mt-0.5 w-4 h-4 text-accent focus:ring-primary-500"
/> />
<User className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" /> <User className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1"> <div className="flex-1">
@@ -109,7 +109,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
<label <label
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${ className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
settings.identity_mode === 'guest' settings.identity_mode === 'guest'
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' ? 'border-accent-dark bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800' : 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`} }`}
> >
@@ -119,7 +119,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
value="guest" value="guest"
checked={settings.identity_mode === 'guest'} checked={settings.identity_mode === 'guest'}
onChange={() => onChange({ ...settings, identity_mode: 'guest' })} onChange={() => onChange({ ...settings, identity_mode: 'guest' })}
className="mt-0.5 w-4 h-4 text-primary-600 focus:ring-primary-500" className="mt-0.5 w-4 h-4 text-accent focus:ring-primary-500"
/> />
<Users className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" /> <Users className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1"> <div className="flex-1">
@@ -150,7 +150,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox" type="checkbox"
checked={settings.allow_ratings} checked={settings.allow_ratings}
onChange={() => handleToggle('allow_ratings')} onChange={() => handleToggle('allow_ratings')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500" className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/> />
<Star className="w-5 h-5 text-neutral-600 dark:text-neutral-400" /> <Star className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1"> <div className="flex-1">
@@ -168,7 +168,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox" type="checkbox"
checked={settings.allow_likes} checked={settings.allow_likes}
onChange={() => handleToggle('allow_likes')} onChange={() => handleToggle('allow_likes')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500" className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/> />
<Heart className="w-5 h-5 text-neutral-600 dark:text-neutral-400" /> <Heart className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1"> <div className="flex-1">
@@ -186,7 +186,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox" type="checkbox"
checked={settings.allow_comments} checked={settings.allow_comments}
onChange={() => handleToggle('allow_comments')} onChange={() => handleToggle('allow_comments')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500" className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/> />
<MessageSquare className="w-5 h-5 text-neutral-600 dark:text-neutral-400" /> <MessageSquare className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1"> <div className="flex-1">
@@ -204,7 +204,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox" type="checkbox"
checked={settings.allow_favorites} checked={settings.allow_favorites}
onChange={() => handleToggle('allow_favorites')} onChange={() => handleToggle('allow_favorites')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500" className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/> />
<Bookmark className="w-5 h-5 text-neutral-600 dark:text-neutral-400" /> <Bookmark className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1"> <div className="flex-1">
@@ -232,7 +232,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox" type="checkbox"
checked={settings.require_name_email} checked={settings.require_name_email}
onChange={() => handleToggle('require_name_email')} onChange={() => handleToggle('require_name_email')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500" className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/> />
<div className="flex-1"> <div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100"> <div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
@@ -250,7 +250,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
checked={settings.moderate_comments} checked={settings.moderate_comments}
onChange={() => handleToggle('moderate_comments')} onChange={() => handleToggle('moderate_comments')}
disabled={!settings.allow_comments} disabled={!settings.allow_comments}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 disabled:opacity-50" className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 disabled:opacity-50"
/> />
<Shield className="w-5 h-5 text-neutral-600 dark:text-neutral-400" /> <Shield className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1"> <div className="flex-1">
@@ -268,7 +268,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox" type="checkbox"
checked={settings.show_feedback_to_guests} checked={settings.show_feedback_to_guests}
onChange={() => handleToggle('show_feedback_to_guests')} onChange={() => handleToggle('show_feedback_to_guests')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500" className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/> />
<Eye className="w-5 h-5 text-neutral-600 dark:text-neutral-400" /> <Eye className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1"> <div className="flex-1">
@@ -292,7 +292,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
type="checkbox" type="checkbox"
checked={settings.enable_rate_limiting} checked={settings.enable_rate_limiting}
onChange={() => handleToggle('enable_rate_limiting')} onChange={() => handleToggle('enable_rate_limiting')}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500" className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/> />
<div className="flex-1"> <div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100"> <div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
@@ -316,7 +316,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
max="60" max="60"
value={settings.rate_limit_window_minutes || 15} value={settings.rate_limit_window_minutes || 15}
onChange={(e) => handleNumberChange('rate_limit_window_minutes', e.target.value)} onChange={(e) => handleNumberChange('rate_limit_window_minutes', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-primary-500 focus:border-primary-500" className="w-full px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-primary-500 focus:border-accent-dark"
/> />
</div> </div>
<div> <div>
@@ -329,7 +329,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
max="100" max="100"
value={settings.rate_limit_max_requests || 10} value={settings.rate_limit_max_requests || 10}
onChange={(e) => handleNumberChange('rate_limit_max_requests', e.target.value)} onChange={(e) => handleNumberChange('rate_limit_max_requests', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-primary-500 focus:border-primary-500" className="w-full px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-primary-500 focus:border-accent-dark"
/> />
</div> </div>
</div> </div>
@@ -102,7 +102,7 @@ export const FocalPointPicker: React.FC<FocalPointPickerProps> = ({
onClick={() => onChange(p.value)} onClick={() => onChange(p.value)}
className={ className={
keywordToPercent(currentValue) === p.value keywordToPercent(currentValue) === p.value
? 'bg-primary-50 border-primary-300 text-primary-700' ? 'bg-accent-dark/15 border-accent-dark/30 text-accent-dark'
: '' : ''
} }
> >
@@ -166,7 +166,7 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
</div> </div>
<div className="flex justify-center gap-1 mt-3"> <div className="flex justify-center gap-1 mt-3">
{[0, 1, 2, 3].map((idx) => ( {[0, 1, 2, 3].map((idx) => (
<div key={idx} className={`w-2 h-2 rounded-full ${idx === 0 ? 'bg-primary-600' : 'bg-neutral-300'}`} /> <div key={idx} className={`w-2 h-2 rounded-full ${idx === 0 ? 'bg-accent-dark' : 'bg-neutral-300'}`} />
))} ))}
</div> </div>
</div> </div>
@@ -161,7 +161,7 @@ export const GuestInviteDialog: React.FC<GuestInviteDialogProps> = ({ eventId, o
<button <button
type="button" type="button"
onClick={() => copy(invite)} onClick={() => copy(invite)}
className="p-1.5 text-neutral-500 hover:text-primary-600" className="p-1.5 text-neutral-500 hover:text-accent"
title={t('admin.guests.copyLink', 'Copy link')} title={t('admin.guests.copyLink', 'Copy link')}
> >
{copiedId === invite.id ? ( {copiedId === invite.id ? (
@@ -54,7 +54,7 @@ export const GuestSelectionsAggregate: React.FC<GuestSelectionsAggregateProps> =
alt={p.filename} alt={p.filename}
className="w-full aspect-square object-cover rounded" className="w-full aspect-square object-cover rounded"
/> />
<div className="absolute top-2 right-2 bg-primary-600 text-white text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1 shadow"> <div className="absolute top-2 right-2 bg-accent-dark text-white text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1 shadow">
<Users className="w-3 h-3" /> <Users className="w-3 h-3" />
{p.picker_count} {p.picker_count}
</div> </div>
@@ -132,7 +132,7 @@ export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
onClick={() => handleSelect(photo.id)} onClick={() => handleSelect(photo.id)}
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${ className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
photo.id === selectedPhotoId photo.id === selectedPhotoId
? 'border-primary-500 ring-2 ring-primary-500 ring-offset-2' ? 'border-accent-dark ring-2 ring-primary-500 ring-offset-2'
: 'border-transparent hover:border-neutral-300' : 'border-transparent hover:border-neutral-300'
}`} }`}
> >
@@ -144,7 +144,7 @@ export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
/> />
</div> </div>
{photo.id === selectedPhotoId && ( {photo.id === selectedPhotoId && (
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1"> <div className="absolute top-2 right-2 bg-accent-dark/150 text-white rounded-full p-1">
<Check className="w-4 h-4" /> <Check className="w-4 h-4" />
</div> </div>
)} )}
@@ -135,7 +135,7 @@ export const MandatoryPasswordChangeModal: React.FC = () => {
<button <button
type="button" type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))} onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded" className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
> >
{showPasswords.current ? {showPasswords.current ?
<EyeOff className="w-4 h-4 text-neutral-500" /> : <EyeOff className="w-4 h-4 text-neutral-500" /> :
@@ -163,7 +163,7 @@ export const MandatoryPasswordChangeModal: React.FC = () => {
<button <button
type="button" type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))} onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded" className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
> >
{showPasswords.new ? {showPasswords.new ?
<EyeOff className="w-4 h-4 text-neutral-500" /> : <EyeOff className="w-4 h-4 text-neutral-500" /> :
@@ -191,7 +191,7 @@ export const MandatoryPasswordChangeModal: React.FC = () => {
<button <button
type="button" type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))} onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded" className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
> >
{showPasswords.confirm ? {showPasswords.confirm ?
<EyeOff className="w-4 h-4 text-neutral-500" /> : <EyeOff className="w-4 h-4 text-neutral-500" /> :
@@ -163,7 +163,7 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
type="checkbox" type="checkbox"
checked={sendEmail} checked={sendEmail}
onChange={(e) => setSendEmail(e.target.checked)} onChange={(e) => setSendEmail(e.target.checked)}
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 focus:ring-2" className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 focus:ring-2"
/> />
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -231,7 +231,7 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
type="text" type="text"
value={resultPassword} value={resultPassword}
readOnly readOnly
className="flex-1 px-3 py-2 bg-neutral-50 border border-neutral-300 rounded-lg font-mono text-sm" className="flex-1 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-700 text-neutral-900 dark:text-neutral-100 rounded-lg font-mono text-sm"
/> />
<Button <Button
variant="outline" variant="outline"
@@ -126,7 +126,7 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
)} )}
{t('export.button', 'Export')} {t('export.button', 'Export')}
{hasSelection && ( {hasSelection && (
<span className="bg-primary-100 text-primary-700 text-xs px-2 py-0.5 rounded-full"> <span className="bg-accent-dark/15 text-accent-dark text-xs px-2 py-0.5 rounded-full">
{selectedPhotoIds.length} {selectedPhotoIds.length}
</span> </span>
)} )}
@@ -85,7 +85,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
<select <select
value={filters.minRating ?? ''} value={filters.minRating ?? ''}
onChange={(e) => handleRatingChange(e.target.value === '' ? null : parseFloat(e.target.value))} onChange={(e) => handleRatingChange(e.target.value === '' ? null : parseFloat(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 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" 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 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
disabled={isLoading} disabled={isLoading}
> >
{RATING_OPTIONS.map(option => ( {RATING_OPTIONS.map(option => (
@@ -103,7 +103,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
type="checkbox" type="checkbox"
checked={filters.hasLikes || false} checked={filters.hasLikes || false}
onChange={() => handleCheckboxChange('hasLikes')} onChange={() => handleCheckboxChange('hasLikes')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500" className="rounded border-neutral-300 text-accent focus:ring-primary-500"
disabled={isLoading} disabled={isLoading}
/> />
<Heart className="w-4 h-4 text-red-500" /> <Heart className="w-4 h-4 text-red-500" />
@@ -120,7 +120,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
type="checkbox" type="checkbox"
checked={filters.hasFavorites || false} checked={filters.hasFavorites || false}
onChange={() => handleCheckboxChange('hasFavorites')} onChange={() => handleCheckboxChange('hasFavorites')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500" className="rounded border-neutral-300 text-accent focus:ring-primary-500"
disabled={isLoading} disabled={isLoading}
/> />
<Bookmark className="w-4 h-4 text-yellow-500" /> <Bookmark className="w-4 h-4 text-yellow-500" />
@@ -137,7 +137,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
type="checkbox" type="checkbox"
checked={filters.hasComments || false} checked={filters.hasComments || false}
onChange={() => handleCheckboxChange('hasComments')} onChange={() => handleCheckboxChange('hasComments')}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500" className="rounded border-neutral-300 text-accent focus:ring-primary-500"
disabled={isLoading} disabled={isLoading}
/> />
<MessageCircle className="w-4 h-4 text-blue-500" /> <MessageCircle className="w-4 h-4 text-blue-500" />
@@ -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}
@@ -60,7 +60,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
const numeric = Number(raw); const numeric = Number(raw);
onCategoryChange(Number.isNaN(numeric) ? raw : numeric); onCategoryChange(Number.isNaN(numeric) ? raw : numeric);
}} }}
className="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 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" className="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 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
> >
<option value="">{t('gallery.allCategories', 'All Categories')}</option> <option value="">{t('gallery.allCategories', 'All Categories')}</option>
<option value="0">{t('gallery.uncategorized', 'Uncategorized')}</option> <option value="0">{t('gallery.uncategorized', 'Uncategorized')}</option>
@@ -78,7 +78,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
<select <select
value={mediaType} value={mediaType}
onChange={(e) => onMediaTypeChange(e.target.value as 'all' | 'photo' | 'video')} onChange={(e) => onMediaTypeChange(e.target.value as 'all' | 'photo' | 'video')}
className="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 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" className="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 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
> >
<option value="all">{t('gallery.allMedia', 'All media')}</option> <option value="all">{t('gallery.allMedia', 'All media')}</option>
<option value="photo">{t('gallery.photosOnly', 'Photos only')}</option> <option value="photo">{t('gallery.photosOnly', 'Photos only')}</option>
@@ -92,7 +92,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
<select <select
value={sortBy} value={sortBy}
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)} onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)}
className="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 focus:ring-2 focus:ring-primary-500 focus:border-primary-500" className="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 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
> >
<option value="date">{t('gallery.sortByDate', 'Sort by Date')}</option> <option value="date">{t('gallery.sortByDate', 'Sort by Date')}</option>
<option value="name">{t('gallery.sortByName', 'Sort by Name')}</option> <option value="name">{t('gallery.sortByName', 'Sort by Name')}</option>
@@ -342,7 +342,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
id="replace-by-name" id="replace-by-name"
checked={replaceByName} checked={replaceByName}
onChange={(e) => setReplaceByName(e.target.checked)} onChange={(e) => setReplaceByName(e.target.checked)}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500" className="rounded border-neutral-300 text-accent focus:ring-primary-500"
/> />
<label htmlFor="replace-by-name" className="text-sm text-neutral-700 dark:text-neutral-300"> <label htmlFor="replace-by-name" className="text-sm text-neutral-700 dark:text-neutral-300">
{t('upload.replaceByName', 'Replace existing photos with same name')} {t('upload.replaceByName', 'Replace existing photos with same name')}
@@ -353,8 +353,8 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
<div <div
className={clsx( className={clsx(
"border-2 border-dashed rounded-lg p-8 text-center transition-colors", "border-2 border-dashed rounded-lg p-8 text-center transition-colors",
"hover:border-primary-400 hover:bg-primary-50/50", "hover:border-accent-dark hover:bg-accent-dark/15",
selectedFiles.length > 0 ? "border-primary-400 bg-primary-50/30 dark:bg-primary-900/20" : "border-neutral-300 dark:border-neutral-600" selectedFiles.length > 0 ? "border-accent-dark bg-accent-dark/15" : "border-neutral-300 dark:border-neutral-600"
)} )}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> >
@@ -496,7 +496,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
</div> </div>
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2"> <div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
<div <div
className="bg-primary-600 h-2 rounded-full transition-all duration-300" className="bg-accent-dark h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }} style={{ width: `${uploadProgress}%` }}
/> />
</div> </div>
@@ -196,7 +196,7 @@ export const RestoreWizard = () => {
onClick={() => setRestoreData(prev => ({ ...prev, source: 'local' }))} onClick={() => setRestoreData(prev => ({ ...prev, source: 'local' }))}
className={`p-6 rounded-lg border-2 transition-all ${ className={`p-6 rounded-lg border-2 transition-all ${
restoreData.source === 'local' restoreData.source === 'local'
? 'border-primary bg-primary-50 dark:bg-primary-900/30' ? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500' : 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`} }`}
> >
@@ -211,7 +211,7 @@ export const RestoreWizard = () => {
onClick={() => setRestoreData(prev => ({ ...prev, source: 's3' }))} onClick={() => setRestoreData(prev => ({ ...prev, source: 's3' }))}
className={`p-6 rounded-lg border-2 transition-all ${ className={`p-6 rounded-lg border-2 transition-all ${
restoreData.source === 's3' restoreData.source === 's3'
? 'border-primary bg-primary-50 dark:bg-primary-900/30' ? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500' : 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`} }`}
> >
@@ -226,7 +226,7 @@ export const RestoreWizard = () => {
onClick={() => setRestoreData(prev => ({ ...prev, source: 'upload' }))} onClick={() => setRestoreData(prev => ({ ...prev, source: 'upload' }))}
className={`p-6 rounded-lg border-2 transition-all ${ className={`p-6 rounded-lg border-2 transition-all ${
restoreData.source === 'upload' restoreData.source === 'upload'
? 'border-primary bg-primary-50 dark:bg-primary-900/30' ? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500' : 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`} }`}
> >
@@ -312,7 +312,7 @@ export const RestoreWizard = () => {
key={backup.id} key={backup.id}
className={`p-4 cursor-pointer transition-all ${ className={`p-4 cursor-pointer transition-all ${
restoreData.selectedBackup?.id === backup.id restoreData.selectedBackup?.id === backup.id
? 'ring-2 ring-primary bg-primary-50 dark:bg-primary-900/30' ? 'ring-2 ring-primary bg-accent-dark/15'
: 'hover:shadow-md' : 'hover:shadow-md'
}`} }`}
onClick={() => setRestoreData(prev => ({ ...prev, selectedBackup: backup }))} onClick={() => setRestoreData(prev => ({ ...prev, selectedBackup: backup }))}
@@ -388,7 +388,7 @@ export const RestoreWizard = () => {
onClick={() => setRestoreData(prev => ({ ...prev, restoreType: type.id }))} onClick={() => setRestoreData(prev => ({ ...prev, restoreType: type.id }))}
className={`p-4 rounded-lg border-2 text-left transition-all ${ className={`p-4 rounded-lg border-2 text-left transition-all ${
restoreData.restoreType === type.id restoreData.restoreType === type.id
? 'border-primary bg-primary-50 dark:bg-primary-900/30' ? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500' : 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`} }`}
> >
@@ -100,14 +100,14 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
onClick={() => handlePresetSelect(key)} onClick={() => handlePresetSelect(key)}
className={`relative p-4 rounded-lg border-2 transition-all ${ className={`relative p-4 rounded-lg border-2 transition-all ${
selectedPreset === key selectedPreset === key
? 'border-primary-600 bg-primary-50' ? 'tile-selected'
: 'border-neutral-200 hover:border-neutral-300' : 'border-neutral-200 hover:border-neutral-300'
}`} }`}
> >
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{theme.name}</span> <span className="font-medium text-sm">{theme.name}</span>
{selectedPreset === key && ( {selectedPreset === key && (
<Check className="w-4 h-4 text-primary-600" /> <Check className="w-4 h-4 text-accent" />
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -247,7 +247,7 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
onClick={() => handleChange('borderRadius', radius)} onClick={() => handleChange('borderRadius', radius)}
className={`px-4 py-2 rounded-lg border-2 transition-all ${ className={`px-4 py-2 rounded-lg border-2 transition-all ${
localTheme.borderRadius === radius localTheme.borderRadius === radius
? 'border-primary-600 bg-primary-50' ? 'tile-selected'
: 'border-neutral-200 hover:border-neutral-300' : 'border-neutral-200 hover:border-neutral-300'
}`} }`}
> >
@@ -54,8 +54,63 @@ 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;
// Sync palette from Branding. When provided, a small button appears in
// the colour-pickers section header. The caller resolves the active
// Branding theme and fires onChange with the merged 8-token values —
// only the colour tokens swap, layout/header/typography stay put so an
// admin who's already arranged the structure can pull just the palette.
onSyncFromBranding?: () => 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="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>
);
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" />,
@@ -116,7 +171,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
isApplying = false, isApplying = false,
cssTemplates, cssTemplates,
cssTemplateId, cssTemplateId,
onCssTemplateChange onCssTemplateChange,
forceColorMode,
onForceColorModeChange,
onSyncFromBranding
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value); const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
@@ -158,7 +216,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
}, [presetName]); }, [presetName]);
const handleChange = (key: keyof ThemeConfig, newValue: any) => { const handleChange = (key: keyof ThemeConfig, newValue: any) => {
const updated = { ...localTheme, [key]: newValue }; const updated: ThemeConfig = { ...localTheme, [key]: newValue };
// Legacy alias: keep primaryColor in lockstep with accentDarkColor so
// any consumer that still reads --color-primary or themeConfig.primaryColor
// doesn't drift after the 8-token migration.
if (key === 'accentDarkColor') {
updated.primaryColor = newValue;
}
setLocalTheme(updated); setLocalTheme(updated);
// When any change is made, mark it as custom // When any change is made, mark it as custom
@@ -248,7 +312,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' ? 'tile-selected'
: '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'
}`} }`}
> >
@@ -260,23 +324,29 @@ 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">
<div className="flex gap-1"> <div className="flex gap-1">
{/* Preview swatches: background, surface, accent-dark, accent
gives a quick read of the preset's full palette. */}
<div <div
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600" className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.primaryColor }} 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 <div
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600" className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.accentColor }} style={{ backgroundColor: theme.config.accentColor }}
/> />
<div
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
style={{ backgroundColor: theme.config.backgroundColor }}
/>
</div> </div>
{theme.config.galleryLayout && layoutIcons[theme.config.galleryLayout] && ( {theme.config.galleryLayout && layoutIcons[theme.config.galleryLayout] && (
<div className="ml-auto text-neutral-400"> <div className="ml-auto text-neutral-400">
@@ -331,7 +401,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' ? 'tile-selected'
: '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'
}`} }`}
> >
@@ -350,7 +420,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>
))} ))}
@@ -678,7 +748,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' ? 'tile-selected'
: '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'
}`} }`}
> >
@@ -694,7 +764,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>
))} ))}
@@ -717,7 +787,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' ? 'tile-selected'
: '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'
}`} }`}
> >
@@ -731,7 +801,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>
))} ))}
@@ -757,7 +827,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' ? 'tile-selected'
: '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'
}`} }`}
> >
@@ -773,7 +843,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
@@ -781,7 +851,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' ? 'tile-selected'
: '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'
}`} }`}
> >
@@ -797,7 +867,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>
@@ -815,10 +885,26 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Color Customization */} {/* Color Customization */}
<Card className="p-6"> <Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2"> <div className="flex items-center justify-between gap-2 mb-4">
<Palette className="w-5 h-5" /> <h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
{t('branding.colors')} <Palette className="w-5 h-5" />
</h3> {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 */} {/* Color Mode Selector */}
<div className="mb-6"> <div className="mb-6">
@@ -834,25 +920,27 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
handleChange('colorMode', mode); handleChange('colorMode', mode);
// When switching to dark, auto-populate dark defaults if colors are still light // When switching to dark, auto-populate dark defaults if colors are still light
if (mode === 'dark' && (!localTheme.backgroundColor || localTheme.backgroundColor === '#fafafa' || localTheme.backgroundColor === '#ffffff')) { if (mode === 'dark' && (!localTheme.backgroundColor || localTheme.backgroundColor === '#fafafa' || localTheme.backgroundColor === '#ffffff')) {
const updated = { const updated: ThemeConfig = {
...localTheme, ...localTheme,
colorMode: mode, colorMode: mode,
backgroundColor: '#0f0f0f', backgroundColor: '#0f0f0f',
textColor: '#e5e5e5',
surfaceColor: '#1a1a1a', surfaceColor: '#1a1a1a',
elevatedColor: '#242424',
surfaceBorderColor: '#2e2e2e', surfaceBorderColor: '#2e2e2e',
textColor: '#e5e5e5',
mutedTextColor: '#a3a3a3', mutedTextColor: '#a3a3a3',
}; };
setLocalTheme(updated); setLocalTheme(updated);
onChange({ ...updated, customCss }); onChange({ ...updated, customCss });
} else if (mode === 'light' && localTheme.colorMode === 'dark') { } else if (mode === 'light' && localTheme.colorMode === 'dark') {
const updated = { const updated: ThemeConfig = {
...localTheme, ...localTheme,
colorMode: mode, colorMode: mode,
backgroundColor: '#fafafa', backgroundColor: '#fafafa',
textColor: '#171717',
surfaceColor: '#ffffff', surfaceColor: '#ffffff',
elevatedColor: '#f5f5f5',
surfaceBorderColor: '#e5e5e5', surfaceBorderColor: '#e5e5e5',
textColor: '#171717',
mutedTextColor: '#737373', mutedTextColor: '#737373',
}; };
setLocalTheme(updated); setLocalTheme(updated);
@@ -861,7 +949,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-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' : 'border-neutral-300 dark:border-neutral-600 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`} }`}
> >
@@ -874,87 +962,214 @@ 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-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> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {/*
* 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.
*/}
<div className="space-y-6">
{/* Surfaces */}
<div> <div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2"> <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.primaryColor')} {t('branding.colorGroupSurfaces', 'Surfaces')}
</label> <span
<div className="flex gap-2"> className="info-tooltip text-neutral-400 dark:text-neutral-500"
<input data-tooltip={t(
type="color" 'branding.colorGroupSurfacesHelp',
value={localTheme.primaryColor || '#5C8762'} 'The neutral layers behind your content. Background sits furthest back; Surface and Elevated stack on top.'
onChange={(e) => handleChange('primaryColor', e.target.value)} )}
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer" tabIndex={0}
/> >
<Input <Info className="w-3.5 h-3.5" />
value={localTheme.primaryColor || '#5C8762'} </span>
onChange={(e) => handleChange('primaryColor', e.target.value)} </h4>
placeholder="#5C8762" <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
className="flex-1" {[
/> {
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>
</div> </div>
{/* Text */}
<div> <div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2"> <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.accentColor')} {t('branding.colorGroupText', 'Text')}
</label> <span
<div className="flex gap-2"> className="info-tooltip text-neutral-400 dark:text-neutral-500"
<input data-tooltip={t(
type="color" 'branding.colorGroupTextHelp',
value={localTheme.accentColor || '#22c55e'} 'Foreground text colours. Primary is for everything readers focus on; Secondary is for supporting copy.'
onChange={(e) => handleChange('accentColor', e.target.value)} )}
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer" tabIndex={0}
/> >
<Input <Info className="w-3.5 h-3.5" />
value={localTheme.accentColor || '#22c55e'} </span>
onChange={(e) => handleChange('accentColor', e.target.value)} </h4>
placeholder="#22c55e" <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
className="flex-1" {[
/> {
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>
</div> </div>
{/* Accent */}
<div> <div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2"> <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.backgroundColor')} {t('branding.colorGroupAccent', 'Accent')}
</label> <span
<div className="flex gap-2"> className="info-tooltip text-neutral-400 dark:text-neutral-500"
<input data-tooltip={t(
type="color" 'branding.colorGroupAccentHelp',
value={localTheme.backgroundColor || '#fafafa'} 'Brand colours that highlight interactive elements. Use a strong colour pair — Accent is for outlines/text, Accent Dark is for filled buttons.'
onChange={(e) => handleChange('backgroundColor', e.target.value)} )}
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer" tabIndex={0}
/> >
<Input <Info className="w-3.5 h-3.5" />
value={localTheme.backgroundColor || '#fafafa'} </span>
onChange={(e) => handleChange('backgroundColor', e.target.value)} </h4>
placeholder="#fafafa" <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
className="flex-1" {[
/> {
</div> key: 'accentColor',
</div> label: t('branding.accentColor', 'Accent'),
help: t(
<div> 'branding.accentColorHelp',
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2"> 'Links, icons, focus rings, hover states on primary buttons, active sidebar item underline. Should read clearly on both Background and Surface.'
{t('branding.textColor')} ),
</label> fallback: '#22c55e',
<div className="flex gap-2"> },
<input {
type="color" key: 'accentDarkColor',
value={localTheme.textColor || '#171717'} label: t('branding.accentDarkColor', 'Accent (filled)'),
onChange={(e) => handleChange('textColor', e.target.value)} help: t(
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer" 'branding.accentDarkColorHelp',
/> 'Filled CTA buttons, active sidebar item background, badges and tags. Needs enough contrast for white text to be readable on top.'
<Input ),
value={localTheme.textColor || '#171717'} fallback: '#5C8762',
onChange={(e) => handleChange('textColor', e.target.value)} },
placeholder="#171717" ].map(({ key, label, help, fallback }) => (
className="flex-1" <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>
{/* primaryColor is kept in sync with accentDarkColor inside
handleChange() no dedicated picker. */}
</div> </div>
</div> </div>
</Card> </Card>
@@ -1108,14 +1323,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' ? 'tile-selected'
: '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">
@@ -1130,14 +1345,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' ? 'tile-selected'
: '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">
@@ -1161,7 +1376,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')}
@@ -1179,10 +1394,14 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')} {t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')}
</p> </p>
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto"> <code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
{`--primary-color: ${localTheme.primaryColor || '#5C8762'}; {`--color-background: ${localTheme.backgroundColor || '#fafafa'};
--accent-color: ${localTheme.accentColor || '#22c55e'}; --color-surface: ${localTheme.surfaceColor || '#ffffff'};
--background-color: ${localTheme.backgroundColor || '#fafafa'}; --color-elevated: ${localTheme.elevatedColor || '#f5f5f5'};
--text-color: ${localTheme.textColor || '#171717'}; --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'}; --font-family: ${localTheme.fontFamily || 'Inter, sans-serif'};
--heading-font: ${localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'};`} --heading-font: ${localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'};`}
</code> </code>
+17 -19
View File
@@ -93,32 +93,30 @@ export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
{showDetails && ( {showDetails && (
<> <>
{/* Color Palette */} {/* Color Palette show all 8 tokens of the active theme.
Each swatch only renders if its token is set so legacy themes
(pre-8-token migration) still render their original 4 swatches. */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-neutral-500 dark:text-neutral-300" /> <Palette className="w-4 h-4 text-neutral-500 dark:text-neutral-300" />
<span className="text-sm text-neutral-600 dark:text-neutral-200">{t('branding.colors')}:</span> <span className="text-sm text-neutral-600 dark:text-neutral-200">{t('branding.colors')}:</span>
<div className="flex gap-1"> <div className="flex gap-1">
{themeConfig.primaryColor && ( {[
{ value: themeConfig.backgroundColor, title: t('branding.backgroundColor', 'Background') },
{ value: themeConfig.surfaceColor, title: t('branding.surfaceColor', 'Surface') },
{ value: themeConfig.elevatedColor, title: t('branding.elevatedColor', 'Elevated') },
{ value: themeConfig.surfaceBorderColor, title: t('branding.borderColor', 'Border') },
{ value: themeConfig.textColor, title: t('branding.textColor', 'Text') },
{ value: themeConfig.mutedTextColor, title: t('branding.mutedTextColor', 'Muted text') },
{ value: themeConfig.accentColor, title: t('branding.accentColor', 'Accent') },
{ value: themeConfig.accentDarkColor || themeConfig.primaryColor, title: t('branding.accentDarkColor', 'Accent (filled)') },
].filter((s) => !!s.value).map((s, i) => (
<div <div
key={i}
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600" className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
style={{ backgroundColor: themeConfig.primaryColor }} style={{ backgroundColor: s.value }}
title={t('branding.primaryColor')} title={s.title}
/> />
)} ))}
{themeConfig.accentColor && (
<div
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
style={{ backgroundColor: themeConfig.accentColor }}
title={t('branding.accentColor')}
/>
)}
{themeConfig.backgroundColor && (
<div
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
style={{ backgroundColor: themeConfig.backgroundColor }}
title={t('branding.backgroundColor')}
/>
)}
</div> </div>
</div> </div>
@@ -116,20 +116,20 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col"> <div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */} {/* Header */}
<div className="px-6 py-4 border-b border-neutral-200 flex items-center justify-between"> <div className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-semibold text-neutral-900"> <h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{t('events.galleryTheme')} {t('events.galleryTheme')}
</h2> </h2>
<p className="text-sm text-neutral-600 mt-1"> <p className="text-sm text-neutral-600 dark:text-neutral-300 mt-1">
{t('events.customizingThemeFor', { event: eventName })} {t('events.customizingThemeFor', { event: eventName })}
</p> </p>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 transition-colors" className="text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
> >
<X className="w-6 h-6" /> <X className="w-6 h-6" />
</button> </button>
@@ -139,7 +139,7 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 h-full"> <div className="grid grid-cols-1 lg:grid-cols-2 h-full">
{/* Left side - Theme Customizer */} {/* Left side - Theme Customizer */}
<div className="p-6 overflow-y-auto border-r border-neutral-200"> <div className="p-6 overflow-y-auto border-r border-neutral-200 dark:border-neutral-700">
<ThemeCustomizerEnhanced <ThemeCustomizerEnhanced
value={theme} value={theme}
onChange={handleThemeChange} onChange={handleThemeChange}
@@ -155,11 +155,11 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
</div> </div>
{/* Right side - Gallery Preview */} {/* Right side - Gallery Preview */}
<div className="p-6 bg-neutral-50 overflow-y-auto"> <div className="p-6 bg-neutral-50 dark:bg-neutral-800 overflow-y-auto">
<div className="space-y-4"> <div className="space-y-4">
{/* Grid Style Selector */} {/* Grid Style Selector */}
<div> <div>
<h3 className="text-sm font-medium text-neutral-700 mb-3"> <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-200 mb-3">
{t('branding.previewLayout')} {t('branding.previewLayout')}
</h3> </h3>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
@@ -169,12 +169,12 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
onClick={() => setPreviewLayout(layout)} onClick={() => setPreviewLayout(layout)}
className={`relative p-3 rounded-lg border-2 transition-all ${ className={`relative p-3 rounded-lg border-2 transition-all ${
(previewLayout || theme.galleryLayout || 'grid') === layout (previewLayout || theme.galleryLayout || 'grid') === layout
? 'border-primary-600 bg-primary-50' ? 'tile-selected'
: 'border-neutral-200 hover:border-neutral-300 bg-white' : 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 bg-white dark:bg-neutral-900'
}`} }`}
> >
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
<div className="text-neutral-700"> <div className="text-neutral-700 dark:text-neutral-200">
{layoutIcons[layout]} {layoutIcons[layout]}
</div> </div>
<span className="text-xs capitalize"> <span className="text-xs capitalize">
@@ -185,7 +185,7 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
</span> </span>
</div> </div>
{(previewLayout || theme.galleryLayout || 'grid') === layout && ( {(previewLayout || theme.galleryLayout || 'grid') === layout && (
<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" />
)} )}
</button> </button>
))} ))}
@@ -43,7 +43,7 @@ export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
onChange={handleChange} onChange={handleChange}
placeholder={placeholder} placeholder={placeholder}
rows={rows} rows={rows}
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 placeholder-neutral-400 dark:placeholder-neutral-500 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm" 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 placeholder-neutral-400 dark:placeholder-neutral-500 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark transition-colors resize-none font-mono text-sm"
/> />
<div className="absolute top-2 right-2 text-neutral-400" title="Line breaks will be preserved in emails"> <div className="absolute top-2 right-2 text-neutral-400" title="Line breaks will be preserved in emails">
<HelpCircle className="w-4 h-4" aria-hidden="true" /> <HelpCircle className="w-4 h-4" aria-hidden="true" />
@@ -300,7 +300,7 @@ export const WordFilterManager: React.FC = () => {
type="checkbox" type="checkbox"
checked={filter.is_active} checked={filter.is_active}
onChange={() => handleToggleActive(filter)} onChange={() => handleToggleActive(filter)}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500" className="w-4 h-4 text-accent rounded focus:ring-primary-500"
/> />
<span className="font-medium text-neutral-900 dark:text-neutral-100">{filter.word}</span> <span className="font-medium text-neutral-900 dark:text-neutral-100">{filter.word}</span>
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getSeverityBadgeClass(filter.severity)}`}> <span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getSeverityBadgeClass(filter.severity)}`}>
@@ -50,7 +50,10 @@ export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center"> <div
className="min-h-screen flex items-center justify-center"
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
>
<Loading size="lg" /> <Loading size="lg" />
</div> </div>
); );
@@ -80,11 +83,20 @@ export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback
<main className="flex-1 flex items-start justify-center px-4"> <main className="flex-1 flex items-start justify-center px-4">
<div className="max-w-2xl w-full"> <div className="max-w-2xl w-full">
<Card padding="lg"> <Card padding="lg">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-6"> {/*
* Heading + body now read from theme tokens so dark themes
* (and force-dark mode) render correctly without dark: variants
* fighting the CSS variables.
*/}
<h1
className="text-2xl sm:text-3xl font-bold mb-6"
style={{ color: 'var(--color-text)' }}
>
{page.title} {page.title}
</h1> </h1>
<div <div
className="prose prose-neutral dark:prose-invert max-w-none" className="prose prose-neutral dark:prose-invert max-w-none"
style={{ color: 'var(--color-text)' }}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(page.content, { __html: DOMPurify.sanitize(page.content, {
ALLOWED_TAGS, ALLOWED_TAGS,
@@ -97,7 +109,8 @@ export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback
<div className="mt-8"> <div className="mt-8">
<Link <Link
to="/" to="/"
className="text-sm font-medium text-primary-600 hover:text-primary-700" className="text-sm font-medium hover:underline"
style={{ color: 'var(--color-accent)' }}
> >
{lang === 'de' ? '← Zur Startseite' : '← Back to home'} {lang === 'de' ? '← Zur Startseite' : '← Back to home'}
</Link> </Link>
@@ -106,13 +119,16 @@ export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback
</div> </div>
</main> </main>
<footer className="py-8 text-center text-xs text-neutral-500"> <footer
className="py-8 text-center text-xs"
style={{ color: 'var(--color-muted-text)' }}
>
<div className="flex justify-center gap-4"> <div className="flex justify-center gap-4">
<Link to="/impressum" className="hover:text-neutral-700"> <Link to="/impressum" className="hover:underline">
{lang === 'de' ? 'Impressum' : 'Legal Notice'} {lang === 'de' ? 'Impressum' : 'Legal Notice'}
</Link> </Link>
<span className="text-neutral-400"></span> <span style={{ color: 'var(--color-surface-border)' }}></span>
<Link to="/datenschutz" className="hover:text-neutral-700"> <Link to="/datenschutz" className="hover:underline">
{lang === 'de' ? 'Datenschutz' : 'Privacy Policy'} {lang === 'de' ? 'Datenschutz' : 'Privacy Policy'}
</Link> </Link>
</div> </div>
@@ -84,7 +84,7 @@ export const LanguageSelector: React.FC = () => {
onClick={() => handleLanguageChange(language.code)} onClick={() => handleLanguageChange(language.code)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3 ${ className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3 ${
language.code === i18n.language language.code === i18n.language
? 'text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30' ? 'text-accent bg-accent-dark/15'
: 'text-neutral-700 dark:text-neutral-300' : 'text-neutral-700 dark:text-neutral-300'
}`} }`}
> >
+1 -1
View File
@@ -23,7 +23,7 @@ export const Loading: React.FC<LoadingProps> = ({
const content = ( const content = (
<div className={clsx('flex flex-col items-center justify-center', className)}> <div className={clsx('flex flex-col items-center justify-center', className)}>
<Loader2 className={clsx('animate-spin text-primary-600', sizeStyles[size])} /> <Loader2 className={clsx('animate-spin text-accent', sizeStyles[size])} />
{text && ( {text && (
<p className="mt-4 text-sm text-neutral-600">{text}</p> <p className="mt-4 text-sm text-neutral-600">{text}</p>
)} )}
+1 -1
View File
@@ -4,7 +4,7 @@ export const SkipLink: React.FC = () => {
return ( return (
<a <a
href="#main-content" href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-primary-600 text-white px-4 py-2 rounded-lg z-50 focus:outline-none focus:ring-2 focus:ring-primary-700" className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 bg-accent-dark text-white px-4 py-2 rounded-lg z-50 focus:outline-none focus:ring-2 focus:ring-primary-700"
> >
Skip to main content Skip to main content
</a> </a>
@@ -23,7 +23,7 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
<div className="fixed bottom-4 right-4 bg-surface rounded-lg shadow-lg border border-surface p-4 min-w-[300px] z-50"> <div className="fixed bottom-4 right-4 bg-surface rounded-lg shadow-lg border border-surface p-4 min-w-[300px] z-50">
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Download className="w-5 h-5 text-primary-600 animate-bounce" /> <Download className="w-5 h-5 text-accent animate-bounce" />
<div> <div>
<p className="text-sm font-medium text-theme">{t('download.downloading')}</p> <p className="text-sm font-medium text-theme">{t('download.downloading')}</p>
{fileName && ( {fileName && (
@@ -43,7 +43,7 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
<div className="w-full bg-black/10 rounded-full h-2"> <div className="w-full bg-black/10 rounded-full h-2">
<div <div
className="bg-primary-600 h-2 rounded-full transition-all duration-300" className="bg-accent-dark h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }} style={{ width: `${progress}%` }}
/> />
</div> </div>
@@ -168,7 +168,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
<Heart className="w-3 h-3 sm:w-4 sm:h-4" /> <Heart className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('gallery.liked', 'Liked')}</span> <span className="hidden sm:inline">{t('gallery.liked', 'Liked')}</span>
{likeCount > 0 && ( {likeCount > 0 && (
<span className="bg-primary-100 text-primary-700 px-1.5 rounded"> <span className="bg-accent-dark/15 text-accent-dark px-1.5 rounded">
{likeCount} {likeCount}
</span> </span>
)} )}
@@ -183,7 +183,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
<Bookmark className="w-3 h-3 sm:w-4 sm:h-4" /> <Bookmark className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('gallery.favorited', 'Saved')}</span> <span className="hidden sm:inline">{t('gallery.favorited', 'Saved')}</span>
{favoriteCount > 0 && ( {favoriteCount > 0 && (
<span className="bg-primary-100 text-primary-700 px-1.5 rounded"> <span className="bg-accent-dark/15 text-accent-dark px-1.5 rounded">
{favoriteCount} {favoriteCount}
</span> </span>
)} )}
@@ -198,7 +198,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
<Star className="w-3 h-3 sm:w-4 sm:h-4" /> <Star className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('gallery.rated', 'Rated')}</span> <span className="hidden sm:inline">{t('gallery.rated', 'Rated')}</span>
{ratedCount > 0 && ( {ratedCount > 0 && (
<span className="bg-primary-100 text-primary-700 px-1.5 rounded"> <span className="bg-accent-dark/15 text-accent-dark px-1.5 rounded">
{ratedCount} {ratedCount}
</span> </span>
)} )}
@@ -495,7 +495,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{t('gallery.needHelp')}{' '} {t('gallery.needHelp')}{' '}
<a <a
href={`mailto:${brandingSettings.support_email}`} href={`mailto:${brandingSettings.support_email}`}
className="text-primary-600 hover:text-primary-700 break-all" className="text-accent hover:opacity-80 break-all"
> >
{brandingSettings.support_email} {brandingSettings.support_email}
</a> </a>
@@ -255,7 +255,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
className={` className={`
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
${selectedCategoryId === null ${selectedCategoryId === null
? 'bg-primary-600/20 text-primary-500' ? 'bg-accent-dark text-white'
: 'hover:bg-black/10 text-muted-theme' : 'hover:bg-black/10 text-muted-theme'
} }
`} `}
@@ -278,7 +278,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
className={` className={`
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
${isSelected ${isSelected
? 'bg-primary-600/20 text-primary-500' ? 'bg-accent-dark text-white'
: 'hover:bg-black/10 text-muted-theme' : 'hover:bg-black/10 text-muted-theme'
} }
`} `}
@@ -362,7 +362,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
className={` className={`
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3 gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
${isSelected ${isSelected
? 'bg-primary-600/20 text-primary-500' ? 'bg-accent-dark text-white'
: 'hover:bg-black/10 text-muted-theme' : 'hover:bg-black/10 text-muted-theme'
} }
`} `}
@@ -341,7 +341,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
themeToApply = settingsData.theme_config; themeToApply = settingsData.theme_config;
} }
// 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.
// Instance-wide force color mode is enforced inside ThemeContext.applyTheme,
// so callers don't have to wrap the theme themselves.
if (themeToApply) { if (themeToApply) {
// Use setTimeout to ensure this runs after any global theme application // Use setTimeout to ensure this runs after any global theme application
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -145,7 +145,7 @@ export const GuestNamePromptModal: React.FC<GuestNamePromptModalProps> = ({
closePrompt(); closePrompt();
openRecovery(); openRecovery();
}} }}
className="text-sm text-primary-600 hover:underline w-full text-center pt-2" className="text-sm text-accent hover:underline w-full text-center pt-2"
> >
{t('gallery.guestPrompt.alreadyHere', "I've been here before")} {t('gallery.guestPrompt.alreadyHere', "I've been here before")}
</button> </button>
@@ -179,7 +179,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
value={commentText} value={commentText}
onChange={(e) => setCommentText(e.target.value)} onChange={(e) => setCommentText(e.target.value)}
placeholder={t('feedback.writeComment', 'Write a comment...')} placeholder={t('feedback.writeComment', 'Write a comment...')}
className={`w-full px-3 py-2 text-sm border rounded-lg resize-vertical min-h-[100px] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${ className={`w-full px-3 py-2 text-sm border rounded-lg resize-vertical min-h-[100px] focus:ring-2 focus:ring-primary-500 focus:border-accent-dark ${
errors.comment_text ? 'border-red-500' : 'border-surface' errors.comment_text ? 'border-red-500' : 'border-surface'
}`} }`}
rows={4} rows={4}
@@ -97,7 +97,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
setShowSortMenu(false); setShowSortMenu(false);
}} }}
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${ className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
sortBy === 'date' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme' sortBy === 'date' ? 'bg-accent-dark text-white' : 'text-muted-theme'
}`} }`}
> >
{t('gallery.sortByDate')} {t('gallery.sortByDate')}
@@ -108,7 +108,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
setShowSortMenu(false); setShowSortMenu(false);
}} }}
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${ className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
sortBy === 'capture_date' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme' sortBy === 'capture_date' ? 'bg-accent-dark text-white' : 'text-muted-theme'
}`} }`}
> >
{t('photoSort.dateTaken', 'Date Taken')} {t('photoSort.dateTaken', 'Date Taken')}
@@ -119,7 +119,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
setShowSortMenu(false); setShowSortMenu(false);
}} }}
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${ className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
sortBy === 'name' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme' sortBy === 'name' ? 'bg-accent-dark text-white' : 'text-muted-theme'
}`} }`}
> >
{t('gallery.sortByName')} {t('gallery.sortByName')}
@@ -130,7 +130,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
setShowSortMenu(false); setShowSortMenu(false);
}} }}
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${ className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
sortBy === 'size' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme' sortBy === 'size' ? 'bg-accent-dark text-white' : 'text-muted-theme'
}`} }`}
> >
{t('gallery.sortBySize')} {t('gallery.sortBySize')}
@@ -141,7 +141,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
setShowSortMenu(false); setShowSortMenu(false);
}} }}
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${ className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
sortBy === 'rating' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme' sortBy === 'rating' ? 'bg-accent-dark text-white' : 'text-muted-theme'
}`} }`}
> >
{t('gallery.sortByRating', 'Sort by Rating')} {t('gallery.sortByRating', 'Sort by Rating')}
@@ -298,7 +298,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
<div className="absolute top-2 left-2 flex gap-1 z-10"> <div className="absolute top-2 left-2 flex gap-1 z-10">
{(photo.comment_count ?? 0) > 0 && ( {(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}> <div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}>
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" /> <MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
<span className="text-xs font-medium text-muted-theme">{photo.comment_count ?? 0}</span> <span className="text-xs font-medium text-muted-theme">{photo.comment_count ?? 0}</span>
</div> </div>
)} )}
@@ -341,7 +341,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
{/* Selection checkbox - Larger on mobile for easier tapping */} {/* Selection checkbox - Larger on mobile for easier tapping */}
{isSelectionMode && ( {isSelectionMode && (
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100'} transition-opacity`}> <div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100'} transition-opacity`}>
<div className={`w-7 h-7 sm:w-6 sm:h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}> <div className={`w-7 h-7 sm:w-6 sm:h-6 rounded-full border-2 ${isSelected ? 'bg-accent-dark border-accent-dark' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />} {isSelected && <Check className="w-4 h-4 text-white" />}
</div> </div>
</div> </div>
@@ -677,7 +677,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
> >
<MessageSquare className="w-5 h-5 text-white" /> <MessageSquare className="w-5 h-5 text-white" />
{((currentPhoto.comment_count ?? 0) > 0 || (currentPhoto.average_rating ?? 0) > 0) && ( {((currentPhoto.comment_count ?? 0) > 0 || (currentPhoto.average_rating ?? 0) > 0) && (
<span className="absolute -top-1 -right-1 bg-primary-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center"> <span className="absolute -top-1 -right-1 bg-accent-dark/150 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{(currentPhoto.comment_count ?? 0) > 0 ? currentPhoto.comment_count ?? 0 : '★'} {(currentPhoto.comment_count ?? 0) > 0 ? currentPhoto.comment_count ?? 0 : '★'}
</span> </span>
)} )}
@@ -157,7 +157,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
{/* Upload Area */} {/* Upload Area */}
<div className="mb-4 sm:mb-6"> <div className="mb-4 sm:mb-6">
<label className="block"> <label className="block">
<div className="border-2 border-dashed border-surface rounded-lg p-6 sm:p-8 text-center hover:border-primary-500 transition-colors cursor-pointer"> <div className="border-2 border-dashed border-surface rounded-lg p-6 sm:p-8 text-center hover:border-accent-dark transition-colors cursor-pointer">
<Upload className="w-10 h-10 sm:w-12 sm:h-12 text-neutral-400 mx-auto mb-3" /> <Upload className="w-10 h-10 sm:w-12 sm:h-12 text-neutral-400 mx-auto mb-3" />
<p className="text-sm font-medium text-muted-theme mb-1"> <p className="text-sm font-medium text-muted-theme mb-1">
{t('upload.clickToUpload')} {t('upload.clickToUpload')}
@@ -210,7 +210,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
<div className="w-20"> <div className="w-20">
<div className="bg-neutral-200 rounded-full h-2"> <div className="bg-neutral-200 rounded-full h-2">
<div <div
className="bg-primary-600 h-2 rounded-full transition-all" className="bg-accent-dark h-2 rounded-full transition-all"
style={{ width: `${uploadProgress[file.name]}%` }} style={{ width: `${uploadProgress[file.name]}%` }}
/> />
</div> </div>
@@ -321,7 +321,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
className={`absolute top-2 right-2 z-20 transition-opacity ${checkboxVisibilityClass} md:group-hover:opacity-100`} className={`absolute top-2 right-2 z-20 transition-opacity ${checkboxVisibilityClass} md:group-hover:opacity-100`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }} onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
> >
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}> <div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />} {isSelected && <Check className="w-4 h-4 text-white" />}
</div> </div>
</button> </button>
@@ -341,7 +341,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
)} )}
{commentCount > 0 && ( {commentCount > 0 && (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Commented"> <span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Commented">
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" /> <MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
</span> </span>
)} )}
</div> </div>
@@ -370,7 +370,7 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
> >
<div <div
className={`w-6 h-6 rounded-full border-2 ${ className={`w-6 h-6 rounded-full border-2 ${
isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white' isSelected ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'
} flex items-center justify-center transition-colors`} } flex items-center justify-center transition-colors`}
> >
{isSelected && <Check className="w-4 h-4 text-white" />} {isSelected && <Check className="w-4 h-4 text-white" />}
@@ -403,7 +403,7 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm"
title="Commented" title="Commented"
> >
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" /> <MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
</span> </span>
)} )}
</div> </div>
@@ -102,7 +102,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
<div className="absolute top-2 left-2 flex gap-1 z-10"> <div className="absolute top-2 left-2 flex gap-1 z-10">
{(photo.comment_count ?? 0) > 0 && ( {(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}> <div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}>
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" /> <MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span> <span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
</div> </div>
)} )}
@@ -245,7 +245,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
}`} }`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }} onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
> >
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}> <div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />} {isSelected && <Check className="w-4 h-4 text-white" />}
</div> </div>
</button> </button>
@@ -486,7 +486,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<div className="absolute top-2 left-2 flex gap-1 z-10"> <div className="absolute top-2 left-2 flex gap-1 z-10">
{(photo.comment_count ?? 0) > 0 && ( {(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1"> <div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" /> <MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span> <span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
</div> </div>
)} )}
@@ -542,7 +542,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}`} }`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }} onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
> >
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}> <div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />} {selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div> </div>
</button> </button>
@@ -602,7 +602,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<div className="absolute top-2 left-2 flex gap-1 z-10"> <div className="absolute top-2 left-2 flex gap-1 z-10">
{(photo.comment_count ?? 0) > 0 && ( {(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1"> <div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" /> <MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span> <span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
</div> </div>
)} )}
@@ -658,7 +658,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}`} }`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }} onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
> >
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}> <div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />} {selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div> </div>
</button> </button>
@@ -727,7 +727,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<div className="absolute top-2 left-2 flex gap-1 z-10"> <div className="absolute top-2 left-2 flex gap-1 z-10">
{(photo.comment_count ?? 0) > 0 && ( {(photo.comment_count ?? 0) > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1"> <div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" /> <MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span> <span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
</div> </div>
)} )}
@@ -783,7 +783,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}`} }`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }} onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
> >
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}> <div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />} {selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div> </div>
</button> </button>
@@ -180,7 +180,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
}`} }`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }} onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
> >
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}> <div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />} {isSelected && <Check className="w-4 h-4 text-white" />}
</div> </div>
</button> </button>
@@ -86,8 +86,8 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
{/* Date marker */} {/* Date marker */}
{showDates && ( {showDates && (
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<div className="hidden lg:flex items-center justify-center w-16 h-16 bg-white border-4 border-primary-600 rounded-full z-10"> <div className="hidden lg:flex items-center justify-center w-16 h-16 bg-white border-4 border-accent-dark rounded-full z-10">
<Calendar className="w-6 h-6 text-primary-600" /> <Calendar className="w-6 h-6 text-accent" />
</div> </div>
<h3 className="text-xl font-semibold text-theme"> <h3 className="text-xl font-semibold text-theme">
{group.label} {group.label}
@@ -218,7 +218,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}`} }`}
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }} onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
> >
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}> <div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-accent-dark border-accent-dark' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />} {selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div> </div>
</button> </button>
+43 -8
View File
@@ -1,11 +1,19 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react'; import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { usePublicSettings } from '../hooks/usePublicSettings';
type DarkModePreference = 'light' | 'dark' | 'system'; type DarkModePreference = 'light' | 'dark' | 'system';
interface AdminDarkModeContextType { interface AdminDarkModeContextType {
preference: DarkModePreference; preference: DarkModePreference;
isDark: boolean; isDark: boolean;
/**
* When the admin has set `branding_force_color_mode`, the toggle is locked
* to that value. Consumers (AdminHeader) hide their toggle when this is
* truthy UI parity with the user's "disable lightmode option page wide"
* request from discussion #397.
*/
forcedMode: 'dark' | 'light' | null;
setPreference: (pref: DarkModePreference) => void; setPreference: (pref: DarkModePreference) => void;
toggle: () => void; toggle: () => void;
} }
@@ -24,12 +32,25 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
const location = useLocation(); const location = useLocation();
const isLoginPage = location.pathname === '/admin/login'; const isLoginPage = location.pathname === '/admin/login';
// Instance-wide force mode (read from branding settings). When set, this
// wins over user preference and system preference. Refetches every 30s so
// toggling it in the Branding tab propagates to other open tabs without
// needing a full reload.
const { data: publicSettings } = usePublicSettings({ refetchInterval: 30_000 });
const forcedMode: 'dark' | 'light' | null = publicSettings?.branding_force_color_mode === 'dark'
? 'dark'
: publicSettings?.branding_force_color_mode === 'light'
? 'light'
: null;
const [preference, setPreferenceState] = useState<DarkModePreference>(() => { const [preference, setPreferenceState] = useState<DarkModePreference>(() => {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'dark' || stored === 'light' || stored === 'system') return stored; if (stored === 'dark' || stored === 'light' || stored === 'system') return stored;
return 'light'; return 'light';
}); });
// The effective dark state: if a force mode is set, that wins; otherwise
// we resolve from the user's preference (light / dark / system).
const [isDark, setIsDark] = useState(() => resolveIsDark(preference)); const [isDark, setIsDark] = useState(() => resolveIsDark(preference));
const applyDarkClass = useCallback((dark: boolean, forceLight = false) => { const applyDarkClass = useCallback((dark: boolean, forceLight = false) => {
@@ -42,26 +63,37 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
}, []); }, []);
const setPreference = useCallback((pref: DarkModePreference) => { const setPreference = useCallback((pref: DarkModePreference) => {
// If an admin has locked the instance to a specific mode, the user
// toggle is a no-op — silently ignore so we don't desync the UI.
if (forcedMode) return;
setPreferenceState(pref); setPreferenceState(pref);
localStorage.setItem(STORAGE_KEY, pref); localStorage.setItem(STORAGE_KEY, pref);
const dark = resolveIsDark(pref); const dark = resolveIsDark(pref);
setIsDark(dark); setIsDark(dark);
// Don't apply dark on login page // Don't apply dark on login page
applyDarkClass(dark, isLoginPage); applyDarkClass(dark, isLoginPage);
}, [applyDarkClass, isLoginPage]); }, [applyDarkClass, isLoginPage, forcedMode]);
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (forcedMode) return;
setPreference(isDark ? 'light' : 'dark'); setPreference(isDark ? 'light' : 'dark');
}, [isDark, setPreference]); }, [isDark, setPreference, forcedMode]);
// Apply on mount and when route changes - skip dark mode on login page // Apply on mount and when route or force mode changes. The force-mode
// branch wins, then per-route login override, then user preference.
useEffect(() => { useEffect(() => {
if (forcedMode) {
const dark = forcedMode === 'dark';
setIsDark(dark);
applyDarkClass(dark, isLoginPage && forcedMode === 'light');
return;
}
applyDarkClass(isDark, isLoginPage); applyDarkClass(isDark, isLoginPage);
}, [applyDarkClass, isDark, isLoginPage]); }, [applyDarkClass, isDark, isLoginPage, forcedMode]);
// Listen for system changes when preference is 'system' // Listen for system changes when preference is 'system' (and no force mode)
useEffect(() => { useEffect(() => {
if (preference !== 'system') return; if (forcedMode || preference !== 'system') return;
const mql = window.matchMedia('(prefers-color-scheme: dark)'); const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => { const handler = (e: MediaQueryListEvent) => {
@@ -70,7 +102,7 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
}; };
mql.addEventListener('change', handler); mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler); return () => mql.removeEventListener('change', handler);
}, [preference, applyDarkClass]); }, [preference, applyDarkClass, forcedMode]);
// Strip dark class when unmounting (navigating away from admin) // Strip dark class when unmounting (navigating away from admin)
useEffect(() => { useEffect(() => {
@@ -79,7 +111,10 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
}; };
}, []); }, []);
const value = useMemo(() => ({ preference, isDark, setPreference, toggle }), [preference, isDark, setPreference, toggle]); const value = useMemo(
() => ({ preference, isDark, forcedMode, setPreference, toggle }),
[preference, isDark, forcedMode, setPreference, toggle]
);
return ( return (
<AdminDarkModeContext.Provider value={value}> <AdminDarkModeContext.Provider value={value}>
+54 -16
View File
@@ -2,6 +2,8 @@ import React, { createContext, useContext, useState, useEffect, useCallback, use
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types'; import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types';
import { fontsService, extractFamilyName, type FontDefinition } from '../services/fonts.service'; import { fontsService, extractFamilyName, type FontDefinition } from '../services/fonts.service';
import { applyForceColorMode } from '../utils/themeMigration';
import { usePublicSettings } from '../hooks/usePublicSettings';
// Self-hosted font loader. Resolves the available-fonts list once (cached for // Self-hosted font loader. Resolves the available-fonts list once (cached for
// 5 minutes) and lazily injects @font-face blocks into <head> only for the // 5 minutes) and lazily injects @font-face blocks into <head> only for the
@@ -90,8 +92,8 @@ interface ThemeProviderProps {
initialThemeName?: string; initialThemeName?: string;
} }
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
children, children,
initialTheme = GALLERY_THEME_PRESETS.default.config, initialTheme = GALLERY_THEME_PRESETS.default.config,
initialThemeName = 'default' initialThemeName = 'default'
}) => { }) => {
@@ -99,20 +101,50 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
const [themeName, setThemeName] = useState(initialThemeName); const [themeName, setThemeName] = useState(initialThemeName);
const [resolvedColorMode, setResolvedColorMode] = useState<'light' | 'dark'>(() => resolveColorMode(initialTheme.colorMode)); const [resolvedColorMode, setResolvedColorMode] = useState<'light' | 'dark'>(() => resolveColorMode(initialTheme.colorMode));
const applyTheme = useCallback((themeConfig: ThemeConfig) => { // Subscribe to the instance-wide force color mode setting. When an admin
// toggles "Force dark / light" in Branding, all open admin and gallery
// tabs re-apply the active theme through applyForceColorMode within the
// refetch interval so the lock takes effect without a full reload.
// Refetch is best-effort — a stale cached value just means a delayed flip,
// not a broken state.
const { data: publicSettings } = usePublicSettings({ refetchInterval: 30_000 });
const forcedMode = publicSettings?.branding_force_color_mode === 'dark'
? 'dark'
: publicSettings?.branding_force_color_mode === 'light'
? 'light'
: null;
const applyTheme = useCallback((rawThemeConfig: ThemeConfig) => {
const root = document.documentElement; const root = document.documentElement;
// Apply CSS variables // Honour the instance-wide force color mode at the chokepoint so every
// call site (gallery, admin, preview iframe, branding live preview) is
// forced to follow without each one having to remember to do it.
// applyForceColorMode is a no-op when forcedMode is null, and only
// swaps surface/text tokens when the active theme doesn't natively
// support the locked mode — accent CI colours are preserved either way.
const themeConfig = applyForceColorMode(rawThemeConfig, forcedMode);
// Apply CSS variables — 8-token CI palette.
// Legacy --color-primary / --color-primary-light / --color-primary-dark
// are kept for any consumer still reading them; they mirror accent-dark.
if (themeConfig.primaryColor) { if (themeConfig.primaryColor) {
root.style.setProperty('--color-primary', themeConfig.primaryColor); root.style.setProperty('--color-primary', themeConfig.primaryColor);
// Generate primary color shades
root.style.setProperty('--color-primary-light', lightenColor(themeConfig.primaryColor, 20)); root.style.setProperty('--color-primary-light', lightenColor(themeConfig.primaryColor, 20));
root.style.setProperty('--color-primary-dark', darkenColor(themeConfig.primaryColor, 20)); root.style.setProperty('--color-primary-dark', darkenColor(themeConfig.primaryColor, 20));
} }
if (themeConfig.accentColor) { if (themeConfig.accentColor) {
root.style.setProperty('--color-accent', themeConfig.accentColor); root.style.setProperty('--color-accent', themeConfig.accentColor);
} }
// Accent-dark: filled CTA background. Falls back to primaryColor for
// legacy themes that pre-date the explicit token (matches the previous
// implicit behavior where .btn-primary used --color-primary).
const accentDark = themeConfig.accentDarkColor || themeConfig.primaryColor;
if (accentDark) {
root.style.setProperty('--color-accent-dark', accentDark);
}
if (themeConfig.backgroundColor) { if (themeConfig.backgroundColor) {
root.style.setProperty('--color-background', themeConfig.backgroundColor); root.style.setProperty('--color-background', themeConfig.backgroundColor);
@@ -194,6 +226,16 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
root.style.setProperty('--color-surface', '#ffffff'); root.style.setProperty('--color-surface', '#ffffff');
} }
// Elevated: raised panels, image placeholders. Falls back to a slight
// shift from surface so the layering still reads on legacy themes.
if (themeConfig.elevatedColor) {
root.style.setProperty('--color-elevated', themeConfig.elevatedColor);
} else if (effectiveMode === 'dark') {
root.style.setProperty('--color-elevated', '#242424');
} else {
root.style.setProperty('--color-elevated', '#f5f5f5');
}
if (themeConfig.surfaceBorderColor) { if (themeConfig.surfaceBorderColor) {
root.style.setProperty('--color-surface-border', themeConfig.surfaceBorderColor); root.style.setProperty('--color-surface-border', themeConfig.surfaceBorderColor);
} else if (effectiveMode === 'dark') { } else if (effectiveMode === 'dark') {
@@ -252,7 +294,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
} }
styleElement.textContent = themeConfig.customCss; styleElement.textContent = themeConfig.customCss;
} }
}, []); }, [forcedMode]);
const setThemeConfig = useCallback((newTheme: ThemeConfig) => { const setThemeConfig = useCallback((newTheme: ThemeConfig) => {
setTheme(newTheme); setTheme(newTheme);
@@ -272,15 +314,11 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
setThemeByName('default'); setThemeByName('default');
}, [setThemeByName]); }, [setThemeByName]);
// Apply theme when it changes, but skip if it's the same // Apply theme when it changes, OR when force-mode changes (so an admin
// toggling Force dark / light in Branding flips every open tab on the
// next public-settings refetch tick — no reload needed).
useEffect(() => { useEffect(() => {
const root = document.documentElement; applyTheme(theme);
const currentPrimary = root.style.getPropertyValue('--color-primary');
// Only apply if the theme has actually changed
if (currentPrimary !== theme.primaryColor) {
applyTheme(theme);
}
}, [theme, applyTheme]); }, [theme, applyTheme]);
// Load theme from localStorage on mount (skip if in gallery view) // Load theme from localStorage on mount (skip if in gallery view)
+180 -20
View File
@@ -19,29 +19,60 @@
@layer base { @layer base {
:root { :root {
/* Theme CSS Variables */ /*
* Theme CSS Variables 8-token CI palette.
* Token names are kept aligned with frontend/src/types/theme.types.ts
* (ThemeConfig). ThemeContext.applyTheme() writes these from the active
* theme/branding settings; values here are the "Classic Grid" defaults.
*/
--color-background: #fafafa; /* page base */
--color-surface: #ffffff; /* cards, nav, alternating sections */
--color-elevated: #f5f5f5; /* raised panels */
--color-surface-border: #e5e5e5; /* dividers, borders (a.k.a. border token) */
--color-text: #171717; /* primary text */
--color-muted-text: #737373; /* secondary text */
--color-accent: #22c55e; /* links, focus rings, hover */
--color-accent-dark: #5C8762; /* primary CTA fill */
/* Legacy aliases kept for any consumer that still reads --color-primary.
* Both resolve to the accent-dark token (the previous "primary" CTA color). */
--color-primary: #5C8762; --color-primary: #5C8762;
--color-primary-light: #7aa583; --color-primary-light: #7aa583;
--color-primary-dark: #4a6f4f; --color-primary-dark: #4a6f4f;
--color-accent: #22c55e;
--color-background: #fafafa;
--color-text: #171717;
--font-family: 'Inter', 'Noto Sans', system-ui, -apple-system, sans-serif; --font-family: 'Inter', 'Noto Sans', system-ui, -apple-system, sans-serif;
--heading-font-family: 'Inter', 'Noto Sans', system-ui, -apple-system, sans-serif; --heading-font-family: 'Inter', 'Noto Sans', system-ui, -apple-system, sans-serif;
--border-radius: 0.5rem; --border-radius: 0.5rem;
--font-size-base: 16px; --font-size-base: 16px;
--shadow-default: 0 4px 6px rgba(0,0,0,0.1); --shadow-default: 0 4px 6px rgba(0,0,0,0.1);
/* Surface colors (for cards, inputs, etc.) */
--color-surface: #ffffff;
--color-surface-border: #e5e5e5;
--color-muted-text: #737373;
/* Tailwind RGB values for primary color */ /* Tailwind RGB values for primary color (legacy, used by primary-* utilities) */
--tw-color-primary: 92 135 98; --tw-color-primary: 92 135 98;
--radius: 0.5rem; --radius: 0.5rem;
} }
/*
* Admin dark mode unification.
* AdminDarkModeContext toggles `.dark` on <html> without going through
* applyTheme(). Previously this only flipped the components that had
* explicit `.dark .x` overrides; everything else (cards/inputs/buttons
* that now read CSS variables) stayed light. We re-declare the 8-token
* defaults under `.dark` so the same variables resolve to a dark palette
* whenever the class is present. Gallery applyTheme() still wins because
* it writes inline `--color-*` styles on the html element, which beat the
* .dark stylesheet rule in the cascade.
*/
.dark {
--color-background: #0a0a0a;
--color-surface: #171717;
--color-elevated: #1f1f1f;
--color-surface-border: #262626;
--color-text: #f5f5f5;
--color-muted-text: #a3a3a3;
--color-accent: #22c55e;
--color-accent-dark: #5C8762;
}
* { * {
font-family: var(--font-family); font-family: var(--font-family);
} }
@@ -100,33 +131,59 @@
} }
@layer components { @layer components {
/* Button styles */ /*
* Button styles.
*
* Migration note: .btn-primary now binds to --color-accent-dark instead
* of --color-primary. The two tokens are kept in lockstep by the
* customizer (handleChange syncs primaryColor accentDarkColor) and by
* the migration helper (legacy themes get accentDarkColor = primaryColor),
* so existing instances render identically after upgrade but new themes
* that set only accentDarkColor (the 8-token CI flow) now drive primary
* buttons correctly. White text + accent-dark fill matches the rest of
* the selected-state visual language (sidebar, tile-selected, segmented
* buttons). Hover stays on --color-primary-dark for the auto-darkened
* legacy behaviour.
*/
.btn { .btn {
@apply inline-flex items-center justify-center font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50; @apply inline-flex items-center justify-center font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50;
border-radius: var(--border-radius); border-radius: var(--border-radius);
--tw-ring-color: var(--color-accent);
--tw-ring-offset-color: var(--color-background);
} }
.btn-primary { .btn-primary {
background-color: var(--color-primary); background-color: var(--color-accent-dark);
color: white; color: white;
@apply hover:opacity-90 focus-visible:ring-2; @apply hover:opacity-90;
} }
.btn-primary:hover { .btn-primary:hover {
background-color: var(--color-primary-dark); background-color: var(--color-primary-dark);
} }
.btn-alt {
background-color: transparent;
color: var(--color-text);
border: 2px solid var(--color-text);
}
.btn-alt:hover {
background-color: var(--color-text);
color: var(--color-background);
}
.btn-secondary { .btn-secondary {
background-color: var(--color-surface-border); background-color: var(--color-surface-border);
color: var(--color-text); color: var(--color-text);
@apply hover:opacity-80 focus-visible:ring-neutral-400; @apply hover:opacity-80;
} }
.btn-outline { .btn-outline {
border-color: var(--color-surface-border); border-color: var(--color-surface-border);
background-color: transparent; background-color: transparent;
color: var(--color-muted-text); color: var(--color-muted-text);
@apply border hover:opacity-80 focus-visible:ring-neutral-400; @apply border hover:opacity-80;
} }
.btn-sm { .btn-sm {
@@ -141,7 +198,13 @@
@apply h-11 px-8 text-lg; @apply h-11 px-8 text-lg;
} }
/* Input styles - Admin inputs use explicit Tailwind colors */ /*
* Input styles - Admin inputs keep explicit Tailwind colors so the visual
* contract is byte-for-byte identical to pre-migration (border-neutral-300
* vs the lighter neutral-200/surface-border). Dark mode is handled by the
* .dark .input override below. Gallery inputs use .input-themed which
* binds to the 8-token palette via CSS variables.
*/
.input { .input {
@apply flex h-10 w-full rounded-lg border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50; @apply flex h-10 w-full rounded-lg border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
@apply bg-white border-neutral-300 text-neutral-900; @apply bg-white border-neutral-300 text-neutral-900;
@@ -163,7 +226,10 @@
color: var(--color-muted-text); color: var(--color-muted-text);
} }
/* Card styles - Admin cards use explicit Tailwind colors, gallery cards use CSS variables */ /* Card styles - Admin cards keep their explicit Tailwind colors for
* migration safety (visible border lightness change otherwise); the
* .dark .card override below handles admin dark mode. Gallery uses
* .card-themed which binds to the 8-token palette. */
.card { .card {
@apply rounded-xl border bg-white border-neutral-200; @apply rounded-xl border bg-white border-neutral-200;
box-shadow: var(--shadow-default); box-shadow: var(--shadow-default);
@@ -191,11 +257,33 @@
@apply mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl w-full; @apply mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl w-full;
} }
/* Surface utility classes for gallery dark mode */ /*
* Theme-token utility classes exposed so any component (admin or gallery)
* can opt into the 8-token palette without an inline style. The Tailwind
* config also exposes these as full color aliases (bg-surface, text-theme,
* etc.) but these single-purpose classes are kept for back-compat with
* existing call sites.
*/
.bg-background {
background-color: var(--color-background);
}
.bg-surface { .bg-surface {
background-color: var(--color-surface); background-color: var(--color-surface);
} }
.bg-elevated {
background-color: var(--color-elevated);
}
.bg-accent {
background-color: var(--color-accent);
}
.bg-accent-dark {
background-color: var(--color-accent-dark);
}
.border-surface { .border-surface {
border-color: var(--color-surface-border); border-color: var(--color-surface-border);
} }
@@ -208,6 +296,77 @@
color: var(--color-muted-text); color: var(--color-muted-text);
} }
.text-accent {
color: var(--color-accent);
}
/*
* Selected-tile state for picker grids (Gallery Layout, Filter Bar Style,
* Header Style, Hero Divider, Theme Presets). Used in place of the dim
* "border + light tint + accent text" pattern which had poor contrast on
* dark themes (accent text on dim accent bg). The full accent-dark fill
* with white descendants gives a strong, accessible selection cue and
* follows the user's CI palette.
*/
.tile-selected {
background-color: var(--color-accent-dark) !important;
border-color: var(--color-accent-dark) !important;
color: #ffffff !important;
}
.tile-selected *,
.tile-selected svg {
color: #ffffff !important;
}
/*
* Inline hover tooltip used by the colour-picker info icons.
* Pure CSS no library, no JS state. Wrap an `<Info>` icon (or any
* trigger) in <span class="info-tooltip" data-tooltip="..."> and a
* positioned bubble fades in on hover/focus. The native HTML `title`
* attribute has a ~1.5s delay and is suppressed in some browsers, which
* is why earlier iterations appeared not to work for users.
*/
.info-tooltip {
position: relative;
display: inline-flex;
cursor: help;
}
.info-tooltip::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
z-index: 50;
min-width: 200px;
max-width: 320px;
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
background-color: #171717;
color: #fafafa;
font-size: 0.75rem;
line-height: 1.35;
font-weight: 400;
text-align: left;
white-space: normal;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease-out;
}
.info-tooltip:hover::after,
.info-tooltip:focus-visible::after {
opacity: 1;
}
.dark .info-tooltip::after {
background-color: #fafafa;
color: #171717;
}
/* Image loading skeleton */ /* Image loading skeleton */
.skeleton { .skeleton {
@apply animate-pulse rounded-lg bg-neutral-200; @apply animate-pulse rounded-lg bg-neutral-200;
@@ -281,7 +440,8 @@
@apply bg-red-900/40 text-red-300; @apply bg-red-900/40 text-red-300;
} }
/* Secondary and outline buttons for admin dark mode */ /* Secondary and outline buttons for admin dark mode restore explicit
* neutral overrides so existing admin pages render exactly as before. */
.dark .btn-secondary { .dark .btn-secondary {
@apply bg-neutral-700 border-neutral-600 text-neutral-100; @apply bg-neutral-700 border-neutral-600 text-neutral-100;
} }
+3 -1
View File
@@ -157,7 +157,9 @@ export const GalleryPage: React.FC = () => {
} }
} }
// Apply theme // Apply theme. Force color mode is enforced inside ThemeContext.applyTheme
// (it subscribes to public settings) so callers don't have to wrap the
// theme themselves — keeps the lock consistent across every entry point.
if (themeToApply) { if (themeToApply) {
setTheme(themeToApply); setTheme(themeToApply);
} }
+1 -1
View File
@@ -225,7 +225,7 @@ export const AdminDashboard: React.FC = () => {
{expiringTotal > 5 && ( {expiringTotal > 5 && (
<button <button
onClick={() => navigate('/admin/events?filter=expiring')} onClick={() => navigate('/admin/events?filter=expiring')}
className="w-full mt-4 text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium" className="w-full mt-4 text-sm text-accent hover:opacity-80 font-medium"
> >
{t('admin.viewAllExpiringEvents', { count: expiringTotal })} {t('admin.viewAllExpiringEvents', { count: expiringTotal })}
</button> </button>
+1 -1
View File
@@ -203,7 +203,7 @@ export const AdminLoginPage: React.FC = () => {
<label className="flex items-center"> <label className="flex items-center">
<input <input
type="checkbox" type="checkbox"
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500" className="w-4 h-4 text-accent border-neutral-300 rounded focus:ring-primary-500"
/> />
<span className="ml-2 text-sm text-neutral-700">{t('adminLogin.rememberMe')}</span> <span className="ml-2 text-sm text-neutral-700">{t('adminLogin.rememberMe')}</span>
</label> </label>
+1 -1
View File
@@ -454,7 +454,7 @@ export const AnalyticsPage: React.FC = () => {
? 'bg-red-600' ? 'bg-red-600'
: usagePercent >= 90 : usagePercent >= 90
? 'bg-amber-500' ? 'bg-amber-500'
: 'bg-primary-600'; : 'bg-accent-dark';
const limitDescriptor = storageInfo const limitDescriptor = storageInfo
? storageInfo.soft_limit_configured ? storageInfo.soft_limit_configured
? t('admin.storageSoftLimitConfigured', { limit: limitDisplay }) ? t('admin.storageSoftLimitConfigured', { limit: limitDisplay })
+3 -3
View File
@@ -150,7 +150,7 @@ export const ArchivesPage: React.FC = () => {
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('archives.totalArchives')}</p> <p className="text-sm text-neutral-600 dark:text-neutral-400">{t('archives.totalArchives')}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{archives.length}</p> <p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{archives.length}</p>
</div> </div>
<Archive className="w-8 h-8 text-primary-600" /> <Archive className="w-8 h-8 text-accent" />
</div> </div>
</Card> </Card>
@@ -212,7 +212,7 @@ export const ArchivesPage: React.FC = () => {
<select <select
value={filterType} value={filterType}
onChange={(e) => setFilterType(e.target.value)} onChange={(e) => setFilterType(e.target.value)}
className="px-4 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-primary-500" className="px-4 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="all">{t('archives.allTypes')}</option> <option value="all">{t('archives.allTypes')}</option>
<option value="wedding">{t('archives.wedding')}</option> <option value="wedding">{t('archives.wedding')}</option>
@@ -225,7 +225,7 @@ export const ArchivesPage: React.FC = () => {
<select <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)} onChange={(e) => setSortBy(e.target.value as any)}
className="px-4 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-primary-500" className="px-4 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="date">{t('archives.sortByDate')}</option> <option value="date">{t('archives.sortByDate')}</option>
<option value="name">{t('archives.sortByName')}</option> <option value="name">{t('archives.sortByName')}</option>
@@ -197,7 +197,7 @@ export const BackupManagement = () => {
className={` className={`
py-2 px-1 border-b-2 font-medium text-sm flex items-center space-x-2 py-2 px-1 border-b-2 font-medium text-sm flex items-center space-x-2
${activeTab === tab.id ${activeTab === tab.id
? 'border-primary-600 dark:border-primary-400 text-primary-600 dark:text-primary-400' ? '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' : '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'
} }
`} `}
+25 -7
View File
@@ -31,6 +31,7 @@ export const BrandingPage: React.FC = () => {
logo_display_hero: true, logo_display_hero: true,
logo_display_mode: 'logo_and_text', logo_display_mode: 'logo_and_text',
hide_powered_by: false, hide_powered_by: false,
force_color_mode: null,
}); });
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme); const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
@@ -128,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
@@ -478,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'
}`} }`}
> >
@@ -511,7 +527,7 @@ export const BrandingPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={brandingSettings.logo_display_header !== false} checked={brandingSettings.logo_display_header !== false}
onChange={(e) => handleBrandingChange('logo_display_header', e.target.checked)} onChange={(e) => handleBrandingChange('logo_display_header', e.target.checked)}
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500" className="rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
/> />
<div> <div>
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100"> <span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
@@ -528,7 +544,7 @@ export const BrandingPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={brandingSettings.logo_display_hero !== false} checked={brandingSettings.logo_display_hero !== false}
onChange={(e) => handleBrandingChange('logo_display_hero', e.target.checked)} onChange={(e) => handleBrandingChange('logo_display_hero', e.target.checked)}
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500" className="rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
/> />
<div> <div>
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100"> <span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
@@ -551,7 +567,7 @@ export const BrandingPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={brandingSettings.hide_powered_by === true} checked={brandingSettings.hide_powered_by === true}
onChange={(e) => handleBrandingChange('hide_powered_by', e.target.checked)} onChange={(e) => handleBrandingChange('hide_powered_by', e.target.checked)}
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500" className="rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
/> />
<div> <div>
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100"> <span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
@@ -570,7 +586,7 @@ export const BrandingPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={brandingSettings.watermark_enabled} checked={brandingSettings.watermark_enabled}
onChange={(e) => handleBrandingChange('watermark_enabled', e.target.checked)} onChange={(e) => handleBrandingChange('watermark_enabled', e.target.checked)}
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500" className="rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
/> />
<div> <div>
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{t('branding.enableWatermarks')}</span> <span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{t('branding.enableWatermarks')}</span>
@@ -649,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'
}`} }`}
> >
@@ -732,7 +748,7 @@ export const BrandingPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={isPreviewMode} checked={isPreviewMode}
onChange={(e) => setIsPreviewMode(e.target.checked)} onChange={(e) => setIsPreviewMode(e.target.checked)}
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500" className="rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
/> />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('branding.applyLivePreview')}</span> <span className="text-sm text-neutral-700 dark:text-neutral-300">{t('branding.applyLivePreview')}</span>
</label> </label>
@@ -747,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>
+16 -16
View File
@@ -381,7 +381,7 @@ export const CMSPage: React.FC = () => {
<Card className="space-y-6"> <Card className="space-y-6">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<div className="flex items-center gap-2 text-primary-600 mb-1"> <div className="flex items-center gap-2 text-accent mb-1">
<Globe className="w-5 h-5" /> <Globe className="w-5 h-5" />
<span className="text-sm font-semibold uppercase tracking-wide">{t('settings.publicSite.badge')}</span> <span className="text-sm font-semibold uppercase tracking-wide">{t('settings.publicSite.badge')}</span>
</div> </div>
@@ -398,7 +398,7 @@ export const CMSPage: React.FC = () => {
<span <span
aria-hidden="true" aria-hidden="true"
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${ className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
publicSiteEnabled ? 'bg-primary-600' : 'bg-neutral-300' publicSiteEnabled ? 'bg-accent-dark' : 'bg-neutral-300'
}`} }`}
> >
<span <span
@@ -422,11 +422,11 @@ export const CMSPage: React.FC = () => {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 dark:text-neutral-200 mb-2"> <label className="flex items-center gap-2 text-sm font-medium text-neutral-800 dark:text-neutral-200 mb-2">
<Sparkles className="w-4 h-4 text-primary-500" /> <Sparkles className="w-4 h-4 text-accent" />
{t('settings.publicSite.htmlLabel')} {t('settings.publicSite.htmlLabel')}
</label> </label>
<textarea <textarea
className="w-full h-64 font-mono text-sm rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 dark:disabled:bg-neutral-700 disabled:text-neutral-500 dark:disabled:text-neutral-400" className="w-full h-64 font-mono text-sm rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-accent-dark disabled:bg-neutral-100 dark:disabled:bg-neutral-700 disabled:text-neutral-500 dark:disabled:text-neutral-400"
value={publicSiteHtml} value={publicSiteHtml}
onChange={(event) => setPublicSiteHtml(event.target.value)} onChange={(event) => setPublicSiteHtml(event.target.value)}
disabled={!publicSiteEnabled} disabled={!publicSiteEnabled}
@@ -439,11 +439,11 @@ export const CMSPage: React.FC = () => {
<div> <div>
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 dark:text-neutral-200 mb-2"> <label className="flex items-center gap-2 text-sm font-medium text-neutral-800 dark:text-neutral-200 mb-2">
<ShieldCheck className="w-4 h-4 text-primary-500" /> <ShieldCheck className="w-4 h-4 text-accent" />
{t('settings.publicSite.cssLabel')} {t('settings.publicSite.cssLabel')}
</label> </label>
<textarea <textarea
className="w-full h-48 font-mono text-sm rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 dark:disabled:bg-neutral-700 disabled:text-neutral-500 dark:disabled:text-neutral-400" className="w-full h-48 font-mono text-sm rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-accent-dark disabled:bg-neutral-100 dark:disabled:bg-neutral-700 disabled:text-neutral-500 dark:disabled:text-neutral-400"
value={publicSiteCss} value={publicSiteCss}
onChange={(event) => setPublicSiteCss(event.target.value)} onChange={(event) => setPublicSiteCss(event.target.value)}
disabled={!publicSiteEnabled} disabled={!publicSiteEnabled}
@@ -487,7 +487,7 @@ export const CMSPage: React.FC = () => {
<span className="text-xs text-neutral-500 dark:text-neutral-400">{t('settings.publicSite.previewSandboxed')}</span> <span className="text-xs text-neutral-500 dark:text-neutral-400">{t('settings.publicSite.previewSandboxed')}</span>
</div> </div>
{publicSiteEnabled ? ( {publicSiteEnabled ? (
<div className="rounded-xl border border-neutral-200 overflow-hidden shadow-sm bg-white"> <div className="rounded-xl border border-neutral-200 dark:border-neutral-700 overflow-hidden shadow-sm bg-white dark:bg-neutral-800">
<iframe <iframe
title="public-site-preview" title="public-site-preview"
sandbox="allow-same-origin" sandbox="allow-same-origin"
@@ -523,10 +523,10 @@ export const CMSPage: React.FC = () => {
} }
setSelectedPage(page.slug); setSelectedPage(page.slug);
}} }}
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${ className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 border ${
selectedPage === page.slug selectedPage === page.slug
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 border border-primary-300 dark:border-primary-700' ? 'tile-selected'
: 'bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-700 text-neutral-900 dark:text-neutral-100' : 'bg-white dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-700 text-neutral-900 dark:text-neutral-100'
}`} }`}
> >
<FileText className="w-5 h-5 flex-shrink-0" /> <FileText className="w-5 h-5 flex-shrink-0" />
@@ -554,7 +554,7 @@ export const CMSPage: React.FC = () => {
href={`${window.location.origin}/${selectedPage}?lang=en`} href={`${window.location.origin}/${selectedPage}?lang=en`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700" className="flex items-center gap-2 text-accent hover:opacity-80"
> >
<Globe className="w-4 h-4" /> <Globe className="w-4 h-4" />
{t('cms.englishVersion')} {t('cms.englishVersion')}
@@ -563,7 +563,7 @@ export const CMSPage: React.FC = () => {
href={`${window.location.origin}/${selectedPage}?lang=de`} href={`${window.location.origin}/${selectedPage}?lang=de`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700" className="flex items-center gap-2 text-accent hover:opacity-80"
> >
<Globe className="w-4 h-4" /> <Globe className="w-4 h-4" />
{t('cms.germanVersion')} {t('cms.germanVersion')}
@@ -576,7 +576,7 @@ export const CMSPage: React.FC = () => {
<Card padding="md" className="mt-4"> <Card padding="md" className="mt-4">
<div className="text-sm"> <div className="text-sm">
{isAutoSaving && ( {isAutoSaving && (
<div className="flex items-center gap-2 text-neutral-600"> <div className="flex items-center gap-2 text-neutral-600 dark:text-neutral-300">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" /> <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Auto-saving... Auto-saving...
</div> </div>
@@ -612,7 +612,7 @@ export const CMSPage: React.FC = () => {
onClick={() => setEditingLang('en')} onClick={() => setEditingLang('en')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${ className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'en' editingLang === 'en'
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300' ? 'bg-accent-dark/15 text-accent-dark'
: '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'
}`} }`}
> >
@@ -622,7 +622,7 @@ export const CMSPage: React.FC = () => {
onClick={() => setEditingLang('de')} onClick={() => setEditingLang('de')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${ className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'de' editingLang === 'de'
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300' ? 'bg-accent-dark/15 text-accent-dark'
: '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'
}`} }`}
> >
@@ -637,7 +637,7 @@ export const CMSPage: React.FC = () => {
<label className="flex items-start gap-3 cursor-pointer"> <label className="flex items-start gap-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
className="mt-1 h-4 w-4 rounded border-neutral-300 text-primary-600 focus:ring-primary-500" className="mt-1 h-4 w-4 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
checked={!!editForm.use_external_url} checked={!!editForm.use_external_url}
onChange={(e) => handleUseExternalUrlChange(e.target.checked)} onChange={(e) => handleUseExternalUrlChange(e.target.checked)}
/> />
+41 -8
View File
@@ -242,6 +242,9 @@ export const CreateEventPage: React.FC = () => {
// Apply the global Branding default theme on first load so admins who set a // Apply the global Branding default theme on first load so admins who set a
// site-wide default in Branding actually see it on new events (#323). // site-wide default in Branding actually see it on new events (#323).
// This is the "always inherit colours from Branding" guarantee — every new
// gallery starts with the site palette unless the admin then picks a preset
// or hits Sync from Branding inside the customizer to re-pull it later.
const brandingThemeApplied = useRef(false); const brandingThemeApplied = useRef(false);
useEffect(() => { useEffect(() => {
if (brandingThemeApplied.current) return; if (brandingThemeApplied.current) return;
@@ -497,7 +500,7 @@ export const CreateEventPage: React.FC = () => {
onClick={() => setFormData({ ...formData, event_type: type.value })} onClick={() => setFormData({ ...formData, event_type: type.value })}
className={`p-4 rounded-lg border-2 transition-all ${ className={`p-4 rounded-lg border-2 transition-all ${
formData.event_type === type.value formData.event_type === type.value
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30' ? 'tile-selected'
: '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'
}`} }`}
> >
@@ -597,6 +600,36 @@ export const CreateEventPage: React.FC = () => {
onPresetChange={handlePresetChange} onPresetChange={handlePresetChange}
showGalleryLayouts={true} showGalleryLayouts={true}
hideActions={true} hideActions={true}
onSyncFromBranding={() => {
// Pull the 8 colour tokens (+ legacy primary alias) from
// the global Branding theme into the current event theme.
// Layout / header / typography are kept untouched so an
// admin who has already arranged structure can refresh
// just the palette.
const branding = settings?.theme_config as ThemeConfig | undefined;
if (!branding) {
toast.error(t('toast.brandingThemeMissing', 'No branding theme has been saved yet.'));
return;
}
setFormData(prev => ({
...prev,
theme_preset: 'custom',
theme_config: {
...prev.theme_config,
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 ?? prev.theme_config.colorMode,
},
}));
toast.success(t('toast.brandingPaletteSynced', 'Palette synced from Branding.'));
}}
/> />
{/* Gallery Preview */} {/* Gallery Preview */}
@@ -627,7 +660,7 @@ export const CreateEventPage: React.FC = () => {
onClick={() => setFormData({ ...formData, css_template_id: null })} onClick={() => setFormData({ ...formData, css_template_id: null })}
className={`p-4 rounded-lg border-2 transition-all text-left ${ className={`p-4 rounded-lg border-2 transition-all text-left ${
formData.css_template_id === null formData.css_template_id === null
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30' ? 'tile-selected'
: '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'
}`} }`}
> >
@@ -645,7 +678,7 @@ export const CreateEventPage: React.FC = () => {
onClick={() => setFormData({ ...formData, css_template_id: template.id })} onClick={() => setFormData({ ...formData, css_template_id: template.id })}
className={`p-4 rounded-lg border-2 transition-all text-left ${ className={`p-4 rounded-lg border-2 transition-all text-left ${
formData.css_template_id === template.id formData.css_template_id === template.id
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30' ? 'tile-selected'
: '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'
}`} }`}
> >
@@ -724,7 +757,7 @@ export const CreateEventPage: React.FC = () => {
setFormData(prev => ({ ...prev, admin_email: email })); setFormData(prev => ({ ...prev, admin_email: email }));
} }
}} }}
className="text-xs px-2 py-1 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded focus:ring-2 focus:ring-primary-500 focus:border-primary-500" className="text-xs px-2 py-1 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
> >
<option value="">{t('events.adminEmailCustom', 'Custom email')}</option> <option value="">{t('events.adminEmailCustom', 'Custom email')}</option>
{activeAdmins.map(a => ( {activeAdmins.map(a => (
@@ -741,7 +774,7 @@ export const CreateEventPage: React.FC = () => {
<label className="flex items-start gap-2"> <label className="flex items-start gap-2">
<input <input
type="checkbox" type="checkbox"
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500" className="mt-1 w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
checked={formData.require_password} checked={formData.require_password}
onChange={(e) => { onChange={(e) => {
const checked = e.target.checked; const checked = e.target.checked;
@@ -892,7 +925,7 @@ export const CreateEventPage: React.FC = () => {
<select <select
value={formData.default_photo_sort} value={formData.default_photo_sort}
onChange={(e) => setFormData({ ...formData, default_photo_sort: e.target.value })} onChange={(e) => setFormData({ ...formData, 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-primary-500" 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_desc">{t('photoSort.uploadDateNewest', 'Upload Date (Newest First)')}</option>
<option value="upload_date_asc">{t('photoSort.uploadDateOldest', 'Upload Date (Oldest First)')}</option> <option value="upload_date_asc">{t('photoSort.uploadDateOldest', 'Upload Date (Oldest First)')}</option>
@@ -908,7 +941,7 @@ export const CreateEventPage: React.FC = () => {
<label className="flex items-start gap-2"> <label className="flex items-start gap-2">
<input <input
type="checkbox" type="checkbox"
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500" className="mt-1 w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
checked={formData.client_access_enabled} checked={formData.client_access_enabled}
onChange={(e) => setFormData(prev => ({ onChange={(e) => setFormData(prev => ({
...prev, ...prev,
@@ -948,7 +981,7 @@ export const CreateEventPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={formData.allow_user_uploads} checked={formData.allow_user_uploads}
onChange={(e) => setFormData({ ...formData, allow_user_uploads: e.target.checked })} onChange={(e) => setFormData({ ...formData, allow_user_uploads: e.target.checked })}
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500" className="rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
/> />
<div> <div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300"> <span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
+124 -53
View File
@@ -18,7 +18,7 @@ import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common'; import { Button, Input, Card, Loading } from '../../components/common';
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal'; import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor'; import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
import { Palette } from 'lucide-react'; import { Palette, RefreshCw, Info } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service'; import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
import { settingsService } from '../../services/settings.service'; import { settingsService } from '../../services/settings.service';
@@ -110,8 +110,19 @@ export const EmailConfigPage: React.FC = () => {
htmlContent: '', htmlContent: '',
textContent: '' textContent: ''
}); });
// 8-token email palette. The first two are the historical settings —
// upgraded installs keep their saved values. The last six are new and
// default to the literals previously hard-coded into emailProcessor.js,
// which means an admin who never opens this card sees emails render
// exactly as before. Touching any picker enables full email theming.
const [emailPrimaryColor, setEmailPrimaryColor] = useState('#5C8762'); const [emailPrimaryColor, setEmailPrimaryColor] = useState('#5C8762');
const [emailSecondaryColor, setEmailSecondaryColor] = useState('#f9f9f9'); const [emailSecondaryColor, setEmailSecondaryColor] = useState('#f9f9f9');
const [emailBodyBgColor, setEmailBodyBgColor] = useState('#f5f5f5');
const [emailContainerBgColor, setEmailContainerBgColor] = useState('#ffffff');
const [emailListBgColor, setEmailListBgColor] = useState('#f9f9f9');
const [emailBodyTextColor, setEmailBodyTextColor] = useState('#333333');
const [emailMutedTextColor, setEmailMutedTextColor] = useState('#666666');
const [emailButtonTextColor, setEmailButtonTextColor] = useState('#ffffff');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// SMTP Configuration state // SMTP Configuration state
@@ -142,6 +153,12 @@ export const EmailConfigPage: React.FC = () => {
if (allSettings) { if (allSettings) {
if (allSettings.email_primary_color) setEmailPrimaryColor(allSettings.email_primary_color); if (allSettings.email_primary_color) setEmailPrimaryColor(allSettings.email_primary_color);
if (allSettings.email_secondary_color) setEmailSecondaryColor(allSettings.email_secondary_color); if (allSettings.email_secondary_color) setEmailSecondaryColor(allSettings.email_secondary_color);
if (allSettings.email_body_bg_color) setEmailBodyBgColor(allSettings.email_body_bg_color);
if (allSettings.email_container_bg_color) setEmailContainerBgColor(allSettings.email_container_bg_color);
if (allSettings.email_list_bg_color) setEmailListBgColor(allSettings.email_list_bg_color);
if (allSettings.email_body_text_color) setEmailBodyTextColor(allSettings.email_body_text_color);
if (allSettings.email_muted_text_color) setEmailMutedTextColor(allSettings.email_muted_text_color);
if (allSettings.email_button_text_color) setEmailButtonTextColor(allSettings.email_button_text_color);
} }
}, [allSettings]); }, [allSettings]);
@@ -213,7 +230,7 @@ export const EmailConfigPage: React.FC = () => {
}); });
const saveEmailColorsMutation = useMutation({ const saveEmailColorsMutation = useMutation({
mutationFn: (colors: { email_primary_color: string; email_secondary_color: string }) => mutationFn: (colors: Record<string, string>) =>
settingsService.updateSettings(colors), settingsService.updateSettings(colors),
onSuccess: () => { onSuccess: () => {
toast.success(t('toast.saveSuccess')); toast.success(t('toast.saveSuccess'));
@@ -228,9 +245,53 @@ export const EmailConfigPage: React.FC = () => {
saveEmailColorsMutation.mutate({ saveEmailColorsMutation.mutate({
email_primary_color: emailPrimaryColor, email_primary_color: emailPrimaryColor,
email_secondary_color: emailSecondaryColor, email_secondary_color: emailSecondaryColor,
email_body_bg_color: emailBodyBgColor,
email_container_bg_color: emailContainerBgColor,
email_list_bg_color: emailListBgColor,
email_body_text_color: emailBodyTextColor,
email_muted_text_color: emailMutedTextColor,
email_button_text_color: emailButtonTextColor,
}); });
}; };
/**
* Sync email colours from the active Branding theme so admins can hit one
* button and have email + site share an identical palette.
*
* Mapping (Branding token email token):
* accentDarkColor email_primary_color (header bg, H2, button bg, link)
* surfaceColor email_secondary_color (footer bg)
* backgroundColor email_body_bg_color (outer wrapper)
* surfaceColor email_container_bg_color (email card)
* elevatedColor email_list_bg_color (info <ul> panel)
* textColor email_body_text_color
* mutedTextColor email_muted_text_color
* (constant) email_button_text_color (#ffffff no Branding equivalent)
*
* Just updates local state admin still has to click Save to persist.
* That two-step keeps the flow predictable and avoids surprise saves.
*/
const handleSyncFromBranding = () => {
const theme = allSettings?.theme_config || {};
const accentDark = theme.accentDarkColor || theme.primaryColor || '#5C8762';
const surface = theme.surfaceColor || '#ffffff';
const background = theme.backgroundColor || '#fafafa';
const elevated = theme.elevatedColor || '#f5f5f5';
const textColor = theme.textColor || '#171717';
const mutedText = theme.mutedTextColor || '#737373';
setEmailPrimaryColor(accentDark);
setEmailSecondaryColor(surface);
setEmailBodyBgColor(background);
setEmailContainerBgColor(surface);
setEmailListBgColor(elevated);
setEmailBodyTextColor(textColor);
setEmailMutedTextColor(mutedText);
// Button text stays #ffffff — needs to read on accent-dark fill regardless
// of branding accent choice. Admins can still override it manually.
toast.info(t('email.syncedFromBranding', 'Email colours synced from Branding. Click Save to apply.'));
};
const handleSaveSmtp = () => { const handleSaveSmtp = () => {
// Validate SMTP config // Validate SMTP config
if (!smtpConfig.smtp_host || !smtpConfig.smtp_port || !smtpConfig.from_email) { if (!smtpConfig.smtp_host || !smtpConfig.smtp_port || !smtpConfig.from_email) {
@@ -353,7 +414,7 @@ export const EmailConfigPage: React.FC = () => {
onClick={() => setActiveTab('smtp')} onClick={() => setActiveTab('smtp')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${ className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'smtp' activeTab === 'smtp'
? 'border-primary-600 text-primary-600' ? 'border-accent text-accent'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300' : 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
}`} }`}
> >
@@ -363,7 +424,7 @@ export const EmailConfigPage: React.FC = () => {
onClick={() => setActiveTab('templates')} onClick={() => setActiveTab('templates')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${ className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'templates' activeTab === 'templates'
? 'border-primary-600 text-primary-600' ? 'border-accent text-accent'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300' : 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
}`} }`}
> >
@@ -412,7 +473,7 @@ export const EmailConfigPage: React.FC = () => {
<select <select
value={smtpConfig.smtp_secure ? 'ssl' : 'tls'} value={smtpConfig.smtp_secure ? 'ssl' : 'tls'}
onChange={(e) => setSmtpConfig(prev => ({ ...prev, smtp_secure: e.target.value === 'ssl' }))} onChange={(e) => setSmtpConfig(prev => ({ ...prev, smtp_secure: e.target.value === 'ssl' }))}
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-primary-500" 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="tls">TLS</option> <option value="tls">TLS</option>
<option value="ssl">SSL</option> <option value="ssl">SSL</option>
@@ -427,7 +488,7 @@ export const EmailConfigPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={!smtpConfig.tls_reject_unauthorized} checked={!smtpConfig.tls_reject_unauthorized}
onChange={(e) => setSmtpConfig(prev => ({ ...prev, tls_reject_unauthorized: !e.target.checked }))} onChange={(e) => setSmtpConfig(prev => ({ ...prev, tls_reject_unauthorized: !e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500" className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/> />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300"> <span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('email.ignoreSslErrors')} {t('email.ignoreSslErrors')}
@@ -579,56 +640,66 @@ export const EmailConfigPage: React.FC = () => {
{activeTab === 'smtp' && ( {activeTab === 'smtp' && (
<div className="mt-6"> <div className="mt-6">
<Card padding="md"> <Card padding="md">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center justify-between gap-4 mb-4">
<Palette className="w-5 h-5 text-neutral-500" /> <div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('email.brandingTitle')}</h2> <Palette className="w-5 h-5 text-neutral-500" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('email.brandingTitle')}</h2>
</div>
{/* One-click copy from Branding theme so email + site share an
identical palette. Just stages the values admin still has
to click Save to persist (avoids surprise mass-saves). */}
<Button
variant="outline"
size="sm"
onClick={handleSyncFromBranding}
leftIcon={<RefreshCw className="w-4 h-4" />}
>
{t('email.syncFromBranding', 'Sync from Branding')}
</Button>
</div> </div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">{t('email.brandingDescription')}</p> <p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">{t('email.brandingDescription')}</p>
{/* 8 email colour pickers. Each row uses the same compact label
+ info-tooltip pattern as the gallery palette in
ThemeCustomizerEnhanced keeps the two configurators visually
consistent without sharing the React component (the email
state is local to this page and saved through a different
endpoint, so reuse would be more friction than value). */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div> {[
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2"> { label: t('email.primaryColor', 'Primary'), help: t('email.primaryColorHelp', 'Header bar, H2 headings, button background, link colour. Maps to Branding → Accent (filled).'), value: emailPrimaryColor, setter: setEmailPrimaryColor, fallback: '#5C8762' },
{t('email.primaryColor')} { label: t('email.secondaryColor', 'Footer background'), help: t('email.secondaryColorHelp', 'Footer bar background. Maps to Branding → Surface.'), value: emailSecondaryColor, setter: setEmailSecondaryColor, fallback: '#f9f9f9' },
</label> { label: t('email.bodyBgColor', 'Page background'), help: t('email.bodyBgColorHelp', 'The wrapper around the email card — what the recipient sees behind the email itself. Maps to Branding → Background.'), value: emailBodyBgColor, setter: setEmailBodyBgColor, fallback: '#f5f5f5' },
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{t('email.primaryColorHint')}</p> { label: t('email.containerBgColor', 'Email card'), help: t('email.containerBgColorHelp', 'The white card that holds the email content. Maps to Branding → Surface.'), value: emailContainerBgColor, setter: setEmailContainerBgColor, fallback: '#ffffff' },
<div className="flex items-center gap-3"> { label: t('email.listBgColor', 'Info panel'), help: t('email.listBgColorHelp', 'Background of the bulleted info panels inside the email body. Maps to Branding → Elevated.'), value: emailListBgColor, setter: setEmailListBgColor, fallback: '#f9f9f9' },
<input { label: t('email.bodyTextColor', 'Body text'), help: t('email.bodyTextColorHelp', 'Paragraph and bold text colour. Maps to Branding → Primary text.'), value: emailBodyTextColor, setter: setEmailBodyTextColor, fallback: '#333333' },
type="color" { label: t('email.mutedTextColor', 'Footer text'), help: t('email.mutedTextColorHelp', 'Footer text and copyright line. Maps to Branding → Secondary text.'), value: emailMutedTextColor, setter: setEmailMutedTextColor, fallback: '#666666' },
value={emailPrimaryColor} { label: t('email.buttonTextColor', 'Button text'), help: t('email.buttonTextColorHelp', 'Text colour on filled buttons. Should contrast cleanly against the Primary colour. No Branding equivalent — usually white.'), value: emailButtonTextColor, setter: setEmailButtonTextColor, fallback: '#ffffff' },
onChange={(e) => setEmailPrimaryColor(e.target.value)} ].map(({ label, help, value, setter, fallback }) => (
className="w-10 h-10 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer" <div key={label}>
/> <label className="flex items-center gap-1.5 text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
<Input {label}
type="text" <span className="info-tooltip text-neutral-400 dark:text-neutral-500" data-tooltip={help} tabIndex={0}>
value={emailPrimaryColor} <Info className="w-3.5 h-3.5" />
onChange={(e) => setEmailPrimaryColor(e.target.value)} </span>
className="w-32" </label>
placeholder="#5C8762" <div className="flex items-center gap-3">
/> <input
type="color"
value={value}
onChange={(e) => setter(e.target.value)}
className="w-10 h-10 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
type="text"
value={value}
onChange={(e) => setter(e.target.value)}
className="w-32"
placeholder={fallback}
/>
</div>
</div> </div>
</div> ))}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('email.secondaryColor')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{t('email.secondaryColorHint')}</p>
<div className="flex items-center gap-3">
<input
type="color"
value={emailSecondaryColor}
onChange={(e) => setEmailSecondaryColor(e.target.value)}
className="w-10 h-10 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
/>
<Input
type="text"
value={emailSecondaryColor}
onChange={(e) => setEmailSecondaryColor(e.target.value)}
className="w-32"
placeholder="#f9f9f9"
/>
</div>
</div>
</div> </div>
<div className="mt-6"> <div className="mt-6">
@@ -664,7 +735,7 @@ export const EmailConfigPage: React.FC = () => {
}} }}
className={`w-full text-left p-3 rounded-lg transition-colors ${ className={`w-full text-left p-3 rounded-lg transition-colors ${
selectedTemplateKey === template.template_key selectedTemplateKey === template.template_key
? 'bg-primary-50 dark:bg-primary-900/30 border-2 border-primary-600' ? 'tile-selected'
: 'bg-neutral-50 dark:bg-neutral-700 border-2 border-transparent hover:bg-neutral-100 dark:hover:bg-neutral-600' : 'bg-neutral-50 dark:bg-neutral-700 border-2 border-transparent hover:bg-neutral-100 dark:hover:bg-neutral-600'
}`} }`}
> >
@@ -720,7 +791,7 @@ export const EmailConfigPage: React.FC = () => {
onClick={() => setEditingLang(lang.code)} onClick={() => setEditingLang(lang.code)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-1.5 ${ className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-1.5 ${
editingLang === lang.code editingLang === lang.code
? 'bg-white dark:bg-neutral-800 text-primary-700 dark:text-primary-300 shadow-sm' ? 'bg-white dark:bg-neutral-800 text-accent-dark shadow-sm'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200' : 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200'
}`} }`}
> >
+57 -29
View File
@@ -99,7 +99,7 @@ const FolderTreeNode: React.FC<{
const rowClass = const rowClass =
'flex items-center gap-1 py-1 pr-1 rounded ' + 'flex items-center gap-1 py-1 pr-1 rounded ' +
(isSelected (isSelected
? 'bg-primary-50 dark:bg-primary-900/30' ? 'bg-accent-dark/15'
: 'hover:bg-neutral-50 dark:hover:bg-neutral-700'); : 'hover:bg-neutral-50 dark:hover:bg-neutral-700');
return ( return (
@@ -119,12 +119,12 @@ const FolderTreeNode: React.FC<{
className={ className={
'flex items-center gap-1.5 flex-1 min-w-0 text-left text-sm ' + 'flex items-center gap-1.5 flex-1 min-w-0 text-left text-sm ' +
(isSelected (isSelected
? 'text-primary-700 dark:text-primary-300 font-medium' ? 'text-accent-dark font-medium'
: 'text-neutral-900 dark:text-neutral-100') : 'text-neutral-900 dark:text-neutral-100')
} }
> >
{isExpanded ? ( {isExpanded ? (
<FolderOpen className="w-4 h-4 flex-shrink-0 text-primary-500" /> <FolderOpen className="w-4 h-4 flex-shrink-0 text-accent" />
) : ( ) : (
<Folder className="w-4 h-4 flex-shrink-0 text-neutral-500" /> <Folder className="w-4 h-4 flex-shrink-0 text-neutral-500" />
)} )}
@@ -924,7 +924,7 @@ export const EventDetailsPage: React.FC = () => {
} }
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 border border-primary-600 rounded-lg hover:bg-primary-50 transition-colors" 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" /> <ExternalLink className="w-4 h-4" />
{t('events.viewGallery')} {t('events.viewGallery')}
@@ -1006,7 +1006,7 @@ export const EventDetailsPage: React.FC = () => {
onClick={() => setActiveTab('overview')} onClick={() => setActiveTab('overview')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${ className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'overview' activeTab === 'overview'
? 'border-primary-500 text-primary-600 dark:text-primary-400' ? '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' : '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'
}`} }`}
> >
@@ -1016,7 +1016,7 @@ export const EventDetailsPage: React.FC = () => {
onClick={() => setActiveTab('photos')} onClick={() => setActiveTab('photos')}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${ className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === 'photos' activeTab === 'photos'
? 'border-primary-500 text-primary-600 dark:text-primary-400' ? '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' : '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'
}`} }`}
> >
@@ -1032,7 +1032,7 @@ export const EventDetailsPage: React.FC = () => {
onClick={() => setActiveTab('categories')} onClick={() => setActiveTab('categories')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${ className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'categories' activeTab === 'categories'
? 'border-primary-500 text-primary-600 dark:text-primary-400' ? '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' : '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'
}`} }`}
> >
@@ -1043,7 +1043,7 @@ export const EventDetailsPage: React.FC = () => {
onClick={() => setActiveTab('guests')} onClick={() => setActiveTab('guests')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${ className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'guests' activeTab === 'guests'
? 'border-primary-500 text-primary-600 dark:text-primary-400' ? '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' : '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'
}`} }`}
> >
@@ -1071,7 +1071,7 @@ export const EventDetailsPage: React.FC = () => {
<textarea <textarea
value={editForm.welcome_message} value={editForm.welcome_message}
onChange={(e) => setEditForm(prev => ({ ...prev, welcome_message: e.target.value }))} 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-primary-500" 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} rows={3}
placeholder={t('events.welcomeMessage')} placeholder={t('events.welcomeMessage')}
/> />
@@ -1162,7 +1162,7 @@ export const EventDetailsPage: React.FC = () => {
<label className="flex items-start gap-2"> <label className="flex items-start gap-2">
<input <input
type="checkbox" type="checkbox"
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500" 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} checked={editForm.require_password}
onChange={(e) => { onChange={(e) => {
const checked = e.target.checked; const checked = e.target.checked;
@@ -1252,7 +1252,7 @@ export const EventDetailsPage: React.FC = () => {
: '' : ''
})); }));
}} }}
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-primary-500" 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="managed">{t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}</option>
<option value="reference">{t('events.sourceModeReference', 'Reference external folder')}</option> <option value="reference">{t('events.sourceModeReference', 'Reference external folder')}</option>
@@ -1288,7 +1288,7 @@ export const EventDetailsPage: React.FC = () => {
value={editForm.photo_cap} value={editForm.photo_cap}
onChange={(e) => setEditForm(prev => ({ ...prev, photo_cap: parseInt(e.target.value) || 0 }))} onChange={(e) => setEditForm(prev => ({ ...prev, photo_cap: parseInt(e.target.value) || 0 }))}
min={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-primary-500" 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"> <span className="text-xs text-neutral-500 dark:text-neutral-400">
{t('events.photoCapHelp', 'Maximum number of photos allowed. 0 = unlimited')} {t('events.photoCapHelp', 'Maximum number of photos allowed. 0 = unlimited')}
@@ -1304,7 +1304,7 @@ export const EventDetailsPage: React.FC = () => {
<select <select
value={editForm.default_photo_sort} value={editForm.default_photo_sort}
onChange={(e) => setEditForm(prev => ({ ...prev, default_photo_sort: e.target.value }))} 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-primary-500" 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_desc">{t('photoSort.uploadDateNewest', 'Upload Date (Newest First)')}</option>
<option value="upload_date_asc">{t('photoSort.uploadDateOldest', 'Upload Date (Oldest First)')}</option> <option value="upload_date_asc">{t('photoSort.uploadDateOldest', 'Upload Date (Oldest First)')}</option>
@@ -1321,7 +1321,7 @@ export const EventDetailsPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={editForm.allow_user_uploads} checked={editForm.allow_user_uploads}
onChange={(e) => setEditForm(prev => ({ ...prev, allow_user_uploads: e.target.checked }))} onChange={(e) => setEditForm(prev => ({ ...prev, allow_user_uploads: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500" 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> <span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('events.allowUserUploads')}</span>
</label> </label>
@@ -1341,7 +1341,7 @@ export const EventDetailsPage: React.FC = () => {
...prev, ...prev,
upload_category_id: e.target.value ? parseInt(e.target.value) : null 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-primary-500" 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> <option value="">{t('events.selectCategory')}</option>
{categories?.map(category => ( {categories?.map(category => (
@@ -1368,7 +1368,7 @@ export const EventDetailsPage: React.FC = () => {
{/* Download Protection Settings */} {/* Download Protection Settings */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700"> <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"> <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-primary-600" /> <Shield className="w-4 h-4 text-accent" />
{t('events.downloadProtection', 'Download Protection')} {t('events.downloadProtection', 'Download Protection')}
</h3> </h3>
@@ -1378,7 +1378,7 @@ export const EventDetailsPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={editForm.allow_downloads} checked={editForm.allow_downloads}
onChange={(e) => setEditForm(prev => ({ ...prev, allow_downloads: e.target.checked }))} onChange={(e) => setEditForm(prev => ({ ...prev, allow_downloads: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500" 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" /> <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> <span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.allowDownloads', 'Allow photo downloads')}</span>
@@ -1389,7 +1389,7 @@ export const EventDetailsPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={editForm.disable_right_click} checked={editForm.disable_right_click}
onChange={(e) => setEditForm(prev => ({ ...prev, disable_right_click: e.target.checked }))} onChange={(e) => setEditForm(prev => ({ ...prev, disable_right_click: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500" 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" /> <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> <span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.disableRightClick', 'Block right-click menu')}</span>
@@ -1407,7 +1407,7 @@ export const EventDetailsPage: React.FC = () => {
// S3 without going through the watermark pipeline. // S3 without going through the watermark pipeline.
allow_presigned_download: e.target.checked ? false : prev.allow_presigned_download, allow_presigned_download: e.target.checked ? false : prev.allow_presigned_download,
}))} }))}
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500" 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" /> <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> <span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.watermarkDownloads', 'Add watermark to downloads')}</span>
@@ -1425,7 +1425,7 @@ export const EventDetailsPage: React.FC = () => {
checked={!!editForm.allow_presigned_download} checked={!!editForm.allow_presigned_download}
disabled={editForm.watermark_downloads} disabled={editForm.watermark_downloads}
onChange={(e) => setEditForm(prev => ({ ...prev, allow_presigned_download: e.target.checked }))} onChange={(e) => setEditForm(prev => ({ ...prev, allow_presigned_download: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500" 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" /> <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"> <span className="text-sm text-neutral-700 dark:text-neutral-300">
@@ -1438,7 +1438,7 @@ export const EventDetailsPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={editForm.enable_devtools_protection} checked={editForm.enable_devtools_protection}
onChange={(e) => setEditForm(prev => ({ ...prev, enable_devtools_protection: e.target.checked }))} onChange={(e) => setEditForm(prev => ({ ...prev, enable_devtools_protection: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500" 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" /> <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> <span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.enableDevtoolsProtection', 'Detect developer tools')}</span>
@@ -1449,7 +1449,7 @@ export const EventDetailsPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={editForm.use_canvas_rendering} checked={editForm.use_canvas_rendering}
onChange={(e) => setEditForm(prev => ({ ...prev, use_canvas_rendering: e.target.checked }))} onChange={(e) => setEditForm(prev => ({ ...prev, use_canvas_rendering: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500" 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" /> <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> <span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.useCanvasRendering', 'Canvas rendering (advanced protection)')}</span>
@@ -1464,7 +1464,7 @@ export const EventDetailsPage: React.FC = () => {
{/* Hero Logo Settings */} {/* Hero Logo Settings */}
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700"> <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"> <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-primary-600" /> <Layout className="w-4 h-4 text-accent" />
{t('events.heroLogoSettings', 'Hero Logo Settings')} {t('events.heroLogoSettings', 'Hero Logo Settings')}
</h3> </h3>
@@ -1474,7 +1474,7 @@ export const EventDetailsPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={editForm.hero_logo_visible} checked={editForm.hero_logo_visible}
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_visible: e.target.checked }))} onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_visible: e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500" 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" /> <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> <span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.heroLogoVisible', 'Display logo in hero section')}</span>
@@ -1489,7 +1489,7 @@ export const EventDetailsPage: React.FC = () => {
<select <select
value={editForm.hero_logo_size} value={editForm.hero_logo_size}
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_size: e.target.value as 'small' | 'medium' | 'large' | 'xlarge' }))} 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-primary-500 text-sm" 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="small">{t('events.heroLogoSizeSmall', 'Small')}</option>
<option value="medium">{t('events.heroLogoSizeMedium', 'Medium')}</option> <option value="medium">{t('events.heroLogoSizeMedium', 'Medium')}</option>
@@ -1505,7 +1505,7 @@ export const EventDetailsPage: React.FC = () => {
<select <select
value={editForm.hero_logo_position} value={editForm.hero_logo_position}
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_position: e.target.value as 'top' | 'center' | 'bottom' }))} 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-primary-500 text-sm" 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="top">{t('events.heroLogoPositionTop', 'Top (above title)')}</option>
<option value="center">{t('events.heroLogoPositionCenter', 'Center (between title and dates)')}</option> <option value="center">{t('events.heroLogoPositionCenter', 'Center (between title and dates)')}</option>
@@ -1532,7 +1532,7 @@ export const EventDetailsPage: React.FC = () => {
/> />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="cursor-pointer inline-flex items-center gap-1 text-xs text-primary-600 hover:text-primary-700"> <label className="cursor-pointer inline-flex items-center gap-1 text-xs text-accent hover:opacity-80">
<Upload className="w-3 h-3" /> <Upload className="w-3 h-3" />
{t('events.replaceLogo', 'Replace')} {t('events.replaceLogo', 'Replace')}
<input <input
@@ -1670,7 +1670,7 @@ export const EventDetailsPage: React.FC = () => {
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.heroPhoto')}</dt> <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"> <dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
{event.hero_photo_id ? ( {event.hero_photo_id ? (
<span className="text-primary-600 dark:text-primary-400">{t('events.heroPhotoSelected')}</span> <span className="text-accent">{t('events.heroPhotoSelected')}</span>
) : ( ) : (
<span className="text-neutral-400">{t('events.noHeroPhotoSelected')}</span> <span className="text-neutral-400">{t('events.noHeroPhotoSelected')}</span>
)} )}
@@ -1846,7 +1846,7 @@ export const EventDetailsPage: React.FC = () => {
<label className="flex items-start gap-2"> <label className="flex items-start gap-2">
<input <input
type="checkbox" type="checkbox"
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500" 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} checked={!!event?.client_access_enabled}
onChange={async (e) => { onChange={async (e) => {
try { try {
@@ -2095,6 +2095,34 @@ export const EventDetailsPage: React.FC = () => {
} }
} }
}} }}
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) }));
toast.success(t('toast.brandingPaletteSynced', 'Palette synced from Branding.'));
}}
isPreviewMode={true} isPreviewMode={true}
showGalleryLayouts={true} showGalleryLayouts={true}
hideActions={true} hideActions={true}
@@ -192,7 +192,7 @@ export const EventFeedbackPage: React.FC = () => {
onClick={() => setActiveTab(tab.id as any)} onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center gap-2 px-1 py-2 border-b-2 font-medium text-sm transition-colors ${ className={`flex items-center gap-2 px-1 py-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === tab.id activeTab === tab.id
? 'border-primary-600 text-primary-600' ? 'border-accent text-accent'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300' : 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
}`} }`}
> >
+5 -5
View File
@@ -153,7 +153,7 @@ export const EventTypesPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={showInactive} checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)} onChange={(e) => setShowInactive(e.target.checked)}
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500" className="rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
/> />
<span className="text-sm text-neutral-700 dark:text-neutral-300"> <span className="text-sm text-neutral-700 dark:text-neutral-300">
{t('eventTypes.showInactive', 'Show inactive')} {t('eventTypes.showInactive', 'Show inactive')}
@@ -241,7 +241,7 @@ export const EventTypesPage: React.FC = () => {
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button <button
onClick={() => setEditingType(type)} onClick={() => setEditingType(type)}
className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg text-neutral-600 dark:text-neutral-400 hover:text-primary-600" className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg text-neutral-600 dark:text-neutral-400 hover:text-accent"
title={t('common.edit', 'Edit')} title={t('common.edit', 'Edit')}
> >
<Edit className="w-4 h-4" /> <Edit className="w-4 h-4" />
@@ -432,7 +432,7 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
onClick={() => setForm({ ...form, emoji })} onClick={() => setForm({ ...form, emoji })}
className={`p-2 text-xl rounded-lg border-2 transition-all ${ className={`p-2 text-xl rounded-lg border-2 transition-all ${
form.emoji === emoji form.emoji === emoji
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30' ? 'tile-selected'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500' : 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`} }`}
> >
@@ -450,7 +450,7 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
<select <select
value={form.theme_preset} value={form.theme_preset}
onChange={(e) => setForm({ ...form, theme_preset: e.target.value })} onChange={(e) => setForm({ ...form, theme_preset: 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-primary-500" 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"
> >
{Object.entries(GALLERY_THEME_PRESETS).map(([key, preset]) => ( {Object.entries(GALLERY_THEME_PRESETS).map(([key, preset]) => (
<option key={key} value={key}> <option key={key} value={key}>
@@ -467,7 +467,7 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
type="checkbox" type="checkbox"
checked={eventType?.is_active} checked={eventType?.is_active}
onChange={(e) => onSubmit({ is_active: e.target.checked })} onChange={(e) => onSubmit({ is_active: e.target.checked })}
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500" className="rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
/> />
<span className="text-sm text-neutral-700 dark:text-neutral-300"> <span className="text-sm text-neutral-700 dark:text-neutral-300">
{t('eventTypes.form.isActive', 'Active (visible in event creation)')} {t('eventTypes.form.isActive', 'Active (visible in event creation)')}
+5 -5
View File
@@ -321,7 +321,7 @@ export const EventsListPage: React.FC = () => {
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.totalEvents')}</p> <p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.totalEvents')}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{dashboardStats?.totalEvents ?? 0}</p> <p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{dashboardStats?.totalEvents ?? 0}</p>
</div> </div>
<Calendar className="w-8 h-8 text-primary-600" /> <Calendar className="w-8 h-8 text-accent" />
</div> </div>
</Card> </Card>
@@ -423,8 +423,8 @@ export const EventsListPage: React.FC = () => {
{/* Bulk Actions */} {/* Bulk Actions */}
{selectedEvents.length > 0 && ( {selectedEvents.length > 0 && (
<div className="mt-4 p-3 bg-primary-50 dark:bg-primary-900/30 rounded-lg flex items-center justify-between"> <div className="mt-4 p-3 bg-accent-dark/15 rounded-lg flex items-center justify-between">
<span className="text-sm text-primary-900 dark:text-primary-100"> <span className="text-sm text-accent-dark">
{t('events.eventsSelected', { count: selectedEvents.length })} {t('events.eventsSelected', { count: selectedEvents.length })}
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -465,7 +465,7 @@ export const EventsListPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={selectedEvents.length === events.length && events.length > 0} checked={selectedEvents.length === events.length && events.length > 0}
onChange={handleSelectAll} onChange={handleSelectAll}
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500 dark:bg-neutral-700" className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500 dark:bg-neutral-700"
/> />
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
@@ -513,7 +513,7 @@ export const EventsListPage: React.FC = () => {
type="checkbox" type="checkbox"
checked={selectedEvents.includes(event.id)} checked={selectedEvents.includes(event.id)}
onChange={() => handleSelectEvent(event.id)} onChange={() => handleSelectEvent(event.id)}
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500 dark:bg-neutral-700" className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500 dark:bg-neutral-700"
/> />
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
+3 -3
View File
@@ -202,14 +202,14 @@ export const SettingsPage: React.FC = () => {
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
className={`group w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm font-medium transition-colors ${ className={`group w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive isActive
? 'bg-primary-50 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300' ? 'bg-accent-dark text-white'
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800' : 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`} }`}
> >
<Icon <Icon
className={`w-4 h-4 flex-shrink-0 ${ className={`w-4 h-4 flex-shrink-0 ${
isActive isActive
? 'text-primary-600 dark:text-primary-400' ? 'text-accent'
: 'text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-700 dark:group-hover:text-neutral-200' : 'text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-700 dark:group-hover:text-neutral-200'
}`} }`}
/> />
@@ -229,7 +229,7 @@ export const SettingsPage: React.FC = () => {
after they switch, especially after a mobile select change. */} after they switch, especially after a mobile select change. */}
<div className="mb-4 lg:mb-6 pb-3 border-b border-neutral-200 dark:border-neutral-700"> <div className="mb-4 lg:mb-6 pb-3 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<activeItem.icon className="w-5 h-5 text-primary-600 dark:text-primary-400" /> <activeItem.icon className="w-5 h-5 text-accent" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"> <h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{activeItem.label} {activeItem.label}
</h2> </h2>
@@ -140,7 +140,7 @@ const CreateInvitationModal: React.FC<CreateInvitationModalProps> = ({
setRoleId(e.target.value ? Number(e.target.value) : ''); setRoleId(e.target.value ? Number(e.target.value) : '');
setErrors((prev) => ({ ...prev, role: undefined })); setErrors((prev) => ({ ...prev, role: undefined }));
}} }}
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:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500" 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:outline-none focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
disabled={isLoading} disabled={isLoading}
> >
<option value="">{t('userManagement.selectRole')}</option> <option value="">{t('userManagement.selectRole')}</option>
@@ -253,7 +253,7 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
<select <select
value={roleId} value={roleId}
onChange={(e) => setRoleId(e.target.value ? Number(e.target.value) : '')} onChange={(e) => setRoleId(e.target.value ? Number(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:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500" 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:outline-none focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
disabled={isLoading} disabled={isLoading}
> >
<option value="">{t('userManagement.selectRole')}</option> <option value="">{t('userManagement.selectRole')}</option>
@@ -608,7 +608,7 @@ export const UserManagementPage: React.FC = () => {
{users?.length || 0} {users?.length || 0}
</p> </p>
</div> </div>
<Users className="w-8 h-8 text-primary-600" /> <Users className="w-8 h-8 text-accent" />
</div> </div>
</Card> </Card>
@@ -664,7 +664,7 @@ export const UserManagementPage: React.FC = () => {
onClick={() => setActiveTab(tab.key)} onClick={() => setActiveTab(tab.key)}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${ className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
activeTab === tab.key activeTab === tab.key
? 'border-primary-600 text-primary-600' ? 'border-accent text-accent'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300' : 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
}`} }`}
> >
@@ -672,7 +672,7 @@ export const UserManagementPage: React.FC = () => {
<span <span
className={`px-2 py-0.5 text-xs rounded-full ${ className={`px-2 py-0.5 text-xs rounded-full ${
activeTab === tab.key activeTab === tab.key
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300' ? 'bg-accent-dark/15 text-accent-dark'
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400' : 'bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400'
}`} }`}
> >
@@ -740,8 +740,8 @@ export const UserManagementPage: React.FC = () => {
<tr key={user.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700/50"> <tr key={user.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700/50">
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900/40 flex items-center justify-center"> <div className="w-10 h-10 rounded-full bg-accent-dark/15 flex items-center justify-center">
<span className="text-primary-700 dark:text-primary-300 font-medium text-sm"> <span className="text-accent-dark font-medium text-sm">
{user.username.charAt(0).toUpperCase()} {user.username.charAt(0).toUpperCase()}
</span> </span>
</div> </div>
@@ -794,7 +794,7 @@ export const UserManagementPage: React.FC = () => {
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button <button
onClick={() => handleEditUser(user)} onClick={() => handleEditUser(user)}
className="p-1.5 text-neutral-400 hover:text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/30 rounded-lg transition-colors" className="p-1.5 text-neutral-400 hover:text-accent hover:bg-accent-dark/15 rounded-lg transition-colors"
title={t('userManagement.editUser')} title={t('userManagement.editUser')}
> >
<Edit className="w-4 h-4" /> <Edit className="w-4 h-4" />
@@ -138,7 +138,7 @@ export const WebhookDeliveriesPage: React.FC = () => {
return ( return (
<div className="p-6"> <div className="p-6">
<p className="text-sm text-neutral-600 dark:text-neutral-400">Webhook not found.</p> <p className="text-sm text-neutral-600 dark:text-neutral-400">Webhook not found.</p>
<Link to="/admin/settings" className="text-primary-600 hover:underline"> Back to settings</Link> <Link to="/admin/settings" className="text-accent hover:underline"> Back to settings</Link>
</div> </div>
); );
} }
@@ -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'
}`} }`}
> >
@@ -19,6 +19,12 @@ export interface PublicSettings {
branding_logo_display_header?: boolean; branding_logo_display_header?: boolean;
branding_logo_display_hero?: boolean; branding_logo_display_hero?: boolean;
branding_hide_powered_by?: boolean; branding_hide_powered_by?: boolean;
/**
* Force the entire site into a specific color mode (instance-wide).
* 'dark' or 'light' override user/system preference; null = no force.
* AdminDarkModeContext + ThemeContext both honor this.
*/
branding_force_color_mode?: 'dark' | 'light' | null;
theme_config: any; theme_config: any;
default_language: string; default_language: string;
enable_analytics: boolean; enable_analytics: boolean;
+12 -1
View File
@@ -19,6 +19,12 @@ export interface BrandingSettings {
logo_display_hero?: boolean; logo_display_hero?: boolean;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text'; logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
hide_powered_by?: boolean; hide_powered_by?: boolean;
/**
* Force the entire admin and public site into a specific color mode.
* When set, the user-facing dark/light toggle is hidden and any per-theme
* `colorMode` override is ignored. `null` means no force (default behavior).
*/
force_color_mode?: 'dark' | 'light' | null;
} }
export interface ThemeSettings { export interface ThemeSettings {
@@ -288,7 +294,12 @@ export const settingsService = {
logo_display_header: this._parseBoolean(rawSettings.branding_logo_display_header, true), logo_display_header: this._parseBoolean(rawSettings.branding_logo_display_header, true),
logo_display_hero: this._parseBoolean(rawSettings.branding_logo_display_hero, true), logo_display_hero: this._parseBoolean(rawSettings.branding_logo_display_hero, true),
logo_display_mode: rawSettings.branding_logo_display_mode || 'logo_and_text', logo_display_mode: rawSettings.branding_logo_display_mode || 'logo_and_text',
hide_powered_by: this._parseBoolean(rawSettings.branding_hide_powered_by, false) hide_powered_by: this._parseBoolean(rawSettings.branding_hide_powered_by, false),
force_color_mode: rawSettings.branding_force_color_mode === 'dark'
? 'dark'
: rawSettings.branding_force_color_mode === 'light'
? 'light'
: null
}; };
}, },
+15 -3
View File
@@ -27,9 +27,14 @@
margin-bottom: 0; margin-bottom: 0;
} }
/* Code block styling */ /*
* Code block styling uses the 8-token CI palette so blocks read against
* both light and dark surfaces. The variables fall back to the previous
* #f5f5f5 if a host page hasn't loaded the theme yet.
*/
.prose pre { .prose pre {
background-color: #f5f5f5; background-color: var(--color-elevated, #f5f5f5);
border: 1px solid var(--color-surface-border, #e5e5e5);
border-radius: 0.375rem; border-radius: 0.375rem;
padding: 1rem; padding: 1rem;
overflow-x: auto; overflow-x: auto;
@@ -45,13 +50,20 @@
/* Inline code styling */ /* Inline code styling */
.prose code { .prose code {
background-color: #f5f5f5; background-color: var(--color-elevated, #f5f5f5);
padding: 0.125rem 0.375rem; padding: 0.125rem 0.375rem;
border-radius: 0.25rem; border-radius: 0.25rem;
font-size: 0.875em; font-size: 0.875em;
font-weight: 400; font-weight: 400;
} }
/* Blockquote styling — themed left rule that flips with dark mode. */
.prose blockquote {
border-left: 3px solid var(--color-accent, #017C7C);
padding-left: 1rem;
color: var(--color-muted-text, #737373);
}
/* Text alignment classes */ /* Text alignment classes */
.prose .text-left { .prose .text-left {
text-align: left !important; text-align: left !important;
+56 -2
View File
@@ -53,13 +53,27 @@ export interface GalleryLayoutSettings {
} }
export interface ThemeConfig { export interface ThemeConfig {
// Colors // Colors — 8-token CI palette.
// Naming kept for backward-compat with existing settings rows; semantics:
// backgroundColor → page base
// surfaceColor → cards / nav / alternating sections
// elevatedColor → raised panels / image placeholders
// surfaceBorderColor→ dividers, borders, grid lines (a.k.a. "border" token)
// textColor → primary text (Text 1°)
// mutedTextColor → secondary text (Text 2°)
// accentColor → links, icons, focus rings, hover
// accentDarkColor → primary CTA fill / filled states
//
// primaryColor is retained as a legacy alias and migrated to accentDarkColor
// by frontend/src/utils/themeMigration.ts. Do not surface it in new UI.
primaryColor?: string; primaryColor?: string;
accentColor?: string; accentColor?: string;
accentDarkColor?: string;
backgroundColor?: string; backgroundColor?: string;
textColor?: string;
surfaceColor?: string; surfaceColor?: string;
elevatedColor?: string;
surfaceBorderColor?: string; surfaceBorderColor?: string;
textColor?: string;
mutedTextColor?: string; mutedTextColor?: string;
// Color Mode // Color Mode
@@ -115,8 +129,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: { config: {
primaryColor: '#5C8762', primaryColor: '#5C8762',
accentColor: '#22c55e', accentColor: '#22c55e',
accentDarkColor: '#5C8762',
backgroundColor: '#fafafa', backgroundColor: '#fafafa',
surfaceColor: '#ffffff',
elevatedColor: '#f5f5f5',
surfaceBorderColor: '#e5e5e5',
textColor: '#171717', textColor: '#171717',
mutedTextColor: '#737373',
borderRadius: 'md', borderRadius: 'md',
galleryLayout: 'grid', galleryLayout: 'grid',
gallerySettings: { gallerySettings: {
@@ -136,8 +155,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: { config: {
primaryColor: '#c9a961', primaryColor: '#c9a961',
accentColor: '#e6ddd4', accentColor: '#e6ddd4',
accentDarkColor: '#c9a961',
backgroundColor: '#fdfcfb', backgroundColor: '#fdfcfb',
surfaceColor: '#ffffff',
elevatedColor: '#faf6f0',
surfaceBorderColor: '#e8e0d4',
textColor: '#3f3f3f', textColor: '#3f3f3f',
mutedTextColor: '#7a7a7a',
fontFamily: 'Playfair Display, serif', fontFamily: 'Playfair Display, serif',
headingFontFamily: 'Playfair Display, serif', headingFontFamily: 'Playfair Display, serif',
borderRadius: 'lg', borderRadius: 'lg',
@@ -163,8 +187,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: { config: {
primaryColor: '#3b82f6', primaryColor: '#3b82f6',
accentColor: '#1e40af', accentColor: '#1e40af',
accentDarkColor: '#3b82f6',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
surfaceColor: '#ffffff',
elevatedColor: '#f8fafc',
surfaceBorderColor: '#e2e8f0',
textColor: '#0f172a', textColor: '#0f172a',
mutedTextColor: '#64748b',
fontFamily: 'Inter, sans-serif', fontFamily: 'Inter, sans-serif',
borderRadius: 'sm', borderRadius: 'sm',
galleryLayout: 'masonry', galleryLayout: 'masonry',
@@ -189,8 +218,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: { config: {
primaryColor: '#ec4899', primaryColor: '#ec4899',
accentColor: '#fbbf24', accentColor: '#fbbf24',
accentDarkColor: '#ec4899',
backgroundColor: '#fef3c7', backgroundColor: '#fef3c7',
surfaceColor: '#ffffff',
elevatedColor: '#fef9e3',
surfaceBorderColor: '#fde68a',
textColor: '#451a03', textColor: '#451a03',
mutedTextColor: '#92400e',
fontFamily: 'Comic Neue, cursive', fontFamily: 'Comic Neue, cursive',
borderRadius: 'lg', borderRadius: 'lg',
galleryLayout: 'carousel', galleryLayout: 'carousel',
@@ -214,8 +248,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: { config: {
primaryColor: '#1f2937', primaryColor: '#1f2937',
accentColor: '#059669', accentColor: '#059669',
accentDarkColor: '#1f2937',
backgroundColor: '#f9fafb', backgroundColor: '#f9fafb',
surfaceColor: '#ffffff',
elevatedColor: '#f3f4f6',
surfaceBorderColor: '#e5e7eb',
textColor: '#111827', textColor: '#111827',
mutedTextColor: '#6b7280',
fontFamily: 'IBM Plex Sans, sans-serif', fontFamily: 'IBM Plex Sans, sans-serif',
borderRadius: 'sm', borderRadius: 'sm',
galleryLayout: 'timeline', galleryLayout: 'timeline',
@@ -238,8 +277,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: { config: {
primaryColor: '#7c3aed', primaryColor: '#7c3aed',
accentColor: '#f59e0b', accentColor: '#f59e0b',
accentDarkColor: '#7c3aed',
backgroundColor: '#faf5ff', backgroundColor: '#faf5ff',
surfaceColor: '#ffffff',
elevatedColor: '#f3e8ff',
surfaceBorderColor: '#e9d5ff',
textColor: '#1e1b4b', textColor: '#1e1b4b',
mutedTextColor: '#6b7280',
fontFamily: 'Montserrat, sans-serif', fontFamily: 'Montserrat, sans-serif',
borderRadius: 'none', borderRadius: 'none',
galleryLayout: 'mosaic', galleryLayout: 'mosaic',
@@ -261,9 +305,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: { config: {
primaryColor: '#5C8762', primaryColor: '#5C8762',
accentColor: '#22c55e', accentColor: '#22c55e',
accentDarkColor: '#5C8762',
backgroundColor: '#0f0f0f', backgroundColor: '#0f0f0f',
textColor: '#e5e5e5', textColor: '#e5e5e5',
surfaceColor: '#1a1a1a', surfaceColor: '#1a1a1a',
elevatedColor: '#242424',
surfaceBorderColor: '#2e2e2e', surfaceBorderColor: '#2e2e2e',
mutedTextColor: '#a3a3a3', mutedTextColor: '#a3a3a3',
colorMode: 'dark', colorMode: 'dark',
@@ -287,9 +333,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: { config: {
primaryColor: '#c9a961', primaryColor: '#c9a961',
accentColor: '#e6ddd4', accentColor: '#e6ddd4',
accentDarkColor: '#c9a961',
backgroundColor: '#121212', backgroundColor: '#121212',
textColor: '#f0ebe5', textColor: '#f0ebe5',
surfaceColor: '#1e1e1e', surfaceColor: '#1e1e1e',
elevatedColor: '#262626',
surfaceBorderColor: '#333333', surfaceBorderColor: '#333333',
mutedTextColor: '#a3a3a3', mutedTextColor: '#a3a3a3',
colorMode: 'dark', colorMode: 'dark',
@@ -317,9 +365,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: { config: {
primaryColor: '#3b82f6', primaryColor: '#3b82f6',
accentColor: '#1e40af', accentColor: '#1e40af',
accentDarkColor: '#3b82f6',
backgroundColor: '#0a0a0a', backgroundColor: '#0a0a0a',
textColor: '#f5f5f5', textColor: '#f5f5f5',
surfaceColor: '#171717', surfaceColor: '#171717',
elevatedColor: '#1f1f1f',
surfaceBorderColor: '#262626', surfaceBorderColor: '#262626',
mutedTextColor: '#a3a3a3', mutedTextColor: '#a3a3a3',
colorMode: 'dark', colorMode: 'dark',
@@ -345,9 +395,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: { config: {
primaryColor: '#18181b', primaryColor: '#18181b',
accentColor: '#ef4444', accentColor: '#ef4444',
accentDarkColor: '#18181b',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
textColor: '#18181b', textColor: '#18181b',
surfaceColor: '#ffffff', surfaceColor: '#ffffff',
elevatedColor: '#fafafa',
surfaceBorderColor: '#f4f4f5', surfaceBorderColor: '#f4f4f5',
mutedTextColor: '#71717a', mutedTextColor: '#71717a',
colorMode: 'light', colorMode: 'light',
@@ -372,9 +424,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
config: { config: {
primaryColor: '#c9a961', primaryColor: '#c9a961',
accentColor: '#c9a961', accentColor: '#c9a961',
accentDarkColor: '#a88c4a',
backgroundColor: '#0d0d0d', backgroundColor: '#0d0d0d',
textColor: '#f2f2f2', textColor: '#f2f2f2',
surfaceColor: '#1a1a1a', surfaceColor: '#1a1a1a',
elevatedColor: '#222222',
surfaceBorderColor: '#262626', surfaceBorderColor: '#262626',
mutedTextColor: '#a3a3a3', mutedTextColor: '#a3a3a3',
colorMode: 'dark', colorMode: 'dark',
@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';
import { migrateThemeConfig, applyForceColorMode } from '../themeMigration';
import type { ThemeConfig } from '../../types/theme.types';
describe('migrateThemeConfig — 8-token palette fill', () => {
it('derives light surface defaults for a legacy 4-color light theme', () => {
const legacy: ThemeConfig = {
primaryColor: '#5C8762',
accentColor: '#22c55e',
backgroundColor: '#fafafa',
textColor: '#171717',
colorMode: 'light',
galleryLayout: 'grid',
};
const migrated = migrateThemeConfig(legacy);
expect(migrated.surfaceColor).toBe('#ffffff');
expect(migrated.elevatedColor).toBe('#f5f5f5');
expect(migrated.surfaceBorderColor).toBe('#e5e5e5');
expect(migrated.mutedTextColor).toBe('#737373');
// Legacy primaryColor was used as the CTA fill — preserved as accentDark.
expect(migrated.accentDarkColor).toBe('#5C8762');
// Existing fields untouched.
expect(migrated.primaryColor).toBe('#5C8762');
expect(migrated.backgroundColor).toBe('#fafafa');
expect(migrated.textColor).toBe('#171717');
});
it('derives dark surface defaults for a legacy 4-color dark theme', () => {
const legacy: ThemeConfig = {
primaryColor: '#3b82f6',
accentColor: '#1e40af',
backgroundColor: '#0a0a0a',
textColor: '#f5f5f5',
colorMode: 'dark',
galleryLayout: 'grid',
};
const migrated = migrateThemeConfig(legacy);
expect(migrated.surfaceColor).toBe('#1a1a1a');
expect(migrated.elevatedColor).toBe('#242424');
expect(migrated.surfaceBorderColor).toBe('#2e2e2e');
expect(migrated.mutedTextColor).toBe('#a3a3a3');
expect(migrated.accentDarkColor).toBe('#3b82f6');
});
it('does not overwrite explicit 8-token values', () => {
const fullPalette: ThemeConfig = {
primaryColor: '#014E4E',
accentColor: '#017C7C',
accentDarkColor: '#014E4E',
backgroundColor: '#0D0D0D',
surfaceColor: '#111414',
elevatedColor: '#182222',
surfaceBorderColor: '#1E2E2E',
textColor: '#EBEBEB',
mutedTextColor: '#4A6060',
colorMode: 'dark',
galleryLayout: 'grid',
};
const migrated = migrateThemeConfig(fullPalette);
expect(migrated.surfaceColor).toBe('#111414');
expect(migrated.elevatedColor).toBe('#182222');
expect(migrated.surfaceBorderColor).toBe('#1E2E2E');
expect(migrated.mutedTextColor).toBe('#4A6060');
expect(migrated.accentDarkColor).toBe('#014E4E');
});
it('still migrates the legacy "hero" galleryLayout while filling palette', () => {
const legacy = {
primaryColor: '#5C8762',
accentColor: '#22c55e',
backgroundColor: '#fafafa',
textColor: '#171717',
galleryLayout: 'hero',
} as unknown as ThemeConfig;
const migrated = migrateThemeConfig(legacy);
expect(migrated.galleryLayout).toBe('grid');
expect(migrated.headerStyle).toBe('hero');
expect(migrated.heroDividerStyle).toBe('wave');
// Palette still filled.
expect(migrated.surfaceColor).toBe('#ffffff');
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 customDark: 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(customDark, 'dark');
expect(result.colorMode).toBe('dark');
// Custom 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(customDark, 'light');
expect(result.colorMode).toBe('light');
expect(result.backgroundColor).toBe('#fafafa');
expect(result.surfaceColor).toBe('#ffffff');
expect(result.textColor).toBe('#171717');
// Custom accent colours survive the flip.
expect(result.accentColor).toBe('#017C7C');
expect(result.accentDarkColor).toBe('#014E4E');
});
});
+120 -12
View File
@@ -1,34 +1,142 @@
import type { ThemeConfig, HeaderStyleType, HeroDividerStyle, GalleryLayoutType } from '../types/theme.types'; import type { ThemeConfig, HeaderStyleType, HeroDividerStyle, GalleryLayoutType } from '../types/theme.types';
/** /**
* Migrates legacy theme configurations that used 'hero' as a galleryLayout * Surface defaults for the two color modes the same values applyTheme()
* to the new decoupled headerStyle + galleryLayout system. * 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.
* *
* This ensures backward compatibility with existing events that have * If the theme already matches the locked mode (or no lock is set), only
* 'hero' set as their galleryLayout. * 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
* saved before the palette expanded from 4 8 explicit tokens.
*
* The visible look of an existing instance must not change just because the
* type system grew (per project memory: migrations preserve visual state).
* For each missing token we fall back to the value the renderer was already
* deriving implicitly:
* - accentDarkColor primaryColor (legacy primary was used as CTA fill)
* - elevatedColor surfaceColor (or a slight shift for light themes)
* - surfaceColor '#ffffff' / '#1a1a1a' depending on colorMode
* - surfaceBorderColor '#e5e5e5' / '#2e2e2e'
* - mutedTextColor '#737373' / '#a3a3a3'
*/
function fillMissingPaletteTokens(theme: ThemeConfig): ThemeConfig {
const isDark = theme.colorMode === 'dark';
const filled: ThemeConfig = { ...theme };
if (!filled.surfaceColor) {
filled.surfaceColor = isDark ? '#1a1a1a' : '#ffffff';
}
if (!filled.elevatedColor) {
// For dark themes raise slightly above surface; for light, drop slightly below.
filled.elevatedColor = isDark ? '#242424' : '#f5f5f5';
}
if (!filled.surfaceBorderColor) {
filled.surfaceBorderColor = isDark ? '#2e2e2e' : '#e5e5e5';
}
if (!filled.mutedTextColor) {
filled.mutedTextColor = isDark ? '#a3a3a3' : '#737373';
}
if (!filled.accentDarkColor) {
// Legacy themes used primaryColor as the CTA fill — preserve that.
filled.accentDarkColor = filled.primaryColor;
}
return filled;
}
/**
* Migrates legacy theme configurations:
* - 'hero' galleryLayout decoupled headerStyle + galleryLayout
* - missing 8-token CI palette fields derived from legacy 4-color set
*
* This ensures backward compatibility with existing events.
*/ */
export function migrateThemeConfig(theme: ThemeConfig): ThemeConfig { export function migrateThemeConfig(theme: ThemeConfig): ThemeConfig {
if (!theme) return theme; if (!theme) return theme;
let migrated = theme;
// Check if this theme uses the legacy 'hero' layout // Check if this theme uses the legacy 'hero' layout
if ((theme.galleryLayout as string) === 'hero') { if ((migrated.galleryLayout as string) === 'hero') {
return { migrated = {
...theme, ...migrated,
headerStyle: 'hero' as HeaderStyleType, headerStyle: 'hero' as HeaderStyleType,
galleryLayout: 'grid' as GalleryLayoutType, galleryLayout: 'grid' as GalleryLayoutType,
heroDividerStyle: (theme.heroDividerStyle || 'wave') as HeroDividerStyle, heroDividerStyle: (migrated.heroDividerStyle || 'wave') as HeroDividerStyle,
}; };
} }
// If headerStyle is not set but galleryLayout is valid, default to 'standard' // If headerStyle is not set but galleryLayout is valid, default to 'standard'
if (!theme.headerStyle && theme.galleryLayout) { if (!migrated.headerStyle && migrated.galleryLayout) {
return { migrated = {
...theme, ...migrated,
headerStyle: 'standard' as HeaderStyleType, headerStyle: 'standard' as HeaderStyleType,
}; };
} }
return theme; // Fill any missing 8-token palette fields so the renderer never has to
// fall back to hard-coded defaults that diverge from the original look.
return fillMissingPaletteTokens(migrated);
} }
/** /**
+13
View File
@@ -8,6 +8,19 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
// 8-token CI palette aliases — these read CSS variables that are set
// either by ThemeContext.applyTheme (gallery + branding) or by the
// :root.dark { } block in index.css (admin dark mode). Use these in
// place of bg-white / text-neutral-900 / border-neutral-200 so that
// every component flips with dark/light mode automatically.
background: 'var(--color-background)',
surface: 'var(--color-surface)',
elevated: 'var(--color-elevated)',
'border-token': 'var(--color-surface-border)',
'text-primary': 'var(--color-text)',
'text-secondary': 'var(--color-muted-text)',
accent: 'var(--color-accent)',
'accent-dark': 'var(--color-accent-dark)',
primary: { primary: {
50: '#f0fdf4', 50: '#f0fdf4',
100: '#dcfce7', 100: '#dcfce7',
+55
View File
@@ -215,3 +215,58 @@ test.describe('Gallery Theme Color Mode', () => {
await expect(page.getByRole('button', { name: /^Auto$/i })).toBeVisible(); await expect(page.getByRole('button', { name: /^Auto$/i })).toBeVisible();
}); });
}); });
test.describe('Force color mode (instance-wide lock)', () => {
// The force color mode setting is exposed in Branding > Force color mode.
// When set, the user-facing dark/light toggle in the admin header should
// disappear entirely. We verify both presence of the controls and that
// toggling them hides the header chip.
test('force-mode controls are present in Branding settings', async ({ page }, testInfo) => {
if (testInfo.project.name === 'mobile-chrome') {
test.skip('Force color mode validated on desktop viewport');
}
await loginToAdmin(page);
await page.goto('/admin/branding');
await expect(page.getByRole('heading', { name: /Force color mode|Farbmodus erzwingen/i })).toBeVisible({ timeout: 10000 });
// The three states must be selectable as buttons.
await expect(page.getByRole('button', { name: /No force|Kein/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Force dark|Dunkel erzwingen/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Force light|Hell erzwingen/i })).toBeVisible();
});
test('selecting force-dark hides the header dark mode toggle on next reload', async ({ page }, testInfo) => {
if (testInfo.project.name === 'mobile-chrome') {
test.skip('Force color mode validated on desktop viewport');
}
await loginToAdmin(page);
// Confirm the toggle is initially visible (no force mode set).
let toggle = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i });
await expect(toggle).toBeVisible();
// Set force-dark via Branding page.
await page.goto('/admin/branding');
await page.getByRole('button', { name: /Force dark|Dunkel erzwingen/i }).click();
await page.getByRole('button', { name: /^Save|^Speichern/i }).first().click();
// Reload so the public-settings refetch picks up the new value.
await page.reload();
await page.waitForLoadState('networkidle');
toggle = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i });
await expect(toggle).toHaveCount(0);
// The .dark class should be applied to <html>.
const html = page.locator('html');
await expect(html).toHaveClass(/(^|\s)dark(\s|$)/);
// Restore: clear the force mode so subsequent test runs aren't affected.
await page.goto('/admin/branding');
await page.getByRole('button', { name: /No force|Kein/i }).click();
await page.getByRole('button', { name: /^Save|^Speichern/i }).first().click();
});
});