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
@@ -152,8 +152,8 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
<Card className="p-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{t('backup.configuration.enableBackup')}</h3>
<p className="mt-1 text-sm text-gray-600">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('backup.configuration.enableBackup')}</h3>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('backup.configuration.enableBackupHelp')}
</p>
</div>
@@ -164,15 +164,15 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
onChange={(e) => handleChange('backup_enabled', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
<div className="w-11 h-6 bg-neutral-200 dark:bg-neutral-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 dark:after:border-neutral-500 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
</Card>
{/* Destination Configuration */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.configuration.destinationType')}</h3>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.destinationType')}</h3>
{/* Destination Type Selection */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
{destinationTypes.map((type) => {
@@ -184,17 +184,17 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
onClick={() => handleChange('backup_destination_type', type.id)}
className={`p-4 rounded-lg border-2 transition-all ${
formData.backup_destination_type === type.id
? 'border-primary bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
? 'border-primary bg-primary-50 dark:bg-primary-900/30'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<Icon className={`h-8 w-8 mb-2 mx-auto ${
formData.backup_destination_type === type.id
? 'text-primary'
: 'text-gray-400'
: 'text-neutral-400'
}`} />
<h4 className="font-medium text-gray-900">{type.name}</h4>
<p className="text-xs text-gray-500 mt-1">{type.description}</p>
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{type.name}</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{type.description}</p>
</button>
);
})}
@@ -205,7 +205,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
{formData.backup_destination_type === 'local' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.destinationPath')}
</label>
<Input
@@ -215,7 +215,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
placeholder={t('backup.configuration.fields.destinationPathPlaceholder')}
required
/>
<p className="mt-1 text-xs text-gray-500">
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('backup.configuration.fields.destinationPathHelp')}
</p>
</div>
@@ -226,7 +226,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.rsyncHost')}
</label>
<Input
@@ -238,7 +238,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.rsyncUser')}
</label>
<Input
@@ -251,7 +251,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.rsyncPath')}
</label>
<Input
@@ -263,7 +263,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.rsyncSshKey')}
</label>
<div className="relative">
@@ -271,18 +271,18 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
value={formData.backup_rsync_ssh_key}
onChange={(e) => handleChange('backup_rsync_ssh_key', e.target.value)}
placeholder={t('backup.configuration.fields.rsyncSshKeyPlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary font-mono text-sm"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:outline-none focus:ring-primary focus:border-primary font-mono text-sm"
rows={4}
/>
<button
type="button"
onClick={() => setShowSecrets(prev => ({ ...prev, ssh_key: !prev.ssh_key }))}
className="absolute top-2 right-2 text-gray-400 hover:text-gray-600"
className="absolute top-2 right-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
>
{showSecrets.ssh_key ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
<p className="mt-1 text-xs text-gray-500">
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('backup.configuration.fields.rsyncSshKeyHelp')}
</p>
</div>
@@ -292,7 +292,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
{formData.backup_destination_type === 's3' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.s3Endpoint')}
</label>
<Input
@@ -302,13 +302,13 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
placeholder="https://s3.amazonaws.com"
required
/>
<p className="mt-1 text-xs text-gray-500">
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('backup.configuration.fields.s3EndpointHelp')}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.s3Bucket')}
</label>
<Input
@@ -320,7 +320,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.s3Region')}
</label>
<Input
@@ -333,7 +333,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.s3AccessKey')}
</label>
<Input
@@ -345,7 +345,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.s3SecretKey')}
</label>
<div className="relative">
@@ -359,7 +359,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
<button
type="button"
onClick={() => setShowSecrets(prev => ({ ...prev, s3_secret_key: !prev.s3_secret_key }))}
className="absolute top-1/2 -translate-y-1/2 right-2 text-gray-400 hover:text-gray-600"
className="absolute top-1/2 -translate-y-1/2 right-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
>
{showSecrets.s3_secret_key ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
@@ -398,17 +398,17 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
{/* Schedule Configuration */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.configuration.schedule.title')}</h3>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.schedule.title')}</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.schedule.scheduleType')}
</label>
<select
value={formData.backup_schedule}
onChange={(e) => handleChange('backup_schedule', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
>
{scheduleOptions.map(option => (
<option key={option.value} value={option.value}>
@@ -420,7 +420,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
{formData.backup_schedule === 'custom' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.schedule.customCron')}
</label>
<Input
@@ -429,14 +429,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
onChange={(e) => handleChange('backup_schedule_cron', e.target.value)}
placeholder="0 3 * * *"
/>
<p className="mt-1 text-xs text-gray-500">
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('backup.configuration.schedule.customCronHelp')}
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.schedule.retention')}
</label>
<Input
@@ -446,7 +446,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
min="1"
max="365"
/>
<p className="mt-1 text-xs text-gray-500">
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('backup.configuration.schedule.retentionHelp')}
</p>
</div>
@@ -455,7 +455,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
{/* Backup Content Selection */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.configuration.whatToBackup.title')}</h3>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.whatToBackup.title')}</h3>
<div className="space-y-3">
<label className="flex items-center">
@@ -463,14 +463,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
type="checkbox"
checked={formData.backup_include_database}
onChange={(e) => handleChange('backup_include_database', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div className="ml-3">
<div className="flex items-center space-x-2">
<Database className="h-4 w-4 text-gray-400" />
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.database')}</span>
<Database className="h-4 w-4 text-neutral-400" />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.database')}</span>
</div>
<p className="text-xs text-gray-500">{t('backup.configuration.whatToBackup.databaseHelp')}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.databaseHelp')}</p>
</div>
</label>
@@ -479,14 +479,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
type="checkbox"
checked={formData.backup_include_photos}
onChange={(e) => handleChange('backup_include_photos', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div className="ml-3">
<div className="flex items-center space-x-2">
<Image className="h-4 w-4 text-gray-400" />
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.photos')}</span>
<Image className="h-4 w-4 text-neutral-400" />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.photos')}</span>
</div>
<p className="text-xs text-gray-500">{t('backup.configuration.whatToBackup.photosHelp')}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.photosHelp')}</p>
</div>
</label>
@@ -495,14 +495,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
type="checkbox"
checked={formData.backup_include_archives}
onChange={(e) => handleChange('backup_include_archives', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div className="ml-3">
<div className="flex items-center space-x-2">
<FileArchive className="h-4 w-4 text-gray-400" />
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.archives')}</span>
<FileArchive className="h-4 w-4 text-neutral-400" />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.archives')}</span>
</div>
<p className="text-xs text-gray-500">{t('backup.configuration.whatToBackup.archivesHelp')}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.archivesHelp')}</p>
</div>
</label>
@@ -511,14 +511,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
type="checkbox"
checked={formData.backup_include_thumbnails}
onChange={(e) => handleChange('backup_include_thumbnails', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div className="ml-3">
<div className="flex items-center space-x-2">
<Image className="h-4 w-4 text-gray-400" />
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.thumbnails')}</span>
<Image className="h-4 w-4 text-neutral-400" />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.thumbnails')}</span>
</div>
<p className="text-xs text-gray-500">{t('backup.configuration.whatToBackup.thumbnailsHelp')}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.thumbnailsHelp')}</p>
</div>
</label>
</div>
@@ -526,7 +526,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
{/* Advanced Options */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.configuration.advancedOptions.title')}</h3>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.advancedOptions.title')}</h3>
<div className="space-y-4">
<label className="flex items-center">
@@ -534,11 +534,11 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
type="checkbox"
checked={formData.backup_compression}
onChange={(e) => handleChange('backup_compression', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div className="ml-3">
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.advancedOptions.compression')}</span>
<p className="text-xs text-gray-500">{t('backup.configuration.advancedOptions.compressionHelp')}</p>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.advancedOptions.compression')}</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.advancedOptions.compressionHelp')}</p>
</div>
</label>
@@ -548,17 +548,17 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
type="checkbox"
checked={formData.backup_encryption}
onChange={(e) => handleChange('backup_encryption', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div className="ml-3">
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.advancedOptions.encryption')}</span>
<p className="text-xs text-gray-500">{t('backup.configuration.advancedOptions.encryptionHelp')}</p>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.advancedOptions.encryption')}</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.advancedOptions.encryptionHelp')}</p>
</div>
</label>
{formData.backup_encryption && (
<div className="ml-7">
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.advancedOptions.encryptionPassphrase')}
</label>
<div className="relative">
@@ -572,7 +572,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
<button
type="button"
onClick={() => setShowSecrets(prev => ({ ...prev, encryption_passphrase: !prev.encryption_passphrase }))}
className="absolute top-1/2 -translate-y-1/2 right-2 text-gray-400 hover:text-gray-600"
className="absolute top-1/2 -translate-y-1/2 right-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
>
{showSecrets.encryption_passphrase ? <EyeOff size={20} /> : <Eye size={20} />}
</button>