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 -52
View File
@@ -109,7 +109,7 @@ export const BackupHistory = () => {
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
type="text"
placeholder={t('backup.history.searchPlaceholder')}
@@ -124,7 +124,7 @@ export const BackupHistory = () => {
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
className="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"
>
<option value="all">All Status</option>
<option value="completed">Completed</option>
@@ -149,33 +149,33 @@ export const BackupHistory = () => {
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<tr className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('backup.history.columns.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('backup.history.columns.dateTime')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('backup.history.columns.type')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('backup.history.columns.size')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('backup.history.columns.duration')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('backup.history.columns.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
{backups.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
<FileArchive className="h-12 w-12 mx-auto mb-3 text-gray-300" />
<p className="text-lg font-medium">No backups found</p>
<td colSpan={6} className="px-6 py-12 text-center text-neutral-500 dark:text-neutral-400">
<FileArchive className="h-12 w-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" />
<p className="text-lg font-medium text-neutral-900 dark:text-neutral-100">No backups found</p>
<p className="text-sm mt-1">Backups will appear here once created</p>
</td>
</tr>
@@ -188,40 +188,40 @@ export const BackupHistory = () => {
return (
<React.Fragment key={backup.id}>
<tr className="hover:bg-gray-50">
<tr className="hover:bg-neutral-50 dark:hover:bg-neutral-700/50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<StatusIcon className={`h-5 w-5 ${statusColor}`} />
<span className="ml-2 text-sm font-medium text-gray-900 capitalize">
<span className="ml-2 text-sm font-medium text-neutral-900 dark:text-neutral-100 capitalize">
{backup.status}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div>
<p className="text-sm font-medium text-gray-900">
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{format(new Date(backup.created_at), 'PPP')}
</p>
<p className="text-xs text-gray-500">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{format(new Date(backup.created_at), 'p')} {formatDistanceToNow(new Date(backup.created_at), { addSuffix: true })}
</p>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 capitalize">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300 capitalize">
{backup.backup_type || 'Manual'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<p className="text-sm text-gray-900">
<p className="text-sm text-neutral-900 dark:text-neutral-100">
{formatBytes(stats.total_size || 0)}
</p>
<p className="text-xs text-gray-500">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{stats.files_processed || 0} {t('backup.dashboard.stats.files')}
</p>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{backup.duration_seconds
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-900 dark:text-neutral-100">
{backup.duration_seconds
? `${Math.round(backup.duration_seconds / 60)}m ${backup.duration_seconds % 60}s`
: '-'}
</td>
@@ -229,7 +229,7 @@ export const BackupHistory = () => {
<div className="flex items-center justify-end space-x-2">
<button
onClick={() => toggleRowExpansion(backup.id)}
className="text-gray-400 hover:text-gray-600"
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
title={t('backup.actions.view')}
>
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
@@ -237,7 +237,7 @@ export const BackupHistory = () => {
{backup.manifest_path && (
<button
onClick={() => window.open(`/admin/backup/download/${backup.id}`, '_blank')}
className="text-gray-400 hover:text-gray-600"
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
title={t('backup.actions.download')}
>
<Download size={20} />
@@ -245,7 +245,7 @@ export const BackupHistory = () => {
)}
<button
onClick={() => handleDelete(backup)}
className="text-gray-400 hover:text-red-600"
className="text-neutral-400 hover:text-red-600"
title={t('backup.actions.delete')}
disabled={deleteMutation.isLoading}
>
@@ -258,24 +258,24 @@ export const BackupHistory = () => {
{/* Expanded Details Row */}
{isExpanded && (
<tr>
<td colSpan={6} className="px-6 py-4 bg-gray-50">
<td colSpan={6} className="px-6 py-4 bg-neutral-50 dark:bg-neutral-700/50">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Backup Details */}
<div className="space-y-2">
<h4 className="font-medium text-gray-900">{t('backup.history.details.backupDetails')}</h4>
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.history.details.backupDetails')}</h4>
<div className="text-sm space-y-1">
<div className="flex justify-between">
<span className="text-gray-500">{t('backup.history.details.destination')}:</span>
<span className="text-gray-900">{backup.destination_type || 'Unknown'}</span>
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.destination')}:</span>
<span className="text-neutral-900 dark:text-neutral-100">{backup.destination_type || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">{t('backup.history.details.started')}:</span>
<span className="text-gray-900">{format(new Date(backup.created_at), 'p')}</span>
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.started')}:</span>
<span className="text-neutral-900 dark:text-neutral-100">{format(new Date(backup.created_at), 'p')}</span>
</div>
{backup.completed_at && (
<div className="flex justify-between">
<span className="text-gray-500">{t('backup.history.details.completed')}:</span>
<span className="text-gray-900">{format(new Date(backup.completed_at), 'p')}</span>
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.completed')}:</span>
<span className="text-neutral-900 dark:text-neutral-100">{format(new Date(backup.completed_at), 'p')}</span>
</div>
)}
</div>
@@ -283,21 +283,21 @@ export const BackupHistory = () => {
{/* Content Backed Up */}
<div className="space-y-2">
<h4 className="font-medium text-gray-900">{t('backup.history.details.contentBackedUp')}</h4>
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.history.details.contentBackedUp')}</h4>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Database className={`h-4 w-4 ${stats.database_backed_up ? 'text-green-500' : 'text-gray-300'}`} />
<span className="text-sm text-gray-700">{t('backup.configuration.whatToBackup.database')}</span>
<Database className={`h-4 w-4 ${stats.database_backed_up ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.database')}</span>
</div>
<div className="flex items-center space-x-2">
<Image className={`h-4 w-4 ${stats.photos_backed_up > 0 ? 'text-green-500' : 'text-gray-300'}`} />
<span className="text-sm text-gray-700">
<Image className={`h-4 w-4 ${stats.photos_backed_up > 0 ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
Photos ({stats.photos_backed_up || 0} of {stats.total_photos || 0})
</span>
</div>
<div className="flex items-center space-x-2">
<FileArchive className={`h-4 w-4 ${stats.archives_backed_up > 0 ? 'text-green-500' : 'text-gray-300'}`} />
<span className="text-sm text-gray-700">
<FileArchive className={`h-4 w-4 ${stats.archives_backed_up > 0 ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
Archives ({stats.archives_backed_up || 0})
</span>
</div>
@@ -307,8 +307,8 @@ export const BackupHistory = () => {
{/* Error Information */}
{backup.error_message && (
<div className="space-y-2">
<h4 className="font-medium text-red-900">{t('backup.history.details.errorDetails')}</h4>
<p className="text-sm text-red-700 bg-red-50 p-2 rounded">
<h4 className="font-medium text-red-900 dark:text-red-200">{t('backup.history.details.errorDetails')}</h4>
<p className="text-sm text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/30 p-2 rounded">
{backup.error_message}
</p>
</div>
@@ -317,8 +317,8 @@ export const BackupHistory = () => {
{/* Manifest Path */}
{backup.manifest_path && (
<div className="space-y-2">
<h4 className="font-medium text-gray-900">{t('backup.history.details.manifest')}</h4>
<p className="text-sm text-gray-600 font-mono break-all">
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.history.details.manifest')}</h4>
<p className="text-sm text-neutral-600 dark:text-neutral-400 font-mono break-all">
{backup.manifest_path}
</p>
</div>
@@ -337,7 +337,7 @@ export const BackupHistory = () => {
{/* Pagination */}
{pagination.pages > 1 && (
<div className="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
<div className="bg-white dark:bg-neutral-800 px-4 py-3 border-t border-neutral-200 dark:border-neutral-700 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1 flex justify-between sm:hidden">
<Button
@@ -359,7 +359,7 @@ export const BackupHistory = () => {
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
<p className="text-sm text-neutral-700 dark:text-neutral-300">
{t('backup.history.pagination.showing', {
from: (currentPage - 1) * pagination.limit + 1,
to: Math.min(currentPage * pagination.limit, pagination.total),
@@ -372,11 +372,11 @@ export const BackupHistory = () => {
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm font-medium text-neutral-500 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('backup.history.pagination.previous')}
</button>
{[...Array(Math.min(5, pagination.pages))].map((_, i) => {
const pageNum = i + 1;
return (
@@ -385,19 +385,19 @@ export const BackupHistory = () => {
onClick={() => setCurrentPage(pageNum)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === pageNum
? 'z-10 bg-primary-50 border-primary text-primary'
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
? 'z-10 bg-primary-50 dark:bg-primary-900/30 border-primary text-primary'
: 'bg-white dark:bg-neutral-800 border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700'
}`}
>
{pageNum}
</button>
);
})}
<button
onClick={() => setCurrentPage(p => Math.min(pagination.pages, p + 1))}
disabled={currentPage === pagination.pages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm font-medium text-neutral-500 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('backup.history.pagination.next')}
</button>