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:
@@ -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;
|
||||
};
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user