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
@@ -52,6 +52,18 @@ export interface EventSettings {
event_require_expiration: boolean;
}
export interface SeoSettings {
allow_indexing: boolean;
block_ai_crawlers: boolean;
block_social_bots: boolean;
blocked_ai_agents: string[];
custom_rules: Array<{ userAgent: string; disallow: string[] }>;
meta_noindex: boolean;
meta_nofollow: boolean;
meta_noai: boolean;
sitemap_url: string;
}
export function useSettingsState() {
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
@@ -114,6 +126,19 @@ export function useSettingsState() {
event_require_expiration: true
});
// SEO settings state
const [seoSettings, setSeoSettings] = useState<SeoSettings>({
allow_indexing: false,
block_ai_crawlers: true,
block_social_bots: false,
blocked_ai_agents: [],
custom_rules: [],
meta_noindex: true,
meta_nofollow: false,
meta_noai: true,
sitemap_url: ''
});
// Account form state
const [accountForm, setAccountForm] = useState({
username: '',
@@ -183,6 +208,18 @@ export function useSettingsState() {
event_require_event_date: toBoolean(settings.event_require_event_date, true),
event_require_expiration: toBoolean(settings.event_require_expiration, true)
});
setSeoSettings({
allow_indexing: toBoolean(settings.seo_allow_indexing, false),
block_ai_crawlers: toBoolean(settings.seo_block_ai_crawlers, true),
block_social_bots: toBoolean(settings.seo_block_social_bots, false),
blocked_ai_agents: Array.isArray(settings.seo_blocked_ai_agents) ? settings.seo_blocked_ai_agents : [],
custom_rules: Array.isArray(settings.seo_custom_rules) ? settings.seo_custom_rules : [],
meta_noindex: toBoolean(settings.seo_meta_noindex, true),
meta_nofollow: toBoolean(settings.seo_meta_nofollow, false),
meta_noai: toBoolean(settings.seo_meta_noai, true),
sitemap_url: settings.seo_sitemap_url || ''
});
}
}, [settings, i18n]);
@@ -270,6 +307,24 @@ export function useSettingsState() {
}
});
const saveSeoMutation = useMutation({
mutationFn: async () => {
const settingsData: Record<string, unknown> = {};
Object.entries(seoSettings).forEach(([key, value]) => {
settingsData[`seo_${key}`] = value;
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const saveEventSettingsMutation = useMutation({
mutationFn: async () => {
const settingsData: Record<string, unknown> = {};
@@ -472,6 +527,8 @@ export function useSettingsState() {
setAnalyticsSettings,
eventSettings,
setEventSettings,
seoSettings,
setSeoSettings,
// Account form
accountForm,
@@ -501,6 +558,7 @@ export function useSettingsState() {
saveSecurityMutation,
saveAnalyticsMutation,
saveEventSettingsMutation,
saveSeoMutation,
// Translation
t,
+2 -1
View File
@@ -1,6 +1,6 @@
// Hooks
export { useSettingsState, MAX_FILES_PER_UPLOAD_LIMIT } from './hooks/useSettingsState';
export type { GeneralSettings, SecuritySettings, AnalyticsSettings, EventSettings } from './hooks/useSettingsState';
export type { GeneralSettings, SecuritySettings, AnalyticsSettings, EventSettings, SeoSettings } from './hooks/useSettingsState';
export { useStatusTab } from './hooks/useStatusTab';
// Tab components
@@ -13,3 +13,4 @@ export { CategoriesTab } from './tabs/CategoriesTab';
export { AnalyticsTab } from './tabs/AnalyticsTab';
export { ModerationTab } from './tabs/ModerationTab';
export { StylingTab } from './tabs/StylingTab';
export { SEOTab } from './tabs/SEOTab';
@@ -23,7 +23,7 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.umamiIntegration')}</h2>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.analytics.umamiIntegration')}</h2>
<div className="space-y-4">
<label className="flex items-center">
@@ -33,13 +33,13 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_enabled: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.analytics.enableUmami')}</span>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.analytics.enableUmami')}</span>
</label>
{analyticsSettings.umami_enabled && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.umamiUrl')}
</label>
<Input
@@ -49,13 +49,13 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
placeholder="https://analytics.yourdomain.com"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.umamiUrlHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.websiteId')}
</label>
<Input
@@ -65,13 +65,13 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.websiteIdHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.analytics.shareUrl')}
</label>
<Input
@@ -81,17 +81,17 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
placeholder="https://analytics.yourdomain.com/share/..."
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.analytics.shareUrlHelp')}
</p>
</div>
</>
)}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">{t('settings.analytics.umamiInfo')}</p>
<p>{t('settings.analytics.umamiInfoText')}</p>
<a href="https://umami.is" target="_blank" rel="noopener noreferrer" className="underline mt-1 inline-block">
@@ -116,22 +116,22 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
{/* Backend Analytics Info */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.backendAnalytics')}</h2>
<p className="text-sm text-neutral-700 mb-4">{t('settings.analytics.backendAnalyticsText')}</p>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.analytics.backendAnalytics')}</h2>
<p className="text-sm text-neutral-700 dark:text-neutral-300 mb-4">{t('settings.analytics.backendAnalyticsText')}</p>
<div className="grid grid-cols-2 gap-4">
<div className="bg-neutral-50 rounded-lg p-4">
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.tracked')}</h3>
<ul className="text-xs text-neutral-600 space-y-1">
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-2">{t('settings.analytics.tracked')}</h3>
<ul className="text-xs text-neutral-600 dark:text-neutral-400 space-y-1">
<li> {t('settings.analytics.galleryViews')}</li>
<li> {t('settings.analytics.photoDownloads')}</li>
<li> {t('settings.analytics.uniqueVisitors')}</li>
<li> {t('settings.analytics.deviceTypes')}</li>
</ul>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.privacy')}</h3>
<p className="text-xs text-neutral-600">
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-2">{t('settings.analytics.privacy')}</h3>
<p className="text-xs text-neutral-600 dark:text-neutral-400">
{t('settings.analytics.privacyText')}
</p>
</div>
@@ -15,10 +15,10 @@ export const CategoriesTab: React.FC = () => {
<Card padding="md">
<div className="flex items-start gap-3">
<Image className="w-5 h-5 text-blue-600 flex-shrink-0" />
<Image className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-blue-900">{t('settings.categories.about')}</h3>
<p className="text-sm text-blue-700 mt-1">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-200">{t('settings.categories.about')}</h3>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
{t('settings.categories.aboutText')}
</p>
</div>
@@ -23,10 +23,10 @@ export const EventsTab: React.FC<EventsTabProps> = ({
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">
{t('settings.events.requiredFields', 'Required Fields')}
</h2>
<p className="text-sm text-neutral-600 mb-4">
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.events.requiredFieldsDescription', 'Configure which contact fields are required when creating new events.')}
</p>
@@ -40,10 +40,10 @@ export const EventsTab: React.FC<EventsTabProps> = ({
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('settings.events.requireCustomerName', 'Require customer name')}
</span>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.events.requireCustomerNameHelp', 'Customer name must be provided for new events')}
</p>
</div>
@@ -59,10 +59,10 @@ export const EventsTab: React.FC<EventsTabProps> = ({
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('settings.events.requireCustomerEmail', 'Require customer email')}
</span>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.events.requireCustomerEmailHelp', 'Customer email must be provided for new events')}
</p>
{!eventSettings.event_require_customer_email && (
@@ -84,10 +84,10 @@ export const EventsTab: React.FC<EventsTabProps> = ({
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('settings.events.requireAdminEmail', 'Require admin email')}
</span>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.events.requireAdminEmailHelp', 'Admin email must be provided for new events')}
</p>
{!eventSettings.event_require_admin_email && (
@@ -109,10 +109,10 @@ export const EventsTab: React.FC<EventsTabProps> = ({
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('settings.events.requireEventDate', 'Require event date')}
</span>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.events.requireEventDateHelp', 'Event date must be provided when creating events')}
</p>
{!eventSettings.event_require_event_date && (
@@ -134,10 +134,10 @@ export const EventsTab: React.FC<EventsTabProps> = ({
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('settings.events.requireExpiration', 'Require expiration date')}
</span>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.events.requireExpirationHelp', 'Galleries must have an expiration date')}
</p>
{!eventSettings.event_require_expiration && (
@@ -165,8 +165,8 @@ export const EventsTab: React.FC<EventsTabProps> = ({
<Card padding="md">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">{t('settings.events.noteTitle', 'Note')}</p>
<p>
{t('settings.events.noteText', 'These settings only affect new event creation. Existing events are not affected. Default behavior requires all fields.')}
@@ -36,7 +36,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.accountSection')}</h2>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.general.accountSection')}</h2>
{adminProfileLoading ? (
<div className="py-8 flex justify-center">
<Loading size="md" />
@@ -44,7 +44,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
) : (
<form className="space-y-4" onSubmit={handleAccountSubmit}>
<div>
<label htmlFor="admin-account-username" className="block text-sm font-medium text-neutral-700 mb-1">
<label htmlFor="admin-account-username" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.general.accountUsername')}
</label>
<Input
@@ -56,13 +56,13 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
error={accountErrors.username}
/>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.accountUsernameHelp')}
</p>
</div>
<div>
<label htmlFor="admin-account-email" className="block text-sm font-medium text-neutral-700 mb-1">
<label htmlFor="admin-account-email" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.general.accountEmail')}
</label>
<Input
@@ -74,7 +74,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
error={accountErrors.email}
/>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.accountEmailHelp')}
</p>
</div>
@@ -94,11 +94,11 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.siteConfiguration')}</h2>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.general.siteConfiguration')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.general.siteUrl')}
</label>
<Input
@@ -108,14 +108,14 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
placeholder="https://yourdomain.com"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.siteUrlHelp')}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.general.defaultExpiration')}
</label>
<Input
@@ -127,7 +127,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.general.maxFileSize')}
</label>
<Input
@@ -139,7 +139,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.general.maxFilesPerUpload')}
</label>
<Input
@@ -157,14 +157,14 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
min="1"
max={MAX_FILES_PER_UPLOAD_LIMIT}
/>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.general.allowedFileTypes')}
</label>
<Input
@@ -173,7 +173,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
onChange={(e) => setGeneralSettings(prev => ({ ...prev, allowed_file_types: e.target.value }))}
placeholder="jpg,jpeg,png,gif"
/>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.allowedFileTypesHelp')}
</p>
</div>
@@ -181,7 +181,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.featureToggles')}</h2>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.general.featureToggles')}</h2>
<div className="space-y-3">
<label className="flex items-center">
@@ -191,7 +191,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_analytics: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableAnalytics')}</span>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.general.enableAnalytics')}</span>
</label>
<label className="flex items-center">
@@ -201,7 +201,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_registration: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableRegistration')}</span>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.general.enableRegistration')}</span>
</label>
<label className="flex items-center">
@@ -211,7 +211,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
onChange={(e) => setGeneralSettings(prev => ({ ...prev, maintenance_mode: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.general.maintenanceMode')}</span>
</label>
<div>
@@ -222,9 +222,9 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
onChange={(e) => setGeneralSettings(prev => ({ ...prev, short_gallery_urls: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableShortGalleryUrls')}</span>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.general.enableShortGalleryUrls')}</span>
</label>
<p className="text-xs text-neutral-500 ml-6 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 ml-6 mt-1">
{t('settings.general.enableShortGalleryUrlsHelp')}
</p>
</div>
@@ -232,22 +232,22 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.language')}</h2>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.general.language')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('settings.general.language')}
</label>
<select
value={generalSettings.default_language}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_language: e.target.value }))}
className="w-full px-3 py-2 border border-neutral-300 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 rounded-lg 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"
>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.defaultLanguageHelp')}
</p>
</div>
@@ -255,11 +255,11 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.dateTimeFormat')}</h2>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.general.dateTimeFormat')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('settings.general.dateFormat')}
</label>
<select
@@ -272,14 +272,14 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
date_format: { format, locale }
}));
}}
className="w-full px-3 py-2 border border-neutral-300 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 rounded-lg 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"
>
<option value="dd/MM/yyyy">DD/MM/YYYY (European)</option>
<option value="MM/dd/yyyy">MM/DD/YYYY (US)</option>
<option value="yyyy-MM-dd">YYYY-MM-DD (ISO)</option>
<option value="dd.MM.yyyy">DD.MM.YYYY (German)</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.dateFormatHelp')}
</p>
</div>
@@ -121,23 +121,23 @@ export const ImageSecurityTab: React.FC = () => {
<div className="space-y-6">
{/* Default Protection Level */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-primary-600" />
{t('settings.imageSecurity.defaultProtection', 'Default Protection Settings')}
</h2>
<p className="text-sm text-neutral-600 mb-4">
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.imageSecurity.defaultProtectionHelp', 'These settings apply to all new events. Individual events can override these defaults.')}
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.imageSecurity.protectionLevel', 'Default Protection Level')}
</label>
<select
value={settings.default_protection_level}
onChange={(e) => handleChange('default_protection_level', e.target.value as ImageSecuritySettings['default_protection_level'])}
className="w-full px-3 py-2 border border-neutral-300 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 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"
>
<option value="basic">{t('events.protectionLevelBasic', 'Basic - Right-click blocking only')}</option>
<option value="standard">{t('events.protectionLevelStandard', 'Standard - Keyboard shortcuts blocked')}</option>
@@ -148,7 +148,7 @@ export const ImageSecurityTab: React.FC = () => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.imageSecurity.imageQuality', 'Default Image Quality')}
</label>
<input
@@ -157,13 +157,13 @@ export const ImageSecurityTab: React.FC = () => {
max="100"
value={settings.default_image_quality}
onChange={(e) => handleChange('default_image_quality', parseInt(e.target.value) || 85)}
className="w-full px-3 py-2 border border-neutral-300 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 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"
/>
<p className="text-xs text-neutral-500 mt-1">1-100, higher = better quality</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">1-100, higher = better quality</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.imageSecurity.fragmentationLevel', 'Fragmentation Level')}
</label>
<input
@@ -172,9 +172,9 @@ export const ImageSecurityTab: React.FC = () => {
max="10"
value={settings.default_fragmentation_level}
onChange={(e) => handleChange('default_fragmentation_level', parseInt(e.target.value) || 3)}
className="w-full px-3 py-2 border border-neutral-300 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 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"
/>
<p className="text-xs text-neutral-500 mt-1">1-10, higher = more protection</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">1-10, higher = more protection</p>
</div>
</div>
@@ -187,7 +187,7 @@ export const ImageSecurityTab: React.FC = () => {
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Monitor className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{t('settings.imageSecurity.enableDevtools', 'Enable DevTools detection by default')}
</span>
</label>
@@ -200,7 +200,7 @@ export const ImageSecurityTab: React.FC = () => {
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
<span className="text-sm text-neutral-700">
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{t('settings.imageSecurity.enableCanvas', 'Enable canvas rendering by default (advanced protection)')}
</span>
</label>
@@ -210,16 +210,16 @@ export const ImageSecurityTab: React.FC = () => {
{/* Rate Limiting */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">
{t('settings.imageSecurity.rateLimiting', 'Rate Limiting')}
</h2>
<p className="text-sm text-neutral-600 mb-4">
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.imageSecurity.rateLimitingHelp', 'Limit how many images can be requested to prevent scraping.')}
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.imageSecurity.requestsPerMinute', 'Requests per minute')}
</label>
<input
@@ -228,12 +228,12 @@ export const ImageSecurityTab: React.FC = () => {
max="1000"
value={settings.max_image_requests_per_minute}
onChange={(e) => handleChange('max_image_requests_per_minute', parseInt(e.target.value) || 30)}
className="w-full px-3 py-2 border border-neutral-300 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 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.imageSecurity.requestsPer5Minutes', 'Requests per 5 min')}
</label>
<input
@@ -242,12 +242,12 @@ export const ImageSecurityTab: React.FC = () => {
max="5000"
value={settings.max_image_requests_per_5_minutes}
onChange={(e) => handleChange('max_image_requests_per_5_minutes', parseInt(e.target.value) || 100)}
className="w-full px-3 py-2 border border-neutral-300 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 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.imageSecurity.requestsPerHour', 'Requests per hour')}
</label>
<input
@@ -256,7 +256,7 @@ export const ImageSecurityTab: React.FC = () => {
max="10000"
value={settings.max_image_requests_per_hour}
onChange={(e) => handleChange('max_image_requests_per_hour', parseInt(e.target.value) || 500)}
className="w-full px-3 py-2 border border-neutral-300 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 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"
/>
</div>
</div>
@@ -264,14 +264,14 @@ export const ImageSecurityTab: React.FC = () => {
{/* Security Monitoring */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">
{t('settings.imageSecurity.securityMonitoring', 'Security Monitoring')}
</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.imageSecurity.suspiciousThreshold', 'Suspicious activity threshold')}
</label>
<input
@@ -280,13 +280,13 @@ export const ImageSecurityTab: React.FC = () => {
max="100"
value={settings.suspicious_activity_threshold}
onChange={(e) => handleChange('suspicious_activity_threshold', parseInt(e.target.value) || 10)}
className="w-full px-3 py-2 border border-neutral-300 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 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"
/>
<p className="text-xs text-neutral-500 mt-1">Violations before flagging as suspicious</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Violations before flagging as suspicious</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.imageSecurity.autoBlockThreshold', 'Auto-block threshold')}
</label>
<input
@@ -295,9 +295,9 @@ export const ImageSecurityTab: React.FC = () => {
max="500"
value={settings.auto_block_threshold}
onChange={(e) => handleChange('auto_block_threshold', parseInt(e.target.value) || 50)}
className="w-full px-3 py-2 border border-neutral-300 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 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"
/>
<p className="text-xs text-neutral-500 mt-1">Violations before auto-blocking IP</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Violations before auto-blocking IP</p>
</div>
</div>
@@ -309,7 +309,7 @@ export const ImageSecurityTab: React.FC = () => {
onChange={(e) => handleChange('security_monitoring_enabled', e.target.checked)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300 dark:text-neutral-300">
{t('settings.imageSecurity.enableMonitoring', 'Enable security monitoring')}
</span>
</label>
@@ -321,7 +321,7 @@ export const ImageSecurityTab: React.FC = () => {
onChange={(e) => handleChange('block_suspicious_ips', e.target.checked)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300 dark:text-neutral-300">
{t('settings.imageSecurity.blockSuspiciousIps', 'Automatically block suspicious IPs')}
</span>
</label>
@@ -333,7 +333,7 @@ export const ImageSecurityTab: React.FC = () => {
onChange={(e) => handleChange('log_security_events_to_db', e.target.checked)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300 dark:text-neutral-300">
{t('settings.imageSecurity.logEvents', 'Log security events to database')}
</span>
</label>
@@ -342,10 +342,10 @@ export const ImageSecurityTab: React.FC = () => {
</Card>
{/* Info Box */}
<Card padding="md" className="bg-blue-50 border-blue-200">
<Card padding="md" className="bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">{t('settings.imageSecurity.infoTitle', 'About Image Protection')}</p>
<p>
{t('settings.imageSecurity.infoText', 'These protection features help prevent casual downloading and copying but cannot block all methods. Determined users may still find ways to capture images. Consider using watermarks and legal agreements for comprehensive protection.')}
@@ -0,0 +1,347 @@
import React, { useState, useMemo } from 'react';
import { Save, Globe, Bot, X, Plus, Eye, Shield } from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { SeoSettings } from '../hooks/useSettingsState';
interface SEOTabProps {
seoSettings: SeoSettings;
setSeoSettings: React.Dispatch<React.SetStateAction<SeoSettings>>;
saveSeoMutation: {
mutate: () => void;
isPending: boolean;
};
}
export const SEOTab: React.FC<SEOTabProps> = ({
seoSettings,
setSeoSettings,
saveSeoMutation,
}) => {
const { t } = useTranslation();
const [newAgent, setNewAgent] = useState('');
const [showPreview, setShowPreview] = useState(false);
const [newRuleAgent, setNewRuleAgent] = useState('');
const [newRulePath, setNewRulePath] = useState('/');
const handleAddAgent = () => {
const agent = newAgent.trim();
if (agent && !seoSettings.blocked_ai_agents.includes(agent)) {
setSeoSettings(prev => ({
...prev,
blocked_ai_agents: [...prev.blocked_ai_agents, agent]
}));
setNewAgent('');
}
};
const handleRemoveAgent = (agent: string) => {
setSeoSettings(prev => ({
...prev,
blocked_ai_agents: prev.blocked_ai_agents.filter(a => a !== agent)
}));
};
const handleAddCustomRule = () => {
const agent = newRuleAgent.trim();
const path = newRulePath.trim();
if (agent && path) {
setSeoSettings(prev => ({
...prev,
custom_rules: [...prev.custom_rules, { userAgent: agent, disallow: [path] }]
}));
setNewRuleAgent('');
setNewRulePath('/');
}
};
const handleRemoveCustomRule = (index: number) => {
setSeoSettings(prev => ({
...prev,
custom_rules: prev.custom_rules.filter((_, i) => i !== index)
}));
};
const robotsTxtPreview = useMemo(() => {
const lines: string[] = [];
lines.push('# Protected paths');
lines.push('User-agent: *');
lines.push('Disallow: /admin');
lines.push('Disallow: /api');
lines.push('');
if (!seoSettings.allow_indexing) {
lines.push('# Indexing disabled');
lines.push('User-agent: *');
lines.push('Disallow: /');
lines.push('');
}
if (seoSettings.block_ai_crawlers && seoSettings.blocked_ai_agents.length > 0) {
lines.push('# AI/LLM crawler blocking');
for (const agent of seoSettings.blocked_ai_agents) {
lines.push(`User-agent: ${agent}`);
lines.push('Disallow: /');
lines.push('');
}
}
if (seoSettings.block_social_bots) {
lines.push('# Social media bot blocking');
for (const bot of ['Twitterbot', 'facebookexternalhit', 'LinkedInBot', 'Slackbot', 'WhatsApp', 'TelegramBot', 'Discordbot']) {
lines.push(`User-agent: ${bot}`);
lines.push('Disallow: /');
lines.push('');
}
}
if (seoSettings.custom_rules.length > 0) {
lines.push('# Custom rules');
for (const rule of seoSettings.custom_rules) {
lines.push(`User-agent: ${rule.userAgent}`);
for (const path of rule.disallow) {
lines.push(`Disallow: ${path}`);
}
lines.push('');
}
}
if (seoSettings.sitemap_url) {
lines.push(`Sitemap: ${seoSettings.sitemap_url}`);
}
return lines.join('\n');
}, [seoSettings]);
return (
<div className="space-y-6">
{/* Search Engine Indexing */}
<Card padding="md">
<div className="flex items-center gap-2 mb-4">
<Globe className="w-5 h-5 text-primary-600" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('settings.seo.indexingTitle', 'Search Engine Indexing')}</h2>
</div>
<div className="space-y-4">
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={seoSettings.allow_indexing}
onChange={(e) => setSeoSettings(prev => ({ ...prev, allow_indexing: e.target.checked }))}
className="w-4 h-4 mt-0.5 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('settings.seo.allowIndexing', 'Allow search engine indexing')}</span>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">{t('settings.seo.allowIndexingHelp', 'When disabled, all crawlers are blocked via robots.txt. Recommended off for private photo platforms.')}</p>
</div>
</label>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 dark:text-neutral-300 mb-1">
{t('settings.seo.sitemapUrl', 'Sitemap URL')}
</label>
<Input
type="url"
value={seoSettings.sitemap_url}
onChange={(e) => setSeoSettings(prev => ({ ...prev, sitemap_url: e.target.value }))}
placeholder="https://example.com/sitemap.xml"
/>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">{t('settings.seo.sitemapUrlHelp', 'Optional. Added to robots.txt if provided.')}</p>
</div>
</div>
</Card>
{/* AI & Bot Blocking */}
<Card padding="md">
<div className="flex items-center gap-2 mb-4">
<Bot className="w-5 h-5 text-primary-600" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('settings.seo.aiBlockingTitle', 'AI & Bot Blocking')}</h2>
</div>
<div className="space-y-4">
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={seoSettings.block_ai_crawlers}
onChange={(e) => setSeoSettings(prev => ({ ...prev, block_ai_crawlers: e.target.checked }))}
className="w-4 h-4 mt-0.5 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('settings.seo.blockAiCrawlers', 'Block AI/LLM crawlers')}</span>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">{t('settings.seo.blockAiCrawlersHelp', 'Prevent AI training bots from accessing your content.')}</p>
</div>
</label>
{seoSettings.block_ai_crawlers && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 dark:text-neutral-300 mb-2">
{t('settings.seo.blockedAgents', 'Blocked AI agents')}
</label>
<div className="flex flex-wrap gap-2 mb-3">
{seoSettings.blocked_ai_agents.map(agent => (
<span key={agent} className="inline-flex items-center gap-1 px-2.5 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-full text-sm">
{agent}
<button
onClick={() => handleRemoveAgent(agent)}
className="text-neutral-400 hover:text-red-500 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<Input
type="text"
value={newAgent}
onChange={(e) => setNewAgent(e.target.value)}
placeholder={t('settings.seo.addAgentPlaceholder', 'Enter agent name...')}
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddAgent())}
/>
<Button variant="outline" size="sm" onClick={handleAddAgent}>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
)}
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={seoSettings.block_social_bots}
onChange={(e) => setSeoSettings(prev => ({ ...prev, block_social_bots: e.target.checked }))}
className="w-4 h-4 mt-0.5 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('settings.seo.blockSocialBots', 'Block social media preview bots')}</span>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">{t('settings.seo.blockSocialBotsHelp', 'Prevent link previews on Twitter, Facebook, LinkedIn, etc.')}</p>
</div>
</label>
</div>
</Card>
{/* Meta Tags & Custom Rules */}
<Card padding="md">
<div className="flex items-center gap-2 mb-4">
<Shield className="w-5 h-5 text-primary-600" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('settings.seo.metaTagsTitle', 'Meta Tags & Custom Rules')}</h2>
</div>
<div className="space-y-4">
<div className="space-y-3">
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={seoSettings.meta_noindex}
onChange={(e) => setSeoSettings(prev => ({ ...prev, meta_noindex: e.target.checked }))}
className="w-4 h-4 mt-0.5 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('settings.seo.metaNoindex', 'Add noindex meta tag')}</span>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">{t('settings.seo.metaNoindexHelp', 'Tells search engines not to index pages (HTML-level, complements robots.txt).')}</p>
</div>
</label>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={seoSettings.meta_nofollow}
onChange={(e) => setSeoSettings(prev => ({ ...prev, meta_nofollow: e.target.checked }))}
className="w-4 h-4 mt-0.5 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('settings.seo.metaNofollow', 'Add nofollow meta tag')}</span>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">{t('settings.seo.metaNofollowHelp', 'Tells search engines not to follow links on pages.')}</p>
</div>
</label>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={seoSettings.meta_noai}
onChange={(e) => setSeoSettings(prev => ({ ...prev, meta_noai: e.target.checked }))}
className="w-4 h-4 mt-0.5 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('settings.seo.metaNoai', 'Add noai/noimageai meta tag')}</span>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">{t('settings.seo.metaNoaiHelp', 'Signals that content should not be used for AI training.')}</p>
</div>
</label>
</div>
{/* Custom Rules */}
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-3">{t('settings.seo.customRules', 'Custom robots.txt rules')}</h3>
{seoSettings.custom_rules.length > 0 && (
<div className="space-y-2 mb-3">
{seoSettings.custom_rules.map((rule, index) => (
<div key={index} className="flex items-center gap-2 p-2 bg-neutral-50 dark:bg-neutral-800 rounded-lg text-sm">
<code className="flex-1 text-neutral-700 dark:text-neutral-300">
User-agent: {rule.userAgent} / Disallow: {rule.disallow.join(', ')}
</code>
<button
onClick={() => handleRemoveCustomRule(index)}
className="text-neutral-400 hover:text-red-500 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
<div className="flex gap-2">
<Input
type="text"
value={newRuleAgent}
onChange={(e) => setNewRuleAgent(e.target.value)}
placeholder={t('settings.seo.ruleAgentPlaceholder', 'User-agent')}
/>
<Input
type="text"
value={newRulePath}
onChange={(e) => setNewRulePath(e.target.value)}
placeholder={t('settings.seo.rulePathPlaceholder', 'Disallow path')}
/>
<Button variant="outline" size="sm" onClick={handleAddCustomRule}>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
{/* robots.txt Preview */}
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4">
<button
onClick={() => setShowPreview(!showPreview)}
className="flex items-center gap-2 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
>
<Eye className="w-4 h-4" />
{showPreview
? t('settings.seo.hidePreview', 'Hide robots.txt preview')
: t('settings.seo.showPreview', 'Show robots.txt preview')}
</button>
{showPreview && (
<pre className="mt-3 p-4 bg-neutral-900 text-neutral-100 rounded-lg text-sm overflow-x-auto whitespace-pre font-mono max-h-80 overflow-y-auto">
{robotsTxtPreview}
</pre>
)}
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveSeoMutation.mutate()}
isLoading={saveSeoMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.seo.saveSettings', 'Save SEO Settings')}
</Button>
</div>
</Card>
</div>
);
};
@@ -23,11 +23,11 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.security.passwordSettings')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.security.minPasswordLength')}
</label>
<Input
@@ -40,20 +40,20 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.security.passwordComplexity')}
</label>
<select
value={securitySettings.password_complexity}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, password_complexity: e.target.value }))}
className="w-full px-3 py-2 border border-neutral-300 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 rounded-lg 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"
>
<option value="simple">{t('settings.security.complexitySimple')}</option>
<option value="moderate">{t('settings.security.complexityModerate')}</option>
<option value="strong">{t('settings.security.complexityStrong')}</option>
<option value="very_strong">{t('settings.security.complexityVeryStrong')}</option>
</select>
<p className="mt-1 text-sm text-neutral-600">
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('settings.security.passwordComplexityHelp')}
</p>
</div>
@@ -61,12 +61,12 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.sessionAuth')}</h2>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.security.sessionAuth')}</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.security.sessionTimeout')}
</label>
<Input
@@ -78,7 +78,7 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.security.attemptWindowMinutes')}
</label>
<Input
@@ -88,12 +88,12 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
min="1"
max="1440"
/>
<p className="mt-1 text-sm text-neutral-600">
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('settings.security.attemptWindowMinutesHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.security.lockoutDurationMinutes')}
</label>
<Input
@@ -103,12 +103,12 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
min="1"
max="1440"
/>
<p className="mt-1 text-sm text-neutral-600">
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('settings.security.lockoutDurationMinutesHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.security.maxLoginAttempts')}
</label>
<Input
@@ -118,7 +118,7 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
min="1"
max="50"
/>
<p className="mt-1 text-sm text-neutral-600">
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('settings.security.maxLoginAttemptsHelp')}
</p>
</div>
@@ -131,13 +131,13 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_2fa: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.security.enable2FA')}</span>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.security.enable2FA')}</span>
</label>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.recaptchaSettings')}</h2>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('settings.security.recaptchaSettings')}</h2>
<div className="space-y-4">
<label className="flex items-center">
@@ -147,13 +147,13 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_recaptcha: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.security.enableRecaptcha')}</span>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.security.enableRecaptcha')}</span>
</label>
{securitySettings.enable_recaptcha && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.security.siteKey')}
</label>
<Input
@@ -165,7 +165,7 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.security.secretKey')}
</label>
<Input
@@ -179,10 +179,10 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
</>
)}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p>{t('settings.security.recaptchaHelp')} <a href="https://www.google.com/recaptcha/admin" target="_blank" rel="noopener noreferrer" className="underline">Google reCAPTCHA Admin</a></p>
</div>
</div>
@@ -125,9 +125,9 @@ export const StatusTab: React.FC<StatusTabProps> = ({
: usagePercentage >= 90
? 'bg-amber-500'
: 'bg-primary-600';
const limitCardClass = overSoftLimit ? 'bg-amber-50 border border-amber-200' : 'bg-neutral-50';
const limitValueClass = overSoftLimit ? 'text-amber-700' : 'text-neutral-900';
const limitDescriptorClass = overSoftLimit ? 'text-amber-700 font-semibold' : 'text-neutral-600';
const limitCardClass = overSoftLimit ? 'bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800' : 'bg-neutral-50 dark:bg-neutral-800';
const limitValueClass = overSoftLimit ? 'text-amber-700 dark:text-amber-300' : 'text-neutral-900 dark:text-neutral-100';
const limitDescriptorClass = overSoftLimit ? 'text-amber-700 dark:text-amber-300 font-semibold' : 'text-neutral-600 dark:text-neutral-400';
const recommendedDescriptorValue = (recommendedDisplay ?? limitDisplay);
const diskMetricsReliable = storageInfo.disk_metrics_reliable;
const overrideSource = storageInfo.disk_override_source;
@@ -161,26 +161,26 @@ export const StatusTab: React.FC<StatusTabProps> = ({
return (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<HardDrive className="w-5 h-5" />
{t('settings.systemStatus.storageOverview')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.storage.totalUsed')}</p>
<p className="text-2xl font-bold text-neutral-900">
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.storage.totalUsed')}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{settingsService.formatBytes(storageInfo.total_used)}
</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.storage.archiveStorage')}</p>
<p className="text-2xl font-bold text-neutral-900">
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.storage.archiveStorage')}</p>
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{settingsService.formatBytes(storageInfo.archive_storage)}
</p>
</div>
<div className={`rounded-lg p-4 ${limitCardClass}`}>
<p className="text-sm text-neutral-600">{t('settings.storage.storageLimit')}</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.storage.storageLimit')}</p>
<p className={`text-2xl font-bold ${limitValueClass}`}>
{limitDisplay}
</p>
@@ -194,12 +194,12 @@ export const StatusTab: React.FC<StatusTabProps> = ({
<div className="mb-4">
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-600">{t('settings.storage.storageUsage')}</span>
<span className={`font-medium ${overSoftLimit ? 'text-red-600' : 'text-neutral-900'}`}>
<span className="text-neutral-600 dark:text-neutral-400">{t('settings.storage.storageUsage')}</span>
<span className={`font-medium ${overSoftLimit ? 'text-red-600 dark:text-red-400' : 'text-neutral-900 dark:text-neutral-100'}`}>
{usagePercentage}%
</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-3">
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-3">
<div
className={`${progressColor} h-3 rounded-full transition-all`}
style={{ width: `${usageWidth}%` }}
@@ -207,24 +207,24 @@ export const StatusTab: React.FC<StatusTabProps> = ({
</div>
</div>
<div className="border-t border-neutral-200 pt-4 mt-6 space-y-4">
<p className="text-sm text-neutral-600">
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4 mt-6 space-y-4">
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('settings.storage.storageLimitHelper')}
</p>
{diskSummaryCards.length > 0 && (diskMetricsReliable || overrideSource) && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{diskSummaryCards.map((card) => (
<div key={card.label} className="bg-neutral-50 rounded-lg p-4">
<p className="text-xs text-neutral-500 uppercase tracking-wide">{card.label}</p>
<p className="text-lg font-semibold text-neutral-900 mt-1">{card.value}</p>
<div key={card.label} className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
<p className="text-xs text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">{card.label}</p>
<p className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mt-1">{card.value}</p>
</div>
))}
</div>
)}
{!diskMetricsReliable && !overrideSource && (
<p className="text-xs text-neutral-500">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.storage.diskMetricsUnavailable')}
</p>
)}
@@ -253,7 +253,7 @@ export const StatusTab: React.FC<StatusTabProps> = ({
helperText={t('settings.storage.softLimitHelper')}
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
/>
<p className="text-xs text-neutral-500">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('settings.storage.limitNotEnforced')}
</p>
</div>
@@ -303,13 +303,13 @@ export const StatusTab: React.FC<StatusTabProps> = ({
</Button>
</div>
<div className="border-t border-neutral-200 pt-4 mt-6 space-y-4">
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4 mt-6 space-y-4">
<div>
<p className="text-sm font-medium text-neutral-700">{t('settings.storage.overrideTitle')}</p>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('settings.storage.overrideTitle')}</p>
{overrideControlled ? (
<p className="text-xs text-neutral-500 mt-1">{t('settings.storage.diskOverrideEnvNote')}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('settings.storage.diskOverrideEnvNote')}</p>
) : (
<p className="text-xs text-neutral-500 mt-1">{t('settings.storage.diskOverrideSettingsHelp')}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('settings.storage.diskOverrideSettingsHelp')}</p>
)}
</div>
@@ -386,40 +386,40 @@ export const StatusTab: React.FC<StatusTabProps> = ({
{systemStatus && (
<>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Server className="w-5 h-5" />
{t('settings.systemStatus.systemInfo')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.platform')}</p>
<p className="font-semibold">{systemStatus.system.platform}</p>
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.platform')}</p>
<p className="font-semibold text-neutral-900 dark:text-neutral-100">{systemStatus.system.platform}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.nodeVersion')}</p>
<p className="font-semibold">{systemStatus.system.nodeVersion}</p>
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.nodeVersion')}</p>
<p className="font-semibold text-neutral-900 dark:text-neutral-100">{systemStatus.system.nodeVersion}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.uptime')}</p>
<p className="font-semibold">{Math.floor(systemStatus.system.uptime / 3600)}h {Math.floor((systemStatus.system.uptime % 3600) / 60)}m</p>
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.uptime')}</p>
<p className="font-semibold text-neutral-900 dark:text-neutral-100">{Math.floor(systemStatus.system.uptime / 3600)}h {Math.floor((systemStatus.system.uptime % 3600) / 60)}m</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.cpuCores')}</p>
<p className="font-semibold">{systemStatus.system.cpu.cores}</p>
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.cpuCores')}</p>
<p className="font-semibold text-neutral-900 dark:text-neutral-100">{systemStatus.system.cpu.cores}</p>
</div>
</div>
<div className="mt-4">
<h3 className="text-sm font-semibold text-neutral-900 mb-2">{t('settings.systemStatus.memoryUsage')}</h3>
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('settings.systemStatus.memoryUsage')}</h3>
<div className="mb-2">
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-600">{t('settings.systemStatus.memoryUsed')}</span>
<span className="font-medium">
<span className="text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.memoryUsed')}</span>
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{settingsService.formatBytes(systemStatus.system.memory.used)} / {settingsService.formatBytes(systemStatus.system.memory.total)}
</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-2">
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{
@@ -432,71 +432,71 @@ export const StatusTab: React.FC<StatusTabProps> = ({
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Database className="w-5 h-5" />
{t('settings.systemStatus.databaseInfo')}
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.events}</p>
<p className="text-xs text-neutral-600">{t('navigation.events')}</p>
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{systemStatus.database.tables.events}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('navigation.events')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.photos}</p>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.photos')}</p>
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{systemStatus.database.tables.photos}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.photos')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.admins}</p>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.admins')}</p>
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{systemStatus.database.tables.admins}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.admins')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.categories}</p>
<p className="text-xs text-neutral-600">{t('settings.categories.title')}</p>
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{systemStatus.database.tables.categories}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.categories.title')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{settingsService.formatBytes(systemStatus.database.size)}</p>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.dbSize')}</p>
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{settingsService.formatBytes(systemStatus.database.size)}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.dbSize')}</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Activity className="w-5 h-5" />
{t('settings.systemStatus.services')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-neutral-50 rounded-lg p-4">
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.fileWatcher')}</p>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('settings.systemStatus.fileWatcher')}</p>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.fileWatcherDesc')}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.fileWatcherDesc')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.expirationChecker')}</p>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('settings.systemStatus.expirationChecker')}</p>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.expirationCheckerDesc')}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.expirationCheckerDesc')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.emailProcessor')}</p>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('settings.systemStatus.emailProcessor')}</p>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.emailProcessorDesc')}</p>
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.emailProcessorDesc')}</p>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
<h3 className="text-sm font-semibold text-blue-900 mb-2">{t('settings.systemStatus.emailQueue')}</h3>
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-200 mb-2">{t('settings.systemStatus.emailQueue')}</h3>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-blue-700">{t('settings.systemStatus.pending')}:</span>
<span className="ml-2 font-semibold text-blue-900">
<span className="text-blue-700 dark:text-blue-300">{t('settings.systemStatus.pending')}:</span>
<span className="ml-2 font-semibold text-blue-900 dark:text-blue-200">
{systemStatus.emailQueue.pending}
{systemStatus.emailQueue.stuck > 0 && (
<span className="text-orange-600 text-xs ml-1">
@@ -506,17 +506,17 @@ export const StatusTab: React.FC<StatusTabProps> = ({
</span>
</div>
<div>
<span className="text-green-700">{t('settings.systemStatus.sent')}:</span>
<span className="ml-2 font-semibold text-green-900">{systemStatus.emailQueue.sent}</span>
<span className="text-green-700 dark:text-green-400">{t('settings.systemStatus.sent')}:</span>
<span className="ml-2 font-semibold text-green-900 dark:text-green-300">{systemStatus.emailQueue.sent}</span>
</div>
<div>
<span className="text-red-700">{t('settings.systemStatus.failed')}:</span>
<span className="ml-2 font-semibold text-red-900">{systemStatus.emailQueue.failed}</span>
<span className="text-red-700 dark:text-red-400">{t('settings.systemStatus.failed')}:</span>
<span className="ml-2 font-semibold text-red-900 dark:text-red-300">{systemStatus.emailQueue.failed}</span>
</div>
</div>
{systemStatus.emailQueue.stuck > 0 && (
<div className="mt-3 p-3 bg-orange-50 rounded-md">
<p className="text-xs text-orange-800">
<div className="mt-3 p-3 bg-orange-50 dark:bg-orange-900/30 rounded-md">
<p className="text-xs text-orange-800 dark:text-orange-200">
<span className="font-semibold">Warning: {systemStatus.emailQueue.stuck} email(s) stuck:</span> These emails have exceeded retry limits and won&apos;t be processed automatically.
Only {systemStatus.emailQueue.processable} of {systemStatus.emailQueue.pending} pending emails will be processed.
</p>
@@ -529,7 +529,7 @@ export const StatusTab: React.FC<StatusTabProps> = ({
{/* Last update time */}
{systemStatus && (
<div className="text-xs text-neutral-500 text-right flex items-center justify-end gap-1">
<div className="text-xs text-neutral-500 dark:text-neutral-400 text-right flex items-center justify-end gap-1">
<Clock className="w-3 h-3" />
{t('settings.systemStatus.lastUpdate')}: {new Date(systemStatus.timestamp).toLocaleString()}
</div>