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
+5 -5
View File
@@ -30,7 +30,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-neutral-700 mb-1.5"
className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1.5"
>
{label}
</label>
@@ -38,7 +38,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
<div className="relative">
{leftIcon && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<span className="text-neutral-500">{leftIcon}</span>
<span className="text-neutral-500 dark:text-neutral-400">{leftIcon}</span>
</div>
)}
<input
@@ -59,17 +59,17 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
/>
{rightIcon && (
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<span className="text-neutral-500">{rightIcon}</span>
<span className="text-neutral-500 dark:text-neutral-400">{rightIcon}</span>
</div>
)}
</div>
{error && (
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-600">
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
{helperText && !error && (
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-neutral-500">
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-neutral-500 dark:text-neutral-400">
{helperText}
</p>
)}
@@ -41,7 +41,7 @@ export const LanguageSelector: React.FC = () => {
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<Globe className="w-4 h-4" />
<currentLanguage.Flag className="w-5 h-5" />
@@ -49,15 +49,15 @@ export const LanguageSelector: React.FC = () => {
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-50">
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 py-1 z-50">
{languages.map((language) => (
<button
key={language.code}
onClick={() => handleLanguageChange(language.code)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 flex items-center gap-3 ${
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3 ${
language.code === i18n.language
? 'text-primary-600 bg-primary-50'
: 'text-neutral-700'
? 'text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30'
: 'text-neutral-700 dark:text-neutral-300'
}`}
>
<language.Flag className="w-5 h-5" />
@@ -0,0 +1,52 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl } from '../../utils/url';
export const RobotsMetaTags: React.FC = () => {
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
try {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.ok) {
return response.json();
}
return null;
} catch {
return null;
}
},
staleTime: 5 * 60 * 1000,
});
useEffect(() => {
// Remove any existing robots meta tags we previously injected
document.querySelectorAll('meta[name="robots"][data-picpeak]').forEach(el => el.remove());
const directives: string[] = [];
if (settings?.seo_meta_noindex) directives.push('noindex');
if (settings?.seo_meta_nofollow) directives.push('nofollow');
if (directives.length > 0) {
const meta = document.createElement('meta');
meta.name = 'robots';
meta.content = directives.join(', ');
meta.setAttribute('data-picpeak', 'true');
document.head.appendChild(meta);
}
if (settings?.seo_meta_noai) {
const metaAi = document.createElement('meta');
metaAi.name = 'robots';
metaAi.content = 'noai, noimageai';
metaAi.setAttribute('data-picpeak', 'true');
document.head.appendChild(metaAi);
}
return () => {
document.querySelectorAll('meta[name="robots"][data-picpeak]').forEach(el => el.remove());
};
}, [settings?.seo_meta_noindex, settings?.seo_meta_nofollow, settings?.seo_meta_noai]);
return null;
};
+1
View File
@@ -14,6 +14,7 @@ export {
export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator';
export { SkipLink } from './SkipLink';
export { DynamicFavicon } from './DynamicFavicon';
export { RobotsMetaTags } from './RobotsMetaTags';
export { LanguageSelector } from './LanguageSelector';
export { AuthenticatedImage } from './AuthenticatedImage';
export { AuthenticatedVideo } from './AuthenticatedVideo';