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:
@@ -104,4 +104,59 @@ describe('publicSiteService', () => {
|
||||
expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,9 +219,17 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
logo_display_header,
|
||||
logo_display_hero,
|
||||
logo_display_mode,
|
||||
hide_powered_by
|
||||
hide_powered_by,
|
||||
force_color_mode
|
||||
} = 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
|
||||
const oldSettingsHash = await watermarkService.getSettingsHash();
|
||||
|
||||
@@ -243,7 +251,8 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
logo_display_header,
|
||||
logo_display_hero,
|
||||
logo_display_mode,
|
||||
hide_powered_by
|
||||
hide_powered_by,
|
||||
force_color_mode: normalizedForceColorMode
|
||||
};
|
||||
|
||||
// Handle favicon deletion if empty string or null is provided
|
||||
|
||||
@@ -65,6 +65,14 @@ router.get('/', async (req, res) => {
|
||||
branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false,
|
||||
branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text',
|
||||
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,
|
||||
default_language: settingsObject.general_default_language || 'en',
|
||||
enable_analytics: settingsObject.general_enable_analytics !== false,
|
||||
|
||||
@@ -162,29 +162,52 @@ function darkenColor(hex, amount = 0.15) {
|
||||
|
||||
// Wrap HTML body in the styled email template with header, footer, and logo
|
||||
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 companyName = 'PicPeak';
|
||||
let primaryColor = '#5C8762';
|
||||
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 {
|
||||
const brandingSettings = await db('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'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');
|
||||
|
||||
const readSetting = (val, fallback) => {
|
||||
if (!val) return fallback;
|
||||
try { return JSON.parse(val); } catch (e) { return val; }
|
||||
};
|
||||
|
||||
brandingSettings.forEach(setting => {
|
||||
const val = setting.setting_value;
|
||||
if (setting.setting_key === 'branding_logo_url' && val) {
|
||||
try { logoUrl = JSON.parse(val); } catch (e) { logoUrl = val; }
|
||||
} else if (setting.setting_key === 'branding_company_name' && val) {
|
||||
try { companyName = JSON.parse(val); } catch (e) { companyName = val; }
|
||||
} else if (setting.setting_key === 'email_primary_color' && val) {
|
||||
try { primaryColor = JSON.parse(val); } catch (e) { primaryColor = val; }
|
||||
} else if (setting.setting_key === 'email_secondary_color' && val) {
|
||||
try { secondaryColor = JSON.parse(val); } catch (e) { secondaryColor = val; }
|
||||
switch (setting.setting_key) {
|
||||
case 'branding_logo_url': logoUrl = readSetting(val, logoUrl); break;
|
||||
case 'branding_company_name': companyName = readSetting(val, companyName); break;
|
||||
case 'email_primary_color': primaryColor = readSetting(val, primaryColor); break;
|
||||
case 'email_secondary_color': secondaryColor = readSetting(val, secondaryColor); break;
|
||||
case 'email_body_bg_color': bodyBgColor = readSetting(val, bodyBgColor); break;
|
||||
case 'email_container_bg_color': containerBgColor = readSetting(val, containerBgColor); break;
|
||||
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) {
|
||||
@@ -211,17 +234,17 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
background-color: ${bodyBgColor};
|
||||
color: ${bodyTextColor};
|
||||
}
|
||||
.email-wrapper {
|
||||
background-color: #f5f5f5;
|
||||
background-color: ${bodyBgColor};
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
background-color: ${containerBgColor};
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
@@ -250,7 +273,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.email-content ul {
|
||||
background-color: #f9f9f9;
|
||||
background-color: ${listBgColor};
|
||||
padding: 20px 20px 20px 40px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
@@ -262,7 +285,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
background-color: ${primaryColor};
|
||||
color: white !important;
|
||||
color: ${buttonTextColor} !important;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-weight: 500;
|
||||
@@ -284,7 +307,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.email-footer p {
|
||||
color: #666;
|
||||
color: ${mutedTextColor};
|
||||
font-size: 14px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
@@ -296,7 +319,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||
color: ${hoverColor};
|
||||
}
|
||||
strong {
|
||||
color: #333;
|
||||
color: ${bodyTextColor};
|
||||
}
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-wrapper {
|
||||
@@ -565,12 +588,34 @@ async function processTemplate(template, variables, language = '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 += `
|
||||
<div style="margin-top: 24px; padding: 20px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
|
||||
<strong style="font-size: 15px;">🔒 ${ci18n.label}</strong>
|
||||
<p style="margin: 10px 0 8px;">${ci18n.desc}</p>
|
||||
<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 style="margin: 8px 0;">${ci18n.pin}: <strong>${processedVariables.client_password}</strong></p>
|
||||
<p style="color: #856404; font-size: 12px; margin: 8px 0 0;">⚠️ ${ci18n.warning}</p>
|
||||
|
||||
@@ -87,11 +87,19 @@ async function fetchBrandingContext() {
|
||||
supportEmail: null,
|
||||
logoUrl: 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: {
|
||||
primary: '#16a34a',
|
||||
accent: '#0f766e',
|
||||
accentDark: '#16a34a',
|
||||
background: '#f4fbf6',
|
||||
text: '#0f172a'
|
||||
surface: '#ffffff',
|
||||
elevated: '#f5f5f5',
|
||||
border: '#e5e5e5',
|
||||
text: '#0f172a',
|
||||
mutedText: '#737373'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,10 +125,17 @@ async function fetchBrandingContext() {
|
||||
try {
|
||||
const themeConfig = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
|
||||
if (themeConfig && typeof themeConfig === 'object') {
|
||||
// Legacy 4 colors
|
||||
context.colors.primary = themeConfig.primaryColor || context.colors.primary;
|
||||
context.colors.accent = themeConfig.accentColor || context.colors.accent;
|
||||
context.colors.background = themeConfig.backgroundColor || context.colors.background;
|
||||
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) {
|
||||
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) {
|
||||
themeAppliedRef.current = true;
|
||||
// Instance-wide force color mode is enforced inside ThemeContext.applyTheme.
|
||||
setTheme(settingsData.theme_config);
|
||||
}
|
||||
}, [settingsData, setTheme]);
|
||||
|
||||
@@ -133,7 +133,7 @@ export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, gue
|
||||
onClick={() => setTab(k)}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 transition ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -245,7 +245,7 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
|
||||
type="checkbox"
|
||||
checked={mergeSelection.includes(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>
|
||||
)}
|
||||
@@ -278,7 +278,7 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
|
||||
<button
|
||||
type="button"
|
||||
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')}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
@@ -286,7 +286,7 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
|
||||
<div className="relative group">
|
||||
<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')}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
|
||||
@@ -23,7 +23,7 @@ interface AdminHeaderProps {
|
||||
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAdminAuth();
|
||||
const { isDark, toggle: toggleDarkMode } = useAdminDarkMode();
|
||||
const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode();
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { formatTimeAgo } = useLocalizedTimeAgo();
|
||||
@@ -116,14 +116,17 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
{/* Language Selector */}
|
||||
<LanguageSelector />
|
||||
|
||||
{/* Dark Mode Toggle */}
|
||||
<button
|
||||
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"
|
||||
title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')}
|
||||
>
|
||||
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
{/* Dark Mode Toggle — hidden entirely when an admin has locked
|
||||
the instance to a specific mode via Branding > Force color mode. */}
|
||||
{!forcedMode && (
|
||||
<button
|
||||
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"
|
||||
title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')}
|
||||
>
|
||||
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="relative" ref={notificationRef}>
|
||||
@@ -146,7 +149,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
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')}
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
@@ -175,7 +178,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
<div
|
||||
key={notification.id}
|
||||
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">
|
||||
@@ -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">
|
||||
<button
|
||||
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')}
|
||||
</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-xs text-neutral-500 dark:text-neutral-400">{user?.email}</p>
|
||||
</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" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const AdminLayout: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-950 flex items-center justify-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>
|
||||
</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 ${
|
||||
selectedPhotos.has(photo.id)
|
||||
? 'bg-primary-600 border-primary-600'
|
||||
? 'bg-accent-dark border-accent-dark'
|
||||
: 'bg-white/90 border-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 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -266,7 +266,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowCategoryMenu(!showCategoryMenu)}
|
||||
className="text-xs text-primary-400 hover:text-primary-300"
|
||||
className="text-xs text-accent hover:text-accent-dark"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
@@ -393,7 +393,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
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})
|
||||
</button>
|
||||
|
||||
@@ -90,12 +90,17 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
||||
onClick={() => onClose()}
|
||||
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
|
||||
? 'bg-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'
|
||||
}`}
|
||||
>
|
||||
{/* 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 ${
|
||||
isActive ? 'text-primary-600' : 'text-neutral-400'
|
||||
isActive ? 'text-white' : 'text-neutral-400'
|
||||
}`} />
|
||||
{t(item.nameKey)}
|
||||
</NavLink>
|
||||
@@ -136,7 +141,7 @@ const StorageInfo: React.FC = () => {
|
||||
? Math.round((storageInfo.total_used / limitInUse) * 100)
|
||||
: 0;
|
||||
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
|
||||
? 'bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-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)}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -385,7 +385,7 @@ export const BackupHistory = () => {
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -25,13 +25,13 @@ export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="p-6">
|
||||
<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
|
||||
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}
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-500" />
|
||||
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export const BulkCategoryModal: React.FC<BulkCategoryModalProps> = ({
|
||||
id="category-select"
|
||||
value={selectedCategoryId ?? ''}
|
||||
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}
|
||||
>
|
||||
<option value="">{t('photos.uncategorized', 'Uncategorized')}</option>
|
||||
|
||||
@@ -143,8 +143,10 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`p-2 rounded hover:bg-neutral-100 transition-colors ${
|
||||
active ? 'bg-primary-100 text-primary-700' : 'text-neutral-700'
|
||||
className={`p-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors ${
|
||||
active
|
||||
? 'bg-accent-dark/15 text-accent-dark'
|
||||
: 'text-neutral-700 dark:text-neutral-200'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={title}
|
||||
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 (
|
||||
<div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''}`}>
|
||||
<div className="border border-neutral-300 rounded-lg overflow-hidden h-full flex flex-col">
|
||||
<div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-white dark:bg-neutral-900' : ''}`}>
|
||||
<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 */}
|
||||
<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 */}
|
||||
<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">
|
||||
<button
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => setViewMode('edit')} className={viewModeChipClass('edit')}>
|
||||
<Edit3 className="w-4 h-4 inline-block mr-1" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => setViewMode('preview')} className={viewModeChipClass('preview')}>
|
||||
<Eye className="w-4 h-4 inline-block mr-1" />
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<button onClick={() => setViewMode('split')} className={viewModeChipClass('split')}>
|
||||
<Columns className="w-4 h-4 inline-block mr-1" />
|
||||
Split
|
||||
</button>
|
||||
@@ -295,7 +285,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<Heading6 className="w-4 h-4" />
|
||||
</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
|
||||
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" />
|
||||
</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
|
||||
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" />
|
||||
</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
|
||||
onClick={() => setShowLinkDialog(true)}
|
||||
@@ -372,7 +362,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
<Minus className="w-4 h-4" />
|
||||
</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
|
||||
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" />
|
||||
</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
|
||||
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" />
|
||||
</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
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
@@ -438,14 +428,14 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
|
||||
{/* Link Dialog */}
|
||||
{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
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && addLink()}
|
||||
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
|
||||
/>
|
||||
<Button size="sm" onClick={addLink}>Add Link</Button>
|
||||
@@ -460,21 +450,22 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
|
||||
{/* Editor Content Area */}
|
||||
<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' && (
|
||||
<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
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
{/* Preview */}
|
||||
{viewMode !== 'edit' && (
|
||||
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} overflow-auto bg-neutral-50 p-4`}>
|
||||
<div
|
||||
className="prose prose-neutral max-w-none"
|
||||
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} overflow-auto bg-neutral-50 dark:bg-neutral-800 p-4`}>
|
||||
<div
|
||||
className="prose prose-neutral dark:prose-invert max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: getPreviewContent() }}
|
||||
/>
|
||||
</div>
|
||||
@@ -482,12 +473,12 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<span>{wordCount} words</span>
|
||||
<span>{charCount} characters</span>
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
@@ -496,7 +487,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
{/* Help Modal */}
|
||||
{showHelp && (
|
||||
<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">
|
||||
<h2 className="text-xl font-semibold mb-4">Editor Help & Keyboard Shortcuts</h2>
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ export const CategoryManager: React.FC = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -205,7 +205,7 @@ export const CategoryManager: React.FC = () => {
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
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')}
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
|
||||
@@ -108,7 +108,7 @@ export const CssTemplateEditor: React.FC = () => {
|
||||
onClick={() => setActiveSlot(slot)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -138,7 +138,7 @@ export const CssTemplateEditor: React.FC = () => {
|
||||
value={activeTemplate.name}
|
||||
onChange={(e) => updateLocalTemplate({ name: e.target.value })}
|
||||
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>
|
||||
|
||||
@@ -149,7 +149,7 @@ export const CssTemplateEditor: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={activeTemplate.is_enabled}
|
||||
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">
|
||||
{t('cssTemplates.enableTemplate', 'Enable this template')}
|
||||
@@ -169,7 +169,7 @@ export const CssTemplateEditor: React.FC = () => {
|
||||
<textarea
|
||||
value={activeTemplate.css_content}
|
||||
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}
|
||||
placeholder="/* Enter your custom CSS here */"
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
|
||||
{/* Header */}
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -146,7 +146,7 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
|
||||
disabled={disabled}
|
||||
className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-600 transition-colors ${
|
||||
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'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={title}
|
||||
@@ -304,7 +304,7 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
|
||||
onClick={() => setShowVariables(!showVariables)}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
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"
|
||||
type="button"
|
||||
>
|
||||
<code className="text-primary-600 dark:text-primary-400">{`{{${variable}}}`}</code>
|
||||
<code className="text-accent">{`{{${variable}}}`}</code>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -348,19 +348,19 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
|
||||
|
||||
{/* Link Dialog */}
|
||||
{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
|
||||
type="url"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addLink()}
|
||||
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
|
||||
/>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{t('email.editor.addLink')}
|
||||
|
||||
@@ -102,7 +102,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
{/* Hero photo thumbnail */}
|
||||
<button
|
||||
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')}
|
||||
>
|
||||
{heroPhoto ? (
|
||||
@@ -195,7 +195,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : 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" />
|
||||
)}
|
||||
@@ -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">
|
||||
<button
|
||||
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')}
|
||||
>
|
||||
{heroPhoto ? (
|
||||
@@ -244,7 +244,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : 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" />
|
||||
)}
|
||||
@@ -288,7 +288,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
onClick={() => handleSelectHeroPhoto(heroPickerCategoryId, photo.id)}
|
||||
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -300,7 +300,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
/>
|
||||
</div>
|
||||
{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" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -158,11 +158,11 @@ export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
|
||||
</div>
|
||||
|
||||
{renameResult.newShareLink && (
|
||||
<div className="p-3 bg-neutral-50 rounded-lg">
|
||||
<p className="text-sm font-medium text-neutral-700 mb-1">
|
||||
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-200 mb-1">
|
||||
{t('events.rename.newLink', 'New Gallery Link')}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -198,7 +198,7 @@ export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
|
||||
// Renaming in progress
|
||||
<div className="space-y-4 py-8">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,7 +258,7 @@ export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
|
||||
type="checkbox"
|
||||
checked={resendEmail}
|
||||
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>
|
||||
<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 ? (
|
||||
<div className="text-center py-8">
|
||||
<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 className="space-y-3">
|
||||
{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-shrink-0">
|
||||
<div className="w-10 h-10 bg-neutral-100 rounded-full flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-neutral-600" />
|
||||
<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 dark:text-neutral-300" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<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')}
|
||||
</span>
|
||||
<span className="text-neutral-500">•</span>
|
||||
<span className="text-neutral-500">
|
||||
<span className="text-neutral-500 dark:text-neutral-400">•</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400">
|
||||
{format(
|
||||
typeof item.created_at === 'string'
|
||||
? parseISO(item.created_at)
|
||||
@@ -191,7 +191,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
|
||||
{pendingComments.length > maxItems && !showAll && (
|
||||
<button
|
||||
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 })}
|
||||
</button>
|
||||
@@ -203,7 +203,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||
<a
|
||||
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" />
|
||||
{t('feedback.viewAllFeedback', 'View all feedback & settings')}
|
||||
|
||||
@@ -61,7 +61,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
type="checkbox"
|
||||
checked={settings.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">
|
||||
{t('feedback.settings.enableFeedback', 'Enable feedback')}
|
||||
@@ -80,7 +80,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
<label
|
||||
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
|
||||
(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'
|
||||
}`}
|
||||
>
|
||||
@@ -90,7 +90,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
value="simple"
|
||||
checked={(settings.identity_mode || 'simple') === '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" />
|
||||
<div className="flex-1">
|
||||
@@ -109,7 +109,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
<label
|
||||
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -119,7 +119,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
value="guest"
|
||||
checked={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" />
|
||||
<div className="flex-1">
|
||||
@@ -150,7 +150,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
type="checkbox"
|
||||
checked={settings.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" />
|
||||
<div className="flex-1">
|
||||
@@ -168,7 +168,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
type="checkbox"
|
||||
checked={settings.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" />
|
||||
<div className="flex-1">
|
||||
@@ -186,7 +186,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
type="checkbox"
|
||||
checked={settings.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" />
|
||||
<div className="flex-1">
|
||||
@@ -204,7 +204,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
type="checkbox"
|
||||
checked={settings.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" />
|
||||
<div className="flex-1">
|
||||
@@ -232,7 +232,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
type="checkbox"
|
||||
checked={settings.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="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}
|
||||
onChange={() => handleToggle('moderate_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" />
|
||||
<div className="flex-1">
|
||||
@@ -268,7 +268,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
type="checkbox"
|
||||
checked={settings.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" />
|
||||
<div className="flex-1">
|
||||
@@ -292,7 +292,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
type="checkbox"
|
||||
checked={settings.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="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
@@ -316,7 +316,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
max="60"
|
||||
value={settings.rate_limit_window_minutes || 15}
|
||||
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>
|
||||
@@ -329,7 +329,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
||||
max="100"
|
||||
value={settings.rate_limit_max_requests || 10}
|
||||
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>
|
||||
|
||||
@@ -102,7 +102,7 @@ export const FocalPointPicker: React.FC<FocalPointPickerProps> = ({
|
||||
onClick={() => onChange(p.value)}
|
||||
className={
|
||||
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 className="flex justify-center gap-1 mt-3">
|
||||
{[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>
|
||||
|
||||
@@ -161,7 +161,7 @@ export const GuestInviteDialog: React.FC<GuestInviteDialogProps> = ({ eventId, o
|
||||
<button
|
||||
type="button"
|
||||
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')}
|
||||
>
|
||||
{copiedId === invite.id ? (
|
||||
|
||||
@@ -54,7 +54,7 @@ export const GuestSelectionsAggregate: React.FC<GuestSelectionsAggregateProps> =
|
||||
alt={p.filename}
|
||||
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" />
|
||||
{p.picker_count}
|
||||
</div>
|
||||
|
||||
@@ -132,7 +132,7 @@ export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
|
||||
onClick={() => handleSelect(photo.id)}
|
||||
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -144,7 +144,7 @@ export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
|
||||
/>
|
||||
</div>
|
||||
{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" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -135,7 +135,7 @@ export const MandatoryPasswordChangeModal: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
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 ?
|
||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||
@@ -163,7 +163,7 @@ export const MandatoryPasswordChangeModal: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
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 ?
|
||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||
@@ -191,7 +191,7 @@ export const MandatoryPasswordChangeModal: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
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 ?
|
||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||
|
||||
@@ -163,7 +163,7 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
|
||||
type="checkbox"
|
||||
checked={sendEmail}
|
||||
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 items-center gap-2">
|
||||
@@ -231,7 +231,7 @@ export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
|
||||
type="text"
|
||||
value={resultPassword}
|
||||
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
|
||||
variant="outline"
|
||||
|
||||
@@ -126,7 +126,7 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
)}
|
||||
{t('export.button', 'Export')}
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -85,7 +85,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
<select
|
||||
value={filters.minRating ?? ''}
|
||||
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}
|
||||
>
|
||||
{RATING_OPTIONS.map(option => (
|
||||
@@ -103,7 +103,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
type="checkbox"
|
||||
checked={filters.hasLikes || false}
|
||||
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}
|
||||
/>
|
||||
<Heart className="w-4 h-4 text-red-500" />
|
||||
@@ -120,7 +120,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
type="checkbox"
|
||||
checked={filters.hasFavorites || false}
|
||||
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}
|
||||
/>
|
||||
<Bookmark className="w-4 h-4 text-yellow-500" />
|
||||
@@ -137,7 +137,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
type="checkbox"
|
||||
checked={filters.hasComments || false}
|
||||
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}
|
||||
/>
|
||||
<MessageCircle className="w-4 h-4 text-blue-500" />
|
||||
@@ -160,7 +160,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
onClick={() => handleLogicChange('AND')}
|
||||
className={`px-3 py-1 text-sm font-medium transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
@@ -172,7 +172,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
onClick={() => handleLogicChange('OR')}
|
||||
className={`px-3 py-1 text-sm font-medium transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
|
||||
@@ -60,7 +60,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
const numeric = Number(raw);
|
||||
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="0">{t('gallery.uncategorized', 'Uncategorized')}</option>
|
||||
@@ -78,7 +78,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
<select
|
||||
value={mediaType}
|
||||
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="photo">{t('gallery.photosOnly', 'Photos only')}</option>
|
||||
@@ -92,7 +92,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
<select
|
||||
value={sortBy}
|
||||
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="name">{t('gallery.sortByName', 'Sort by Name')}</option>
|
||||
|
||||
@@ -342,7 +342,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
id="replace-by-name"
|
||||
checked={replaceByName}
|
||||
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">
|
||||
{t('upload.replaceByName', 'Replace existing photos with same name')}
|
||||
@@ -353,8 +353,8 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
<div
|
||||
className={clsx(
|
||||
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
|
||||
"hover:border-primary-400 hover:bg-primary-50/50",
|
||||
selectedFiles.length > 0 ? "border-primary-400 bg-primary-50/30 dark:bg-primary-900/20" : "border-neutral-300 dark:border-neutral-600"
|
||||
"hover:border-accent-dark hover:bg-accent-dark/15",
|
||||
selectedFiles.length > 0 ? "border-accent-dark bg-accent-dark/15" : "border-neutral-300 dark:border-neutral-600"
|
||||
)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
@@ -496,7 +496,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
|
||||
<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}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -196,7 +196,7 @@ export const RestoreWizard = () => {
|
||||
onClick={() => setRestoreData(prev => ({ ...prev, source: 'local' }))}
|
||||
className={`p-6 rounded-lg border-2 transition-all ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -211,7 +211,7 @@ export const RestoreWizard = () => {
|
||||
onClick={() => setRestoreData(prev => ({ ...prev, source: 's3' }))}
|
||||
className={`p-6 rounded-lg border-2 transition-all ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -226,7 +226,7 @@ export const RestoreWizard = () => {
|
||||
onClick={() => setRestoreData(prev => ({ ...prev, source: 'upload' }))}
|
||||
className={`p-6 rounded-lg border-2 transition-all ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -312,7 +312,7 @@ export const RestoreWizard = () => {
|
||||
key={backup.id}
|
||||
className={`p-4 cursor-pointer transition-all ${
|
||||
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'
|
||||
}`}
|
||||
onClick={() => setRestoreData(prev => ({ ...prev, selectedBackup: backup }))}
|
||||
@@ -388,7 +388,7 @@ export const RestoreWizard = () => {
|
||||
onClick={() => setRestoreData(prev => ({ ...prev, restoreType: type.id }))}
|
||||
className={`p-4 rounded-lg border-2 text-left transition-all ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -100,14 +100,14 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
||||
onClick={() => handlePresetSelect(key)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
selectedPreset === key
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
? 'tile-selected'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-sm">{theme.name}</span>
|
||||
{selectedPreset === key && (
|
||||
<Check className="w-4 h-4 text-primary-600" />
|
||||
<Check className="w-4 h-4 text-accent" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -247,7 +247,7 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
|
||||
onClick={() => handleChange('borderRadius', radius)}
|
||||
className={`px-4 py-2 rounded-lg border-2 transition-all ${
|
||||
localTheme.borderRadius === radius
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
? 'tile-selected'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -54,8 +54,63 @@ interface ThemeCustomizerEnhancedProps {
|
||||
cssTemplates?: EnabledTemplate[];
|
||||
cssTemplateId?: number | null;
|
||||
onCssTemplateChange?: (templateId: number | null) => void;
|
||||
// Force color mode is an instance-level branding setting (not part of the
|
||||
// per-theme config), but it lives next to the per-theme Color Mode picker
|
||||
// so the Branding admin can find both controls in one place. When these
|
||||
// props are omitted (e.g. event-level theme editor), the section is hidden.
|
||||
forceColorMode?: 'dark' | 'light' | null;
|
||||
onForceColorModeChange?: (mode: 'dark' | 'light' | null) => void;
|
||||
// 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> = {
|
||||
grid: <Grid3X3 className="w-5 h-5" />,
|
||||
masonry: <Layers className="w-5 h-5" />,
|
||||
@@ -116,7 +171,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
isApplying = false,
|
||||
cssTemplates,
|
||||
cssTemplateId,
|
||||
onCssTemplateChange
|
||||
onCssTemplateChange,
|
||||
forceColorMode,
|
||||
onForceColorModeChange,
|
||||
onSyncFromBranding
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||
@@ -158,7 +216,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
}, [presetName]);
|
||||
|
||||
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);
|
||||
|
||||
// When any change is made, mark it as custom
|
||||
@@ -248,7 +312,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onClick={() => handlePresetSelect(key)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -260,23 +324,29 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
)}
|
||||
</div>
|
||||
{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 className="flex items-center gap-2 mt-3">
|
||||
<div className="flex gap-1">
|
||||
{/* Preview swatches: background, surface, accent-dark, accent
|
||||
— gives a quick read of the preset's full palette. */}
|
||||
<div
|
||||
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
|
||||
style={{ backgroundColor: theme.config.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
|
||||
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
|
||||
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>
|
||||
{theme.config.galleryLayout && layoutIcons[theme.config.galleryLayout] && (
|
||||
<div className="ml-auto text-neutral-400">
|
||||
@@ -331,7 +401,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onClick={() => handleChange('galleryLayout', layout)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -350,7 +420,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</span>
|
||||
</div>
|
||||
{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>
|
||||
))}
|
||||
@@ -678,7 +748,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onClick={() => handleChange('headerStyle', style)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
(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'
|
||||
}`}
|
||||
>
|
||||
@@ -694,7 +764,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</span>
|
||||
</div>
|
||||
{(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>
|
||||
))}
|
||||
@@ -717,7 +787,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onClick={() => handleChange('heroDividerStyle', divider)}
|
||||
className={`relative p-3 rounded-lg border-2 transition-all ${
|
||||
(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'
|
||||
}`}
|
||||
>
|
||||
@@ -731,7 +801,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</span>
|
||||
</div>
|
||||
{(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>
|
||||
))}
|
||||
@@ -757,7 +827,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onClick={() => handleChange('controlsStyle', 'classic')}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
(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'
|
||||
}`}
|
||||
>
|
||||
@@ -773,7 +843,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</span>
|
||||
</div>
|
||||
{(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
|
||||
@@ -781,7 +851,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onClick={() => handleChange('controlsStyle', 'sidebar')}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -797,7 +867,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</span>
|
||||
</div>
|
||||
{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>
|
||||
</div>
|
||||
@@ -815,10 +885,26 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
{/* Color Customization */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<Palette className="w-5 h-5" />
|
||||
{t('branding.colors')}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between gap-2 mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
<Palette className="w-5 h-5" />
|
||||
{t('branding.colors')}
|
||||
</h3>
|
||||
{/* "Sync from Branding" — caller-supplied so the customizer
|
||||
doesn't have to know how to resolve the Branding theme.
|
||||
Used in event create/edit to reset palette to site colours. */}
|
||||
{onSyncFromBranding && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
onClick={onSyncFromBranding}
|
||||
>
|
||||
{t('branding.syncFromBranding', 'Sync from Branding')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color Mode Selector */}
|
||||
<div className="mb-6">
|
||||
@@ -834,25 +920,27 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
handleChange('colorMode', mode);
|
||||
// When switching to dark, auto-populate dark defaults if colors are still light
|
||||
if (mode === 'dark' && (!localTheme.backgroundColor || localTheme.backgroundColor === '#fafafa' || localTheme.backgroundColor === '#ffffff')) {
|
||||
const updated = {
|
||||
const updated: ThemeConfig = {
|
||||
...localTheme,
|
||||
colorMode: mode,
|
||||
backgroundColor: '#0f0f0f',
|
||||
textColor: '#e5e5e5',
|
||||
surfaceColor: '#1a1a1a',
|
||||
elevatedColor: '#242424',
|
||||
surfaceBorderColor: '#2e2e2e',
|
||||
textColor: '#e5e5e5',
|
||||
mutedTextColor: '#a3a3a3',
|
||||
};
|
||||
setLocalTheme(updated);
|
||||
onChange({ ...updated, customCss });
|
||||
} else if (mode === 'light' && localTheme.colorMode === 'dark') {
|
||||
const updated = {
|
||||
const updated: ThemeConfig = {
|
||||
...localTheme,
|
||||
colorMode: mode,
|
||||
backgroundColor: '#fafafa',
|
||||
textColor: '#171717',
|
||||
surfaceColor: '#ffffff',
|
||||
elevatedColor: '#f5f5f5',
|
||||
surfaceBorderColor: '#e5e5e5',
|
||||
textColor: '#171717',
|
||||
mutedTextColor: '#737373',
|
||||
};
|
||||
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 ${
|
||||
(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'
|
||||
}`}
|
||||
>
|
||||
@@ -874,87 +962,214 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('branding.colorModeHelp', 'Auto follows the visitor\'s system preference.')}
|
||||
</p>
|
||||
|
||||
{/*
|
||||
* Force color mode (instance-wide). Lives next to the per-theme
|
||||
* Color Mode picker so the admin can find both controls in one
|
||||
* place. The data flows through props from BrandingPage which
|
||||
* persists it to branding settings; only renders when the
|
||||
* onForceColorModeChange handler is provided (i.e. only on the
|
||||
* Branding admin page, not in event-level theme editors).
|
||||
*/}
|
||||
{onForceColorModeChange && (
|
||||
<div className="mt-5 pt-5 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h4 className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('branding.forceColorMode', 'Force color mode')}
|
||||
</h4>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
|
||||
{t(
|
||||
'branding.forceColorModeHelp',
|
||||
'Lock the entire admin and public site to dark or light. The user-facing dark/light toggle is hidden whenever a lock is active. Per-event themes that try to override the colour mode are also forced to follow.'
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{([
|
||||
{ value: null, label: t('branding.forceColorModeNone', 'No force (user choice)') },
|
||||
{ value: 'dark', label: t('branding.forceColorModeDark', 'Force dark') },
|
||||
{ value: 'light', label: t('branding.forceColorModeLight', 'Force light') },
|
||||
] as const).map(({ value, label }) => {
|
||||
const active = (forceColorMode ?? null) === value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={String(value)}
|
||||
onClick={() => onForceColorModeChange(value)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
||||
active
|
||||
? 'border-accent-dark bg-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 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>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.primaryColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={localTheme.primaryColor || '#5C8762'}
|
||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.primaryColor || '#5C8762'}
|
||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
||||
placeholder="#5C8762"
|
||||
className="flex-1"
|
||||
/>
|
||||
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3 flex items-center gap-1.5">
|
||||
{t('branding.colorGroupSurfaces', 'Surfaces')}
|
||||
<span
|
||||
className="info-tooltip text-neutral-400 dark:text-neutral-500"
|
||||
data-tooltip={t(
|
||||
'branding.colorGroupSurfacesHelp',
|
||||
'The neutral layers behind your content. Background sits furthest back; Surface and Elevated stack on top.'
|
||||
)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
key: 'backgroundColor',
|
||||
label: t('branding.backgroundColor', 'Background'),
|
||||
help: t('branding.backgroundColorHelp', 'The page itself — body background of every gallery, admin page and CMS page.'),
|
||||
fallback: '#fafafa',
|
||||
},
|
||||
{
|
||||
key: 'surfaceColor',
|
||||
label: t('branding.surfaceColor', 'Surface'),
|
||||
help: t('branding.surfaceColorHelp', 'Cards, sidebar, header bar and navigation. The first layer above Background.'),
|
||||
fallback: '#ffffff',
|
||||
},
|
||||
{
|
||||
key: 'elevatedColor',
|
||||
label: t('branding.elevatedColor', 'Elevated'),
|
||||
help: t('branding.elevatedColorHelp', 'Panels that float above cards: image placeholders, hover/active rows, modal headers, code blocks.'),
|
||||
fallback: '#f5f5f5',
|
||||
},
|
||||
{
|
||||
key: 'surfaceBorderColor',
|
||||
label: t('branding.borderColor', 'Border'),
|
||||
help: t('branding.borderColorHelp', 'Dividers, table grid lines, card outlines, input borders.'),
|
||||
fallback: '#e5e5e5',
|
||||
},
|
||||
].map(({ key, label, help, fallback }) => (
|
||||
<ColorPickerRow
|
||||
key={key}
|
||||
label={label}
|
||||
help={help}
|
||||
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
||||
fallback={fallback}
|
||||
onChange={(v) => handleChange(key as keyof ThemeConfig, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.accentColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={localTheme.accentColor || '#22c55e'}
|
||||
onChange={(e) => handleChange('accentColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.accentColor || '#22c55e'}
|
||||
onChange={(e) => handleChange('accentColor', e.target.value)}
|
||||
placeholder="#22c55e"
|
||||
className="flex-1"
|
||||
/>
|
||||
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3 flex items-center gap-1.5">
|
||||
{t('branding.colorGroupText', 'Text')}
|
||||
<span
|
||||
className="info-tooltip text-neutral-400 dark:text-neutral-500"
|
||||
data-tooltip={t(
|
||||
'branding.colorGroupTextHelp',
|
||||
'Foreground text colours. Primary is for everything readers focus on; Secondary is for supporting copy.'
|
||||
)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
key: 'textColor',
|
||||
label: t('branding.textColor', 'Primary text'),
|
||||
help: t('branding.textColorHelp', 'Headlines, body copy, table cells, form input values, navigation labels — the main text colour.'),
|
||||
fallback: '#171717',
|
||||
},
|
||||
{
|
||||
key: 'mutedTextColor',
|
||||
label: t('branding.mutedTextColor', 'Secondary text'),
|
||||
help: t('branding.mutedTextColorHelp', 'Captions, helper text under inputs, table column headers, footer links, dates and metadata.'),
|
||||
fallback: '#737373',
|
||||
},
|
||||
].map(({ key, label, help, fallback }) => (
|
||||
<ColorPickerRow
|
||||
key={key}
|
||||
label={label}
|
||||
help={help}
|
||||
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
||||
fallback={fallback}
|
||||
onChange={(v) => handleChange(key as keyof ThemeConfig, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accent */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.backgroundColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={localTheme.backgroundColor || '#fafafa'}
|
||||
onChange={(e) => handleChange('backgroundColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.backgroundColor || '#fafafa'}
|
||||
onChange={(e) => handleChange('backgroundColor', e.target.value)}
|
||||
placeholder="#fafafa"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.textColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={localTheme.textColor || '#171717'}
|
||||
onChange={(e) => handleChange('textColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.textColor || '#171717'}
|
||||
onChange={(e) => handleChange('textColor', e.target.value)}
|
||||
placeholder="#171717"
|
||||
className="flex-1"
|
||||
/>
|
||||
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3 flex items-center gap-1.5">
|
||||
{t('branding.colorGroupAccent', 'Accent')}
|
||||
<span
|
||||
className="info-tooltip text-neutral-400 dark:text-neutral-500"
|
||||
data-tooltip={t(
|
||||
'branding.colorGroupAccentHelp',
|
||||
'Brand colours that highlight interactive elements. Use a strong colour pair — Accent is for outlines/text, Accent Dark is for filled buttons.'
|
||||
)}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[
|
||||
{
|
||||
key: 'accentColor',
|
||||
label: t('branding.accentColor', 'Accent'),
|
||||
help: t(
|
||||
'branding.accentColorHelp',
|
||||
'Links, icons, focus rings, hover states on primary buttons, active sidebar item underline. Should read clearly on both Background and Surface.'
|
||||
),
|
||||
fallback: '#22c55e',
|
||||
},
|
||||
{
|
||||
key: 'accentDarkColor',
|
||||
label: t('branding.accentDarkColor', 'Accent (filled)'),
|
||||
help: t(
|
||||
'branding.accentDarkColorHelp',
|
||||
'Filled CTA buttons, active sidebar item background, badges and tags. Needs enough contrast for white text to be readable on top.'
|
||||
),
|
||||
fallback: '#5C8762',
|
||||
},
|
||||
].map(({ key, label, help, fallback }) => (
|
||||
<ColorPickerRow
|
||||
key={key}
|
||||
label={label}
|
||||
help={help}
|
||||
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
|
||||
fallback={fallback}
|
||||
onChange={(v) => handleChange(key as keyof ThemeConfig, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* primaryColor is kept in sync with accentDarkColor inside
|
||||
handleChange() — no dedicated picker. */}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -1108,14 +1323,14 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onClick={() => onCssTemplateChange(null)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
|
||||
!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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{t('branding.noTemplate', 'No Template')}</span>
|
||||
{!cssTemplateId && (
|
||||
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
|
||||
<Check className="w-4 h-4 text-accent-dark flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">
|
||||
@@ -1130,14 +1345,14 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onClick={() => onCssTemplateChange(template.id)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{template.name}</span>
|
||||
{cssTemplateId === template.id && (
|
||||
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
|
||||
<Check className="w-4 h-4 text-accent-dark flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">
|
||||
@@ -1161,7 +1376,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
{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:')}
|
||||
</p>
|
||||
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
|
||||
{`--primary-color: ${localTheme.primaryColor || '#5C8762'};
|
||||
--accent-color: ${localTheme.accentColor || '#22c55e'};
|
||||
--background-color: ${localTheme.backgroundColor || '#fafafa'};
|
||||
--text-color: ${localTheme.textColor || '#171717'};
|
||||
{`--color-background: ${localTheme.backgroundColor || '#fafafa'};
|
||||
--color-surface: ${localTheme.surfaceColor || '#ffffff'};
|
||||
--color-elevated: ${localTheme.elevatedColor || '#f5f5f5'};
|
||||
--color-surface-border: ${localTheme.surfaceBorderColor || '#e5e5e5'};
|
||||
--color-text: ${localTheme.textColor || '#171717'};
|
||||
--color-muted-text: ${localTheme.mutedTextColor || '#737373'};
|
||||
--color-accent: ${localTheme.accentColor || '#22c55e'};
|
||||
--color-accent-dark: ${localTheme.accentDarkColor || localTheme.primaryColor || '#5C8762'};
|
||||
--font-family: ${localTheme.fontFamily || 'Inter, sans-serif'};
|
||||
--heading-font: ${localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'};`}
|
||||
</code>
|
||||
|
||||
@@ -93,32 +93,30 @@ export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
||||
|
||||
{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">
|
||||
<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>
|
||||
<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
|
||||
key={i}
|
||||
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
|
||||
style={{ backgroundColor: themeConfig.primaryColor }}
|
||||
title={t('branding.primaryColor')}
|
||||
style={{ backgroundColor: s.value }}
|
||||
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>
|
||||
|
||||
|
||||
@@ -116,20 +116,20 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<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>
|
||||
<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')}
|
||||
</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 })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
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" />
|
||||
</button>
|
||||
@@ -139,7 +139,7 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 h-full">
|
||||
{/* 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
|
||||
value={theme}
|
||||
onChange={handleThemeChange}
|
||||
@@ -155,11 +155,11 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* Grid Style Selector */}
|
||||
<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')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
@@ -169,12 +169,12 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
||||
onClick={() => setPreviewLayout(layout)}
|
||||
className={`relative p-3 rounded-lg border-2 transition-all ${
|
||||
(previewLayout || theme.galleryLayout || 'grid') === layout
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300 bg-white'
|
||||
? 'tile-selected'
|
||||
: '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="text-neutral-700">
|
||||
<div className="text-neutral-700 dark:text-neutral-200">
|
||||
{layoutIcons[layout]}
|
||||
</div>
|
||||
<span className="text-xs capitalize">
|
||||
@@ -185,7 +185,7 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
{(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>
|
||||
))}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
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">
|
||||
<HelpCircle className="w-4 h-4" aria-hidden="true" />
|
||||
|
||||
@@ -300,7 +300,7 @@ export const WordFilterManager: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={filter.is_active}
|
||||
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={`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) {
|
||||
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" />
|
||||
</div>
|
||||
);
|
||||
@@ -80,11 +83,20 @@ export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback
|
||||
<main className="flex-1 flex items-start justify-center px-4">
|
||||
<div className="max-w-2xl w-full">
|
||||
<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}
|
||||
</h1>
|
||||
<div
|
||||
className="prose prose-neutral dark:prose-invert max-w-none"
|
||||
style={{ color: 'var(--color-text)' }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(page.content, {
|
||||
ALLOWED_TAGS,
|
||||
@@ -97,7 +109,8 @@ export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
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'}
|
||||
</Link>
|
||||
@@ -106,13 +119,16 @@ export const CMSContentBlock: React.FC<CMSContentBlockProps> = ({ slug, fallback
|
||||
</div>
|
||||
</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">
|
||||
<Link to="/impressum" className="hover:text-neutral-700">
|
||||
<Link to="/impressum" className="hover:underline">
|
||||
{lang === 'de' ? 'Impressum' : 'Legal Notice'}
|
||||
</Link>
|
||||
<span className="text-neutral-400">•</span>
|
||||
<Link to="/datenschutz" className="hover:text-neutral-700">
|
||||
<span style={{ color: 'var(--color-surface-border)' }}>•</span>
|
||||
<Link to="/datenschutz" className="hover:underline">
|
||||
{lang === 'de' ? 'Datenschutz' : 'Privacy Policy'}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +84,7 @@ export const LanguageSelector: React.FC = () => {
|
||||
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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const Loading: React.FC<LoadingProps> = ({
|
||||
|
||||
const content = (
|
||||
<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 && (
|
||||
<p className="mt-4 text-sm text-neutral-600">{text}</p>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ export const SkipLink: React.FC = () => {
|
||||
return (
|
||||
<a
|
||||
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
|
||||
</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="flex items-start justify-between mb-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>
|
||||
<p className="text-sm font-medium text-theme">{t('download.downloading')}</p>
|
||||
{fileName && (
|
||||
@@ -43,7 +43,7 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
|
||||
|
||||
<div className="w-full bg-black/10 rounded-full h-2">
|
||||
<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}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -168,7 +168,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
||||
<Heart className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="hidden sm:inline">{t('gallery.liked', 'Liked')}</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@@ -183,7 +183,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
||||
<Bookmark className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="hidden sm:inline">{t('gallery.favorited', 'Saved')}</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@@ -198,7 +198,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
||||
<Star className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="hidden sm:inline">{t('gallery.rated', 'Rated')}</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -495,7 +495,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
{t('gallery.needHelp')}{' '}
|
||||
<a
|
||||
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}
|
||||
</a>
|
||||
|
||||
@@ -255,7 +255,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
className={`
|
||||
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
${selectedCategoryId === null
|
||||
? 'bg-primary-600/20 text-primary-500'
|
||||
? 'bg-accent-dark text-white'
|
||||
: 'hover:bg-black/10 text-muted-theme'
|
||||
}
|
||||
`}
|
||||
@@ -278,7 +278,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
className={`
|
||||
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
${isSelected
|
||||
? 'bg-primary-600/20 text-primary-500'
|
||||
? 'bg-accent-dark text-white'
|
||||
: 'hover:bg-black/10 text-muted-theme'
|
||||
}
|
||||
`}
|
||||
@@ -362,7 +362,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
className={`
|
||||
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
|
||||
${isSelected
|
||||
? 'bg-primary-600/20 text-primary-500'
|
||||
? 'bg-accent-dark text-white'
|
||||
: 'hover:bg-black/10 text-muted-theme'
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -341,7 +341,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
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) {
|
||||
// Use setTimeout to ensure this runs after any global theme application
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -145,7 +145,7 @@ export const GuestNamePromptModal: React.FC<GuestNamePromptModalProps> = ({
|
||||
closePrompt();
|
||||
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")}
|
||||
</button>
|
||||
|
||||
@@ -179,7 +179,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
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'
|
||||
}`}
|
||||
rows={4}
|
||||
|
||||
@@ -97,7 +97,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
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')}
|
||||
@@ -108,7 +108,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
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')}
|
||||
@@ -119,7 +119,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
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')}
|
||||
@@ -130,7 +130,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
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')}
|
||||
@@ -141,7 +141,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
setShowSortMenu(false);
|
||||
}}
|
||||
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')}
|
||||
|
||||
@@ -298,7 +298,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||
{(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`}>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -341,7 +341,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
{/* Selection checkbox - Larger on mobile for easier tapping */}
|
||||
{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={`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" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -677,7 +677,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
>
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
{((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 : '★'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -157,7 +157,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
{/* Upload Area */}
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<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" />
|
||||
<p className="text-sm font-medium text-muted-theme mb-1">
|
||||
{t('upload.clickToUpload')}
|
||||
@@ -210,7 +210,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
<div className="w-20">
|
||||
<div className="bg-neutral-200 rounded-full h-2">
|
||||
<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]}%` }}
|
||||
/>
|
||||
</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`}
|
||||
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" />}
|
||||
</div>
|
||||
</button>
|
||||
@@ -341,7 +341,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
)}
|
||||
{commentCount > 0 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -370,7 +370,7 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
|
||||
>
|
||||
<div
|
||||
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`}
|
||||
>
|
||||
{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"
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -102,7 +102,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||
{(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`}>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -245,7 +245,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
}`}
|
||||
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" />}
|
||||
</div>
|
||||
</button>
|
||||
@@ -486,7 +486,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||
{(photo.comment_count ?? 0) > 0 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -542,7 +542,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
}`}
|
||||
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" />}
|
||||
</div>
|
||||
</button>
|
||||
@@ -602,7 +602,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||
{(photo.comment_count ?? 0) > 0 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -658,7 +658,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
}`}
|
||||
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" />}
|
||||
</div>
|
||||
</button>
|
||||
@@ -727,7 +727,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||
{(photo.comment_count ?? 0) > 0 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -783,7 +783,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
}`}
|
||||
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" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -180,7 +180,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
}`}
|
||||
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" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -86,8 +86,8 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
{/* Date marker */}
|
||||
{showDates && (
|
||||
<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">
|
||||
<Calendar className="w-6 h-6 text-primary-600" />
|
||||
<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-accent" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-theme">
|
||||
{group.label}
|
||||
@@ -218,7 +218,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
}`}
|
||||
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" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
|
||||
type DarkModePreference = 'light' | 'dark' | 'system';
|
||||
|
||||
interface AdminDarkModeContextType {
|
||||
preference: DarkModePreference;
|
||||
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;
|
||||
toggle: () => void;
|
||||
}
|
||||
@@ -24,12 +32,25 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
const location = useLocation();
|
||||
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 stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'dark' || stored === 'light' || stored === 'system') return stored;
|
||||
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 applyDarkClass = useCallback((dark: boolean, forceLight = false) => {
|
||||
@@ -42,26 +63,37 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
}, []);
|
||||
|
||||
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);
|
||||
localStorage.setItem(STORAGE_KEY, pref);
|
||||
const dark = resolveIsDark(pref);
|
||||
setIsDark(dark);
|
||||
// Don't apply dark on login page
|
||||
applyDarkClass(dark, isLoginPage);
|
||||
}, [applyDarkClass, isLoginPage]);
|
||||
}, [applyDarkClass, isLoginPage, forcedMode]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (forcedMode) return;
|
||||
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(() => {
|
||||
if (forcedMode) {
|
||||
const dark = forcedMode === 'dark';
|
||||
setIsDark(dark);
|
||||
applyDarkClass(dark, isLoginPage && forcedMode === 'light');
|
||||
return;
|
||||
}
|
||||
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(() => {
|
||||
if (preference !== 'system') return;
|
||||
if (forcedMode || preference !== 'system') return;
|
||||
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
@@ -70,7 +102,7 @@ export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
};
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, [preference, applyDarkClass]);
|
||||
}, [preference, applyDarkClass, forcedMode]);
|
||||
|
||||
// Strip dark class when unmounting (navigating away from admin)
|
||||
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 (
|
||||
<AdminDarkModeContext.Provider value={value}>
|
||||
|
||||
@@ -2,6 +2,8 @@ import React, { createContext, useContext, useState, useEffect, useCallback, use
|
||||
import type { ReactNode } from 'react';
|
||||
import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||
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
|
||||
// 5 minutes) and lazily injects @font-face blocks into <head> only for the
|
||||
@@ -90,8 +92,8 @@ interface ThemeProviderProps {
|
||||
initialThemeName?: string;
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
||||
children,
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
||||
children,
|
||||
initialTheme = GALLERY_THEME_PRESETS.default.config,
|
||||
initialThemeName = 'default'
|
||||
}) => {
|
||||
@@ -99,20 +101,50 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
||||
const [themeName, setThemeName] = useState(initialThemeName);
|
||||
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;
|
||||
|
||||
// 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) {
|
||||
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-dark', darkenColor(themeConfig.primaryColor, 20));
|
||||
}
|
||||
|
||||
|
||||
if (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) {
|
||||
root.style.setProperty('--color-background', themeConfig.backgroundColor);
|
||||
@@ -194,6 +226,16 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
||||
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) {
|
||||
root.style.setProperty('--color-surface-border', themeConfig.surfaceBorderColor);
|
||||
} else if (effectiveMode === 'dark') {
|
||||
@@ -252,7 +294,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
||||
}
|
||||
styleElement.textContent = themeConfig.customCss;
|
||||
}
|
||||
}, []);
|
||||
}, [forcedMode]);
|
||||
|
||||
const setThemeConfig = useCallback((newTheme: ThemeConfig) => {
|
||||
setTheme(newTheme);
|
||||
@@ -272,15 +314,11 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
||||
setThemeByName('default');
|
||||
}, [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(() => {
|
||||
const root = document.documentElement;
|
||||
const currentPrimary = root.style.getPropertyValue('--color-primary');
|
||||
|
||||
// Only apply if the theme has actually changed
|
||||
if (currentPrimary !== theme.primaryColor) {
|
||||
applyTheme(theme);
|
||||
}
|
||||
applyTheme(theme);
|
||||
}, [theme, applyTheme]);
|
||||
|
||||
// Load theme from localStorage on mount (skip if in gallery view)
|
||||
|
||||
+180
-20
@@ -19,29 +19,60 @@
|
||||
|
||||
@layer base {
|
||||
: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-light: #7aa583;
|
||||
--color-primary-dark: #4a6f4f;
|
||||
--color-accent: #22c55e;
|
||||
--color-background: #fafafa;
|
||||
--color-text: #171717;
|
||||
|
||||
--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;
|
||||
--font-size-base: 16px;
|
||||
--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;
|
||||
--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);
|
||||
}
|
||||
@@ -100,33 +131,59 @@
|
||||
}
|
||||
|
||||
@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 {
|
||||
@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);
|
||||
--tw-ring-color: var(--color-accent);
|
||||
--tw-ring-offset-color: var(--color-background);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-primary);
|
||||
background-color: var(--color-accent-dark);
|
||||
color: white;
|
||||
@apply hover:opacity-90 focus-visible:ring-2;
|
||||
@apply hover:opacity-90;
|
||||
}
|
||||
|
||||
|
||||
.btn-primary:hover {
|
||||
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 {
|
||||
background-color: var(--color-surface-border);
|
||||
color: var(--color-text);
|
||||
@apply hover:opacity-80 focus-visible:ring-neutral-400;
|
||||
@apply hover:opacity-80;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
border-color: var(--color-surface-border);
|
||||
background-color: transparent;
|
||||
color: var(--color-muted-text);
|
||||
@apply border hover:opacity-80 focus-visible:ring-neutral-400;
|
||||
@apply border hover:opacity-80;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@@ -141,7 +198,13 @@
|
||||
@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 {
|
||||
@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;
|
||||
@@ -163,7 +226,10 @@
|
||||
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 {
|
||||
@apply rounded-xl border bg-white border-neutral-200;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
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-color: var(--color-surface-border);
|
||||
}
|
||||
@@ -208,6 +296,77 @@
|
||||
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 */
|
||||
.skeleton {
|
||||
@apply animate-pulse rounded-lg bg-neutral-200;
|
||||
@@ -281,7 +440,8 @@
|
||||
@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 {
|
||||
@apply bg-neutral-700 border-neutral-600 text-neutral-100;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
setTheme(themeToApply);
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ export const AdminDashboard: React.FC = () => {
|
||||
{expiringTotal > 5 && (
|
||||
<button
|
||||
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 })} →
|
||||
</button>
|
||||
|
||||
@@ -203,7 +203,7 @@ export const AdminLoginPage: React.FC = () => {
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
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>
|
||||
</label>
|
||||
|
||||
@@ -454,7 +454,7 @@ export const AnalyticsPage: React.FC = () => {
|
||||
? 'bg-red-600'
|
||||
: usagePercent >= 90
|
||||
? 'bg-amber-500'
|
||||
: 'bg-primary-600';
|
||||
: 'bg-accent-dark';
|
||||
const limitDescriptor = storageInfo
|
||||
? storageInfo.soft_limit_configured
|
||||
? t('admin.storageSoftLimitConfigured', { limit: limitDisplay })
|
||||
|
||||
@@ -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-2xl font-bold text-neutral-900 dark:text-neutral-100">{archives.length}</p>
|
||||
</div>
|
||||
<Archive className="w-8 h-8 text-primary-600" />
|
||||
<Archive className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -212,7 +212,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
<select
|
||||
value={filterType}
|
||||
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="wedding">{t('archives.wedding')}</option>
|
||||
@@ -225,7 +225,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
<select
|
||||
value={sortBy}
|
||||
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="name">{t('archives.sortByName')}</option>
|
||||
|
||||
@@ -197,7 +197,7 @@ export const BackupManagement = () => {
|
||||
className={`
|
||||
py-2 px-1 border-b-2 font-medium text-sm flex items-center space-x-2
|
||||
${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'
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -31,6 +31,7 @@ export const BrandingPage: React.FC = () => {
|
||||
logo_display_hero: true,
|
||||
logo_display_mode: 'logo_and_text',
|
||||
hide_powered_by: false,
|
||||
force_color_mode: null,
|
||||
});
|
||||
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
|
||||
@@ -128,6 +129,21 @@ export const BrandingPage: React.FC = () => {
|
||||
setBrandingSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Force color mode is the only branding setting that auto-saves on click —
|
||||
* users expect a toggle that takes effect immediately, not a setting they
|
||||
* have to remember to click "Save" for. We keep all other branding fields
|
||||
* on the bulk-save flow because typing in a text input shouldn't trigger
|
||||
* a network round-trip per keystroke. Auto-save here invalidates the
|
||||
* public-settings query so AdminDarkModeContext reapplies live without
|
||||
* waiting for its 30-second poll.
|
||||
*/
|
||||
const handleForceColorModeChange = (value: 'dark' | 'light' | null) => {
|
||||
const next = { ...brandingSettings, force_color_mode: value };
|
||||
setBrandingSettings(next);
|
||||
brandingMutation.mutate(next);
|
||||
};
|
||||
|
||||
const handleThemeChange = (newTheme: ThemeConfig) => {
|
||||
// Preset configs don't carry a logoUrl, so a preset change inside the
|
||||
// customizer arrives here with newTheme.logoUrl=undefined. Keep the
|
||||
@@ -478,7 +494,7 @@ export const BrandingPage: React.FC = () => {
|
||||
onClick={() => handleBrandingChange('logo_position', position)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -511,7 +527,7 @@ export const BrandingPage: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={brandingSettings.logo_display_header !== false}
|
||||
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>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
@@ -528,7 +544,7 @@ export const BrandingPage: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={brandingSettings.logo_display_hero !== false}
|
||||
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>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
@@ -551,7 +567,7 @@ export const BrandingPage: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={brandingSettings.hide_powered_by === true}
|
||||
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>
|
||||
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
@@ -570,7 +586,7 @@ export const BrandingPage: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={brandingSettings.watermark_enabled}
|
||||
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>
|
||||
<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)}
|
||||
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -732,7 +748,7 @@ export const BrandingPage: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={isPreviewMode}
|
||||
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>
|
||||
</label>
|
||||
@@ -747,6 +763,8 @@ export const BrandingPage: React.FC = () => {
|
||||
onPresetChange={handlePresetChange}
|
||||
showGalleryLayouts={true}
|
||||
hideActions={true}
|
||||
forceColorMode={brandingSettings.force_color_mode ?? null}
|
||||
onForceColorModeChange={handleForceColorModeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -381,7 +381,7 @@ export const CMSPage: React.FC = () => {
|
||||
<Card className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<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" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">{t('settings.publicSite.badge')}</span>
|
||||
</div>
|
||||
@@ -398,7 +398,7 @@ export const CMSPage: React.FC = () => {
|
||||
<span
|
||||
aria-hidden="true"
|
||||
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
|
||||
@@ -422,11 +422,11 @@ export const CMSPage: React.FC = () => {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<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')}
|
||||
</label>
|
||||
<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}
|
||||
onChange={(event) => setPublicSiteHtml(event.target.value)}
|
||||
disabled={!publicSiteEnabled}
|
||||
@@ -439,11 +439,11 @@ export const CMSPage: React.FC = () => {
|
||||
|
||||
<div>
|
||||
<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')}
|
||||
</label>
|
||||
<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}
|
||||
onChange={(event) => setPublicSiteCss(event.target.value)}
|
||||
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>
|
||||
</div>
|
||||
{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
|
||||
title="public-site-preview"
|
||||
sandbox="allow-same-origin"
|
||||
@@ -523,10 +523,10 @@ export const CMSPage: React.FC = () => {
|
||||
}
|
||||
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
|
||||
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 border border-primary-300 dark:border-primary-700'
|
||||
: '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'
|
||||
? 'tile-selected'
|
||||
: '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" />
|
||||
@@ -554,7 +554,7 @@ export const CMSPage: React.FC = () => {
|
||||
href={`${window.location.origin}/${selectedPage}?lang=en`}
|
||||
target="_blank"
|
||||
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" />
|
||||
{t('cms.englishVersion')}
|
||||
@@ -563,7 +563,7 @@ export const CMSPage: React.FC = () => {
|
||||
href={`${window.location.origin}/${selectedPage}?lang=de`}
|
||||
target="_blank"
|
||||
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" />
|
||||
{t('cms.germanVersion')}
|
||||
@@ -576,7 +576,7 @@ export const CMSPage: React.FC = () => {
|
||||
<Card padding="md" className="mt-4">
|
||||
<div className="text-sm">
|
||||
{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" />
|
||||
Auto-saving...
|
||||
</div>
|
||||
@@ -612,7 +612,7 @@ export const CMSPage: React.FC = () => {
|
||||
onClick={() => setEditingLang('en')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -622,7 +622,7 @@ export const CMSPage: React.FC = () => {
|
||||
onClick={() => setEditingLang('de')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -637,7 +637,7 @@ export const CMSPage: React.FC = () => {
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
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}
|
||||
onChange={(e) => handleUseExternalUrlChange(e.target.checked)}
|
||||
/>
|
||||
|
||||
@@ -242,6 +242,9 @@ export const CreateEventPage: React.FC = () => {
|
||||
|
||||
// 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).
|
||||
// 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);
|
||||
useEffect(() => {
|
||||
if (brandingThemeApplied.current) return;
|
||||
@@ -497,7 +500,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
onClick={() => setFormData({ ...formData, event_type: type.value })}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -597,6 +600,36 @@ export const CreateEventPage: React.FC = () => {
|
||||
onPresetChange={handlePresetChange}
|
||||
showGalleryLayouts={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 */}
|
||||
@@ -627,7 +660,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
onClick={() => setFormData({ ...formData, css_template_id: null })}
|
||||
className={`p-4 rounded-lg border-2 transition-all text-left ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -645,7 +678,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
onClick={() => setFormData({ ...formData, css_template_id: template.id })}
|
||||
className={`p-4 rounded-lg border-2 transition-all text-left ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -724,7 +757,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
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>
|
||||
{activeAdmins.map(a => (
|
||||
@@ -741,7 +774,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
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}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
@@ -892,7 +925,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
<select
|
||||
value={formData.default_photo_sort}
|
||||
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_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">
|
||||
<input
|
||||
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}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
@@ -948,7 +981,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={formData.allow_user_uploads}
|
||||
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>
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
|
||||
@@ -18,7 +18,7 @@ import { toast } from 'react-toastify';
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
|
||||
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 { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
@@ -110,8 +110,19 @@ export const EmailConfigPage: React.FC = () => {
|
||||
htmlContent: '',
|
||||
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 [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();
|
||||
|
||||
// SMTP Configuration state
|
||||
@@ -142,6 +153,12 @@ export const EmailConfigPage: React.FC = () => {
|
||||
if (allSettings) {
|
||||
if (allSettings.email_primary_color) setEmailPrimaryColor(allSettings.email_primary_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]);
|
||||
|
||||
@@ -213,7 +230,7 @@ export const EmailConfigPage: React.FC = () => {
|
||||
});
|
||||
|
||||
const saveEmailColorsMutation = useMutation({
|
||||
mutationFn: (colors: { email_primary_color: string; email_secondary_color: string }) =>
|
||||
mutationFn: (colors: Record<string, string>) =>
|
||||
settingsService.updateSettings(colors),
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.saveSuccess'));
|
||||
@@ -228,9 +245,53 @@ export const EmailConfigPage: React.FC = () => {
|
||||
saveEmailColorsMutation.mutate({
|
||||
email_primary_color: emailPrimaryColor,
|
||||
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 = () => {
|
||||
// Validate SMTP config
|
||||
if (!smtpConfig.smtp_host || !smtpConfig.smtp_port || !smtpConfig.from_email) {
|
||||
@@ -353,7 +414,7 @@ export const EmailConfigPage: React.FC = () => {
|
||||
onClick={() => setActiveTab('smtp')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -363,7 +424,7 @@ export const EmailConfigPage: React.FC = () => {
|
||||
onClick={() => setActiveTab('templates')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -412,7 +473,7 @@ export const EmailConfigPage: React.FC = () => {
|
||||
<select
|
||||
value={smtpConfig.smtp_secure ? 'ssl' : 'tls'}
|
||||
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="ssl">SSL</option>
|
||||
@@ -427,7 +488,7 @@ export const EmailConfigPage: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={!smtpConfig.tls_reject_unauthorized}
|
||||
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">
|
||||
{t('email.ignoreSslErrors')}
|
||||
@@ -579,56 +640,66 @@ export const EmailConfigPage: React.FC = () => {
|
||||
{activeTab === 'smtp' && (
|
||||
<div className="mt-6">
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<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 className="flex items-center justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<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>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('email.primaryColor')}
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{t('email.primaryColorHint')}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={emailPrimaryColor}
|
||||
onChange={(e) => setEmailPrimaryColor(e.target.value)}
|
||||
className="w-10 h-10 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={emailPrimaryColor}
|
||||
onChange={(e) => setEmailPrimaryColor(e.target.value)}
|
||||
className="w-32"
|
||||
placeholder="#5C8762"
|
||||
/>
|
||||
{[
|
||||
{ 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' },
|
||||
{ label: t('email.secondaryColor', 'Footer background'), help: t('email.secondaryColorHelp', 'Footer bar background. Maps to Branding → Surface.'), value: emailSecondaryColor, setter: setEmailSecondaryColor, fallback: '#f9f9f9' },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
].map(({ label, help, value, setter, fallback }) => (
|
||||
<div key={label}>
|
||||
<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 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>
|
||||
<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 className="mt-6">
|
||||
@@ -664,7 +735,7 @@ export const EmailConfigPage: React.FC = () => {
|
||||
}}
|
||||
className={`w-full text-left p-3 rounded-lg transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -720,7 +791,7 @@ export const EmailConfigPage: React.FC = () => {
|
||||
onClick={() => setEditingLang(lang.code)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-1.5 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -99,7 +99,7 @@ const FolderTreeNode: React.FC<{
|
||||
const rowClass =
|
||||
'flex items-center gap-1 py-1 pr-1 rounded ' +
|
||||
(isSelected
|
||||
? 'bg-primary-50 dark:bg-primary-900/30'
|
||||
? 'bg-accent-dark/15'
|
||||
: 'hover:bg-neutral-50 dark:hover:bg-neutral-700');
|
||||
|
||||
return (
|
||||
@@ -119,12 +119,12 @@ const FolderTreeNode: React.FC<{
|
||||
className={
|
||||
'flex items-center gap-1.5 flex-1 min-w-0 text-left text-sm ' +
|
||||
(isSelected
|
||||
? 'text-primary-700 dark:text-primary-300 font-medium'
|
||||
? 'text-accent-dark font-medium'
|
||||
: 'text-neutral-900 dark:text-neutral-100')
|
||||
}
|
||||
>
|
||||
{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" />
|
||||
)}
|
||||
@@ -924,7 +924,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
}
|
||||
target="_blank"
|
||||
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" />
|
||||
{t('events.viewGallery')}
|
||||
@@ -1006,7 +1006,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -1016,7 +1016,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
onClick={() => setActiveTab('photos')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -1032,7 +1032,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
onClick={() => setActiveTab('categories')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -1043,7 +1043,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
onClick={() => setActiveTab('guests')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -1071,7 +1071,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<textarea
|
||||
value={editForm.welcome_message}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, welcome_message: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-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}
|
||||
placeholder={t('events.welcomeMessage')}
|
||||
/>
|
||||
@@ -1162,7 +1162,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
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}
|
||||
onChange={(e) => {
|
||||
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="reference">{t('events.sourceModeReference', 'Reference external folder')}</option>
|
||||
@@ -1288,7 +1288,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
value={editForm.photo_cap}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, photo_cap: parseInt(e.target.value) || 0 }))}
|
||||
min={0}
|
||||
className="w-24 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-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">
|
||||
{t('events.photoCapHelp', 'Maximum number of photos allowed. 0 = unlimited')}
|
||||
@@ -1304,7 +1304,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<select
|
||||
value={editForm.default_photo_sort}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, default_photo_sort: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-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_asc">{t('photoSort.uploadDateOldest', 'Upload Date (Oldest First)')}</option>
|
||||
@@ -1321,7 +1321,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={editForm.allow_user_uploads}
|
||||
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>
|
||||
</label>
|
||||
@@ -1341,7 +1341,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
...prev,
|
||||
upload_category_id: e.target.value ? parseInt(e.target.value) : null
|
||||
}))}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-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>
|
||||
{categories?.map(category => (
|
||||
@@ -1368,7 +1368,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
{/* Download Protection Settings */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-primary-600" />
|
||||
<Shield className="w-4 h-4 text-accent" />
|
||||
{t('events.downloadProtection', 'Download Protection')}
|
||||
</h3>
|
||||
|
||||
@@ -1378,7 +1378,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={editForm.allow_downloads}
|
||||
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" />
|
||||
<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"
|
||||
checked={editForm.disable_right_click}
|
||||
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" />
|
||||
<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.
|
||||
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" />
|
||||
<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}
|
||||
disabled={editForm.watermark_downloads}
|
||||
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" />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
@@ -1438,7 +1438,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={editForm.enable_devtools_protection}
|
||||
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" />
|
||||
<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"
|
||||
checked={editForm.use_canvas_rendering}
|
||||
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" />
|
||||
<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 */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
|
||||
<Layout className="w-4 h-4 text-primary-600" />
|
||||
<Layout className="w-4 h-4 text-accent" />
|
||||
{t('events.heroLogoSettings', 'Hero Logo Settings')}
|
||||
</h3>
|
||||
|
||||
@@ -1474,7 +1474,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={editForm.hero_logo_visible}
|
||||
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" />
|
||||
<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
|
||||
value={editForm.hero_logo_size}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_size: e.target.value as 'small' | 'medium' | 'large' | 'xlarge' }))}
|
||||
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md shadow-sm focus:ring-primary-500 focus:border-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="medium">{t('events.heroLogoSizeMedium', 'Medium')}</option>
|
||||
@@ -1505,7 +1505,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<select
|
||||
value={editForm.hero_logo_position}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_position: e.target.value as 'top' | 'center' | 'bottom' }))}
|
||||
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md shadow-sm focus:ring-primary-500 focus:border-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="center">{t('events.heroLogoPositionCenter', 'Center (between title and dates)')}</option>
|
||||
@@ -1532,7 +1532,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<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" />
|
||||
{t('events.replaceLogo', 'Replace')}
|
||||
<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>
|
||||
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{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>
|
||||
)}
|
||||
@@ -1846,7 +1846,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
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}
|
||||
onChange={async (e) => {
|
||||
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}
|
||||
showGalleryLayouts={true}
|
||||
hideActions={true}
|
||||
|
||||
@@ -192,7 +192,7 @@ export const EventFeedbackPage: React.FC = () => {
|
||||
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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -153,7 +153,7 @@ export const EventTypesPage: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
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">
|
||||
{t('eventTypes.showInactive', 'Show inactive')}
|
||||
@@ -241,7 +241,7 @@ export const EventTypesPage: React.FC = () => {
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
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')}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
@@ -432,7 +432,7 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
||||
onClick={() => setForm({ ...form, emoji })}
|
||||
className={`p-2 text-xl rounded-lg border-2 transition-all ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -450,7 +450,7 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
||||
<select
|
||||
value={form.theme_preset}
|
||||
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]) => (
|
||||
<option key={key} value={key}>
|
||||
@@ -467,7 +467,7 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
||||
type="checkbox"
|
||||
checked={eventType?.is_active}
|
||||
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">
|
||||
{t('eventTypes.form.isActive', 'Active (visible in event creation)')}
|
||||
|
||||
@@ -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-2xl font-bold text-neutral-900 dark:text-neutral-100">{dashboardStats?.totalEvents ?? 0}</p>
|
||||
</div>
|
||||
<Calendar className="w-8 h-8 text-primary-600" />
|
||||
<Calendar className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -423,8 +423,8 @@ export const EventsListPage: React.FC = () => {
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{selectedEvents.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-primary-50 dark:bg-primary-900/30 rounded-lg flex items-center justify-between">
|
||||
<span className="text-sm text-primary-900 dark:text-primary-100">
|
||||
<div className="mt-4 p-3 bg-accent-dark/15 rounded-lg flex items-center justify-between">
|
||||
<span className="text-sm text-accent-dark">
|
||||
{t('events.eventsSelected', { count: selectedEvents.length })}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
@@ -465,7 +465,7 @@ export const EventsListPage: React.FC = () => {
|
||||
type="checkbox"
|
||||
checked={selectedEvents.length === events.length && events.length > 0}
|
||||
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 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"
|
||||
checked={selectedEvents.includes(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 className="px-6 py-4">
|
||||
|
||||
@@ -202,14 +202,14 @@ export const SettingsPage: React.FC = () => {
|
||||
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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-4 h-4 flex-shrink-0 ${
|
||||
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'
|
||||
}`}
|
||||
/>
|
||||
@@ -229,7 +229,7 @@ export const SettingsPage: React.FC = () => {
|
||||
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="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">
|
||||
{activeItem.label}
|
||||
</h2>
|
||||
|
||||
@@ -140,7 +140,7 @@ const CreateInvitationModal: React.FC<CreateInvitationModalProps> = ({
|
||||
setRoleId(e.target.value ? Number(e.target.value) : '');
|
||||
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}
|
||||
>
|
||||
<option value="">{t('userManagement.selectRole')}</option>
|
||||
@@ -253,7 +253,7 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
|
||||
<select
|
||||
value={roleId}
|
||||
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}
|
||||
>
|
||||
<option value="">{t('userManagement.selectRole')}</option>
|
||||
@@ -608,7 +608,7 @@ export const UserManagementPage: React.FC = () => {
|
||||
{users?.length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Users className="w-8 h-8 text-primary-600" />
|
||||
<Users className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -664,7 +664,7 @@ export const UserManagementPage: React.FC = () => {
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -672,7 +672,7 @@ export const UserManagementPage: React.FC = () => {
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -740,8 +740,8 @@ export const UserManagementPage: React.FC = () => {
|
||||
<tr key={user.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700/50">
|
||||
<td className="px-6 py-4">
|
||||
<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">
|
||||
<span className="text-primary-700 dark:text-primary-300 font-medium text-sm">
|
||||
<div className="w-10 h-10 rounded-full bg-accent-dark/15 flex items-center justify-center">
|
||||
<span className="text-accent-dark font-medium text-sm">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -794,7 +794,7 @@ export const UserManagementPage: React.FC = () => {
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
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')}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
|
||||
@@ -138,7 +138,7 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
onClick={() => setFilter(s)}
|
||||
className={`text-xs px-3 py-1 rounded-full ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -19,6 +19,12 @@ export interface PublicSettings {
|
||||
branding_logo_display_header?: boolean;
|
||||
branding_logo_display_hero?: 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;
|
||||
default_language: string;
|
||||
enable_analytics: boolean;
|
||||
|
||||
@@ -19,6 +19,12 @@ export interface BrandingSettings {
|
||||
logo_display_hero?: boolean;
|
||||
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
|
||||
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 {
|
||||
@@ -288,7 +294,12 @@ export const settingsService = {
|
||||
logo_display_header: this._parseBoolean(rawSettings.branding_logo_display_header, true),
|
||||
logo_display_hero: this._parseBoolean(rawSettings.branding_logo_display_hero, true),
|
||||
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
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -27,9 +27,14 @@
|
||||
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 {
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--color-elevated, #f5f5f5);
|
||||
border: 1px solid var(--color-surface-border, #e5e5e5);
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
@@ -45,13 +50,20 @@
|
||||
|
||||
/* Inline code styling */
|
||||
.prose code {
|
||||
background-color: #f5f5f5;
|
||||
background-color: var(--color-elevated, #f5f5f5);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875em;
|
||||
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 */
|
||||
.prose .text-left {
|
||||
text-align: left !important;
|
||||
|
||||
@@ -53,13 +53,27 @@ export interface GalleryLayoutSettings {
|
||||
}
|
||||
|
||||
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;
|
||||
accentColor?: string;
|
||||
accentDarkColor?: string;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
surfaceColor?: string;
|
||||
elevatedColor?: string;
|
||||
surfaceBorderColor?: string;
|
||||
textColor?: string;
|
||||
mutedTextColor?: string;
|
||||
|
||||
// Color Mode
|
||||
@@ -115,8 +129,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
config: {
|
||||
primaryColor: '#5C8762',
|
||||
accentColor: '#22c55e',
|
||||
accentDarkColor: '#5C8762',
|
||||
backgroundColor: '#fafafa',
|
||||
surfaceColor: '#ffffff',
|
||||
elevatedColor: '#f5f5f5',
|
||||
surfaceBorderColor: '#e5e5e5',
|
||||
textColor: '#171717',
|
||||
mutedTextColor: '#737373',
|
||||
borderRadius: 'md',
|
||||
galleryLayout: 'grid',
|
||||
gallerySettings: {
|
||||
@@ -136,8 +155,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
config: {
|
||||
primaryColor: '#c9a961',
|
||||
accentColor: '#e6ddd4',
|
||||
accentDarkColor: '#c9a961',
|
||||
backgroundColor: '#fdfcfb',
|
||||
surfaceColor: '#ffffff',
|
||||
elevatedColor: '#faf6f0',
|
||||
surfaceBorderColor: '#e8e0d4',
|
||||
textColor: '#3f3f3f',
|
||||
mutedTextColor: '#7a7a7a',
|
||||
fontFamily: 'Playfair Display, serif',
|
||||
headingFontFamily: 'Playfair Display, serif',
|
||||
borderRadius: 'lg',
|
||||
@@ -163,8 +187,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
config: {
|
||||
primaryColor: '#3b82f6',
|
||||
accentColor: '#1e40af',
|
||||
accentDarkColor: '#3b82f6',
|
||||
backgroundColor: '#ffffff',
|
||||
surfaceColor: '#ffffff',
|
||||
elevatedColor: '#f8fafc',
|
||||
surfaceBorderColor: '#e2e8f0',
|
||||
textColor: '#0f172a',
|
||||
mutedTextColor: '#64748b',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
borderRadius: 'sm',
|
||||
galleryLayout: 'masonry',
|
||||
@@ -189,8 +218,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
config: {
|
||||
primaryColor: '#ec4899',
|
||||
accentColor: '#fbbf24',
|
||||
accentDarkColor: '#ec4899',
|
||||
backgroundColor: '#fef3c7',
|
||||
surfaceColor: '#ffffff',
|
||||
elevatedColor: '#fef9e3',
|
||||
surfaceBorderColor: '#fde68a',
|
||||
textColor: '#451a03',
|
||||
mutedTextColor: '#92400e',
|
||||
fontFamily: 'Comic Neue, cursive',
|
||||
borderRadius: 'lg',
|
||||
galleryLayout: 'carousel',
|
||||
@@ -214,8 +248,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
config: {
|
||||
primaryColor: '#1f2937',
|
||||
accentColor: '#059669',
|
||||
accentDarkColor: '#1f2937',
|
||||
backgroundColor: '#f9fafb',
|
||||
surfaceColor: '#ffffff',
|
||||
elevatedColor: '#f3f4f6',
|
||||
surfaceBorderColor: '#e5e7eb',
|
||||
textColor: '#111827',
|
||||
mutedTextColor: '#6b7280',
|
||||
fontFamily: 'IBM Plex Sans, sans-serif',
|
||||
borderRadius: 'sm',
|
||||
galleryLayout: 'timeline',
|
||||
@@ -238,8 +277,13 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
config: {
|
||||
primaryColor: '#7c3aed',
|
||||
accentColor: '#f59e0b',
|
||||
accentDarkColor: '#7c3aed',
|
||||
backgroundColor: '#faf5ff',
|
||||
surfaceColor: '#ffffff',
|
||||
elevatedColor: '#f3e8ff',
|
||||
surfaceBorderColor: '#e9d5ff',
|
||||
textColor: '#1e1b4b',
|
||||
mutedTextColor: '#6b7280',
|
||||
fontFamily: 'Montserrat, sans-serif',
|
||||
borderRadius: 'none',
|
||||
galleryLayout: 'mosaic',
|
||||
@@ -261,9 +305,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
config: {
|
||||
primaryColor: '#5C8762',
|
||||
accentColor: '#22c55e',
|
||||
accentDarkColor: '#5C8762',
|
||||
backgroundColor: '#0f0f0f',
|
||||
textColor: '#e5e5e5',
|
||||
surfaceColor: '#1a1a1a',
|
||||
elevatedColor: '#242424',
|
||||
surfaceBorderColor: '#2e2e2e',
|
||||
mutedTextColor: '#a3a3a3',
|
||||
colorMode: 'dark',
|
||||
@@ -287,9 +333,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
config: {
|
||||
primaryColor: '#c9a961',
|
||||
accentColor: '#e6ddd4',
|
||||
accentDarkColor: '#c9a961',
|
||||
backgroundColor: '#121212',
|
||||
textColor: '#f0ebe5',
|
||||
surfaceColor: '#1e1e1e',
|
||||
elevatedColor: '#262626',
|
||||
surfaceBorderColor: '#333333',
|
||||
mutedTextColor: '#a3a3a3',
|
||||
colorMode: 'dark',
|
||||
@@ -317,9 +365,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
config: {
|
||||
primaryColor: '#3b82f6',
|
||||
accentColor: '#1e40af',
|
||||
accentDarkColor: '#3b82f6',
|
||||
backgroundColor: '#0a0a0a',
|
||||
textColor: '#f5f5f5',
|
||||
surfaceColor: '#171717',
|
||||
elevatedColor: '#1f1f1f',
|
||||
surfaceBorderColor: '#262626',
|
||||
mutedTextColor: '#a3a3a3',
|
||||
colorMode: 'dark',
|
||||
@@ -345,9 +395,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
config: {
|
||||
primaryColor: '#18181b',
|
||||
accentColor: '#ef4444',
|
||||
accentDarkColor: '#18181b',
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#18181b',
|
||||
surfaceColor: '#ffffff',
|
||||
elevatedColor: '#fafafa',
|
||||
surfaceBorderColor: '#f4f4f5',
|
||||
mutedTextColor: '#71717a',
|
||||
colorMode: 'light',
|
||||
@@ -372,9 +424,11 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
config: {
|
||||
primaryColor: '#c9a961',
|
||||
accentColor: '#c9a961',
|
||||
accentDarkColor: '#a88c4a',
|
||||
backgroundColor: '#0d0d0d',
|
||||
textColor: '#f2f2f2',
|
||||
surfaceColor: '#1a1a1a',
|
||||
elevatedColor: '#222222',
|
||||
surfaceBorderColor: '#262626',
|
||||
mutedTextColor: '#a3a3a3',
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,142 @@
|
||||
import type { ThemeConfig, HeaderStyleType, HeroDividerStyle, GalleryLayoutType } from '../types/theme.types';
|
||||
|
||||
/**
|
||||
* Migrates legacy theme configurations that used 'hero' as a galleryLayout
|
||||
* to the new decoupled headerStyle + galleryLayout system.
|
||||
* Surface defaults for the two color modes — the same values applyTheme()
|
||||
* falls back to when a theme has no explicit surface/elevated/border/text
|
||||
* tokens. Exposed here so the force-color-mode helper can swap them
|
||||
* wholesale when an admin locks the instance to a mode that the active
|
||||
* theme doesn't natively support.
|
||||
*/
|
||||
const DARK_SURFACE_DEFAULTS = {
|
||||
backgroundColor: '#0f0f0f',
|
||||
surfaceColor: '#1a1a1a',
|
||||
elevatedColor: '#242424',
|
||||
surfaceBorderColor: '#2e2e2e',
|
||||
textColor: '#e5e5e5',
|
||||
mutedTextColor: '#a3a3a3',
|
||||
};
|
||||
|
||||
const LIGHT_SURFACE_DEFAULTS = {
|
||||
backgroundColor: '#fafafa',
|
||||
surfaceColor: '#ffffff',
|
||||
elevatedColor: '#f5f5f5',
|
||||
surfaceBorderColor: '#e5e5e5',
|
||||
textColor: '#171717',
|
||||
mutedTextColor: '#737373',
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply an instance-wide force color mode lock to a theme config.
|
||||
*
|
||||
* This ensures backward compatibility with existing events that have
|
||||
* 'hero' set as their galleryLayout.
|
||||
* If the theme already matches the locked mode (or no lock is set), only
|
||||
* the colorMode flag is pinned. If the theme is locked to a mode it
|
||||
* doesn't natively support (e.g. an admin set Force Dark but is opening
|
||||
* a light gallery preset), the surface/text tokens are replaced with the
|
||||
* matching mode's defaults — the user's accent/accentDark colours are
|
||||
* preserved so brand identity survives the flip.
|
||||
*
|
||||
* Centralised here so GlobalThemeProvider, GalleryPage and GalleryView
|
||||
* stay in sync (#397 follow-up: galleries did not visibly flip when
|
||||
* Force Dark/Light was toggled because only colorMode was overridden,
|
||||
* leaving the original light/dark surface colours in place).
|
||||
*/
|
||||
export function applyForceColorMode(
|
||||
theme: ThemeConfig,
|
||||
forced: 'dark' | 'light' | null | undefined
|
||||
): ThemeConfig {
|
||||
if (!forced) return theme;
|
||||
|
||||
const themeMode = theme.colorMode === 'auto'
|
||||
? (typeof window !== 'undefined'
|
||||
&& window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light')
|
||||
: (theme.colorMode || 'light');
|
||||
|
||||
if (themeMode === forced) {
|
||||
return { ...theme, colorMode: forced };
|
||||
}
|
||||
|
||||
const surfaces = forced === 'dark' ? DARK_SURFACE_DEFAULTS : LIGHT_SURFACE_DEFAULTS;
|
||||
return {
|
||||
...theme,
|
||||
...surfaces,
|
||||
colorMode: forced,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills in any missing 8-token CI palette fields on legacy themes that were
|
||||
* 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 {
|
||||
if (!theme) return theme;
|
||||
|
||||
let migrated = theme;
|
||||
|
||||
// Check if this theme uses the legacy 'hero' layout
|
||||
if ((theme.galleryLayout as string) === 'hero') {
|
||||
return {
|
||||
...theme,
|
||||
if ((migrated.galleryLayout as string) === 'hero') {
|
||||
migrated = {
|
||||
...migrated,
|
||||
headerStyle: 'hero' as HeaderStyleType,
|
||||
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 (!theme.headerStyle && theme.galleryLayout) {
|
||||
return {
|
||||
...theme,
|
||||
if (!migrated.headerStyle && migrated.galleryLayout) {
|
||||
migrated = {
|
||||
...migrated,
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,19 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
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: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
|
||||
@@ -215,3 +215,58 @@ test.describe('Gallery Theme Color Mode', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user