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:
@@ -110,3 +110,6 @@ test-results/
|
|||||||
|
|
||||||
# Development docker compose
|
# Development docker compose
|
||||||
docker-compose.dev.yml
|
docker-compose.dev.yml
|
||||||
|
|
||||||
|
# New layout development files
|
||||||
|
new-layouts/
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const DEFAULT_AI_AGENTS = [
|
||||||
|
'GPTBot',
|
||||||
|
'ChatGPT-User',
|
||||||
|
'Google-Extended',
|
||||||
|
'Claude-Web',
|
||||||
|
'Anthropic-AI',
|
||||||
|
'CCBot',
|
||||||
|
'Bytespider',
|
||||||
|
'FacebookBot',
|
||||||
|
'Omgilibot',
|
||||||
|
'Diffbot',
|
||||||
|
'PetalBot',
|
||||||
|
'Amazonbot',
|
||||||
|
'PerplexityBot',
|
||||||
|
'YouBot',
|
||||||
|
'Applebot-Extended'
|
||||||
|
];
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
const defaults = [
|
||||||
|
{ setting_key: 'seo_allow_indexing', setting_value: JSON.stringify(false), setting_type: 'seo' },
|
||||||
|
{ setting_key: 'seo_block_ai_crawlers', setting_value: JSON.stringify(true), setting_type: 'seo' },
|
||||||
|
{ setting_key: 'seo_block_social_bots', setting_value: JSON.stringify(false), setting_type: 'seo' },
|
||||||
|
{ setting_key: 'seo_blocked_ai_agents', setting_value: JSON.stringify(DEFAULT_AI_AGENTS), setting_type: 'seo' },
|
||||||
|
{ setting_key: 'seo_custom_rules', setting_value: JSON.stringify([]), setting_type: 'seo' },
|
||||||
|
{ setting_key: 'seo_meta_noindex', setting_value: JSON.stringify(true), setting_type: 'seo' },
|
||||||
|
{ setting_key: 'seo_meta_nofollow', setting_value: JSON.stringify(false), setting_type: 'seo' },
|
||||||
|
{ setting_key: 'seo_meta_noai', setting_value: JSON.stringify(true), setting_type: 'seo' },
|
||||||
|
{ setting_key: 'seo_sitemap_url', setting_value: JSON.stringify(''), setting_type: 'seo' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const setting of defaults) {
|
||||||
|
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
|
||||||
|
if (!exists) {
|
||||||
|
await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
await knex('app_settings')
|
||||||
|
.whereIn('setting_key', [
|
||||||
|
'seo_allow_indexing',
|
||||||
|
'seo_block_ai_crawlers',
|
||||||
|
'seo_block_social_bots',
|
||||||
|
'seo_blocked_ai_agents',
|
||||||
|
'seo_custom_rules',
|
||||||
|
'seo_meta_noindex',
|
||||||
|
'seo_meta_nofollow',
|
||||||
|
'seo_meta_noai',
|
||||||
|
'seo_sitemap_url'
|
||||||
|
])
|
||||||
|
.del();
|
||||||
|
};
|
||||||
@@ -252,10 +252,29 @@ function renderBrandFooter(branding) {
|
|||||||
</footer>`;
|
</footer>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSeoMetaTags(seoSettings) {
|
||||||
|
const tags = [];
|
||||||
|
const robotsDirectives = [];
|
||||||
|
|
||||||
|
if (seoSettings.seo_meta_noindex) robotsDirectives.push('noindex');
|
||||||
|
if (seoSettings.seo_meta_nofollow) robotsDirectives.push('nofollow');
|
||||||
|
|
||||||
|
if (robotsDirectives.length > 0) {
|
||||||
|
tags.push(`<meta name="robots" content="${robotsDirectives.join(', ')}" />`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seoSettings.seo_meta_noai) {
|
||||||
|
tags.push('<meta name="robots" content="noai, noimageai" />');
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags.join('\n ');
|
||||||
|
}
|
||||||
|
|
||||||
function buildPublicSiteDocument(payload) {
|
function buildPublicSiteDocument(payload) {
|
||||||
const inlineStyles = composeInlineStyles(payload);
|
const inlineStyles = composeInlineStyles(payload);
|
||||||
const header = renderBrandHeader(payload.branding);
|
const header = renderBrandHeader(payload.branding);
|
||||||
const footer = renderBrandFooter(payload.branding);
|
const footer = renderBrandFooter(payload.branding);
|
||||||
|
const seoMeta = payload.seoSettings ? buildSeoMetaTags(payload.seoSettings) : '';
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
@@ -265,6 +284,7 @@ function buildPublicSiteDocument(payload) {
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>${payload.title}</title>
|
<title>${payload.title}</title>
|
||||||
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
|
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
|
||||||
|
${seoMeta}
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
@@ -296,6 +316,21 @@ async function handlePublicSiteRequest(req, res, next) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject SEO meta settings into payload
|
||||||
|
try {
|
||||||
|
const seoRows = await db('app_settings')
|
||||||
|
.where('setting_type', 'seo')
|
||||||
|
.whereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai'])
|
||||||
|
.select('setting_key', 'setting_value');
|
||||||
|
const seoSettings = {};
|
||||||
|
for (const row of seoRows) {
|
||||||
|
let val = row.setting_value;
|
||||||
|
if (typeof val === 'string') { try { val = JSON.parse(val); } catch {} }
|
||||||
|
seoSettings[row.setting_key] = val;
|
||||||
|
}
|
||||||
|
payload.seoSettings = seoSettings;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const document = buildPublicSiteDocument(payload);
|
const document = buildPublicSiteDocument(payload);
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
@@ -395,6 +430,22 @@ if (process.env.NODE_ENV === 'development') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// robots.txt endpoint (dynamic, served from DB settings)
|
||||||
|
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
|
||||||
|
app.get('/robots.txt', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const robotsTxt = await generateRobotsTxt();
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||||
|
res.status(200).send(robotsTxt);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to generate robots.txt', { error: error.message });
|
||||||
|
// Safe default for a private photo platform
|
||||||
|
res.setHeader('Content-Type', 'text/plain');
|
||||||
|
res.status(200).send('User-agent: *\nDisallow: /\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/health', async (req, res) => {
|
app.get('/health', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -733,6 +733,70 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update SEO settings
|
||||||
|
router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = req.body;
|
||||||
|
|
||||||
|
// Validate seo_blocked_ai_agents is an array of strings
|
||||||
|
if (settings.seo_blocked_ai_agents !== undefined) {
|
||||||
|
if (!Array.isArray(settings.seo_blocked_ai_agents) ||
|
||||||
|
!settings.seo_blocked_ai_agents.every(a => typeof a === 'string')) {
|
||||||
|
return res.status(400).json({ error: 'seo_blocked_ai_agents must be an array of strings' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate seo_custom_rules structure
|
||||||
|
if (settings.seo_custom_rules !== undefined) {
|
||||||
|
if (!Array.isArray(settings.seo_custom_rules)) {
|
||||||
|
return res.status(400).json({ error: 'seo_custom_rules must be an array' });
|
||||||
|
}
|
||||||
|
for (const rule of settings.seo_custom_rules) {
|
||||||
|
if (!rule.userAgent || typeof rule.userAgent !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Each custom rule must have a userAgent string' });
|
||||||
|
}
|
||||||
|
if (!Array.isArray(rule.disallow) || !rule.disallow.every(d => typeof d === 'string')) {
|
||||||
|
return res.status(400).json({ error: 'Each custom rule must have a disallow array of strings' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or insert each setting
|
||||||
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
|
await db('app_settings')
|
||||||
|
.insert({
|
||||||
|
setting_key: key,
|
||||||
|
setting_value: JSON.stringify(value),
|
||||||
|
setting_type: 'seo',
|
||||||
|
updated_at: new Date()
|
||||||
|
})
|
||||||
|
.onConflict('setting_key')
|
||||||
|
.merge({
|
||||||
|
setting_value: JSON.stringify(value),
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear robots.txt cache
|
||||||
|
const { clearRobotsTxtCache } = require('../services/robotsTxtService');
|
||||||
|
clearRobotsTxtCache();
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await db('activity_logs').insert({
|
||||||
|
activity_type: 'seo_settings_updated',
|
||||||
|
actor_type: 'admin',
|
||||||
|
actor_id: req.admin.id,
|
||||||
|
actor_name: req.admin.username,
|
||||||
|
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'SEO settings updated successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SEO settings update error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update SEO settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get storage info
|
// Get storage info
|
||||||
router.get('/storage/info', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
router.get('/storage/info', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ router.get('/', async (req, res) => {
|
|||||||
.where(function() {
|
.where(function() {
|
||||||
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics', 'boolean'])
|
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics', 'boolean'])
|
||||||
.orWhere('setting_key', 'like', 'analytics_%')
|
.orWhere('setting_key', 'like', 'analytics_%')
|
||||||
.orWhere('setting_key', 'like', 'event_require_%');
|
.orWhere('setting_key', 'like', 'event_require_%')
|
||||||
|
.orWhereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai']);
|
||||||
})
|
})
|
||||||
.select('setting_key', 'setting_value');
|
.select('setting_key', 'setting_value');
|
||||||
});
|
});
|
||||||
@@ -76,7 +77,11 @@ router.get('/', async (req, res) => {
|
|||||||
event_require_customer_email: settingsObject.event_require_customer_email !== false,
|
event_require_customer_email: settingsObject.event_require_customer_email !== false,
|
||||||
event_require_admin_email: settingsObject.event_require_admin_email !== false,
|
event_require_admin_email: settingsObject.event_require_admin_email !== false,
|
||||||
event_require_event_date: settingsObject.event_require_event_date !== false,
|
event_require_event_date: settingsObject.event_require_event_date !== false,
|
||||||
event_require_expiration: settingsObject.event_require_expiration !== false
|
event_require_expiration: settingsObject.event_require_expiration !== false,
|
||||||
|
// SEO meta tag flags (safe to expose - these are intended for crawlers)
|
||||||
|
seo_meta_noindex: settingsObject.seo_meta_noindex === true,
|
||||||
|
seo_meta_nofollow: settingsObject.seo_meta_nofollow === true,
|
||||||
|
seo_meta_noai: settingsObject.seo_meta_noai === true
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json(publicSettings);
|
res.json(publicSettings);
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
const { db } = require('../database/db');
|
||||||
|
|
||||||
|
let cachedRobotsTxt = null;
|
||||||
|
let cacheTimestamp = 0;
|
||||||
|
const CACHE_TTL_MS = 60 * 1000; // 60 seconds
|
||||||
|
|
||||||
|
function parseSetting(raw) {
|
||||||
|
if (raw === null || raw === undefined) return null;
|
||||||
|
if (typeof raw !== 'string') return raw;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSeoSettings() {
|
||||||
|
const rows = await db('app_settings')
|
||||||
|
.where('setting_type', 'seo')
|
||||||
|
.select('setting_key', 'setting_value');
|
||||||
|
|
||||||
|
const settings = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
settings[row.setting_key] = parseSetting(row.setting_value);
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOCIAL_BOTS = [
|
||||||
|
'Twitterbot',
|
||||||
|
'facebookexternalhit',
|
||||||
|
'LinkedInBot',
|
||||||
|
'Slackbot',
|
||||||
|
'WhatsApp',
|
||||||
|
'TelegramBot',
|
||||||
|
'Discordbot'
|
||||||
|
];
|
||||||
|
|
||||||
|
async function generateRobotsTxt() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cachedRobotsTxt && (now - cacheTimestamp) < CACHE_TTL_MS) {
|
||||||
|
return cachedRobotsTxt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await getSeoSettings();
|
||||||
|
const allowIndexing = settings.seo_allow_indexing === true;
|
||||||
|
const blockAiCrawlers = settings.seo_block_ai_crawlers !== false;
|
||||||
|
const blockSocialBots = settings.seo_block_social_bots === true;
|
||||||
|
const aiAgents = Array.isArray(settings.seo_blocked_ai_agents)
|
||||||
|
? settings.seo_blocked_ai_agents
|
||||||
|
: [];
|
||||||
|
const customRules = Array.isArray(settings.seo_custom_rules)
|
||||||
|
? settings.seo_custom_rules
|
||||||
|
: [];
|
||||||
|
const sitemapUrl = settings.seo_sitemap_url || '';
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
// Always block admin and API paths for all agents
|
||||||
|
lines.push('# Protected paths');
|
||||||
|
lines.push('User-agent: *');
|
||||||
|
lines.push('Disallow: /admin');
|
||||||
|
lines.push('Disallow: /api');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
if (!allowIndexing) {
|
||||||
|
// Block everything for all agents
|
||||||
|
lines.push('# Indexing disabled - block all crawlers');
|
||||||
|
lines.push('User-agent: *');
|
||||||
|
lines.push('Disallow: /');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block AI crawlers if enabled
|
||||||
|
if (blockAiCrawlers && aiAgents.length > 0) {
|
||||||
|
lines.push('# AI/LLM crawler blocking');
|
||||||
|
for (const agent of aiAgents) {
|
||||||
|
lines.push(`User-agent: ${agent}`);
|
||||||
|
lines.push('Disallow: /');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block social bots if enabled
|
||||||
|
if (blockSocialBots) {
|
||||||
|
lines.push('# Social media bot blocking');
|
||||||
|
for (const bot of SOCIAL_BOTS) {
|
||||||
|
lines.push(`User-agent: ${bot}`);
|
||||||
|
lines.push('Disallow: /');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom rules
|
||||||
|
if (customRules.length > 0) {
|
||||||
|
lines.push('# Custom rules');
|
||||||
|
for (const rule of customRules) {
|
||||||
|
if (rule.userAgent && Array.isArray(rule.disallow)) {
|
||||||
|
lines.push(`User-agent: ${rule.userAgent}`);
|
||||||
|
for (const path of rule.disallow) {
|
||||||
|
lines.push(`Disallow: ${path}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sitemap
|
||||||
|
if (sitemapUrl) {
|
||||||
|
lines.push(`Sitemap: ${sitemapUrl}`);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = lines.join('\n');
|
||||||
|
cachedRobotsTxt = result;
|
||||||
|
cacheTimestamp = now;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRobotsTxtCache() {
|
||||||
|
cachedRobotsTxt = null;
|
||||||
|
cacheTimestamp = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateRobotsTxt,
|
||||||
|
clearRobotsTxtCache
|
||||||
|
};
|
||||||
@@ -111,6 +111,17 @@ server {
|
|||||||
proxy_cache_valid 404 1m;
|
proxy_cache_valid 404 1m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Dynamic robots.txt served by backend
|
||||||
|
location = /robots.txt {
|
||||||
|
set $backend_upstream backend;
|
||||||
|
proxy_pass http://$backend_upstream:3000/robots.txt;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
# Delegate root requests to backend for public landing page handling
|
# Delegate root requests to backend for public landing page handling
|
||||||
location = / {
|
location = / {
|
||||||
# Use variable to force DNS resolution per request (required for Docker Swarm)
|
# Use variable to force DNS resolution per request (required for Docker Swarm)
|
||||||
|
|||||||
+15
-3
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ToastContainer } from 'react-toastify';
|
import { ToastContainer } from 'react-toastify';
|
||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
} from './pages/admin';
|
} from './pages/admin';
|
||||||
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
|
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
|
||||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
|
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags } from './components/common';
|
||||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||||
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
||||||
import { getApiBaseUrl } from './utils/url';
|
import { getApiBaseUrl } from './utils/url';
|
||||||
@@ -45,6 +45,17 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
// Track dark mode for toast theming
|
||||||
|
const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
setToastTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light');
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Initialize Umami Analytics based on settings
|
// Initialize Umami Analytics based on settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeAnalytics = async () => {
|
const initializeAnalytics = async () => {
|
||||||
@@ -103,6 +114,7 @@ function App() {
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<GlobalThemeProvider>
|
<GlobalThemeProvider>
|
||||||
<DynamicFavicon />
|
<DynamicFavicon />
|
||||||
|
<RobotsMetaTags />
|
||||||
<Router>
|
<Router>
|
||||||
<MaintenanceWrapper>
|
<MaintenanceWrapper>
|
||||||
<SkipLink />
|
<SkipLink />
|
||||||
@@ -165,7 +177,7 @@ function App() {
|
|||||||
pauseOnFocusLoss
|
pauseOnFocusLoss
|
||||||
draggable
|
draggable
|
||||||
pauseOnHover
|
pauseOnHover
|
||||||
theme="light"
|
theme={toastTheme}
|
||||||
/>
|
/>
|
||||||
</GlobalThemeProvider>
|
</GlobalThemeProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { AdminAuthProvider, PermissionsProvider } from '../../contexts';
|
import { AdminAuthProvider, PermissionsProvider } from '../../contexts';
|
||||||
|
import { AdminDarkModeProvider } from '../../contexts/AdminDarkModeContext';
|
||||||
|
|
||||||
export const AdminAuthWrapper: React.FC = () => {
|
export const AdminAuthWrapper: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<AdminAuthProvider>
|
<AdminAuthProvider>
|
||||||
<PermissionsProvider>
|
<PermissionsProvider>
|
||||||
<Outlet />
|
<AdminDarkModeProvider>
|
||||||
|
<Outlet />
|
||||||
|
</AdminDarkModeProvider>
|
||||||
</PermissionsProvider>
|
</PermissionsProvider>
|
||||||
</AdminAuthProvider>
|
</AdminAuthProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2 } from 'lucide-react';
|
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2, Sun, Moon } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
import { useLocalizedTimeAgo } from '../../hooks/useLocalizedTimeAgo';
|
import { useLocalizedTimeAgo } from '../../hooks/useLocalizedTimeAgo';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { useAdminAuth } from '../../contexts';
|
import { useAdminAuth } from '../../contexts';
|
||||||
|
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
||||||
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
|
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
|
||||||
import { PasswordChangeModal } from './PasswordChangeModal';
|
import { PasswordChangeModal } from './PasswordChangeModal';
|
||||||
import { LanguageSelector } from '../common';
|
import { LanguageSelector } from '../common';
|
||||||
@@ -20,6 +21,7 @@ interface AdminHeaderProps {
|
|||||||
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, logout } = useAdminAuth();
|
const { user, logout } = useAdminAuth();
|
||||||
|
const { isDark, toggle: toggleDarkMode } = useAdminDarkMode();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { format } = useLocalizedDate();
|
const { format } = useLocalizedDate();
|
||||||
const { formatTimeAgo } = useLocalizedTimeAgo();
|
const { formatTimeAgo } = useLocalizedTimeAgo();
|
||||||
@@ -68,7 +70,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
const unreadCount = notificationsData?.unreadCount || 0;
|
const unreadCount = notificationsData?.unreadCount || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 bg-white border-b border-neutral-200">
|
<header className="sticky top-0 z-30 bg-white dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<div className="px-4 sm:px-6 lg:px-8">
|
<div className="px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
{/* Left side - Menu button, Logo, and Date */}
|
{/* Left side - Menu button, Logo, and Date */}
|
||||||
@@ -87,8 +89,8 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date display - hidden on smaller screens */}
|
{/* Date display - hidden on smaller screens */}
|
||||||
<div className="hidden xl:block pl-3 border-l border-neutral-200 ml-1">
|
<div className="hidden xl:block pl-3 border-l border-neutral-200 dark:border-neutral-700 ml-1">
|
||||||
<p className="text-base text-neutral-700">
|
<p className="text-base text-neutral-700 dark:text-neutral-300">
|
||||||
{format(new Date(), 'PPPP')}
|
{format(new Date(), 'PPPP')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,11 +101,20 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
{/* Language Selector */}
|
{/* Language Selector */}
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
|
|
||||||
|
{/* Dark Mode Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleDarkMode}
|
||||||
|
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||||
|
title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<div className="relative" ref={notificationRef}>
|
<div className="relative" ref={notificationRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNotifications(!showNotifications)}
|
onClick={() => setShowNotifications(!showNotifications)}
|
||||||
className="relative p-2 text-neutral-500 hover:text-neutral-700 hover:bg-neutral-100 rounded-lg transition-colors"
|
className="relative p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<Bell className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
@@ -113,14 +124,14 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
|
|
||||||
{/* Notifications dropdown */}
|
{/* Notifications dropdown */}
|
||||||
{showNotifications && (
|
{showNotifications && (
|
||||||
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-lg border border-neutral-200">
|
<div className="absolute right-0 mt-2 w-96 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700">
|
||||||
<div className="px-4 py-3 border-b border-neutral-100 flex items-center justify-between">
|
<div className="px-4 py-3 border-b border-neutral-100 dark:border-neutral-700 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">{t('admin.notifications')}</h3>
|
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{t('admin.notifications')}</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => markAllAsReadMutation.mutate()}
|
onClick={() => markAllAsReadMutation.mutate()}
|
||||||
className="text-xs text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
className="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 flex items-center gap-1"
|
||||||
title={t('admin.markAllRead')}
|
title={t('admin.markAllRead')}
|
||||||
>
|
>
|
||||||
<CheckCircle className="w-3 h-3" />
|
<CheckCircle className="w-3 h-3" />
|
||||||
@@ -129,7 +140,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => clearAllMutation.mutate()}
|
onClick={() => clearAllMutation.mutate()}
|
||||||
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
|
className="text-xs text-neutral-600 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 flex items-center gap-1"
|
||||||
title={t('admin.clearAll')}
|
title={t('admin.clearAll')}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
@@ -139,7 +150,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<div className="max-h-96 overflow-y-auto">
|
||||||
{notifications.length === 0 ? (
|
{notifications.length === 0 ? (
|
||||||
<div className="px-4 py-8 text-center text-sm text-neutral-500">
|
<div className="px-4 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
{t('admin.noNotificationsMessage')}
|
{t('admin.noNotificationsMessage')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -148,7 +159,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
className={`px-4 py-3 hover:bg-neutral-50 cursor-pointer border-l-4 ${
|
className={`px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-700 cursor-pointer border-l-4 ${
|
||||||
notification.isRead ? 'border-transparent opacity-75' : 'border-primary-500'
|
notification.isRead ? 'border-transparent opacity-75' : 'border-primary-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -157,10 +168,10 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
<Bell className="w-4 h-4" />
|
<Bell className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-neutral-900">
|
<p className="text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
{notificationsService.formatNotificationMessage(notification)}
|
{notificationsService.formatNotificationMessage(notification)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
{formatTimeAgo(notification.createdAt)}
|
{formatTimeAgo(notification.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,10 +182,10 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<div className="px-4 py-2 border-t border-neutral-100 text-center">
|
<div className="px-4 py-2 border-t border-neutral-100 dark:border-neutral-700 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNotifications(false)}
|
onClick={() => setShowNotifications(false)}
|
||||||
className="text-sm text-primary-600 hover:text-primary-700"
|
className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
|
||||||
>
|
>
|
||||||
{t('admin.close')}
|
{t('admin.close')}
|
||||||
</button>
|
</button>
|
||||||
@@ -188,11 +199,11 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
<div className="relative" ref={userMenuRef}>
|
<div className="relative" ref={userMenuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowUserMenu(!showUserMenu)}
|
onClick={() => setShowUserMenu(!showUserMenu)}
|
||||||
className="flex items-center gap-3 p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
className="flex items-center gap-3 p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<div className="text-right hidden sm:block">
|
<div className="text-right hidden sm:block">
|
||||||
<p className="text-sm font-medium text-neutral-900">{user?.username}</p>
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{user?.username}</p>
|
||||||
<p className="text-xs text-neutral-500">{user?.email}</p>
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
|
<div className="w-8 h-8 bg-primary-600 rounded-full flex items-center justify-center">
|
||||||
<User className="w-5 h-5 text-white" />
|
<User className="w-5 h-5 text-white" />
|
||||||
@@ -201,17 +212,17 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
|
|
||||||
{/* User dropdown */}
|
{/* User dropdown */}
|
||||||
{showUserMenu && (
|
{showUserMenu && (
|
||||||
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-neutral-200 py-1">
|
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 py-1">
|
||||||
<div className="px-4 py-2 border-b border-neutral-100 sm:hidden">
|
<div className="px-4 py-2 border-b border-neutral-100 dark:border-neutral-700 sm:hidden">
|
||||||
<p className="text-sm font-medium text-neutral-900">{user?.username}</p>
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{user?.username}</p>
|
||||||
<p className="text-xs text-neutral-500">{user?.email}</p>
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowUserMenu(false);
|
setShowUserMenu(false);
|
||||||
navigate('/admin/settings');
|
navigate('/admin/settings');
|
||||||
}}
|
}}
|
||||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
|
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
{t('navigation.settings')}
|
{t('navigation.settings')}
|
||||||
@@ -221,14 +232,14 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
setShowUserMenu(false);
|
setShowUserMenu(false);
|
||||||
setShowPasswordModal(true);
|
setShowPasswordModal(true);
|
||||||
}}
|
}}
|
||||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
|
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<Lock className="w-4 h-4" />
|
<Lock className="w-4 h-4" />
|
||||||
{t('admin.changePassword')}
|
{t('admin.changePassword')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
|
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
{t('common.logout')}
|
{t('common.logout')}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const AdminLayout: React.FC = () => {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-950 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="w-16 h-16 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
<div className="w-16 h-16 border-4 border-primary-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
||||||
<p className="text-neutral-600">Loading...</p>
|
<p className="text-neutral-600">Loading...</p>
|
||||||
@@ -31,7 +31,7 @@ export const AdminLayout: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-neutral-50 flex overflow-hidden">
|
<div className="h-screen bg-neutral-50 dark:bg-neutral-950 flex overflow-hidden">
|
||||||
{/* Mandatory Password Change Modal */}
|
{/* Mandatory Password Change Modal */}
|
||||||
{mustChangePassword && <MandatoryPasswordChangeModal />}
|
{mustChangePassword && <MandatoryPasswordChangeModal />}
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
|
|
||||||
{selectedPhotos.size > 0 && (
|
{selectedPhotos.size > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-sm text-neutral-600">
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('gallery.photosSelected', { count: selectedPhotos.size })}
|
{t('gallery.photosSelected', { count: selectedPhotos.size })}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -215,7 +215,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-neutral-600">
|
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('gallery.photosCount', { count: photos.length })}
|
{t('gallery.photosCount', { count: photos.length })}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,7 +234,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
data-testid={`admin-photo-tile-${photo.id}`}
|
data-testid={`admin-photo-tile-${photo.id}`}
|
||||||
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 transition-opacity ${
|
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-800 transition-opacity ${
|
||||||
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
|
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
|
||||||
} ${isDeleting ? 'opacity-50' : ''}`}
|
} ${isDeleting ? 'opacity-50' : ''}`}
|
||||||
onClick={() => !isDeleting && onPhotoClick(photo, index)}
|
onClick={() => !isDeleting && onPhotoClick(photo, index)}
|
||||||
@@ -353,7 +353,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
|
|
||||||
{photos.length === 0 && (
|
{photos.length === 0 && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-neutral-500">{t('gallery.noMedia', 'No media uploaded yet')}</p>
|
<p className="text-neutral-500 dark:text-neutral-400">{t('gallery.noMedia', 'No media uploaded yet')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -59,15 +59,15 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white border-r border-neutral-200 transform transition-transform duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
|
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-neutral-900 border-r border-neutral-200 dark:border-neutral-700 transform transition-transform duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
|
||||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col h-screen lg:h-full">
|
<div className="flex flex-col h-screen lg:h-full">
|
||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 flex-shrink-0">
|
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 dark:border-neutral-700 flex-shrink-0">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="text-xl font-bold text-neutral-900">{t('admin.title')}</span>
|
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('admin.title')}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -90,8 +90,8 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
|||||||
onClick={() => onClose()}
|
onClick={() => onClose()}
|
||||||
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
className={`flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary-50 text-primary-700'
|
? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400'
|
||||||
: 'text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900'
|
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon className={`w-5 h-5 mr-3 ${
|
<item.icon className={`w-5 h-5 mr-3 ${
|
||||||
@@ -138,26 +138,26 @@ const StorageInfo: React.FC = () => {
|
|||||||
const isOverSoftLimit = limitInUse && storageInfo.total_used >= limitInUse;
|
const isOverSoftLimit = limitInUse && storageInfo.total_used >= limitInUse;
|
||||||
const progressBarClass = isOverSoftLimit ? 'bg-red-600' : 'bg-primary-600';
|
const progressBarClass = isOverSoftLimit ? 'bg-red-600' : 'bg-primary-600';
|
||||||
const containerClass = isOverSoftLimit
|
const containerClass = isOverSoftLimit
|
||||||
? 'bg-red-50 border border-red-200'
|
? 'bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800'
|
||||||
: 'bg-neutral-100';
|
: 'bg-neutral-100 dark:bg-neutral-800';
|
||||||
const softLimitDisplay = settingsService.formatBytes(limitInUse);
|
const softLimitDisplay = settingsService.formatBytes(limitInUse);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 border-t border-neutral-200">
|
<div className="p-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<div className={`${containerClass} rounded-lg p-3 transition-colors duration-300`}>
|
<div className={`${containerClass} rounded-lg p-3 transition-colors duration-300`}>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-neutral-700">{t('admin.storageUsed')}</span>
|
<span className="text-neutral-700 dark:text-neutral-300">{t('admin.storageUsed')}</span>
|
||||||
<span className="font-medium text-neutral-900">
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{settingsService.formatBytes(storageInfo.total_used)}
|
{settingsService.formatBytes(storageInfo.total_used)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 w-full bg-neutral-200 rounded-full h-2">
|
<div className="mt-2 w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className={`${progressBarClass} h-2 rounded-full transition-all duration-300`}
|
className={`${progressBarClass} h-2 rounded-full transition-all duration-300`}
|
||||||
style={{ width: `${Math.min(usagePercent, 100)}%` }}
|
style={{ width: `${Math.min(usagePercent, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-600 mt-1">
|
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||||
{t('admin.storagePercent', { percent: usagePercent, limit: softLimitDisplay })}
|
{t('admin.storagePercent', { percent: usagePercent, limit: softLimitDisplay })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -152,8 +152,8 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{t('backup.configuration.enableBackup')}</h3>
|
<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-gray-600">
|
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('backup.configuration.enableBackupHelp')}
|
{t('backup.configuration.enableBackupHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -164,14 +164,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
onChange={(e) => handleChange('backup_enabled', e.target.checked)}
|
onChange={(e) => handleChange('backup_enabled', e.target.checked)}
|
||||||
className="sr-only peer"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Destination Configuration */}
|
{/* Destination Configuration */}
|
||||||
<Card className="p-6">
|
<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 */}
|
{/* Destination Type Selection */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
@@ -184,17 +184,17 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
onClick={() => handleChange('backup_destination_type', type.id)}
|
onClick={() => handleChange('backup_destination_type', type.id)}
|
||||||
className={`p-4 rounded-lg border-2 transition-all ${
|
className={`p-4 rounded-lg border-2 transition-all ${
|
||||||
formData.backup_destination_type === type.id
|
formData.backup_destination_type === type.id
|
||||||
? 'border-primary bg-primary-50'
|
? 'border-primary bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
: '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 ${
|
<Icon className={`h-8 w-8 mb-2 mx-auto ${
|
||||||
formData.backup_destination_type === type.id
|
formData.backup_destination_type === type.id
|
||||||
? 'text-primary'
|
? 'text-primary'
|
||||||
: 'text-gray-400'
|
: 'text-neutral-400'
|
||||||
}`} />
|
}`} />
|
||||||
<h4 className="font-medium text-gray-900">{type.name}</h4>
|
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{type.name}</h4>
|
||||||
<p className="text-xs text-gray-500 mt-1">{type.description}</p>
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{type.description}</p>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -205,7 +205,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
{formData.backup_destination_type === 'local' && (
|
{formData.backup_destination_type === 'local' && (
|
||||||
<>
|
<>
|
||||||
<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.destinationPath')}
|
{t('backup.configuration.fields.destinationPath')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -215,7 +215,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
placeholder={t('backup.configuration.fields.destinationPathPlaceholder')}
|
placeholder={t('backup.configuration.fields.destinationPathPlaceholder')}
|
||||||
required
|
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')}
|
{t('backup.configuration.fields.destinationPathHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -226,7 +226,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<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.rsyncHost')}
|
{t('backup.configuration.fields.rsyncHost')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -238,7 +238,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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.rsyncUser')}
|
{t('backup.configuration.fields.rsyncUser')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -251,7 +251,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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')}
|
{t('backup.configuration.fields.rsyncPath')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -263,7 +263,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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.rsyncSshKey')}
|
{t('backup.configuration.fields.rsyncSshKey')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -271,18 +271,18 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
value={formData.backup_rsync_ssh_key}
|
value={formData.backup_rsync_ssh_key}
|
||||||
onChange={(e) => handleChange('backup_rsync_ssh_key', e.target.value)}
|
onChange={(e) => handleChange('backup_rsync_ssh_key', e.target.value)}
|
||||||
placeholder={t('backup.configuration.fields.rsyncSshKeyPlaceholder')}
|
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}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowSecrets(prev => ({ ...prev, ssh_key: !prev.ssh_key }))}
|
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} />}
|
{showSecrets.ssh_key ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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')}
|
{t('backup.configuration.fields.rsyncSshKeyHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,7 +292,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
{formData.backup_destination_type === 's3' && (
|
{formData.backup_destination_type === 's3' && (
|
||||||
<>
|
<>
|
||||||
<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.s3Endpoint')}
|
{t('backup.configuration.fields.s3Endpoint')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -302,13 +302,13 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
placeholder="https://s3.amazonaws.com"
|
placeholder="https://s3.amazonaws.com"
|
||||||
required
|
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')}
|
{t('backup.configuration.fields.s3EndpointHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<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.s3Bucket')}
|
{t('backup.configuration.fields.s3Bucket')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -320,7 +320,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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.s3Region')}
|
{t('backup.configuration.fields.s3Region')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -333,7 +333,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<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.s3AccessKey')}
|
{t('backup.configuration.fields.s3AccessKey')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -345,7 +345,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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.s3SecretKey')}
|
{t('backup.configuration.fields.s3SecretKey')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -359,7 +359,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowSecrets(prev => ({ ...prev, s3_secret_key: !prev.s3_secret_key }))}
|
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} />}
|
{showSecrets.s3_secret_key ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||||
</button>
|
</button>
|
||||||
@@ -398,17 +398,17 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
|
|
||||||
{/* Schedule Configuration */}
|
{/* Schedule Configuration */}
|
||||||
<Card className="p-6">
|
<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 className="space-y-4">
|
||||||
<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.scheduleType')}
|
{t('backup.configuration.schedule.scheduleType')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.backup_schedule}
|
value={formData.backup_schedule}
|
||||||
onChange={(e) => handleChange('backup_schedule', e.target.value)}
|
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 => (
|
{scheduleOptions.map(option => (
|
||||||
<option key={option.value} value={option.value}>
|
<option key={option.value} value={option.value}>
|
||||||
@@ -420,7 +420,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
|
|
||||||
{formData.backup_schedule === 'custom' && (
|
{formData.backup_schedule === 'custom' && (
|
||||||
<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.customCron')}
|
{t('backup.configuration.schedule.customCron')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -429,14 +429,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
onChange={(e) => handleChange('backup_schedule_cron', e.target.value)}
|
onChange={(e) => handleChange('backup_schedule_cron', e.target.value)}
|
||||||
placeholder="0 3 * * *"
|
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')}
|
{t('backup.configuration.schedule.customCronHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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.schedule.retention')}
|
{t('backup.configuration.schedule.retention')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -446,7 +446,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
min="1"
|
min="1"
|
||||||
max="365"
|
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')}
|
{t('backup.configuration.schedule.retentionHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -455,7 +455,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
|
|
||||||
{/* Backup Content Selection */}
|
{/* Backup Content Selection */}
|
||||||
<Card className="p-6">
|
<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">
|
<div className="space-y-3">
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
@@ -463,14 +463,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.backup_include_database}
|
checked={formData.backup_include_database}
|
||||||
onChange={(e) => handleChange('backup_include_database', e.target.checked)}
|
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="ml-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Database className="h-4 w-4 text-gray-400" />
|
<Database className="h-4 w-4 text-neutral-400" />
|
||||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.database')}</span>
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.database')}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -479,14 +479,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.backup_include_photos}
|
checked={formData.backup_include_photos}
|
||||||
onChange={(e) => handleChange('backup_include_photos', e.target.checked)}
|
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="ml-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Image className="h-4 w-4 text-gray-400" />
|
<Image className="h-4 w-4 text-neutral-400" />
|
||||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.photos')}</span>
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.photos')}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -495,14 +495,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.backup_include_archives}
|
checked={formData.backup_include_archives}
|
||||||
onChange={(e) => handleChange('backup_include_archives', e.target.checked)}
|
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="ml-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<FileArchive className="h-4 w-4 text-gray-400" />
|
<FileArchive className="h-4 w-4 text-neutral-400" />
|
||||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.archives')}</span>
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.archives')}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -511,14 +511,14 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.backup_include_thumbnails}
|
checked={formData.backup_include_thumbnails}
|
||||||
onChange={(e) => handleChange('backup_include_thumbnails', e.target.checked)}
|
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="ml-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Image className="h-4 w-4 text-gray-400" />
|
<Image className="h-4 w-4 text-neutral-400" />
|
||||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.thumbnails')}</span>
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.thumbnails')}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -526,7 +526,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
|
|
||||||
{/* Advanced Options */}
|
{/* Advanced Options */}
|
||||||
<Card className="p-6">
|
<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">
|
<div className="space-y-4">
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
@@ -534,11 +534,11 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.backup_compression}
|
checked={formData.backup_compression}
|
||||||
onChange={(e) => handleChange('backup_compression', e.target.checked)}
|
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">
|
<div className="ml-3">
|
||||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.advancedOptions.compression')}</span>
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.advancedOptions.compression')}</span>
|
||||||
<p className="text-xs text-gray-500">{t('backup.configuration.advancedOptions.compressionHelp')}</p>
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.advancedOptions.compressionHelp')}</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -548,17 +548,17 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.backup_encryption}
|
checked={formData.backup_encryption}
|
||||||
onChange={(e) => handleChange('backup_encryption', e.target.checked)}
|
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">
|
<div className="ml-3">
|
||||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.advancedOptions.encryption')}</span>
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.advancedOptions.encryption')}</span>
|
||||||
<p className="text-xs text-gray-500">{t('backup.configuration.advancedOptions.encryptionHelp')}</p>
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.advancedOptions.encryptionHelp')}</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{formData.backup_encryption && (
|
{formData.backup_encryption && (
|
||||||
<div className="ml-7">
|
<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')}
|
{t('backup.configuration.advancedOptions.encryptionPassphrase')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -572,7 +572,7 @@ export const BackupConfiguration = ({ config, onSave, isSaving }) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowSecrets(prev => ({ ...prev, encryption_passphrase: !prev.encryption_passphrase }))}
|
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} />}
|
{showSecrets.encryption_passphrase ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ const StatCard = ({ icon: Icon, label, value, color = 'blue', subtext }) => (
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-gray-600">{label}</p>
|
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">{label}</p>
|
||||||
<p className="mt-2 text-3xl font-semibold text-gray-900">{value}</p>
|
<p className="mt-2 text-3xl font-semibold text-neutral-900 dark:text-neutral-100">{value}</p>
|
||||||
{subtext && (
|
{subtext && (
|
||||||
<p className="mt-1 text-sm text-gray-500">{subtext}</p>
|
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">{subtext}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`p-3 bg-${color}-100 rounded-lg`}>
|
<div className={`p-3 bg-${color}-100 dark:bg-${color}-900/40 rounded-lg`}>
|
||||||
<Icon className={`h-6 w-6 text-${color}-600`} />
|
<Icon className={`h-6 w-6 text-${color}-600 dark:text-${color}-400`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -86,14 +86,14 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Configuration Alert */}
|
{/* Configuration Alert */}
|
||||||
{!isConfigured && (
|
{!isConfigured && (
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
<div className="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<AlertTriangle className="h-5 w-5 text-amber-400 mt-0.5" />
|
<AlertTriangle className="h-5 w-5 text-amber-400 mt-0.5" />
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<h3 className="text-sm font-medium text-amber-800">
|
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||||
{t('backup.dashboard.notConfigured.title')}
|
{t('backup.dashboard.notConfigured.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-amber-700">
|
<p className="mt-1 text-sm text-amber-700 dark:text-amber-300">
|
||||||
{t('backup.dashboard.notConfigured.message')}
|
{t('backup.dashboard.notConfigured.message')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,8 +104,8 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
{/* Health Score Card */}
|
{/* Health Score Card */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{t('backup.dashboard.health.title')}</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('backup.dashboard.health.title')}</h3>
|
||||||
<span className={`px-3 py-1 rounded-full text-sm font-medium bg-${healthColors[health.status]}-100 text-${healthColors[health.status]}-700`}>
|
<span className={`px-3 py-1 rounded-full text-sm font-medium bg-${healthColors[health.status]}-100 dark:bg-${healthColors[health.status]}-900/40 text-${healthColors[health.status]}-700 dark:text-${healthColors[health.status]}-300`}>
|
||||||
{health.status.charAt(0).toUpperCase() + health.status.slice(1)}
|
{health.status.charAt(0).toUpperCase() + health.status.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +120,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="8"
|
strokeWidth="8"
|
||||||
fill="none"
|
fill="none"
|
||||||
className="text-gray-200"
|
className="text-neutral-200 dark:text-neutral-700"
|
||||||
/>
|
/>
|
||||||
<circle
|
<circle
|
||||||
cx="48"
|
cx="48"
|
||||||
@@ -134,14 +134,14 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<span className="text-2xl font-bold text-gray-900">{health.score}%</span>
|
<span className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{health.score}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-gray-700 font-medium">{health.message}</p>
|
<p className="text-neutral-700 dark:text-neutral-300 font-medium">{health.message}</p>
|
||||||
{lastBackup && (
|
{lastBackup && (
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
Last successful backup: {formatDistanceToNow(new Date(lastBackup.created_at), { addSuffix: true })}
|
Last successful backup: {formatDistanceToNow(new Date(lastBackup.created_at), { addSuffix: true })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -206,10 +206,10 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
{/* Recent Activity */}
|
{/* Recent Activity */}
|
||||||
{status?.recentBackups && status.recentBackups.length > 0 && (
|
{status?.recentBackups && status.recentBackups.length > 0 && (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.dashboard.recentActivity.title')}</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.dashboard.recentActivity.title')}</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{status.recentBackups.slice(0, 5).map((backup) => (
|
{status.recentBackups.slice(0, 5).map((backup) => (
|
||||||
<div key={backup.id} className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
|
<div key={backup.id} className="flex items-center justify-between py-3 border-b border-neutral-100 dark:border-neutral-700 last:border-0">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
{backup.status === 'completed' ? (
|
{backup.status === 'completed' ? (
|
||||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
@@ -219,19 +219,19 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
<Loader2 className="h-5 w-5 text-blue-500 animate-spin" />
|
<Loader2 className="h-5 w-5 text-blue-500 animate-spin" />
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">
|
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{t('backup.dashboard.backupType', { type: backup.backup_type })}
|
{t('backup.dashboard.backupType', { type: backup.backup_type })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
{format(new Date(backup.created_at), 'PPp')}
|
{format(new Date(backup.created_at), 'PPp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-medium text-gray-900">
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{formatBytes(backup.statistics?.total_size || 0)}
|
{formatBytes(backup.statistics?.total_size || 0)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
{backup.statistics?.files_processed || 0} files
|
{backup.statistics?.files_processed || 0} files
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,15 +244,15 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
{/* Storage Status */}
|
{/* Storage Status */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.dashboard.coverage.title')}</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.dashboard.coverage.title')}</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Database className="h-5 w-5 text-gray-400" />
|
<Database className="h-5 w-5 text-neutral-400" />
|
||||||
<span className="text-gray-700">Database</span>
|
<span className="text-neutral-700 dark:text-neutral-300">Database</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
statistics.database_backed_up ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'
|
statistics.database_backed_up ? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300' : 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300'
|
||||||
}`}>
|
}`}>
|
||||||
{statistics.database_backed_up ? t('backup.dashboard.coverage.included') : t('backup.dashboard.coverage.excluded')}
|
{statistics.database_backed_up ? t('backup.dashboard.coverage.included') : t('backup.dashboard.coverage.excluded')}
|
||||||
</span>
|
</span>
|
||||||
@@ -260,20 +260,20 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Image className="h-5 w-5 text-gray-400" />
|
<Image className="h-5 w-5 text-neutral-400" />
|
||||||
<span className="text-gray-700">{t('backup.configuration.whatToBackup.photos')}</span>
|
<span className="text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.photos')}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
{statistics.photos_backed_up || 0} {t('common.of')} {statistics.total_photos || 0}
|
{statistics.photos_backed_up || 0} {t('common.of')} {statistics.total_photos || 0}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<FileArchive className="h-5 w-5 text-gray-400" />
|
<FileArchive className="h-5 w-5 text-neutral-400" />
|
||||||
<span className="text-gray-700">{t('backup.configuration.whatToBackup.archives')}</span>
|
<span className="text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.archives')}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
{statistics.archives_backed_up || 0} {t('backup.dashboard.stats.files')}
|
{statistics.archives_backed_up || 0} {t('backup.dashboard.stats.files')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,7 +281,7 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.dashboard.storageDestination')}</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.dashboard.storageDestination')}</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
{config?.backup_destination_type === 's3' ? (
|
{config?.backup_destination_type === 's3' ? (
|
||||||
@@ -289,15 +289,15 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
) : config?.backup_destination_type === 'rsync' ? (
|
) : config?.backup_destination_type === 'rsync' ? (
|
||||||
<Server className="h-5 w-5 text-purple-500" />
|
<Server className="h-5 w-5 text-purple-500" />
|
||||||
) : (
|
) : (
|
||||||
<HardDrive className="h-5 w-5 text-gray-500" />
|
<HardDrive className="h-5 w-5 text-neutral-500" />
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">
|
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{config?.backup_destination_type
|
{config?.backup_destination_type
|
||||||
? t(`backup.configuration.destinationTypes.${config.backup_destination_type}.name`)
|
? t(`backup.configuration.destinationTypes.${config.backup_destination_type}.name`)
|
||||||
: t('backup.dashboard.notConfigured.title')}
|
: t('backup.dashboard.notConfigured.title')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
{config?.backup_destination_type === 's3' && config?.backup_s3_bucket
|
{config?.backup_destination_type === 's3' && config?.backup_s3_bucket
|
||||||
? `Bucket: ${config.backup_s3_bucket}`
|
? `Bucket: ${config.backup_s3_bucket}`
|
||||||
: config?.backup_destination_type === 'local' && config?.backup_destination_path
|
: config?.backup_destination_type === 'local' && config?.backup_destination_path
|
||||||
@@ -310,10 +310,10 @@ export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{config?.backup_retention_days && (
|
{config?.backup_retention_days && (
|
||||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
<div className="mt-4 p-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Info className="h-4 w-4 text-gray-400" />
|
<Info className="h-4 w-4 text-neutral-400" />
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
{t('backup.configuration.schedule.retentionDays')} {config.backup_retention_days} {t('backup.configuration.schedule.retentionHelp').replace('days (older backups will be automatically deleted)', '')}
|
{t('backup.configuration.schedule.retentionDays')} {config.backup_retention_days} {t('backup.configuration.schedule.retentionHelp').replace('days (older backups will be automatically deleted)', '')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export const BackupHistory = () => {
|
|||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="relative">
|
<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
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('backup.history.searchPlaceholder')}
|
placeholder={t('backup.history.searchPlaceholder')}
|
||||||
@@ -124,7 +124,7 @@ export const BackupHistory = () => {
|
|||||||
<select
|
<select
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={(e) => setFilterStatus(e.target.value)}
|
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="all">All Status</option>
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
@@ -149,33 +149,33 @@ export const BackupHistory = () => {
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-50 border-b border-gray-200">
|
<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-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.status')}
|
{t('backup.history.columns.status')}
|
||||||
</th>
|
</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')}
|
{t('backup.history.columns.dateTime')}
|
||||||
</th>
|
</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')}
|
{t('backup.history.columns.type')}
|
||||||
</th>
|
</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')}
|
{t('backup.history.columns.size')}
|
||||||
</th>
|
</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')}
|
{t('backup.history.columns.duration')}
|
||||||
</th>
|
</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')}
|
{t('backup.history.columns.actions')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</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 ? (
|
{backups.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
<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-gray-300" />
|
<FileArchive className="h-12 w-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" />
|
||||||
<p className="text-lg font-medium">No backups found</p>
|
<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>
|
<p className="text-sm mt-1">Backups will appear here once created</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -188,39 +188,39 @@ export const BackupHistory = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={backup.id}>
|
<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">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<StatusIcon className={`h-5 w-5 ${statusColor}`} />
|
<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}
|
{backup.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div>
|
<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')}
|
{format(new Date(backup.created_at), 'PPP')}
|
||||||
</p>
|
</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 })}
|
{format(new Date(backup.created_at), 'p')} • {formatDistanceToNow(new Date(backup.created_at), { addSuffix: true })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<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'}
|
{backup.backup_type || 'Manual'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<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)}
|
{formatBytes(stats.total_size || 0)}
|
||||||
</p>
|
</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')}
|
{stats.files_processed || 0} {t('backup.dashboard.stats.files')}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
{backup.duration_seconds
|
{backup.duration_seconds
|
||||||
? `${Math.round(backup.duration_seconds / 60)}m ${backup.duration_seconds % 60}s`
|
? `${Math.round(backup.duration_seconds / 60)}m ${backup.duration_seconds % 60}s`
|
||||||
: '-'}
|
: '-'}
|
||||||
@@ -229,7 +229,7 @@ export const BackupHistory = () => {
|
|||||||
<div className="flex items-center justify-end space-x-2">
|
<div className="flex items-center justify-end space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleRowExpansion(backup.id)}
|
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')}
|
title={t('backup.actions.view')}
|
||||||
>
|
>
|
||||||
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||||
@@ -237,7 +237,7 @@ export const BackupHistory = () => {
|
|||||||
{backup.manifest_path && (
|
{backup.manifest_path && (
|
||||||
<button
|
<button
|
||||||
onClick={() => window.open(`/admin/backup/download/${backup.id}`, '_blank')}
|
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')}
|
title={t('backup.actions.download')}
|
||||||
>
|
>
|
||||||
<Download size={20} />
|
<Download size={20} />
|
||||||
@@ -245,7 +245,7 @@ export const BackupHistory = () => {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(backup)}
|
onClick={() => handleDelete(backup)}
|
||||||
className="text-gray-400 hover:text-red-600"
|
className="text-neutral-400 hover:text-red-600"
|
||||||
title={t('backup.actions.delete')}
|
title={t('backup.actions.delete')}
|
||||||
disabled={deleteMutation.isLoading}
|
disabled={deleteMutation.isLoading}
|
||||||
>
|
>
|
||||||
@@ -258,24 +258,24 @@ export const BackupHistory = () => {
|
|||||||
{/* Expanded Details Row */}
|
{/* Expanded Details Row */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<tr>
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{/* Backup Details */}
|
{/* Backup Details */}
|
||||||
<div className="space-y-2">
|
<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="text-sm space-y-1">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">{t('backup.history.details.destination')}:</span>
|
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.destination')}:</span>
|
||||||
<span className="text-gray-900">{backup.destination_type || 'Unknown'}</span>
|
<span className="text-neutral-900 dark:text-neutral-100">{backup.destination_type || 'Unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">{t('backup.history.details.started')}:</span>
|
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.started')}:</span>
|
||||||
<span className="text-gray-900">{format(new Date(backup.created_at), 'p')}</span>
|
<span className="text-neutral-900 dark:text-neutral-100">{format(new Date(backup.created_at), 'p')}</span>
|
||||||
</div>
|
</div>
|
||||||
{backup.completed_at && (
|
{backup.completed_at && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">{t('backup.history.details.completed')}:</span>
|
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.completed')}:</span>
|
||||||
<span className="text-gray-900">{format(new Date(backup.completed_at), 'p')}</span>
|
<span className="text-neutral-900 dark:text-neutral-100">{format(new Date(backup.completed_at), 'p')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -283,21 +283,21 @@ export const BackupHistory = () => {
|
|||||||
|
|
||||||
{/* Content Backed Up */}
|
{/* Content Backed Up */}
|
||||||
<div className="space-y-2">
|
<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="space-y-2">
|
||||||
<div className="flex items-center space-x-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'}`} />
|
<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-gray-700">{t('backup.configuration.whatToBackup.database')}</span>
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.database')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<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'}`} />
|
<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-gray-700">
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
Photos ({stats.photos_backed_up || 0} of {stats.total_photos || 0})
|
Photos ({stats.photos_backed_up || 0} of {stats.total_photos || 0})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<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'}`} />
|
<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-gray-700">
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
Archives ({stats.archives_backed_up || 0})
|
Archives ({stats.archives_backed_up || 0})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,8 +307,8 @@ export const BackupHistory = () => {
|
|||||||
{/* Error Information */}
|
{/* Error Information */}
|
||||||
{backup.error_message && (
|
{backup.error_message && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium text-red-900">{t('backup.history.details.errorDetails')}</h4>
|
<h4 className="font-medium text-red-900 dark:text-red-200">{t('backup.history.details.errorDetails')}</h4>
|
||||||
<p className="text-sm text-red-700 bg-red-50 p-2 rounded">
|
<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}
|
{backup.error_message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,8 +317,8 @@ export const BackupHistory = () => {
|
|||||||
{/* Manifest Path */}
|
{/* Manifest Path */}
|
||||||
{backup.manifest_path && (
|
{backup.manifest_path && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium text-gray-900">{t('backup.history.details.manifest')}</h4>
|
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.history.details.manifest')}</h4>
|
||||||
<p className="text-sm text-gray-600 font-mono break-all">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 font-mono break-all">
|
||||||
{backup.manifest_path}
|
{backup.manifest_path}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -337,7 +337,7 @@ export const BackupHistory = () => {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{pagination.pages > 1 && (
|
{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 items-center justify-between">
|
||||||
<div className="flex-1 flex justify-between sm:hidden">
|
<div className="flex-1 flex justify-between sm:hidden">
|
||||||
<Button
|
<Button
|
||||||
@@ -359,7 +359,7 @@ export const BackupHistory = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
{t('backup.history.pagination.showing', {
|
{t('backup.history.pagination.showing', {
|
||||||
from: (currentPage - 1) * pagination.limit + 1,
|
from: (currentPage - 1) * pagination.limit + 1,
|
||||||
to: Math.min(currentPage * pagination.limit, pagination.total),
|
to: Math.min(currentPage * pagination.limit, pagination.total),
|
||||||
@@ -372,7 +372,7 @@ export const BackupHistory = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
disabled={currentPage === 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')}
|
{t('backup.history.pagination.previous')}
|
||||||
</button>
|
</button>
|
||||||
@@ -385,8 +385,8 @@ export const BackupHistory = () => {
|
|||||||
onClick={() => setCurrentPage(pageNum)}
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||||
currentPage === pageNum
|
currentPage === pageNum
|
||||||
? 'z-10 bg-primary-50 border-primary text-primary'
|
? 'z-10 bg-primary-50 dark:bg-primary-900/30 border-primary text-primary'
|
||||||
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
|
: '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}
|
{pageNum}
|
||||||
@@ -397,7 +397,7 @@ export const BackupHistory = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(p => Math.min(pagination.pages, p + 1))}
|
onClick={() => setCurrentPage(p => Math.min(pagination.pages, p + 1))}
|
||||||
disabled={currentPage === pagination.pages}
|
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')}
|
{t('backup.history.pagination.next')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -44,27 +44,27 @@ export const BulkCategoryModal: React.FC<BulkCategoryModalProps> = ({
|
|||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-xl font-semibold text-neutral-900">
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
{t('photos.moveToCategory', 'Move {{count}} photos to category', { count: photoCount })}
|
{t('photos.moveToCategory', 'Move {{count}} photos to category', { count: photoCount })}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
|
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5 text-neutral-500" />
|
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label htmlFor="category-select" className="block text-sm font-medium text-neutral-700 mb-2">
|
<label htmlFor="category-select" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||||
{t('photos.selectCategory', 'Select category')}
|
{t('photos.selectCategory', 'Select category')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="category-select"
|
id="category-select"
|
||||||
value={selectedCategoryId ?? ''}
|
value={selectedCategoryId ?? ''}
|
||||||
onChange={(e) => setSelectedCategoryId(e.target.value === '' ? null : Number(e.target.value))}
|
onChange={(e) => setSelectedCategoryId(e.target.value === '' ? null : Number(e.target.value))}
|
||||||
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"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<option value="">{t('photos.uncategorized', 'Uncategorized')}</option>
|
<option value="">{t('photos.uncategorized', 'Uncategorized')}</option>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export const CategoryManager: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">{t('categories.title')}</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('categories.title')}</h3>
|
||||||
{!isAdding && (
|
{!isAdding && (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -116,14 +116,14 @@ export const CategoryManager: React.FC = () => {
|
|||||||
|
|
||||||
{/* Add new category form */}
|
{/* Add new category form */}
|
||||||
{isAdding && (
|
{isAdding && (
|
||||||
<div className="flex gap-2 p-3 bg-neutral-50 rounded-lg">
|
<div className="flex gap-2 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newCategoryName}
|
value={newCategoryName}
|
||||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
||||||
placeholder={t('categories.categoryName')}
|
placeholder={t('categories.categoryName')}
|
||||||
className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
className="flex-1 px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -154,14 +154,14 @@ export const CategoryManager: React.FC = () => {
|
|||||||
{/* Categories list */}
|
{/* Categories list */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{categories.length === 0 ? (
|
{categories.length === 0 ? (
|
||||||
<p className="text-neutral-500 text-center py-8">
|
<p className="text-neutral-500 dark:text-neutral-400 text-center py-8">
|
||||||
{t('categories.noCategoriesYet')}
|
{t('categories.noCategoriesYet')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
categories.map((category) => (
|
categories.map((category) => (
|
||||||
<div
|
<div
|
||||||
key={category.id}
|
key={category.id}
|
||||||
className="flex items-center justify-between p-3 bg-white rounded-lg border border-neutral-200 hover:border-neutral-300 transition-colors"
|
className="flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 transition-colors"
|
||||||
>
|
>
|
||||||
{editingId === category.id ? (
|
{editingId === category.id ? (
|
||||||
<div className="flex gap-2 flex-1">
|
<div className="flex gap-2 flex-1">
|
||||||
@@ -173,7 +173,7 @@ export const CategoryManager: React.FC = () => {
|
|||||||
if (e.key === 'Enter') handleUpdate(category.id);
|
if (e.key === 'Enter') handleUpdate(category.id);
|
||||||
if (e.key === 'Escape') cancelEdit();
|
if (e.key === 'Escape') cancelEdit();
|
||||||
}}
|
}}
|
||||||
className="flex-1 px-3 py-1 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
className="flex-1 px-3 py-1 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -199,20 +199,20 @@ export const CategoryManager: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-neutral-900">{category.name}</p>
|
<p className="font-medium text-neutral-900 dark:text-neutral-100">{category.name}</p>
|
||||||
<p className="text-sm text-neutral-500">/{category.slug}</p>
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">/{category.slug}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => startEdit(category)}
|
onClick={() => startEdit(category)}
|
||||||
className="p-1.5 text-neutral-600 hover:text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
className="p-1.5 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/30 rounded transition-colors"
|
||||||
title={t('common.edit')}
|
title={t('common.edit')}
|
||||||
>
|
>
|
||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(category)}
|
onClick={() => handleDelete(category)}
|
||||||
className="p-1.5 text-neutral-600 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
className="p-1.5 text-neutral-600 dark:text-neutral-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
|
||||||
title={t('common.delete')}
|
title={t('common.delete')}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -92,14 +92,14 @@ export const CssTemplateEditor: React.FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||||
<Code className="w-5 h-5" />
|
<Code className="w-5 h-5" />
|
||||||
{t('cssTemplates.title', 'Custom CSS Templates')}
|
{t('cssTemplates.title', 'Custom CSS Templates')}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className="flex border-b border-neutral-200 mb-6">
|
<div className="flex border-b border-neutral-200 dark:border-neutral-700 mb-6">
|
||||||
{[1, 2, 3].map(slot => {
|
{[1, 2, 3].map(slot => {
|
||||||
const template = localTemplates.find(t => t.slot_number === slot);
|
const template = localTemplates.find(t => t.slot_number === slot);
|
||||||
return (
|
return (
|
||||||
@@ -109,7 +109,7 @@ export const CssTemplateEditor: React.FC = () => {
|
|||||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
activeSlot === slot
|
activeSlot === slot
|
||||||
? 'border-primary-600 text-primary-600'
|
? 'border-primary-600 text-primary-600'
|
||||||
: 'border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300'
|
: 'border-transparent text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('cssTemplates.template', 'Template')} {slot}
|
{t('cssTemplates.template', 'Template')} {slot}
|
||||||
@@ -130,7 +130,7 @@ export const CssTemplateEditor: React.FC = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Template Name */}
|
{/* Template Name */}
|
||||||
<div>
|
<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 dark:text-neutral-300 mb-2">
|
||||||
{t('cssTemplates.templateName', 'Template Name')}
|
{t('cssTemplates.templateName', 'Template Name')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -138,7 +138,7 @@ export const CssTemplateEditor: React.FC = () => {
|
|||||||
value={activeTemplate.name}
|
value={activeTemplate.name}
|
||||||
onChange={(e) => updateLocalTemplate({ name: e.target.value })}
|
onChange={(e) => updateLocalTemplate({ name: e.target.value })}
|
||||||
maxLength={50}
|
maxLength={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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,18 +151,18 @@ export const CssTemplateEditor: React.FC = () => {
|
|||||||
onChange={(e) => updateLocalTemplate({ is_enabled: e.target.checked })}
|
onChange={(e) => updateLocalTemplate({ is_enabled: e.target.checked })}
|
||||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-neutral-700">
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
{t('cssTemplates.enableTemplate', 'Enable this template')}
|
{t('cssTemplates.enableTemplate', 'Enable this template')}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-neutral-500 mt-1 ml-6">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 ml-6">
|
||||||
{t('cssTemplates.enableHint', 'Enabled templates can be selected when creating events')}
|
{t('cssTemplates.enableHint', 'Enabled templates can be selected when creating events')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CSS Editor */}
|
{/* CSS Editor */}
|
||||||
<div>
|
<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 dark:text-neutral-300 mb-2">
|
||||||
{t('cssTemplates.cssContent', 'CSS Content')}
|
{t('cssTemplates.cssContent', 'CSS Content')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -177,22 +177,22 @@ export const CssTemplateEditor: React.FC = () => {
|
|||||||
{(activeTemplate.css_content?.length || 0).toLocaleString()} / 102,400 {t('common.characters', 'characters')}
|
{(activeTemplate.css_content?.length || 0).toLocaleString()} / 102,400 {t('common.characters', 'characters')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-500 mt-2">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
|
||||||
{t('cssTemplates.cssHint', 'Use .gallery-page to scope styles to the gallery. Available variables: --gallery-bg, --gallery-text, --gallery-accent')}
|
{t('cssTemplates.cssHint', 'Use .gallery-page to scope styles to the gallery. Available variables: --gallery-bg, --gallery-text, --gallery-accent')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Security Notice */}
|
{/* Security Notice */}
|
||||||
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||||
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||||||
<div className="text-xs text-amber-800">
|
<div className="text-xs text-amber-800 dark:text-amber-200">
|
||||||
<strong>{t('cssTemplates.securityNotice', 'Security Notice')}:</strong>{' '}
|
<strong>{t('cssTemplates.securityNotice', 'Security Notice')}:</strong>{' '}
|
||||||
{t('cssTemplates.securityText', 'CSS is sanitized to prevent malicious code. External URLs, @import, and JavaScript expressions are blocked.')}
|
{t('cssTemplates.securityText', 'CSS is sanitized to prevent malicious code. External URLs, @import, and JavaScript expressions are blocked.')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center justify-between pt-4 border-t border-neutral-100">
|
<div className="flex items-center justify-between pt-4 border-t border-neutral-100 dark:border-neutral-700">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{activeSlot === 1 && activeTemplate.is_default && (
|
{activeSlot === 1 && activeTemplate.is_default && (
|
||||||
<Button
|
<Button
|
||||||
@@ -227,7 +227,7 @@ export const CssTemplateEditor: React.FC = () => {
|
|||||||
|
|
||||||
{/* Last Updated */}
|
{/* Last Updated */}
|
||||||
{activeTemplate.updated_at && (
|
{activeTemplate.updated_at && (
|
||||||
<p className="text-xs text-neutral-400 text-right">
|
<p className="text-xs text-neutral-400 dark:text-neutral-500 text-right">
|
||||||
{t('cssTemplates.lastUpdated', 'Last updated')}: {new Date(activeTemplate.updated_at).toLocaleString()}
|
{t('cssTemplates.lastUpdated', 'Last updated')}: {new Date(activeTemplate.updated_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,27 +25,27 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
|
|||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
|
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
<div className="flex items-center justify-between p-6 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Mail className="w-6 h-6 text-primary-600" />
|
<Mail className="w-6 h-6 text-primary-600" />
|
||||||
<h2 className="text-xl font-semibold text-neutral-900">Email Preview</h2>
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Email Preview</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-neutral-400 hover:text-neutral-600 transition-colors"
|
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-6 h-6" />
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subject */}
|
{/* Subject */}
|
||||||
<div className="px-6 py-4 border-b border-neutral-200 bg-neutral-50">
|
<div className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
|
||||||
<p className="text-sm font-medium text-neutral-600">Subject:</p>
|
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">Subject:</p>
|
||||||
<p className="text-lg font-semibold text-neutral-900 mt-1">{subject}</p>
|
<p className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mt-1">{subject}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* View mode toggle */}
|
{/* View mode toggle */}
|
||||||
<div className="px-6 py-3 border-b border-neutral-200">
|
<div className="px-6 py-3 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={viewMode === 'html' ? 'primary' : 'outline'}
|
variant={viewMode === 'html' ? 'primary' : 'outline'}
|
||||||
@@ -71,7 +71,7 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className="flex-1 overflow-auto p-6">
|
||||||
{viewMode === 'html' ? (
|
{viewMode === 'html' ? (
|
||||||
<div className="bg-white border border-neutral-200 rounded-lg shadow-sm">
|
<div className="bg-white border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-sm">
|
||||||
<iframe
|
<iframe
|
||||||
srcDoc={htmlContent}
|
srcDoc={htmlContent}
|
||||||
className="w-full h-[600px] border-0"
|
className="w-full h-[600px] border-0"
|
||||||
@@ -79,8 +79,8 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-6">
|
<div className="bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg p-6">
|
||||||
<pre className="whitespace-pre-wrap font-mono text-sm text-neutral-700">
|
<pre className="whitespace-pre-wrap font-mono text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
{textContent}
|
{textContent}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +88,7 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex justify-end gap-3 p-6 border-t border-neutral-200">
|
<div className="flex justify-end gap-3 p-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h3 className="text-sm font-medium text-neutral-700">{t('categories.eventSpecificCategories')}</h3>
|
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.eventSpecificCategories')}</h3>
|
||||||
{!isAdding && (
|
{!isAdding && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -132,7 +132,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
|||||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
||||||
placeholder={t('categories.categoryName')}
|
placeholder={t('categories.categoryName')}
|
||||||
className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -162,7 +162,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
|||||||
|
|
||||||
{/* Event categories list */}
|
{/* Event categories list */}
|
||||||
{eventCategories.length === 0 ? (
|
{eventCategories.length === 0 ? (
|
||||||
<p className="text-sm text-neutral-500 italic">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||||
{t('categories.noEventSpecificCategories')}
|
{t('categories.noEventSpecificCategories')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -174,13 +174,13 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={category.id}
|
key={category.id}
|
||||||
className="flex items-center justify-between px-3 py-2 bg-neutral-50 rounded-md"
|
className="flex items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
{/* Hero photo thumbnail */}
|
{/* Hero photo thumbnail */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setHeroPickerCategoryId(category.id)}
|
onClick={() => setHeroPickerCategoryId(category.id)}
|
||||||
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 overflow-hidden bg-neutral-100 hover:border-primary-400 transition-colors flex items-center justify-center"
|
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-primary-400 transition-colors flex items-center justify-center"
|
||||||
title={t('categories.setCoverPhoto')}
|
title={t('categories.setCoverPhoto')}
|
||||||
>
|
>
|
||||||
{heroPhoto ? (
|
{heroPhoto ? (
|
||||||
@@ -195,11 +195,11 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
|||||||
<ImageIcon className="w-4 h-4 text-neutral-300" />
|
<ImageIcon className="w-4 h-4 text-neutral-300" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-neutral-700 truncate">{category.name}</span>
|
<span className="text-sm text-neutral-700 dark:text-neutral-300 truncate">{category.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(category)}
|
onClick={() => handleDelete(category)}
|
||||||
className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
|
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||||
title={t('categories.deleteCategoryTitle')}
|
title={t('categories.deleteCategoryTitle')}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
@@ -216,8 +216,8 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show available global categories */}
|
{/* Show available global categories */}
|
||||||
<div className="mt-4 pt-3 border-t border-neutral-200">
|
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<p className="text-xs font-medium text-neutral-500 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
|
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{categories
|
{categories
|
||||||
.filter(cat => cat.is_global)
|
.filter(cat => cat.is_global)
|
||||||
@@ -226,10 +226,10 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
|||||||
? photos.find(p => p.id === cat.hero_photo_id)
|
? photos.find(p => p.id === cat.hero_photo_id)
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 rounded-md">
|
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md">
|
||||||
<button
|
<button
|
||||||
onClick={() => setHeroPickerCategoryId(cat.id)}
|
onClick={() => setHeroPickerCategoryId(cat.id)}
|
||||||
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 overflow-hidden bg-neutral-100 hover:border-primary-400 transition-colors flex items-center justify-center"
|
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-primary-400 transition-colors flex items-center justify-center"
|
||||||
title={t('categories.setCoverPhoto')}
|
title={t('categories.setCoverPhoto')}
|
||||||
>
|
>
|
||||||
{heroPhoto ? (
|
{heroPhoto ? (
|
||||||
@@ -244,7 +244,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
|||||||
<ImageIcon className="w-4 h-4 text-neutral-300" />
|
<ImageIcon className="w-4 h-4 text-neutral-300" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-neutral-600">{cat.name}</span>
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">{cat.name}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -255,12 +255,12 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
|||||||
{heroPickerCategoryId !== null && (
|
{heroPickerCategoryId !== null && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||||
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
||||||
<div className="p-6 border-b border-neutral-200">
|
<div className="p-6 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold">{t('categories.setCoverPhoto')}</h2>
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('categories.setCoverPhoto')}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setHeroPickerCategoryId(null)}
|
onClick={() => setHeroPickerCategoryId(null)}
|
||||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -269,7 +269,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
|||||||
|
|
||||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
|
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
|
||||||
{photos.length === 0 ? (
|
{photos.length === 0 ? (
|
||||||
<p className="text-center text-neutral-500 py-8">
|
<p className="text-center text-neutral-500 dark:text-neutral-400 py-8">
|
||||||
{t('events.noPhotosAvailable')}
|
{t('events.noPhotosAvailable')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -287,7 +287,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
|||||||
: 'border-transparent hover:border-neutral-300'
|
: 'border-transparent hover:border-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="aspect-square bg-neutral-100">
|
<div className="aspect-square bg-neutral-100 dark:bg-neutral-700">
|
||||||
<AuthenticatedImage
|
<AuthenticatedImage
|
||||||
src={photo.thumbnail_url || photo.url}
|
src={photo.thumbnail_url || photo.url}
|
||||||
alt={photo.filename}
|
alt={photo.filename}
|
||||||
@@ -309,7 +309,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 border-t border-neutral-200 flex justify-between gap-3">
|
<div className="p-6 border-t border-neutral-200 dark:border-neutral-700 flex justify-between gap-3">
|
||||||
{categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
|
{categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
|||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||||
<MessageSquare className="w-5 h-5" />
|
<MessageSquare className="w-5 h-5" />
|
||||||
{t('feedback.settings.title', 'Guest Feedback Settings')}
|
{t('feedback.settings.title', 'Guest Feedback Settings')}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -62,7 +62,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
|||||||
onChange={() => handleToggle('feedback_enabled')}
|
onChange={() => handleToggle('feedback_enabled')}
|
||||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-neutral-700">
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
{t('feedback.settings.enableFeedback', 'Enable feedback')}
|
{t('feedback.settings.enableFeedback', 'Enable feedback')}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -72,77 +72,77 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
|||||||
<>
|
<>
|
||||||
{/* Feedback Types */}
|
{/* Feedback Types */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium text-neutral-700">
|
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
{t('feedback.settings.feedbackTypes', 'Feedback Types')}
|
{t('feedback.settings.feedbackTypes', 'Feedback Types')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
<label className="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.allow_ratings}
|
checked={settings.allow_ratings}
|
||||||
onChange={() => handleToggle('allow_ratings')}
|
onChange={() => handleToggle('allow_ratings')}
|
||||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<Star className="w-5 h-5 text-neutral-600" />
|
<Star className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm font-medium text-neutral-900">
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{t('feedback.settings.ratings', 'Star Ratings')}
|
{t('feedback.settings.ratings', 'Star Ratings')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('feedback.settings.ratingsDesc', 'Allow guests to rate photos (1-5 stars)')}
|
{t('feedback.settings.ratingsDesc', 'Allow guests to rate photos (1-5 stars)')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
<label className="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.allow_likes}
|
checked={settings.allow_likes}
|
||||||
onChange={() => handleToggle('allow_likes')}
|
onChange={() => handleToggle('allow_likes')}
|
||||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<Heart className="w-5 h-5 text-neutral-600" />
|
<Heart className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm font-medium text-neutral-900">
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{t('feedback.settings.likes', 'Likes')}
|
{t('feedback.settings.likes', 'Likes')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('feedback.settings.likesDesc', 'Simple like/unlike functionality')}
|
{t('feedback.settings.likesDesc', 'Simple like/unlike functionality')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
<label className="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.allow_comments}
|
checked={settings.allow_comments}
|
||||||
onChange={() => handleToggle('allow_comments')}
|
onChange={() => handleToggle('allow_comments')}
|
||||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<MessageSquare className="w-5 h-5 text-neutral-600" />
|
<MessageSquare className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm font-medium text-neutral-900">
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{t('feedback.settings.comments', 'Comments')}
|
{t('feedback.settings.comments', 'Comments')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('feedback.settings.commentsDesc', 'Text comments on photos')}
|
{t('feedback.settings.commentsDesc', 'Text comments on photos')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
<label className="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={settings.allow_favorites}
|
checked={settings.allow_favorites}
|
||||||
onChange={() => handleToggle('allow_favorites')}
|
onChange={() => handleToggle('allow_favorites')}
|
||||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<Bookmark className="w-5 h-5 text-neutral-600" />
|
<Bookmark className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm font-medium text-neutral-900">
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{t('feedback.settings.favorites', 'Favorites')}
|
{t('feedback.settings.favorites', 'Favorites')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('feedback.settings.favoritesDesc', 'Mark photos as favorites')}
|
{t('feedback.settings.favoritesDesc', 'Mark photos as favorites')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,11 +150,11 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-4" />
|
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
|
||||||
|
|
||||||
{/* Privacy & Moderation */}
|
{/* Privacy & Moderation */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium text-neutral-700">
|
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
{t('feedback.settings.privacyModeration', 'Privacy & Moderation')}
|
{t('feedback.settings.privacyModeration', 'Privacy & Moderation')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -166,10 +166,10 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
|||||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm font-medium text-neutral-900">
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{t('feedback.settings.requireInfo', 'Require Name & Email')}
|
{t('feedback.settings.requireInfo', 'Require Name & Email')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('feedback.settings.requireInfoDesc', 'Guests must provide name and email to leave feedback')}
|
{t('feedback.settings.requireInfoDesc', 'Guests must provide name and email to leave feedback')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,12 +183,12 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
|||||||
disabled={!settings.allow_comments}
|
disabled={!settings.allow_comments}
|
||||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 disabled:opacity-50"
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
<Shield className="w-5 h-5 text-neutral-600" />
|
<Shield className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm font-medium text-neutral-900">
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{t('feedback.settings.moderateComments', 'Moderate Comments')}
|
{t('feedback.settings.moderateComments', 'Moderate Comments')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('feedback.settings.moderateCommentsDesc', 'Comments require approval before being visible')}
|
{t('feedback.settings.moderateCommentsDesc', 'Comments require approval before being visible')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,12 +201,12 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
|||||||
onChange={() => handleToggle('show_feedback_to_guests')}
|
onChange={() => handleToggle('show_feedback_to_guests')}
|
||||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<Eye className="w-5 h-5 text-neutral-600" />
|
<Eye className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm font-medium text-neutral-900">
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{t('feedback.settings.showToGuests', 'Show Feedback to Guests')}
|
{t('feedback.settings.showToGuests', 'Show Feedback to Guests')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('feedback.settings.showToGuestsDesc', 'Other guests can see ratings, likes, and approved comments')}
|
{t('feedback.settings.showToGuestsDesc', 'Other guests can see ratings, likes, and approved comments')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,7 +214,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-4" />
|
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
|
||||||
|
|
||||||
{/* Rate Limiting */}
|
{/* Rate Limiting */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -226,10 +226,10 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
|||||||
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm font-medium text-neutral-900">
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{t('feedback.settings.enableRateLimiting', 'Enable Rate Limiting')}
|
{t('feedback.settings.enableRateLimiting', 'Enable Rate Limiting')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('feedback.settings.rateLimitingDesc', 'Prevent spam by limiting feedback frequency')}
|
{t('feedback.settings.rateLimitingDesc', 'Prevent spam by limiting feedback frequency')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,7 +238,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
|||||||
{settings.enable_rate_limiting && (
|
{settings.enable_rate_limiting && (
|
||||||
<div className="grid grid-cols-2 gap-4 ml-7">
|
<div className="grid grid-cols-2 gap-4 ml-7">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
<label className="block text-xs font-medium text-neutral-600 dark:text-neutral-400 mb-1">
|
||||||
{t('feedback.settings.timeWindow', 'Time Window (minutes)')}
|
{t('feedback.settings.timeWindow', 'Time Window (minutes)')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -247,11 +247,11 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
|||||||
max="60"
|
max="60"
|
||||||
value={settings.rate_limit_window_minutes || 15}
|
value={settings.rate_limit_window_minutes || 15}
|
||||||
onChange={(e) => handleNumberChange('rate_limit_window_minutes', e.target.value)}
|
onChange={(e) => handleNumberChange('rate_limit_window_minutes', e.target.value)}
|
||||||
className="w-full px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
className="w-full px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-primary-500 focus:border-primary-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">
|
<label className="block text-xs font-medium text-neutral-600 dark:text-neutral-400 mb-1">
|
||||||
{t('feedback.settings.maxRequests', 'Max Requests')}
|
{t('feedback.settings.maxRequests', 'Max Requests')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -260,7 +260,7 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
|
|||||||
max="100"
|
max="100"
|
||||||
value={settings.rate_limit_max_requests || 10}
|
value={settings.rate_limit_max_requests || 10}
|
||||||
onChange={(e) => handleNumberChange('rate_limit_max_requests', e.target.value)}
|
onChange={(e) => handleNumberChange('rate_limit_max_requests', e.target.value)}
|
||||||
className="w-full px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
className="w-full px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-primary-500 focus:border-primary-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
|
|||||||
if (!isEditing) {
|
if (!isEditing) {
|
||||||
return (
|
return (
|
||||||
<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('events.heroPhoto')}
|
{t('events.heroPhoto')}
|
||||||
</label>
|
</label>
|
||||||
{currentHeroPhoto ? (
|
{currentHeroPhoto ? (
|
||||||
@@ -57,10 +57,10 @@ export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<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('events.heroPhoto')}
|
{t('events.heroPhoto')}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-neutral-500 mb-2">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">
|
||||||
{t('events.heroPhotoHelp')}
|
{t('events.heroPhotoHelp')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -107,12 +107,12 @@ export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||||
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
||||||
<div className="p-6 border-b border-neutral-200">
|
<div className="p-6 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold">{t('events.selectHeroPhoto')}</h2>
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('events.selectHeroPhoto')}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -157,7 +157,7 @@ export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 border-t border-neutral-200 flex justify-end gap-3">
|
<div className="p-6 border-t border-neutral-200 dark:border-neutral-700 flex justify-end gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
|
|||||||
@@ -103,10 +103,10 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-xl font-semibold text-neutral-900">{t('passwordChange.title')}</h2>
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('passwordChange.title')}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
|
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5 text-neutral-500" />
|
<X className="w-5 h-5 text-neutral-500" />
|
||||||
</button>
|
</button>
|
||||||
@@ -115,7 +115,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Current Password */}
|
{/* Current Password */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
{t('passwordChange.currentPassword')}
|
{t('passwordChange.currentPassword')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -131,7 +131,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))}
|
onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))}
|
||||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
|
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
|
||||||
>
|
>
|
||||||
{showPasswords.current ?
|
{showPasswords.current ?
|
||||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||||
@@ -143,7 +143,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
|
|
||||||
{/* New Password */}
|
{/* New Password */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
{t('passwordChange.newPassword')}
|
{t('passwordChange.newPassword')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -159,7 +159,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
|
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
|
||||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
|
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
|
||||||
>
|
>
|
||||||
{showPasswords.new ?
|
{showPasswords.new ?
|
||||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||||
@@ -171,7 +171,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
|
|
||||||
{/* Confirm Password */}
|
{/* Confirm Password */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||||
{t('passwordChange.confirmPassword')}
|
{t('passwordChange.confirmPassword')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -187,7 +187,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
|
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
|
||||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
|
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
|
||||||
>
|
>
|
||||||
{showPasswords.confirm ?
|
{showPasswords.confirm ?
|
||||||
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
<EyeOff className="w-4 h-4 text-neutral-500" /> :
|
||||||
@@ -198,10 +198,10 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password Requirements */}
|
{/* Password Requirements */}
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
<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">
|
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
<p className="font-medium">{t('passwordChange.requirements')}</p>
|
<p className="font-medium">{t('passwordChange.requirements')}</p>
|
||||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||||
<li>{t('passwordChange.minLength')}</li>
|
<li>{t('passwordChange.minLength')}</li>
|
||||||
|
|||||||
@@ -101,8 +101,8 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
|||||||
inline-flex items-center gap-2 px-4 py-2 rounded-lg border font-medium text-sm
|
inline-flex items-center gap-2 px-4 py-2 rounded-lg border font-medium text-sm
|
||||||
transition-colors
|
transition-colors
|
||||||
${isDisabled
|
${isDisabled
|
||||||
? 'bg-neutral-100 text-neutral-400 border-neutral-200 cursor-not-allowed'
|
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-400 border-neutral-200 dark:border-neutral-700 cursor-not-allowed'
|
||||||
: 'bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-50'
|
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@@ -129,9 +129,9 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
{/* Dropdown Menu */}
|
||||||
<div className="absolute right-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-neutral-200 z-20">
|
<div className="absolute right-0 mt-2 w-72 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 z-20">
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<p className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
<p className="px-3 py-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||||
{hasSelection
|
{hasSelection
|
||||||
? t('export.exportSelected', 'Export {{count}} selected', { count: selectedPhotoIds.length })
|
? t('export.exportSelected', 'Export {{count}} selected', { count: selectedPhotoIds.length })
|
||||||
: t('export.exportFiltered', 'Export filtered photos')
|
: t('export.exportFiltered', 'Export filtered photos')
|
||||||
@@ -145,14 +145,14 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
|||||||
key={format.value}
|
key={format.value}
|
||||||
onClick={() => handleExport(format.value as 'txt' | 'csv' | 'xmp' | 'json')}
|
onClick={() => handleExport(format.value as 'txt' | 'csv' | 'xmp' | 'json')}
|
||||||
disabled={exportMutation.isPending}
|
disabled={exportMutation.isPending}
|
||||||
className="w-full flex items-start gap-3 px-3 py-2 rounded-md hover:bg-neutral-50 text-left transition-colors"
|
className="w-full flex items-start gap-3 px-3 py-2 rounded-md hover:bg-neutral-50 dark:hover:bg-neutral-700 text-left transition-colors"
|
||||||
>
|
>
|
||||||
<Icon className="w-5 h-5 text-neutral-500 mt-0.5" />
|
<Icon className="w-5 h-5 text-neutral-500 dark:text-neutral-400 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-neutral-900">
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{format.label}
|
{format.label}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{format.description}
|
{format.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,7 +165,7 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasSelection && !hasFilters && (
|
{!hasSelection && !hasFilters && (
|
||||||
<p className="mt-1 text-xs text-neutral-500">
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('export.hint', 'Select photos or apply filters to export')}
|
{t('export.hint', 'Select photos or apply filters to export')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
|||||||
filters.hasComments;
|
filters.hasComments;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg border border-neutral-200 p-4 mb-4">
|
<div className="bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 p-4 mb-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="font-medium text-neutral-900 flex items-center gap-2">
|
<h3 className="font-medium text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||||
<Filter className="w-4 h-4" />
|
<Filter className="w-4 h-4" />
|
||||||
{t('filter.feedbackFilters', 'Feedback Filters')}
|
{t('filter.feedbackFilters', 'Feedback Filters')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -78,14 +78,14 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Rating Filter */}
|
{/* Rating Filter */}
|
||||||
<div>
|
<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">
|
||||||
<Star className="w-4 h-4 inline mr-1" />
|
<Star className="w-4 h-4 inline mr-1" />
|
||||||
{t('filter.rating', 'Rating')}
|
{t('filter.rating', 'Rating')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={filters.minRating ?? ''}
|
value={filters.minRating ?? ''}
|
||||||
onChange={(e) => handleRatingChange(e.target.value === '' ? null : parseFloat(e.target.value))}
|
onChange={(e) => handleRatingChange(e.target.value === '' ? null : parseFloat(e.target.value))}
|
||||||
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"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{RATING_OPTIONS.map(option => (
|
{RATING_OPTIONS.map(option => (
|
||||||
@@ -107,10 +107,10 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<Heart className="w-4 h-4 text-red-500" />
|
<Heart className="w-4 h-4 text-red-500" />
|
||||||
<span className="text-sm text-neutral-700">
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
{t('filter.hasLikes', 'Has likes')}
|
{t('filter.hasLikes', 'Has likes')}
|
||||||
{summary && (
|
{summary && (
|
||||||
<span className="text-neutral-500 ml-1">({summary.withLikes})</span>
|
<span className="text-neutral-500 dark:text-neutral-400 ml-1">({summary.withLikes})</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -124,10 +124,10 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<Bookmark className="w-4 h-4 text-yellow-500" />
|
<Bookmark className="w-4 h-4 text-yellow-500" />
|
||||||
<span className="text-sm text-neutral-700">
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
{t('filter.hasFavorites', 'Has favorites')}
|
{t('filter.hasFavorites', 'Has favorites')}
|
||||||
{summary && (
|
{summary && (
|
||||||
<span className="text-neutral-500 ml-1">({summary.withFavorites})</span>
|
<span className="text-neutral-500 dark:text-neutral-400 ml-1">({summary.withFavorites})</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -141,10 +141,10 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<MessageCircle className="w-4 h-4 text-blue-500" />
|
<MessageCircle className="w-4 h-4 text-blue-500" />
|
||||||
<span className="text-sm text-neutral-700">
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
{t('filter.hasComments', 'Has comments')}
|
{t('filter.hasComments', 'Has comments')}
|
||||||
{summary && (
|
{summary && (
|
||||||
<span className="text-neutral-500 ml-1">({summary.withComments})</span>
|
<span className="text-neutral-500 dark:text-neutral-400 ml-1">({summary.withComments})</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -153,15 +153,15 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
|||||||
{/* Logic Toggle */}
|
{/* Logic Toggle */}
|
||||||
{(filters.hasLikes || filters.hasFavorites || filters.hasComments) && (
|
{(filters.hasLikes || filters.hasFavorites || filters.hasComments) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-neutral-600">{t('filter.combineWith', 'Combine with')}:</span>
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('filter.combineWith', 'Combine with')}:</span>
|
||||||
<div className="flex rounded-lg border border-neutral-200 overflow-hidden">
|
<div className="flex rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleLogicChange('AND')}
|
onClick={() => handleLogicChange('AND')}
|
||||||
className={`px-3 py-1 text-sm font-medium transition-colors ${
|
className={`px-3 py-1 text-sm font-medium transition-colors ${
|
||||||
filters.logic === 'AND' || !filters.logic
|
filters.logic === 'AND' || !filters.logic
|
||||||
? 'bg-primary-600 text-white'
|
? 'bg-primary-600 text-white'
|
||||||
: 'bg-white text-neutral-600 hover:bg-neutral-50'
|
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||||
}`}
|
}`}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
@@ -173,7 +173,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
|||||||
className={`px-3 py-1 text-sm font-medium transition-colors ${
|
className={`px-3 py-1 text-sm font-medium transition-colors ${
|
||||||
filters.logic === 'OR'
|
filters.logic === 'OR'
|
||||||
? 'bg-primary-600 text-white'
|
? 'bg-primary-600 text-white'
|
||||||
: 'bg-white text-neutral-600 hover:bg-neutral-50'
|
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||||
}`}
|
}`}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
@@ -185,7 +185,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
|||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
{summary && (
|
{summary && (
|
||||||
<div className="pt-2 border-t border-neutral-100 text-sm text-neutral-600">
|
<div className="pt-2 border-t border-neutral-100 dark:border-neutral-700 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('filter.showingPhotos', 'Total photos')}: {summary.total}
|
{t('filter.showingPhotos', 'Total photos')}: {summary.total}
|
||||||
{summary.withRatings > 0 && (
|
{summary.withRatings > 0 && (
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-neutral-200 rounded-lg p-4 mb-6">
|
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 mb-6">
|
||||||
<div className="flex flex-col lg:flex-row gap-4">
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -60,7 +60,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
|||||||
const numeric = Number(raw);
|
const numeric = Number(raw);
|
||||||
onCategoryChange(Number.isNaN(numeric) ? raw : numeric);
|
onCategoryChange(Number.isNaN(numeric) ? raw : numeric);
|
||||||
}}
|
}}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
className="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="">{t('gallery.allCategories', 'All Categories')}</option>
|
<option value="">{t('gallery.allCategories', 'All Categories')}</option>
|
||||||
<option value="0">{t('gallery.uncategorized', 'Uncategorized')}</option>
|
<option value="0">{t('gallery.uncategorized', 'Uncategorized')}</option>
|
||||||
@@ -78,7 +78,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
|||||||
<select
|
<select
|
||||||
value={mediaType}
|
value={mediaType}
|
||||||
onChange={(e) => onMediaTypeChange(e.target.value as 'all' | 'photo' | 'video')}
|
onChange={(e) => onMediaTypeChange(e.target.value as 'all' | 'photo' | 'video')}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
className="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="all">{t('gallery.allMedia', 'All media')}</option>
|
<option value="all">{t('gallery.allMedia', 'All media')}</option>
|
||||||
<option value="photo">{t('gallery.photosOnly', 'Photos only')}</option>
|
<option value="photo">{t('gallery.photosOnly', 'Photos only')}</option>
|
||||||
@@ -92,7 +92,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
|||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)}
|
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
className="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="date">{t('gallery.sortByDate', 'Sort by Date')}</option>
|
<option value="date">{t('gallery.sortByDate', 'Sort by Date')}</option>
|
||||||
<option value="name">{t('gallery.sortByName', 'Sort by Name')}</option>
|
<option value="name">{t('gallery.sortByName', 'Sort by Name')}</option>
|
||||||
@@ -102,13 +102,13 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSortToggle}
|
onClick={handleSortToggle}
|
||||||
className="p-2 border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors"
|
className="p-2 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
|
||||||
aria-label={sortOrder === 'asc' ? t('gallery.sortDescending', 'Sort descending') : t('gallery.sortAscending', 'Sort ascending')}
|
aria-label={sortOrder === 'asc' ? t('gallery.sortDescending', 'Sort descending') : t('gallery.sortAscending', 'Sort ascending')}
|
||||||
>
|
>
|
||||||
{sortOrder === 'asc' ? (
|
{sortOrder === 'asc' ? (
|
||||||
<SortAsc className="w-5 h-5 text-neutral-600" />
|
<SortAsc className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||||
) : (
|
) : (
|
||||||
<SortDesc className="w-5 h-5 text-neutral-600" />
|
<SortDesc className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -203,13 +203,13 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Category Selection */}
|
{/* Category Selection */}
|
||||||
<div>
|
<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('upload.photoCategory')}
|
{t('upload.photoCategory')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={selectedCategoryId || ''}
|
value={selectedCategoryId || ''}
|
||||||
onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : null)}
|
onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : null)}
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-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"
|
||||||
>
|
>
|
||||||
<option value="">{t('upload.noCategory')}</option>
|
<option value="">{t('upload.noCategory')}</option>
|
||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
@@ -225,21 +225,21 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
|
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
|
||||||
"hover:border-primary-400 hover:bg-primary-50/50",
|
"hover:border-primary-400 hover:bg-primary-50/50",
|
||||||
selectedFiles.length > 0 ? "border-primary-400 bg-primary-50/30" : "border-neutral-300"
|
selectedFiles.length > 0 ? "border-primary-400 bg-primary-50/30 dark:bg-primary-900/20" : "border-neutral-300 dark:border-neutral-600"
|
||||||
)}
|
)}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
>
|
>
|
||||||
<Upload className="w-12 h-12 mx-auto text-neutral-400 mb-4" />
|
<Upload className="w-12 h-12 mx-auto text-neutral-400 dark:text-neutral-500 mb-4" />
|
||||||
<p className="text-neutral-700 font-medium mb-1">
|
<p className="text-neutral-700 dark:text-neutral-300 font-medium mb-1">
|
||||||
{t('upload.clickToUpload')}
|
{t('upload.clickToUpload')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-neutral-500">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
|
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"text-xs mt-2",
|
"text-xs mt-2",
|
||||||
remainingSlots === 0 ? "text-red-600" : "text-neutral-500"
|
remainingSlots === 0 ? "text-red-600" : "text-neutral-500 dark:text-neutral-400"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{remainingSlots === 0
|
{remainingSlots === 0
|
||||||
@@ -263,22 +263,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
{/* Selected Files */}
|
{/* Selected Files */}
|
||||||
{selectedFiles.length > 0 && (
|
{selectedFiles.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-medium text-neutral-700">
|
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
{t('upload.selectedFiles')} ({selectedFiles.length})
|
{t('upload.selectedFiles')} ({selectedFiles.length})
|
||||||
</p>
|
</p>
|
||||||
<div className="max-h-48 overflow-y-auto space-y-2">
|
<div className="max-h-48 overflow-y-auto space-y-2">
|
||||||
{selectedFiles.map((file, index) => (
|
{selectedFiles.map((file, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg"
|
className="flex items-center justify-between p-2 bg-neutral-50 dark:bg-neutral-800 rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Image className="w-5 h-5 text-neutral-400" />
|
<Image className="w-5 h-5 text-neutral-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-neutral-700 truncate max-w-xs">
|
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300 truncate max-w-xs">
|
||||||
{file.name}
|
{file.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{formatFileSize(file.size)}
|
{formatFileSize(file.size)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,7 +288,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removeFile(index);
|
removeFile(index);
|
||||||
}}
|
}}
|
||||||
className="p-1 hover:bg-neutral-200 rounded"
|
className="p-1 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -313,21 +313,21 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="flex justify-between text-sm text-neutral-600 mb-1">
|
<div className="flex justify-between text-sm text-neutral-600 dark:text-neutral-400 mb-1">
|
||||||
<span>
|
<span>
|
||||||
{t('upload.uploading')}
|
{t('upload.uploading')}
|
||||||
{totalChunks > 1 && ` (${t('common.chunk')} ${currentChunk}/${totalChunks})`}
|
{totalChunks > 1 && ` (${t('common.chunk')} ${currentChunk}/${totalChunks})`}
|
||||||
</span>
|
</span>
|
||||||
<span>{uploadProgress}%</span>
|
<span>{uploadProgress}%</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||||
style={{ width: `${uploadProgress}%` }}
|
style={{ width: `${uploadProgress}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{totalChunks > 1 && (
|
{totalChunks > 1 && (
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
{t('upload.uploadingChunks', { count: selectedFiles.length, total: totalChunks })}
|
{t('upload.uploadingChunks', { count: selectedFiles.length, total: totalChunks })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[90vh]">
|
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[90vh]">
|
||||||
{/* Fixed Header */}
|
{/* Fixed Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
<div className="flex items-center justify-between p-6 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<h2 className="text-xl font-semibold text-neutral-900">{t('upload.uploadMedia', t('events.uploadPhotos'))}</h2>
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('upload.uploadMedia', t('events.uploadPhotos'))}</h2>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const RestoreWizard = () => {
|
|||||||
{ id: 'backup', title: t('backup.restore.steps.chooseBackup') },
|
{ id: 'backup', title: t('backup.restore.steps.chooseBackup') },
|
||||||
{ id: 'options', title: t('backup.restore.steps.restoreOptions') },
|
{ id: 'options', title: t('backup.restore.steps.restoreOptions') },
|
||||||
{ id: 'confirm', title: t('backup.restore.steps.reviewConfirm') },
|
{ id: 'confirm', title: t('backup.restore.steps.reviewConfirm') },
|
||||||
{ id: 'progress', title: t('backup.restore.steps.restoreProgress') }
|
{ id: 'progress', title: t('backup.restore.steps.progress') }
|
||||||
];
|
];
|
||||||
|
|
||||||
const restoreTypes = [
|
const restoreTypes = [
|
||||||
@@ -187,8 +187,8 @@ export const RestoreWizard = () => {
|
|||||||
const renderSourceSelection = () => (
|
const renderSourceSelection = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.source.title')}</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.source.title')}</h3>
|
||||||
<p className="text-sm text-gray-600">{t('backup.restore.source.subtitle')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.source.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
@@ -196,52 +196,52 @@ export const RestoreWizard = () => {
|
|||||||
onClick={() => setRestoreData(prev => ({ ...prev, source: 'local' }))}
|
onClick={() => setRestoreData(prev => ({ ...prev, source: 'local' }))}
|
||||||
className={`p-6 rounded-lg border-2 transition-all ${
|
className={`p-6 rounded-lg border-2 transition-all ${
|
||||||
restoreData.source === 'local'
|
restoreData.source === 'local'
|
||||||
? 'border-primary bg-primary-50'
|
? 'border-primary bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<HardDrive className={`h-12 w-12 mb-3 mx-auto ${
|
<HardDrive className={`h-12 w-12 mb-3 mx-auto ${
|
||||||
restoreData.source === 'local' ? 'text-primary' : 'text-gray-400'
|
restoreData.source === 'local' ? 'text-primary' : 'text-neutral-400'
|
||||||
}`} />
|
}`} />
|
||||||
<h4 className="font-medium text-gray-900">{t('backup.restore.source.local.name')}</h4>
|
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.local.name')}</h4>
|
||||||
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.local.description')}</p>
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.local.description')}</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setRestoreData(prev => ({ ...prev, source: 's3' }))}
|
onClick={() => setRestoreData(prev => ({ ...prev, source: 's3' }))}
|
||||||
className={`p-6 rounded-lg border-2 transition-all ${
|
className={`p-6 rounded-lg border-2 transition-all ${
|
||||||
restoreData.source === 's3'
|
restoreData.source === 's3'
|
||||||
? 'border-primary bg-primary-50'
|
? 'border-primary bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Cloud className={`h-12 w-12 mb-3 mx-auto ${
|
<Cloud className={`h-12 w-12 mb-3 mx-auto ${
|
||||||
restoreData.source === 's3' ? 'text-primary' : 'text-gray-400'
|
restoreData.source === 's3' ? 'text-primary' : 'text-neutral-400'
|
||||||
}`} />
|
}`} />
|
||||||
<h4 className="font-medium text-gray-900">{t('backup.restore.source.s3.name')}</h4>
|
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.s3.name')}</h4>
|
||||||
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.s3.description')}</p>
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.s3.description')}</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setRestoreData(prev => ({ ...prev, source: 'upload' }))}
|
onClick={() => setRestoreData(prev => ({ ...prev, source: 'upload' }))}
|
||||||
className={`p-6 rounded-lg border-2 transition-all ${
|
className={`p-6 rounded-lg border-2 transition-all ${
|
||||||
restoreData.source === 'upload'
|
restoreData.source === 'upload'
|
||||||
? 'border-primary bg-primary-50'
|
? 'border-primary bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Upload className={`h-12 w-12 mb-3 mx-auto ${
|
<Upload className={`h-12 w-12 mb-3 mx-auto ${
|
||||||
restoreData.source === 'upload' ? 'text-primary' : 'text-gray-400'
|
restoreData.source === 'upload' ? 'text-primary' : 'text-neutral-400'
|
||||||
}`} />
|
}`} />
|
||||||
<h4 className="font-medium text-gray-900">{t('backup.restore.source.upload.name')}</h4>
|
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.upload.name')}</h4>
|
||||||
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.upload.description')}</p>
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.upload.description')}</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Source-specific configuration */}
|
{/* Source-specific configuration */}
|
||||||
{restoreData.source === 's3' && (
|
{restoreData.source === 's3' && (
|
||||||
<Card className="p-4 space-y-4">
|
<Card className="p-4 space-y-4">
|
||||||
<h4 className="font-medium text-gray-900">{t('backup.restore.source.configuration.s3')}</h4>
|
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.configuration.s3')}</h4>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder={t('backup.restore.source.configuration.endpoint')}
|
placeholder={t('backup.restore.source.configuration.endpoint')}
|
||||||
@@ -283,8 +283,8 @@ export const RestoreWizard = () => {
|
|||||||
{restoreData.source === 'upload' && (
|
{restoreData.source === 'upload' && (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Upload className="h-12 w-12 mx-auto mb-3 text-gray-400" />
|
<Upload className="h-12 w-12 mx-auto mb-3 text-neutral-400" />
|
||||||
<p className="text-sm text-gray-600">{t('backup.restore.source.upload.comingSoon')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.source.upload.comingSoon')}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -294,16 +294,16 @@ export const RestoreWizard = () => {
|
|||||||
const renderBackupSelection = () => (
|
const renderBackupSelection = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.backup.title')}</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.backup.title')}</h3>
|
||||||
<p className="text-sm text-gray-600">{t('backup.restore.backup.subtitle')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.backup.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadingBackups ? (
|
{loadingBackups ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : availableBackups?.length === 0 ? (
|
) : availableBackups?.length === 0 ? (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<FileArchive className="h-12 w-12 mx-auto mb-3 text-gray-300" />
|
<FileArchive className="h-12 w-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" />
|
||||||
<p className="text-gray-500">{t('backup.restore.backup.noBackupsFound')}</p>
|
<p className="text-neutral-500 dark:text-neutral-400">{t('backup.restore.backup.noBackupsFound')}</p>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -312,7 +312,7 @@ export const RestoreWizard = () => {
|
|||||||
key={backup.id}
|
key={backup.id}
|
||||||
className={`p-4 cursor-pointer transition-all ${
|
className={`p-4 cursor-pointer transition-all ${
|
||||||
restoreData.selectedBackup?.id === backup.id
|
restoreData.selectedBackup?.id === backup.id
|
||||||
? 'ring-2 ring-primary bg-primary-50'
|
? 'ring-2 ring-primary bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'hover:shadow-md'
|
: 'hover:shadow-md'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setRestoreData(prev => ({ ...prev, selectedBackup: backup }))}
|
onClick={() => setRestoreData(prev => ({ ...prev, selectedBackup: backup }))}
|
||||||
@@ -320,25 +320,25 @@ export const RestoreWizard = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<div className={`p-2 rounded-lg ${
|
<div className={`p-2 rounded-lg ${
|
||||||
backup.status === 'completed' ? 'bg-green-100' : 'bg-amber-100'
|
backup.status === 'completed' ? 'bg-green-100 dark:bg-green-900/40' : 'bg-amber-100 dark:bg-amber-900/40'
|
||||||
}`}>
|
}`}>
|
||||||
{backup.status === 'completed' ? (
|
{backup.status === 'completed' ? (
|
||||||
<CheckCircle className="h-6 w-6 text-green-600" />
|
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||||
) : (
|
) : (
|
||||||
<AlertCircle className="h-6 w-6 text-amber-600" />
|
<AlertCircle className="h-6 w-6 text-amber-600 dark:text-amber-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">
|
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{format(new Date(backup.created_at), 'PPP')} {t('backup.restore.backup.at')} {format(new Date(backup.created_at), 'p')}
|
{format(new Date(backup.created_at), 'PPP')} {t('backup.restore.backup.at')} {format(new Date(backup.created_at), 'p')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
{t('backup.dashboard.backupType', { type: backup.backup_type })} • {formatBytes(backup.total_size || 0)}
|
{t('backup.dashboard.backupType', { type: backup.backup_type })} • {formatBytes(backup.total_size || 0)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{backup.encrypted && (
|
{backup.encrypted && (
|
||||||
<Shield className="h-5 w-5 text-gray-400" />
|
<Shield className="h-5 w-5 text-neutral-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -347,12 +347,12 @@ export const RestoreWizard = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{restoreData.selectedBackup?.encrypted && (
|
{restoreData.selectedBackup?.encrypted && (
|
||||||
<Card className="p-4 bg-amber-50 border-amber-200">
|
<Card className="p-4 bg-amber-50 dark:bg-amber-900/30 border-amber-200 dark:border-amber-800">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<Shield className="h-5 w-5 text-amber-600 mt-0.5" />
|
<Shield className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-amber-900">{t('backup.restore.backup.encrypted')}</p>
|
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">{t('backup.restore.backup.encrypted')}</p>
|
||||||
<p className="text-sm text-amber-700 mt-1">
|
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||||
{t('backup.restore.backup.encryptedMessage')}
|
{t('backup.restore.backup.encryptedMessage')}
|
||||||
</p>
|
</p>
|
||||||
<Input
|
<Input
|
||||||
@@ -375,8 +375,8 @@ export const RestoreWizard = () => {
|
|||||||
const renderRestoreOptions = () => (
|
const renderRestoreOptions = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.options.title')}</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.options.title')}</h3>
|
||||||
<p className="text-sm text-gray-600">{t('backup.restore.options.subtitle')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.options.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -388,18 +388,18 @@ export const RestoreWizard = () => {
|
|||||||
onClick={() => setRestoreData(prev => ({ ...prev, restoreType: type.id }))}
|
onClick={() => setRestoreData(prev => ({ ...prev, restoreType: type.id }))}
|
||||||
className={`p-4 rounded-lg border-2 text-left transition-all ${
|
className={`p-4 rounded-lg border-2 text-left transition-all ${
|
||||||
restoreData.restoreType === type.id
|
restoreData.restoreType === type.id
|
||||||
? 'border-primary bg-primary-50'
|
? 'border-primary bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<Icon className={`h-6 w-6 mt-1 ${
|
<Icon className={`h-6 w-6 mt-1 ${
|
||||||
restoreData.restoreType === type.id ? 'text-primary' : 'text-gray-400'
|
restoreData.restoreType === type.id ? 'text-primary' : 'text-neutral-400'
|
||||||
}`} />
|
}`} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h4 className="font-medium text-gray-900">{type.name}</h4>
|
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{type.name}</h4>
|
||||||
<p className="text-sm text-gray-600 mt-1">{type.description}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">{type.description}</p>
|
||||||
<p className="text-xs text-amber-600 mt-2">
|
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
||||||
<AlertTriangle className="inline h-3 w-3 mr-1" />
|
<AlertTriangle className="inline h-3 w-3 mr-1" />
|
||||||
{type.warning}
|
{type.warning}
|
||||||
</p>
|
</p>
|
||||||
@@ -412,7 +412,7 @@ export const RestoreWizard = () => {
|
|||||||
|
|
||||||
{/* Additional Options */}
|
{/* Additional Options */}
|
||||||
<Card className="p-4 space-y-4">
|
<Card className="p-4 space-y-4">
|
||||||
<h4 className="font-medium text-gray-900">{t('backup.restore.options.additionalOptions.title')}</h4>
|
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.options.additionalOptions.title')}</h4>
|
||||||
|
|
||||||
<label className="flex items-start space-x-3">
|
<label className="flex items-start space-x-3">
|
||||||
<input
|
<input
|
||||||
@@ -422,11 +422,11 @@ export const RestoreWizard = () => {
|
|||||||
...prev,
|
...prev,
|
||||||
skipPreBackup: e.target.checked
|
skipPreBackup: e.target.checked
|
||||||
}))}
|
}))}
|
||||||
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-700">{t('backup.restore.options.additionalOptions.skipPreBackup')}</p>
|
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.restore.options.additionalOptions.skipPreBackup')}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('backup.restore.options.additionalOptions.skipPreBackupHelp')}
|
{t('backup.restore.options.additionalOptions.skipPreBackupHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,11 +440,11 @@ export const RestoreWizard = () => {
|
|||||||
...prev,
|
...prev,
|
||||||
force: e.target.checked
|
force: e.target.checked
|
||||||
}))}
|
}))}
|
||||||
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-700">{t('backup.restore.options.additionalOptions.force')}</p>
|
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.restore.options.additionalOptions.force')}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('backup.restore.options.additionalOptions.forceHelp')}
|
{t('backup.restore.options.additionalOptions.forceHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -456,8 +456,8 @@ export const RestoreWizard = () => {
|
|||||||
const renderConfirmation = () => (
|
const renderConfirmation = () => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.confirmation.title')}</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.confirmation.title')}</h3>
|
||||||
<p className="text-sm text-gray-600">{t('backup.restore.confirmation.subtitle')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{validationResult ? (
|
{validationResult ? (
|
||||||
@@ -465,8 +465,8 @@ export const RestoreWizard = () => {
|
|||||||
{/* Validation Results */}
|
{/* Validation Results */}
|
||||||
<Card className={`p-4 ${
|
<Card className={`p-4 ${
|
||||||
validationResult.validation?.isValid
|
validationResult.validation?.isValid
|
||||||
? 'bg-green-50 border-green-200'
|
? 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800'
|
||||||
: 'bg-red-50 border-red-200'
|
: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
{validationResult.validation?.isValid ? (
|
{validationResult.validation?.isValid ? (
|
||||||
@@ -476,14 +476,14 @@ export const RestoreWizard = () => {
|
|||||||
)}
|
)}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className={`text-sm font-medium ${
|
<p className={`text-sm font-medium ${
|
||||||
validationResult.validation?.isValid ? 'text-green-900' : 'text-red-900'
|
validationResult.validation?.isValid ? 'text-green-900 dark:text-green-200' : 'text-red-900 dark:text-red-200'
|
||||||
}`}>
|
}`}>
|
||||||
{validationResult.validation?.isValid
|
{validationResult.validation?.isValid
|
||||||
? t('backup.restore.confirmation.validation.passed')
|
? t('backup.restore.confirmation.validation.passed')
|
||||||
: t('backup.restore.confirmation.validation.failed')}
|
: t('backup.restore.confirmation.validation.failed')}
|
||||||
</p>
|
</p>
|
||||||
{validationResult.validation?.errors?.length > 0 && (
|
{validationResult.validation?.errors?.length > 0 && (
|
||||||
<ul className="mt-2 text-sm text-red-700 list-disc list-inside">
|
<ul className="mt-2 text-sm text-red-700 dark:text-red-300 list-disc list-inside">
|
||||||
{validationResult.validation.errors.map((error, idx) => (
|
{validationResult.validation.errors.map((error, idx) => (
|
||||||
<li key={idx}>{error}</li>
|
<li key={idx}>{error}</li>
|
||||||
))}
|
))}
|
||||||
@@ -496,17 +496,17 @@ export const RestoreWizard = () => {
|
|||||||
{/* Space Check */}
|
{/* Space Check */}
|
||||||
{validationResult.spaceCheck && (
|
{validationResult.spaceCheck && (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h4 className="font-medium text-gray-900 mb-3">{t('backup.restore.confirmation.spaceCheck.title')}</h4>
|
<h4 className="font-medium text-neutral-900 dark:text-neutral-100 mb-3">{t('backup.restore.confirmation.spaceCheck.title')}</h4>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.required')}:</span>
|
<span className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.spaceCheck.required')}:</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{validationResult.spaceCheck.requiredFormatted || formatBytes(validationResult.spaceCheck.required || 0)}
|
{validationResult.spaceCheck.requiredFormatted || formatBytes(validationResult.spaceCheck.required || 0)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.available')}:</span>
|
<span className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.spaceCheck.available')}:</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{validationResult.spaceCheck.availableFormatted ||
|
{validationResult.spaceCheck.availableFormatted ||
|
||||||
(validationResult.spaceCheck.available != null ? formatBytes(validationResult.spaceCheck.available) : t('common.unknown', 'Unknown'))}
|
(validationResult.spaceCheck.available != null ? formatBytes(validationResult.spaceCheck.available) : t('common.unknown', 'Unknown'))}
|
||||||
</span>
|
</span>
|
||||||
@@ -523,38 +523,38 @@ export const RestoreWizard = () => {
|
|||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h4 className="font-medium text-gray-900 mb-3">{t('backup.restore.confirmation.summary.title')}</h4>
|
<h4 className="font-medium text-neutral-900 dark:text-neutral-100 mb-3">{t('backup.restore.confirmation.summary.title')}</h4>
|
||||||
<dl className="space-y-2 text-sm">
|
<dl className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-gray-600">{t('backup.restore.confirmation.summary.source')}:</dt>
|
<dt className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.summary.source')}:</dt>
|
||||||
<dd className="font-medium capitalize">{restoreData.source}</dd>
|
<dd className="font-medium text-neutral-900 dark:text-neutral-100 capitalize">{restoreData.source}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-gray-600">{t('backup.restore.confirmation.summary.backupDate')}:</dt>
|
<dt className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.summary.backupDate')}:</dt>
|
||||||
<dd className="font-medium">
|
<dd className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{format(new Date(restoreData.selectedBackup.created_at), 'PPp')}
|
{format(new Date(restoreData.selectedBackup.created_at), 'PPp')}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-gray-600">{t('backup.restore.confirmation.summary.restoreType')}:</dt>
|
<dt className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.summary.restoreType')}:</dt>
|
||||||
<dd className="font-medium capitalize">{restoreData.restoreType}</dd>
|
<dd className="font-medium text-neutral-900 dark:text-neutral-100 capitalize">{restoreData.restoreType}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<dt className="text-gray-600">{t('backup.restore.confirmation.summary.preBackup')}:</dt>
|
<dt className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.summary.preBackup')}:</dt>
|
||||||
<dd className="font-medium">{restoreData.skipPreBackup ? t('backup.restore.confirmation.summary.skipped') : t('backup.restore.confirmation.summary.enabled')}</dd>
|
<dd className="font-medium text-neutral-900 dark:text-neutral-100">{restoreData.skipPreBackup ? t('backup.restore.confirmation.summary.skipped') : t('backup.restore.confirmation.summary.enabled')}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Warning */}
|
{/* Warning */}
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
<div className="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<AlertTriangle className="h-5 w-5 text-amber-400 mt-0.5" />
|
<AlertTriangle className="h-5 w-5 text-amber-400 mt-0.5" />
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<h3 className="text-sm font-medium text-amber-800">
|
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||||
{t('backup.restore.confirmation.warning.title')}
|
{t('backup.restore.confirmation.warning.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-amber-700">
|
<p className="mt-1 text-sm text-amber-700 dark:text-amber-300">
|
||||||
{t('backup.restore.confirmation.warning.message')}
|
{t('backup.restore.confirmation.warning.message')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -564,7 +564,7 @@ export const RestoreWizard = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
||||||
<p className="mt-2 text-sm text-gray-600">{t('backup.restore.confirmation.validation.checking')}</p>
|
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.validation.checking')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -577,8 +577,8 @@ export const RestoreWizard = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.progress.title')}</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.progress.title')}</h3>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{isRunning ? t('backup.restore.progress.inProgress') : t('backup.restore.progress.completed')}
|
{isRunning ? t('backup.restore.progress.inProgress') : t('backup.restore.progress.completed')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -587,17 +587,17 @@ export const RestoreWizard = () => {
|
|||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">{t('backup.restore.progress.overallProgress')}</span>
|
<span className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.progress.overallProgress')}</span>
|
||||||
<span className="font-medium">{progress.percentage || 0}%</span>
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">{progress.percentage || 0}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-3">
|
||||||
<div
|
<div
|
||||||
className="bg-primary h-3 rounded-full transition-all duration-500"
|
className="bg-primary h-3 rounded-full transition-all duration-500"
|
||||||
style={{ width: `${progress.percentage || 0}%` }}
|
style={{ width: `${progress.percentage || 0}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{progress.currentFile && (
|
{progress.currentFile && (
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('backup.restore.progress.current')}: {progress.currentFile}
|
{t('backup.restore.progress.current')}: {progress.currentFile}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -606,7 +606,7 @@ export const RestoreWizard = () => {
|
|||||||
|
|
||||||
{/* Status Details */}
|
{/* Status Details */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h4 className="font-medium text-gray-900 mb-4">{t('backup.restore.progress.statusDetails')}</h4>
|
<h4 className="font-medium text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.restore.progress.statusDetails')}</h4>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{progress.steps?.map((step, idx) => (
|
{progress.steps?.map((step, idx) => (
|
||||||
<div key={idx} className="flex items-center space-x-3">
|
<div key={idx} className="flex items-center space-x-3">
|
||||||
@@ -617,16 +617,16 @@ export const RestoreWizard = () => {
|
|||||||
) : step.status === 'failed' ? (
|
) : step.status === 'failed' ? (
|
||||||
<XCircle className="h-5 w-5 text-red-500" />
|
<XCircle className="h-5 w-5 text-red-500" />
|
||||||
) : (
|
) : (
|
||||||
<Clock className="h-5 w-5 text-gray-300" />
|
<Clock className="h-5 w-5 text-neutral-300 dark:text-neutral-600" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-gray-900">{step.name}</p>
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{step.name}</p>
|
||||||
{step.message && (
|
{step.message && (
|
||||||
<p className="text-xs text-gray-500">{step.message}</p>
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">{step.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{step.duration && (
|
{step.duration && (
|
||||||
<span className="text-xs text-gray-500">{step.duration}</span>
|
<span className="text-xs text-neutral-500 dark:text-neutral-400">{step.duration}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -636,9 +636,9 @@ export const RestoreWizard = () => {
|
|||||||
{/* Logs */}
|
{/* Logs */}
|
||||||
{progress.logs && progress.logs.length > 0 && (
|
{progress.logs && progress.logs.length > 0 && (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h4 className="font-medium text-gray-900 mb-4">{t('backup.restore.progress.restoreLogs')}</h4>
|
<h4 className="font-medium text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.restore.progress.restoreLogs')}</h4>
|
||||||
<div className="bg-gray-900 rounded-lg p-4 max-h-64 overflow-y-auto">
|
<div className="bg-neutral-900 rounded-lg p-4 max-h-64 overflow-y-auto">
|
||||||
<pre className="text-xs text-gray-300 font-mono">
|
<pre className="text-xs text-neutral-300 font-mono">
|
||||||
{progress.logs.join('\n')}
|
{progress.logs.join('\n')}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -647,14 +647,14 @@ export const RestoreWizard = () => {
|
|||||||
|
|
||||||
{/* Completion Actions */}
|
{/* Completion Actions */}
|
||||||
{!isRunning && progress.status === 'completed' && (
|
{!isRunning && progress.status === 'completed' && (
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
<div className="bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<CheckCircle className="h-5 w-5 text-green-400 mt-0.5" />
|
<CheckCircle className="h-5 w-5 text-green-400 mt-0.5" />
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<h3 className="text-sm font-medium text-green-800">
|
<h3 className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||||
{t('backup.restore.progress.success.title')}
|
{t('backup.restore.progress.success.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-sm text-green-700">
|
<p className="mt-1 text-sm text-green-700 dark:text-green-300">
|
||||||
{t('backup.restore.progress.success.message')}
|
{t('backup.restore.progress.success.message')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -688,7 +688,7 @@ export const RestoreWizard = () => {
|
|||||||
? 'bg-primary'
|
? 'bg-primary'
|
||||||
: currentStep === stepIdx
|
: currentStep === stepIdx
|
||||||
? 'bg-primary'
|
? 'bg-primary'
|
||||||
: 'bg-gray-300'
|
: 'bg-neutral-300 dark:bg-neutral-600'
|
||||||
}
|
}
|
||||||
`}>
|
`}>
|
||||||
{currentStep > stepIdx ? (
|
{currentStep > stepIdx ? (
|
||||||
@@ -700,13 +700,13 @@ export const RestoreWizard = () => {
|
|||||||
{stepIdx !== steps.length - 1 && (
|
{stepIdx !== steps.length - 1 && (
|
||||||
<div className={`
|
<div className={`
|
||||||
absolute top-4 w-full h-0.5
|
absolute top-4 w-full h-0.5
|
||||||
${currentStep > stepIdx ? 'bg-primary' : 'bg-gray-300'}
|
${currentStep > stepIdx ? 'bg-primary' : 'bg-neutral-300 dark:bg-neutral-600'}
|
||||||
`} style={{ left: '2rem', right: '-2rem' }} />
|
`} style={{ left: '2rem', right: '-2rem' }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`
|
<span className={`
|
||||||
mt-2 text-xs font-medium
|
mt-2 text-xs font-medium
|
||||||
${currentStep >= stepIdx ? 'text-gray-900' : 'text-gray-500'}
|
${currentStep >= stepIdx ? 'text-neutral-900 dark:text-neutral-100' : 'text-neutral-500 dark:text-neutral-400'}
|
||||||
`}>
|
`}>
|
||||||
{step.title}
|
{step.title}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Clock,
|
Clock,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Layout
|
Layout,
|
||||||
|
Columns,
|
||||||
|
Film
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ThemeConfig, GalleryLayoutType, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
import { ThemeConfig, GalleryLayoutType, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -24,7 +26,9 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
|||||||
masonry: <Layers className="w-4 h-4" />,
|
masonry: <Layers className="w-4 h-4" />,
|
||||||
carousel: <Play className="w-4 h-4" />,
|
carousel: <Play className="w-4 h-4" />,
|
||||||
timeline: <Clock className="w-4 h-4" />,
|
timeline: <Clock className="w-4 h-4" />,
|
||||||
mosaic: <LayoutGrid className="w-4 h-4" />
|
mosaic: <LayoutGrid className="w-4 h-4" />,
|
||||||
|
'gallery-premium': <Columns className="w-4 h-4" />,
|
||||||
|
'gallery-story': <Film className="w-4 h-4" />
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
||||||
@@ -78,10 +82,10 @@ export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
|||||||
{/* Theme Name & Layout */}
|
{/* Theme Name & Layout */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Layout className="w-4 h-4 text-neutral-500" />
|
<Layout className="w-4 h-4 text-neutral-500 dark:text-neutral-300" />
|
||||||
<span className="text-sm font-medium text-neutral-700">{themeName}</span>
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-100">{themeName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-200">
|
||||||
{layoutIcons[galleryLayout]}
|
{layoutIcons[galleryLayout]}
|
||||||
<span className="capitalize">{t(`branding.layoutDescriptions.${galleryLayout}`)}</span>
|
<span className="capitalize">{t(`branding.layoutDescriptions.${galleryLayout}`)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,26 +95,26 @@ export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
|||||||
<>
|
<>
|
||||||
{/* Color Palette */}
|
{/* Color Palette */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Palette className="w-4 h-4 text-neutral-500" />
|
<Palette className="w-4 h-4 text-neutral-500 dark:text-neutral-300" />
|
||||||
<span className="text-sm text-neutral-600">{t('branding.colors')}:</span>
|
<span className="text-sm text-neutral-600 dark:text-neutral-200">{t('branding.colors')}:</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{themeConfig.primaryColor && (
|
{themeConfig.primaryColor && (
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded border border-neutral-300"
|
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
|
||||||
style={{ backgroundColor: themeConfig.primaryColor }}
|
style={{ backgroundColor: themeConfig.primaryColor }}
|
||||||
title={t('branding.primaryColor')}
|
title={t('branding.primaryColor')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{themeConfig.accentColor && (
|
{themeConfig.accentColor && (
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded border border-neutral-300"
|
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
|
||||||
style={{ backgroundColor: themeConfig.accentColor }}
|
style={{ backgroundColor: themeConfig.accentColor }}
|
||||||
title={t('branding.accentColor')}
|
title={t('branding.accentColor')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{themeConfig.backgroundColor && (
|
{themeConfig.backgroundColor && (
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded border border-neutral-300"
|
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
|
||||||
style={{ backgroundColor: themeConfig.backgroundColor }}
|
style={{ backgroundColor: themeConfig.backgroundColor }}
|
||||||
title={t('branding.backgroundColor')}
|
title={t('branding.backgroundColor')}
|
||||||
/>
|
/>
|
||||||
@@ -121,9 +125,9 @@ export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
|||||||
{/* Typography */}
|
{/* Typography */}
|
||||||
{themeConfig.fontFamily && (
|
{themeConfig.fontFamily && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Type className="w-4 h-4 text-neutral-500" />
|
<Type className="w-4 h-4 text-neutral-500 dark:text-neutral-300" />
|
||||||
<span className="text-sm text-neutral-600">{t('branding.bodyFont')}:</span>
|
<span className="text-sm text-neutral-600 dark:text-neutral-200">{t('branding.bodyFont')}:</span>
|
||||||
<span className="text-sm font-medium" style={{ fontFamily: themeConfig.fontFamily }}>
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-100" style={{ fontFamily: themeConfig.fontFamily }}>
|
||||||
{themeConfig.fontFamily}
|
{themeConfig.fontFamily}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,11 +135,11 @@ export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
|||||||
|
|
||||||
{/* Layout Settings */}
|
{/* Layout Settings */}
|
||||||
{themeConfig.gallerySettings && (
|
{themeConfig.gallerySettings && (
|
||||||
<div className="text-sm text-neutral-600">
|
<div className="text-sm text-neutral-600 dark:text-neutral-200">
|
||||||
{themeConfig.gallerySettings.spacing && (
|
{themeConfig.gallerySettings.spacing && (
|
||||||
<span className="inline-flex items-center gap-1 mr-3">
|
<span className="inline-flex items-center gap-1 mr-3">
|
||||||
<span>{t('branding.photoSpacing')}:</span>
|
<span>{t('branding.photoSpacing')}:</span>
|
||||||
<span className="font-medium capitalize">
|
<span className="font-medium capitalize text-neutral-700 dark:text-neutral-100">
|
||||||
{t(`branding.spacing.${themeConfig.gallerySettings.spacing}`)}
|
{t(`branding.spacing.${themeConfig.gallerySettings.spacing}`)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -143,7 +147,7 @@ export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
|||||||
{themeConfig.gallerySettings.photoAnimation && themeConfig.gallerySettings.photoAnimation !== 'none' && (
|
{themeConfig.gallerySettings.photoAnimation && themeConfig.gallerySettings.photoAnimation !== 'none' && (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<span>{t('branding.photoAnimation')}:</span>
|
<span>{t('branding.photoAnimation')}:</span>
|
||||||
<span className="font-medium capitalize">
|
<span className="font-medium capitalize text-neutral-700 dark:text-neutral-100">
|
||||||
{t(`branding.animation.${themeConfig.gallerySettings.photoAnimation}`)}
|
{t(`branding.animation.${themeConfig.gallerySettings.photoAnimation}`)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -56,25 +56,25 @@ export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismis
|
|||||||
: t('admin.updates.channelStable', 'Stable');
|
: t('admin.updates.channelStable', 'Stable');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-4 rounded-r-lg">
|
<div className="bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-500 p-4 mb-4 rounded-r-lg">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<ArrowUpCircle className="w-5 h-5 text-blue-500 mt-0.5 mr-3 flex-shrink-0" />
|
<ArrowUpCircle className="w-5 h-5 text-blue-500 mt-0.5 mr-3 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold text-blue-800">
|
<h4 className="text-sm font-semibold text-blue-800 dark:text-blue-200">
|
||||||
{t('admin.updates.available', 'Update Available')}
|
{t('admin.updates.available', 'Update Available')}
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-blue-700 mt-1">
|
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||||
{t('admin.updates.newVersion', 'Version {{version}} is available', {
|
{t('admin.updates.newVersion', 'Version {{version}} is available', {
|
||||||
version: updateInfo.latest.forChannel
|
version: updateInfo.latest.forChannel
|
||||||
})}
|
})}
|
||||||
<span className="text-blue-500 ml-2">
|
<span className="text-blue-500 dark:text-blue-400 ml-2">
|
||||||
({t('admin.updates.currentVersion', 'Current: {{version}}', {
|
({t('admin.updates.currentVersion', 'Current: {{version}}', {
|
||||||
version: updateInfo.current
|
version: updateInfo.current
|
||||||
})})
|
})})
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-blue-600 mt-1">
|
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
||||||
{t('admin.updates.channel', 'Channel: {{channel}}', {
|
{t('admin.updates.channel', 'Channel: {{channel}}', {
|
||||||
channel: channelLabel
|
channel: channelLabel
|
||||||
})}
|
})}
|
||||||
@@ -83,7 +83,7 @@ export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismis
|
|||||||
href="https://github.com/the-luap/picpeak/releases"
|
href="https://github.com/the-luap/picpeak/releases"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center text-xs text-blue-600 hover:text-blue-800 mt-2"
|
className="inline-flex items-center text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 mt-2"
|
||||||
>
|
>
|
||||||
{t('admin.updates.viewReleaseNotes', 'View Release Notes')}
|
{t('admin.updates.viewReleaseNotes', 'View Release Notes')}
|
||||||
<ExternalLink className="w-3 h-3 ml-1" />
|
<ExternalLink className="w-3 h-3 ml-1" />
|
||||||
@@ -92,7 +92,7 @@ export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismis
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleDismiss}
|
onClick={handleDismiss}
|
||||||
className="text-blue-400 hover:text-blue-600 p-1"
|
className="text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 p-1"
|
||||||
aria-label={t('common.close', 'Close')}
|
aria-label={t('common.close', 'Close')}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
|
|||||||
@@ -43,23 +43,23 @@ export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none 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 placeholder-neutral-400 dark:placeholder-neutral-500 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-2 right-2 text-neutral-400" title="Line breaks will be preserved in emails">
|
<div className="absolute top-2 right-2 text-neutral-400" title="Line breaks will be preserved in emails">
|
||||||
<HelpCircle className="w-4 h-4" aria-hidden="true" />
|
<HelpCircle className="w-4 h-4" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-neutral-500">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
Tip: Press Enter to create a new line. Each line will appear as a separate paragraph in emails.
|
Tip: Press Enter to create a new line. Each line will appear as a separate paragraph in emails.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{value && (
|
{value && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="text-sm font-medium text-neutral-700 mb-2">Preview:</p>
|
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Preview:</p>
|
||||||
<div className="p-4 bg-neutral-50 rounded-lg border border-neutral-200">
|
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
||||||
<div
|
<div
|
||||||
className="text-sm text-neutral-700 whitespace-pre-wrap"
|
className="text-sm text-neutral-700 dark:text-neutral-300 whitespace-pre-wrap"
|
||||||
dangerouslySetInnerHTML={{ __html: getPreviewHtml() }}
|
dangerouslySetInnerHTML={{ __html: getPreviewHtml() }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -151,15 +151,15 @@ export const WordFilterManager: React.FC = () => {
|
|||||||
const getSeverityBadgeClass = (severity: string) => {
|
const getSeverityBadgeClass = (severity: string) => {
|
||||||
switch (severity) {
|
switch (severity) {
|
||||||
case 'low':
|
case 'low':
|
||||||
return 'bg-blue-100 text-blue-800';
|
return 'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300';
|
||||||
case 'moderate':
|
case 'moderate':
|
||||||
return 'bg-yellow-100 text-yellow-800';
|
return 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300';
|
||||||
case 'high':
|
case 'high':
|
||||||
return 'bg-orange-100 text-orange-800';
|
return 'bg-orange-100 dark:bg-orange-900/40 text-orange-800 dark:text-orange-300';
|
||||||
case 'block':
|
case 'block':
|
||||||
return 'bg-red-100 text-red-800';
|
return 'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300';
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-100 text-gray-800';
|
return 'bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-300';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,17 +182,17 @@ export const WordFilterManager: React.FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-2">
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
|
||||||
{t('settings.moderation.wordFilters', 'Word Filters')}
|
{t('settings.moderation.wordFilters', 'Word Filters')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-neutral-600">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('settings.moderation.description', 'Manage words that should be filtered or blocked in comments')}
|
{t('settings.moderation.description', 'Manage words that should be filtered or blocked in comments')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add new filter */}
|
{/* Add new filter */}
|
||||||
<div className="mb-6 p-4 bg-neutral-50 rounded-lg">
|
<div className="mb-6 p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
|
||||||
<h3 className="text-sm font-medium text-neutral-900 mb-3">
|
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 dark:text-neutral-100 mb-3">
|
||||||
{t('settings.moderation.addFilter', 'Add New Filter')}
|
{t('settings.moderation.addFilter', 'Add New Filter')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -207,7 +207,7 @@ export const WordFilterManager: React.FC = () => {
|
|||||||
<select
|
<select
|
||||||
value={newSeverity}
|
value={newSeverity}
|
||||||
onChange={(e) => setNewSeverity(e.target.value as any)}
|
onChange={(e) => setNewSeverity(e.target.value as any)}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="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"
|
||||||
>
|
>
|
||||||
<option value="low">{t('settings.moderation.severityLow', 'Low')}</option>
|
<option value="low">{t('settings.moderation.severityLow', 'Low')}</option>
|
||||||
<option value="moderate">{t('settings.moderation.severityModerate', 'Moderate')}</option>
|
<option value="moderate">{t('settings.moderation.severityModerate', 'Moderate')}</option>
|
||||||
@@ -239,7 +239,7 @@ export const WordFilterManager: React.FC = () => {
|
|||||||
{/* Filters list */}
|
{/* Filters list */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filteredFilters.length === 0 ? (
|
{filteredFilters.length === 0 ? (
|
||||||
<div className="text-center py-8 text-neutral-500">
|
<div className="text-center py-8 text-neutral-500 dark:text-neutral-400">
|
||||||
{searchTerm ?
|
{searchTerm ?
|
||||||
t('settings.moderation.noMatchingFilters', 'No matching filters found') :
|
t('settings.moderation.noMatchingFilters', 'No matching filters found') :
|
||||||
t('settings.moderation.noFilters', 'No word filters configured yet')
|
t('settings.moderation.noFilters', 'No word filters configured yet')
|
||||||
@@ -250,7 +250,7 @@ export const WordFilterManager: React.FC = () => {
|
|||||||
<div
|
<div
|
||||||
key={filter.id}
|
key={filter.id}
|
||||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||||
filter.is_active ? 'border-neutral-200 bg-white' : 'border-neutral-100 bg-neutral-50 opacity-60'
|
filter.is_active ? 'border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800' : 'border-neutral-100 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900 opacity-60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{editingId === filter.id ? (
|
{editingId === filter.id ? (
|
||||||
@@ -265,7 +265,7 @@ export const WordFilterManager: React.FC = () => {
|
|||||||
<select
|
<select
|
||||||
value={editSeverity}
|
value={editSeverity}
|
||||||
onChange={(e) => setEditSeverity(e.target.value as any)}
|
onChange={(e) => setEditSeverity(e.target.value as any)}
|
||||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="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"
|
||||||
>
|
>
|
||||||
<option value="low">{t('settings.moderation.severityLow', 'Low')}</option>
|
<option value="low">{t('settings.moderation.severityLow', 'Low')}</option>
|
||||||
<option value="moderate">{t('settings.moderation.severityModerate', 'Moderate')}</option>
|
<option value="moderate">{t('settings.moderation.severityModerate', 'Moderate')}</option>
|
||||||
@@ -302,7 +302,7 @@ export const WordFilterManager: React.FC = () => {
|
|||||||
onChange={() => handleToggleActive(filter)}
|
onChange={() => handleToggleActive(filter)}
|
||||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<span className="font-medium text-neutral-900">{filter.word}</span>
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">{filter.word}</span>
|
||||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getSeverityBadgeClass(filter.severity)}`}>
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getSeverityBadgeClass(filter.severity)}`}>
|
||||||
{getSeverityIcon(filter.severity)}
|
{getSeverityIcon(filter.severity)}
|
||||||
{filter.severity}
|
{filter.severity}
|
||||||
@@ -340,15 +340,15 @@ export const WordFilterManager: React.FC = () => {
|
|||||||
{/* Severity explanation */}
|
{/* Severity explanation */}
|
||||||
<Card>
|
<Card>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3">
|
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||||
{t('settings.moderation.severityLevels', 'Severity Levels')}
|
{t('settings.moderation.severityLevels', 'Severity Levels')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{getSeverityIcon('low')}
|
{getSeverityIcon('low')}
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-neutral-900">{t('settings.moderation.severityLow', 'Low')}: </span>
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.moderation.severityLow', 'Low')}: </span>
|
||||||
<span className="text-neutral-600">
|
<span className="text-neutral-600 dark:text-neutral-400">
|
||||||
{t('settings.moderation.lowDescription', 'Word is flagged for review but not automatically blocked')}
|
{t('settings.moderation.lowDescription', 'Word is flagged for review but not automatically blocked')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,8 +356,8 @@ export const WordFilterManager: React.FC = () => {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{getSeverityIcon('moderate')}
|
{getSeverityIcon('moderate')}
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-neutral-900">{t('settings.moderation.severityModerate', 'Moderate')}: </span>
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.moderation.severityModerate', 'Moderate')}: </span>
|
||||||
<span className="text-neutral-600">
|
<span className="text-neutral-600 dark:text-neutral-400">
|
||||||
{t('settings.moderation.moderateDescription', 'Comment requires manual approval before being visible')}
|
{t('settings.moderation.moderateDescription', 'Comment requires manual approval before being visible')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -365,8 +365,8 @@ export const WordFilterManager: React.FC = () => {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{getSeverityIcon('high')}
|
{getSeverityIcon('high')}
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-neutral-900">{t('settings.moderation.severityHigh', 'High')}: </span>
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.moderation.severityHigh', 'High')}: </span>
|
||||||
<span className="text-neutral-600">
|
<span className="text-neutral-600 dark:text-neutral-400">
|
||||||
{t('settings.moderation.highDescription', 'Comment is automatically hidden and requires admin review')}
|
{t('settings.moderation.highDescription', 'Comment is automatically hidden and requires admin review')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -374,8 +374,8 @@ export const WordFilterManager: React.FC = () => {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{getSeverityIcon('block')}
|
{getSeverityIcon('block')}
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-neutral-900">{t('settings.moderation.severityBlock', 'Block')}: </span>
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.moderation.severityBlock', 'Block')}: </span>
|
||||||
<span className="text-neutral-600">
|
<span className="text-neutral-600 dark:text-neutral-400">
|
||||||
{t('settings.moderation.blockDescription', 'Comment is rejected immediately and cannot be submitted')}
|
{t('settings.moderation.blockDescription', 'Comment is rejected immediately and cannot be submitted')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
{label && (
|
{label && (
|
||||||
<label
|
<label
|
||||||
htmlFor={inputId}
|
htmlFor={inputId}
|
||||||
className="block text-sm font-medium text-neutral-700 mb-1.5"
|
className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1.5"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
@@ -38,7 +38,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
{leftIcon && (
|
{leftIcon && (
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<span className="text-neutral-500">{leftIcon}</span>
|
<span className="text-neutral-500 dark:text-neutral-400">{leftIcon}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
@@ -59,17 +59,17 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
/>
|
/>
|
||||||
{rightIcon && (
|
{rightIcon && (
|
||||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||||
<span className="text-neutral-500">{rightIcon}</span>
|
<span className="text-neutral-500 dark:text-neutral-400">{rightIcon}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-600">
|
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-600 dark:text-red-400">
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{helperText && !error && (
|
{helperText && !error && (
|
||||||
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-neutral-500">
|
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
{helperText}
|
{helperText}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const LanguageSelector: React.FC = () => {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
>
|
>
|
||||||
<Globe className="w-4 h-4" />
|
<Globe className="w-4 h-4" />
|
||||||
<currentLanguage.Flag className="w-5 h-5" />
|
<currentLanguage.Flag className="w-5 h-5" />
|
||||||
@@ -49,15 +49,15 @@ export const LanguageSelector: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-50">
|
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 py-1 z-50">
|
||||||
{languages.map((language) => (
|
{languages.map((language) => (
|
||||||
<button
|
<button
|
||||||
key={language.code}
|
key={language.code}
|
||||||
onClick={() => handleLanguageChange(language.code)}
|
onClick={() => handleLanguageChange(language.code)}
|
||||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 flex items-center gap-3 ${
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3 ${
|
||||||
language.code === i18n.language
|
language.code === i18n.language
|
||||||
? 'text-primary-600 bg-primary-50'
|
? 'text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'text-neutral-700'
|
: 'text-neutral-700 dark:text-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<language.Flag className="w-5 h-5" />
|
<language.Flag className="w-5 h-5" />
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getApiBaseUrl } from '../../utils/url';
|
||||||
|
|
||||||
|
export const RobotsMetaTags: React.FC = () => {
|
||||||
|
const { data: settings } = useQuery({
|
||||||
|
queryKey: ['public-settings'],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
||||||
|
if (response.ok) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Remove any existing robots meta tags we previously injected
|
||||||
|
document.querySelectorAll('meta[name="robots"][data-picpeak]').forEach(el => el.remove());
|
||||||
|
|
||||||
|
const directives: string[] = [];
|
||||||
|
if (settings?.seo_meta_noindex) directives.push('noindex');
|
||||||
|
if (settings?.seo_meta_nofollow) directives.push('nofollow');
|
||||||
|
|
||||||
|
if (directives.length > 0) {
|
||||||
|
const meta = document.createElement('meta');
|
||||||
|
meta.name = 'robots';
|
||||||
|
meta.content = directives.join(', ');
|
||||||
|
meta.setAttribute('data-picpeak', 'true');
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings?.seo_meta_noai) {
|
||||||
|
const metaAi = document.createElement('meta');
|
||||||
|
metaAi.name = 'robots';
|
||||||
|
metaAi.content = 'noai, noimageai';
|
||||||
|
metaAi.setAttribute('data-picpeak', 'true');
|
||||||
|
document.head.appendChild(metaAi);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.querySelectorAll('meta[name="robots"][data-picpeak]').forEach(el => el.remove());
|
||||||
|
};
|
||||||
|
}, [settings?.seo_meta_noindex, settings?.seo_meta_nofollow, settings?.seo_meta_noai]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@ export {
|
|||||||
export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator';
|
export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator';
|
||||||
export { SkipLink } from './SkipLink';
|
export { SkipLink } from './SkipLink';
|
||||||
export { DynamicFavicon } from './DynamicFavicon';
|
export { DynamicFavicon } from './DynamicFavicon';
|
||||||
|
export { RobotsMetaTags } from './RobotsMetaTags';
|
||||||
export { LanguageSelector } from './LanguageSelector';
|
export { LanguageSelector } from './LanguageSelector';
|
||||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
export { AuthenticatedImage } from './AuthenticatedImage';
|
||||||
export { AuthenticatedVideo } from './AuthenticatedVideo';
|
export { AuthenticatedVideo } from './AuthenticatedVideo';
|
||||||
|
|||||||
@@ -20,28 +20,28 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
|
|||||||
if (!isDownloading) return null;
|
if (!isDownloading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 right-4 bg-white rounded-lg shadow-lg border border-neutral-200 p-4 min-w-[300px] z-50">
|
<div className="fixed bottom-4 right-4 bg-surface rounded-lg shadow-lg border border-surface p-4 min-w-[300px] z-50">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Download className="w-5 h-5 text-primary-600 animate-bounce" />
|
<Download className="w-5 h-5 text-primary-600 animate-bounce" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-neutral-900">{t('download.downloading')}</p>
|
<p className="text-sm font-medium text-theme">{t('download.downloading')}</p>
|
||||||
{fileName && (
|
{fileName && (
|
||||||
<p className="text-xs text-neutral-500 truncate max-w-[200px]">{fileName}</p>
|
<p className="text-xs text-muted-theme truncate max-w-[200px]">{fileName}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="p-1 hover:bg-neutral-100 rounded transition-colors"
|
className="p-1 hover:bg-black/10 rounded transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4 text-neutral-500" />
|
<X className="w-4 h-4 text-muted-theme" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full bg-neutral-200 rounded-full h-2">
|
<div className="w-full bg-black/10 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||||
style={{ width: `${progress}%` }}
|
style={{ width: `${progress}%` }}
|
||||||
@@ -49,7 +49,7 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{progress > 0 && (
|
{progress > 0 && (
|
||||||
<p className="text-xs text-neutral-500 mt-1">{Math.round(progress)}{t('download.percentComplete')}</p>
|
<p className="text-xs text-muted-theme mt-1">{Math.round(progress)}{t('download.percentComplete')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,18 +47,18 @@ export const FeedbackIdentityModal: React.FC<FeedbackIdentityModalProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} />
|
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} />
|
||||||
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute top-4 right-4 p-1 hover:bg-neutral-100 rounded-lg transition-colors"
|
className="absolute top-4 right-4 p-1 hover:bg-black/10 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5 text-neutral-600" />
|
<X className="w-5 h-5 text-muted-theme" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-2">
|
<h2 className="text-lg font-semibold text-theme mb-2">
|
||||||
{t('feedback.identityRequired', 'Your Information Required')}
|
{t('feedback.identityRequired', 'Your Information Required')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-neutral-600 mb-4">
|
<p className="text-sm text-muted-theme mb-4">
|
||||||
{t('feedback.identityReason', 'Please provide your name and email to submit {{type}}.', { type: feedbackType })}
|
{t('feedback.identityReason', 'Please provide your name and email to submit {{type}}.', { type: feedbackType })}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className={`${className}`}>
|
<div className={`${className}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-neutral-700 whitespace-nowrap">
|
<span className="text-sm text-muted-theme whitespace-nowrap">
|
||||||
{t('gallery.feedbackFilter', 'Feedback Filter')}
|
{t('gallery.feedbackFilter', 'Feedback Filter')}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -99,7 +99,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
{/* Mobile-optimized vertical layout */}
|
{/* Mobile-optimized vertical layout */}
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs text-neutral-600 font-medium">
|
<div className="text-xs text-muted-theme font-medium">
|
||||||
{t('gallery.feedbackFilter', 'Feedback Filter')}
|
{t('gallery.feedbackFilter', 'Feedback Filter')}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -146,7 +146,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
/* Desktop layout - inline with categories */
|
/* Desktop layout - inline with categories */
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm text-neutral-600 font-medium whitespace-nowrap">
|
<span className="text-sm text-muted-theme font-medium whitespace-nowrap">
|
||||||
{t('gallery.feedbackFilter', 'Feedback Filter')}:
|
{t('gallery.feedbackFilter', 'Feedback Filter')}:
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -128,15 +128,15 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
const heroLogoSize = getLogoDimensions('hero');
|
const heroLogoSize = getLogoDimensions('hero');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="gallery-page min-h-screen bg-neutral-50">
|
<div className="gallery-page min-h-screen" style={{ backgroundColor: 'var(--color-background)' }}>
|
||||||
{/* Dynamic Favicon */}
|
{/* Dynamic Favicon */}
|
||||||
<DynamicFavicon />
|
<DynamicFavicon />
|
||||||
|
|
||||||
{/* Header structure */}
|
{/* Header structure */}
|
||||||
<header className={`gallery-header bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || isHeroHeader ? 'shadow-sm' : ''}`}>
|
<header className={`gallery-header bg-surface border-b border-surface sticky top-0 z-40 ${isNonGridLayout || isHeroHeader ? 'shadow-sm' : ''}`}>
|
||||||
{/* For non-grid layouts - keep the current structure (standard and minimal/none) */}
|
{/* For non-grid layouts - keep the current structure (standard and minimal/none) */}
|
||||||
{isNonGridLayout && !isHeroHeader && !isMinimalHeader && !isNoHeader && (
|
{isNonGridLayout && !isHeroHeader && !isMinimalHeader && !isNoHeader && (
|
||||||
<div className="bg-neutral-50 border-b border-neutral-200">
|
<div className="bg-surface border-b border-surface">
|
||||||
<div className="container py-2">
|
<div className="container py-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{/* Left side - Menu button and other header extras */}
|
{/* Left side - Menu button and other header extras */}
|
||||||
@@ -206,7 +206,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
style={headerLogoSize.style}
|
style={headerLogoSize.style}
|
||||||
/>
|
/>
|
||||||
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
||||||
<span className="hidden sm:inline text-lg font-semibold text-neutral-900">
|
<span className="hidden sm:inline text-lg font-semibold text-theme">
|
||||||
{brandingSettings.company_name}
|
{brandingSettings.company_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -214,7 +214,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
)}
|
)}
|
||||||
{!shouldShowLogo('header') && shouldShowCompanyName() && brandingSettings?.company_name && (
|
{!shouldShowLogo('header') && shouldShowCompanyName() && brandingSettings?.company_name && (
|
||||||
<div className={`flex-shrink-0 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
|
<div className={`flex-shrink-0 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
|
||||||
<span className="text-lg font-semibold text-neutral-900">
|
<span className="text-lg font-semibold text-theme">
|
||||||
{brandingSettings.company_name || 'PicPeak'}
|
{brandingSettings.company_name || 'PicPeak'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,13 +224,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
{/* Center - Event info */}
|
{/* Center - Event info */}
|
||||||
<div className="flex-1 min-w-0 text-center sm:text-left">
|
<div className="flex-1 min-w-0 text-center sm:text-left">
|
||||||
<h1
|
<h1
|
||||||
className="text-base sm:text-lg lg:text-xl font-bold text-neutral-900 leading-tight truncate"
|
className="text-base sm:text-lg lg:text-xl font-bold text-theme leading-tight truncate"
|
||||||
style={{ fontFamily: headingFontFamily }}
|
style={{ fontFamily: headingFontFamily }}
|
||||||
>
|
>
|
||||||
{event.event_name}
|
{event.event_name}
|
||||||
</h1>
|
</h1>
|
||||||
{(event.event_date || event.expires_at) && (
|
{(event.event_date || event.expires_at) && (
|
||||||
<div className="hidden sm:flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs sm:text-sm text-neutral-600">
|
<div className="hidden sm:flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs sm:text-sm text-muted-theme">
|
||||||
{event.event_date && (
|
{event.event_date && (
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1 flex-shrink-0" />
|
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1 flex-shrink-0" />
|
||||||
@@ -284,7 +284,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
|
|
||||||
{/* Mobile dates row */}
|
{/* Mobile dates row */}
|
||||||
{(event.event_date || event.expires_at) && (
|
{(event.event_date || event.expires_at) && (
|
||||||
<div className="flex sm:hidden justify-center gap-x-3 mt-2 text-xs text-neutral-600">
|
<div className="flex sm:hidden justify-center gap-x-3 mt-2 text-xs text-muted-theme">
|
||||||
{event.event_date && (
|
{event.event_date && (
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
|
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||||
@@ -304,7 +304,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
|
|
||||||
{/* For minimal/none header + non-grid layouts - compact menu bar */}
|
{/* For minimal/none header + non-grid layouts - compact menu bar */}
|
||||||
{isNonGridLayout && (isMinimalHeader || isNoHeader) && (
|
{isNonGridLayout && (isMinimalHeader || isNoHeader) && (
|
||||||
<div className="bg-neutral-50 border-b border-neutral-200">
|
<div className="bg-surface border-b border-surface">
|
||||||
<div className="container py-2">
|
<div className="container py-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -312,7 +312,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
{headerExtra}
|
{headerExtra}
|
||||||
{isMinimalHeader && (
|
{isMinimalHeader && (
|
||||||
<h1
|
<h1
|
||||||
className="text-sm font-semibold text-neutral-900 truncate"
|
className="text-sm font-semibold text-theme truncate"
|
||||||
style={{ fontFamily: headingFontFamily }}
|
style={{ fontFamily: headingFontFamily }}
|
||||||
>
|
>
|
||||||
{event.event_name}
|
{event.event_name}
|
||||||
@@ -357,7 +357,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{menuButton}
|
{menuButton}
|
||||||
<h1
|
<h1
|
||||||
className="text-sm font-semibold text-neutral-900 truncate"
|
className="text-sm font-semibold text-theme truncate"
|
||||||
style={{ fontFamily: headingFontFamily }}
|
style={{ fontFamily: headingFontFamily }}
|
||||||
>
|
>
|
||||||
{event.event_name}
|
{event.event_name}
|
||||||
@@ -564,10 +564,10 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
<main className="container">{children}</main>
|
<main className="container">{children}</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="gallery-footer mt-8 sm:mt-12 py-6 sm:py-8 border-t border-neutral-200">
|
<footer className="gallery-footer mt-8 sm:mt-12 py-6 sm:py-8 border-t border-surface">
|
||||||
<div className="container text-center px-4">
|
<div className="container text-center px-4">
|
||||||
{brandingSettings?.support_email && (
|
{brandingSettings?.support_email && (
|
||||||
<p className="text-xs sm:text-sm text-neutral-600 mb-2">
|
<p className="text-xs sm:text-sm text-muted-theme mb-2">
|
||||||
{t('gallery.needHelp')}{' '}
|
{t('gallery.needHelp')}{' '}
|
||||||
<a
|
<a
|
||||||
href={`mailto:${brandingSettings.support_email}`}
|
href={`mailto:${brandingSettings.support_email}`}
|
||||||
@@ -577,14 +577,14 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs sm:text-sm text-neutral-500">
|
<p className="text-xs sm:text-sm text-muted-theme">
|
||||||
{brandingSettings?.footer_text || `© ${new Date().getFullYear()}${brandingSettings?.company_name ? ` ${brandingSettings.company_name}` : ''}. All rights reserved.`}
|
{brandingSettings?.footer_text || `© ${new Date().getFullYear()}${brandingSettings?.company_name ? ` ${brandingSettings.company_name}` : ''}. All rights reserved.`}
|
||||||
{!brandingSettings?.hide_powered_by && (
|
{!brandingSettings?.hide_powered_by && (
|
||||||
<> | Powered by <span className="font-semibold">PicPeak</span></>
|
<> | Powered by <span className="font-semibold">PicPeak</span></>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
|
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
|
||||||
<p className="text-xs text-neutral-400 mt-2">
|
<p className="text-xs text-muted-theme mt-2">
|
||||||
{brandingSettings.company_name} - {brandingSettings.company_tagline}
|
{brandingSettings.company_name} - {brandingSettings.company_tagline}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -592,14 +592,14 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
<div className="mt-4 flex items-center justify-center gap-4">
|
<div className="mt-4 flex items-center justify-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
to="/impressum"
|
to="/impressum"
|
||||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||||
>
|
>
|
||||||
{t('legal.impressum')}
|
{t('legal.impressum')}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-xs text-neutral-400">|</span>
|
<span className="text-xs text-muted-theme">|</span>
|
||||||
<Link
|
<Link
|
||||||
to="/datenschutz"
|
to="/datenschutz"
|
||||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||||
>
|
>
|
||||||
{t('legal.datenschutz')}
|
{t('legal.datenschutz')}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -120,20 +120,20 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
<div
|
<div
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
className={`
|
className={`
|
||||||
gallery-sidebar fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out flex flex-col
|
gallery-sidebar fixed top-0 left-0 h-full bg-surface shadow-xl z-50 transition-transform duration-300 ease-in-out flex flex-col
|
||||||
${isMobile ? 'w-full max-w-sm' : 'w-80'}
|
${isMobile ? 'w-full max-w-sm' : 'w-80'}
|
||||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="gallery-sidebar-header flex items-center justify-between p-4 border-b border-neutral-200">
|
<div className="gallery-sidebar-header flex items-center justify-between p-4 border-b border-surface">
|
||||||
<h2 className="gallery-sidebar-title text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2>
|
<h2 className="gallery-sidebar-title text-lg font-semibold text-theme">{t('gallery.filters')}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="gallery-sidebar-close p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
className="gallery-sidebar-close p-2 hover:bg-black/10 rounded-lg transition-colors"
|
||||||
aria-label={t('common.close')}
|
aria-label={t('common.close')}
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5 text-neutral-600" />
|
<X className="w-5 h-5 text-muted-theme" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
<div className="gallery-sidebar-content flex-1 overflow-y-auto">
|
<div className="gallery-sidebar-content flex-1 overflow-y-auto">
|
||||||
{/* Upload Section - Show prominently at top for mobile users */}
|
{/* Upload Section - Show prominently at top for mobile users */}
|
||||||
{allowUploads && onUploadClick && (
|
{allowUploads && onUploadClick && (
|
||||||
<div className="gallery-sidebar-section gallery-sidebar-upload p-4 border-b border-neutral-200">
|
<div className="gallery-sidebar-section gallery-sidebar-upload p-4 border-b border-surface">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -159,15 +159,15 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
|
|
||||||
{/* Search Section - Hidden for carousel layout */}
|
{/* Search Section - Hidden for carousel layout */}
|
||||||
{galleryLayout !== 'carousel' && (
|
{galleryLayout !== 'carousel' && (
|
||||||
<div className="gallery-sidebar-section gallery-sidebar-search p-4 border-b border-neutral-200">
|
<div className="gallery-sidebar-section gallery-sidebar-search p-4 border-b border-surface">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="gallery-sidebar-search-icon absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
<Search className="gallery-sidebar-search-icon absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-theme" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
placeholder={t('gallery.searchPlaceholder')}
|
placeholder={t('gallery.searchPlaceholder')}
|
||||||
className="gallery-sidebar-search-input w-full pl-10 pr-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
className="gallery-sidebar-search-input w-full pl-10 pr-4 py-2 bg-surface border border-surface rounded-lg text-theme placeholder:text-muted-theme focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,8 +175,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
|
|
||||||
{/* Download Section - Hidden if gallery is expired or downloads disabled */}
|
{/* Download Section - Hidden if gallery is expired or downloads disabled */}
|
||||||
{allowDownloads && (
|
{allowDownloads && (
|
||||||
<div className="gallery-sidebar-section gallery-sidebar-downloads p-4 border-b border-neutral-200">
|
<div className="gallery-sidebar-section gallery-sidebar-downloads p-4 border-b border-surface">
|
||||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-muted-theme mb-3 flex items-center gap-2">
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
{t('gallery.download')}
|
{t('gallery.download')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -220,7 +220,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
|
|
||||||
{/* Feedback Filter Section */}
|
{/* Feedback Filter Section */}
|
||||||
{feedbackEnabled && onFilterChange && (
|
{feedbackEnabled && onFilterChange && (
|
||||||
<div className="gallery-sidebar-section gallery-sidebar-feedback p-4 border-b border-neutral-200">
|
<div className="gallery-sidebar-section gallery-sidebar-feedback p-4 border-b border-surface">
|
||||||
<GalleryFilter
|
<GalleryFilter
|
||||||
currentFilter={filterType}
|
currentFilter={filterType}
|
||||||
onFilterChange={(filter) => {
|
onFilterChange={(filter) => {
|
||||||
@@ -239,8 +239,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
|
|
||||||
{/* Categories Section - Hidden for carousel layout */}
|
{/* Categories Section - Hidden for carousel layout */}
|
||||||
{galleryLayout !== 'carousel' && categories.length > 0 && (
|
{galleryLayout !== 'carousel' && categories.length > 0 && (
|
||||||
<div className="gallery-sidebar-section gallery-sidebar-categories p-4 border-b border-neutral-200">
|
<div className="gallery-sidebar-section gallery-sidebar-categories p-4 border-b border-surface">
|
||||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-muted-theme mb-3 flex items-center gap-2">
|
||||||
<Filter className="w-4 h-4" />
|
<Filter className="w-4 h-4" />
|
||||||
{t('gallery.categories')}
|
{t('gallery.categories')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -254,13 +254,13 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
className={`
|
className={`
|
||||||
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||||
${selectedCategoryId === null
|
${selectedCategoryId === null
|
||||||
? 'bg-primary-50 text-primary-700'
|
? 'bg-primary-600/20 text-primary-500'
|
||||||
: 'hover:bg-neutral-50 text-neutral-700'
|
: 'hover:bg-black/10 text-muted-theme'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<span>{t('gallery.allCategories')}</span>
|
<span>{t('gallery.allCategories')}</span>
|
||||||
<span className="text-sm text-neutral-500">{totalPhotos}</span>
|
<span className="text-sm text-muted-theme">{totalPhotos}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{categories.map((category) => {
|
{categories.map((category) => {
|
||||||
@@ -277,8 +277,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
className={`
|
className={`
|
||||||
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||||
${isSelected
|
${isSelected
|
||||||
? 'bg-primary-50 text-primary-700'
|
? 'bg-primary-600/20 text-primary-500'
|
||||||
: 'hover:bg-neutral-50 text-neutral-700'
|
: 'hover:bg-black/10 text-muted-theme'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@@ -286,7 +286,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
{isSelected && <Check className="w-4 h-4" />}
|
{isSelected && <Check className="w-4 h-4" />}
|
||||||
{category.name}
|
{category.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-neutral-500">{count}</span>
|
<span className="text-sm text-muted-theme">{count}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -295,8 +295,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showMediaFilter && onMediaFilterChange && (
|
{showMediaFilter && onMediaFilterChange && (
|
||||||
<div className="gallery-sidebar-section gallery-sidebar-media p-4 border-b border-neutral-200">
|
<div className="gallery-sidebar-section gallery-sidebar-media p-4 border-b border-surface">
|
||||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-muted-theme mb-3 flex items-center gap-2">
|
||||||
<Filter className="w-4 h-4" />
|
<Filter className="w-4 h-4" />
|
||||||
{t('gallery.mediaType', 'Media')}
|
{t('gallery.mediaType', 'Media')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -341,7 +341,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
{/* Sort Section - Hidden for carousel and timeline layouts */}
|
{/* Sort Section - Hidden for carousel and timeline layouts */}
|
||||||
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
|
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
|
||||||
<div className="gallery-sidebar-section gallery-sidebar-sort p-4">
|
<div className="gallery-sidebar-section gallery-sidebar-sort p-4">
|
||||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-muted-theme mb-3 flex items-center gap-2">
|
||||||
<SortAsc className="w-4 h-4" />
|
<SortAsc className="w-4 h-4" />
|
||||||
{t('gallery.sortBy')}
|
{t('gallery.sortBy')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -361,8 +361,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
className={`
|
className={`
|
||||||
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
|
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
|
||||||
${isSelected
|
${isSelected
|
||||||
? 'bg-primary-50 text-primary-700'
|
? 'bg-primary-600/20 text-primary-500'
|
||||||
: 'hover:bg-neutral-50 text-neutral-700'
|
: 'hover:bg-black/10 text-muted-theme'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -119,11 +119,11 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Comments Header */}
|
{/* Comments Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900 flex items-center gap-2">
|
<h3 className="text-sm font-semibold text-theme flex items-center gap-2">
|
||||||
<MessageSquare className="w-4 h-4" />
|
<MessageSquare className="w-4 h-4" />
|
||||||
{t('feedback.comments', 'Comments')}
|
{t('feedback.comments', 'Comments')}
|
||||||
{visibleComments.length > 0 && (
|
{visibleComments.length > 0 && (
|
||||||
<span className="text-neutral-500">({visibleComments.length})</span>
|
<span className="text-muted-theme">({visibleComments.length})</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
{!showCommentForm && (
|
{!showCommentForm && (
|
||||||
@@ -139,7 +139,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
|||||||
|
|
||||||
{/* Comment Form */}
|
{/* Comment Form */}
|
||||||
{showCommentForm && (
|
{showCommentForm && (
|
||||||
<form onSubmit={handleSubmitComment} className="space-y-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
|
<form onSubmit={handleSubmitComment} className="space-y-3 p-4 bg-surface rounded-lg border border-surface">
|
||||||
{requireNameEmail && (
|
{requireNameEmail && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<Input
|
<Input
|
||||||
@@ -165,7 +165,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
|||||||
onChange={(e) => setCommentText(e.target.value)}
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
placeholder={t('feedback.writeComment', 'Write a comment...')}
|
placeholder={t('feedback.writeComment', 'Write a comment...')}
|
||||||
className={`w-full px-3 py-2 text-sm border rounded-lg resize-vertical min-h-[100px] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
|
className={`w-full px-3 py-2 text-sm border rounded-lg resize-vertical min-h-[100px] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
|
||||||
errors.comment_text ? 'border-red-500' : 'border-neutral-300'
|
errors.comment_text ? 'border-red-500' : 'border-surface'
|
||||||
}`}
|
}`}
|
||||||
rows={4}
|
rows={4}
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
@@ -173,7 +173,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
|||||||
{errors.comment_text && (
|
{errors.comment_text && (
|
||||||
<p className="text-xs text-red-600 mt-1">{errors.comment_text}</p>
|
<p className="text-xs text-red-600 mt-1">{errors.comment_text}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-muted-theme mt-1">
|
||||||
{commentText.length}/500
|
{commentText.length}/500
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -210,16 +210,16 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
|||||||
{visibleComments.map((comment) => (
|
{visibleComments.map((comment) => (
|
||||||
<div key={comment.id} className="flex gap-3">
|
<div key={comment.id} className="flex gap-3">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="w-8 h-8 bg-neutral-200 rounded-full flex items-center justify-center">
|
<div className="w-8 h-8 bg-black/10 rounded-full flex items-center justify-center">
|
||||||
<User className="w-4 h-4 text-neutral-600" />
|
<User className="w-4 h-4 text-muted-theme" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-baseline gap-2 mb-1">
|
<div className="flex items-baseline gap-2 mb-1">
|
||||||
<span className="text-sm font-medium text-neutral-900">
|
<span className="text-sm font-medium text-theme">
|
||||||
{comment.guest_name || t('feedback.anonymous', 'Anonymous')}
|
{comment.guest_name || t('feedback.anonymous', 'Anonymous')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-neutral-500">
|
<span className="text-xs text-muted-theme">
|
||||||
{format(new Date(comment.created_at), 'PP')}
|
{format(new Date(comment.created_at), 'PP')}
|
||||||
</span>
|
</span>
|
||||||
{comment.is_mine && !comment.is_approved && (
|
{comment.is_mine && !comment.is_approved && (
|
||||||
@@ -228,7 +228,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-neutral-700 break-words">
|
<p className="text-sm text-muted-theme break-words">
|
||||||
{comment.comment_text}
|
{comment.comment_text}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +239,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
|||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
{visibleComments.length === 0 && !showCommentForm && (
|
{visibleComments.length === 0 && !showCommentForm && (
|
||||||
<p className="text-sm text-neutral-500 text-center py-4">
|
<p className="text-sm text-muted-theme text-center py-4">
|
||||||
{t('feedback.noComments', 'No comments yet. Be the first to comment!')}
|
{t('feedback.noComments', 'No comments yet. Be the first to comment!')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
|||||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
||||||
isFavorited
|
isFavorited
|
||||||
? 'bg-amber-50 text-amber-600 hover:bg-amber-100'
|
? 'bg-amber-50 text-amber-600 hover:bg-amber-100'
|
||||||
: 'bg-neutral-50 text-neutral-600 hover:bg-neutral-100'
|
: 'bg-surface text-muted-theme hover:bg-black/10'
|
||||||
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||||
aria-label={isFavorited ? t('feedback.unfavorite', 'Remove from favorites') : t('feedback.favorite', 'Add to favorites')}
|
aria-label={isFavorited ? t('feedback.unfavorite', 'Remove from favorites') : t('feedback.favorite', 'Add to favorites')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -89,14 +89,14 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showSortMenu && (
|
{showSortMenu && (
|
||||||
<div className="absolute right-0 md:right-auto md:left-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-10">
|
<div className="absolute right-0 md:right-auto md:left-0 mt-2 w-48 bg-surface rounded-lg shadow-lg border border-surface py-1 z-10">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSortChange('date');
|
onSortChange('date');
|
||||||
setShowSortMenu(false);
|
setShowSortMenu(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
|
||||||
sortBy === 'date' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'
|
sortBy === 'date' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('gallery.sortByDate')}
|
{t('gallery.sortByDate')}
|
||||||
@@ -106,8 +106,8 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
onSortChange('name');
|
onSortChange('name');
|
||||||
setShowSortMenu(false);
|
setShowSortMenu(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
|
||||||
sortBy === 'name' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'
|
sortBy === 'name' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('gallery.sortByName')}
|
{t('gallery.sortByName')}
|
||||||
@@ -117,8 +117,8 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
onSortChange('size');
|
onSortChange('size');
|
||||||
setShowSortMenu(false);
|
setShowSortMenu(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
|
||||||
sortBy === 'size' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'
|
sortBy === 'size' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('gallery.sortBySize')}
|
{t('gallery.sortBySize')}
|
||||||
@@ -128,8 +128,8 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
onSortChange('rating');
|
onSortChange('rating');
|
||||||
setShowSortMenu(false);
|
setShowSortMenu(false);
|
||||||
}}
|
}}
|
||||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
|
||||||
sortBy === 'rating' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'
|
sortBy === 'rating' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('gallery.sortByRating', 'Sort by Rating')}
|
{t('gallery.sortByRating', 'Sort by Rating')}
|
||||||
@@ -178,7 +178,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
{/* Desktop: compact horizontal feedback filter with headline (icons only) */}
|
{/* Desktop: compact horizontal feedback filter with headline (icons only) */}
|
||||||
{feedbackEnabled && onFilterChange && (
|
{feedbackEnabled && onFilterChange && (
|
||||||
<div className="hidden lg:flex items-center gap-2 mx-2 flex-shrink-0">
|
<div className="hidden lg:flex items-center gap-2 mx-2 flex-shrink-0">
|
||||||
<span className="text-sm text-neutral-600 whitespace-nowrap">
|
<span className="text-sm text-muted-theme whitespace-nowrap">
|
||||||
{t('gallery.feedbackFilter', 'Feedback Filter')}
|
{t('gallery.feedbackFilter', 'Feedback Filter')}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -231,7 +231,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs md:text-sm text-neutral-600 flex-shrink-0 ml-auto">
|
<p className="text-xs md:text-sm text-muted-theme flex-shrink-0 ml-auto">
|
||||||
{photoCount} {t('common.media', 'media')}
|
{photoCount} {t('common.media', 'media')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,7 +239,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
|
|
||||||
{showMediaFilter && onMediaFilterChange && (
|
{showMediaFilter && onMediaFilterChange && (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs md:text-sm text-neutral-600 whitespace-nowrap">
|
<span className="text-xs md:text-sm text-muted-theme whitespace-nowrap">
|
||||||
{t('gallery.mediaType', 'Media')}
|
{t('gallery.mediaType', 'Media')}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -274,7 +274,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
{/* Mobile/Tablet: compact horizontal icons with headline below categories */}
|
{/* Mobile/Tablet: compact horizontal icons with headline below categories */}
|
||||||
{feedbackEnabled && onFilterChange && (
|
{feedbackEnabled && onFilterChange && (
|
||||||
<div className="flex lg:hidden items-center gap-2">
|
<div className="flex lg:hidden items-center gap-2">
|
||||||
<span className="text-xs text-neutral-600 whitespace-nowrap">
|
<span className="text-xs text-muted-theme whitespace-nowrap">
|
||||||
{t('gallery.feedbackFilter', 'Feedback Filter')}
|
{t('gallery.feedbackFilter', 'Feedback Filter')}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
|||||||
if (photos.length === 0) {
|
if (photos.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-neutral-600">{t('gallery.noPhotosFound')}</p>
|
<p className="text-muted-theme">{t('gallery.noPhotosFound')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,7 +155,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
|||||||
|
|
||||||
{isSelectionMode && (
|
{isSelectionMode && (
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3">
|
||||||
<span className="text-xs sm:text-sm text-neutral-600">
|
<span className="text-xs sm:text-sm text-muted-theme">
|
||||||
{t('gallery.photosSelected', { count: selectedPhotos.size })}
|
{t('gallery.photosSelected', { count: selectedPhotos.size })}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -299,13 +299,13 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
|||||||
{(photo.comment_count ?? 0) > 0 && (
|
{(photo.comment_count ?? 0) > 0 && (
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}>
|
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}>
|
||||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
|
<span className="text-xs font-medium text-muted-theme">{photo.comment_count ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(photo.average_rating ?? 0) > 0 && (
|
{(photo.average_rating ?? 0) > 0 && (
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating ?? 0).toFixed(1)}`}>
|
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating ?? 0).toFixed(1)}`}>
|
||||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating ?? 0).toFixed(1)}</span>
|
<span className="text-xs font-medium text-muted-theme">{Number(photo.average_rating ?? 0).toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -323,7 +323,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
|||||||
}}
|
}}
|
||||||
aria-label="View full size"
|
aria-label="View full size"
|
||||||
>
|
>
|
||||||
<Maximize2 className="w-5 h-5 text-neutral-800" />
|
<Maximize2 className="w-5 h-5 text-theme" />
|
||||||
</button>
|
</button>
|
||||||
{allowDownloads && (
|
{allowDownloads && (
|
||||||
<button
|
<button
|
||||||
@@ -331,7 +331,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
|||||||
onClick={onDownload}
|
onClick={onDownload}
|
||||||
aria-label="Download photo"
|
aria-label="Download photo"
|
||||||
>
|
>
|
||||||
<Download className="w-5 h-5 text-neutral-800" />
|
<Download className="w-5 h-5 text-theme" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -567,12 +567,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
|
|
||||||
{/* Feedback Panel */}
|
{/* Feedback Panel */}
|
||||||
{showFeedback && (
|
{showFeedback && (
|
||||||
<div className="absolute right-0 top-0 bottom-0 w-full sm:w-[26rem] bg-white shadow-xl z-20 overflow-y-auto flex flex-col border-l border-neutral-200">
|
<div className="absolute right-0 top-0 bottom-0 w-full sm:w-[26rem] bg-surface shadow-xl z-20 overflow-y-auto flex flex-col border-l border-surface">
|
||||||
<div className="sticky top-0 bg-white border-b px-4 py-3 flex items-center justify-between">
|
<div className="sticky top-0 bg-surface border-b border-surface px-4 py-3 flex items-center justify-between">
|
||||||
<h3 className="font-semibold text-neutral-900">Photo Feedback</h3>
|
<h3 className="font-semibold" style={{ color: 'var(--color-text)' }}>Photo Feedback</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFeedback(false)}
|
onClick={() => setShowFeedback(false)}
|
||||||
className="p-1 hover:bg-neutral-100 rounded transition-colors"
|
className="p-1 hover:bg-black/10 rounded transition-colors"
|
||||||
aria-label="Close feedback"
|
aria-label="Close feedback"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
|||||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
|
||||||
isLiked
|
isLiked
|
||||||
? 'bg-red-50 text-red-600 hover:bg-red-100'
|
? 'bg-red-50 text-red-600 hover:bg-red-100'
|
||||||
: 'bg-neutral-50 text-neutral-600 hover:bg-neutral-100'
|
: 'bg-surface text-muted-theme hover:bg-black/10'
|
||||||
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||||
aria-label={isLiked ? t('feedback.unlike', 'Unlike') : t('feedback.like', 'Like')}
|
aria-label={isLiked ? t('feedback.unlike', 'Unlike') : t('feedback.like', 'Like')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
|
|||||||
className={`w-6 h-6 transition-colors ${
|
className={`w-6 h-6 transition-colors ${
|
||||||
star <= (hoveredRating || currentRating)
|
star <= (hoveredRating || currentRating)
|
||||||
? 'fill-yellow-500 text-yellow-500'
|
? 'fill-yellow-500 text-yellow-500'
|
||||||
: 'text-neutral-300 hover:text-yellow-400'
|
: 'text-black/30 hover:text-yellow-400'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -132,9 +132,9 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
|
|||||||
|
|
||||||
{/* Average Rating Display */}
|
{/* Average Rating Display */}
|
||||||
{totalRatings > 0 && (
|
{totalRatings > 0 && (
|
||||||
<div className="text-sm text-neutral-600">
|
<div className="text-sm text-muted-theme">
|
||||||
<span className="font-medium">{safeAverageRating.toFixed(1)}</span>
|
<span className="font-medium">{safeAverageRating.toFixed(1)}</span>
|
||||||
<span className="text-neutral-400 ml-1">
|
<span className="text-muted-theme ml-1">
|
||||||
({t('feedback.ratingsCount', '{{count}} ratings', { count: totalRatings })})
|
({t('feedback.ratingsCount', '{{count}} ratings', { count: totalRatings })})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -114,15 +114,15 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
|
||||||
<div className="w-full sm:max-w-2xl bg-white flex flex-col max-h-[100vh] sm:max-h-[90vh] rounded-2xl shadow-xl overflow-hidden">
|
<div className="w-full sm:max-w-2xl bg-surface flex flex-col max-h-[100vh] sm:max-h-[90vh] rounded-2xl shadow-xl overflow-hidden">
|
||||||
{/* Fixed Header */}
|
{/* Fixed Header */}
|
||||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-neutral-200 flex-shrink-0">
|
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-surface flex-shrink-0">
|
||||||
<h2 className="text-lg sm:text-xl font-semibold text-neutral-900">{t('upload.uploadPhotos')}</h2>
|
<h2 className="text-lg sm:text-xl font-semibold text-theme">{t('upload.uploadPhotos')}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1.5 sm:p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
className="p-1.5 sm:p-2 hover:bg-black/10 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5 text-neutral-500" />
|
<X className="w-5 h-5 text-muted-theme" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,12 +131,12 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
{/* Upload Area */}
|
{/* Upload Area */}
|
||||||
<div className="mb-4 sm:mb-6">
|
<div className="mb-4 sm:mb-6">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<div className="border-2 border-dashed border-neutral-300 rounded-lg p-6 sm:p-8 text-center hover:border-primary-500 transition-colors cursor-pointer">
|
<div className="border-2 border-dashed border-surface rounded-lg p-6 sm:p-8 text-center hover:border-primary-500 transition-colors cursor-pointer">
|
||||||
<Upload className="w-10 h-10 sm:w-12 sm:h-12 text-neutral-400 mx-auto mb-3" />
|
<Upload className="w-10 h-10 sm:w-12 sm:h-12 text-neutral-400 mx-auto mb-3" />
|
||||||
<p className="text-sm font-medium text-neutral-700 mb-1">
|
<p className="text-sm font-medium text-muted-theme mb-1">
|
||||||
{t('upload.clickToUpload')}
|
{t('upload.clickToUpload')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-muted-theme">
|
||||||
{t('upload.fileRequirements')}
|
{t('upload.fileRequirements')}
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
@@ -154,19 +154,19 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
{/* Selected Files */}
|
{/* Selected Files */}
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium text-neutral-700 mb-2">
|
<h3 className="text-sm font-medium text-muted-theme mb-2">
|
||||||
{t('upload.selectedFiles')} ({files.length})
|
{t('upload.selectedFiles')} ({files.length})
|
||||||
</h3>
|
</h3>
|
||||||
{files.map((file, index) => (
|
{files.map((file, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-center justify-between p-3 bg-neutral-50 rounded-lg"
|
className="flex items-center justify-between p-3 bg-surface rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-neutral-900 truncate">
|
<p className="text-sm font-medium text-theme truncate">
|
||||||
{file.name}
|
{file.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-muted-theme">
|
||||||
{formatBytes(file.size)}
|
{formatBytes(file.size)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,10 +188,10 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => removeFile(index)}
|
onClick={() => removeFile(index)}
|
||||||
className="p-1 hover:bg-neutral-200 rounded transition-colors"
|
className="p-1 hover:bg-black/10 rounded transition-colors"
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4 text-neutral-500" />
|
<X className="w-4 h-4 text-muted-theme" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +201,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fixed Footer */}
|
{/* Fixed Footer */}
|
||||||
<div className="flex items-center justify-end gap-2 sm:gap-3 p-4 sm:p-6 border-t border-neutral-200 bg-white flex-shrink-0">
|
<div className="flex items-center justify-end gap-2 sm:gap-3 p-4 sm:p-6 border-t border-surface bg-surface flex-shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* Timeline line */}
|
{/* Timeline line */}
|
||||||
<div className="absolute left-8 top-0 bottom-0 w-0.5 bg-neutral-300 hidden lg:block" />
|
<div className="absolute left-8 top-0 bottom-0 w-0.5 bg-black/20 hidden lg:block" />
|
||||||
|
|
||||||
{/* Timeline groups */}
|
{/* Timeline groups */}
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
@@ -87,7 +87,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
<div className="hidden lg:flex items-center justify-center w-16 h-16 bg-white border-4 border-primary-600 rounded-full z-10">
|
<div className="hidden lg:flex items-center justify-center w-16 h-16 bg-white border-4 border-primary-600 rounded-full z-10">
|
||||||
<Calendar className="w-6 h-6 text-primary-600" />
|
<Calendar className="w-6 h-6 text-primary-600" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-neutral-800">
|
<h3 className="text-xl font-semibold text-theme">
|
||||||
{group.label}
|
{group.label}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
type DarkModePreference = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
interface AdminDarkModeContextType {
|
||||||
|
preference: DarkModePreference;
|
||||||
|
isDark: boolean;
|
||||||
|
setPreference: (pref: DarkModePreference) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminDarkModeContext = createContext<AdminDarkModeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'admin-dark-mode';
|
||||||
|
|
||||||
|
function resolveIsDark(pref: DarkModePreference): boolean {
|
||||||
|
if (pref === 'dark') return true;
|
||||||
|
if (pref === 'light') return false;
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminDarkModeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const isLoginPage = location.pathname === '/admin/login';
|
||||||
|
|
||||||
|
const [preference, setPreferenceState] = useState<DarkModePreference>(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'dark' || stored === 'light' || stored === 'system') return stored;
|
||||||
|
return 'light';
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isDark, setIsDark] = useState(() => resolveIsDark(preference));
|
||||||
|
|
||||||
|
const applyDarkClass = useCallback((dark: boolean, forceLight = false) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (dark && !forceLight) {
|
||||||
|
root.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
root.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setPreference = useCallback((pref: DarkModePreference) => {
|
||||||
|
setPreferenceState(pref);
|
||||||
|
localStorage.setItem(STORAGE_KEY, pref);
|
||||||
|
const dark = resolveIsDark(pref);
|
||||||
|
setIsDark(dark);
|
||||||
|
// Don't apply dark on login page
|
||||||
|
applyDarkClass(dark, isLoginPage);
|
||||||
|
}, [applyDarkClass, isLoginPage]);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setPreference(isDark ? 'light' : 'dark');
|
||||||
|
}, [isDark, setPreference]);
|
||||||
|
|
||||||
|
// Apply on mount and when route changes - skip dark mode on login page
|
||||||
|
useEffect(() => {
|
||||||
|
applyDarkClass(isDark, isLoginPage);
|
||||||
|
}, [applyDarkClass, isDark, isLoginPage]);
|
||||||
|
|
||||||
|
// Listen for system changes when preference is 'system'
|
||||||
|
useEffect(() => {
|
||||||
|
if (preference !== 'system') return;
|
||||||
|
|
||||||
|
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
|
setIsDark(e.matches);
|
||||||
|
applyDarkClass(e.matches);
|
||||||
|
};
|
||||||
|
mql.addEventListener('change', handler);
|
||||||
|
return () => mql.removeEventListener('change', handler);
|
||||||
|
}, [preference, applyDarkClass]);
|
||||||
|
|
||||||
|
// Strip dark class when unmounting (navigating away from admin)
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ preference, isDark, setPreference, toggle }), [preference, isDark, setPreference, toggle]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminDarkModeContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AdminDarkModeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAdminDarkMode = () => {
|
||||||
|
const context = useContext(AdminDarkModeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAdminDarkMode must be used within AdminDarkModeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -2,9 +2,18 @@ import React, { createContext, useContext, useState, useEffect, useCallback, use
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types';
|
import { ThemeConfig, EventTheme, GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||||
|
|
||||||
|
function resolveColorMode(mode: 'light' | 'dark' | 'auto' | undefined): 'light' | 'dark' {
|
||||||
|
if (mode === 'dark') return 'dark';
|
||||||
|
if (mode === 'auto') {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
|
||||||
interface ThemeContextType {
|
interface ThemeContextType {
|
||||||
theme: ThemeConfig;
|
theme: ThemeConfig;
|
||||||
themeName: string;
|
themeName: string;
|
||||||
|
resolvedColorMode: 'light' | 'dark';
|
||||||
setTheme: (theme: ThemeConfig) => void;
|
setTheme: (theme: ThemeConfig) => void;
|
||||||
setThemeByName: (themeName: string) => void;
|
setThemeByName: (themeName: string) => void;
|
||||||
applyTheme: (theme: ThemeConfig) => void;
|
applyTheme: (theme: ThemeConfig) => void;
|
||||||
@@ -34,6 +43,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [theme, setTheme] = useState<ThemeConfig>(initialTheme);
|
const [theme, setTheme] = useState<ThemeConfig>(initialTheme);
|
||||||
const [themeName, setThemeName] = useState(initialThemeName);
|
const [themeName, setThemeName] = useState(initialThemeName);
|
||||||
|
const [resolvedColorMode, setResolvedColorMode] = useState<'light' | 'dark'>(() => resolveColorMode(initialTheme.colorMode));
|
||||||
|
|
||||||
const applyTheme = useCallback((themeConfig: ThemeConfig) => {
|
const applyTheme = useCallback((themeConfig: ThemeConfig) => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@@ -97,6 +107,53 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
|||||||
root.style.setProperty('--shadow-default', shadowMap[themeConfig.shadowStyle]);
|
root.style.setProperty('--shadow-default', shadowMap[themeConfig.shadowStyle]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply surface colors
|
||||||
|
const effectiveMode = resolveColorMode(themeConfig.colorMode);
|
||||||
|
setResolvedColorMode(effectiveMode);
|
||||||
|
|
||||||
|
if (themeConfig.surfaceColor) {
|
||||||
|
root.style.setProperty('--color-surface', themeConfig.surfaceColor);
|
||||||
|
} else if (effectiveMode === 'dark') {
|
||||||
|
// Auto-derive dark surface if not explicitly set
|
||||||
|
root.style.setProperty('--color-surface', '#1a1a1a');
|
||||||
|
} else {
|
||||||
|
root.style.setProperty('--color-surface', '#ffffff');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (themeConfig.surfaceBorderColor) {
|
||||||
|
root.style.setProperty('--color-surface-border', themeConfig.surfaceBorderColor);
|
||||||
|
} else if (effectiveMode === 'dark') {
|
||||||
|
root.style.setProperty('--color-surface-border', '#2e2e2e');
|
||||||
|
} else {
|
||||||
|
root.style.setProperty('--color-surface-border', '#e5e5e5');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (themeConfig.mutedTextColor) {
|
||||||
|
root.style.setProperty('--color-muted-text', themeConfig.mutedTextColor);
|
||||||
|
} else if (effectiveMode === 'dark') {
|
||||||
|
root.style.setProperty('--color-muted-text', '#a3a3a3');
|
||||||
|
} else {
|
||||||
|
root.style.setProperty('--color-muted-text', '#737373');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust shadow intensity for dark mode
|
||||||
|
if (themeConfig.shadowStyle) {
|
||||||
|
const lightShadowMap = {
|
||||||
|
none: 'none',
|
||||||
|
subtle: '0 1px 3px rgba(0,0,0,0.12)',
|
||||||
|
normal: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
dramatic: '0 10px 25px rgba(0,0,0,0.15)',
|
||||||
|
};
|
||||||
|
const darkShadowMap = {
|
||||||
|
none: 'none',
|
||||||
|
subtle: '0 1px 3px rgba(0,0,0,0.4)',
|
||||||
|
normal: '0 4px 6px rgba(0,0,0,0.35)',
|
||||||
|
dramatic: '0 10px 25px rgba(0,0,0,0.5)',
|
||||||
|
};
|
||||||
|
const map = effectiveMode === 'dark' ? darkShadowMap : lightShadowMap;
|
||||||
|
root.style.setProperty('--shadow-default', map[themeConfig.shadowStyle]);
|
||||||
|
}
|
||||||
|
|
||||||
// Apply background pattern
|
// Apply background pattern
|
||||||
if (themeConfig.backgroundPattern && themeConfig.backgroundPattern !== 'none') {
|
if (themeConfig.backgroundPattern && themeConfig.backgroundPattern !== 'none') {
|
||||||
const patternMap = {
|
const patternMap = {
|
||||||
@@ -184,14 +241,25 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
|
|||||||
}
|
}
|
||||||
}, [theme, themeName]);
|
}, [theme, themeName]);
|
||||||
|
|
||||||
|
// Listen for system color scheme changes when colorMode is 'auto'
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme.colorMode !== 'auto') return;
|
||||||
|
|
||||||
|
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handler = () => applyTheme(theme);
|
||||||
|
mql.addEventListener('change', handler);
|
||||||
|
return () => mql.removeEventListener('change', handler);
|
||||||
|
}, [theme, applyTheme]);
|
||||||
|
|
||||||
const contextValue = useMemo(() => ({
|
const contextValue = useMemo(() => ({
|
||||||
theme,
|
theme,
|
||||||
themeName,
|
themeName,
|
||||||
|
resolvedColorMode,
|
||||||
setTheme: setThemeConfig,
|
setTheme: setThemeConfig,
|
||||||
setThemeByName,
|
setThemeByName,
|
||||||
applyTheme,
|
applyTheme,
|
||||||
resetTheme
|
resetTheme
|
||||||
}), [theme, themeName, setThemeConfig, setThemeByName, applyTheme, resetTheme]);
|
}), [theme, themeName, resolvedColorMode, setThemeConfig, setThemeByName, applyTheme, resetTheme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={contextValue}>
|
<ThemeContext.Provider value={contextValue}>
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ export interface EventSettings {
|
|||||||
event_require_expiration: boolean;
|
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() {
|
export function useSettingsState() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@@ -114,6 +126,19 @@ export function useSettingsState() {
|
|||||||
event_require_expiration: true
|
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
|
// Account form state
|
||||||
const [accountForm, setAccountForm] = useState({
|
const [accountForm, setAccountForm] = useState({
|
||||||
username: '',
|
username: '',
|
||||||
@@ -183,6 +208,18 @@ export function useSettingsState() {
|
|||||||
event_require_event_date: toBoolean(settings.event_require_event_date, true),
|
event_require_event_date: toBoolean(settings.event_require_event_date, true),
|
||||||
event_require_expiration: toBoolean(settings.event_require_expiration, 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]);
|
}, [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({
|
const saveEventSettingsMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const settingsData: Record<string, unknown> = {};
|
const settingsData: Record<string, unknown> = {};
|
||||||
@@ -472,6 +527,8 @@ export function useSettingsState() {
|
|||||||
setAnalyticsSettings,
|
setAnalyticsSettings,
|
||||||
eventSettings,
|
eventSettings,
|
||||||
setEventSettings,
|
setEventSettings,
|
||||||
|
seoSettings,
|
||||||
|
setSeoSettings,
|
||||||
|
|
||||||
// Account form
|
// Account form
|
||||||
accountForm,
|
accountForm,
|
||||||
@@ -501,6 +558,7 @@ export function useSettingsState() {
|
|||||||
saveSecurityMutation,
|
saveSecurityMutation,
|
||||||
saveAnalyticsMutation,
|
saveAnalyticsMutation,
|
||||||
saveEventSettingsMutation,
|
saveEventSettingsMutation,
|
||||||
|
saveSeoMutation,
|
||||||
|
|
||||||
// Translation
|
// Translation
|
||||||
t,
|
t,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Hooks
|
// Hooks
|
||||||
export { useSettingsState, MAX_FILES_PER_UPLOAD_LIMIT } from './hooks/useSettingsState';
|
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';
|
export { useStatusTab } from './hooks/useStatusTab';
|
||||||
|
|
||||||
// Tab components
|
// Tab components
|
||||||
@@ -13,3 +13,4 @@ export { CategoriesTab } from './tabs/CategoriesTab';
|
|||||||
export { AnalyticsTab } from './tabs/AnalyticsTab';
|
export { AnalyticsTab } from './tabs/AnalyticsTab';
|
||||||
export { ModerationTab } from './tabs/ModerationTab';
|
export { ModerationTab } from './tabs/ModerationTab';
|
||||||
export { StylingTab } from './tabs/StylingTab';
|
export { StylingTab } from './tabs/StylingTab';
|
||||||
|
export { SEOTab } from './tabs/SEOTab';
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card padding="md">
|
<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">
|
<div className="space-y-4">
|
||||||
<label className="flex items-center">
|
<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 }))}
|
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_enabled: e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
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>
|
</label>
|
||||||
|
|
||||||
{analyticsSettings.umami_enabled && (
|
{analyticsSettings.umami_enabled && (
|
||||||
<>
|
<>
|
||||||
<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.umamiUrl')}
|
{t('settings.analytics.umamiUrl')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -49,13 +49,13 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
|
|||||||
placeholder="https://analytics.yourdomain.com"
|
placeholder="https://analytics.yourdomain.com"
|
||||||
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
|
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')}
|
{t('settings.analytics.umamiUrlHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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.analytics.websiteId')}
|
{t('settings.analytics.websiteId')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -65,13 +65,13 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
|
|||||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||||
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
|
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')}
|
{t('settings.analytics.websiteIdHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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.analytics.shareUrl')}
|
{t('settings.analytics.shareUrl')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -81,17 +81,17 @@ export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
|
|||||||
placeholder="https://analytics.yourdomain.com/share/..."
|
placeholder="https://analytics.yourdomain.com/share/..."
|
||||||
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
|
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')}
|
{t('settings.analytics.shareUrlHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div className="flex items-start gap-3">
|
||||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||||
<div className="text-sm text-blue-800">
|
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
<p className="font-medium mb-1">{t('settings.analytics.umamiInfo')}</p>
|
<p className="font-medium mb-1">{t('settings.analytics.umamiInfo')}</p>
|
||||||
<p>{t('settings.analytics.umamiInfoText')}</p>
|
<p>{t('settings.analytics.umamiInfoText')}</p>
|
||||||
<a href="https://umami.is" target="_blank" rel="noopener noreferrer" className="underline mt-1 inline-block">
|
<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 */}
|
{/* Backend Analytics Info */}
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.backendAnalytics')}</h2>
|
<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 mb-4">{t('settings.analytics.backendAnalyticsText')}</p>
|
<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="grid grid-cols-2 gap-4">
|
||||||
<div className="bg-neutral-50 rounded-lg p-4">
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
|
||||||
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.tracked')}</h3>
|
<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 space-y-1">
|
<ul className="text-xs text-neutral-600 dark:text-neutral-400 space-y-1">
|
||||||
<li>• {t('settings.analytics.galleryViews')}</li>
|
<li>• {t('settings.analytics.galleryViews')}</li>
|
||||||
<li>• {t('settings.analytics.photoDownloads')}</li>
|
<li>• {t('settings.analytics.photoDownloads')}</li>
|
||||||
<li>• {t('settings.analytics.uniqueVisitors')}</li>
|
<li>• {t('settings.analytics.uniqueVisitors')}</li>
|
||||||
<li>• {t('settings.analytics.deviceTypes')}</li>
|
<li>• {t('settings.analytics.deviceTypes')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-neutral-50 rounded-lg p-4">
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
|
||||||
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.privacy')}</h3>
|
<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">
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">
|
||||||
{t('settings.analytics.privacyText')}
|
{t('settings.analytics.privacyText')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ export const CategoriesTab: React.FC = () => {
|
|||||||
|
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<div className="flex items-start gap-3">
|
<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>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-blue-900">{t('settings.categories.about')}</h3>
|
<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 mt-1">
|
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||||
{t('settings.categories.aboutText')}
|
{t('settings.categories.aboutText')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export const EventsTab: React.FC<EventsTabProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card padding="md">
|
<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')}
|
{t('settings.events.requiredFields', 'Required Fields')}
|
||||||
</h2>
|
</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.')}
|
{t('settings.events.requiredFieldsDescription', 'Configure which contact fields are required when creating new events.')}
|
||||||
</p>
|
</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"
|
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<div>
|
<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')}
|
{t('settings.events.requireCustomerName', 'Require customer name')}
|
||||||
</span>
|
</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')}
|
{t('settings.events.requireCustomerNameHelp', 'Customer name must be provided for new events')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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"
|
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<div>
|
<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')}
|
{t('settings.events.requireCustomerEmail', 'Require customer email')}
|
||||||
</span>
|
</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')}
|
{t('settings.events.requireCustomerEmailHelp', 'Customer email must be provided for new events')}
|
||||||
</p>
|
</p>
|
||||||
{!eventSettings.event_require_customer_email && (
|
{!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"
|
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<div>
|
<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')}
|
{t('settings.events.requireAdminEmail', 'Require admin email')}
|
||||||
</span>
|
</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')}
|
{t('settings.events.requireAdminEmailHelp', 'Admin email must be provided for new events')}
|
||||||
</p>
|
</p>
|
||||||
{!eventSettings.event_require_admin_email && (
|
{!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"
|
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<div>
|
<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')}
|
{t('settings.events.requireEventDate', 'Require event date')}
|
||||||
</span>
|
</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')}
|
{t('settings.events.requireEventDateHelp', 'Event date must be provided when creating events')}
|
||||||
</p>
|
</p>
|
||||||
{!eventSettings.event_require_event_date && (
|
{!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"
|
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<div>
|
<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')}
|
{t('settings.events.requireExpiration', 'Require expiration date')}
|
||||||
</span>
|
</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')}
|
{t('settings.events.requireExpirationHelp', 'Galleries must have an expiration date')}
|
||||||
</p>
|
</p>
|
||||||
{!eventSettings.event_require_expiration && (
|
{!eventSettings.event_require_expiration && (
|
||||||
@@ -165,8 +165,8 @@ export const EventsTab: React.FC<EventsTabProps> = ({
|
|||||||
|
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||||
<div className="text-sm text-blue-800">
|
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
<p className="font-medium mb-1">{t('settings.events.noteTitle', 'Note')}</p>
|
<p className="font-medium mb-1">{t('settings.events.noteTitle', 'Note')}</p>
|
||||||
<p>
|
<p>
|
||||||
{t('settings.events.noteText', 'These settings only affect new event creation. Existing events are not affected. Default behavior requires all fields.')}
|
{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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card padding="md">
|
<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 ? (
|
{adminProfileLoading ? (
|
||||||
<div className="py-8 flex justify-center">
|
<div className="py-8 flex justify-center">
|
||||||
<Loading size="md" />
|
<Loading size="md" />
|
||||||
@@ -44,7 +44,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<form className="space-y-4" onSubmit={handleAccountSubmit}>
|
<form className="space-y-4" onSubmit={handleAccountSubmit}>
|
||||||
<div>
|
<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')}
|
{t('settings.general.accountUsername')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -56,13 +56,13 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
|
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
|
||||||
error={accountErrors.username}
|
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')}
|
{t('settings.general.accountUsernameHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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')}
|
{t('settings.general.accountEmail')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -74,7 +74,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
|
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
|
||||||
error={accountErrors.email}
|
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')}
|
{t('settings.general.accountEmailHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,11 +94,11 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding="md">
|
<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 className="space-y-4">
|
||||||
<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.siteUrl')}
|
{t('settings.general.siteUrl')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -108,14 +108,14 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
placeholder="https://yourdomain.com"
|
placeholder="https://yourdomain.com"
|
||||||
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
|
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')}
|
{t('settings.general.siteUrlHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<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.defaultExpiration')}
|
{t('settings.general.defaultExpiration')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -127,7 +127,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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.maxFileSize')}
|
{t('settings.general.maxFileSize')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -139,7 +139,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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.maxFilesPerUpload')}
|
{t('settings.general.maxFilesPerUpload')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -157,14 +157,14 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
min="1"
|
min="1"
|
||||||
max={MAX_FILES_PER_UPLOAD_LIMIT}
|
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 })}
|
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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')}
|
{t('settings.general.allowedFileTypes')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -173,7 +173,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, allowed_file_types: e.target.value }))}
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, allowed_file_types: e.target.value }))}
|
||||||
placeholder="jpg,jpeg,png,gif"
|
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')}
|
{t('settings.general.allowedFileTypesHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +181,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding="md">
|
<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">
|
<div className="space-y-3">
|
||||||
<label className="flex items-center">
|
<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 }))}
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_analytics: e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
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>
|
||||||
|
|
||||||
<label className="flex items-center">
|
<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 }))}
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_registration: e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
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>
|
||||||
|
|
||||||
<label className="flex items-center">
|
<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 }))}
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, maintenance_mode: e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
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>
|
</label>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -222,9 +222,9 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, short_gallery_urls: e.target.checked }))}
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, short_gallery_urls: e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
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>
|
</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')}
|
{t('settings.general.enableShortGalleryUrlsHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,22 +232,22 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding="md">
|
<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 className="space-y-4">
|
||||||
<div>
|
<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')}
|
{t('settings.general.language')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={generalSettings.default_language}
|
value={generalSettings.default_language}
|
||||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_language: e.target.value }))}
|
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="en">English</option>
|
||||||
<option value="de">Deutsch</option>
|
<option value="de">Deutsch</option>
|
||||||
</select>
|
</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')}
|
{t('settings.general.defaultLanguageHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -255,11 +255,11 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding="md">
|
<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 className="space-y-4">
|
||||||
<div>
|
<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')}
|
{t('settings.general.dateFormat')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -272,14 +272,14 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
|||||||
date_format: { format, locale }
|
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="dd/MM/yyyy">DD/MM/YYYY (European)</option>
|
||||||
<option value="MM/dd/yyyy">MM/DD/YYYY (US)</option>
|
<option value="MM/dd/yyyy">MM/DD/YYYY (US)</option>
|
||||||
<option value="yyyy-MM-dd">YYYY-MM-DD (ISO)</option>
|
<option value="yyyy-MM-dd">YYYY-MM-DD (ISO)</option>
|
||||||
<option value="dd.MM.yyyy">DD.MM.YYYY (German)</option>
|
<option value="dd.MM.yyyy">DD.MM.YYYY (German)</option>
|
||||||
</select>
|
</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')}
|
{t('settings.general.dateFormatHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -121,23 +121,23 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Default Protection Level */}
|
{/* Default Protection Level */}
|
||||||
<Card padding="md">
|
<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" />
|
<Shield className="w-5 h-5 text-primary-600" />
|
||||||
{t('settings.imageSecurity.defaultProtection', 'Default Protection Settings')}
|
{t('settings.imageSecurity.defaultProtection', 'Default Protection Settings')}
|
||||||
</h2>
|
</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.')}
|
{t('settings.imageSecurity.defaultProtectionHelp', 'These settings apply to all new events. Individual events can override these defaults.')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<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.protectionLevel', 'Default Protection Level')}
|
{t('settings.imageSecurity.protectionLevel', 'Default Protection Level')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={settings.default_protection_level}
|
value={settings.default_protection_level}
|
||||||
onChange={(e) => handleChange('default_protection_level', e.target.value as ImageSecuritySettings['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="basic">{t('events.protectionLevelBasic', 'Basic - Right-click blocking only')}</option>
|
||||||
<option value="standard">{t('events.protectionLevelStandard', 'Standard - Keyboard shortcuts blocked')}</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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<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.imageQuality', 'Default Image Quality')}
|
{t('settings.imageSecurity.imageQuality', 'Default Image Quality')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -157,13 +157,13 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
max="100"
|
max="100"
|
||||||
value={settings.default_image_quality}
|
value={settings.default_image_quality}
|
||||||
onChange={(e) => handleChange('default_image_quality', parseInt(e.target.value) || 85)}
|
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>
|
||||||
|
|
||||||
<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')}
|
{t('settings.imageSecurity.fragmentationLevel', 'Fragmentation Level')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -172,9 +172,9 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
max="10"
|
max="10"
|
||||||
value={settings.default_fragmentation_level}
|
value={settings.default_fragmentation_level}
|
||||||
onChange={(e) => handleChange('default_fragmentation_level', parseInt(e.target.value) || 3)}
|
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>
|
||||||
</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"
|
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" />
|
<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')}
|
{t('settings.imageSecurity.enableDevtools', 'Enable DevTools detection by default')}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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"
|
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" />
|
<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)')}
|
{t('settings.imageSecurity.enableCanvas', 'Enable canvas rendering by default (advanced protection)')}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -210,16 +210,16 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
|
|
||||||
{/* Rate Limiting */}
|
{/* Rate Limiting */}
|
||||||
<Card padding="md">
|
<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')}
|
{t('settings.imageSecurity.rateLimiting', 'Rate Limiting')}
|
||||||
</h2>
|
</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.')}
|
{t('settings.imageSecurity.rateLimitingHelp', 'Limit how many images can be requested to prevent scraping.')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<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.requestsPerMinute', 'Requests per minute')}
|
{t('settings.imageSecurity.requestsPerMinute', 'Requests per minute')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -228,12 +228,12 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
max="1000"
|
max="1000"
|
||||||
value={settings.max_image_requests_per_minute}
|
value={settings.max_image_requests_per_minute}
|
||||||
onChange={(e) => handleChange('max_image_requests_per_minute', parseInt(e.target.value) || 30)}
|
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>
|
||||||
|
|
||||||
<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')}
|
{t('settings.imageSecurity.requestsPer5Minutes', 'Requests per 5 min')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -242,12 +242,12 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
max="5000"
|
max="5000"
|
||||||
value={settings.max_image_requests_per_5_minutes}
|
value={settings.max_image_requests_per_5_minutes}
|
||||||
onChange={(e) => handleChange('max_image_requests_per_5_minutes', parseInt(e.target.value) || 100)}
|
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>
|
||||||
|
|
||||||
<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')}
|
{t('settings.imageSecurity.requestsPerHour', 'Requests per hour')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -256,7 +256,7 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
max="10000"
|
max="10000"
|
||||||
value={settings.max_image_requests_per_hour}
|
value={settings.max_image_requests_per_hour}
|
||||||
onChange={(e) => handleChange('max_image_requests_per_hour', parseInt(e.target.value) || 500)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,14 +264,14 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
|
|
||||||
{/* Security Monitoring */}
|
{/* Security Monitoring */}
|
||||||
<Card padding="md">
|
<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')}
|
{t('settings.imageSecurity.securityMonitoring', 'Security Monitoring')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<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.suspiciousThreshold', 'Suspicious activity threshold')}
|
{t('settings.imageSecurity.suspiciousThreshold', 'Suspicious activity threshold')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -280,13 +280,13 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
max="100"
|
max="100"
|
||||||
value={settings.suspicious_activity_threshold}
|
value={settings.suspicious_activity_threshold}
|
||||||
onChange={(e) => handleChange('suspicious_activity_threshold', parseInt(e.target.value) || 10)}
|
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>
|
||||||
|
|
||||||
<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')}
|
{t('settings.imageSecurity.autoBlockThreshold', 'Auto-block threshold')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -295,9 +295,9 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
max="500"
|
max="500"
|
||||||
value={settings.auto_block_threshold}
|
value={settings.auto_block_threshold}
|
||||||
onChange={(e) => handleChange('auto_block_threshold', parseInt(e.target.value) || 50)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
onChange={(e) => handleChange('security_monitoring_enabled', e.target.checked)}
|
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"
|
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')}
|
{t('settings.imageSecurity.enableMonitoring', 'Enable security monitoring')}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -321,7 +321,7 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
onChange={(e) => handleChange('block_suspicious_ips', e.target.checked)}
|
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"
|
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')}
|
{t('settings.imageSecurity.blockSuspiciousIps', 'Automatically block suspicious IPs')}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -333,7 +333,7 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
onChange={(e) => handleChange('log_security_events_to_db', e.target.checked)}
|
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"
|
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')}
|
{t('settings.imageSecurity.logEvents', 'Log security events to database')}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -342,10 +342,10 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Info Box */}
|
{/* 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">
|
<div className="flex items-start gap-3">
|
||||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
<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">
|
<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 className="font-medium mb-1">{t('settings.imageSecurity.infoTitle', 'About Image Protection')}</p>
|
||||||
<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.')}
|
{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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card padding="md">
|
<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 className="space-y-4">
|
||||||
<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.minPasswordLength')}
|
{t('settings.security.minPasswordLength')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -40,20 +40,20 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
|
|||||||
</div>
|
</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.security.passwordComplexity')}
|
{t('settings.security.passwordComplexity')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={securitySettings.password_complexity}
|
value={securitySettings.password_complexity}
|
||||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, password_complexity: e.target.value }))}
|
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="simple">{t('settings.security.complexitySimple')}</option>
|
||||||
<option value="moderate">{t('settings.security.complexityModerate')}</option>
|
<option value="moderate">{t('settings.security.complexityModerate')}</option>
|
||||||
<option value="strong">{t('settings.security.complexityStrong')}</option>
|
<option value="strong">{t('settings.security.complexityStrong')}</option>
|
||||||
<option value="very_strong">{t('settings.security.complexityVeryStrong')}</option>
|
<option value="very_strong">{t('settings.security.complexityVeryStrong')}</option>
|
||||||
</select>
|
</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')}
|
{t('settings.security.passwordComplexityHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,12 +61,12 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding="md">
|
<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="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<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.sessionTimeout')}
|
{t('settings.security.sessionTimeout')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -78,7 +78,7 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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.security.attemptWindowMinutes')}
|
{t('settings.security.attemptWindowMinutes')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -88,12 +88,12 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
|
|||||||
min="1"
|
min="1"
|
||||||
max="1440"
|
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')}
|
{t('settings.security.attemptWindowMinutesHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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.security.lockoutDurationMinutes')}
|
{t('settings.security.lockoutDurationMinutes')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -103,12 +103,12 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
|
|||||||
min="1"
|
min="1"
|
||||||
max="1440"
|
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')}
|
{t('settings.security.lockoutDurationMinutesHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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.security.maxLoginAttempts')}
|
{t('settings.security.maxLoginAttempts')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -118,7 +118,7 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
|
|||||||
min="1"
|
min="1"
|
||||||
max="50"
|
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')}
|
{t('settings.security.maxLoginAttemptsHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,13 +131,13 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
|
|||||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_2fa: e.target.checked }))}
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_2fa: e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding="md">
|
<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">
|
<div className="space-y-4">
|
||||||
<label className="flex items-center">
|
<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 }))}
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_recaptcha: e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
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>
|
</label>
|
||||||
|
|
||||||
{securitySettings.enable_recaptcha && (
|
{securitySettings.enable_recaptcha && (
|
||||||
<>
|
<>
|
||||||
<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.siteKey')}
|
{t('settings.security.siteKey')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -165,7 +165,7 @@ export const SecurityTab: React.FC<SecurityTabProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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.security.secretKey')}
|
{t('settings.security.secretKey')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<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">
|
<div className="flex items-start gap-3">
|
||||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
||||||
<div className="text-sm text-blue-800">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -125,9 +125,9 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
|||||||
: usagePercentage >= 90
|
: usagePercentage >= 90
|
||||||
? 'bg-amber-500'
|
? 'bg-amber-500'
|
||||||
: 'bg-primary-600';
|
: 'bg-primary-600';
|
||||||
const limitCardClass = overSoftLimit ? 'bg-amber-50 border border-amber-200' : 'bg-neutral-50';
|
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' : 'text-neutral-900';
|
const limitValueClass = overSoftLimit ? 'text-amber-700 dark:text-amber-300' : 'text-neutral-900 dark:text-neutral-100';
|
||||||
const limitDescriptorClass = overSoftLimit ? 'text-amber-700 font-semibold' : 'text-neutral-600';
|
const limitDescriptorClass = overSoftLimit ? 'text-amber-700 dark:text-amber-300 font-semibold' : 'text-neutral-600 dark:text-neutral-400';
|
||||||
const recommendedDescriptorValue = (recommendedDisplay ?? limitDisplay);
|
const recommendedDescriptorValue = (recommendedDisplay ?? limitDisplay);
|
||||||
const diskMetricsReliable = storageInfo.disk_metrics_reliable;
|
const diskMetricsReliable = storageInfo.disk_metrics_reliable;
|
||||||
const overrideSource = storageInfo.disk_override_source;
|
const overrideSource = storageInfo.disk_override_source;
|
||||||
@@ -161,26 +161,26 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="md">
|
<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" />
|
<HardDrive className="w-5 h-5" />
|
||||||
{t('settings.systemStatus.storageOverview')}
|
{t('settings.systemStatus.storageOverview')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
<div className="bg-neutral-50 rounded-lg p-4">
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
|
||||||
<p className="text-sm text-neutral-600">{t('settings.storage.totalUsed')}</p>
|
<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">
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{settingsService.formatBytes(storageInfo.total_used)}
|
{settingsService.formatBytes(storageInfo.total_used)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-neutral-50 rounded-lg p-4">
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
|
||||||
<p className="text-sm text-neutral-600">{t('settings.storage.archiveStorage')}</p>
|
<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">
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{settingsService.formatBytes(storageInfo.archive_storage)}
|
{settingsService.formatBytes(storageInfo.archive_storage)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={`rounded-lg p-4 ${limitCardClass}`}>
|
<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}`}>
|
<p className={`text-2xl font-bold ${limitValueClass}`}>
|
||||||
{limitDisplay}
|
{limitDisplay}
|
||||||
</p>
|
</p>
|
||||||
@@ -194,12 +194,12 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
|||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex justify-between text-sm mb-1">
|
<div className="flex justify-between text-sm mb-1">
|
||||||
<span className="text-neutral-600">{t('settings.storage.storageUsage')}</span>
|
<span className="text-neutral-600 dark:text-neutral-400">{t('settings.storage.storageUsage')}</span>
|
||||||
<span className={`font-medium ${overSoftLimit ? 'text-red-600' : 'text-neutral-900'}`}>
|
<span className={`font-medium ${overSoftLimit ? 'text-red-600 dark:text-red-400' : 'text-neutral-900 dark:text-neutral-100'}`}>
|
||||||
{usagePercentage}%
|
{usagePercentage}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
className={`${progressColor} h-3 rounded-full transition-all`}
|
className={`${progressColor} h-3 rounded-full transition-all`}
|
||||||
style={{ width: `${usageWidth}%` }}
|
style={{ width: `${usageWidth}%` }}
|
||||||
@@ -207,24 +207,24 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
||||||
<p className="text-sm text-neutral-600">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('settings.storage.storageLimitHelper')}
|
{t('settings.storage.storageLimitHelper')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{diskSummaryCards.length > 0 && (diskMetricsReliable || overrideSource) && (
|
{diskSummaryCards.length > 0 && (diskMetricsReliable || overrideSource) && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
{diskSummaryCards.map((card) => (
|
{diskSummaryCards.map((card) => (
|
||||||
<div key={card.label} className="bg-neutral-50 rounded-lg p-4">
|
<div key={card.label} className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
|
||||||
<p className="text-xs text-neutral-500 uppercase tracking-wide">{card.label}</p>
|
<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 mt-1">{card.value}</p>
|
<p className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mt-1">{card.value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!diskMetricsReliable && !overrideSource && (
|
{!diskMetricsReliable && !overrideSource && (
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('settings.storage.diskMetricsUnavailable')}
|
{t('settings.storage.diskMetricsUnavailable')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -253,7 +253,7 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
|||||||
helperText={t('settings.storage.softLimitHelper')}
|
helperText={t('settings.storage.softLimitHelper')}
|
||||||
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
|
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')}
|
{t('settings.storage.limitNotEnforced')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,13 +303,13 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
<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 ? (
|
{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>
|
</div>
|
||||||
|
|
||||||
@@ -386,40 +386,40 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
|||||||
{systemStatus && (
|
{systemStatus && (
|
||||||
<>
|
<>
|
||||||
<Card padding="md">
|
<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" />
|
<Server className="w-5 h-5" />
|
||||||
{t('settings.systemStatus.systemInfo')}
|
{t('settings.systemStatus.systemInfo')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<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">
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
|
||||||
<p className="text-sm text-neutral-600">{t('settings.systemStatus.platform')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.platform')}</p>
|
||||||
<p className="font-semibold">{systemStatus.system.platform}</p>
|
<p className="font-semibold text-neutral-900 dark:text-neutral-100">{systemStatus.system.platform}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-neutral-50 rounded-lg p-4">
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
|
||||||
<p className="text-sm text-neutral-600">{t('settings.systemStatus.nodeVersion')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.nodeVersion')}</p>
|
||||||
<p className="font-semibold">{systemStatus.system.nodeVersion}</p>
|
<p className="font-semibold text-neutral-900 dark:text-neutral-100">{systemStatus.system.nodeVersion}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-neutral-50 rounded-lg p-4">
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
|
||||||
<p className="text-sm text-neutral-600">{t('settings.systemStatus.uptime')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{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>
|
<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>
|
||||||
<div className="bg-neutral-50 rounded-lg p-4">
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-4">
|
||||||
<p className="text-sm text-neutral-600">{t('settings.systemStatus.cpuCores')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.cpuCores')}</p>
|
||||||
<p className="font-semibold">{systemStatus.system.cpu.cores}</p>
|
<p className="font-semibold text-neutral-900 dark:text-neutral-100">{systemStatus.system.cpu.cores}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<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="mb-2">
|
||||||
<div className="flex justify-between text-sm mb-1">
|
<div className="flex justify-between text-sm mb-1">
|
||||||
<span className="text-neutral-600">{t('settings.systemStatus.memoryUsed')}</span>
|
<span className="text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.memoryUsed')}</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{settingsService.formatBytes(systemStatus.system.memory.used)} / {settingsService.formatBytes(systemStatus.system.memory.total)}
|
{settingsService.formatBytes(systemStatus.system.memory.used)} / {settingsService.formatBytes(systemStatus.system.memory.total)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||||
style={{
|
style={{
|
||||||
@@ -432,71 +432,71 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding="md">
|
<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" />
|
<Database className="w-5 h-5" />
|
||||||
{t('settings.systemStatus.databaseInfo')}
|
{t('settings.systemStatus.databaseInfo')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
<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">
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
|
||||||
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.events}</p>
|
<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">{t('navigation.events')}</p>
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('navigation.events')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-neutral-50 rounded-lg p-3 text-center">
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
|
||||||
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.photos}</p>
|
<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">{t('settings.systemStatus.photos')}</p>
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.photos')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-neutral-50 rounded-lg p-3 text-center">
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
|
||||||
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.admins}</p>
|
<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">{t('settings.systemStatus.admins')}</p>
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.admins')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-neutral-50 rounded-lg p-3 text-center">
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
|
||||||
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.categories}</p>
|
<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">{t('settings.categories.title')}</p>
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.categories.title')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-neutral-50 rounded-lg p-3 text-center">
|
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
|
||||||
<p className="text-2xl font-bold text-neutral-900">{settingsService.formatBytes(systemStatus.database.size)}</p>
|
<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">{t('settings.systemStatus.dbSize')}</p>
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.systemStatus.dbSize')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding="md">
|
<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" />
|
<Activity className="w-5 h-5" />
|
||||||
{t('settings.systemStatus.services')}
|
{t('settings.systemStatus.services')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<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">
|
<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" />
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
</div>
|
</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>
|
||||||
<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">
|
<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" />
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
</div>
|
</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>
|
||||||
<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">
|
<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" />
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
|
<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 mb-2">{t('settings.systemStatus.emailQueue')}</h3>
|
<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 className="grid grid-cols-3 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-blue-700">{t('settings.systemStatus.pending')}:</span>
|
<span className="text-blue-700 dark:text-blue-300">{t('settings.systemStatus.pending')}:</span>
|
||||||
<span className="ml-2 font-semibold text-blue-900">
|
<span className="ml-2 font-semibold text-blue-900 dark:text-blue-200">
|
||||||
{systemStatus.emailQueue.pending}
|
{systemStatus.emailQueue.pending}
|
||||||
{systemStatus.emailQueue.stuck > 0 && (
|
{systemStatus.emailQueue.stuck > 0 && (
|
||||||
<span className="text-orange-600 text-xs ml-1">
|
<span className="text-orange-600 text-xs ml-1">
|
||||||
@@ -506,17 +506,17 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-green-700">{t('settings.systemStatus.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">{systemStatus.emailQueue.sent}</span>
|
<span className="ml-2 font-semibold text-green-900 dark:text-green-300">{systemStatus.emailQueue.sent}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-red-700">{t('settings.systemStatus.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">{systemStatus.emailQueue.failed}</span>
|
<span className="ml-2 font-semibold text-red-900 dark:text-red-300">{systemStatus.emailQueue.failed}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{systemStatus.emailQueue.stuck > 0 && (
|
{systemStatus.emailQueue.stuck > 0 && (
|
||||||
<div className="mt-3 p-3 bg-orange-50 rounded-md">
|
<div className="mt-3 p-3 bg-orange-50 dark:bg-orange-900/30 rounded-md">
|
||||||
<p className="text-xs text-orange-800">
|
<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't be processed automatically.
|
<span className="font-semibold">Warning: {systemStatus.emailQueue.stuck} email(s) stuck:</span> These emails have exceeded retry limits and won't be processed automatically.
|
||||||
Only {systemStatus.emailQueue.processable} of {systemStatus.emailQueue.pending} pending emails will be processed.
|
Only {systemStatus.emailQueue.processable} of {systemStatus.emailQueue.pending} pending emails will be processed.
|
||||||
</p>
|
</p>
|
||||||
@@ -529,7 +529,7 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
|||||||
|
|
||||||
{/* Last update time */}
|
{/* Last update time */}
|
||||||
{systemStatus && (
|
{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" />
|
<Clock className="w-3 h-3" />
|
||||||
{t('settings.systemStatus.lastUpdate')}: {new Date(systemStatus.timestamp).toLocaleString()}
|
{t('settings.systemStatus.lastUpdate')}: {new Date(systemStatus.timestamp).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -148,8 +148,8 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-neutral-900">{t('navigation.dashboard')}</h1>
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('navigation.dashboard')}</h1>
|
||||||
<p className="text-neutral-600 mt-1">{t('admin.dashboardSubtitle')}</p>
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('admin.dashboardSubtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -166,13 +166,13 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
<Card key={stat.title} className="p-6">
|
<Card key={stat.title} className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-neutral-600">{stat.title}</p>
|
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">{stat.title}</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900 mt-1">{stat.value}</p>
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 mt-1">{stat.value}</p>
|
||||||
{stat.change && (
|
{stat.change && (
|
||||||
<p className="text-sm text-neutral-500 mt-1">{stat.change}</p>
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">{stat.change}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`p-3 rounded-full bg-neutral-100 ${stat.color}`}>
|
<div className={`p-3 rounded-full bg-neutral-100 dark:bg-neutral-700 ${stat.color}`}>
|
||||||
<stat.icon className="w-6 h-6" />
|
<stat.icon className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,12 +186,12 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900">{t('admin.eventsExpiringSoon')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('admin.eventsExpiringSoon')}</h2>
|
||||||
<AlertTriangle className="w-5 h-5 text-orange-600" />
|
<AlertTriangle className="w-5 h-5 text-orange-600" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expiringEvents.length === 0 ? (
|
{expiringEvents.length === 0 ? (
|
||||||
<p className="text-neutral-600 py-8 text-center">{t('admin.noEventsExpiring')}</p>
|
<p className="text-neutral-600 dark:text-neutral-400 py-8 text-center">{t('admin.noEventsExpiring')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{expiringEvents.slice(0, 5).map((event) => {
|
{expiringEvents.slice(0, 5).map((event) => {
|
||||||
@@ -200,22 +200,22 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
className="flex items-center justify-between p-4 bg-orange-50 rounded-lg border border-orange-200 cursor-pointer hover:bg-orange-100 transition-colors"
|
className="flex items-center justify-between p-4 bg-orange-50 dark:bg-orange-900/30 rounded-lg border border-orange-200 dark:border-orange-800 cursor-pointer hover:bg-orange-100 dark:hover:bg-orange-900/50 transition-colors"
|
||||||
onClick={() => navigate(`/admin/events/${event.id}`)}
|
onClick={() => navigate(`/admin/events/${event.id}`)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-neutral-900">{event.event_name}</h3>
|
<h3 className="font-medium text-neutral-900 dark:text-neutral-100">{event.event_name}</h3>
|
||||||
{event.event_date && (
|
{event.event_date && (
|
||||||
<p className="text-sm text-neutral-600">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{format(parseISO(event.event_date), 'PP')}
|
{format(parseISO(event.event_date), 'PP')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-medium text-orange-600">
|
<p className="text-sm font-medium text-orange-600 dark:text-orange-400">
|
||||||
{t('admin.daysLeft', { count: daysLeft })}
|
{t('admin.daysLeft', { count: daysLeft })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('gallery.expires')} {format(parseISO(event.expires_at!), 'PP')}
|
{t('gallery.expires')} {format(parseISO(event.expires_at!), 'PP')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,7 +228,7 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
{expiringEvents.length > 5 && (
|
{expiringEvents.length > 5 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin/events?filter=expiring')}
|
onClick={() => navigate('/admin/events?filter=expiring')}
|
||||||
className="w-full mt-4 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
className="w-full mt-4 text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium"
|
||||||
>
|
>
|
||||||
{t('admin.viewAllExpiringEvents', { count: expiringEvents.length })} →
|
{t('admin.viewAllExpiringEvents', { count: expiringEvents.length })} →
|
||||||
</button>
|
</button>
|
||||||
@@ -239,13 +239,13 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
{/* Recent Activity */}
|
{/* Recent Activity */}
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900">{t('admin.recentActivity')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('admin.recentActivity')}</h2>
|
||||||
<Clock className="w-5 h-5 text-neutral-500" />
|
<Clock className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!recentActivity || recentActivity.length === 0 ? (
|
{!recentActivity || recentActivity.length === 0 ? (
|
||||||
<p className="text-sm text-neutral-500 text-center py-4">{t('admin.noRecentActivity')}</p>
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-4">{t('admin.noRecentActivity')}</p>
|
||||||
) : (
|
) : (
|
||||||
recentActivity.slice(0, 5).map((activity) => {
|
recentActivity.slice(0, 5).map((activity) => {
|
||||||
// Get color based on activity type
|
// Get color based on activity type
|
||||||
@@ -288,11 +288,11 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
<div key={activity.id} className="flex items-start gap-3">
|
<div key={activity.id} className="flex items-start gap-3">
|
||||||
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${getActivityColor(activity.type)}`} />
|
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${getActivityColor(activity.type)}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-neutral-900 break-words">
|
<p className="text-sm text-neutral-900 dark:text-neutral-100 break-words">
|
||||||
{getActivityMessage()}
|
{getActivityMessage()}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-neutral-500">{activity.actorName}</p>
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">{activity.actorName}</p>
|
||||||
<p className="text-xs text-neutral-400 mt-1">
|
<p className="text-xs text-neutral-400 dark:text-neutral-500 mt-1">
|
||||||
{formatDistanceToNow(parseISO(activity.createdAt), { addSuffix: true })}
|
{formatDistanceToNow(parseISO(activity.createdAt), { addSuffix: true })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
const isPositive = trend > 0;
|
const isPositive = trend > 0;
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center text-xs font-medium ${
|
<span className={`inline-flex items-center text-xs font-medium ${
|
||||||
isPositive ? 'text-green-700' : 'text-red-700'
|
isPositive ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'
|
||||||
}`}>
|
}`}>
|
||||||
<TrendingUp className={`w-3 h-3 mr-1 ${!isPositive ? 'rotate-180' : ''}`} />
|
<TrendingUp className={`w-3 h-3 mr-1 ${!isPositive ? 'rotate-180' : ''}`} />
|
||||||
{Math.abs(trend)}%
|
{Math.abs(trend)}%
|
||||||
@@ -237,8 +237,8 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-neutral-900">{t('analytics.title')}</h1>
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('analytics.title')}</h1>
|
||||||
<p className="text-neutral-600 mt-1">{t('analytics.detailedSubtitle')}</p>
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('analytics.detailedSubtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -265,8 +265,8 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-neutral-900">{t('analytics.title')}</h1>
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('analytics.title')}</h1>
|
||||||
<p className="text-neutral-600 mt-1">{t('analytics.subtitle')}</p>
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('analytics.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{umamiConfig.shareUrl && (
|
{umamiConfig.shareUrl && (
|
||||||
@@ -288,7 +288,7 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
<select
|
<select
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
onChange={(e) => setDateRange(e.target.value as any)}
|
onChange={(e) => setDateRange(e.target.value as any)}
|
||||||
className="px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
className="px-4 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||||
>
|
>
|
||||||
<option value="7d">{t('analytics.last7Days')}</option>
|
<option value="7d">{t('analytics.last7Days')}</option>
|
||||||
<option value="30d">{t('analytics.last30Days')}</option>
|
<option value="30d">{t('analytics.last30Days')}</option>
|
||||||
@@ -302,8 +302,8 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600">{t('analytics.pageViews')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('analytics.pageViews')}</p>
|
||||||
<p className="text-3xl font-bold text-neutral-900">{analytics?.pageViews.total.toLocaleString()}</p>
|
<p className="text-3xl font-bold text-neutral-900 dark:text-neutral-100">{analytics?.pageViews.total.toLocaleString()}</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{renderTrendBadge(analytics?.pageViews.trend || 0)}
|
{renderTrendBadge(analytics?.pageViews.trend || 0)}
|
||||||
</div>
|
</div>
|
||||||
@@ -319,8 +319,8 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600">{t('analytics.uniqueVisitors')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('analytics.uniqueVisitors')}</p>
|
||||||
<p className="text-3xl font-bold text-neutral-900">{analytics?.uniqueVisitors.total.toLocaleString()}</p>
|
<p className="text-3xl font-bold text-neutral-900 dark:text-neutral-100">{analytics?.uniqueVisitors.total.toLocaleString()}</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{renderTrendBadge(analytics?.uniqueVisitors.trend || 0)}
|
{renderTrendBadge(analytics?.uniqueVisitors.trend || 0)}
|
||||||
</div>
|
</div>
|
||||||
@@ -336,8 +336,8 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600">{t('analytics.totalDownloads')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('analytics.totalDownloads')}</p>
|
||||||
<p className="text-3xl font-bold text-neutral-900">{analytics?.downloads.total.toLocaleString()}</p>
|
<p className="text-3xl font-bold text-neutral-900 dark:text-neutral-100">{analytics?.downloads.total.toLocaleString()}</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
{renderTrendBadge(analytics?.downloads.trend || 0)}
|
{renderTrendBadge(analytics?.downloads.trend || 0)}
|
||||||
</div>
|
</div>
|
||||||
@@ -345,8 +345,8 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
<Download className="w-8 h-8 text-purple-600" />
|
<Download className="w-8 h-8 text-purple-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
<p className="text-xs text-neutral-500 uppercase">{t('analytics.topGallery')}</p>
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 uppercase">{t('analytics.topGallery')}</p>
|
||||||
<p className="text-sm font-medium text-neutral-900 truncate">
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">
|
||||||
{analytics?.downloads.topGalleries[0]?.name}
|
{analytics?.downloads.topGalleries[0]?.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,19 +357,19 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
{/* Top Pages */}
|
{/* Top Pages */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.topPages')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('analytics.topPages')}</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{analytics?.topPages.map((page, index) => (
|
{analytics?.topPages.map((page, index) => (
|
||||||
<div key={index} className="flex items-center justify-between">
|
<div key={index} className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-neutral-900">{page.path}</p>
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{page.path}</p>
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{page.uniqueVisitors} {t('analytics.visitors')}
|
{page.uniqueVisitors} {t('analytics.visitors')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-semibold text-neutral-900">{page.views}</p>
|
<p className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{page.views}</p>
|
||||||
<p className="text-xs text-neutral-500">{t('analytics.views')}</p>
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('analytics.views')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -378,15 +378,15 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Top Downloads */}
|
{/* Top Downloads */}
|
||||||
<Card padding="md" className="mt-6">
|
<Card padding="md" className="mt-6">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.topDownloadsByGallery')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('analytics.topDownloadsByGallery')}</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{analytics?.downloads.topGalleries.map((gallery, index) => (
|
{analytics?.downloads.topGalleries.map((gallery, index) => (
|
||||||
<div key={index} className="flex items-center justify-between">
|
<div key={index} className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium text-neutral-900">{gallery.name}</p>
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{gallery.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex-1 bg-neutral-200 rounded-full h-2 max-w-[100px]">
|
<div className="flex-1 bg-neutral-200 dark:bg-neutral-700 rounded-full h-2 max-w-[100px]">
|
||||||
<div
|
<div
|
||||||
className="bg-purple-600 h-2 rounded-full"
|
className="bg-purple-600 h-2 rounded-full"
|
||||||
style={{
|
style={{
|
||||||
@@ -394,7 +394,7 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-semibold text-neutral-900 w-12 text-right">
|
<p className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 w-12 text-right">
|
||||||
{gallery.downloads}
|
{gallery.downloads}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -408,28 +408,28 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Device Breakdown */}
|
{/* Device Breakdown */}
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.deviceBreakdown')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('analytics.deviceBreakdown')}</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Monitor className="w-5 h-5 text-neutral-600" />
|
<Monitor className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||||
<span className="text-sm text-neutral-700">{t('analytics.desktop')}</span>
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('analytics.desktop')}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold">{analytics?.devices.desktop}%</span>
|
<span className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{analytics?.devices.desktop}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Smartphone className="w-5 h-5 text-neutral-600" />
|
<Smartphone className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||||
<span className="text-sm text-neutral-700">{t('analytics.mobile')}</span>
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('analytics.mobile')}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold">{analytics?.devices.mobile}%</span>
|
<span className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{analytics?.devices.mobile}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Tablet className="w-5 h-5 text-neutral-600" />
|
<Tablet className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||||
<span className="text-sm text-neutral-700">{t('analytics.tablet')}</span>
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('analytics.tablet')}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-semibold">{analytics?.devices.tablet}%</span>
|
<span className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{analytics?.devices.tablet}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -463,34 +463,34 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.storageUsage')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('analytics.storageUsage')}</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between text-sm mb-1">
|
<div className="flex justify-between text-sm mb-1">
|
||||||
<span className="text-neutral-600">{t('analytics.used')}</span>
|
<span className="text-neutral-600 dark:text-neutral-400">{t('analytics.used')}</span>
|
||||||
<span className="font-medium">{adminService.formatBytes(dashboardStats.storageUsed)}</span>
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">{adminService.formatBytes(dashboardStats.storageUsed)}</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
className={`${progressColor} h-2 rounded-full transition-all`}
|
className={`${progressColor} h-2 rounded-full transition-all`}
|
||||||
style={{ width: `${usageWidth}%` }}
|
style={{ width: `${usageWidth}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
{usagePercent}% {t('analytics.of')} {limitDisplay}
|
{usagePercent}% {t('analytics.of')} {limitDisplay}
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-xs mt-1 ${overSoftLimit ? 'text-red-600 font-semibold' : 'text-red-500 font-medium'}`}>
|
<p className={`text-xs mt-1 ${overSoftLimit ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-red-500 dark:text-red-400 font-medium'}`}>
|
||||||
{limitDescriptor}
|
{limitDescriptor}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-2 border-t">
|
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-neutral-600">{t('analytics.totalPhotos')}</span>
|
<span className="text-neutral-600 dark:text-neutral-400">{t('analytics.totalPhotos')}</span>
|
||||||
<span className="font-medium">{dashboardStats.totalPhotos.toLocaleString()}</span>
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">{dashboardStats.totalPhotos.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm mt-2">
|
<div className="flex justify-between text-sm mt-2">
|
||||||
<span className="text-neutral-600">{t('analytics.activeEvents')}</span>
|
<span className="text-neutral-600 dark:text-neutral-400">{t('analytics.activeEvents')}</span>
|
||||||
<span className="font-medium">{dashboardStats.activeEvents}</span>
|
<span className="font-medium text-neutral-900 dark:text-neutral-100">{dashboardStats.activeEvents}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -502,12 +502,12 @@ export const AnalyticsPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Configuration Notice */}
|
{/* Configuration Notice */}
|
||||||
{umamiConfig.enabled === false && (
|
{umamiConfig.enabled === false && (
|
||||||
<Card padding="md" className="mt-6 bg-amber-50 border-amber-200">
|
<Card padding="md" className="mt-6 bg-amber-50 dark:bg-amber-900/30 border-amber-200 dark:border-amber-800">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Activity className="w-5 h-5 text-amber-600 flex-shrink-0" />
|
<Activity className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-amber-900">{t('analytics.notConfigured')}</p>
|
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">{t('analytics.notConfigured')}</p>
|
||||||
<p className="text-sm text-amber-700 mt-1">
|
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||||
{t('analytics.configureInstructions')}
|
{t('analytics.configureInstructions')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -138,8 +138,8 @@ export const ArchivesPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-neutral-900">{t('archives.title')}</h1>
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('archives.title')}</h1>
|
||||||
<p className="text-neutral-600 mt-1">{t('archives.subtitle')}</p>
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('archives.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Statistics Cards */}
|
{/* Statistics Cards */}
|
||||||
@@ -147,8 +147,8 @@ export const ArchivesPage: React.FC = () => {
|
|||||||
<Card padding="sm">
|
<Card padding="sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600">{t('archives.totalArchives')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('archives.totalArchives')}</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900">{archives.length}</p>
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{archives.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<Archive className="w-8 h-8 text-primary-600" />
|
<Archive className="w-8 h-8 text-primary-600" />
|
||||||
</div>
|
</div>
|
||||||
@@ -157,8 +157,8 @@ export const ArchivesPage: React.FC = () => {
|
|||||||
<Card padding="sm">
|
<Card padding="sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600">{t('archives.storageUsed')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('archives.storageUsed')}</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900">{archiveService.formatBytes(getTotalSize())}</p>
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{archiveService.formatBytes(getTotalSize())}</p>
|
||||||
</div>
|
</div>
|
||||||
<HardDrive className="w-8 h-8 text-blue-600" />
|
<HardDrive className="w-8 h-8 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
@@ -167,8 +167,8 @@ export const ArchivesPage: React.FC = () => {
|
|||||||
<Card padding="sm">
|
<Card padding="sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600">{t('archives.totalPhotos')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('archives.totalPhotos')}</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900">
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{(() => {
|
{(() => {
|
||||||
const total = archives.reduce((sum, a) => sum + (parseInt(String(a.photoCount)) || 0), 0);
|
const total = archives.reduce((sum, a) => sum + (parseInt(String(a.photoCount)) || 0), 0);
|
||||||
return total === 0 ? '0' : total.toLocaleString();
|
return total === 0 ? '0' : total.toLocaleString();
|
||||||
@@ -182,8 +182,8 @@ export const ArchivesPage: React.FC = () => {
|
|||||||
<Card padding="sm">
|
<Card padding="sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600">{t('archives.avgArchiveSize')}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('archives.avgArchiveSize')}</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900">
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{archives.length > 0
|
{archives.length > 0
|
||||||
? archiveService.formatBytes(getTotalSize() / archives.length)
|
? archiveService.formatBytes(getTotalSize() / archives.length)
|
||||||
: '0 Bytes'
|
: '0 Bytes'
|
||||||
@@ -212,7 +212,7 @@ export const ArchivesPage: React.FC = () => {
|
|||||||
<select
|
<select
|
||||||
value={filterType}
|
value={filterType}
|
||||||
onChange={(e) => setFilterType(e.target.value)}
|
onChange={(e) => setFilterType(e.target.value)}
|
||||||
className="px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
className="px-4 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
>
|
>
|
||||||
<option value="all">{t('archives.allTypes')}</option>
|
<option value="all">{t('archives.allTypes')}</option>
|
||||||
<option value="wedding">{t('archives.wedding')}</option>
|
<option value="wedding">{t('archives.wedding')}</option>
|
||||||
@@ -225,7 +225,7 @@ export const ArchivesPage: React.FC = () => {
|
|||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value as any)}
|
onChange={(e) => setSortBy(e.target.value as any)}
|
||||||
className="px-4 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
className="px-4 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
>
|
>
|
||||||
<option value="date">{t('archives.sortByDate')}</option>
|
<option value="date">{t('archives.sortByDate')}</option>
|
||||||
<option value="name">{t('archives.sortByName')}</option>
|
<option value="name">{t('archives.sortByName')}</option>
|
||||||
@@ -239,61 +239,61 @@ export const ArchivesPage: React.FC = () => {
|
|||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-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('archives.tableHeaders.event')}
|
{t('archives.tableHeaders.event')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-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('archives.tableHeaders.type')}
|
{t('archives.tableHeaders.type')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-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('archives.tableHeaders.archivedDate')}
|
{t('archives.tableHeaders.archivedDate')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-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('archives.tableHeaders.size')}
|
{t('archives.tableHeaders.size')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-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('archives.tableHeaders.photos')}
|
{t('archives.tableHeaders.photos')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-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('archives.tableHeaders.actions')}
|
{t('archives.tableHeaders.actions')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-neutral-200">
|
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||||
{filteredArchives.length === 0 ? (
|
{filteredArchives.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-6 py-12 text-center text-neutral-500">
|
<td colSpan={6} className="px-6 py-12 text-center text-neutral-500 dark:text-neutral-400">
|
||||||
{t('archives.noArchivesFound')}
|
{t('archives.noArchivesFound')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredArchives.map((archive) => (
|
filteredArchives.map((archive) => (
|
||||||
<tr key={archive.id} className="hover:bg-neutral-50">
|
<tr key={archive.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700/50">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-neutral-900">{archive.eventName}</p>
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{archive.eventName}</p>
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('archives.eventDateNA').replace('N/A', formatDate(archive.eventDate, 'MMM d, yyyy') || 'N/A')}
|
{t('archives.eventDateNA').replace('N/A', formatDate(archive.eventDate, 'MMM d, yyyy') || 'N/A')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-neutral-700 capitalize">
|
<td className="px-6 py-4 text-sm text-neutral-700 dark:text-neutral-300 capitalize">
|
||||||
{archive.eventType}
|
{archive.eventType}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-neutral-700">
|
<td className="px-6 py-4 text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
<div>
|
<div>
|
||||||
<p>{formatDate(archive.archivedAt, 'MMM d, yyyy') || t('archives.processing')}</p>
|
<p>{formatDate(archive.archivedAt, 'MMM d, yyyy') || t('archives.processing')}</p>
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{formatDate(archive.archivedAt, 'h:mm a')}
|
{formatDate(archive.archivedAt, 'h:mm a')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-neutral-700">
|
<td className="px-6 py-4 text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
{archiveService.formatBytes(archive.archiveSize)}
|
{archiveService.formatBytes(archive.archiveSize)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-neutral-700">
|
<td className="px-6 py-4 text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
{archive.photoCount}
|
{archive.photoCount}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
@@ -383,12 +383,12 @@ export const ArchivesPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Storage Warning */}
|
{/* Storage Warning */}
|
||||||
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
<div className="mt-6 p-4 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0" />
|
<AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-amber-900">{t('archives.storageManagement')}</p>
|
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">{t('archives.storageManagement')}</p>
|
||||||
<p className="text-sm text-amber-700 mt-1">
|
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||||
{t('archives.storageInfo')}
|
{t('archives.storageInfo')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ export const BackupManagement = () => {
|
|||||||
<div className="p-8 max-w-7xl mx-auto">
|
<div className="p-8 max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t('backup.title')}</h1>
|
<h1 className="text-3xl font-bold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.title')}</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-neutral-600 dark:text-neutral-400">
|
||||||
{t('backup.subtitle')}
|
{t('backup.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,27 +126,27 @@ export const BackupManagement = () => {
|
|||||||
{backupStatus?.isRunning ? (
|
{backupStatus?.isRunning ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-5 w-5 text-blue-500 animate-spin" />
|
<Loader2 className="h-5 w-5 text-blue-500 animate-spin" />
|
||||||
<span className="text-blue-600 font-medium">{t('backup.status.inProgress')}</span>
|
<span className="text-blue-600 dark:text-blue-400 font-medium">{t('backup.status.inProgress')}</span>
|
||||||
</>
|
</>
|
||||||
) : backupStatus?.lastBackup ? (
|
) : backupStatus?.lastBackup ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
<span className="text-gray-700">
|
<span className="text-neutral-700 dark:text-neutral-300">
|
||||||
{t('backup.status.lastBackup')}: {format(new Date(backupStatus.lastBackup.created_at), 'PPp')}
|
{t('backup.status.lastBackup')}: {format(new Date(backupStatus.lastBackup.created_at), 'PPp')}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<AlertCircle className="h-5 w-5 text-amber-500" />
|
<AlertCircle className="h-5 w-5 text-amber-500" />
|
||||||
<span className="text-gray-700">{t('backup.status.noBackups')}</span>
|
<span className="text-neutral-700 dark:text-neutral-300">{t('backup.status.noBackups')}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{backupConfig?.backup_enabled && (
|
{backupConfig?.backup_enabled && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Clock className="h-5 w-5 text-gray-400" />
|
<Clock className="h-5 w-5 text-neutral-400" />
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('backup.status.nextBackup')}: {backupStatus?.nextBackup || t('backup.status.notScheduled')}
|
{t('backup.status.nextBackup')}: {backupStatus?.nextBackup || t('backup.status.notScheduled')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,8 +175,8 @@ export const BackupManagement = () => {
|
|||||||
|
|
||||||
<div className={`flex items-center space-x-1 px-3 py-1 rounded-full text-sm font-medium ${
|
<div className={`flex items-center space-x-1 px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
backupConfig?.backup_enabled
|
backupConfig?.backup_enabled
|
||||||
? 'bg-green-100 text-green-700'
|
? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300'
|
||||||
: 'bg-gray-100 text-gray-700'
|
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300'
|
||||||
}`}>
|
}`}>
|
||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
<span>{backupConfig?.backup_enabled ? t('backup.status.enabled') : t('backup.status.disabled')}</span>
|
<span>{backupConfig?.backup_enabled ? t('backup.status.enabled') : t('backup.status.disabled')}</span>
|
||||||
@@ -186,7 +186,7 @@ export const BackupManagement = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="border-b border-gray-200 mb-6">
|
<div className="border-b border-neutral-200 dark:border-neutral-700 mb-6">
|
||||||
<nav className="-mb-px flex space-x-8">
|
<nav className="-mb-px flex space-x-8">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
@@ -197,8 +197,8 @@ export const BackupManagement = () => {
|
|||||||
className={`
|
className={`
|
||||||
py-2 px-1 border-b-2 font-medium text-sm flex items-center space-x-2
|
py-2 px-1 border-b-2 font-medium text-sm flex items-center space-x-2
|
||||||
${activeTab === tab.id
|
${activeTab === tab.id
|
||||||
? 'border-primary text-primary'
|
? 'border-primary-600 dark:border-primary-400 text-primary-600 dark:text-primary-400'
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -251,8 +251,8 @@ export const BrandingPage: React.FC = () => {
|
|||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-neutral-900">{t('branding.title')}</h1>
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('branding.title')}</h1>
|
||||||
<p className="text-neutral-600 mt-1">{t('branding.subtitle')}</p>
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('branding.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -274,7 +274,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Company Branding */}
|
{/* Company Branding */}
|
||||||
<Card padding="md" className="mb-6">
|
<Card padding="md" className="mb-6">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.companyInfo')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('branding.companyInfo')}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<Input
|
<Input
|
||||||
label={t('branding.companyName')}
|
label={t('branding.companyName')}
|
||||||
@@ -299,22 +299,22 @@ export const BrandingPage: React.FC = () => {
|
|||||||
helperText={t('branding.supportEmailHelp')}
|
helperText={t('branding.supportEmailHelp')}
|
||||||
/>
|
/>
|
||||||
<div>
|
<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('branding.footerText')}
|
{t('branding.footerText')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={brandingSettings.footer_text}
|
value={brandingSettings.footer_text}
|
||||||
onChange={(e) => handleBrandingChange('footer_text', e.target.value)}
|
onChange={(e) => handleBrandingChange('footer_text', e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
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-lg focus:ring-2 focus:ring-primary-500"
|
||||||
rows={2}
|
rows={2}
|
||||||
placeholder={`© ${new Date().getFullYear()} Your Company. All rights reserved.`}
|
placeholder={`© ${new Date().getFullYear()} Your Company. All rights reserved.`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-neutral-200">
|
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<div>
|
<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('branding.favicon')}
|
{t('branding.favicon')}
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -325,7 +325,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
alt="Current favicon"
|
alt="Current favicon"
|
||||||
className="w-8 h-8"
|
className="w-8 h-8"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-neutral-600">{t('branding.currentFavicon')}</span>
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('branding.currentFavicon')}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -351,20 +351,20 @@ export const BrandingPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{t('branding.uploadFavicon')}
|
{t('branding.uploadFavicon')}
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-neutral-600 mt-1">{t('branding.faviconHelp')}</p>
|
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">{t('branding.faviconHelp')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logo Customization Settings */}
|
{/* Logo Customization Settings */}
|
||||||
<div className="mt-6 pt-6 border-t border-neutral-200">
|
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<h3 className="text-md font-semibold text-neutral-900 mb-4">{t('branding.logoCustomization', 'Logo Customization')}</h3>
|
<h3 className="text-md font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('branding.logoCustomization', 'Logo Customization')}</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Logo Upload */}
|
{/* Logo Upload */}
|
||||||
<div>
|
<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('branding.logo', 'Logo')}
|
{t('branding.logo', 'Logo')}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -373,7 +373,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
<img
|
<img
|
||||||
src={brandingSettings.logo_url.startsWith('http') ? brandingSettings.logo_url : buildResourceUrl(brandingSettings.logo_url)}
|
src={brandingSettings.logo_url.startsWith('http') ? brandingSettings.logo_url : buildResourceUrl(brandingSettings.logo_url)}
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
className="h-16 object-contain bg-neutral-100 rounded p-2"
|
className="h-16 object-contain bg-neutral-100 dark:bg-neutral-700 rounded p-2"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -402,19 +402,19 @@ export const BrandingPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-600 mt-1">
|
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||||
{t('branding.logoHelp', 'PNG, JPG or SVG format, recommended width: 200px')}
|
{t('branding.logoHelp', 'PNG, JPG or SVG format, recommended width: 200px')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Logo Size */}
|
{/* Logo Size */}
|
||||||
<div>
|
<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('branding.logoSize', 'Logo Size')}
|
{t('branding.logoSize', 'Logo Size')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={brandingSettings.logo_size || 'medium'}
|
value={brandingSettings.logo_size || 'medium'}
|
||||||
onChange={(e) => handleBrandingChange('logo_size', e.target.value)}
|
onChange={(e) => handleBrandingChange('logo_size', e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
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-lg focus:ring-2 focus:ring-primary-500"
|
||||||
>
|
>
|
||||||
<option value="small">{t('branding.logoSizeSmall', 'Small (32px)')}</option>
|
<option value="small">{t('branding.logoSizeSmall', 'Small (32px)')}</option>
|
||||||
<option value="medium">{t('branding.logoSizeMedium', 'Medium (48px)')}</option>
|
<option value="medium">{t('branding.logoSizeMedium', 'Medium (48px)')}</option>
|
||||||
@@ -427,7 +427,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
{/* Custom Height (only shown when size is custom) */}
|
{/* Custom Height (only shown when size is custom) */}
|
||||||
{brandingSettings.logo_size === 'custom' && (
|
{brandingSettings.logo_size === 'custom' && (
|
||||||
<div>
|
<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('branding.logoMaxHeight', 'Maximum Height (pixels)')}
|
{t('branding.logoMaxHeight', 'Maximum Height (pixels)')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -436,9 +436,9 @@ export const BrandingPage: React.FC = () => {
|
|||||||
max="200"
|
max="200"
|
||||||
value={brandingSettings.logo_max_height || 48}
|
value={brandingSettings.logo_max_height || 48}
|
||||||
onChange={(e) => handleBrandingChange('logo_max_height', parseInt(e.target.value))}
|
onChange={(e) => handleBrandingChange('logo_max_height', parseInt(e.target.value))}
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
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-lg focus:ring-2 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-neutral-600 mt-1">
|
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||||
{t('branding.logoMaxHeightHelp', 'Set a custom maximum height for the logo (20-200 pixels)')}
|
{t('branding.logoMaxHeightHelp', 'Set a custom maximum height for the logo (20-200 pixels)')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,7 +446,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Logo Position */}
|
{/* Logo Position */}
|
||||||
<div>
|
<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('branding.logoPosition', 'Logo Position in Header')}
|
{t('branding.logoPosition', 'Logo Position in Header')}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -458,7 +458,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
brandingSettings.logo_position === position
|
brandingSettings.logo_position === position
|
||||||
? 'bg-primary-600 text-white'
|
? 'bg-primary-600 text-white'
|
||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`branding.position${position.charAt(0).toUpperCase() + position.slice(1)}`, position.charAt(0).toUpperCase() + position.slice(1))}
|
{t(`branding.position${position.charAt(0).toUpperCase() + position.slice(1)}`, position.charAt(0).toUpperCase() + position.slice(1))}
|
||||||
@@ -469,13 +469,13 @@ export const BrandingPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Display Mode */}
|
{/* Display Mode */}
|
||||||
<div>
|
<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('branding.logoDisplayMode', 'Display Mode')}
|
{t('branding.logoDisplayMode', 'Display Mode')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={brandingSettings.logo_display_mode || 'logo_and_text'}
|
value={brandingSettings.logo_display_mode || 'logo_and_text'}
|
||||||
onChange={(e) => handleBrandingChange('logo_display_mode', e.target.value)}
|
onChange={(e) => handleBrandingChange('logo_display_mode', e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
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-lg focus:ring-2 focus:ring-primary-500"
|
||||||
>
|
>
|
||||||
<option value="logo_only">{t('branding.logoOnly', 'Logo Only')}</option>
|
<option value="logo_only">{t('branding.logoOnly', 'Logo Only')}</option>
|
||||||
<option value="text_only">{t('branding.textOnly', 'Company Name Only')}</option>
|
<option value="text_only">{t('branding.textOnly', 'Company Name Only')}</option>
|
||||||
@@ -490,13 +490,13 @@ export const BrandingPage: React.FC = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={brandingSettings.logo_display_header !== false}
|
checked={brandingSettings.logo_display_header !== false}
|
||||||
onChange={(e) => handleBrandingChange('logo_display_header', e.target.checked)}
|
onChange={(e) => handleBrandingChange('logo_display_header', e.target.checked)}
|
||||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-neutral-900">
|
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{t('branding.showLogoInHeader', 'Show logo in gallery header')}
|
{t('branding.showLogoInHeader', 'Show logo in gallery header')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-neutral-600">
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">
|
||||||
{t('branding.showLogoInHeaderHelp', 'Display the logo in the main header bar')}
|
{t('branding.showLogoInHeaderHelp', 'Display the logo in the main header bar')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -507,13 +507,13 @@ export const BrandingPage: React.FC = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={brandingSettings.logo_display_hero !== false}
|
checked={brandingSettings.logo_display_hero !== false}
|
||||||
onChange={(e) => handleBrandingChange('logo_display_hero', e.target.checked)}
|
onChange={(e) => handleBrandingChange('logo_display_hero', e.target.checked)}
|
||||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-neutral-900">
|
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{t('branding.showLogoInHero', 'Show logo in hero section')}
|
{t('branding.showLogoInHero', 'Show logo in hero section')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-neutral-600">
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">
|
||||||
{t('branding.showLogoInHeroHelp', 'Display the logo in hero sections (for non-grid layouts)')}
|
{t('branding.showLogoInHeroHelp', 'Display the logo in hero sections (for non-grid layouts)')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,49 +523,49 @@ export const BrandingPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* White Label Settings */}
|
{/* White Label Settings */}
|
||||||
<div className="mt-6 pt-6 border-t border-neutral-200">
|
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<h3 className="text-md font-semibold text-neutral-900 mb-4">{t('branding.whiteLabel', 'White Label')}</h3>
|
<h3 className="text-md font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('branding.whiteLabel', 'White Label')}</h3>
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={brandingSettings.hide_powered_by === true}
|
checked={brandingSettings.hide_powered_by === true}
|
||||||
onChange={(e) => handleBrandingChange('hide_powered_by', e.target.checked)}
|
onChange={(e) => handleBrandingChange('hide_powered_by', e.target.checked)}
|
||||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-neutral-900">
|
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{t('branding.hidePoweredBy', 'Hide "Powered by PicPeak" branding')}
|
{t('branding.hidePoweredBy', 'Hide "Powered by PicPeak" branding')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-neutral-600">
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">
|
||||||
{t('branding.hidePoweredByHelp', 'Remove the PicPeak attribution from gallery footers for a fully white-labeled experience')}
|
{t('branding.hidePoweredByHelp', 'Remove the PicPeak attribution from gallery footers for a fully white-labeled experience')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-neutral-200">
|
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={brandingSettings.watermark_enabled}
|
checked={brandingSettings.watermark_enabled}
|
||||||
onChange={(e) => handleBrandingChange('watermark_enabled', e.target.checked)}
|
onChange={(e) => handleBrandingChange('watermark_enabled', e.target.checked)}
|
||||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-neutral-900">{t('branding.enableWatermarks')}</span>
|
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{t('branding.enableWatermarks')}</span>
|
||||||
<p className="text-xs text-neutral-600">{t('branding.watermarkHelp')}</p>
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.watermarkHelp')}</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Watermark Settings */}
|
{/* Watermark Settings */}
|
||||||
{brandingSettings.watermark_enabled && (
|
{brandingSettings.watermark_enabled && (
|
||||||
<div className="mt-6 space-y-6 border-t border-neutral-200 pt-6">
|
<div className="mt-6 space-y-6 border-t border-neutral-200 dark:border-neutral-700 pt-6">
|
||||||
<h3 className="text-md font-semibold text-neutral-900">{t('branding.watermarkSettings')}</h3>
|
<h3 className="text-md font-semibold text-neutral-900 dark:text-neutral-100">{t('branding.watermarkSettings')}</h3>
|
||||||
|
|
||||||
{/* Watermark Logo Upload */}
|
{/* Watermark Logo Upload */}
|
||||||
<div>
|
<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('branding.watermarkLogo')}
|
{t('branding.watermarkLogo')}
|
||||||
</label>
|
</label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -574,9 +574,9 @@ export const BrandingPage: React.FC = () => {
|
|||||||
<img
|
<img
|
||||||
src={brandingSettings.watermark_logo_url.startsWith('http') ? brandingSettings.watermark_logo_url : buildResourceUrl(brandingSettings.watermark_logo_url)}
|
src={brandingSettings.watermark_logo_url.startsWith('http') ? brandingSettings.watermark_logo_url : buildResourceUrl(brandingSettings.watermark_logo_url)}
|
||||||
alt="Current watermark"
|
alt="Current watermark"
|
||||||
className="h-16 w-auto object-contain bg-neutral-100 p-2 rounded"
|
className="h-16 w-auto object-contain bg-neutral-100 dark:bg-neutral-700 p-2 rounded"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-neutral-600">{t('branding.currentWatermark')}</span>
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('branding.currentWatermark')}</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -604,14 +604,14 @@ export const BrandingPage: React.FC = () => {
|
|||||||
{t('branding.uploadWatermarkLogo')}
|
{t('branding.uploadWatermarkLogo')}
|
||||||
</Button>
|
</Button>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-neutral-600 mt-1">{t('branding.watermarkHelp')}</p>
|
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">{t('branding.watermarkHelp')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Position Selector */}
|
{/* Position Selector */}
|
||||||
<div>
|
<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('branding.watermarkPosition')}
|
{t('branding.watermarkPosition')}
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-2 max-w-xs">
|
<div className="grid grid-cols-3 gap-2 max-w-xs">
|
||||||
@@ -629,7 +629,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
|
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
|
||||||
brandingSettings.watermark_position === position.value
|
brandingSettings.watermark_position === position.value
|
||||||
? 'bg-primary-600 text-white border-primary-600'
|
? 'bg-primary-600 text-white border-primary-600'
|
||||||
: 'bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-50'
|
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{position.label}
|
{position.label}
|
||||||
@@ -640,7 +640,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Opacity Slider */}
|
{/* Opacity Slider */}
|
||||||
<div>
|
<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('branding.watermarkOpacity')}: {brandingSettings.watermark_opacity || 50}%
|
{t('branding.watermarkOpacity')}: {brandingSettings.watermark_opacity || 50}%
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -660,7 +660,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
outline: 'none'
|
outline: 'none'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-xs text-neutral-500 mt-1">
|
<div className="flex justify-between text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
<span>10%</span>
|
<span>10%</span>
|
||||||
<span>50%</span>
|
<span>50%</span>
|
||||||
<span>100%</span>
|
<span>100%</span>
|
||||||
@@ -669,7 +669,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Size Slider */}
|
{/* Size Slider */}
|
||||||
<div>
|
<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('branding.watermarkSize')}: {brandingSettings.watermark_size || 15}%
|
{t('branding.watermarkSize')}: {brandingSettings.watermark_size || 15}%
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -689,7 +689,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
outline: 'none'
|
outline: 'none'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-xs text-neutral-500 mt-1">
|
<div className="flex justify-between text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
<span>5%</span>
|
<span>5%</span>
|
||||||
<span>15%</span>
|
<span>15%</span>
|
||||||
<span>30%</span>
|
<span>30%</span>
|
||||||
@@ -701,7 +701,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Theme Customization */}
|
{/* Theme Customization */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<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">
|
||||||
<Palette className="w-5 h-5" />
|
<Palette className="w-5 h-5" />
|
||||||
{t('branding.galleryTheme')}
|
{t('branding.galleryTheme')}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -711,9 +711,9 @@ export const BrandingPage: React.FC = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isPreviewMode}
|
checked={isPreviewMode}
|
||||||
onChange={(e) => setIsPreviewMode(e.target.checked)}
|
onChange={(e) => setIsPreviewMode(e.target.checked)}
|
||||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-neutral-700">{t('branding.applyLivePreview')}</span>
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('branding.applyLivePreview')}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
@@ -733,7 +733,7 @@ export const BrandingPage: React.FC = () => {
|
|||||||
{/* Right side - Gallery Preview */}
|
{/* Right side - Gallery Preview */}
|
||||||
<div className="lg:sticky lg:top-4 lg:h-fit">
|
<div className="lg:sticky lg:top-4 lg:h-fit">
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h3 className="text-sm font-medium text-neutral-700 mb-3">
|
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-3">
|
||||||
{t('branding.livePreview')}
|
{t('branding.livePreview')}
|
||||||
</h3>
|
</h3>
|
||||||
<GalleryPreview
|
<GalleryPreview
|
||||||
@@ -747,12 +747,12 @@ export const BrandingPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Event-Specific Themes Info */}
|
{/* Event-Specific Themes Info */}
|
||||||
<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">
|
<div className="flex items-start gap-3">
|
||||||
<Palette className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
<Palette className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-blue-900">{t('branding.eventSpecificThemes')}</h3>
|
<h3 className="text-sm font-medium text-blue-900 dark:text-blue-200">{t('branding.eventSpecificThemes')}</h3>
|
||||||
<p className="text-sm text-blue-700 mt-1">
|
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||||
{t('branding.eventThemesInfo')}
|
{t('branding.eventThemesInfo')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -325,8 +325,8 @@ export const CMSPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-neutral-900">{t('cms.title')}</h1>
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('cms.title')}</h1>
|
||||||
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('cms.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -337,8 +337,8 @@ export const CMSPage: React.FC = () => {
|
|||||||
<Globe className="w-5 h-5" />
|
<Globe className="w-5 h-5" />
|
||||||
<span className="text-sm font-semibold uppercase tracking-wide">{t('settings.publicSite.badge')}</span>
|
<span className="text-sm font-semibold uppercase tracking-wide">{t('settings.publicSite.badge')}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-semibold text-neutral-900">{t('settings.publicSite.title')}</h2>
|
<h2 className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">{t('settings.publicSite.title')}</h2>
|
||||||
<p className="text-neutral-600 mt-1 max-w-2xl">{t('settings.publicSite.subtitle')}</p>
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1 max-w-2xl">{t('settings.publicSite.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||||
<input
|
<input
|
||||||
@@ -359,7 +359,7 @@ export const CMSPage: React.FC = () => {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium text-neutral-700">
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
{publicSiteEnabled ? t('settings.publicSite.enabled') : t('settings.publicSite.disabled')}
|
{publicSiteEnabled ? t('settings.publicSite.enabled') : t('settings.publicSite.disabled')}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -373,35 +373,35 @@ export const CMSPage: React.FC = () => {
|
|||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
|
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 dark:text-neutral-200 mb-2">
|
||||||
<Sparkles className="w-4 h-4 text-primary-500" />
|
<Sparkles className="w-4 h-4 text-primary-500" />
|
||||||
{t('settings.publicSite.htmlLabel')}
|
{t('settings.publicSite.htmlLabel')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full h-64 font-mono text-sm rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 disabled:text-neutral-500"
|
className="w-full h-64 font-mono text-sm rounded-lg border border-neutral-300 dark:border-neutral-600 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 disabled:bg-neutral-100 dark:disabled:bg-neutral-700 disabled:text-neutral-500 dark:disabled:text-neutral-400"
|
||||||
value={publicSiteHtml}
|
value={publicSiteHtml}
|
||||||
onChange={(event) => setPublicSiteHtml(event.target.value)}
|
onChange={(event) => setPublicSiteHtml(event.target.value)}
|
||||||
disabled={!publicSiteEnabled}
|
disabled={!publicSiteEnabled}
|
||||||
placeholder={t('settings.publicSite.htmlPlaceholder') || ''}
|
placeholder={t('settings.publicSite.htmlPlaceholder') || ''}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
{t('settings.publicSite.htmlHelp')}
|
{t('settings.publicSite.htmlHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
|
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 dark:text-neutral-200 mb-2">
|
||||||
<ShieldCheck className="w-4 h-4 text-primary-500" />
|
<ShieldCheck className="w-4 h-4 text-primary-500" />
|
||||||
{t('settings.publicSite.cssLabel')}
|
{t('settings.publicSite.cssLabel')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full h-48 font-mono text-sm rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 disabled:text-neutral-500"
|
className="w-full h-48 font-mono text-sm rounded-lg border border-neutral-300 dark:border-neutral-600 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 disabled:bg-neutral-100 dark:disabled:bg-neutral-700 disabled:text-neutral-500 dark:disabled:text-neutral-400"
|
||||||
value={publicSiteCss}
|
value={publicSiteCss}
|
||||||
onChange={(event) => setPublicSiteCss(event.target.value)}
|
onChange={(event) => setPublicSiteCss(event.target.value)}
|
||||||
disabled={!publicSiteEnabled}
|
disabled={!publicSiteEnabled}
|
||||||
placeholder={t('settings.publicSite.cssPlaceholder') || ''}
|
placeholder={t('settings.publicSite.cssPlaceholder') || ''}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
{t('settings.publicSite.cssHelp')}
|
{t('settings.publicSite.cssHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,7 +425,7 @@ export const CMSPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg bg-neutral-50 border border-neutral-200 p-3 text-xs text-neutral-600 leading-relaxed">
|
<div className="rounded-lg bg-neutral-50 dark:bg-neutral-800/50 border border-neutral-200 dark:border-neutral-700 p-3 text-xs text-neutral-600 dark:text-neutral-400 leading-relaxed">
|
||||||
<p className="font-semibold mb-1">{t('settings.publicSite.sanitizationNotice')}</p>
|
<p className="font-semibold mb-1">{t('settings.publicSite.sanitizationNotice')}</p>
|
||||||
<p>{t('settings.publicSite.htmlHelp')}</p>
|
<p>{t('settings.publicSite.htmlHelp')}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -433,10 +433,10 @@ export const CMSPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-neutral-800 uppercase tracking-wide">
|
<h3 className="text-sm font-semibold text-neutral-800 dark:text-neutral-200 uppercase tracking-wide">
|
||||||
{t('settings.publicSite.previewTitle')}
|
{t('settings.publicSite.previewTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-xs text-neutral-500">{t('settings.publicSite.previewSandboxed')}</span>
|
<span className="text-xs text-neutral-500 dark:text-neutral-400">{t('settings.publicSite.previewSandboxed')}</span>
|
||||||
</div>
|
</div>
|
||||||
{publicSiteEnabled ? (
|
{publicSiteEnabled ? (
|
||||||
<div className="rounded-xl border border-neutral-200 overflow-hidden shadow-sm bg-white">
|
<div className="rounded-xl border border-neutral-200 overflow-hidden shadow-sm bg-white">
|
||||||
@@ -448,7 +448,7 @@ export const CMSPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-dashed border-neutral-300 bg-neutral-50 p-8 text-center text-sm text-neutral-500">
|
<div className="rounded-xl border border-dashed border-neutral-300 dark:border-neutral-600 bg-neutral-50 dark:bg-neutral-800/50 p-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
{t('settings.publicSite.previewDisabled')}
|
{t('settings.publicSite.previewDisabled')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -462,7 +462,7 @@ export const CMSPage: React.FC = () => {
|
|||||||
{/* Page Selection */}
|
{/* Page Selection */}
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('cms.pages')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('cms.pages')}</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{pages?.map((page) => (
|
{pages?.map((page) => (
|
||||||
<button
|
<button
|
||||||
@@ -477,14 +477,14 @@ export const CMSPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
|
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
|
||||||
selectedPage === page.slug
|
selectedPage === page.slug
|
||||||
? 'bg-primary-100 text-primary-700 border border-primary-300'
|
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300 border border-primary-300 dark:border-primary-700'
|
||||||
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
|
: 'bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-700 text-neutral-900 dark:text-neutral-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText className="w-5 h-5 flex-shrink-0" />
|
<FileText className="w-5 h-5 flex-shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium truncate">{t(`legal.${page.slug}`)}</p>
|
<p className="font-medium truncate">{t(`legal.${page.slug}`)}</p>
|
||||||
<p className="text-sm text-neutral-500">/{page.slug}</p>
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">/{page.slug}</p>
|
||||||
</div>
|
</div>
|
||||||
{selectedPage === page.slug && hasUnsavedChanges && (
|
{selectedPage === page.slug && hasUnsavedChanges && (
|
||||||
<div className="w-2 h-2 bg-yellow-500 rounded-full flex-shrink-0" />
|
<div className="w-2 h-2 bg-yellow-500 rounded-full flex-shrink-0" />
|
||||||
@@ -495,7 +495,7 @@ export const CMSPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding="md" className="mt-4">
|
<Card padding="md" className="mt-4">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('cms.previewLinks')}</h3>
|
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('cms.previewLinks')}</h3>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<a
|
<a
|
||||||
href={`${window.location.origin}/${selectedPage}?lang=en`}
|
href={`${window.location.origin}/${selectedPage}?lang=en`}
|
||||||
@@ -549,7 +549,7 @@ export const CMSPage: React.FC = () => {
|
|||||||
<div className="lg:col-span-3">
|
<div className="lg:col-span-3">
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900">
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
{t('cms.editPage', { page: t(`legal.${selectedPage}`) })}
|
{t('cms.editPage', { page: t(`legal.${selectedPage}`) })}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -559,8 +559,8 @@ export const CMSPage: React.FC = () => {
|
|||||||
onClick={() => setEditingLang('en')}
|
onClick={() => setEditingLang('en')}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
editingLang === 'en'
|
editingLang === 'en'
|
||||||
? 'bg-primary-100 text-primary-700'
|
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
|
||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
English
|
English
|
||||||
@@ -569,8 +569,8 @@ export const CMSPage: React.FC = () => {
|
|||||||
onClick={() => setEditingLang('de')}
|
onClick={() => setEditingLang('de')}
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
editingLang === 'de'
|
editingLang === 'de'
|
||||||
? 'bg-primary-100 text-primary-700'
|
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
|
||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Deutsch
|
Deutsch
|
||||||
@@ -581,7 +581,7 @@ export const CMSPage: React.FC = () => {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<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('cms.pageTitle')} ({editingLang === 'en' ? 'English' : 'German'})
|
{t('cms.pageTitle')} ({editingLang === 'en' ? 'English' : 'German'})
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -593,7 +593,7 @@ export const CMSPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<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('cms.pageContent')} ({editingLang === 'en' ? 'English' : 'German'})
|
{t('cms.pageContent')} ({editingLang === 'en' ? 'English' : 'German'})
|
||||||
</label>
|
</label>
|
||||||
<CMSEditor
|
<CMSEditor
|
||||||
@@ -606,7 +606,7 @@ export const CMSPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentPage?.updated_at && (
|
{currentPage?.updated_at && (
|
||||||
<p className="text-xs text-neutral-500 mt-4">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-4">
|
||||||
{t('cms.lastUpdated')} {new Date(currentPage.updated_at).toLocaleString()}
|
{t('cms.lastUpdated')} {new Date(currentPage.updated_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{t('common.back')}
|
{t('common.back')}
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-2xl font-bold text-neutral-900">{t('events.create')}</h1>
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('events.create')}</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -373,13 +373,13 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
{/* Event Details */}
|
{/* Event Details */}
|
||||||
<Card>
|
<Card>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||||
<Calendar className="w-5 h-5" />
|
<Calendar className="w-5 h-5" />
|
||||||
{t('events.eventDetails')}
|
{t('events.eventDetails')}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div>
|
<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('events.eventType')}
|
{t('events.eventType')}
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
@@ -390,12 +390,12 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
onClick={() => setFormData({ ...formData, event_type: type.value })}
|
onClick={() => setFormData({ ...formData, event_type: type.value })}
|
||||||
className={`p-4 rounded-lg border-2 transition-all ${
|
className={`p-4 rounded-lg border-2 transition-all ${
|
||||||
formData.event_type === type.value
|
formData.event_type === type.value
|
||||||
? 'border-primary-600 bg-primary-50'
|
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-neutral-200 hover:border-neutral-300'
|
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="text-2xl mb-1">{type.emoji}</div>
|
<div className="text-2xl mb-1">{type.emoji}</div>
|
||||||
<div className="text-sm font-medium">{type.name}</div>
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{type.name}</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -422,7 +422,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<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('events.welcomeMessage')}
|
{t('events.welcomeMessage')}
|
||||||
</label>
|
</label>
|
||||||
<WelcomeMessageEditor
|
<WelcomeMessageEditor
|
||||||
@@ -439,7 +439,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||||
<Palette className="w-5 h-5" />
|
<Palette className="w-5 h-5" />
|
||||||
{t('events.themeAndStyle')}
|
{t('events.themeAndStyle')}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -456,14 +456,9 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Quick Theme Preview */}
|
{/* Quick Theme Preview */}
|
||||||
{!showThemeCustomizer && (
|
{!showThemeCustomizer && (
|
||||||
<div className="p-4 rounded-lg border border-neutral-200"
|
<div className="p-4 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
|
||||||
style={{
|
|
||||||
backgroundColor: formData.theme_config.backgroundColor,
|
|
||||||
color: formData.theme_config.textColor
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="font-semibold" style={{ fontFamily: formData.theme_config.fontFamily }}>
|
<h3 className="font-semibold text-neutral-900 dark:text-neutral-100" style={{ fontFamily: formData.theme_config.fontFamily }}>
|
||||||
{GALLERY_THEME_PRESETS[formData.theme_preset]?.name || 'Custom Theme'}
|
{GALLERY_THEME_PRESETS[formData.theme_preset]?.name || 'Custom Theme'}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -477,7 +472,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm opacity-80">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
Gallery Layout: <span className="font-medium capitalize">{formData.theme_config.galleryLayout || 'grid'}</span>
|
Gallery Layout: <span className="font-medium capitalize">{formData.theme_config.galleryLayout || 'grid'}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -511,12 +506,12 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Custom CSS Template Selection */}
|
{/* Custom CSS Template Selection */}
|
||||||
{cssTemplates && cssTemplates.length > 0 && (
|
{cssTemplates && cssTemplates.length > 0 && (
|
||||||
<div className="pt-6 border-t border-neutral-200">
|
<div className="pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<h3 className="text-md font-semibold text-neutral-900 mb-3 flex items-center gap-2">
|
<h3 className="text-md font-semibold text-neutral-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
|
||||||
<Code className="w-4 h-4" />
|
<Code className="w-4 h-4" />
|
||||||
{t('events.customCssTemplate', 'Custom CSS Template')}
|
{t('events.customCssTemplate', 'Custom CSS Template')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-neutral-600 mb-4">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
{t('events.customCssTemplateDesc', 'Apply a custom CSS template to style the gallery with unique visual effects.')}
|
{t('events.customCssTemplateDesc', 'Apply a custom CSS template to style the gallery with unique visual effects.')}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
@@ -526,12 +521,12 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
onClick={() => setFormData({ ...formData, css_template_id: null })}
|
onClick={() => setFormData({ ...formData, css_template_id: null })}
|
||||||
className={`p-4 rounded-lg border-2 transition-all text-left ${
|
className={`p-4 rounded-lg border-2 transition-all text-left ${
|
||||||
formData.css_template_id === null
|
formData.css_template_id === null
|
||||||
? 'border-primary-600 bg-primary-50'
|
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-neutral-200 hover:border-neutral-300'
|
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-medium text-sm">{t('events.noTemplate', 'No Template')}</div>
|
<div className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{t('events.noTemplate', 'No Template')}</div>
|
||||||
<div className="text-xs text-neutral-500 mt-1">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
{t('events.useThemeOnly', 'Use theme preset only')}
|
{t('events.useThemeOnly', 'Use theme preset only')}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -544,12 +539,12 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
onClick={() => setFormData({ ...formData, css_template_id: template.id })}
|
onClick={() => setFormData({ ...formData, css_template_id: template.id })}
|
||||||
className={`p-4 rounded-lg border-2 transition-all text-left ${
|
className={`p-4 rounded-lg border-2 transition-all text-left ${
|
||||||
formData.css_template_id === template.id
|
formData.css_template_id === template.id
|
||||||
? 'border-primary-600 bg-primary-50'
|
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-neutral-200 hover:border-neutral-300'
|
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-medium text-sm">{template.name}</div>
|
<div className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{template.name}</div>
|
||||||
<div className="text-xs text-neutral-500 mt-1">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
{t('events.customTemplate', 'Custom Template')} {template.slot_number}
|
{t('events.customTemplate', 'Custom Template')} {template.slot_number}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -563,7 +558,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
{/* Access & Security */}
|
{/* Access & Security */}
|
||||||
<Card>
|
<Card>
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||||
<Lock className="w-5 h-5" />
|
<Lock className="w-5 h-5" />
|
||||||
{t('events.accessAndSecurity')}
|
{t('events.accessAndSecurity')}
|
||||||
</h2>
|
</h2>
|
||||||
@@ -605,7 +600,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
<label className="flex items-start gap-2">
|
<label className="flex items-start gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||||
checked={formData.require_password}
|
checked={formData.require_password}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const checked = e.target.checked;
|
const checked = e.target.checked;
|
||||||
@@ -621,17 +616,17 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-neutral-700">
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
{t('events.requirePasswordToggle')}
|
{t('events.requirePasswordToggle')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
|
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{!formData.require_password && (
|
{!formData.require_password && (
|
||||||
<div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
|
<div className="rounded-md border border-orange-200 dark:border-orange-800 bg-orange-50 dark:bg-orange-900/30 p-3 text-xs text-orange-800 dark:text-orange-300">
|
||||||
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -687,7 +682,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
|
|
||||||
{requireExpiration ? (
|
{requireExpiration ? (
|
||||||
<div>
|
<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('events.galleryExpiration')}
|
{t('events.galleryExpiration')}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -702,40 +697,40 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
leftIcon={<Clock className="w-5 h-5" />}
|
leftIcon={<Clock className="w-5 h-5" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.daysAfterEvent')}</span>
|
||||||
</div>
|
</div>
|
||||||
{formData.event_date && (
|
{formData.event_date && (
|
||||||
<p className="mt-2 text-sm text-neutral-500">
|
<p className="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
|
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border border-blue-200 bg-blue-50 p-3">
|
<div className="rounded-md border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/30 p-3">
|
||||||
<div className="flex items-center gap-2 text-blue-800">
|
<div className="flex items-center gap-2 text-blue-800 dark:text-blue-300">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
<span className="text-sm font-medium">{t('events.noExpiration', 'No Expiration')}</span>
|
<span className="text-sm font-medium">{t('events.noExpiration', 'No Expiration')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-blue-700">
|
<p className="mt-1 text-xs text-blue-700 dark:text-blue-400">
|
||||||
{t('events.noExpirationHelp', 'This gallery will remain active until manually archived.')}
|
{t('events.noExpirationHelp', 'This gallery will remain active until manually archived.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User Upload Settings */}
|
{/* User Upload Settings */}
|
||||||
<div className="pt-4 border-t border-neutral-200">
|
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<label className="flex items-center gap-3">
|
<label className="flex items-center gap-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={formData.allow_user_uploads}
|
checked={formData.allow_user_uploads}
|
||||||
onChange={(e) => setFormData({ ...formData, allow_user_uploads: e.target.checked })}
|
onChange={(e) => setFormData({ ...formData, allow_user_uploads: e.target.checked })}
|
||||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-neutral-700">
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
{t('events.allowUserUploads')}
|
{t('events.allowUserUploads')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-xs text-neutral-500 mt-0.5">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||||
{t('events.allowUserUploadsDescription')}
|
{t('events.allowUserUploadsDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -743,7 +738,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
|
|
||||||
{formData.allow_user_uploads && categories && categories.length > 0 && (
|
{formData.allow_user_uploads && categories && categories.length > 0 && (
|
||||||
<div className="mt-4 ml-7">
|
<div className="mt-4 ml-7">
|
||||||
<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('events.uploadCategory')}
|
{t('events.uploadCategory')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -752,7 +747,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
...formData,
|
...formData,
|
||||||
upload_category_id: e.target.value ? Number(e.target.value) : null
|
upload_category_id: e.target.value ? Number(e.target.value) : null
|
||||||
})}
|
})}
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg focus:ring-2 focus:ring-primary-500 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
||||||
>
|
>
|
||||||
<option value="">{t('events.selectCategory')}</option>
|
<option value="">{t('events.selectCategory')}</option>
|
||||||
{categories.map(category => (
|
{categories.map(category => (
|
||||||
@@ -761,7 +756,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="mt-1 text-xs text-neutral-500">
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('events.uploadCategoryHelp')}
|
{t('events.uploadCategoryHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -256,16 +256,16 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
const renderVariableHelp = () => {
|
const renderVariableHelp = () => {
|
||||||
const variables = editedTemplate.variables || [];
|
const variables = editedTemplate.variables || [];
|
||||||
return (
|
return (
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
<h4 className="text-sm font-semibold text-blue-900 mb-2">{t('email.templateVariables')}</h4>
|
<h4 className="text-sm font-semibold text-blue-900 dark:text-blue-200 mb-2">{t('email.templateVariables')}</h4>
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
{variables.map(variable => (
|
{variables.map(variable => (
|
||||||
<code key={variable} className="text-blue-700 bg-blue-100 px-2 py-1 rounded">
|
<code key={variable} className="text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 px-2 py-1 rounded">
|
||||||
{`{{${variable}}}`}
|
{`{{${variable}}}`}
|
||||||
</code>
|
</code>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-blue-700 mt-2">
|
<p className="text-xs text-blue-700 dark:text-blue-300 mt-2">
|
||||||
{t('email.variableHelp')}
|
{t('email.variableHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,19 +283,19 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-neutral-900">{t('email.title')}</h1>
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('email.title')}</h1>
|
||||||
<p className="text-neutral-600 mt-1">{t('email.subtitle')}</p>
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('email.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className="border-b border-neutral-200 mb-6">
|
<div className="border-b border-neutral-200 dark:border-neutral-700 mb-6">
|
||||||
<nav className="-mb-px flex gap-6">
|
<nav className="-mb-px flex gap-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('smtp')}
|
onClick={() => setActiveTab('smtp')}
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'smtp'
|
activeTab === 'smtp'
|
||||||
? 'border-primary-600 text-primary-600'
|
? 'border-primary-600 text-primary-600'
|
||||||
: 'border-transparent text-neutral-500 hover:text-neutral-700'
|
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('email.smtpSettings')}
|
{t('email.smtpSettings')}
|
||||||
@@ -305,7 +305,7 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === 'templates'
|
activeTab === 'templates'
|
||||||
? 'border-primary-600 text-primary-600'
|
? 'border-primary-600 text-primary-600'
|
||||||
: 'border-transparent text-neutral-500 hover:text-neutral-700'
|
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('email.emailTemplates')}
|
{t('email.emailTemplates')}
|
||||||
@@ -317,11 +317,11 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
{activeTab === 'smtp' && (
|
{activeTab === 'smtp' && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('email.smtpConfiguration')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('email.smtpConfiguration')}</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<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('email.smtpHost')} <span className="text-red-500">*</span>
|
{t('email.smtpHost')} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -335,7 +335,7 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<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('email.port')} <span className="text-red-500">*</span>
|
{t('email.port')} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -347,13 +347,13 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
</div>
|
</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('email.security')}
|
{t('email.security')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={smtpConfig.smtp_secure ? 'ssl' : 'tls'}
|
value={smtpConfig.smtp_secure ? 'ssl' : 'tls'}
|
||||||
onChange={(e) => setSmtpConfig(prev => ({ ...prev, smtp_secure: e.target.value === 'ssl' }))}
|
onChange={(e) => setSmtpConfig(prev => ({ ...prev, smtp_secure: e.target.value === 'ssl' }))}
|
||||||
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 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
>
|
>
|
||||||
<option value="tls">TLS</option>
|
<option value="tls">TLS</option>
|
||||||
<option value="ssl">SSL</option>
|
<option value="ssl">SSL</option>
|
||||||
@@ -368,17 +368,17 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!smtpConfig.tls_reject_unauthorized}
|
checked={!smtpConfig.tls_reject_unauthorized}
|
||||||
onChange={(e) => setSmtpConfig(prev => ({ ...prev, tls_reject_unauthorized: !e.target.checked }))}
|
onChange={(e) => setSmtpConfig(prev => ({ ...prev, tls_reject_unauthorized: !e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium text-neutral-700">
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||||
{t('email.ignoreSslErrors')}
|
{t('email.ignoreSslErrors')}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{!smtpConfig.tls_reject_unauthorized && (
|
{!smtpConfig.tls_reject_unauthorized && (
|
||||||
<div className="mt-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
<div className="mt-2 p-3 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<ShieldAlert className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
|
<ShieldAlert className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||||
<p className="text-xs text-amber-800">
|
<p className="text-xs text-amber-800 dark:text-amber-300">
|
||||||
{t('email.ignoreSslWarning')}
|
{t('email.ignoreSslWarning')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -386,7 +386,7 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
)}</div>
|
)}</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('email.username')}
|
{t('email.username')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -399,7 +399,7 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
</div>
|
</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('email.password')}
|
{t('email.password')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -421,7 +421,7 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
</div>
|
</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('email.fromEmail')} <span className="text-red-500">*</span>
|
{t('email.fromEmail')} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -434,7 +434,7 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
</div>
|
</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('email.fromName')}
|
{t('email.fromName')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -458,12 +458,12 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('email.testEmailSection')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('email.testEmailSection')}</h2>
|
||||||
|
|
||||||
<div className="mb-4 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
<div className="mb-4 p-4 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0" />
|
<AlertCircle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0" />
|
||||||
<div className="text-sm text-amber-800">
|
<div className="text-sm text-amber-800 dark:text-amber-300">
|
||||||
<p className="font-medium">{t('email.beforeTesting')}</p>
|
<p className="font-medium">{t('email.beforeTesting')}</p>
|
||||||
<ul className="list-disc list-inside mt-1">
|
<ul className="list-disc list-inside mt-1">
|
||||||
<li>{t('email.saveSmtpFirst')}</li>
|
<li>{t('email.saveSmtpFirst')}</li>
|
||||||
@@ -476,7 +476,7 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<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('email.testEmailAddressLabel')}
|
{t('email.testEmailAddressLabel')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -499,10 +499,10 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
<div className="mt-6 p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<CheckCircle className="w-5 h-5 text-green-600 flex-shrink-0" />
|
<CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0" />
|
||||||
<div className="text-sm text-green-800">
|
<div className="text-sm text-green-800 dark:text-green-300">
|
||||||
<p className="font-medium">{t('email.commonSmtpSettings')}</p>
|
<p className="font-medium">{t('email.commonSmtpSettings')}</p>
|
||||||
<ul className="mt-2 space-y-1">
|
<ul className="mt-2 space-y-1">
|
||||||
<li><strong>Gmail:</strong> smtp.gmail.com:587 (TLS)</li>
|
<li><strong>Gmail:</strong> smtp.gmail.com:587 (TLS)</li>
|
||||||
@@ -520,7 +520,7 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
{activeTab === 'templates' && (
|
{activeTab === 'templates' && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<Card padding="sm">
|
<Card padding="sm">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">{t('email.templates')}</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('email.templates')}</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{templates.map(template => {
|
{templates.map(template => {
|
||||||
const templateInfo = defaultTemplateKeys.find(t => t.key === template.template_key);
|
const templateInfo = defaultTemplateKeys.find(t => t.key === template.template_key);
|
||||||
@@ -533,14 +533,14 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
className={`w-full text-left p-3 rounded-lg transition-colors ${
|
className={`w-full text-left p-3 rounded-lg transition-colors ${
|
||||||
selectedTemplateKey === template.template_key
|
selectedTemplateKey === template.template_key
|
||||||
? 'bg-primary-50 border-2 border-primary-600'
|
? 'bg-primary-50 dark:bg-primary-900/30 border-2 border-primary-600'
|
||||||
: 'bg-neutral-50 border-2 border-transparent hover:bg-neutral-100'
|
: 'bg-neutral-50 dark:bg-neutral-700 border-2 border-transparent hover:bg-neutral-100 dark:hover:bg-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="font-medium text-neutral-900">
|
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{templateInfo?.name || template.template_key}
|
{templateInfo?.name || template.template_key}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-neutral-500 mt-1 truncate">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1 truncate">
|
||||||
{template.subject_en || template.subject}
|
{template.subject_en || template.subject}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
@@ -552,15 +552,15 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">{t('email.editTemplate')}</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('email.editTemplate')}</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex gap-1 mr-4">
|
<div className="flex gap-1 mr-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingLang('en')}
|
onClick={() => setEditingLang('en')}
|
||||||
className={`px-3 py-1 text-sm font-medium rounded-lg transition-colors ${
|
className={`px-3 py-1 text-sm font-medium rounded-lg transition-colors ${
|
||||||
editingLang === 'en'
|
editingLang === 'en'
|
||||||
? 'bg-primary-100 text-primary-700'
|
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
|
||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
🇬🇧 English
|
🇬🇧 English
|
||||||
@@ -569,8 +569,8 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
onClick={() => setEditingLang('de')}
|
onClick={() => setEditingLang('de')}
|
||||||
className={`px-3 py-1 text-sm font-medium rounded-lg transition-colors ${
|
className={`px-3 py-1 text-sm font-medium rounded-lg transition-colors ${
|
||||||
editingLang === 'de'
|
editingLang === 'de'
|
||||||
? 'bg-primary-100 text-primary-700'
|
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
|
||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
🇩🇪 Deutsch
|
🇩🇪 Deutsch
|
||||||
@@ -598,19 +598,19 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<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('email.templateName')}
|
{t('email.templateName')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={defaultTemplateKeys.find(t => t.key === selectedTemplateKey)?.name || selectedTemplateKey}
|
value={defaultTemplateKeys.find(t => t.key === selectedTemplateKey)?.name || selectedTemplateKey}
|
||||||
disabled
|
disabled
|
||||||
className="bg-neutral-50"
|
className="bg-neutral-50 dark:bg-neutral-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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('email.subjectLine')} ({editingLang === 'en' ? 'English' : 'German'})
|
{t('email.subjectLine')} ({editingLang === 'en' ? 'English' : 'German'})
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -629,7 +629,7 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
</div>
|
</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('email.emailBody')} ({editingLang === 'en' ? 'English' : 'German'})
|
{t('email.emailBody')} ({editingLang === 'en' ? 'English' : 'German'})
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -643,7 +643,7 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
[editingLang === 'en' ? 'body_html_en' : 'body_html_de']: e.target.value
|
[editingLang === 'en' ? 'body_html_en' : 'body_html_de']: e.target.value
|
||||||
}))}
|
}))}
|
||||||
rows={15}
|
rows={15}
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 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-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -101,23 +101,23 @@ const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => v
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 border rounded-lg p-3">
|
<div className="mt-2 border border-neutral-200 dark:border-neutral-700 rounded-lg p-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="text-sm text-neutral-600">/external-media/{entries?.path || ''}</div>
|
<div className="text-sm text-neutral-600 dark:text-neutral-400">/external-media/{entries?.path || ''}</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button className="text-sm underline" onClick={navigateUp} disabled={!entries?.canNavigateUp}>{t('common.up', 'Up')}</button>
|
<button className="text-sm underline text-neutral-700 dark:text-neutral-300" onClick={navigateUp} disabled={!entries?.canNavigateUp}>{t('common.up', 'Up')}</button>
|
||||||
<button className="text-sm underline" onClick={() => onChange(entries?.path || '')}>{t('common.select', 'Select')}</button>
|
<button className="text-sm underline text-neutral-700 dark:text-neutral-300" onClick={() => onChange(entries?.path || '')}>{t('common.select', 'Select')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-sm text-neutral-500">{t('common.loading', 'Loading...')}</div>
|
<div className="text-sm text-neutral-500 dark:text-neutral-400">{t('common.loading', 'Loading...')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
{entries?.entries?.filter((e: any) => e.type === 'dir').map((e: any) => (
|
{entries?.entries?.filter((e: any) => e.type === 'dir').map((e: any) => (
|
||||||
<button
|
<button
|
||||||
key={e.name}
|
key={e.name}
|
||||||
onClick={() => load([entries?.path, e.name].filter(Boolean).join('/'))}
|
onClick={() => load([entries?.path, e.name].filter(Boolean).join('/'))}
|
||||||
className="px-3 py-2 border rounded text-left hover:bg-neutral-50"
|
className="px-3 py-2 border border-neutral-200 dark:border-neutral-600 rounded text-left text-neutral-900 dark:text-neutral-100 hover:bg-neutral-50 dark:hover:bg-neutral-700"
|
||||||
>
|
>
|
||||||
📁 {e.name}
|
📁 {e.name}
|
||||||
</button>
|
</button>
|
||||||
@@ -125,7 +125,7 @@ const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => v
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{value && (
|
{value && (
|
||||||
<div className="mt-2 text-xs text-neutral-600">{t('common.selected', 'Selected')}: /external-media/{value}</div>
|
<div className="mt-2 text-xs text-neutral-600 dark:text-neutral-400">{t('common.selected', 'Selected')}: /external-media/{value}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -654,8 +654,8 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-neutral-900">{event.event_name}</h1>
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{event.event_name}</h1>
|
||||||
<div className="flex items-center gap-4 mt-2 text-sm text-neutral-600">
|
<div className="flex items-center gap-4 mt-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{event.event_date && (
|
{event.event_date && (
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<Calendar className="w-4 h-4 mr-1" />
|
<Calendar className="w-4 h-4 mr-1" />
|
||||||
@@ -666,14 +666,14 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
isGalleryPublic(event.require_password)
|
isGalleryPublic(event.require_password)
|
||||||
? 'bg-green-100 text-green-700'
|
? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300'
|
||||||
: 'bg-neutral-100 text-neutral-700'
|
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
|
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
|
||||||
</span>
|
</span>
|
||||||
{event.is_archived ? (
|
{event.is_archived ? (
|
||||||
<span className="text-neutral-500 flex items-center">
|
<span className="text-neutral-500 dark:text-neutral-400 flex items-center">
|
||||||
<Archive className="w-4 h-4 mr-1" />
|
<Archive className="w-4 h-4 mr-1" />
|
||||||
{t('events.archived')}
|
{t('events.archived')}
|
||||||
</span>
|
</span>
|
||||||
@@ -787,14 +787,14 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="mb-6 border-b border-neutral-200">
|
<div className="mb-6 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<nav className="-mb-px flex space-x-8">
|
<nav className="-mb-px flex space-x-8">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('overview')}
|
onClick={() => setActiveTab('overview')}
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||||
activeTab === 'overview'
|
activeTab === 'overview'
|
||||||
? 'border-primary-500 text-primary-600'
|
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||||
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
|
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('events.overview')}
|
{t('events.overview')}
|
||||||
@@ -803,14 +803,14 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
onClick={() => setActiveTab('photos')}
|
onClick={() => setActiveTab('photos')}
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||||
activeTab === 'photos'
|
activeTab === 'photos'
|
||||||
? 'border-primary-500 text-primary-600'
|
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||||
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
|
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Image className="w-4 h-4" />
|
<Image className="w-4 h-4" />
|
||||||
<span>{t('events.photos')}</span>
|
<span>{t('events.photos')}</span>
|
||||||
{event.photo_count !== undefined && event.photo_count > 0 && (
|
{event.photo_count !== undefined && event.photo_count > 0 && (
|
||||||
<span className="ml-1 px-2 py-0.5 text-xs font-medium bg-neutral-100 text-neutral-700 rounded-full">
|
<span className="ml-1 px-2 py-0.5 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-full">
|
||||||
{event.photo_count}
|
{event.photo_count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -819,8 +819,8 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
onClick={() => setActiveTab('categories')}
|
onClick={() => setActiveTab('categories')}
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||||
activeTab === 'categories'
|
activeTab === 'categories'
|
||||||
? 'border-primary-500 text-primary-600'
|
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||||
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-300'
|
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t('events.categories')}
|
{t('events.categories')}
|
||||||
@@ -835,25 +835,25 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Event Information */}
|
{/* Event Information */}
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.eventInformation')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.eventInformation')}</h2>
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<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('events.welcomeMessageLabel')}
|
{t('events.welcomeMessageLabel')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={editForm.welcome_message}
|
value={editForm.welcome_message}
|
||||||
onChange={(e) => setEditForm(prev => ({ ...prev, welcome_message: e.target.value }))}
|
onChange={(e) => setEditForm(prev => ({ ...prev, welcome_message: e.target.value }))}
|
||||||
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 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder={t('events.welcomeMessage')}
|
placeholder={t('events.welcomeMessage')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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('events.hostName')}
|
{t('events.hostName')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -865,7 +865,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</div>
|
</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('events.expirationDate')}
|
{t('events.expirationDate')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -891,10 +891,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
if (!heroImageUrl) return null;
|
if (!heroImageUrl) return null;
|
||||||
return (
|
return (
|
||||||
<div className="ml-6 mt-2">
|
<div className="ml-6 mt-2">
|
||||||
<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('events.heroImageAnchor', 'Hero Image Crop Position')}
|
{t('events.heroImageAnchor', 'Hero Image Crop Position')}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-neutral-500 mb-2">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">
|
||||||
{t('events.heroImageAnchorDescription', 'Click on the image to set the focal point for cropping.')}
|
{t('events.heroImageAnchorDescription', 'Click on the image to set the focal point for cropping.')}
|
||||||
</p>
|
</p>
|
||||||
<FocalPointPicker
|
<FocalPointPicker
|
||||||
@@ -911,7 +911,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
<label className="flex items-start gap-2">
|
<label className="flex items-start gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||||
checked={editForm.require_password}
|
checked={editForm.require_password}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const checked = e.target.checked;
|
const checked = e.target.checked;
|
||||||
@@ -927,15 +927,15 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-neutral-700">{t('events.requirePasswordToggle')}</span>
|
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('events.requirePasswordToggle')}</span>
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
|
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{!editForm.require_password && (
|
{!editForm.require_password && (
|
||||||
<div className="mt-2 rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
|
<div className="mt-2 rounded-md border border-orange-200 dark:border-orange-800 bg-orange-50 dark:bg-orange-900/30 p-3 text-xs text-orange-800 dark:text-orange-300">
|
||||||
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -944,7 +944,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
{editForm.require_password && (
|
{editForm.require_password && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<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('events.newPasswordLabel', 'New gallery password')}
|
{t('events.newPasswordLabel', 'New gallery password')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -962,16 +962,16 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
>
|
>
|
||||||
{showNewPassword ? (
|
{showNewPassword ? (
|
||||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300" />
|
||||||
) : (
|
) : (
|
||||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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('events.confirmPassword')}
|
{t('events.confirmPassword')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -986,7 +986,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<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('events.sourceMode', 'Source Mode')}
|
{t('events.sourceMode', 'Source Mode')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -1001,26 +1001,26 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
: ''
|
: ''
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
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 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
>
|
>
|
||||||
<option value="managed">{t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}</option>
|
<option value="managed">{t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}</option>
|
||||||
<option value="reference">{t('events.sourceModeReference', 'Reference external folder')}</option>
|
<option value="reference">{t('events.sourceModeReference', 'Reference external folder')}</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
{t('events.sourceModeHelp', 'Use managed mode for direct uploads or reference an external folder that is mounted at /external-media in Docker.')}
|
{t('events.sourceModeHelp', 'Use managed mode for direct uploads or reference an external folder that is mounted at /external-media in Docker.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editForm.source_mode === 'reference' && (
|
{editForm.source_mode === 'reference' && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<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('events.externalFolder', 'External Folder')}
|
{t('events.externalFolder', 'External Folder')}
|
||||||
</label>
|
</label>
|
||||||
<ExternalFolderPicker
|
<ExternalFolderPicker
|
||||||
value={editForm.external_path || ''}
|
value={editForm.external_path || ''}
|
||||||
onChange={(folder) => setEditForm(prev => ({ ...prev, external_path: folder }))}
|
onChange={(folder) => setEditForm(prev => ({ ...prev, external_path: folder }))}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
{t('events.externalFolderHint', 'These folders come from the /external-media mount inside the container. Ensure it is accessible to the backend process.')}
|
{t('events.externalFolderHint', 'These folders come from the /external-media mount inside the container. Ensure it is accessible to the backend process.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1032,18 +1032,18 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={editForm.allow_user_uploads}
|
checked={editForm.allow_user_uploads}
|
||||||
onChange={(e) => setEditForm(prev => ({ ...prev, allow_user_uploads: e.target.checked }))}
|
onChange={(e) => setEditForm(prev => ({ ...prev, allow_user_uploads: e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<span className="ml-2 text-sm text-neutral-700">{t('events.allowUserUploads')}</span>
|
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('events.allowUserUploads')}</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-neutral-500 mt-1 ml-6">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 ml-6">
|
||||||
{t('events.allowUserUploadsHelp')}
|
{t('events.allowUserUploadsHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editForm.allow_user_uploads && (
|
{editForm.allow_user_uploads && (
|
||||||
<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('events.uploadCategory')}
|
{t('events.uploadCategory')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -1052,7 +1052,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
...prev,
|
...prev,
|
||||||
upload_category_id: e.target.value ? parseInt(e.target.value) : null
|
upload_category_id: e.target.value ? parseInt(e.target.value) : null
|
||||||
}))}
|
}))}
|
||||||
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 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
>
|
>
|
||||||
<option value="">{t('events.selectCategory')}</option>
|
<option value="">{t('events.selectCategory')}</option>
|
||||||
{categories?.map(category => (
|
{categories?.map(category => (
|
||||||
@@ -1061,15 +1061,15 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||||
{t('events.uploadCategoryHelp')}
|
{t('events.uploadCategoryHelp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Feedback Settings */}
|
{/* Feedback Settings */}
|
||||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('feedback.settings.title', 'Guest Feedback Settings')}</h3>
|
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('feedback.settings.title', 'Guest Feedback Settings')}</h3>
|
||||||
<FeedbackSettings
|
<FeedbackSettings
|
||||||
settings={feedbackSettings}
|
settings={feedbackSettings}
|
||||||
onChange={setFeedbackSettings}
|
onChange={setFeedbackSettings}
|
||||||
@@ -1077,8 +1077,8 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Download Protection Settings */}
|
{/* Download Protection Settings */}
|
||||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3 flex items-center gap-2">
|
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
|
||||||
<Shield className="w-4 h-4 text-primary-600" />
|
<Shield className="w-4 h-4 text-primary-600" />
|
||||||
{t('events.downloadProtection', 'Download Protection')}
|
{t('events.downloadProtection', 'Download Protection')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -1089,10 +1089,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={editForm.allow_downloads}
|
checked={editForm.allow_downloads}
|
||||||
onChange={(e) => setEditForm(prev => ({ ...prev, allow_downloads: e.target.checked }))}
|
onChange={(e) => setEditForm(prev => ({ ...prev, allow_downloads: e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<Download className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
<Download className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
|
||||||
<span className="text-sm text-neutral-700">{t('events.allowDownloads', 'Allow photo downloads')}</span>
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.allowDownloads', 'Allow photo downloads')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
@@ -1100,10 +1100,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={editForm.disable_right_click}
|
checked={editForm.disable_right_click}
|
||||||
onChange={(e) => setEditForm(prev => ({ ...prev, disable_right_click: e.target.checked }))}
|
onChange={(e) => setEditForm(prev => ({ ...prev, disable_right_click: e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<MousePointer className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
<MousePointer className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
|
||||||
<span className="text-sm text-neutral-700">{t('events.disableRightClick', 'Block right-click menu')}</span>
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.disableRightClick', 'Block right-click menu')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
@@ -1111,10 +1111,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={editForm.watermark_downloads}
|
checked={editForm.watermark_downloads}
|
||||||
onChange={(e) => setEditForm(prev => ({ ...prev, watermark_downloads: e.target.checked }))}
|
onChange={(e) => setEditForm(prev => ({ ...prev, watermark_downloads: e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<Droplets className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
<Droplets className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
|
||||||
<span className="text-sm text-neutral-700">{t('events.watermarkDownloads', 'Add watermark to downloads')}</span>
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.watermarkDownloads', 'Add watermark to downloads')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
@@ -1122,10 +1122,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={editForm.enable_devtools_protection}
|
checked={editForm.enable_devtools_protection}
|
||||||
onChange={(e) => setEditForm(prev => ({ ...prev, enable_devtools_protection: e.target.checked }))}
|
onChange={(e) => setEditForm(prev => ({ ...prev, enable_devtools_protection: e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<Monitor className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
<Monitor className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
|
||||||
<span className="text-sm text-neutral-700">{t('events.enableDevtoolsProtection', 'Detect developer tools')}</span>
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.enableDevtoolsProtection', 'Detect developer tools')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center">
|
<label className="flex items-center">
|
||||||
@@ -1133,21 +1133,21 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={editForm.use_canvas_rendering}
|
checked={editForm.use_canvas_rendering}
|
||||||
onChange={(e) => setEditForm(prev => ({ ...prev, use_canvas_rendering: e.target.checked }))}
|
onChange={(e) => setEditForm(prev => ({ ...prev, use_canvas_rendering: e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
|
||||||
<span className="text-sm text-neutral-700">{t('events.useCanvasRendering', 'Canvas rendering (advanced protection)')}</span>
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.useCanvasRendering', 'Canvas rendering (advanced protection)')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p className="text-xs text-neutral-500 mt-2">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
|
||||||
{t('events.protectionInfo', 'Protection features help prevent unauthorized downloads but cannot block all methods.')}
|
{t('events.protectionInfo', 'Protection features help prevent unauthorized downloads but cannot block all methods.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hero Logo Settings */}
|
{/* Hero Logo Settings */}
|
||||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3 flex items-center gap-2">
|
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
|
||||||
<Layout className="w-4 h-4 text-primary-600" />
|
<Layout className="w-4 h-4 text-primary-600" />
|
||||||
{t('events.heroLogoSettings', 'Hero Logo Settings')}
|
{t('events.heroLogoSettings', 'Hero Logo Settings')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -1158,22 +1158,22 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={editForm.hero_logo_visible}
|
checked={editForm.hero_logo_visible}
|
||||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_visible: e.target.checked }))}
|
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_visible: e.target.checked }))}
|
||||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
|
||||||
<span className="text-sm text-neutral-700">{t('events.heroLogoVisible', 'Display logo in hero section')}</span>
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.heroLogoVisible', 'Display logo in hero section')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{editForm.hero_logo_visible && (
|
{editForm.hero_logo_visible && (
|
||||||
<>
|
<>
|
||||||
<div className="ml-6">
|
<div className="ml-6">
|
||||||
<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('events.heroLogoSize', 'Logo Size')}
|
{t('events.heroLogoSize', 'Logo Size')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={editForm.hero_logo_size}
|
value={editForm.hero_logo_size}
|
||||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_size: e.target.value as 'small' | 'medium' | 'large' | 'xlarge' }))}
|
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_size: e.target.value as 'small' | 'medium' | 'large' | 'xlarge' }))}
|
||||||
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 text-sm"
|
className="w-full sm:w-48 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 shadow-sm focus:ring-primary-500 focus:border-primary-500 text-sm"
|
||||||
>
|
>
|
||||||
<option value="small">{t('events.heroLogoSizeSmall', 'Small')}</option>
|
<option value="small">{t('events.heroLogoSizeSmall', 'Small')}</option>
|
||||||
<option value="medium">{t('events.heroLogoSizeMedium', 'Medium')}</option>
|
<option value="medium">{t('events.heroLogoSizeMedium', 'Medium')}</option>
|
||||||
@@ -1183,13 +1183,13 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-6">
|
<div className="ml-6">
|
||||||
<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('events.heroLogoPosition', 'Logo Position')}
|
{t('events.heroLogoPosition', 'Logo Position')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={editForm.hero_logo_position}
|
value={editForm.hero_logo_position}
|
||||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_position: e.target.value as 'top' | 'center' | 'bottom' }))}
|
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_position: e.target.value as 'top' | 'center' | 'bottom' }))}
|
||||||
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 text-sm"
|
className="w-full sm:w-48 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 shadow-sm focus:ring-primary-500 focus:border-primary-500 text-sm"
|
||||||
>
|
>
|
||||||
<option value="top">{t('events.heroLogoPositionTop', 'Top (above title)')}</option>
|
<option value="top">{t('events.heroLogoPositionTop', 'Top (above title)')}</option>
|
||||||
<option value="center">{t('events.heroLogoPositionCenter', 'Center (between title and dates)')}</option>
|
<option value="center">{t('events.heroLogoPositionCenter', 'Center (between title and dates)')}</option>
|
||||||
@@ -1198,17 +1198,17 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom Event Logo Upload */}
|
{/* Custom Event Logo Upload */}
|
||||||
<div className="ml-6 mt-3 pt-3 border-t border-neutral-100">
|
<div className="ml-6 mt-3 pt-3 border-t border-neutral-100 dark:border-neutral-700">
|
||||||
<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('events.eventCustomLogo', 'Custom Event Logo')}
|
{t('events.eventCustomLogo', 'Custom Event Logo')}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-neutral-500 mb-2">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">
|
||||||
{t('events.eventCustomLogoDescription', 'Upload a custom logo for this event. This overrides the global branding logo for this gallery only.')}
|
{t('events.eventCustomLogoDescription', 'Upload a custom logo for this event. This overrides the global branding logo for this gallery only.')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{event.hero_logo_url ? (
|
{event.hero_logo_url ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-16 h-16 border border-neutral-200 rounded-md flex items-center justify-center bg-neutral-50 overflow-hidden">
|
<div className="w-16 h-16 border border-neutral-200 dark:border-neutral-600 rounded-md flex items-center justify-center bg-neutral-50 dark:bg-neutral-700 overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={buildResourceUrl(event.hero_logo_url)}
|
src={buildResourceUrl(event.hero_logo_url)}
|
||||||
alt={t('events.eventCustomLogo', 'Custom Event Logo')}
|
alt={t('events.eventCustomLogo', 'Custom Event Logo')}
|
||||||
@@ -1245,7 +1245,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className={`cursor-pointer inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium border border-neutral-300 rounded-md hover:bg-neutral-50 ${logoUploading ? 'opacity-50 pointer-events-none' : ''}`}>
|
<label className={`cursor-pointer inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium border border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 rounded-md hover:bg-neutral-50 dark:hover:bg-neutral-700 ${logoUploading ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||||
<Upload className="w-3.5 h-3.5" />
|
<Upload className="w-3.5 h-3.5" />
|
||||||
{t('events.uploadEventLogo', 'Upload Logo')}
|
{t('events.uploadEventLogo', 'Upload Logo')}
|
||||||
<input
|
<input
|
||||||
@@ -1267,7 +1267,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-neutral-500 mt-2">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
|
||||||
{t('events.heroLogoInfo', 'These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.')}
|
{t('events.heroLogoInfo', 'These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1276,72 +1276,72 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<dl className="space-y-4">
|
<dl className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500">{t('events.sourceMode', 'Source Mode')}</dt>
|
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.sourceMode', 'Source Mode')}</dt>
|
||||||
<dd className="mt-1 text-sm text-neutral-900">
|
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
{event.source_mode === 'reference' ? t('events.sourceModeReference', 'Reference external folder') : t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}
|
{event.source_mode === 'reference' ? t('events.sourceModeReference', 'Reference external folder') : t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}
|
||||||
{event.source_mode === 'reference' && event.external_path ? (
|
{event.source_mode === 'reference' && event.external_path ? (
|
||||||
<span className="text-neutral-500 ml-2">/external-media/{event.external_path}</span>
|
<span className="text-neutral-500 dark:text-neutral-400 ml-2">/external-media/{event.external_path}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500">{t('events.welcomeMessage')}</dt>
|
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.welcomeMessage')}</dt>
|
||||||
<dd className="mt-1 text-sm text-neutral-900">
|
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
{event.welcome_message || <span className="text-neutral-400">{t('events.noWelcomeMessageSet')}</span>}
|
{event.welcome_message || <span className="text-neutral-400">{t('events.noWelcomeMessageSet')}</span>}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500">{t('events.hostName')}</dt>
|
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.hostName')}</dt>
|
||||||
<dd className="mt-1 text-sm text-neutral-900">
|
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
{event.customer_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
|
{event.customer_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500">{t('events.hostEmail')}</dt>
|
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.hostEmail')}</dt>
|
||||||
<dd className="mt-1 text-sm text-neutral-900">{event.customer_email}</dd>
|
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">{event.customer_email}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500">{t('events.adminEmail')}</dt>
|
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.adminEmail')}</dt>
|
||||||
<dd className="mt-1 text-sm text-neutral-900">{event.admin_email}</dd>
|
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">{event.admin_email}</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500">{t('events.created')}</dt>
|
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.created')}</dt>
|
||||||
<dd className="mt-1 text-sm text-neutral-900">
|
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
{event.created_at && format(safeParseDate(event.created_at)!, 'PP')}
|
{event.created_at && format(safeParseDate(event.created_at)!, 'PP')}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500">{t('events.expires')}</dt>
|
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.expires')}</dt>
|
||||||
<dd className="mt-1 text-sm text-neutral-900">
|
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
{event.expires_at ? (
|
{event.expires_at ? (
|
||||||
<>
|
<>
|
||||||
{format(safeParseDate(event.expires_at)!, 'PP')}
|
{format(safeParseDate(event.expires_at)!, 'PP')}
|
||||||
{!event.is_archived && daysUntilExpiration !== null && daysUntilExpiration > 0 && (
|
{!event.is_archived && daysUntilExpiration !== null && daysUntilExpiration > 0 && (
|
||||||
<span className="text-neutral-500 ml-1">
|
<span className="text-neutral-500 dark:text-neutral-400 ml-1">
|
||||||
{t('events.daysLeft', { count: daysUntilExpiration })}
|
{t('events.daysLeft', { count: daysUntilExpiration })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-neutral-500">{t('events.neverExpires', 'Never')}</span>
|
<span className="text-neutral-500 dark:text-neutral-400">{t('events.neverExpires', 'Never')}</span>
|
||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500">{t('events.heroPhoto')}</dt>
|
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.heroPhoto')}</dt>
|
||||||
<dd className="mt-1 text-sm text-neutral-900">
|
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
{event.hero_photo_id ? (
|
{event.hero_photo_id ? (
|
||||||
<span className="text-primary-600">{t('events.heroPhotoSelected')}</span>
|
<span className="text-primary-600 dark:text-primary-400">{t('events.heroPhotoSelected')}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-neutral-400">{t('events.noHeroPhotoSelected')}</span>
|
<span className="text-neutral-400">{t('events.noHeroPhotoSelected')}</span>
|
||||||
)}
|
)}
|
||||||
@@ -1349,21 +1349,21 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500">{t('events.userUploads')}</dt>
|
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.userUploads')}</dt>
|
||||||
<dd className="mt-1 text-sm text-neutral-900">
|
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
{event.allow_user_uploads ? (
|
{event.allow_user_uploads ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium text-green-700 bg-green-100 rounded">
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300 bg-green-100 dark:bg-green-900/40 rounded">
|
||||||
{t('common.yes')}
|
{t('common.yes')}
|
||||||
</span>
|
</span>
|
||||||
{event.upload_category_id && (
|
{event.upload_category_id && (
|
||||||
<p className="text-xs text-neutral-600">
|
<p className="text-xs text-neutral-600 dark:text-neutral-400">
|
||||||
{t('events.uploadCategory')}: {categories.find(c => c.id === event.upload_category_id)?.name || 'N/A'}
|
{t('events.uploadCategory')}: {categories.find(c => c.id === event.upload_category_id)?.name || 'N/A'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium text-neutral-700 bg-neutral-100 rounded">
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-700 rounded">
|
||||||
{t('common.no')}
|
{t('common.no')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1371,41 +1371,41 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Download Protection Display */}
|
{/* Download Protection Display */}
|
||||||
<div className="pt-3 mt-3 border-t border-neutral-200">
|
<div className="pt-3 mt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<dt className="text-sm font-medium text-neutral-500 flex items-center gap-2">
|
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400 flex items-center gap-2">
|
||||||
<Shield className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
{t('events.downloadProtection', 'Download Protection')}
|
{t('events.downloadProtection', 'Download Protection')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-2 text-sm text-neutral-900">
|
<dd className="mt-2 text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<span className={`inline-flex items-center px-2 py-1 text-xs font-medium rounded ${
|
<span className={`inline-flex items-center px-2 py-1 text-xs font-medium rounded ${
|
||||||
event.protection_level === 'maximum' ? 'bg-red-100 text-red-700' :
|
event.protection_level === 'maximum' ? 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300' :
|
||||||
event.protection_level === 'enhanced' ? 'bg-orange-100 text-orange-700' :
|
event.protection_level === 'enhanced' ? 'bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300' :
|
||||||
event.protection_level === 'standard' ? 'bg-blue-100 text-blue-700' :
|
event.protection_level === 'standard' ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300' :
|
||||||
'bg-neutral-100 text-neutral-700'
|
'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300'
|
||||||
}`}>
|
}`}>
|
||||||
{event.protection_level || 'standard'}
|
{event.protection_level || 'standard'}
|
||||||
</span>
|
</span>
|
||||||
{event.disable_right_click && (
|
{event.disable_right_click && (
|
||||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||||
<MousePointer className="w-3 h-3 mr-1" />
|
<MousePointer className="w-3 h-3 mr-1" />
|
||||||
{t('events.rightClickBlocked', 'Right-click blocked')}
|
{t('events.rightClickBlocked', 'Right-click blocked')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{event.enable_devtools_protection && (
|
{event.enable_devtools_protection && (
|
||||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||||
<Monitor className="w-3 h-3 mr-1" />
|
<Monitor className="w-3 h-3 mr-1" />
|
||||||
{t('events.devtoolsDetection', 'DevTools detection')}
|
{t('events.devtoolsDetection', 'DevTools detection')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!event.allow_downloads && (
|
{!event.allow_downloads && (
|
||||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded">
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 rounded">
|
||||||
<Download className="w-3 h-3 mr-1" />
|
<Download className="w-3 h-3 mr-1" />
|
||||||
{t('events.downloadsDisabled', 'Downloads disabled')}
|
{t('events.downloadsDisabled', 'Downloads disabled')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{event.watermark_downloads && (
|
{event.watermark_downloads && (
|
||||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||||
<Droplets className="w-3 h-3 mr-1" />
|
<Droplets className="w-3 h-3 mr-1" />
|
||||||
{t('events.watermarked', 'Watermarked')}
|
{t('events.watermarked', 'Watermarked')}
|
||||||
</span>
|
</span>
|
||||||
@@ -1415,28 +1415,28 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hero Logo Settings Display */}
|
{/* Hero Logo Settings Display */}
|
||||||
<div className="pt-3 mt-3 border-t border-neutral-200">
|
<div className="pt-3 mt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<dt className="text-sm font-medium text-neutral-500 flex items-center gap-2">
|
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400 flex items-center gap-2">
|
||||||
<Layout className="w-4 h-4" />
|
<Layout className="w-4 h-4" />
|
||||||
{t('events.heroLogoSettings', 'Hero Logo Settings')}
|
{t('events.heroLogoSettings', 'Hero Logo Settings')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="mt-2 text-sm text-neutral-900">
|
<dd className="mt-2 text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{event.hero_logo_visible !== false ? (
|
{event.hero_logo_visible !== false ? (
|
||||||
<>
|
<>
|
||||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded">
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 rounded">
|
||||||
<Image className="w-3 h-3 mr-1" />
|
<Image className="w-3 h-3 mr-1" />
|
||||||
{t('events.heroLogoVisibleLabel', 'Logo visible')}
|
{t('events.heroLogoVisibleLabel', 'Logo visible')}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||||
{t('events.heroLogoSizeLabel', 'Size')}: {event.hero_logo_size || 'medium'}
|
{t('events.heroLogoSizeLabel', 'Size')}: {event.hero_logo_size || 'medium'}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||||
{t('events.heroLogoPositionLabel', 'Position')}: {event.hero_logo_position || 'top'}
|
{t('events.heroLogoPositionLabel', 'Position')}: {event.hero_logo_position || 'top'}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded">
|
||||||
<Image className="w-3 h-3 mr-1" />
|
<Image className="w-3 h-3 mr-1" />
|
||||||
{t('events.heroLogoHidden', 'Logo hidden')}
|
{t('events.heroLogoHidden', 'Logo hidden')}
|
||||||
</span>
|
</span>
|
||||||
@@ -1450,14 +1450,14 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Share Link */}
|
{/* Share Link */}
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.shareLink')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.shareLink')}</h2>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={event.share_link}
|
value={event.share_link}
|
||||||
readOnly
|
readOnly
|
||||||
className="flex-1 px-3 py-2 bg-neutral-50 border border-neutral-300 rounded-lg text-sm"
|
className="flex-1 px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -1469,14 +1469,14 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-neutral-600 mt-2">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-2">
|
||||||
{isGalleryPublic(event.require_password)
|
{isGalleryPublic(event.require_password)
|
||||||
? t('events.shareWithGuestsPublic', 'Anyone with this link can view the gallery. No password is required.')
|
? t('events.shareWithGuestsPublic', 'Anyone with this link can view the gallery. No password is required.')
|
||||||
: t('events.shareWithGuests')}
|
: t('events.shareWithGuests')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!event.is_archived && (
|
{!event.is_archived && (
|
||||||
<div className="mt-4 pt-4 border-t border-neutral-200 space-y-2">
|
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700 space-y-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1511,7 +1511,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{!event.is_archived && (
|
{!event.is_archived && (
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.actions')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.actions')}</h2>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -1528,7 +1528,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
{t('events.archiveEvent')}
|
{t('events.archiveEvent')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-xs text-neutral-500 text-center">
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 text-center">
|
||||||
{t('events.archivingInfo')}
|
{t('events.archivingInfo')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1540,44 +1540,44 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Photo Statistics */}
|
{/* Photo Statistics */}
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.photoStatistics')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.photoStatistics')}</h2>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
|
||||||
<span className="text-sm text-neutral-600">{t('events.totalPhotos')}</span>
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.totalPhotos')}</span>
|
||||||
<span className="text-sm font-medium">{event.photo_count || 0}</span>
|
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{event.photo_count || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
|
||||||
<span className="text-sm text-neutral-600">{t('events.totalSize')}</span>
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.totalSize')}</span>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{event.total_size ? `${(event.total_size / (1024 * 1024)).toFixed(1)} MB` : '0 MB'}
|
{event.total_size ? `${(event.total_size / (1024 * 1024)).toFixed(1)} MB` : '0 MB'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
|
||||||
<span className="text-sm text-neutral-600">{t('events.categories')}</span>
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.categories')}</span>
|
||||||
<span className="text-sm font-medium">{categories.length}</span>
|
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{categories.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{event.total_views !== undefined && (
|
{event.total_views !== undefined && (
|
||||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
|
||||||
<span className="text-sm text-neutral-600">{t('events.totalViews')}</span>
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.totalViews')}</span>
|
||||||
<span className="text-sm font-medium">{event.total_views || 0}</span>
|
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{event.total_views || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{event.total_downloads !== undefined && (
|
{event.total_downloads !== undefined && (
|
||||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
|
||||||
<span className="text-sm text-neutral-600">{t('events.totalDownloads')}</span>
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.totalDownloads')}</span>
|
||||||
<span className="text-sm font-medium">{event.total_downloads || 0}</span>
|
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{event.total_downloads || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{event.unique_visitors !== undefined && (
|
{event.unique_visitors !== undefined && (
|
||||||
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 rounded-lg">
|
<div className="flex items-center justify-between py-2 px-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
|
||||||
<span className="text-sm text-neutral-600">{t('events.uniqueVisitors')}</span>
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.uniqueVisitors')}</span>
|
||||||
<span className="text-sm font-medium">{event.unique_visitors || 0}</span>
|
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{event.unique_visitors || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1598,7 +1598,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
{/* Theme & Style */}
|
{/* Theme & Style */}
|
||||||
{isEditing && !event.is_archived && (
|
{isEditing && !event.is_archived && (
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.themeAndStyle')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('branding.themeAndStyle')}</h2>
|
||||||
<ThemeCustomizerEnhanced
|
<ThemeCustomizerEnhanced
|
||||||
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
|
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
|
||||||
onChange={(theme) => {
|
onChange={(theme) => {
|
||||||
@@ -1629,7 +1629,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
{/* Theme Display (when not editing) */}
|
{/* Theme Display (when not editing) */}
|
||||||
{!isEditing && !event.is_archived && (
|
{!isEditing && !event.is_archived && (
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.galleryTheme')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.galleryTheme')}</h2>
|
||||||
<ThemeDisplay
|
<ThemeDisplay
|
||||||
theme={event.color_theme || GALLERY_THEME_PRESETS.default.config}
|
theme={event.color_theme || GALLERY_THEME_PRESETS.default.config}
|
||||||
presetName={event.color_theme && !event.color_theme.startsWith('{') ? event.color_theme : undefined}
|
presetName={event.color_theme && !event.color_theme.startsWith('{') ? event.color_theme : undefined}
|
||||||
@@ -1650,12 +1650,12 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
{/* Archive Status */}
|
{/* Archive Status */}
|
||||||
{event.is_archived ? (
|
{event.is_archived ? (
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.archiveStatusTitle')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('events.archiveStatusTitle')}</h2>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-neutral-500">{t('events.archivedOn')}</p>
|
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.archivedOn')}</p>
|
||||||
<p className="text-sm text-neutral-900">
|
<p className="text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
{event.archived_at && format(safeParseDate(event.archived_at)!, 'PPp')}
|
{event.archived_at && format(safeParseDate(event.archived_at)!, 'PPp')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1798,8 +1798,8 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-2">{t('events.photoCategories')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('events.photoCategories')}</h2>
|
||||||
<p className="text-sm text-neutral-600">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('events.organizeCategoriesInfo')}
|
{t('events.organizeCategoriesInfo')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1808,8 +1808,8 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
eventId={parseInt(id!)}
|
eventId={parseInt(id!)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg">
|
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
|
||||||
<p className="text-sm text-blue-800">
|
<p className="text-sm text-blue-800 dark:text-blue-300">
|
||||||
{t('events.categoriesTip')}
|
{t('events.categoriesTip')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1834,16 +1834,16 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
<Card className="max-w-2xl w-full">
|
<Card className="max-w-2xl w-full">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-xl font-semibold text-neutral-900">{t('events.importExternal', 'Import from External Folder')}</h2>
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('events.importExternal', 'Import from External Folder')}</h2>
|
||||||
<button onClick={() => setShowExternalImport(false)} className="text-neutral-400 hover:text-neutral-600">
|
<button onClick={() => setShowExternalImport(false)} className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-3 text-sm text-neutral-700">
|
<div className="mb-3 text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
{t('events.externalImportInfo', 'All pictures from the selected folder will be imported.')}
|
{t('events.externalImportInfo', 'All pictures from the selected folder will be imported.')}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-2 text-sm text-neutral-700">
|
<div className="mb-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
{t('events.selectExternalFolder', 'Select external folder under /external-media')}
|
{t('events.selectExternalFolder', 'Select external folder under /external-media')}
|
||||||
</div>
|
</div>
|
||||||
<ExternalFolderPicker value={externalPath || event.external_path || ''} onChange={setExternalPath} />
|
<ExternalFolderPicker value={externalPath || event.external_path || ''} onChange={setExternalPath} />
|
||||||
|
|||||||
@@ -119,11 +119,11 @@ export const EventTypesPage: React.FC = () => {
|
|||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-neutral-900 flex items-center gap-2">
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||||
<Tags className="w-6 h-6" />
|
<Tags className="w-6 h-6" />
|
||||||
{t('eventTypes.title', 'Event Types')}
|
{t('eventTypes.title', 'Event Types')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-neutral-600 mt-1">
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
|
||||||
{t('eventTypes.subtitle', 'Customize event types and their default themes')}
|
{t('eventTypes.subtitle', 'Customize event types and their default themes')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,9 +153,9 @@ export const EventTypesPage: React.FC = () => {
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showInactive}
|
checked={showInactive}
|
||||||
onChange={(e) => setShowInactive(e.target.checked)}
|
onChange={(e) => setShowInactive(e.target.checked)}
|
||||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-neutral-700">
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
{t('eventTypes.showInactive', 'Show inactive')}
|
{t('eventTypes.showInactive', 'Show inactive')}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -166,32 +166,32 @@ export const EventTypesPage: React.FC = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase w-10">
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase w-10">
|
||||||
{/* Drag handle column */}
|
{/* Drag handle column */}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase">
|
||||||
{t('eventTypes.table.type', 'Type')}
|
{t('eventTypes.table.type', 'Type')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase">
|
||||||
{t('eventTypes.table.slugPrefix', 'URL Prefix')}
|
{t('eventTypes.table.slugPrefix', 'URL Prefix')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase">
|
||||||
{t('eventTypes.table.theme', 'Default Theme')}
|
{t('eventTypes.table.theme', 'Default Theme')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase">
|
||||||
{t('eventTypes.table.status', 'Status')}
|
{t('eventTypes.table.status', 'Status')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-500 uppercase">
|
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase">
|
||||||
{t('eventTypes.table.actions', 'Actions')}
|
{t('eventTypes.table.actions', 'Actions')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-neutral-200">
|
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||||
{filteredTypes.length === 0 ? (
|
{filteredTypes.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-neutral-500">
|
<td colSpan={6} className="px-4 py-8 text-center text-neutral-500 dark:text-neutral-400">
|
||||||
{searchTerm
|
{searchTerm
|
||||||
? t('eventTypes.noResults', 'No event types found')
|
? t('eventTypes.noResults', 'No event types found')
|
||||||
: t('eventTypes.empty', 'No event types yet')}
|
: t('eventTypes.empty', 'No event types yet')}
|
||||||
@@ -199,7 +199,7 @@ export const EventTypesPage: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredTypes.map((type) => (
|
filteredTypes.map((type) => (
|
||||||
<tr key={type.id} className={`hover:bg-neutral-50 ${!type.is_active ? 'opacity-60' : ''}`}>
|
<tr key={type.id} className={`hover:bg-neutral-50 dark:hover:bg-neutral-700/50 ${!type.is_active ? 'opacity-60' : ''}`}>
|
||||||
<td className="px-4 py-4">
|
<td className="px-4 py-4">
|
||||||
<GripVertical className="w-4 h-4 text-neutral-400 cursor-grab" />
|
<GripVertical className="w-4 h-4 text-neutral-400 cursor-grab" />
|
||||||
</td>
|
</td>
|
||||||
@@ -207,9 +207,9 @@ export const EventTypesPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-2xl">{type.emoji}</span>
|
<span className="text-2xl">{type.emoji}</span>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-neutral-900">{type.name}</div>
|
<div className="font-medium text-neutral-900 dark:text-neutral-100">{type.name}</div>
|
||||||
{type.is_system && (
|
{type.is_system && (
|
||||||
<span className="text-xs text-neutral-500">
|
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('eventTypes.system', 'System')}
|
{t('eventTypes.system', 'System')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -217,21 +217,21 @@ export const EventTypesPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4">
|
<td className="px-4 py-4">
|
||||||
<code className="px-2 py-1 bg-neutral-100 rounded text-sm">
|
<code className="px-2 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-900 dark:text-neutral-100 rounded text-sm">
|
||||||
{type.slug_prefix}
|
{type.slug_prefix}
|
||||||
</code>
|
</code>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4 text-sm text-neutral-600">
|
<td className="px-4 py-4 text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
{GALLERY_THEME_PRESETS[type.theme_preset]?.name || type.theme_preset || '-'}
|
{GALLERY_THEME_PRESETS[type.theme_preset]?.name || type.theme_preset || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4">
|
<td className="px-4 py-4">
|
||||||
{type.is_active ? (
|
{type.is_active ? (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs">
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 rounded-full text-xs">
|
||||||
<Eye className="w-3 h-3" />
|
<Eye className="w-3 h-3" />
|
||||||
{t('common.active', 'Active')}
|
{t('common.active', 'Active')}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-neutral-100 text-neutral-600 rounded-full text-xs">
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400 rounded-full text-xs">
|
||||||
<EyeOff className="w-3 h-3" />
|
<EyeOff className="w-3 h-3" />
|
||||||
{t('common.inactive', 'Inactive')}
|
{t('common.inactive', 'Inactive')}
|
||||||
</span>
|
</span>
|
||||||
@@ -241,7 +241,7 @@ export const EventTypesPage: React.FC = () => {
|
|||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingType(type)}
|
onClick={() => setEditingType(type)}
|
||||||
className="p-2 hover:bg-neutral-100 rounded-lg text-neutral-600 hover:text-primary-600"
|
className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg text-neutral-600 dark:text-neutral-400 hover:text-primary-600"
|
||||||
title={t('common.edit', 'Edit')}
|
title={t('common.edit', 'Edit')}
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
@@ -249,7 +249,7 @@ export const EventTypesPage: React.FC = () => {
|
|||||||
{!type.is_system && (
|
{!type.is_system && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteConfirm(type)}
|
onClick={() => setDeleteConfirm(type)}
|
||||||
className="p-2 hover:bg-red-50 rounded-lg text-neutral-600 hover:text-red-600"
|
className="p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg text-neutral-600 dark:text-neutral-400 hover:text-red-600"
|
||||||
title={t('common.delete', 'Delete')}
|
title={t('common.delete', 'Delete')}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
@@ -266,8 +266,8 @@ export const EventTypesPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Slug Preview Info */}
|
{/* Slug Preview Info */}
|
||||||
<div className="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/30 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||||
<p className="text-sm text-blue-800">
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
<strong>{t('eventTypes.slugInfo.title', 'URL Prefix Info:')}</strong>{' '}
|
<strong>{t('eventTypes.slugInfo.title', 'URL Prefix Info:')}</strong>{' '}
|
||||||
{t('eventTypes.slugInfo.description', 'The URL prefix is used to generate gallery URLs. For example, an event type with prefix "family" will create URLs like: family-smith-family-2025-01-22')}
|
{t('eventTypes.slugInfo.description', 'The URL prefix is used to generate gallery URLs. For example, an event type with prefix "family" will create URLs like: family-smith-family-2025-01-22')}
|
||||||
</p>
|
</p>
|
||||||
@@ -368,17 +368,17 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
|||||||
<Card className="w-full max-w-lg">
|
<Card className="w-full max-w-lg">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-xl font-semibold text-neutral-900">
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
{isEditing
|
{isEditing
|
||||||
? t('eventTypes.edit', 'Edit Event Type')
|
? t('eventTypes.edit', 'Edit Event Type')
|
||||||
: t('eventTypes.createNew', 'New Event Type')}
|
: t('eventTypes.createNew', 'New Event Type')}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 hover:bg-neutral-100 rounded-lg"
|
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5 text-neutral-500" />
|
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -410,9 +410,9 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
|||||||
error={errors.slug_prefix}
|
error={errors.slug_prefix}
|
||||||
/>
|
/>
|
||||||
{form.slug_prefix && (
|
{form.slug_prefix && (
|
||||||
<p className="mt-1 text-xs text-neutral-500">
|
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{t('eventTypes.form.slugPreview', 'Example URL:')}{' '}
|
{t('eventTypes.form.slugPreview', 'Example URL:')}{' '}
|
||||||
<code className="bg-neutral-100 px-1 rounded">
|
<code className="bg-neutral-100 dark:bg-neutral-700 px-1 rounded">
|
||||||
{form.slug_prefix}-event-name-2025-01-22
|
{form.slug_prefix}-event-name-2025-01-22
|
||||||
</code>
|
</code>
|
||||||
</p>
|
</p>
|
||||||
@@ -421,7 +421,7 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
|||||||
|
|
||||||
{/* Emoji */}
|
{/* Emoji */}
|
||||||
<div>
|
<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('eventTypes.form.emoji', 'Icon')}
|
{t('eventTypes.form.emoji', 'Icon')}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -432,8 +432,8 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
|||||||
onClick={() => setForm({ ...form, emoji })}
|
onClick={() => setForm({ ...form, emoji })}
|
||||||
className={`p-2 text-xl rounded-lg border-2 transition-all ${
|
className={`p-2 text-xl rounded-lg border-2 transition-all ${
|
||||||
form.emoji === emoji
|
form.emoji === emoji
|
||||||
? 'border-primary-600 bg-primary-50'
|
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
||||||
: 'border-neutral-200 hover:border-neutral-300'
|
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{emoji}
|
{emoji}
|
||||||
@@ -444,13 +444,13 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
|||||||
|
|
||||||
{/* Theme Preset */}
|
{/* Theme Preset */}
|
||||||
<div>
|
<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('eventTypes.form.themePreset', 'Default Theme')}
|
{t('eventTypes.form.themePreset', 'Default Theme')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={form.theme_preset}
|
value={form.theme_preset}
|
||||||
onChange={(e) => setForm({ ...form, theme_preset: e.target.value })}
|
onChange={(e) => setForm({ ...form, theme_preset: e.target.value })}
|
||||||
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 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
>
|
>
|
||||||
{Object.entries(GALLERY_THEME_PRESETS).map(([key, preset]) => (
|
{Object.entries(GALLERY_THEME_PRESETS).map(([key, preset]) => (
|
||||||
<option key={key} value={key}>
|
<option key={key} value={key}>
|
||||||
@@ -467,16 +467,16 @@ const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={eventType?.is_active}
|
checked={eventType?.is_active}
|
||||||
onChange={(e) => onSubmit({ is_active: e.target.checked })}
|
onChange={(e) => onSubmit({ is_active: e.target.checked })}
|
||||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
className="rounded border-neutral-300 dark:border-neutral-600 text-primary-600 focus:ring-primary-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-neutral-700">
|
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
{t('eventTypes.form.isActive', 'Active (visible in event creation)')}
|
{t('eventTypes.form.isActive', 'Active (visible in event creation)')}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-neutral-200">
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||||
{t('common.cancel', 'Cancel')}
|
{t('common.cancel', 'Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -512,19 +512,19 @@ const DeleteConfirmModal: React.FC<DeleteConfirmModalProps> = ({
|
|||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<div className="p-2 bg-red-100 rounded-full">
|
<div className="p-2 bg-red-100 dark:bg-red-900/40 rounded-full">
|
||||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold text-neutral-900">
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
{t('eventTypes.deleteConfirm.title', 'Delete Event Type')}
|
{t('eventTypes.deleteConfirm.title', 'Delete Event Type')}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-neutral-600 mb-4">
|
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
{t('eventTypes.deleteConfirm.message', 'Are you sure you want to delete')} "{eventType.name}"?
|
{t('eventTypes.deleteConfirm.message', 'Are you sure you want to delete')} "{eventType.name}"?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-sm text-neutral-500 bg-neutral-50 p-3 rounded-lg mb-6">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 bg-neutral-50 dark:bg-neutral-700 p-3 rounded-lg mb-6">
|
||||||
{t('eventTypes.deleteConfirm.warning', 'This action cannot be undone. Make sure no events are using this type.')}
|
{t('eventTypes.deleteConfirm.warning', 'This action cannot be undone. Make sure no events are using this type.')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import {
|
|||||||
AnalyticsTab,
|
AnalyticsTab,
|
||||||
ModerationTab,
|
ModerationTab,
|
||||||
StylingTab,
|
StylingTab,
|
||||||
|
SEOTab,
|
||||||
} from '../../features/settings';
|
} from '../../features/settings';
|
||||||
|
|
||||||
type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'categories' | 'analytics' | 'moderation' | 'styling';
|
type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling';
|
||||||
|
|
||||||
export const SettingsPage: React.FC = () => {
|
export const SettingsPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('general');
|
const [activeTab, setActiveTab] = useState<TabType>('general');
|
||||||
@@ -54,6 +55,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
saveSecurityMutation,
|
saveSecurityMutation,
|
||||||
saveAnalyticsMutation,
|
saveAnalyticsMutation,
|
||||||
saveEventSettingsMutation,
|
saveEventSettingsMutation,
|
||||||
|
seoSettings,
|
||||||
|
setSeoSettings,
|
||||||
|
saveSeoMutation,
|
||||||
} = useSettingsState();
|
} = useSettingsState();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -70,6 +74,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{ key: 'status', label: t('settings.systemStatus.title') },
|
{ key: 'status', label: t('settings.systemStatus.title') },
|
||||||
{ key: 'security', label: t('settings.security.title') },
|
{ key: 'security', label: t('settings.security.title') },
|
||||||
{ key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection') },
|
{ key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection') },
|
||||||
|
{ key: 'seo', label: t('settings.seo.title', 'SEO & Robots') },
|
||||||
{ key: 'categories', label: t('settings.categories.title') },
|
{ key: 'categories', label: t('settings.categories.title') },
|
||||||
{ key: 'analytics', label: t('settings.analytics.title') },
|
{ key: 'analytics', label: t('settings.analytics.title') },
|
||||||
{ key: 'moderation', label: t('settings.moderation.title', 'Moderation') },
|
{ key: 'moderation', label: t('settings.moderation.title', 'Moderation') },
|
||||||
@@ -79,12 +84,12 @@ export const SettingsPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-neutral-900">{t('settings.title')}</h1>
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{t('settings.title')}</h1>
|
||||||
<p className="text-neutral-600 mt-1">{t('settings.subtitle')}</p>
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('settings.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className="border-b border-neutral-200 mb-6">
|
<div className="border-b border-neutral-200 dark:border-neutral-700 mb-6">
|
||||||
<nav className="-mb-px flex gap-6">
|
<nav className="-mb-px flex gap-6">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
@@ -92,8 +97,8 @@ export const SettingsPage: React.FC = () => {
|
|||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||||
activeTab === tab.key
|
activeTab === tab.key
|
||||||
? 'border-primary-600 text-primary-600'
|
? 'border-primary-600 text-primary-600 dark:text-primary-400'
|
||||||
: 'border-transparent text-neutral-500 hover:text-neutral-700'
|
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -153,6 +158,14 @@ export const SettingsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'seo' && (
|
||||||
|
<SEOTab
|
||||||
|
seoSettings={seoSettings}
|
||||||
|
setSeoSettings={setSeoSettings}
|
||||||
|
saveSeoMutation={saveSeoMutation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'imageSecurity' && <ImageSecurityTab />}
|
{activeTab === 'imageSecurity' && <ImageSecurityTab />}
|
||||||
|
|
||||||
{activeTab === 'categories' && <CategoriesTab />}
|
{activeTab === 'categories' && <CategoriesTab />}
|
||||||
|
|||||||
@@ -29,14 +29,14 @@ type TabType = 'users' | 'invitations';
|
|||||||
const getRoleBadgeColor = (roleName: string): string => {
|
const getRoleBadgeColor = (roleName: string): string => {
|
||||||
switch (roleName?.toLowerCase()) {
|
switch (roleName?.toLowerCase()) {
|
||||||
case 'super_admin':
|
case 'super_admin':
|
||||||
return 'bg-red-100 text-red-700 border-red-200';
|
return 'bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800';
|
||||||
case 'admin':
|
case 'admin':
|
||||||
return 'bg-blue-100 text-blue-700 border-blue-200';
|
return 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800';
|
||||||
case 'editor':
|
case 'editor':
|
||||||
return 'bg-green-100 text-green-700 border-green-200';
|
return 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800';
|
||||||
case 'viewer':
|
case 'viewer':
|
||||||
default:
|
default:
|
||||||
return 'bg-neutral-100 text-neutral-700 border-neutral-200';
|
return 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 border-neutral-200 dark:border-neutral-600';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,22 +97,22 @@ const CreateInvitationModal: React.FC<CreateInvitationModalProps> = ({
|
|||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-xl font-semibold text-neutral-900">
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
{t('userManagement.createInvitation')}
|
{t('userManagement.createInvitation')}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
|
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5 text-neutral-500" />
|
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<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('userManagement.email')}
|
{t('userManagement.email')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
@@ -131,7 +131,7 @@ const CreateInvitationModal: React.FC<CreateInvitationModalProps> = ({
|
|||||||
</div>
|
</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('userManagement.role')}
|
{t('userManagement.role')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
@@ -140,7 +140,7 @@ const CreateInvitationModal: React.FC<CreateInvitationModalProps> = ({
|
|||||||
setRoleId(e.target.value ? Number(e.target.value) : '');
|
setRoleId(e.target.value ? Number(e.target.value) : '');
|
||||||
setErrors((prev) => ({ ...prev, role: undefined }));
|
setErrors((prev) => ({ ...prev, role: undefined }));
|
||||||
}}
|
}}
|
||||||
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 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<option value="">{t('userManagement.selectRole')}</option>
|
<option value="">{t('userManagement.selectRole')}</option>
|
||||||
@@ -226,34 +226,34 @@ const EditUserModal: React.FC<EditUserModalProps> = ({
|
|||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-xl font-semibold text-neutral-900">
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
{t('userManagement.editUser')}
|
{t('userManagement.editUser')}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
|
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5 text-neutral-500" />
|
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 p-3 bg-neutral-50 rounded-lg">
|
<div className="mb-4 p-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
|
||||||
<p className="text-sm text-neutral-600">
|
<p className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
{t('userManagement.editingUser')}: <strong>{user.username}</strong>
|
{t('userManagement.editingUser')}: <strong>{user.username}</strong>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-neutral-500">{user.email}</p>
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<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('userManagement.role')}
|
{t('userManagement.role')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={roleId}
|
value={roleId}
|
||||||
onChange={(e) => setRoleId(e.target.value ? Number(e.target.value) : '')}
|
onChange={(e) => setRoleId(e.target.value ? Number(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 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
<option value="">{t('userManagement.selectRole')}</option>
|
<option value="">{t('userManagement.selectRole')}</option>
|
||||||
@@ -323,18 +323,18 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
|||||||
<div className="flex items-start gap-3 mb-4">
|
<div className="flex items-start gap-3 mb-4">
|
||||||
<div
|
<div
|
||||||
className={`p-2 rounded-full ${
|
className={`p-2 rounded-full ${
|
||||||
variant === 'danger' ? 'bg-red-100' : 'bg-amber-100'
|
variant === 'danger' ? 'bg-red-100 dark:bg-red-900/40' : 'bg-amber-100 dark:bg-amber-900/40'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<AlertTriangle
|
<AlertTriangle
|
||||||
className={`w-5 h-5 ${
|
className={`w-5 h-5 ${
|
||||||
variant === 'danger' ? 'text-red-600' : 'text-amber-600'
|
variant === 'danger' ? 'text-red-600 dark:text-red-400' : 'text-amber-600 dark:text-amber-400'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-neutral-900">{title}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{title}</h2>
|
||||||
<p className="text-sm text-neutral-600 mt-1">{message}</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -536,10 +536,10 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-neutral-900">
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{t('userManagement.title')}
|
{t('userManagement.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('userManagement.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
<Loading size="lg" text={t('userManagement.loading')} />
|
<Loading size="lg" text={t('userManagement.loading')} />
|
||||||
@@ -553,10 +553,10 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-bold text-neutral-900">
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{t('userManagement.title')}
|
{t('userManagement.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('userManagement.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-red-600">{t('userManagement.loadError')}</p>
|
<p className="text-red-600">{t('userManagement.loadError')}</p>
|
||||||
@@ -582,10 +582,10 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-neutral-900">
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{t('userManagement.title')}
|
{t('userManagement.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
|
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('userManagement.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -601,10 +601,10 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
<Card padding="sm">
|
<Card padding="sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('userManagement.stats.totalUsers')}
|
{t('userManagement.stats.totalUsers')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900">
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{users?.length || 0}
|
{users?.length || 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -615,10 +615,10 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
<Card padding="sm">
|
<Card padding="sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('userManagement.stats.activeUsers')}
|
{t('userManagement.stats.activeUsers')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900">
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{users?.filter((u) => u.isActive).length || 0}
|
{users?.filter((u) => u.isActive).length || 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -629,10 +629,10 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
<Card padding="sm">
|
<Card padding="sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('userManagement.stats.pendingInvitations')}
|
{t('userManagement.stats.pendingInvitations')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900">
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{invitations?.length || 0}
|
{invitations?.length || 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -643,10 +643,10 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
<Card padding="sm">
|
<Card padding="sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-neutral-600">
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{t('userManagement.stats.inactiveUsers')}
|
{t('userManagement.stats.inactiveUsers')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-2xl font-bold text-neutral-900">
|
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{users?.filter((u) => !u.isActive).length || 0}
|
{users?.filter((u) => !u.isActive).length || 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -656,7 +656,7 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className="border-b border-neutral-200 mb-6">
|
<div className="border-b border-neutral-200 dark:border-neutral-700 mb-6">
|
||||||
<nav className="-mb-px flex gap-6">
|
<nav className="-mb-px flex gap-6">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
@@ -665,15 +665,15 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
|
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
|
||||||
activeTab === tab.key
|
activeTab === tab.key
|
||||||
? 'border-primary-600 text-primary-600'
|
? 'border-primary-600 text-primary-600'
|
||||||
: 'border-transparent text-neutral-500 hover:text-neutral-700'
|
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-0.5 text-xs rounded-full ${
|
className={`px-2 py-0.5 text-xs rounded-full ${
|
||||||
activeTab === tab.key
|
activeTab === tab.key
|
||||||
? 'bg-primary-100 text-primary-700'
|
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
|
||||||
: 'bg-neutral-100 text-neutral-600'
|
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-600 dark:text-neutral-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.count}
|
{tab.count}
|
||||||
@@ -707,29 +707,29 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
<Card className="overflow-visible">
|
<Card className="overflow-visible">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-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('userManagement.table.user')}
|
{t('userManagement.table.user')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-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('userManagement.table.role')}
|
{t('userManagement.table.role')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-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('userManagement.table.status')}
|
{t('userManagement.table.status')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-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('userManagement.table.lastLogin')}
|
{t('userManagement.table.lastLogin')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-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('userManagement.table.actions')}
|
{t('userManagement.table.actions')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-neutral-200">
|
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||||
{filteredUsers.length === 0 ? (
|
{filteredUsers.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-6 py-12 text-center text-neutral-500">
|
<td colSpan={5} className="px-6 py-12 text-center text-neutral-500 dark:text-neutral-400">
|
||||||
{searchTerm
|
{searchTerm
|
||||||
? t('userManagement.noUsersFound')
|
? t('userManagement.noUsersFound')
|
||||||
: t('userManagement.noUsers')}
|
: t('userManagement.noUsers')}
|
||||||
@@ -737,19 +737,19 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredUsers.map((user) => (
|
filteredUsers.map((user) => (
|
||||||
<tr key={user.id} className="hover:bg-neutral-50">
|
<tr key={user.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700/50">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900/40 flex items-center justify-center">
|
||||||
<span className="text-primary-700 font-medium text-sm">
|
<span className="text-primary-700 dark:text-primary-300 font-medium text-sm">
|
||||||
{user.username.charAt(0).toUpperCase()}
|
{user.username.charAt(0).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-neutral-900">
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{user.username}
|
{user.username}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-neutral-500">{user.email}</p>
|
<p className="text-xs text-neutral-500 dark:text-neutral-400">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -767,8 +767,8 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
user.isActive
|
user.isActive
|
||||||
? 'bg-green-100 text-green-700'
|
? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300'
|
||||||
: 'bg-neutral-100 text-neutral-500'
|
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-500 dark:text-neutral-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{user.isActive
|
{user.isActive
|
||||||
@@ -778,14 +778,14 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
{user.lastLogin ? (
|
{user.lastLogin ? (
|
||||||
<div className="flex items-center gap-1 text-sm text-neutral-600">
|
<div className="flex items-center gap-1 text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
{formatDistanceToNow(parseISO(user.lastLogin), {
|
{formatDistanceToNow(parseISO(user.lastLogin), {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-neutral-400">
|
<span className="text-sm text-neutral-400 dark:text-neutral-500">
|
||||||
{t('userManagement.neverLoggedIn')}
|
{t('userManagement.neverLoggedIn')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -794,7 +794,7 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditUser(user)}
|
onClick={() => handleEditUser(user)}
|
||||||
className="p-1.5 text-neutral-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
|
className="p-1.5 text-neutral-400 hover:text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/30 rounded-lg transition-colors"
|
||||||
title={t('userManagement.editUser')}
|
title={t('userManagement.editUser')}
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
@@ -802,7 +802,7 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
{user.isActive && (
|
{user.isActive && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeactivateUser(user)}
|
onClick={() => handleDeactivateUser(user)}
|
||||||
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
|
||||||
title={t('userManagement.deactivateUser')}
|
title={t('userManagement.deactivateUser')}
|
||||||
>
|
>
|
||||||
<UserX className="w-4 h-4" />
|
<UserX className="w-4 h-4" />
|
||||||
@@ -824,29 +824,29 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
<Card className="overflow-visible">
|
<Card className="overflow-visible">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-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('userManagement.table.email')}
|
{t('userManagement.table.email')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-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('userManagement.table.role')}
|
{t('userManagement.table.role')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-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('userManagement.table.invitedBy')}
|
{t('userManagement.table.invitedBy')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-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('userManagement.table.expires')}
|
{t('userManagement.table.expires')}
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-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('userManagement.table.actions')}
|
{t('userManagement.table.actions')}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-neutral-200">
|
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||||
{filteredInvitations.length === 0 ? (
|
{filteredInvitations.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-6 py-12 text-center text-neutral-500">
|
<td colSpan={5} className="px-6 py-12 text-center text-neutral-500 dark:text-neutral-400">
|
||||||
{searchTerm
|
{searchTerm
|
||||||
? t('userManagement.noInvitationsFound')
|
? t('userManagement.noInvitationsFound')
|
||||||
: t('userManagement.noInvitations')}
|
: t('userManagement.noInvitations')}
|
||||||
@@ -856,13 +856,13 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
filteredInvitations.map((invitation) => {
|
filteredInvitations.map((invitation) => {
|
||||||
const isExpired = isPast(parseISO(invitation.expiresAt));
|
const isExpired = isPast(parseISO(invitation.expiresAt));
|
||||||
return (
|
return (
|
||||||
<tr key={invitation.id} className="hover:bg-neutral-50">
|
<tr key={invitation.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-700/50">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/40 flex items-center justify-center">
|
||||||
<Mail className="w-5 h-5 text-blue-600" />
|
<Mail className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-neutral-900">
|
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
{invitation.email}
|
{invitation.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -877,13 +877,13 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
{invitation.roleName}
|
{invitation.roleName}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-neutral-600">
|
<td className="px-6 py-4 text-sm text-neutral-600 dark:text-neutral-300">
|
||||||
{invitation.invitedBy || '-'}
|
{invitation.invitedBy || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 text-sm ${
|
className={`inline-flex items-center gap-1 text-sm ${
|
||||||
isExpired ? 'text-red-600' : 'text-neutral-600'
|
isExpired ? 'text-red-600 dark:text-red-400' : 'text-neutral-600 dark:text-neutral-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
@@ -897,7 +897,7 @@ export const UserManagementPage: React.FC = () => {
|
|||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCancelInvitation(invitation)}
|
onClick={() => handleCancelInvitation(invitation)}
|
||||||
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
|
||||||
title={t('userManagement.cancelInvitation')}
|
title={t('userManagement.cancelInvitation')}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ export const settingsService = {
|
|||||||
endpoint = '/admin/settings/analytics';
|
endpoint = '/admin/settings/analytics';
|
||||||
} else if (firstKey?.startsWith('branding_')) {
|
} else if (firstKey?.startsWith('branding_')) {
|
||||||
endpoint = '/admin/settings/branding';
|
endpoint = '/admin/settings/branding';
|
||||||
|
} else if (firstKey?.startsWith('seo_')) {
|
||||||
|
endpoint = '/admin/settings/seo';
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put(endpoint, settings);
|
await api.put(endpoint, settings);
|
||||||
|
|||||||
Vendored
+11
@@ -1,2 +1,13 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
/// <reference types="vitest" />
|
/// <reference types="vitest" />
|
||||||
|
|
||||||
|
// Swiper CSS module declarations
|
||||||
|
declare module 'swiper/css' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'swiper/css/free-mode' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
|
darkMode: 'class',
|
||||||
content: [
|
content: [
|
||||||
"./index.html",
|
"./index.html",
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
|||||||
Reference in New Issue
Block a user