feat: Add CSS template system with custom gallery styling support

## Changes

### CSS Template System
- Added CSS class hooks to gallery components for custom template targeting
- Gallery sidebar, header, footer, and photo cards can now be styled via CSS templates
- CSS variables on :root allow themes to override colors, effects, and spacing

### Gallery Component CSS Classes Added
- `.gallery-page` - Main gallery container
- `.gallery-header` - Top header bar
- `.gallery-sidebar` - Filter/download sidebar
- `.gallery-sidebar-header`, `.gallery-sidebar-title`, `.gallery-sidebar-close`
- `.gallery-sidebar-content`, `.gallery-sidebar-section`
- `.gallery-sidebar-search-input`, `.gallery-sidebar-search-icon`
- `.gallery-sidebar-backdrop` - Mobile overlay
- `.gallery-btn`, `.gallery-btn-download` - Sidebar buttons
- `.gallery-footer` - Footer section
- `.photo-card`, `.photo-grid` - Photo display elements

### CSS Templates (Database)
- Elegant Dark (id=1): Dark navy theme with light text and red accents
- Liquid Glass Light (id=2): iOS 26 frosted glass effect with gradient background

### Bug Fixes
- Fixed CSS variables not inheriting (moved from .gallery-page to :root)
- Fixed sidebar position breaking layout (removed position: relative override)
- Fixed Elegant Dark sidebar text visibility (white on white issue)

### Other Changes
- Settings page refactoring and cleanup
- i18n locale updates for new gallery features
- Vite proxy port configuration fix
- Admin auth route improvements
- CSS templates service updates
This commit is contained in:
Paul Nothaft
2026-01-03 08:59:01 +01:00
parent 97455ab047
commit 0da45e699a
41 changed files with 6112 additions and 2125 deletions
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid } from 'lucide-react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
// import { settingsService } from '../../services/settings.service';
@@ -44,6 +44,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
const [selectedPreset, setSelectedPreset] = useState(presetName);
const [customCss, setCustomCss] = useState(value.customCss || '');
const [showCssInstructions, setShowCssInstructions] = useState(false);
// const logoInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -494,7 +495,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Row 1: Font Size & Border Radius */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.fontSize')}
@@ -525,7 +527,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
<option value="lg">{t('branding.borderRadiusOptions.large')}</option>
</select>
</div>
</div>
{/* Row 2: Shadow Style & Background Pattern */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('branding.shadowStyle')}
@@ -563,7 +568,93 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Custom CSS */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.customCSS')}</h3>
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Code className="w-5 h-5" />
{t('branding.customCSS')}
</h3>
{/* Collapsible Instructions Panel */}
<div className="mb-4">
<button
type="button"
onClick={() => setShowCssInstructions(!showCssInstructions)}
className="flex items-center gap-2 text-sm text-primary-600 hover:text-primary-700 font-medium"
>
<Info className="w-4 h-4" />
{t('branding.cssInstructions.title', 'How to use Custom CSS')}
<ChevronDown className={`w-4 h-4 transition-transform ${showCssInstructions ? 'rotate-180' : ''}`} />
</button>
{showCssInstructions && (
<div className="mt-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200 text-sm space-y-4">
{/* Available CSS Variables */}
<div>
<h4 className="font-semibold text-neutral-900 mb-2">
{t('branding.cssInstructions.variables', 'Theme CSS Variables')}
</h4>
<p className="text-neutral-600 mb-2">
{t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')}
</p>
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
{`--primary-color: ${localTheme.primaryColor || '#5C8762'};
--accent-color: ${localTheme.accentColor || '#22c55e'};
--background-color: ${localTheme.backgroundColor || '#fafafa'};
--text-color: ${localTheme.textColor || '#171717'};
--font-family: ${localTheme.fontFamily || 'Inter, sans-serif'};
--heading-font: ${localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'};`}
</code>
</div>
{/* Custom Gallery Layouts */}
<div>
<h4 className="font-semibold text-neutral-900 mb-2">
{t('branding.cssInstructions.layouts', 'Custom Gallery Layouts')}
</h4>
<p className="text-neutral-600 mb-2">
{t('branding.cssInstructions.layoutsDesc', 'Target gallery elements with these selectors:')}
</p>
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
{`.gallery-container { /* Main gallery wrapper */ }
.gallery-grid { /* Photo grid container */ }
.gallery-item { /* Individual photo card */ }
.gallery-header { /* Header section */ }
.gallery-hero { /* Hero image area */ }
.photo-overlay { /* Photo hover overlay */ }
.photo-actions { /* Like/favorite buttons */ }`}
</code>
</div>
{/* Glassmorphism Example */}
<div>
<h4 className="font-semibold text-neutral-900 mb-2">
{t('branding.cssInstructions.glassEffect', 'Glassmorphism Effect')}
</h4>
<p className="text-neutral-600 mb-2">
{t('branding.cssInstructions.glassEffectDesc', 'Create modern glass effects:')}
</p>
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
{`.glass-panel {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 16px;
}`}
</code>
</div>
{/* Tips */}
<div className="flex items-start gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<Info className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-blue-800 text-xs">
<strong>{t('branding.cssInstructions.tip', 'Tip')}:</strong>{' '}
{t('branding.cssInstructions.tipText', 'Use CSS Templates from Settings > CSS Templates for pre-built designs like Apple Liquid Glass.')}
</div>
</div>
</div>
)}
</div>
<textarea
value={customCss}
onChange={(e) => {
@@ -575,7 +666,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
}
}}
placeholder="/* Add custom CSS here */"
className="w-full h-32 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg"
className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg bg-neutral-50"
/>
<p className="mt-2 text-sm text-neutral-600">
{t('branding.customCSSHelp')}
@@ -118,12 +118,12 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
const heroLogoSize = getLogoDimensions('hero');
return (
<div className="min-h-screen bg-neutral-50">
<div className="gallery-page min-h-screen bg-neutral-50">
{/* Dynamic Favicon */}
<DynamicFavicon />
{/* Header structure */}
<header className={`bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
<header className={`gallery-header bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
{/* For non-grid layouts (excluding hero) - keep the current structure */}
{isNonGridLayout && (
<div className="bg-neutral-50 border-b border-neutral-200">
@@ -145,12 +145,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
isLoading={isDownloading}
className="gallery-btn gallery-btn-download"
>
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
<span className="sm:hidden">{t('common.download')}</span>
</Button>
)}
{/* Logout button */}
{showLogout && onLogout && (
<Button
@@ -158,7 +159,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
size="sm"
leftIcon={<LogOut className="w-4 h-4" />}
onClick={onLogout}
className="sm:min-w-0"
className="gallery-btn gallery-btn-logout sm:min-w-0"
>
<span className="hidden sm:inline">{t('common.logout')}</span>
</Button>
@@ -184,14 +185,14 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{/* Logo - Show custom logo or fallback to PicPeak logo */}
{shouldShowLogo('header') && (
<div className={`flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
<img
src={brandingSettings?.logo_url ?
buildResourceUrl(brandingSettings.logo_url) :
<div className={`gallery-logo-wrapper flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
<img
src={brandingSettings?.logo_url ?
buildResourceUrl(brandingSettings.logo_url) :
'/picpeak-logo-transparent.png'
}
}
alt={brandingSettings?.company_name || 'PicPeak'}
className={`${headerLogoSize.className} w-auto object-contain`}
className={`gallery-logo ${headerLogoSize.className} w-auto object-contain`}
style={headerLogoSize.style}
/>
{shouldShowCompanyName() && brandingSettings?.company_name && (
@@ -253,13 +254,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
isLoading={isDownloading}
className="hidden sm:flex"
className="gallery-btn gallery-btn-download hidden sm:flex"
>
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
<span className="sm:hidden">{t('common.download')}</span>
</Button>
)}
{/* Logout button */}
{showLogout && onLogout && (
<Button
@@ -267,7 +268,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
size="sm"
leftIcon={<LogOut className="w-4 h-4" />}
onClick={onLogout}
className="sm:min-w-0"
className="gallery-btn gallery-btn-logout sm:min-w-0"
>
<span className="hidden sm:inline">{t('common.logout')}</span>
</Button>
@@ -304,7 +305,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{menuButton}
{headerExtra}
</div>
{/* Right side - Action buttons */}
<div className="flex items-center gap-3 flex-shrink-0">
{/* Download all button */}
@@ -315,12 +316,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
isLoading={isDownloading}
className="gallery-btn gallery-btn-download"
>
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
<span className="sm:hidden">{t('common.download')}</span>
</Button>
)}
{/* Logout button */}
{showLogout && onLogout && (
<Button
@@ -328,7 +330,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
size="sm"
leftIcon={<LogOut className="w-4 h-4" />}
onClick={onLogout}
className="sm:min-w-0"
className="gallery-btn gallery-btn-logout sm:min-w-0"
>
<span className="hidden sm:inline">{t('common.logout')}</span>
</Button>
@@ -341,8 +343,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
{isNonGridLayout && (
<div
className="relative text-white overflow-hidden"
<div
className="gallery-hero relative text-white overflow-hidden"
style={{
backgroundColor: theme.accentColor || '#22c55e',
backgroundImage: theme.backgroundPattern !== 'none'
@@ -425,7 +427,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
<main className="container">{children}</main>
{/* Footer */}
<footer className="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-neutral-200">
<div className="container text-center px-4">
{brandingSettings?.support_email && (
<p className="text-xs sm:text-sm text-neutral-600 mb-2">
@@ -110,8 +110,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<>
{/* Backdrop for mobile */}
{isMobile && isOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
<div
className="gallery-sidebar-backdrop fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
onClick={onClose}
/>
)}
@@ -120,17 +120,17 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<div
ref={sidebarRef}
className={`
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-white shadow-xl z-50 transition-transform duration-300 ease-in-out flex flex-col
${isMobile ? 'w-full max-w-sm' : 'w-80'}
${isOpen ? 'translate-x-0' : '-translate-x-full'}
`}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-neutral-200">
<h2 className="text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2>
<div className="gallery-sidebar-header flex items-center justify-between p-4 border-b border-neutral-200">
<h2 className="gallery-sidebar-title text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2>
<button
onClick={onClose}
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
className="gallery-sidebar-close p-2 hover:bg-neutral-100 rounded-lg transition-colors"
aria-label={t('common.close')}
>
<X className="w-5 h-5 text-neutral-600" />
@@ -138,7 +138,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="gallery-sidebar-content flex-1 overflow-y-auto">
{/* Upload Section - Only show on mobile when uploads are allowed */}
{isMobile && allowUploads && onUploadClick && (
<div className="p-4 border-b border-neutral-200">
@@ -159,15 +159,15 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{/* Search Section - Hidden for carousel layout */}
{galleryLayout !== 'carousel' && (
<div className="p-4 border-b border-neutral-200">
<div className="gallery-sidebar-section gallery-sidebar-search p-4 border-b border-neutral-200">
<div className="relative">
<Search className="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-neutral-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t('gallery.searchPlaceholder')}
className="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 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
</div>
@@ -175,12 +175,12 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{/* Download Section - Hidden if gallery is expired or downloads disabled */}
{allowDownloads && (
<div className="p-4 border-b border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<div className="gallery-sidebar-section gallery-sidebar-downloads p-4 border-b border-neutral-200">
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<Download className="w-4 h-4" />
{t('gallery.download')}
</h3>
<div className="space-y-2">
<Button
variant="primary"
@@ -188,7 +188,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
disabled={isDownloading || totalPhotos === 0}
className="w-full"
className="gallery-btn gallery-btn-download w-full"
>
{t('gallery.downloadAll')} ({totalPhotos})
</Button>
@@ -197,7 +197,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
variant={isSelectionMode ? 'secondary' : 'outline'}
size="sm"
onClick={onToggleSelectionMode}
className="w-full"
className="gallery-btn w-full"
>
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
</Button>
@@ -209,7 +209,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadSelected}
disabled={isDownloading}
className="w-full"
className="gallery-btn gallery-btn-download w-full"
>
{t('gallery.downloadSelected', { count: selectedCount })} ({selectedCount})
</Button>
@@ -220,7 +220,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{/* Feedback Filter Section */}
{feedbackEnabled && onFilterChange && (
<div className="p-4 border-b border-neutral-200">
<div className="gallery-sidebar-section gallery-sidebar-feedback p-4 border-b border-neutral-200">
<GalleryFilter
currentFilter={filterType}
onFilterChange={(filter) => {
@@ -239,12 +239,12 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{/* Categories Section - Hidden for carousel layout */}
{galleryLayout !== 'carousel' && categories.length > 0 && (
<div className="p-4 border-b border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<div className="gallery-sidebar-section gallery-sidebar-categories p-4 border-b border-neutral-200">
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<Filter className="w-4 h-4" />
{t('gallery.categories')}
</h3>
<div className="space-y-1">
<button
onClick={() => {
@@ -252,7 +252,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
if (isMobile) onClose();
}}
className={`
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
? 'bg-primary-50 text-primary-700'
: 'hover:bg-neutral-50 text-neutral-700'
@@ -266,7 +266,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{categories.map((category) => {
const count = photoCounts[category.id] || 0;
const isSelected = selectedCategoryId === category.id;
return (
<button
key={category.id}
@@ -275,7 +275,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
if (isMobile) onClose();
}}
className={`
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
? 'bg-primary-50 text-primary-700'
: 'hover:bg-neutral-50 text-neutral-700'
@@ -295,8 +295,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
)}
{showMediaFilter && onMediaFilterChange && (
<div className="p-4 border-b border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<div className="gallery-sidebar-section gallery-sidebar-media p-4 border-b border-neutral-200">
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<Filter className="w-4 h-4" />
{t('gallery.mediaType', 'Media')}
</h3>
@@ -304,6 +304,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<Button
variant={mediaFilter === 'all' ? 'primary' : 'outline'}
size="sm"
className="gallery-btn"
onClick={() => {
onMediaFilterChange('all');
if (isMobile) onClose();
@@ -314,6 +315,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<Button
variant={mediaFilter === 'photo' ? 'primary' : 'outline'}
size="sm"
className="gallery-btn"
onClick={() => {
onMediaFilterChange('photo');
if (isMobile) onClose();
@@ -324,6 +326,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<Button
variant={mediaFilter === 'video' ? 'primary' : 'outline'}
size="sm"
className="gallery-btn"
onClick={() => {
onMediaFilterChange('video');
if (isMobile) onClose();
@@ -337,17 +340,17 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{/* Sort Section - Hidden for carousel and timeline layouts */}
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
<div className="p-4">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<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">
<SortAsc className="w-4 h-4" />
{t('gallery.sortBy')}
</h3>
<div className="space-y-1">
{sortOptions.map((option) => {
const Icon = option.icon;
const isSelected = sortBy === option.value;
return (
<button
key={option.value}
@@ -356,7 +359,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
if (isMobile) onClose();
}}
className={`
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
? 'bg-primary-50 text-primary-700'
: 'hover:bg-neutral-50 text-neutral-700'
@@ -21,6 +21,7 @@ import { api } from '../../config/api';
import { Upload, Menu } from 'lucide-react';
import { galleryService } from '../../services/gallery.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
import type { Photo } from '../../types';
interface GalleryViewProps {
@@ -55,6 +56,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const [feedbackEnabled, setFeedbackEnabled] = useState(false);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const { watermarkEnabled } = useWatermarkSettings();
// Load and inject custom CSS for this gallery
useGalleryCustomCss(slug);
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
const [filterType, setFilterType] = useState<FilterType>('all');
const [mediaFilter, setMediaFilter] = useState<'all' | 'photo' | 'video'>('all');
@@ -564,6 +569,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
<Button
variant="ghost"
size="sm"
className="gallery-btn"
leftIcon={<Menu className="w-4 h-4" />}
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label={t('gallery.toggleMenu')}
@@ -70,9 +70,9 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
return (
<div className="relative">
<div className="photo-grid relative">
{/* Main Carousel */}
<div className="relative h-[50vh] sm:h-[60vh] lg:h-[70vh] bg-black rounded-lg overflow-hidden">
<div className="photo-card relative h-[50vh] sm:h-[60vh] lg:h-[70vh] bg-black rounded-lg overflow-hidden">
<AuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
@@ -176,7 +176,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
return (
<div
ref={ref}
className={`relative group cursor-pointer aspect-square ${animationClass}`}
className={`photo-card relative group cursor-pointer aspect-square ${animationClass}`}
onClick={handlePhotoClick}
style={{
opacity: !inView && animationType === 'fade' ? 0 : 1
@@ -375,10 +375,10 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
const gridClass = `grid ${spacingClass}
grid-cols-${columns.mobile}
sm:grid-cols-${columns.tablet}
lg:grid-cols-${columns.desktop}
const gridClass = `photo-grid grid ${spacingClass}
grid-cols-${columns.mobile}
sm:grid-cols-${columns.tablet}
lg:grid-cols-${columns.desktop}
xl:grid-cols-${columns.desktop + 1}`;
return (
@@ -187,13 +187,13 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
</div>
{/* Grid Section */}
<div id="gallery-grid-section" className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<div id="gallery-grid-section" className="photo-grid grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{remainingPhotos.map((photo) => {
const actualIndex = photos.findIndex(p => p.id === photo.id);
return (
<div
key={photo.id}
className="relative group cursor-pointer overflow-hidden rounded-lg"
className="photo-card relative group cursor-pointer overflow-hidden rounded-lg"
onClick={() => onPhotoClick(actualIndex)}
>
<AuthenticatedImage
@@ -54,7 +54,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
return (
<div
className="relative group cursor-pointer transition-all duration-300 hover:scale-[1.02]"
className="photo-card relative group cursor-pointer transition-all duration-300 hover:scale-[1.02]"
onClick={onClick}
style={{
...style,
@@ -243,9 +243,9 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
});
return (
<div
<div
ref={containerRef}
className="flex gap-4"
className="photo-grid flex gap-4"
style={{ gap: `${gutter}px` }}
>
{photoColumns.map((column, columnIndex) => (
@@ -49,7 +49,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
return (
<>
<div
className={`relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
className={`photo-card relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
onClick={(e) => {
e.stopPropagation();
onClick(e);
@@ -417,7 +417,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
};
return (
<div className="w-full max-w-7xl mx-auto">
<div className="photo-grid w-full max-w-7xl mx-auto">
{renderMosaicLayout()}
</div>
);
@@ -94,13 +94,13 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
)}
{/* Photos grid for this date */}
<div className="lg:ml-24 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
<div className="photo-grid lg:ml-24 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{group.photos.map((photo) => {
const actualIndex = photos.findIndex(p => p.id === photo.id);
return (
<div
key={photo.id}
className="relative group cursor-pointer aspect-square"
className="photo-card relative group cursor-pointer aspect-square"
onClick={() => onPhotoClick(actualIndex)}
>
<AuthenticatedImage
@@ -0,0 +1,505 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { settingsService } from '../../../services/settings.service';
import { adminService } from '../../../services/admin.service';
import { useAdminAuth } from '../../../contexts';
import { toBoolean, toNumber } from '../../../utils/parsers';
const BYTES_PER_GB = 1024 * 1024 * 1024;
export const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
export interface GeneralSettings {
site_url: string;
default_expiration_days: number;
max_file_size_mb: number;
max_files_per_upload: number;
allowed_file_types: string;
enable_watermark: boolean;
enable_analytics: boolean;
enable_registration: boolean;
maintenance_mode: boolean;
short_gallery_urls: boolean;
default_language: string;
date_format: { format: string; locale: string };
}
export interface SecuritySettings {
password_min_length: number;
password_complexity: string;
enable_2fa: boolean;
session_timeout_minutes: number;
max_login_attempts: number;
attempt_window_minutes: number;
lockout_duration_minutes: number;
enable_recaptcha: boolean;
recaptcha_site_key: string;
recaptcha_secret_key: string;
}
export interface AnalyticsSettings {
umami_enabled: boolean;
umami_url: string;
umami_website_id: string;
umami_share_url: string;
}
export interface EventSettings {
event_require_customer_name: boolean;
event_require_customer_email: boolean;
event_require_admin_email: boolean;
}
export function useSettingsState() {
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
const { updateUserProfile } = useAdminAuth();
// Fetch settings
const { data: settings, isLoading } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
});
const { data: adminProfile, isLoading: adminProfileLoading } = useQuery({
queryKey: ['admin-profile'],
queryFn: () => adminService.getAdminProfile(),
});
// General settings state
const [generalSettings, setGeneralSettings] = useState<GeneralSettings>({
site_url: '',
default_expiration_days: 30,
max_file_size_mb: 50,
max_files_per_upload: 500,
allowed_file_types: 'jpg,jpeg,png,gif,webp',
enable_watermark: false,
enable_analytics: true,
enable_registration: false,
maintenance_mode: false,
short_gallery_urls: false,
default_language: 'en',
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
});
// Security settings state
const [securitySettings, setSecuritySettings] = useState<SecuritySettings>({
password_min_length: 8,
password_complexity: 'moderate',
enable_2fa: false,
session_timeout_minutes: 60,
max_login_attempts: 5,
attempt_window_minutes: 15,
lockout_duration_minutes: 30,
enable_recaptcha: false,
recaptcha_site_key: '',
recaptcha_secret_key: ''
});
// Analytics settings state
const [analyticsSettings, setAnalyticsSettings] = useState<AnalyticsSettings>({
umami_enabled: false,
umami_url: '',
umami_website_id: '',
umami_share_url: ''
});
// Event creation settings state
const [eventSettings, setEventSettings] = useState<EventSettings>({
event_require_customer_name: true,
event_require_customer_email: true,
event_require_admin_email: true
});
// Account form state
const [accountForm, setAccountForm] = useState({
username: '',
email: ''
});
const [accountErrors, setAccountErrors] = useState<Record<string, string>>({});
// Storage state
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);
// Initialize settings from API
useEffect(() => {
if (settings) {
if (settings.general_default_language && settings.general_default_language !== i18n.language) {
i18n.changeLanguage(settings.general_default_language);
}
setGeneralSettings({
site_url: settings.general_site_url || '',
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
max_files_per_upload: Math.min(
MAX_FILES_PER_UPLOAD_LIMIT,
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
),
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
enable_watermark: toBoolean(settings.general_enable_watermark, false),
enable_analytics: toBoolean(settings.general_enable_analytics, true),
enable_registration: toBoolean(settings.general_enable_registration, false),
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
short_gallery_urls: toBoolean(settings.general_short_gallery_urls, false),
default_language: settings.general_default_language || 'en',
date_format: settings.general_date_format
? (typeof settings.general_date_format === 'string'
? { format: settings.general_date_format, locale: settings.general_date_format.includes('MM/dd') ? 'en-US' : 'en-GB' }
: settings.general_date_format)
: { format: 'dd/MM/yyyy', locale: 'en-GB' }
});
setSecuritySettings({
password_min_length: toNumber(settings.security_password_min_length, 8),
password_complexity: settings.security_password_complexity ?? 'moderate',
enable_2fa: toBoolean(settings.security_enable_2fa, false),
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15),
lockout_duration_minutes: toNumber(settings.security_lockout_duration_minutes, 30),
enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
});
setAnalyticsSettings({
umami_enabled: toBoolean(settings.analytics_umami_enabled, false),
umami_url: settings.analytics_umami_url || '',
umami_website_id: settings.analytics_umami_website_id || '',
umami_share_url: settings.analytics_umami_share_url || ''
});
setEventSettings({
event_require_customer_name: toBoolean(settings.event_require_customer_name, true),
event_require_customer_email: toBoolean(settings.event_require_customer_email, true),
event_require_admin_email: toBoolean(settings.event_require_admin_email, true)
});
}
}, [settings, i18n]);
useEffect(() => {
if (adminProfile) {
setAccountForm({
username: adminProfile.username || '',
email: adminProfile.email || ''
});
}
}, [adminProfile]);
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]);
// Mutations
const saveGeneralMutation = useMutation({
mutationFn: async () => {
const settingsData: Record<string, unknown> = {};
Object.entries(generalSettings).forEach(([key, value]) => {
if (key === 'date_format' && typeof value === 'object' && value.format) {
settingsData[`general_${key}`] = value.format;
} else {
settingsData[`general_${key}`] = value;
}
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const saveSecurityMutation = useMutation({
mutationFn: async () => {
const settingsData: Record<string, unknown> = {};
Object.entries(securitySettings).forEach(([key, value]) => {
settingsData[`security_${key}`] = value;
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const saveAnalyticsMutation = useMutation({
mutationFn: async () => {
const settingsData: Record<string, unknown> = {};
Object.entries(analyticsSettings).forEach(([key, value]) => {
settingsData[`analytics_${key}`] = value;
});
return settingsService.updateSettings(settingsData);
},
onSuccess: () => {
toast.success(t('toast.settingsSaved'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
});
const saveEventSettingsMutation = useMutation({
mutationFn: async () => {
const settingsData: Record<string, unknown> = {};
Object.entries(eventSettings).forEach(([key, value]) => {
settingsData[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 updateAdminProfileMutation = useMutation({
mutationFn: (payload: { username: string; email: string }) => adminService.updateAdminProfile(payload),
onSuccess: (updatedUser) => {
toast.success(t('settings.general.accountSaveSuccess'));
setAccountErrors({});
setAccountForm({
username: updatedUser.username,
email: updatedUser.email
});
updateUserProfile(updatedUser);
queryClient.invalidateQueries({ queryKey: ['admin-profile'] });
},
onError: (error: { response?: { data?: { errors?: Array<{ path: string; msg: string }>; error?: string } } }) => {
if (error.response?.data?.errors) {
const fieldErrors: Record<string, string> = {};
for (const err of error.response.data.errors) {
if (err.path === 'username') {
fieldErrors.username = err.msg;
}
if (err.path === 'email') {
fieldErrors.email = err.msg;
}
}
setAccountErrors(fieldErrors);
return;
}
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error(t('toast.saveError'));
}
}
});
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 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'));
}
});
// Handlers
const handleAccountChange = (field: 'username' | 'email') => (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
setAccountForm((prev) => ({ ...prev, [field]: value }));
if (accountErrors[field]) {
setAccountErrors((prev) => ({ ...prev, [field]: '' }));
}
};
const handleAccountSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (updateAdminProfileMutation.isPending) return;
const trimmedUsername = accountForm.username.trim();
const trimmedEmail = accountForm.email.trim();
const errors: Record<string, string> = {};
if (!trimmedUsername) {
errors.username = t('settings.general.accountUsernameRequired');
} else if (trimmedUsername.length < 3) {
errors.username = t('settings.general.accountUsernameLength');
}
if (!trimmedEmail) {
errors.email = t('settings.general.accountEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) {
errors.email = t('settings.general.accountEmailInvalid');
}
if (Object.keys(errors).length > 0) {
setAccountErrors(errors);
return;
}
updateAdminProfileMutation.mutate({
username: trimmedUsername,
email: trimmedEmail
});
};
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 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 });
};
return {
// Loading states
isLoading,
adminProfileLoading,
// Settings data
settings,
generalSettings,
setGeneralSettings,
securitySettings,
setSecuritySettings,
analyticsSettings,
setAnalyticsSettings,
eventSettings,
setEventSettings,
// Account form
accountForm,
accountErrors,
handleAccountChange,
handleAccountSubmit,
updateAdminProfileMutation,
// Storage settings
softLimitGb,
setSoftLimitGb,
softLimitDirty,
setSoftLimitDirty,
capacityOverrideGb,
setCapacityOverrideGb,
availableOverrideGb,
setAvailableOverrideGb,
overrideDirty,
setOverrideDirty,
handleSaveSoftLimit,
handleSaveCapacityOverride,
saveSoftLimitMutation,
saveCapacityOverrideMutation,
// Save mutations
saveGeneralMutation,
saveSecurityMutation,
saveAnalyticsMutation,
saveEventSettingsMutation,
// Translation
t,
};
}
@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import { settingsService } from '../../../services/settings.service';
export function useStatusTab(isActive: boolean) {
// Fetch storage info
const { data: storageInfo } = useQuery({
queryKey: ['admin-storage-info'],
queryFn: () => settingsService.getStorageInfo(),
enabled: isActive
});
// Fetch system status
const { data: systemStatus } = useQuery({
queryKey: ['system-status'],
queryFn: () => settingsService.getSystemStatus(),
enabled: isActive,
refetchInterval: 30000
});
return {
storageInfo,
systemStatus,
};
}
+14
View File
@@ -0,0 +1,14 @@
// Hooks
export { useSettingsState, MAX_FILES_PER_UPLOAD_LIMIT } from './hooks/useSettingsState';
export type { GeneralSettings, SecuritySettings, AnalyticsSettings, EventSettings } from './hooks/useSettingsState';
export { useStatusTab } from './hooks/useStatusTab';
// Tab components
export { GeneralTab } from './tabs/GeneralTab';
export { EventsTab } from './tabs/EventsTab';
export { StatusTab } from './tabs/StatusTab';
export { SecurityTab } from './tabs/SecurityTab';
export { CategoriesTab } from './tabs/CategoriesTab';
export { AnalyticsTab } from './tabs/AnalyticsTab';
export { ModerationTab } from './tabs/ModerationTab';
export { StylingTab } from './tabs/StylingTab';
@@ -0,0 +1,142 @@
import React from 'react';
import { Save, Globe, Key, Activity, AlertCircle } from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { AnalyticsSettings } from '../hooks/useSettingsState';
interface AnalyticsTabProps {
analyticsSettings: AnalyticsSettings;
setAnalyticsSettings: React.Dispatch<React.SetStateAction<AnalyticsSettings>>;
saveAnalyticsMutation: {
mutate: () => void;
isPending: boolean;
};
}
export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
analyticsSettings,
setAnalyticsSettings,
saveAnalyticsMutation,
}) => {
const { t } = useTranslation();
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.umamiIntegration')}</h2>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={analyticsSettings.umami_enabled}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_enabled: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.analytics.enableUmami')}</span>
</label>
{analyticsSettings.umami_enabled && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.analytics.umamiUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_url}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.analytics.umamiUrlHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.analytics.websiteId')}
</label>
<Input
type="text"
value={analyticsSettings.umami_website_id}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_website_id: e.target.value }))}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.analytics.websiteIdHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.analytics.shareUrl')}
</label>
<Input
type="url"
value={analyticsSettings.umami_share_url}
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_share_url: e.target.value }))}
placeholder="https://analytics.yourdomain.com/share/..."
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.analytics.shareUrlHelp')}
</p>
</div>
</>
)}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">{t('settings.analytics.umamiInfo')}</p>
<p>{t('settings.analytics.umamiInfoText')}</p>
<a href="https://umami.is" target="_blank" rel="noopener noreferrer" className="underline mt-1 inline-block">
{t('settings.analytics.learnMore')}
</a>
</div>
</div>
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveAnalyticsMutation.mutate()}
isLoading={saveAnalyticsMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.analytics.saveAnalyticsSettings')}
</Button>
</div>
</Card>
{/* Backend Analytics Info */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.backendAnalytics')}</h2>
<p className="text-sm text-neutral-700 mb-4">{t('settings.analytics.backendAnalyticsText')}</p>
<div className="grid grid-cols-2 gap-4">
<div className="bg-neutral-50 rounded-lg p-4">
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.tracked')}</h3>
<ul className="text-xs text-neutral-600 space-y-1">
<li> {t('settings.analytics.galleryViews')}</li>
<li> {t('settings.analytics.photoDownloads')}</li>
<li> {t('settings.analytics.uniqueVisitors')}</li>
<li> {t('settings.analytics.deviceTypes')}</li>
</ul>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.privacy')}</h3>
<p className="text-xs text-neutral-600">
{t('settings.analytics.privacyText')}
</p>
</div>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,29 @@
import React from 'react';
import { Image } from 'lucide-react';
import { Card } from '../../../components/common';
import { CategoryManager } from '../../../components/admin/CategoryManager';
import { useTranslation } from 'react-i18next';
export const CategoriesTab: React.FC = () => {
const { t } = useTranslation();
return (
<div className="space-y-6">
<Card padding="md">
<CategoryManager />
</Card>
<Card padding="md">
<div className="flex items-start gap-3">
<Image className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div>
<h3 className="text-sm font-semibold text-blue-900">{t('settings.categories.about')}</h3>
<p className="text-sm text-blue-700 mt-1">
{t('settings.categories.aboutText')}
</p>
</div>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,129 @@
import React from 'react';
import { Save, AlertCircle } from 'lucide-react';
import { Button, Card } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { EventSettings } from '../hooks/useSettingsState';
interface EventsTabProps {
eventSettings: EventSettings;
setEventSettings: React.Dispatch<React.SetStateAction<EventSettings>>;
saveEventSettingsMutation: {
mutate: () => void;
isPending: boolean;
};
}
export const EventsTab: React.FC<EventsTabProps> = ({
eventSettings,
setEventSettings,
saveEventSettingsMutation,
}) => {
const { t } = useTranslation();
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
{t('settings.events.requiredFields', 'Required Fields')}
</h2>
<p className="text-sm text-neutral-600 mb-4">
{t('settings.events.requiredFieldsDescription', 'Configure which contact fields are required when creating new events.')}
</p>
<div className="space-y-4">
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_customer_name}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_customer_name: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireCustomerName', 'Require customer name')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireCustomerNameHelp', 'Customer name must be provided for new events')}
</p>
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_customer_email}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_customer_email: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireCustomerEmail', 'Require customer email')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireCustomerEmailHelp', 'Customer email must be provided for new events')}
</p>
{!eventSettings.event_require_customer_email && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.customerEmailWarning', 'Required for sending gallery invitations')}
</p>
)}
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_require_admin_email}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_admin_email: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('settings.events.requireAdminEmail', 'Require admin email')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.events.requireAdminEmailHelp', 'Admin email must be provided for new events')}
</p>
{!eventSettings.event_require_admin_email && (
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{t('settings.events.adminEmailWarning', 'Required for receiving event notifications')}
</p>
)}
</div>
</label>
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveEventSettingsMutation.mutate()}
isLoading={saveEventSettingsMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.events.saveSettings', 'Save Event Settings')}
</Button>
</div>
</Card>
<Card padding="md">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">{t('settings.events.noteTitle', 'Note')}</p>
<p>
{t('settings.events.noteText', 'These settings only affect new event creation. Existing events are not affected. Default behavior requires all fields.')}
</p>
</div>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,311 @@
import React from 'react';
import { Save, Globe, Mail, User } from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { GeneralSettings } from '../hooks/useSettingsState';
import { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState';
interface GeneralTabProps {
generalSettings: GeneralSettings;
setGeneralSettings: React.Dispatch<React.SetStateAction<GeneralSettings>>;
saveGeneralMutation: {
mutate: () => void;
isPending: boolean;
};
accountForm: { username: string; email: string };
accountErrors: Record<string, string>;
handleAccountChange: (field: 'username' | 'email') => (e: React.ChangeEvent<HTMLInputElement>) => void;
handleAccountSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
updateAdminProfileMutation: { isPending: boolean };
adminProfileLoading: boolean;
}
export const GeneralTab: React.FC<GeneralTabProps> = ({
generalSettings,
setGeneralSettings,
saveGeneralMutation,
accountForm,
accountErrors,
handleAccountChange,
handleAccountSubmit,
updateAdminProfileMutation,
adminProfileLoading,
}) => {
const { t } = useTranslation();
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.accountSection')}</h2>
{adminProfileLoading ? (
<div className="py-8 flex justify-center">
<Loading size="md" />
</div>
) : (
<form className="space-y-4" onSubmit={handleAccountSubmit}>
<div>
<label htmlFor="admin-account-username" className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.accountUsername')}
</label>
<Input
id="admin-account-username"
type="text"
value={accountForm.username}
onChange={handleAccountChange('username')}
placeholder="admin"
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
error={accountErrors.username}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.accountUsernameHelp')}
</p>
</div>
<div>
<label htmlFor="admin-account-email" className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.accountEmail')}
</label>
<Input
id="admin-account-email"
type="email"
value={accountForm.email}
onChange={handleAccountChange('email')}
placeholder="[email protected]"
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
error={accountErrors.email}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.accountEmailHelp')}
</p>
</div>
<div className="pt-2">
<Button
type="submit"
variant="primary"
leftIcon={<Save className="w-5 h-5" />}
isLoading={updateAdminProfileMutation.isPending}
>
{t('settings.general.accountSaveButton')}
</Button>
</div>
</form>
)}
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.siteConfiguration')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.siteUrl')}
</label>
<Input
type="url"
value={generalSettings.site_url}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, site_url: e.target.value }))}
placeholder="https://yourdomain.com"
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.siteUrlHelp')}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.defaultExpiration')}
</label>
<Input
type="number"
value={generalSettings.default_expiration_days}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_expiration_days: parseInt(e.target.value) || 30 }))}
min="1"
max="365"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.maxFileSize')}
</label>
<Input
type="number"
value={generalSettings.max_file_size_mb}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, max_file_size_mb: parseInt(e.target.value) || 50 }))}
min="1"
max="500"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.maxFilesPerUpload')}
</label>
<Input
type="number"
value={generalSettings.max_files_per_upload}
onChange={(e) => {
const parsed = parseInt(e.target.value, 10);
setGeneralSettings(prev => ({
...prev,
max_files_per_upload: Number.isFinite(parsed)
? Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, parsed))
: prev.max_files_per_upload
}));
}}
min="1"
max={MAX_FILES_PER_UPLOAD_LIMIT}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.allowedFileTypes')}
</label>
<Input
type="text"
value={generalSettings.allowed_file_types}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, allowed_file_types: e.target.value }))}
placeholder="jpg,jpeg,png,gif"
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.allowedFileTypesHelp')}
</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.featureToggles')}</h2>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.enable_watermark}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_watermark: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableWatermark')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.enable_analytics}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_analytics: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableAnalytics')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.enable_registration}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_registration: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableRegistration')}</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.maintenance_mode}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, maintenance_mode: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span>
</label>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.short_gallery_urls}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, short_gallery_urls: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableShortGalleryUrls')}</span>
</label>
<p className="text-xs text-neutral-500 ml-6 mt-1">
{t('settings.general.enableShortGalleryUrlsHelp')}
</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.language')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('settings.general.language')}
</label>
<select
value={generalSettings.default_language}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_language: e.target.value }))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="en">English</option>
<option value="de">Deutsch</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.defaultLanguageHelp')}
</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.dateTimeFormat')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('settings.general.dateFormat')}
</label>
<select
value={generalSettings.date_format?.format || 'dd/MM/yyyy'}
onChange={(e) => {
const format = e.target.value;
const locale = format === 'MM/dd/yyyy' ? 'en-US' : 'en-GB';
setGeneralSettings(prev => ({
...prev,
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"
>
<option value="dd/MM/yyyy">DD/MM/YYYY (European)</option>
<option value="MM/dd/yyyy">MM/DD/YYYY (US)</option>
<option value="yyyy-MM-dd">YYYY-MM-DD (ISO)</option>
<option value="dd.MM.yyyy">DD.MM.YYYY (German)</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.dateFormatHelp')}
</p>
</div>
</div>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveGeneralMutation.mutate()}
isLoading={saveGeneralMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.general.saveGeneralSettings')}
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,10 @@
import React from 'react';
import { WordFilterManager } from '../../../components/admin/WordFilterManager';
export const ModerationTab: React.FC = () => {
return (
<div className="space-y-6">
<WordFilterManager />
</div>
);
};
@@ -0,0 +1,205 @@
import React from 'react';
import { Save, Key, AlertCircle } from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import type { SecuritySettings } from '../hooks/useSettingsState';
interface SecurityTabProps {
securitySettings: SecuritySettings;
setSecuritySettings: React.Dispatch<React.SetStateAction<SecuritySettings>>;
saveSecurityMutation: {
mutate: () => void;
isPending: boolean;
};
}
export const SecurityTab: React.FC<SecurityTabProps> = ({
securitySettings,
setSecuritySettings,
saveSecurityMutation,
}) => {
const { t } = useTranslation();
return (
<div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.minPasswordLength')}
</label>
<Input
type="number"
value={securitySettings.password_min_length}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, password_min_length: parseInt(e.target.value) || 8 }))}
min="4"
max="32"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.passwordComplexity')}
</label>
<select
value={securitySettings.password_complexity}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, password_complexity: e.target.value }))}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="simple">{t('settings.security.complexitySimple')}</option>
<option value="moderate">{t('settings.security.complexityModerate')}</option>
<option value="strong">{t('settings.security.complexityStrong')}</option>
<option value="very_strong">{t('settings.security.complexityVeryStrong')}</option>
</select>
<p className="mt-1 text-sm text-neutral-600">
{t('settings.security.passwordComplexityHelp')}
</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.sessionAuth')}</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.sessionTimeout')}
</label>
<Input
type="number"
value={securitySettings.session_timeout_minutes}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, session_timeout_minutes: parseInt(e.target.value, 10) || 60 }))}
min="5"
max="1440"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.attemptWindowMinutes')}
</label>
<Input
type="number"
value={securitySettings.attempt_window_minutes}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, attempt_window_minutes: parseInt(e.target.value, 10) || 15 }))}
min="1"
max="1440"
/>
<p className="mt-1 text-sm text-neutral-600">
{t('settings.security.attemptWindowMinutesHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.lockoutDurationMinutes')}
</label>
<Input
type="number"
value={securitySettings.lockout_duration_minutes}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, lockout_duration_minutes: parseInt(e.target.value, 10) || 30 }))}
min="1"
max="1440"
/>
<p className="mt-1 text-sm text-neutral-600">
{t('settings.security.lockoutDurationMinutesHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.maxLoginAttempts')}
</label>
<Input
type="number"
value={securitySettings.max_login_attempts}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value, 10) || 5 }))}
min="1"
max="50"
/>
<p className="mt-1 text-sm text-neutral-600">
{t('settings.security.maxLoginAttemptsHelp')}
</p>
</div>
</div>
<label className="flex items-center">
<input
type="checkbox"
checked={securitySettings.enable_2fa}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_2fa: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.security.enable2FA')}</span>
</label>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.recaptchaSettings')}</h2>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={securitySettings.enable_recaptcha}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_recaptcha: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.security.enableRecaptcha')}</span>
</label>
{securitySettings.enable_recaptcha && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.siteKey')}
</label>
<Input
type="text"
value={securitySettings.recaptcha_site_key}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, recaptcha_site_key: e.target.value }))}
placeholder={t('settings.security.siteKey')}
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.secretKey')}
</label>
<Input
type="password"
value={securitySettings.recaptcha_secret_key}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, recaptcha_secret_key: e.target.value }))}
placeholder={t('settings.security.secretKey')}
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
/>
</div>
</>
)}
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="text-sm text-blue-800">
<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>
<div className="mt-6">
<Button
variant="primary"
onClick={() => saveSecurityMutation.mutate()}
isLoading={saveSecurityMutation.isPending}
leftIcon={<Save className="w-5 h-5" />}
>
{t('settings.security.saveSecuritySettings')}
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,539 @@
import React, { useEffect } from 'react';
import {
Save,
Database,
Server,
CheckCircle,
Clock,
HardDrive,
Activity,
} from 'lucide-react';
import { Button, Card, Input } from '../../../components/common';
import { useTranslation } from 'react-i18next';
import { settingsService } from '../../../services/settings.service';
import { useStatusTab } from '../hooks/useStatusTab';
const BYTES_PER_GB = 1024 * 1024 * 1024;
interface StatusTabProps {
isActive: boolean;
handleSaveSoftLimit: () => void;
handleSaveCapacityOverride: () => void;
saveSoftLimitMutation: { isPending: boolean };
saveCapacityOverrideMutation: { isPending: boolean };
softLimitGb: number | '';
setSoftLimitGb: (value: number | '') => void;
softLimitDirty: boolean;
setSoftLimitDirty: (dirty: boolean) => void;
capacityOverrideGb: number | '';
setCapacityOverrideGb: (value: number | '') => void;
availableOverrideGb: number | '';
setAvailableOverrideGb: (value: number | '') => void;
overrideDirty: boolean;
setOverrideDirty: (dirty: boolean) => void;
}
export const StatusTab: React.FC<StatusTabProps> = ({
isActive,
handleSaveSoftLimit,
handleSaveCapacityOverride,
saveSoftLimitMutation,
saveCapacityOverrideMutation,
softLimitGb,
setSoftLimitGb,
softLimitDirty,
setSoftLimitDirty,
capacityOverrideGb,
setCapacityOverrideGb,
availableOverrideGb,
setAvailableOverrideGb,
overrideDirty,
setOverrideDirty,
}) => {
const { t } = useTranslation();
const { storageInfo, systemStatus } = useStatusTab(isActive);
// Sync soft limit from storage info
useEffect(() => {
if (!storageInfo || 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, setSoftLimitGb]);
// Sync capacity override from storage info
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, setCapacityOverrideGb, setAvailableOverrideGb]);
return (
<div className="space-y-6">
{/* Storage Overview */}
{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;
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="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 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 && (
<>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Server className="w-5 h-5" />
{t('settings.systemStatus.systemInfo')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.platform')}</p>
<p className="font-semibold">{systemStatus.system.platform}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.nodeVersion')}</p>
<p className="font-semibold">{systemStatus.system.nodeVersion}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.uptime')}</p>
<p className="font-semibold">{Math.floor(systemStatus.system.uptime / 3600)}h {Math.floor((systemStatus.system.uptime % 3600) / 60)}m</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<p className="text-sm text-neutral-600">{t('settings.systemStatus.cpuCores')}</p>
<p className="font-semibold">{systemStatus.system.cpu.cores}</p>
</div>
</div>
<div className="mt-4">
<h3 className="text-sm font-semibold text-neutral-900 mb-2">{t('settings.systemStatus.memoryUsage')}</h3>
<div className="mb-2">
<div className="flex justify-between text-sm mb-1">
<span className="text-neutral-600">{t('settings.systemStatus.memoryUsed')}</span>
<span className="font-medium">
{settingsService.formatBytes(systemStatus.system.memory.used)} / {settingsService.formatBytes(systemStatus.system.memory.total)}
</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{
width: `${Math.round((systemStatus.system.memory.used / systemStatus.system.memory.total) * 100)}%`
}}
/>
</div>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Database className="w-5 h-5" />
{t('settings.systemStatus.databaseInfo')}
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.events}</p>
<p className="text-xs text-neutral-600">{t('navigation.events')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.photos}</p>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.photos')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.admins}</p>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.admins')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.categories}</p>
<p className="text-xs text-neutral-600">{t('settings.categories.title')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-neutral-900">{settingsService.formatBytes(systemStatus.database.size)}</p>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.dbSize')}</p>
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Activity className="w-5 h-5" />
{t('settings.systemStatus.services')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-neutral-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.fileWatcher')}</p>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.fileWatcherDesc')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.expirationChecker')}</p>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.expirationCheckerDesc')}</p>
</div>
<div className="bg-neutral-50 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.emailProcessor')}</p>
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
<p className="text-xs text-neutral-600">{t('settings.systemStatus.emailProcessorDesc')}</p>
</div>
</div>
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
<h3 className="text-sm font-semibold text-blue-900 mb-2">{t('settings.systemStatus.emailQueue')}</h3>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-blue-700">{t('settings.systemStatus.pending')}:</span>
<span className="ml-2 font-semibold text-blue-900">
{systemStatus.emailQueue.pending}
{systemStatus.emailQueue.stuck > 0 && (
<span className="text-orange-600 text-xs ml-1">
({systemStatus.emailQueue.stuck} stuck)
</span>
)}
</span>
</div>
<div>
<span className="text-green-700">{t('settings.systemStatus.sent')}:</span>
<span className="ml-2 font-semibold text-green-900">{systemStatus.emailQueue.sent}</span>
</div>
<div>
<span className="text-red-700">{t('settings.systemStatus.failed')}:</span>
<span className="ml-2 font-semibold text-red-900">{systemStatus.emailQueue.failed}</span>
</div>
</div>
{systemStatus.emailQueue.stuck > 0 && (
<div className="mt-3 p-3 bg-orange-50 rounded-md">
<p className="text-xs text-orange-800">
<span className="font-semibold">Warning: {systemStatus.emailQueue.stuck} email(s) stuck:</span> These emails have exceeded retry limits and won&apos;t be processed automatically.
Only {systemStatus.emailQueue.processable} of {systemStatus.emailQueue.pending} pending emails will be processed.
</p>
</div>
)}
</div>
</Card>
</>
)}
{/* Last update time */}
{systemStatus && (
<div className="text-xs text-neutral-500 text-right flex items-center justify-end gap-1">
<Clock className="w-3 h-3" />
{t('settings.systemStatus.lastUpdate')}: {new Date(systemStatus.timestamp).toLocaleString()}
</div>
)}
</div>
);
};
@@ -0,0 +1,10 @@
import React from 'react';
import { CssTemplateEditor } from '../../../components/admin/CssTemplateEditor';
export const StylingTab: React.FC = () => {
return (
<div className="space-y-6">
<CssTemplateEditor />
</div>
);
};
+3
View File
@@ -26,6 +26,9 @@ i18n
interpolation: {
escapeValue: false,
},
// Use v3 format for pluralization (_plural suffix instead of _one/_other)
compatibilityJSON: 'v3',
detection: {
order: ['localStorage', 'cookie', 'navigator', 'htmlTag'],
+69 -1
View File
@@ -582,6 +582,11 @@
"noThemeSet": "Kein Design konfiguriert",
"customizingTheme": "Galerie-Design anpassen",
"customizingThemeFor": "Design für {{event}} anpassen",
"customCssTemplate": "Benutzerdefinierte CSS-Vorlage",
"customCssTemplateDesc": "Verwenden Sie eine CSS-Vorlage, um die Galerie mit einzigartigen visuellen Effekten zu gestalten.",
"noTemplate": "Keine Vorlage",
"useThemeOnly": "Nur Design-Vorlage verwenden",
"customTemplate": "Benutzerdefinierte Vorlage",
"title": "Veranstaltungen",
"create": "Veranstaltung erstellen",
"createEvent": "Veranstaltung erstellen",
@@ -745,6 +750,7 @@
"bulkArchivePartial": "{{success}} Veranstaltungen archiviert, {{failed}} fehlgeschlagen",
"searchEventsPlaceholder": "Veranstaltungen suchen...",
"all": "Alle",
"expiring": "Läuft ab",
"activeFilter": "Aktiv",
"archivedFilter": "Archiviert",
"sortByName": "Nach Name",
@@ -776,6 +782,18 @@
"bulkArchive": "Archivieren",
"confirmBulkArchive": "Sind Sie sicher, dass Sie {{count}} Veranstaltung(en) archivieren möchten?",
"confirmBulkArchiveDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Archivierte Veranstaltungen sind nicht mehr öffentlich zugänglich.",
"copy": "Kopieren",
"copied": "Kopiert!",
"rename": {
"button": "Umbenennen",
"title": "Veranstaltung umbenennen",
"validating": "Neuer Name wird überprüft...",
"renamingFiles": "Dateien werden umbenannt...",
"complete": "Fertig!",
"failed": "Umbenennung fehlgeschlagen",
"filesRenamed": "{{count}} Dateien aktualisiert",
"confirm": "Veranstaltung umbenennen"
},
"stats": {
"totalEvents": "Gesamtveranstaltungen",
"activeEvents": "Aktive Veranstaltungen",
@@ -1089,6 +1107,17 @@
"waves": "Wellen"
},
"customCSSHelp": "Erweitert: Fügen Sie benutzerdefiniertes CSS hinzu, um das Erscheinungsbild weiter anzupassen",
"cssInstructions": {
"title": "Benutzerdefiniertes CSS verwenden",
"variables": "Design CSS-Variablen",
"variablesDesc": "Verwenden Sie diese CSS-Variablen passend zu Ihren Design-Vorlagen:",
"layouts": "Benutzerdefinierte Galerie-Layouts",
"layoutsDesc": "Sprechen Sie Galerie-Elemente mit diesen Selektoren an:",
"glassEffect": "Glasmorphismus-Effekt",
"glassEffectDesc": "Erstellen Sie moderne Glaseffekte:",
"tip": "Tipp",
"tipText": "Verwenden Sie CSS-Vorlagen unter Einstellungen > CSS-Vorlagen für vorgefertigte Designs wie Apple Liquid Glass."
},
"resetToDefault": "Auf Standard zurücksetzen",
"applyTheme": "Theme anwenden",
"customTheme": "Benutzerdefiniertes Design",
@@ -1231,6 +1260,7 @@
"photos_bulk_deleted": "{{count}} Fotos gelöscht aus {{eventName}}",
"settings_updated": "Einstellungen aktualisiert",
"event_updated": "Veranstaltung aktualisiert: {{eventName}}",
"event_renamed": "Veranstaltung umbenannt: {{eventName}}",
"event_deleted": "Veranstaltung gelöscht: {{eventName}}",
"password_changed": "Passwort geändert",
"email_resent": "Erstellungs-E-Mail erneut gesendet für: {{eventName}}",
@@ -1933,7 +1963,45 @@
"photoFeedback": "Foto-Feedback",
"hasFeedback": "Hat Feedback",
"hasComments": "Hat Kommentare",
"hasRating": "Hat Bewertung"
"hasRating": "Hat Bewertung",
"settings": {
"title": "Gast-Feedback-Einstellungen",
"enableFeedback": "Feedback aktivieren",
"feedbackTypes": "Feedback-Typen",
"ratings": "Sternebewertungen",
"ratingsDesc": "Gästen erlauben, Fotos zu bewerten (1-5 Sterne)",
"likes": "Gefällt mir",
"likesDesc": "Einfache Gefällt mir-Funktion",
"comments": "Kommentare",
"commentsDesc": "Textkommentare auf Fotos",
"favorites": "Favoriten",
"favoritesDesc": "Fotos als Favoriten markieren"
}
},
"filter": {
"feedbackFilters": "Feedback-Filter",
"clear": "Löschen",
"rating": "Bewertung",
"allPhotos": "Alle Fotos",
"anyRating": "Jede Bewertung",
"oneStarPlus": "1+ Sterne",
"twoStarsPlus": "2+ Sterne",
"threeStarsPlus": "3+ Sterne",
"fourStarsPlus": "4+ Sterne",
"fiveStarsOnly": "Nur 5 Sterne",
"hasLikes": "Hat Gefällt mir",
"hasFavorites": "Hat Favoriten",
"hasComments": "Hat Kommentare",
"showingPhotos": "Fotos gesamt",
"withRatings": "Mit Bewertungen"
},
"export": {
"button": "Exportieren",
"success": "Export erfolgreich heruntergeladen",
"error": "Export fehlgeschlagen: ",
"exportSelected": "{{count}} ausgewählte exportieren",
"exportFiltered": "Gefilterte Fotos exportieren",
"hint": "Fotos auswählen oder Filter anwenden zum Exportieren"
},
"adminLogin": {
"title": "Admin-Anmeldung",
+57 -2
View File
@@ -461,7 +461,22 @@
"customizeTheme": "Customize Theme",
"noThemeSet": "No theme configured",
"customizingTheme": "Customizing gallery theme",
"customizingThemeFor": "Customizing theme for {{event}}"
"customizingThemeFor": "Customizing theme for {{event}}",
"customCssTemplate": "Custom CSS Template",
"customCssTemplateDesc": "Apply a custom CSS template to style the gallery with unique visual effects.",
"noTemplate": "No Template",
"useThemeOnly": "Use theme preset only",
"customTemplate": "Custom Template",
"rename": {
"button": "Rename",
"title": "Rename Event",
"validating": "Validating new name...",
"renamingFiles": "Renaming files...",
"complete": "Complete!",
"failed": "Rename failed",
"filesRenamed": "{{count}} files updated",
"confirm": "Rename Event"
}
},
"settings": {
"title": "System Settings",
@@ -827,6 +842,17 @@
"waves": "Waves"
},
"customCSSHelp": "Advanced: Add custom CSS to further customize the appearance",
"cssInstructions": {
"title": "How to use Custom CSS",
"variables": "Theme CSS Variables",
"variablesDesc": "Use these CSS variables to match your theme presets:",
"layouts": "Custom Gallery Layouts",
"layoutsDesc": "Target gallery elements with these selectors:",
"glassEffect": "Glassmorphism Effect",
"glassEffectDesc": "Create modern glass effects:",
"tip": "Tip",
"tipText": "Use CSS Templates from Settings > CSS Templates for pre-built designs like Apple Liquid Glass."
},
"resetToDefault": "Reset to Default",
"applyTheme": "Apply Theme",
"customTheme": "Custom Theme",
@@ -967,6 +993,7 @@
"photos_bulk_deleted": "{{count}} photos deleted from {{eventName}}",
"settings_updated": "Settings updated",
"event_updated": "Event updated: {{eventName}}",
"event_renamed": "Event renamed: {{eventName}}",
"event_deleted": "Event deleted: {{eventName}}",
"password_changed": "Password changed",
"email_resent": "Creation email resent for: {{eventName}}",
@@ -1631,7 +1658,35 @@
"photoFeedback": "Photo Feedback",
"hasFeedback": "Has feedback",
"hasComments": "Has comments",
"hasRating": "Has rating"
"hasRating": "Has rating",
"settings": {
"title": "Guest Feedback Settings",
"enableFeedback": "Enable feedback",
"feedbackTypes": "Feedback Types",
"ratings": "Star Ratings",
"ratingsDesc": "Allow guests to rate photos (1-5 stars)",
"likes": "Likes",
"likesDesc": "Simple like/unlike functionality",
"comments": "Comments",
"commentsDesc": "Text comments on photos",
"favorites": "Favorites",
"favoritesDesc": "Mark photos as favorites"
}
},
"filter": {
"feedbackFilters": "Feedback Filters",
"clear": "Clear",
"rating": "Rating",
"allPhotos": "All Photos",
"anyRating": "Any Rating",
"oneStarPlus": "1+ Stars",
"twoStarsPlus": "2+ Stars",
"threeStarsPlus": "3+ Stars",
"fourStarsPlus": "4+ Stars",
"fiveStarsOnly": "5 Stars Only",
"hasLikes": "Has likes",
"hasFavorites": "Has favorites",
"hasComments": "Has comments"
},
"adminLogin": {
"title": "Admin Login",
+10 -5
View File
@@ -383,18 +383,23 @@ export const BrandingPage: React.FC = () => {
</button>
</div>
)}
<label className="cursor-pointer">
<div>
<input
type="file"
accept="image/png,image/jpeg,image/svg+xml"
onChange={handleLogoUpload}
className="hidden"
id="logo-upload"
/>
<span className="btn-secondary inline-flex items-center">
<Upload className="w-4 h-4 mr-2" />
<Button
variant="secondary"
size="sm"
onClick={() => document.getElementById('logo-upload')?.click()}
leftIcon={<Upload className="w-4 h-4" />}
>
{brandingSettings.logo_url ? t('branding.changeLogo', 'Change Logo') : t('branding.uploadLogo', 'Upload Logo')}
</span>
</label>
</Button>
</div>
</div>
<p className="text-xs text-neutral-600 mt-1">
{t('branding.logoHelp', 'PNG, JPG or SVG format, recommended width: 200px')}
@@ -20,8 +20,10 @@ import { eventsService } from '../../services/events.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { cssTemplatesService } from '../../services/cssTemplates.service';
import { useTranslation } from 'react-i18next';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { Code } from 'lucide-react';
interface FormData {
event_type: string;
@@ -39,6 +41,7 @@ interface FormData {
expires_in_days: number;
allow_user_uploads: boolean;
upload_category_id: number | null;
css_template_id: number | null;
feedback_settings: {
feedback_enabled: boolean;
allow_ratings: boolean;
@@ -98,6 +101,7 @@ export const CreateEventPage: React.FC = () => {
expires_in_days: 30,
allow_user_uploads: false,
upload_category_id: null,
css_template_id: null,
feedback_settings: {
feedback_enabled: false,
allow_ratings: true,
@@ -122,6 +126,12 @@ export const CreateEventPage: React.FC = () => {
queryFn: () => categoriesService.getGlobalCategories()
});
// Fetch enabled CSS templates
const { data: cssTemplates } = useQuery({
queryKey: ['css-templates', 'enabled'],
queryFn: () => cssTemplatesService.getEnabledTemplates()
});
// Fetch default settings
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
@@ -268,6 +278,7 @@ export const CreateEventPage: React.FC = () => {
expiration_days: formData.expires_in_days,
allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id,
css_template_id: formData.css_template_id,
feedback_enabled: feedbackSettings.feedback_enabled,
allow_ratings: feedbackSettings.allow_ratings,
allow_likes: feedbackSettings.allow_likes,
@@ -478,6 +489,55 @@ export const CreateEventPage: React.FC = () => {
</div>
</div>
)}
{/* Custom CSS Template Selection */}
{cssTemplates && cssTemplates.length > 0 && (
<div className="pt-6 border-t border-neutral-200">
<h3 className="text-md font-semibold text-neutral-900 mb-3 flex items-center gap-2">
<Code className="w-4 h-4" />
{t('events.customCssTemplate', 'Custom CSS Template')}
</h3>
<p className="text-sm text-neutral-600 mb-4">
{t('events.customCssTemplateDesc', 'Apply a custom CSS template to style the gallery with unique visual effects.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{/* No template option */}
<button
type="button"
onClick={() => setFormData({ ...formData, css_template_id: null })}
className={`p-4 rounded-lg border-2 transition-all text-left ${
formData.css_template_id === null
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="font-medium text-sm">{t('events.noTemplate', 'No Template')}</div>
<div className="text-xs text-neutral-500 mt-1">
{t('events.useThemeOnly', 'Use theme preset only')}
</div>
</button>
{/* Available templates */}
{cssTemplates.map(template => (
<button
key={template.id}
type="button"
onClick={() => setFormData({ ...formData, css_template_id: template.id })}
className={`p-4 rounded-lg border-2 transition-all text-left ${
formData.css_template_id === template.id
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="font-medium text-sm">{template.name}</div>
<div className="text-xs text-neutral-500 mt-1">
{t('events.customTemplate', 'Custom Template')} {template.slot_number}
</div>
</button>
))}
</div>
</div>
)}
</div>
</Card>
@@ -950,9 +950,9 @@ export const EventDetailsPage: React.FC = () => {
) : (
<dl className="space-y-4">
<div>
<dt className="text-sm font-medium text-neutral-500">Source Mode</dt>
<dt className="text-sm font-medium text-neutral-500">{t('events.sourceMode', 'Source Mode')}</dt>
<dd className="mt-1 text-sm text-neutral-900">
{event.source_mode === 'reference' ? 'Reference (external folder)' : 'Managed (upload)'}
{event.source_mode === 'reference' ? t('events.sourceModeReference', 'Reference external folder') : t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}
{event.source_mode === 'reference' && event.external_path ? (
<span className="text-neutral-500 ml-2">/external-media/{event.external_path}</span>
) : null}
File diff suppressed because it is too large Load Diff
@@ -76,7 +76,14 @@ class CssTemplatesService {
async getGalleryCss(slug: string): Promise<string | null> {
try {
const response = await api.get(`/gallery/${slug}/css-template`, {
responseType: 'text'
responseType: 'text',
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
},
params: {
_t: Date.now() // Cache-busting parameter
}
});
if (response.status === 204) {
return null;
+2 -2
View File
@@ -29,11 +29,11 @@ const config: VitestUserConfig = {
host: true,
proxy: {
'/api': {
target: 'http://localhost:3001',
target: 'http://localhost:7101',
changeOrigin: true,
},
'/photos': {
target: 'http://localhost:3001',
target: 'http://localhost:7101',
changeOrigin: true,
},
},