feat: overhaul public landing page and backup tooling

This commit is contained in:
2025-09-19 16:07:43 +02:00
parent ad9c6d63d3
commit 2a4d38813f
72 changed files with 4332 additions and 1466 deletions
+69 -33
View File
@@ -16,6 +16,7 @@ import { format, parseISO } from 'date-fns';
import { Button, Card, Loading } from '../../components/common';
import { useQuery } from '@tanstack/react-query';
import { adminService } from '../../services/admin.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
import { api } from '../../config/api';
@@ -72,6 +73,11 @@ export const AnalyticsPage: React.FC = () => {
queryFn: () => adminService.getDashboardStats(),
});
const { data: storageInfo } = useQuery({
queryKey: ['storage-info'],
queryFn: () => settingsService.getStorageInfo(),
});
// Fetch Umami config from admin settings since we're in admin panel
useEffect(() => {
const fetchUmamiConfig = async () => {
@@ -154,12 +160,12 @@ export const AnalyticsPage: React.FC = () => {
// Get actual download data for top galleries - sort by downloads
const topGalleriesWithDownloads = apiData.topGalleries
.filter(gallery => gallery.downloads > 0) // Only show galleries with downloads
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0)) // Sort by downloads
.filter(gallery => (gallery.downloads ?? 0) > 0) // Only show galleries with downloads
.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0)) // Sort by downloads
.slice(0, 5) // Take top 5
.map(gallery => ({
name: gallery.event_name,
downloads: gallery.downloads || 0
downloads: gallery.downloads ?? 0
}));
return {
@@ -429,38 +435,68 @@ export const AnalyticsPage: React.FC = () => {
</Card>
{/* Storage Information */}
{dashboardStats && (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.storageUsage')}</h2>
<div className="space-y-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-600">{t('analytics.used')}</span>
<span className="font-medium">{adminService.formatBytes(dashboardStats.storageUsed)}</span>
{dashboardStats && (() => {
const softLimitBytes = storageInfo?.storage_soft_limit ?? storageInfo?.storage_limit ?? storageInfo?.recommended_soft_limit ?? null;
const safeSoftLimit = Math.max(
softLimitBytes ?? storageInfo?.recommended_soft_limit ?? (dashboardStats.storageUsed || 1),
1
);
const usageRatio = dashboardStats.storageUsed / safeSoftLimit;
const usagePercent = Math.round(usageRatio * 100);
const usageWidth = Math.min(usageRatio * 100, 100);
const overSoftLimit = softLimitBytes != null && dashboardStats.storageUsed >= softLimitBytes;
const limitDisplay = softLimitBytes != null
? adminService.formatBytes(softLimitBytes)
: storageInfo?.recommended_soft_limit != null
? adminService.formatBytes(storageInfo.recommended_soft_limit)
: t('settings.storage.unlimited');
const progressColor = overSoftLimit
? 'bg-red-600'
: usagePercent >= 90
? 'bg-amber-500'
: 'bg-primary-600';
const limitDescriptor = storageInfo
? storageInfo.soft_limit_configured
? t('admin.storageSoftLimitConfigured', { limit: limitDisplay })
: t('admin.storageSoftLimitRecommended', { limit: limitDisplay })
: t('admin.storageSoftLimitRecommended', { limit: limitDisplay });
return (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.storageUsage')}</h2>
<div className="space-y-4">
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-600">{t('analytics.used')}</span>
<span className="font-medium">{adminService.formatBytes(dashboardStats.storageUsed)}</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-2">
<div
className={`${progressColor} h-2 rounded-full transition-all`}
style={{ width: `${usageWidth}%` }}
/>
</div>
<p className="text-xs text-neutral-500 mt-1">
{usagePercent}% {t('analytics.of')} {limitDisplay}
</p>
<p className={`text-xs mt-1 ${overSoftLimit ? 'text-red-600 font-semibold' : 'text-red-500 font-medium'}`}>
{limitDescriptor}
</p>
</div>
<div className="w-full bg-neutral-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all"
style={{ width: `${Math.min((dashboardStats.storageUsed / (10 * 1024 * 1024 * 1024)) * 100, 100)}%` }}
/>
</div>
<p className="text-xs text-neutral-500 mt-1">
{Math.round((dashboardStats.storageUsed / (10 * 1024 * 1024 * 1024)) * 100)}% {t('analytics.of')} 10 GB
</p>
</div>
<div className="pt-2 border-t">
<div className="flex justify-between text-sm">
<span className="text-neutral-600">{t('analytics.totalPhotos')}</span>
<span className="font-medium">{dashboardStats.totalPhotos.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm mt-2">
<span className="text-neutral-600">{t('analytics.activeEvents')}</span>
<span className="font-medium">{dashboardStats.activeEvents}</span>
<div className="pt-2 border-t">
<div className="flex justify-between text-sm">
<span className="text-neutral-600">{t('analytics.totalPhotos')}</span>
<span className="font-medium">{dashboardStats.totalPhotos.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm mt-2">
<span className="text-neutral-600">{t('analytics.activeEvents')}</span>
<span className="font-medium">{dashboardStats.activeEvents}</span>
</div>
</div>
</div>
</div>
</Card>
)}
</Card>
);
})()}
</div>
</div>
@@ -480,4 +516,4 @@ export const AnalyticsPage: React.FC = () => {
)}
</div>
);
};
};
+3
View File
@@ -0,0 +1,3 @@
import type { ComponentType } from 'react';
export const BackupManagement: ComponentType<any>;
+338 -3
View File
@@ -1,13 +1,15 @@
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Save, FileText, Globe } from 'lucide-react';
import { FileText, Globe, Sparkles, ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify';
import { Button, Card, Input, Loading } from '../../components/common';
import { CMSEditor } from '../../components/admin/CMSEditor';
import { cmsService } from '../../services/cms.service';
import type { CMSPage as CMSPageType } from '../../services/cms.service';
import { settingsService, PublicSiteBranding } from '../../services/settings.service';
export const CMSPage: React.FC = () => {
const { t } = useTranslation();
@@ -15,6 +17,11 @@ export const CMSPage: React.FC = () => {
const [selectedPage, setSelectedPage] = useState<string>('impressum');
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
const [publicSiteEnabled, setPublicSiteEnabled] = useState(false);
const [publicSiteHtml, setPublicSiteHtml] = useState('');
const [publicSiteCss, setPublicSiteCss] = useState('');
const [publicSiteBaseCss, setPublicSiteBaseCss] = useState('');
const [publicSiteBranding, setPublicSiteBranding] = useState<PublicSiteBranding | undefined>(undefined);
// Fetch CMS pages
const { data: pages, isLoading } = useQuery({
@@ -22,6 +29,33 @@ export const CMSPage: React.FC = () => {
queryFn: cmsService.getPages,
});
const { data: adminSettings, isLoading: isLoadingAdminSettings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
});
const { data: publicSiteDefaults, isLoading: isLoadingPublicDefaults } = useQuery({
queryKey: ['public-site-defaults'],
queryFn: () => settingsService.getPublicSiteDefaults(),
});
React.useEffect(() => {
if (publicSiteDefaults) {
setPublicSiteBaseCss(publicSiteDefaults.baseCss || '');
setPublicSiteBranding(publicSiteDefaults.branding);
}
}, [publicSiteDefaults]);
React.useEffect(() => {
if (!adminSettings) {
return;
}
setPublicSiteEnabled(Boolean(adminSettings.general_public_site_enabled));
setPublicSiteHtml((adminSettings.general_public_site_html as string) || '');
setPublicSiteCss((adminSettings.general_public_site_custom_css as string) || '');
}, [adminSettings]);
// Update page mutation
const updateMutation = useMutation({
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
@@ -62,6 +96,53 @@ export const CMSPage: React.FC = () => {
setEditForm(prev => ({ ...prev, [field]: title }));
};
const publicSiteSaveMutation = useMutation({
mutationFn: async () => {
const trimmedHtml = publicSiteHtml.trim();
if (publicSiteEnabled && !trimmedHtml) {
throw new Error('PUBLIC_SITE_HTML_REQUIRED');
}
await settingsService.updatePublicSite({
enabled: publicSiteEnabled,
html: trimmedHtml || '',
css: publicSiteCss,
});
},
onSuccess: async () => {
toast.success(t('settings.publicSite.saveSuccess'));
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
]);
},
onError: (error: any) => {
if (error?.message === 'PUBLIC_SITE_HTML_REQUIRED') {
toast.error(t('settings.publicSite.htmlRequired'));
return;
}
toast.error(t('settings.publicSite.saveError'));
},
});
const publicSiteResetMutation = useMutation({
mutationFn: () => settingsService.resetPublicSite(),
onSuccess: async (data) => {
toast.success(t('settings.publicSite.resetSuccess'));
setPublicSiteHtml(data.html || '');
setPublicSiteCss(data.css || '');
setPublicSiteBaseCss(data.baseCss || '');
setPublicSiteBranding(data.branding ?? publicSiteDefaults?.branding);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
]);
},
onError: () => {
toast.error(t('settings.publicSite.resetError'));
}
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
@@ -72,6 +153,131 @@ export const CMSPage: React.FC = () => {
const currentPage = pages?.find(p => p.slug === selectedPage);
const publicSiteSanitizedHtml = useMemo(() => DOMPurify.sanitize(publicSiteHtml || '', {
ALLOWED_TAGS: [
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div', 'em',
'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span', 'strong', 'sup',
'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul'
],
ALLOWED_ATTR: ['class', 'id', 'role', 'aria-label', 'aria-hidden', 'href', 'target', 'rel', 'src', 'alt', 'title', 'loading', 'decoding', 'width', 'height'],
ALLOW_UNKNOWN_PROTOCOLS: false,
ADD_ATTR: ['data-*'],
}), [publicSiteHtml]);
const sanitizeCss = (css: string) => {
if (!css) {
return '';
}
let sanitized = css;
const disallowedPatterns = [
/@import[^;]+;?/gi,
/@charset[^;]+;?/gi,
/expression\s*\([^)]*\)/gi,
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
];
disallowedPatterns.forEach((pattern) => {
sanitized = sanitized.replace(pattern, '');
});
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
const MAX_LENGTH = 100 * 1024;
if (sanitized.length > MAX_LENGTH) {
sanitized = sanitized.slice(0, MAX_LENGTH);
}
return sanitized.trim();
};
const publicSiteSanitizedCss = useMemo(() => sanitizeCss(publicSiteCss || ''), [publicSiteCss]);
const applyBrandTokens = (html: string, branding: PublicSiteBranding | undefined) => {
if (!html || !branding) {
return html;
}
const tokens: Record<string, string> = {
company_name: branding.companyName || '',
company_tagline: branding.companyTagline || '',
support_email: branding.supportEmail || '',
brand_logo_url: branding.logoUrl || '/picpeak-logo-transparent.png',
brand_primary_hex: branding.colors.primary,
brand_accent_hex: branding.colors.accent,
brand_background_hex: branding.colors.background,
brand_text_hex: branding.colors.text,
};
return html.replace(/\{\{\s*(company_name|company_tagline|support_email|brand_logo_url|brand_primary_hex|brand_accent_hex|brand_background_hex|brand_text_hex)\s*\}\}/gi,
(_, key: string) => tokens[key] || '');
};
const publicSitePreview = useMemo(() => {
const branding = publicSiteBranding || publicSiteDefaults?.branding;
const substitutedHtml = applyBrandTokens(publicSiteSanitizedHtml, branding);
const inlineStyles = [
branding ? `:root {\n --brand-primary: ${branding.colors.primary};\n --brand-accent: ${branding.colors.accent};\n --brand-background: ${branding.colors.background};\n --brand-text: ${branding.colors.text};\n}` : '',
publicSiteBaseCss,
publicSiteSanitizedCss ? `/* Custom styles */\n${publicSiteSanitizedCss}` : ''
].filter(Boolean).join('\n\n');
const logo = branding?.logoUrl ? `<img src="${branding.logoUrl}" alt="${branding.companyName || 'Brand logo'}" class="brand-logo" loading="lazy" decoding="async" />` : '';
const tagline = branding?.companyTagline ? `<p class="brand-tagline">${branding.companyTagline}</p>` : '';
const support = branding?.supportEmail ? `<a href="mailto:${branding.supportEmail}">${branding.supportEmail}</a>` : '';
const footerNote = branding?.footerText ? `<p>${branding.footerText}</p>` : '';
const displayName = branding?.companyName || 'Celebration Stories';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>${inlineStyles}</style>
</head>
<body>
<div class="site-shell">
<header class="site-header">
<div class="header-inner">
<div class="brand">
${logo}
<div class="brand-copy">
<p class="brand-label">${displayName}</p>
${tagline}
</div>
</div>
<nav class="site-nav">
<a href="#collections">Collections</a>
<a href="#features">Features</a>
<a href="#stories">Stories</a>
<a href="#contact">Contact</a>
</nav>
</div>
</header>
<main class="site-main">
${substitutedHtml}
</main>
<footer class="site-footer" id="contact">
<div class="footer-inner">
<div>
<h2>${displayName}</h2>
${footerNote}
</div>
<div class="footer-contact">
<span>${support}</span>
</div>
</div>
</footer>
</div>
</body>
</html>`;
}, [publicSiteBranding, publicSiteDefaults, publicSiteSanitizedHtml, publicSiteBaseCss, publicSiteSanitizedCss]);
const publicSiteLoading = isLoadingAdminSettings || isLoadingPublicDefaults;
return (
<div>
<div className="mb-6">
@@ -79,6 +285,135 @@ export const CMSPage: React.FC = () => {
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
</div>
<div className="mb-8">
<Card className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-primary-600 mb-1">
<Globe className="w-5 h-5" />
<span className="text-sm font-semibold uppercase tracking-wide">{t('settings.publicSite.badge')}</span>
</div>
<h2 className="text-2xl font-semibold text-neutral-900">{t('settings.publicSite.title')}</h2>
<p className="text-neutral-600 mt-1 max-w-2xl">{t('settings.publicSite.subtitle')}</p>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
className="sr-only"
checked={publicSiteEnabled}
onChange={() => setPublicSiteEnabled((prev) => !prev)}
/>
<span
aria-hidden="true"
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
publicSiteEnabled ? 'bg-primary-600' : 'bg-neutral-300'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
publicSiteEnabled ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</span>
<span className="text-sm font-medium text-neutral-700">
{publicSiteEnabled ? t('settings.publicSite.enabled') : t('settings.publicSite.disabled')}
</span>
</label>
</div>
{publicSiteLoading ? (
<div className="flex items-center justify-center min-h-[240px]">
<Loading size="lg" text={t('settings.publicSite.loading')} />
</div>
) : (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
<Sparkles className="w-4 h-4 text-primary-500" />
{t('settings.publicSite.htmlLabel')}
</label>
<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"
value={publicSiteHtml}
onChange={(event) => setPublicSiteHtml(event.target.value)}
disabled={!publicSiteEnabled}
placeholder={t('settings.publicSite.htmlPlaceholder') || ''}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.publicSite.htmlHelp')}
</p>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
<ShieldCheck className="w-4 h-4 text-primary-500" />
{t('settings.publicSite.cssLabel')}
</label>
<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"
value={publicSiteCss}
onChange={(event) => setPublicSiteCss(event.target.value)}
disabled={!publicSiteEnabled}
placeholder={t('settings.publicSite.cssPlaceholder') || ''}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.publicSite.cssHelp')}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button
variant="primary"
onClick={() => publicSiteSaveMutation.mutate()}
disabled={publicSiteSaveMutation.isPending}
isLoading={publicSiteSaveMutation.isPending}
>
{publicSiteSaveMutation.isPending ? t('settings.publicSite.saving') : t('settings.publicSite.saveCta')}
</Button>
<Button
variant="secondary"
onClick={() => publicSiteResetMutation.mutate()}
disabled={publicSiteResetMutation.isPending}
isLoading={publicSiteResetMutation.isPending}
>
{publicSiteResetMutation.isPending ? t('settings.publicSite.resetting') : t('settings.publicSite.resetCta')}
</Button>
</div>
<div className="rounded-lg bg-neutral-50 border border-neutral-200 p-3 text-xs text-neutral-600 leading-relaxed">
<p className="font-semibold mb-1">{t('settings.publicSite.sanitizationNotice')}</p>
<p>{t('settings.publicSite.htmlHelp')}</p>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-800 uppercase tracking-wide">
{t('settings.publicSite.previewTitle')}
</h3>
<span className="text-xs text-neutral-500">{t('settings.publicSite.previewSandboxed')}</span>
</div>
{publicSiteEnabled ? (
<div className="rounded-xl border border-neutral-200 overflow-hidden shadow-sm bg-white">
<iframe
title="public-site-preview"
sandbox="allow-same-origin"
className="w-full h-[480px] bg-white"
srcDoc={publicSitePreview}
/>
</div>
) : (
<div className="rounded-xl border border-dashed border-neutral-300 bg-neutral-50 p-8 text-center text-sm text-neutral-500">
{t('settings.publicSite.previewDisabled')}
</div>
)}
</div>
</div>
)}
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Page Selection */}
<div className="lg:col-span-1">
@@ -200,4 +535,4 @@ export const CMSPage: React.FC = () => {
</div>
</div>
);
};
};
+333 -5
View File
@@ -1,14 +1,16 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Save, FileText, Globe, Clock } from 'lucide-react';
import { FileText, Globe, Clock, Sparkles, ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { debounce } from 'lodash';
import DOMPurify from 'dompurify';
import { Button, Card, Input, Loading } from '../../components/common';
import { CMSEditor } from '../../components/admin/CMSEditor';
import { cmsService } from '../../services/cms.service';
import type { CMSPage as CMSPageType } from '../../services/cms.service';
import { settingsService, PublicSiteBranding } from '../../services/settings.service';
export const CMSPageEnhanced: React.FC = () => {
const { t } = useTranslation();
@@ -19,6 +21,11 @@ export const CMSPageEnhanced: React.FC = () => {
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [isAutoSaving, setIsAutoSaving] = useState(false);
const [publicSiteEnabled, setPublicSiteEnabled] = useState(false);
const [publicSiteHtml, setPublicSiteHtml] = useState('');
const [publicSiteCss, setPublicSiteCss] = useState('');
const [publicSiteBaseCss, setPublicSiteBaseCss] = useState('');
const [publicSiteBranding, setPublicSiteBranding] = useState<PublicSiteBranding | undefined>(undefined);
// Fetch CMS pages
const { data: pages, isLoading } = useQuery({
@@ -26,6 +33,16 @@ export const CMSPageEnhanced: React.FC = () => {
queryFn: cmsService.getPages,
});
const { data: adminSettings, isLoading: isLoadingAdminSettings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
});
const { data: publicSiteDefaults, isLoading: isLoadingPublicDefaults } = useQuery({
queryKey: ['public-site-defaults'],
queryFn: () => settingsService.getPublicSiteDefaults(),
});
// Update page mutation
const updateMutation = useMutation({
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
@@ -45,6 +62,53 @@ export const CMSPageEnhanced: React.FC = () => {
},
});
const publicSiteSaveMutation = useMutation({
mutationFn: async () => {
const trimmedHtml = publicSiteHtml.trim();
if (publicSiteEnabled && !trimmedHtml) {
throw new Error('PUBLIC_SITE_HTML_REQUIRED');
}
await settingsService.updatePublicSite({
enabled: publicSiteEnabled,
html: trimmedHtml || '',
css: publicSiteCss,
});
},
onSuccess: async () => {
toast.success(t('settings.publicSite.saveSuccess'));
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
]);
},
onError: (error: any) => {
if (error?.message === 'PUBLIC_SITE_HTML_REQUIRED') {
toast.error(t('settings.publicSite.htmlRequired'));
return;
}
toast.error(t('settings.publicSite.saveError'));
},
});
const publicSiteResetMutation = useMutation({
mutationFn: () => settingsService.resetPublicSite(),
onSuccess: async (data) => {
toast.success(t('settings.publicSite.resetSuccess'));
setPublicSiteHtml(data.html || '');
setPublicSiteCss(data.css || '');
setPublicSiteBaseCss(data.baseCss || '');
setPublicSiteBranding(data.branding ?? publicSiteDefaults?.branding);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
]);
},
onError: () => {
toast.error(t('settings.publicSite.resetError'));
},
});
// Auto-save functionality
const autoSave = useCallback(
debounce(() => {
@@ -59,6 +123,23 @@ export const CMSPageEnhanced: React.FC = () => {
[hasUnsavedChanges, editForm, selectedPage]
);
useEffect(() => {
if (publicSiteDefaults) {
setPublicSiteBaseCss(publicSiteDefaults.baseCss || '');
setPublicSiteBranding(publicSiteDefaults.branding);
}
}, [publicSiteDefaults]);
useEffect(() => {
if (!adminSettings) {
return;
}
setPublicSiteEnabled(Boolean(adminSettings.general_public_site_enabled));
setPublicSiteHtml((adminSettings.general_public_site_html as string) || '');
setPublicSiteCss((adminSettings.general_public_site_custom_css as string) || '');
}, [adminSettings]);
// Trigger auto-save when content changes
useEffect(() => {
if (hasUnsavedChanges) {
@@ -113,6 +194,126 @@ export const CMSPageEnhanced: React.FC = () => {
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);
const currentPage = pages?.find(p => p.slug === selectedPage);
const publicSiteSanitizedHtml = useMemo(() => DOMPurify.sanitize(publicSiteHtml || '', {
ALLOWED_TAGS: [
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div', 'em',
'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span', 'strong', 'sup',
'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul'
],
ALLOWED_ATTR: ['class', 'id', 'role', 'aria-label', 'aria-hidden', 'href', 'target', 'rel', 'src', 'alt', 'title', 'loading', 'decoding', 'width', 'height'],
ALLOW_UNKNOWN_PROTOCOLS: false,
ADD_ATTR: ['data-*'],
}), [publicSiteHtml]);
const sanitizeCss = (css: string) => {
if (!css) {
return '';
}
let sanitized = css;
const disallowedPatterns = [
/@import[^;]+;?/gi,
/@charset[^;]+;?/gi,
/expression\s*\([^)]*\)/gi,
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
];
disallowedPatterns.forEach((pattern) => {
sanitized = sanitized.replace(pattern, '');
});
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
const MAX_LENGTH = 100 * 1024;
if (sanitized.length > MAX_LENGTH) {
sanitized = sanitized.slice(0, MAX_LENGTH);
}
return sanitized.trim();
};
const publicSiteSanitizedCss = useMemo(() => sanitizeCss(publicSiteCss || ''), [publicSiteCss]);
const applyBrandTokens = (html: string, branding: PublicSiteBranding | undefined) => {
if (!html || !branding) {
return html;
}
const tokens: Record<string, string> = {
company_name: branding.companyName || '',
company_tagline: branding.companyTagline || '',
support_email: branding.supportEmail || '',
};
return html.replace(/\{\{\s*(company_name|company_tagline|support_email)\s*\}\}/gi, (_, key: string) => tokens[key] || '');
};
const publicSitePreview = useMemo(() => {
const branding = publicSiteBranding || publicSiteDefaults?.branding;
const substitutedHtml = applyBrandTokens(publicSiteSanitizedHtml, branding);
const inlineStyles = [
branding ? `:root {\n --brand-primary: ${branding.colors.primary};\n --brand-accent: ${branding.colors.accent};\n --brand-background: ${branding.colors.background};\n --brand-text: ${branding.colors.text};\n}` : '',
publicSiteBaseCss,
publicSiteSanitizedCss ? `/* Custom styles */\n${publicSiteSanitizedCss}` : ''
].filter(Boolean).join('\n\n');
const logo = branding?.logoUrl ? `<img src="${branding.logoUrl}" alt="${branding.companyName || 'Brand logo'}" class="brand-logo" loading="lazy" decoding="async" />` : '';
const tagline = branding?.companyTagline ? `<p class="brand-tagline">${branding.companyTagline}</p>` : '';
const support = branding?.supportEmail ? `<a href="mailto:${branding.supportEmail}">${branding.supportEmail}</a>` : '';
const footerNote = branding?.footerText ? `<p>${branding.footerText}</p>` : '';
const displayName = branding?.companyName || 'Celebration Stories';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>${inlineStyles}</style>
</head>
<body>
<div class="site-shell">
<header class="site-header">
<div class="header-inner">
<div class="brand">
${logo}
<div class="brand-copy">
<p class="brand-label">${displayName}</p>
${tagline}
</div>
</div>
<nav class="site-nav">
<a href="#collections">Collections</a>
<a href="#features">Features</a>
<a href="#stories">Stories</a>
<a href="#contact">Contact</a>
</nav>
</div>
</header>
<main class="site-main">
${substitutedHtml}
</main>
<footer class="site-footer" id="contact">
<div class="footer-inner">
<div>
<h2>${displayName}</h2>
${footerNote}
</div>
<div class="footer-contact">
<span>${support}</span>
</div>
</div>
</footer>
</div>
</body>
</html>`;
}, [publicSiteBranding, publicSiteDefaults, publicSiteSanitizedHtml, publicSiteBaseCss, publicSiteSanitizedCss]);
const publicSiteLoading = isLoadingAdminSettings || isLoadingPublicDefaults;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
@@ -121,8 +322,6 @@ export const CMSPageEnhanced: React.FC = () => {
);
}
const currentPage = pages?.find(p => p.slug === selectedPage);
return (
<div>
<div className="mb-6">
@@ -130,6 +329,135 @@ export const CMSPageEnhanced: React.FC = () => {
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
</div>
<div className="mb-8">
<Card className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-primary-600 mb-1">
<Globe className="w-5 h-5" />
<span className="text-sm font-semibold uppercase tracking-wide">{t('settings.publicSite.badge')}</span>
</div>
<h2 className="text-2xl font-semibold text-neutral-900">{t('settings.publicSite.title')}</h2>
<p className="text-neutral-600 mt-1 max-w-2xl">{t('settings.publicSite.subtitle')}</p>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
className="sr-only"
checked={publicSiteEnabled}
onChange={() => setPublicSiteEnabled((prev) => !prev)}
/>
<span
aria-hidden="true"
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
publicSiteEnabled ? 'bg-primary-600' : 'bg-neutral-300'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
publicSiteEnabled ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</span>
<span className="text-sm font-medium text-neutral-700">
{publicSiteEnabled ? t('settings.publicSite.enabled') : t('settings.publicSite.disabled')}
</span>
</label>
</div>
{publicSiteLoading ? (
<div className="flex items-center justify-center min-h-[240px]">
<Loading size="lg" text={t('settings.publicSite.loading')} />
</div>
) : (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
<Sparkles className="w-4 h-4 text-primary-500" />
{t('settings.publicSite.htmlLabel')}
</label>
<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"
value={publicSiteHtml}
onChange={(event) => setPublicSiteHtml(event.target.value)}
disabled={!publicSiteEnabled}
placeholder={t('settings.publicSite.htmlPlaceholder') || ''}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.publicSite.htmlHelp')}
</p>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
<ShieldCheck className="w-4 h-4 text-primary-500" />
{t('settings.publicSite.cssLabel')}
</label>
<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"
value={publicSiteCss}
onChange={(event) => setPublicSiteCss(event.target.value)}
disabled={!publicSiteEnabled}
placeholder={t('settings.publicSite.cssPlaceholder') || ''}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.publicSite.cssHelp')}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button
variant="primary"
onClick={() => publicSiteSaveMutation.mutate()}
disabled={publicSiteSaveMutation.isPending}
isLoading={publicSiteSaveMutation.isPending}
>
{publicSiteSaveMutation.isPending ? t('settings.publicSite.saving') : t('settings.publicSite.saveCta')}
</Button>
<Button
variant="secondary"
onClick={() => publicSiteResetMutation.mutate()}
disabled={publicSiteResetMutation.isPending}
isLoading={publicSiteResetMutation.isPending}
>
{publicSiteResetMutation.isPending ? t('settings.publicSite.resetting') : t('settings.publicSite.resetCta')}
</Button>
</div>
<div className="rounded-lg bg-neutral-50 border border-neutral-200 p-3 text-xs text-neutral-600 leading-relaxed">
<p className="font-semibold mb-1">{t('settings.publicSite.sanitizationNotice')}</p>
<p>{t('settings.publicSite.htmlHelp')}</p>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-800 uppercase tracking-wide">
{t('settings.publicSite.previewTitle')}
</h3>
<span className="text-xs text-neutral-500">{t('settings.publicSite.previewSandboxed')}</span>
</div>
{publicSiteEnabled ? (
<div className="rounded-xl border border-neutral-200 overflow-hidden shadow-sm bg-white">
<iframe
title="public-site-preview"
sandbox="allow-same-origin"
className="w-full h-[480px] bg-white"
srcDoc={publicSitePreview}
/>
</div>
) : (
<div className="rounded-xl border border-dashed border-neutral-300 bg-neutral-50 p-8 text-center text-sm text-neutral-500">
{t('settings.publicSite.previewDisabled')}
</div>
)}
</div>
</div>
)}
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Page Selection */}
<div className="lg:col-span-1">
@@ -287,4 +615,4 @@ export const CMSPageEnhanced: React.FC = () => {
</div>
</div>
);
};
};
@@ -69,7 +69,7 @@ const EVENT_TYPES = [
export const CreateEventPageEnhanced: React.FC = () => {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const { format } = useLocalizedDate();
const isMountedRef = useRef(true);
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
@@ -632,4 +632,4 @@ export const CreateEventPageEnhanced: React.FC = () => {
</form>
</div>
);
};
};
+12 -9
View File
@@ -29,10 +29,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { archiveService } from '../../services/archive.service';
import { externalMediaService } from '../../services/externalMedia.service';
import { photosService, AdminPhoto } from '../../services/photos.service';
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.service';
import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { useTranslation } from 'react-i18next';
const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => void }> = ({ value, onChange }) => {
const { t } = useTranslation();
@@ -121,8 +120,12 @@ export const EventDetailsPage: React.FC = () => {
allow_likes: true,
allow_comments: true,
allow_favorites: true,
require_moderation: true,
show_public_stats: false
require_name_email: false,
moderate_comments: true,
show_feedback_to_guests: true,
enable_rate_limiting: false,
rate_limit_window_minutes: 15,
rate_limit_max_requests: 10,
});
const [copiedLink, setCopiedLink] = useState(false);
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
@@ -136,10 +139,10 @@ export const EventDetailsPage: React.FC = () => {
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
// Photo filters state
const [photoFilters, setPhotoFilters] = useState({
const [photoFilters, setPhotoFilters] = useState<PhotoFilterParams>({
category_id: undefined as number | null | undefined,
search: '',
sort: 'date' as 'date' | 'name' | 'size' | 'rating',
sort: 'date',
order: 'desc' as 'asc' | 'desc'
});
@@ -1004,9 +1007,9 @@ export const EventDetailsPage: React.FC = () => {
<PhotoFilters
categories={categories}
selectedCategory={photoFilters.category_id}
searchTerm={photoFilters.search}
sortBy={photoFilters.sort}
sortOrder={photoFilters.order}
searchTerm={photoFilters.search ?? ''}
sortBy={photoFilters.sort ?? 'date'}
sortOrder={photoFilters.order ?? 'desc'}
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
+11 -8
View File
@@ -11,7 +11,6 @@ import {
Download,
Shield,
CheckCircle,
XCircle,
Eye,
EyeOff,
Trash2
@@ -25,7 +24,7 @@ import { FeedbackSettings } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { feedbackService } from '../../services/feedback.service';
import type { PhotoFeedback, FeedbackAnalytics } from '../../services/feedback.service';
import type { PhotoFeedback, FeedbackAnalytics, FeedbackResponse } from '../../services/feedback.service';
export const EventFeedbackPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
@@ -44,7 +43,7 @@ export const EventFeedbackPage: React.FC = () => {
// Fetch event details
const { data: event, isLoading: eventLoading } = useQuery({
queryKey: ['event', id],
queryFn: () => eventsService.getEvent(id!),
queryFn: () => eventsService.getEvent(Number(id)),
enabled: !!id
});
@@ -56,14 +55,14 @@ export const EventFeedbackPage: React.FC = () => {
});
// Fetch feedback list
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
const { data: feedbackData, isLoading: feedbackLoading } = useQuery<FeedbackResponse>({
queryKey: ['event-feedback', id, feedbackFilter],
queryFn: () => feedbackService.getEventFeedback(id!, feedbackFilter),
enabled: !!id && activeTab === 'feedback'
});
// Fetch analytics
const { data: analytics, isLoading: analyticsLoading } = useQuery({
const { data: analytics, isLoading: analyticsLoading } = useQuery<FeedbackAnalytics>({
queryKey: ['feedback-analytics', id],
queryFn: () => feedbackService.getEventFeedbackAnalytics(id!),
enabled: !!id && activeTab === 'analytics'
@@ -133,6 +132,10 @@ export const EventFeedbackPage: React.FC = () => {
return <div>{t('events.notFound', 'Event not found')}</div>;
}
const pagination = feedbackData?.pagination;
const perPage = pagination?.per_page ?? feedbackFilter.limit ?? 20;
const totalPages = perPage ? Math.max(1, Math.ceil((pagination?.total ?? 0) / perPage)) : 1;
return (
<div className="max-w-7xl mx-auto">
{/* Header */}
@@ -360,7 +363,7 @@ export const EventFeedbackPage: React.FC = () => {
)}
{/* Pagination */}
{feedbackData?.pagination && feedbackData.pagination.pages > 1 && (
{pagination && totalPages > 1 && (
<div className="flex justify-center gap-2 mt-4">
<Button
variant="outline"
@@ -373,13 +376,13 @@ export const EventFeedbackPage: React.FC = () => {
<span className="flex items-center px-3 text-sm text-neutral-600">
{t('common.pageOf', 'Page {{current}} of {{total}}', {
current: feedbackFilter.page,
total: feedbackData.pagination.pages
total: totalPages
})}
</span>
<Button
variant="outline"
size="sm"
disabled={feedbackFilter.page === feedbackData.pagination.pages}
disabled={feedbackFilter.page >= totalPages}
onClick={() => setFeedbackFilter({ ...feedbackFilter, page: feedbackFilter.page + 1 })}
>
{t('common.next', 'Next')}
+2 -3
View File
@@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect, useRef } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Plus,
@@ -11,7 +11,6 @@ import {
Download,
Trash2,
Calendar,
Users,
Image,
Activity
} from 'lucide-react';
@@ -562,4 +561,4 @@ export const EventsListPage: React.FC = () => {
);
};
EventsListPage.displayName = 'EventsListPage';
EventsListPage.displayName = 'EventsListPage';
+464 -52
View File
@@ -21,6 +21,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
const BYTES_PER_GB = 1024 * 1024 * 1024;
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
const queryClient = useQueryClient();
@@ -82,6 +84,12 @@ export const SettingsPage: React.FC = () => {
umami_share_url: ''
});
const [softLimitGb, setSoftLimitGb] = useState<number | ''>('');
const [softLimitDirty, setSoftLimitDirty] = useState(false);
const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>('');
const [availableOverrideGb, setAvailableOverrideGb] = useState<number | ''>('');
const [overrideDirty, setOverrideDirty] = useState(false);
React.useEffect(() => {
if (settings) {
// Set the language if it's different from current
@@ -109,14 +117,15 @@ export const SettingsPage: React.FC = () => {
// Extract security settings
setSecuritySettings({
require_password: settings.security_require_password || true,
password_min_length: settings.security_password_min_length || 8,
enable_2fa: settings.security_enable_2fa || false,
session_timeout_minutes: settings.security_session_timeout_minutes || 60,
max_login_attempts: settings.security_max_login_attempts || 5,
enable_recaptcha: settings.security_enable_recaptcha || false,
recaptcha_site_key: settings.security_recaptcha_site_key || '',
recaptcha_secret_key: settings.security_recaptcha_secret_key || ''
require_password: settings.security_require_password ?? true,
password_min_length: settings.security_password_min_length ?? 8,
password_complexity: settings.security_password_complexity ?? 'moderate',
enable_2fa: settings.security_enable_2fa ?? false,
session_timeout_minutes: settings.security_session_timeout_minutes ?? 60,
max_login_attempts: settings.security_max_login_attempts ?? 5,
enable_recaptcha: settings.security_enable_recaptcha ?? false,
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
});
// Extract analytics settings
@@ -129,6 +138,66 @@ export const SettingsPage: React.FC = () => {
}
}, [settings, i18n]);
React.useEffect(() => {
if (!settings || overrideDirty) {
return;
}
const capacityOverrideBytes = settings.general_storage_capacity_override_bytes ?? null;
const availableOverrideBytes = settings.general_storage_available_override_bytes ?? null;
setCapacityOverrideGb(
capacityOverrideBytes != null
? Number((capacityOverrideBytes / BYTES_PER_GB).toFixed(2))
: ''
);
setAvailableOverrideGb(
availableOverrideBytes != null
? Number((availableOverrideBytes / BYTES_PER_GB).toFixed(2))
: ''
);
}, [settings, overrideDirty]);
React.useEffect(() => {
if (!storageInfo) {
return;
}
if (softLimitDirty) {
return;
}
const currentLimit = storageInfo.configured_soft_limit ?? storageInfo.storage_soft_limit ?? null;
if (currentLimit === null || currentLimit === undefined) {
setSoftLimitGb('');
return;
}
const limitGb = Number((currentLimit / BYTES_PER_GB).toFixed(2));
setSoftLimitGb(limitGb);
}, [storageInfo, softLimitDirty]);
React.useEffect(() => {
if (!storageInfo || overrideDirty) {
return;
}
if (storageInfo.disk_override_source === 'env') {
setCapacityOverrideGb(
storageInfo.disk_total
? Number((storageInfo.disk_total / BYTES_PER_GB).toFixed(2))
: ''
);
setAvailableOverrideGb(
storageInfo.disk_available
? Number((storageInfo.disk_available / BYTES_PER_GB).toFixed(2))
: ''
);
}
}, [storageInfo, overrideDirty]);
// Save mutations
const saveGeneralMutation = useMutation({
mutationFn: async () => {
@@ -189,6 +258,103 @@ export const SettingsPage: React.FC = () => {
}
});
const saveSoftLimitMutation = useMutation({
mutationFn: async (limitBytes: number | null) => {
return settingsService.updateSettings({
general_storage_soft_limit_bytes: limitBytes,
});
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
setSoftLimitDirty(false);
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['admin-storage-info'] });
queryClient.invalidateQueries({ queryKey: ['storage-info'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const handleSaveSoftLimit = () => {
if (saveSoftLimitMutation.isPending) {
return;
}
if (softLimitGb === '') {
saveSoftLimitMutation.mutate(null);
return;
}
const numericValue = Number(softLimitGb);
if (!Number.isFinite(numericValue) || numericValue < 0) {
toast.error(t('settings.storage.invalidSoftLimit'));
return;
}
const limitBytes = Math.max(0, Math.round(numericValue * BYTES_PER_GB));
saveSoftLimitMutation.mutate(limitBytes);
};
const saveCapacityOverrideMutation = useMutation({
mutationFn: async (payload: { capacity: number | null; available: number | null }) => {
return settingsService.updateSettings({
general_storage_capacity_override_bytes: payload.capacity,
general_storage_available_override_bytes: payload.available,
});
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
setOverrideDirty(false);
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
queryClient.invalidateQueries({ queryKey: ['admin-storage-info'] });
queryClient.invalidateQueries({ queryKey: ['storage-info'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const handleSaveCapacityOverride = () => {
if (saveCapacityOverrideMutation.isPending) {
return;
}
if (capacityOverrideGb === '' && availableOverrideGb !== '') {
toast.error(t('settings.storage.capacityRequiredForAvailable'));
return;
}
const capacityValue = capacityOverrideGb === '' ? null : Number(capacityOverrideGb);
const availableValue = availableOverrideGb === '' ? null : Number(availableOverrideGb);
if ((capacityValue !== null && !Number.isFinite(capacityValue)) || (availableValue !== null && !Number.isFinite(availableValue))) {
toast.error(t('settings.storage.invalidSoftLimit'));
return;
}
if (capacityValue !== null && capacityValue < 0) {
toast.error(t('settings.storage.invalidSoftLimit'));
return;
}
if (availableValue !== null && availableValue < 0) {
toast.error(t('settings.storage.invalidSoftLimit'));
return;
}
const capacityBytes = capacityValue === null ? null : Math.max(0, Math.round(capacityValue * BYTES_PER_GB));
const availableBytes = availableValue === null ? null : Math.max(0, Math.round(availableValue * BYTES_PER_GB));
if (capacityBytes !== null && availableBytes !== null && availableBytes > capacityBytes) {
toast.error(t('settings.storage.availableExceedsCapacity'));
return;
}
saveCapacityOverrideMutation.mutate({ capacity: capacityBytes, available: availableBytes });
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
@@ -455,52 +621,298 @@ export const SettingsPage: React.FC = () => {
{activeTab === 'status' && (
<div className="space-y-6">
{/* Storage Overview */}
{storageInfo && (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<HardDrive className="w-5 h-5" />
{t('settings.systemStatus.storageOverview')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.storage.totalUsed')}</p>
<p className="text-2xl font-bold text-neutral-900">
{settingsService.formatBytes(storageInfo.total_used)}
</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.storage.archiveStorage')}</p>
<p className="text-2xl font-bold text-neutral-900">
{settingsService.formatBytes(storageInfo.archive_storage)}
</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.storage.storageLimit')}</p>
<p className="text-2xl font-bold text-neutral-900">
{settingsService.formatBytes(storageInfo.storage_limit)}
</p>
</div>
</div>
{storageInfo && (() => {
const configuredSoftLimit = storageInfo.configured_soft_limit ?? null;
const effectiveSoftLimit = storageInfo.storage_soft_limit || storageInfo.storage_limit || storageInfo.recommended_soft_limit || 1;
const safeEffectiveSoftLimit = Math.max(effectiveSoftLimit, 1);
const usageRatio = storageInfo.total_used / safeEffectiveSoftLimit;
const usagePercentage = Math.round(usageRatio * 100);
const usageWidth = Math.min(usageRatio * 100, 100);
const overSoftLimit = configuredSoftLimit != null
? storageInfo.total_used >= configuredSoftLimit
: usagePercentage >= 100;
const limitDisplayBytes = configuredSoftLimit ?? storageInfo.storage_soft_limit ?? storageInfo.storage_limit ?? null;
const limitDisplay = limitDisplayBytes != null
? settingsService.formatBytes(limitDisplayBytes)
: t('settings.storage.unlimited');
const diskCapacityBytes = storageInfo.disk_total ?? storageInfo.disk_total_raw ?? null;
const diskAvailableBytes = storageInfo.disk_available ?? storageInfo.disk_available_raw ?? null;
const diskFreeBytes = storageInfo.disk_free ?? storageInfo.disk_free_raw ?? null;
<div className="mb-4">
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-600">{t('settings.storage.storageUsage')}</span>
<span className="font-medium">
{Math.round((storageInfo.total_used / storageInfo.storage_limit) * 100)}%
</span>
const diskCapacityDisplay = diskCapacityBytes != null
? settingsService.formatBytes(diskCapacityBytes)
: null;
const diskAvailableDisplay = diskAvailableBytes != null
? settingsService.formatBytes(diskAvailableBytes)
: null;
const diskFreeDisplay = diskFreeBytes != null
? settingsService.formatBytes(diskFreeBytes)
: null;
const recommendedDisplay = storageInfo.recommended_soft_limit != null
? settingsService.formatBytes(storageInfo.recommended_soft_limit)
: null;
const progressColor = overSoftLimit
? 'bg-red-600'
: usagePercentage >= 90
? 'bg-amber-500'
: 'bg-primary-600';
const limitCardClass = overSoftLimit ? 'bg-amber-50 border border-amber-200' : 'bg-neutral-50';
const limitValueClass = overSoftLimit ? 'text-amber-700' : 'text-neutral-900';
const limitDescriptorClass = overSoftLimit ? 'text-amber-700 font-semibold' : 'text-neutral-600';
const recommendedDescriptorValue = (recommendedDisplay ?? limitDisplay);
const diskMetricsReliable = storageInfo.disk_metrics_reliable;
const overrideSource = storageInfo.disk_override_source;
const overrideControlled = overrideSource === 'env';
const diskSummaryCards: Array<{ label: string; value: string }> = [];
if (diskCapacityDisplay && (diskMetricsReliable || overrideSource)) {
const label = storageInfo.disk_total != null
? t('settings.storage.diskCapacity')
: t('settings.storage.diskCapacityReported');
diskSummaryCards.push({ label, value: diskCapacityDisplay });
}
if (diskAvailableDisplay && (diskMetricsReliable || overrideSource)) {
const label = storageInfo.disk_available != null
? t('settings.storage.diskAvailable')
: t('settings.storage.diskAvailableReported');
diskSummaryCards.push({ label, value: diskAvailableDisplay });
}
if (diskFreeDisplay && storageInfo.disk_free == null && (diskMetricsReliable || overrideSource)) {
diskSummaryCards.push({
label: t('settings.storage.diskFreeReported'),
value: diskFreeDisplay
});
}
if (recommendedDisplay) {
diskSummaryCards.push({
label: t('settings.storage.recommendedSoftLimit'),
value: recommendedDisplay
});
}
return (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<HardDrive className="w-5 h-5" />
{t('settings.systemStatus.storageOverview')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.storage.totalUsed')}</p>
<p className="text-2xl font-bold text-neutral-900">
{settingsService.formatBytes(storageInfo.total_used)}
</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.storage.archiveStorage')}</p>
<p className="text-2xl font-bold text-neutral-900">
{settingsService.formatBytes(storageInfo.archive_storage)}
</p>
</div>
<div className={`rounded-lg p-4 ${limitCardClass}`}>
<p className="text-sm text-neutral-600">{t('settings.storage.storageLimit')}</p>
<p className={`text-2xl font-bold ${limitValueClass}`}>
{limitDisplay}
</p>
<p className={`text-xs mt-1 ${limitDescriptorClass}`}>
{storageInfo.soft_limit_configured
? t('admin.storageSoftLimitConfigured', { limit: limitDisplay })
: t('admin.storageSoftLimitRecommended', { limit: recommendedDescriptorValue })}
</p>
</div>
</div>
<div className="w-full bg-neutral-200 rounded-full h-3">
<div
className="bg-primary-600 h-3 rounded-full transition-all"
style={{
width: `${Math.min((storageInfo.total_used / storageInfo.storage_limit) * 100, 100)}%`
}}
/>
<div className="mb-4">
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-600">{t('settings.storage.storageUsage')}</span>
<span className={`font-medium ${overSoftLimit ? 'text-red-600' : 'text-neutral-900'}`}>
{usagePercentage}%
</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-3">
<div
className={`${progressColor} h-3 rounded-full transition-all`}
style={{ width: `${usageWidth}%` }}
/>
</div>
</div>
</div>
</Card>
)}
<div className="border-t border-neutral-200 pt-4 mt-6 space-y-4">
<p className="text-sm text-neutral-600">
{t('settings.storage.storageLimitHelper')}
</p>
{diskSummaryCards.length > 0 && (diskMetricsReliable || overrideSource) && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{diskSummaryCards.map((card) => (
<div key={card.label} className="bg-neutral-50 rounded-lg p-4">
<p className="text-xs text-neutral-500 uppercase tracking-wide">{card.label}</p>
<p className="text-lg font-semibold text-neutral-900 mt-1">{card.value}</p>
</div>
))}
</div>
)}
{!diskMetricsReliable && !overrideSource && (
<p className="text-xs text-neutral-500">
{t('settings.storage.diskMetricsUnavailable')}
</p>
)}
<div className="grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)]">
<Input
type="number"
inputMode="decimal"
min={0}
step="0.1"
value={softLimitGb === '' ? '' : softLimitGb}
onChange={(e) => {
const value = e.target.value;
setSoftLimitDirty(true);
if (value === '') {
setSoftLimitGb('');
return;
}
const numeric = Number(value);
if (Number.isNaN(numeric)) {
return;
}
setSoftLimitGb(numeric);
}}
label={t('settings.storage.softLimitInputLabel')}
helperText={t('settings.storage.softLimitHelper')}
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
/>
<p className="text-xs text-neutral-500">
{t('settings.storage.limitNotEnforced')}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => {
if (storageInfo.recommended_soft_limit != null) {
const value = Number((storageInfo.recommended_soft_limit / BYTES_PER_GB).toFixed(2));
setSoftLimitGb(value);
setSoftLimitDirty(true);
}
}}
disabled={storageInfo.recommended_soft_limit == null}
>
{t('settings.storage.applyRecommended')}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => {
if (storageInfo.disk_available != null) {
const value = Number((storageInfo.disk_available / BYTES_PER_GB).toFixed(2));
setSoftLimitGb(value);
setSoftLimitDirty(true);
}
}}
disabled={storageInfo.disk_available == null}
>
{t('settings.storage.applyAvailable')}
</Button>
</div>
<div className="flex justify-end">
<Button
variant="primary"
size="sm"
onClick={handleSaveSoftLimit}
isLoading={saveSoftLimitMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('settings.storage.saveSoftLimit')}
</Button>
</div>
<div className="border-t border-neutral-200 pt-4 mt-6 space-y-4">
<div>
<p className="text-sm font-medium text-neutral-700">{t('settings.storage.overrideTitle')}</p>
{overrideControlled ? (
<p className="text-xs text-neutral-500 mt-1">{t('settings.storage.diskOverrideEnvNote')}</p>
) : (
<p className="text-xs text-neutral-500 mt-1">{t('settings.storage.diskOverrideSettingsHelp')}</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
type="number"
inputMode="decimal"
min={0}
step="0.1"
value={capacityOverrideGb === '' ? '' : capacityOverrideGb}
onChange={(e) => {
const value = e.target.value;
setOverrideDirty(true);
if (value === '') {
setCapacityOverrideGb('');
return;
}
const numeric = Number(value);
if (Number.isNaN(numeric)) {
return;
}
setCapacityOverrideGb(numeric);
}}
label={t('settings.storage.overrideCapacityLabel')}
helperText={t('settings.storage.overrideCapacityHelper')}
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
disabled={overrideControlled}
/>
<Input
type="number"
inputMode="decimal"
min={0}
step="0.1"
value={availableOverrideGb === '' ? '' : availableOverrideGb}
onChange={(e) => {
const value = e.target.value;
setOverrideDirty(true);
if (value === '') {
setAvailableOverrideGb('');
return;
}
const numeric = Number(value);
if (Number.isNaN(numeric)) {
return;
}
setAvailableOverrideGb(numeric);
}}
label={t('settings.storage.overrideAvailableLabel')}
helperText={t('settings.storage.overrideAvailableHelper')}
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
disabled={overrideControlled}
/>
</div>
<div className="flex justify-end">
<Button
variant="primary"
size="sm"
onClick={handleSaveCapacityOverride}
isLoading={saveCapacityOverrideMutation.isPending}
disabled={overrideControlled}
leftIcon={<Save className="w-4 h-4" />}
>
{t('settings.storage.saveOverride')}
</Button>
</div>
</div>
</div>
</Card>
);
})()}
{/* System Information */}
{systemStatus && (
@@ -968,4 +1380,4 @@ export const SettingsPage: React.FC = () => {
)}
</div>
);
};
};
+5 -3
View File
@@ -32,7 +32,7 @@ export const PreviewPage: React.FC = () => {
const [brandingSettings, setBrandingSettings] = useState<any>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date');
const mockPhotos = useMemo(() => generateMockPhotos(12), []);
const mockEvent = {
@@ -77,6 +77,8 @@ export const PreviewPage: React.FC = () => {
return a.filename.localeCompare(b.filename);
case 'size':
return b.size - a.size;
case 'rating':
return 0;
case 'date':
default:
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
@@ -129,7 +131,7 @@ export const PreviewPage: React.FC = () => {
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortBy={sortBy}
onSortChange={setSortBy}
onSortChange={(sort) => setSortBy(sort)}
photoCount={filteredPhotos.length}
/>
@@ -140,4 +142,4 @@ export const PreviewPage: React.FC = () => {
</div>
</GalleryLayout>
);
};
};