feat: add admin dark mode and SEO/robots.txt settings

Admin Dark Mode:
- Add AdminDarkModeContext with light/dark/system preference
- Update all admin components with Tailwind dark: classes
- Add dark mode toggle in admin header
- Persist preference in localStorage

SEO Settings:
- Add robots.txt configuration in Settings > SEO tab
- Block AI crawlers (GPTBot, ChatGPT-User, etc.) with toggle
- Custom robots.txt rules management
- Add RobotsMetaTags component for gallery pages
- Backend service for dynamic robots.txt generation
- Database migration for SEO settings storage

UI/UX Improvements:
- Consistent dark mode styling across all admin pages
- Update gallery components with themed CSS classes
- Fix input, card, and button styling for dark mode
This commit is contained in:
Paul Nothaft
2026-02-06 23:26:01 +01:00
parent 4912e2bccf
commit 9c2a0d272a
79 changed files with 2467 additions and 1475 deletions
@@ -0,0 +1,97 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
type DarkModePreference = 'light' | 'dark' | 'system';
interface AdminDarkModeContextType {
preference: DarkModePreference;
isDark: boolean;
setPreference: (pref: DarkModePreference) => void;
toggle: () => void;
}
const AdminDarkModeContext = createContext<AdminDarkModeContextType | undefined>(undefined);
const STORAGE_KEY = 'admin-dark-mode';
function resolveIsDark(pref: DarkModePreference): boolean {
if (pref === 'dark') return true;
if (pref === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const location = useLocation();
const isLoginPage = location.pathname === '/admin/login';
const [preference, setPreferenceState] = useState<DarkModePreference>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'dark' || stored === 'light' || stored === 'system') return stored;
return 'light';
});
const [isDark, setIsDark] = useState(() => resolveIsDark(preference));
const applyDarkClass = useCallback((dark: boolean, forceLight = false) => {
const root = document.documentElement;
if (dark && !forceLight) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}, []);
const setPreference = useCallback((pref: DarkModePreference) => {
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]);
const toggle = useCallback(() => {
setPreference(isDark ? 'light' : 'dark');
}, [isDark, setPreference]);
// Apply on mount and when route changes - skip dark mode on login page
useEffect(() => {
applyDarkClass(isDark, isLoginPage);
}, [applyDarkClass, isDark, isLoginPage]);
// Listen for system changes when preference is 'system'
useEffect(() => {
if (preference !== 'system') return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
setIsDark(e.matches);
applyDarkClass(e.matches);
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [preference, applyDarkClass]);
// Strip dark class when unmounting (navigating away from admin)
useEffect(() => {
return () => {
document.documentElement.classList.remove('dark');
};
}, []);
const value = useMemo(() => ({ preference, isDark, setPreference, toggle }), [preference, isDark, setPreference, toggle]);
return (
<AdminDarkModeContext.Provider value={value}>
{children}
</AdminDarkModeContext.Provider>
);
};
export const useAdminDarkMode = () => {
const context = useContext(AdminDarkModeContext);
if (!context) {
throw new Error('useAdminDarkMode must be used within AdminDarkModeProvider');
}
return context;
};
+69 -1
View File
@@ -2,9 +2,18 @@ import React, { createContext, useContext, useState, useEffect, useCallback, use
import type { ReactNode } from 'react';
import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types';
function resolveColorMode(mode: 'light' | 'dark' | 'auto' | undefined): 'light' | 'dark' {
if (mode === 'dark') return 'dark';
if (mode === 'auto') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'light';
}
interface ThemeContextType {
theme: ThemeConfig;
themeName: string;
resolvedColorMode: 'light' | 'dark';
setTheme: (theme: ThemeConfig) => void;
setThemeByName: (themeName: string) => void;
applyTheme: (theme: ThemeConfig) => void;
@@ -34,6 +43,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
}) => {
const [theme, setTheme] = useState<ThemeConfig>(initialTheme);
const [themeName, setThemeName] = useState(initialThemeName);
const [resolvedColorMode, setResolvedColorMode] = useState<'light' | 'dark'>(() => resolveColorMode(initialTheme.colorMode));
const applyTheme = useCallback((themeConfig: ThemeConfig) => {
const root = document.documentElement;
@@ -97,6 +107,53 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
root.style.setProperty('--shadow-default', shadowMap[themeConfig.shadowStyle]);
}
// Apply surface colors
const effectiveMode = resolveColorMode(themeConfig.colorMode);
setResolvedColorMode(effectiveMode);
if (themeConfig.surfaceColor) {
root.style.setProperty('--color-surface', themeConfig.surfaceColor);
} else if (effectiveMode === 'dark') {
// Auto-derive dark surface if not explicitly set
root.style.setProperty('--color-surface', '#1a1a1a');
} else {
root.style.setProperty('--color-surface', '#ffffff');
}
if (themeConfig.surfaceBorderColor) {
root.style.setProperty('--color-surface-border', themeConfig.surfaceBorderColor);
} else if (effectiveMode === 'dark') {
root.style.setProperty('--color-surface-border', '#2e2e2e');
} else {
root.style.setProperty('--color-surface-border', '#e5e5e5');
}
if (themeConfig.mutedTextColor) {
root.style.setProperty('--color-muted-text', themeConfig.mutedTextColor);
} else if (effectiveMode === 'dark') {
root.style.setProperty('--color-muted-text', '#a3a3a3');
} else {
root.style.setProperty('--color-muted-text', '#737373');
}
// Adjust shadow intensity for dark mode
if (themeConfig.shadowStyle) {
const lightShadowMap = {
none: 'none',
subtle: '0 1px 3px rgba(0,0,0,0.12)',
normal: '0 4px 6px rgba(0,0,0,0.1)',
dramatic: '0 10px 25px rgba(0,0,0,0.15)',
};
const darkShadowMap = {
none: 'none',
subtle: '0 1px 3px rgba(0,0,0,0.4)',
normal: '0 4px 6px rgba(0,0,0,0.35)',
dramatic: '0 10px 25px rgba(0,0,0,0.5)',
};
const map = effectiveMode === 'dark' ? darkShadowMap : lightShadowMap;
root.style.setProperty('--shadow-default', map[themeConfig.shadowStyle]);
}
// Apply background pattern
if (themeConfig.backgroundPattern && themeConfig.backgroundPattern !== 'none') {
const patternMap = {
@@ -184,14 +241,25 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
}
}, [theme, themeName]);
// Listen for system color scheme changes when colorMode is 'auto'
useEffect(() => {
if (theme.colorMode !== 'auto') return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => applyTheme(theme);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [theme, applyTheme]);
const contextValue = useMemo(() => ({
theme,
themeName,
resolvedColorMode,
setTheme: setThemeConfig,
setThemeByName,
applyTheme,
resetTheme
}), [theme, themeName, setThemeConfig, setThemeByName, applyTheme, resetTheme]);
}), [theme, themeName, resolvedColorMode, setThemeConfig, setThemeByName, applyTheme, resetTheme]);
return (
<ThemeContext.Provider value={contextValue}>