feat: overhaul public landing page and backup tooling
This commit is contained in:
@@ -22,7 +22,6 @@ import {
|
||||
AnalyticsPage,
|
||||
BrandingPage,
|
||||
SettingsPage,
|
||||
CMSPage,
|
||||
BackupManagement
|
||||
} from './pages/admin';
|
||||
import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced';
|
||||
|
||||
@@ -167,6 +167,9 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{photos.map((photo, index) => {
|
||||
const isDeleting = deletingPhotos.has(photo.id);
|
||||
const commentCount = photo.comment_count ?? 0;
|
||||
const averageRating = photo.average_rating ?? 0;
|
||||
const likeCount = photo.like_count ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
@@ -258,18 +261,18 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
)}
|
||||
|
||||
{/* Feedback Indicators (moved to bottom-right to avoid covering category) */}
|
||||
{(photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && (
|
||||
{(commentCount > 0 || averageRating > 0 || likeCount > 0) && (
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-1 z-10">
|
||||
{photo.average_rating > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}>
|
||||
{averageRating > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(averageRating).toFixed(1)}`}>
|
||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span>
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(averageRating).toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
{photo.comment_count > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}>
|
||||
{commentCount > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${commentCount} comments`}>
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span>
|
||||
<span className="text-xs font-medium text-neutral-700">{commentCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, ThumbsUp, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { AdminPhoto } from '../../services/photos.service';
|
||||
import { photosService } from '../../services/photos.service';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service';
|
||||
import { Button } from '../common';
|
||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
|
||||
type AdminFeedbackResponse = {
|
||||
feedback: PhotoFeedback[];
|
||||
summary?: FeedbackSummary;
|
||||
};
|
||||
|
||||
interface AdminPhotoViewerProps {
|
||||
photos: AdminPhoto[];
|
||||
initialIndex: number;
|
||||
@@ -34,9 +39,16 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const currentPhoto = photos[currentIndex];
|
||||
const averageRating = currentPhoto?.average_rating ?? 0;
|
||||
const likeCount = currentPhoto?.like_count ?? 0;
|
||||
const favoriteCount = currentPhoto?.favorite_count ?? 0;
|
||||
|
||||
if (!currentPhoto) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch feedback for current photo
|
||||
const { data: feedbackData } = useQuery({
|
||||
const { data: feedbackData } = useQuery<AdminFeedbackResponse>({
|
||||
queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id],
|
||||
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
|
||||
photoId: currentPhoto?.id.toString(),
|
||||
@@ -45,6 +57,8 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
enabled: !!currentPhoto
|
||||
});
|
||||
|
||||
const comments = (feedbackData?.feedback ?? []).filter((item): item is PhotoFeedback => item.feedback_type === 'comment');
|
||||
|
||||
const goToPrevious = () => {
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
||||
};
|
||||
@@ -304,41 +318,41 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
|
||||
{/* Feedback Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
{currentPhoto.average_rating > 0 && (
|
||||
{averageRating > 0 && (
|
||||
<div className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-yellow-400 mb-1">
|
||||
<Star className="w-4 h-4" fill="currentColor" />
|
||||
<span className="text-white font-medium">{Number(currentPhoto.average_rating).toFixed(1)}</span>
|
||||
<span className="text-white font-medium">{Number(averageRating).toFixed(1)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400">Avg Rating</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPhoto.like_count > 0 && (
|
||||
{likeCount > 0 && (
|
||||
<div className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-red-400 mb-1">
|
||||
<Heart className="w-4 h-4" fill="currentColor" />
|
||||
<span className="text-white font-medium">{currentPhoto.like_count}</span>
|
||||
<span className="text-white font-medium">{likeCount}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400">Likes</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPhoto.favorite_count > 0 && (
|
||||
{favoriteCount > 0 && (
|
||||
<div className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-blue-400 mb-1">
|
||||
<Star className="w-4 h-4" />
|
||||
<span className="text-white font-medium">{currentPhoto.favorite_count}</span>
|
||||
<span className="text-white font-medium">{favoriteCount}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400">Favorites</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feedbackData.feedback && (
|
||||
{comments.length > 0 && (
|
||||
<div className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-green-400 mb-1">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span className="text-white font-medium">{feedbackData.feedback.filter(f => f.feedback_type === 'comment').length}</span>
|
||||
<span className="text-white font-medium">{comments.length}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400">Comments</p>
|
||||
</div>
|
||||
@@ -346,20 +360,18 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Comments List */}
|
||||
{feedbackData.feedback && feedbackData.feedback.filter(f => f.feedback_type === 'comment').length > 0 && (
|
||||
{comments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => setExpandedComments(!expandedComments)}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 mb-2"
|
||||
>
|
||||
{expandedComments ? 'Hide' : 'Show'} Comments ({feedbackData.feedback.filter(f => f.feedback_type === 'comment').length})
|
||||
{expandedComments ? 'Hide' : 'Show'} Comments ({comments.length})
|
||||
</button>
|
||||
|
||||
{expandedComments && (
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{feedbackData.feedback
|
||||
.filter(f => f.feedback_type === 'comment')
|
||||
.map((comment) => (
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="bg-neutral-800 rounded-lg p-3">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
@@ -459,7 +471,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
)}
|
||||
|
||||
{/* No feedback message */}
|
||||
{(!feedbackData.feedback || feedbackData.feedback.length === 0) && (
|
||||
{comments.length === 0 && (
|
||||
<p className="text-neutral-400 text-sm">No feedback for this photo yet.</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -475,4 +487,4 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -121,11 +121,20 @@ const StorageInfo: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const usagePercent = Math.round((storageInfo.total_used / storageInfo.storage_limit) * 100);
|
||||
const limitInUse = storageInfo.storage_soft_limit || storageInfo.storage_limit || 1;
|
||||
const usagePercent = limitInUse
|
||||
? Math.round((storageInfo.total_used / limitInUse) * 100)
|
||||
: 0;
|
||||
const isOverSoftLimit = limitInUse && storageInfo.total_used >= limitInUse;
|
||||
const progressBarClass = isOverSoftLimit ? 'bg-red-600' : 'bg-primary-600';
|
||||
const containerClass = isOverSoftLimit
|
||||
? 'bg-red-50 border border-red-200'
|
||||
: 'bg-neutral-100';
|
||||
const softLimitDisplay = settingsService.formatBytes(limitInUse);
|
||||
|
||||
return (
|
||||
<div className="p-4 border-t border-neutral-200">
|
||||
<div className="bg-neutral-100 rounded-lg p-3">
|
||||
<div className={`${containerClass} rounded-lg p-3 transition-colors duration-300`}>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-neutral-700">{t('admin.storageUsed')}</span>
|
||||
<span className="font-medium text-neutral-900">
|
||||
@@ -134,14 +143,14 @@ const StorageInfo: React.FC = () => {
|
||||
</div>
|
||||
<div className="mt-2 w-full bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||
className={`${progressBarClass} h-2 rounded-full transition-all duration-300`}
|
||||
style={{ width: `${Math.min(usagePercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-600 mt-1">
|
||||
{t('admin.storagePercent', { percent: usagePercent, limit: settingsService.formatBytes(storageInfo.storage_limit) })}
|
||||
{t('admin.storagePercent', { percent: usagePercent, limit: softLimitDisplay })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export const BackupConfiguration: ComponentType<any>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export const BackupDashboard: ComponentType<any>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export const BackupHistory: ComponentType<any>;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import HardBreak from '@tiptap/extension-hard-break';
|
||||
@@ -107,10 +107,11 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
},
|
||||
});
|
||||
|
||||
const updateCounts = useCallback((editor: any) => {
|
||||
const text = editor.state.doc.textContent;
|
||||
setCharCount(editor.storage.characterCount.characters());
|
||||
setWordCount(text.trim().split(/\s+/).filter(word => word.length > 0).length);
|
||||
const updateCounts = useCallback((editorInstance: Editor) => {
|
||||
const textContent = editorInstance.state.doc.textContent;
|
||||
setCharCount(editorInstance.storage.characterCount.characters());
|
||||
const words = textContent.trim().split(/\s+/).filter((word: string) => word.length > 0);
|
||||
setWordCount(words.length);
|
||||
}, []);
|
||||
|
||||
// Update editor content when prop changes
|
||||
@@ -568,4 +569,4 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
||||
);
|
||||
};
|
||||
|
||||
CMSEditor.displayName = 'CMSEditor';
|
||||
CMSEditor.displayName = 'CMSEditor';
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
MessageSquare,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Trash2,
|
||||
AlertCircle,
|
||||
import {
|
||||
MessageSquare,
|
||||
EyeOff,
|
||||
Trash2,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
import { parseISO } from 'date-fns';
|
||||
@@ -16,7 +13,7 @@ import { toast } from 'react-toastify';
|
||||
|
||||
import { Card, Loading, Button } from '../common';
|
||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { feedbackService, type FeedbackResponse, type PhotoFeedback } from '../../services/feedback.service';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
interface FeedbackModerationPanelProps {
|
||||
@@ -38,7 +35,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
// Fetch pending feedback
|
||||
const { data: feedbackData, isLoading } = useQuery({
|
||||
const { data: feedbackData, isLoading } = useQuery<FeedbackResponse>({
|
||||
queryKey: ['event-feedback-moderation', eventId],
|
||||
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
|
||||
type: 'comment',
|
||||
@@ -77,12 +74,12 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
|
||||
);
|
||||
}
|
||||
|
||||
const pendingComments = feedbackData?.feedback || [];
|
||||
const pendingComments: PhotoFeedback[] = feedbackData?.feedback || [];
|
||||
const hasPending = pendingComments.length > 0;
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<div className="p-6">
|
||||
<div className={compact ? 'p-4' : 'p-6'}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">
|
||||
{t('feedback.pendingModeration', 'Pending Moderation')}
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Shield, Eye, Lock, AlertTriangle, Info } from 'lucide-react';
|
||||
import { Button, Card, Toggle, Select, Input, Textarea } from '../common';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface ProtectionSettings {
|
||||
default_protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
default_image_quality: number;
|
||||
enable_devtools_protection: boolean;
|
||||
max_image_requests_per_minute: number;
|
||||
suspicious_activity_threshold: number;
|
||||
enable_canvas_rendering: boolean;
|
||||
default_fragmentation_level: number;
|
||||
enable_overlay_protection: boolean;
|
||||
protection_warning_message: string;
|
||||
}
|
||||
|
||||
export const ImageProtectionSettings: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState<ProtectionSettings>({
|
||||
default_protection_level: 'standard',
|
||||
default_image_quality: 85,
|
||||
enable_devtools_protection: true,
|
||||
max_image_requests_per_minute: 30,
|
||||
suspicious_activity_threshold: 10,
|
||||
enable_canvas_rendering: false,
|
||||
default_fragmentation_level: 3,
|
||||
enable_overlay_protection: true,
|
||||
protection_warning_message: 'Images in this gallery are protected from unauthorized download.'
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await settingsService.getSettings();
|
||||
|
||||
// Map settings from API response
|
||||
const protectionSettings: ProtectionSettings = {
|
||||
default_protection_level: response.default_protection_level || 'standard',
|
||||
default_image_quality: parseInt(response.default_image_quality) || 85,
|
||||
enable_devtools_protection: response.enable_devtools_protection !== false,
|
||||
max_image_requests_per_minute: parseInt(response.max_image_requests_per_minute) || 30,
|
||||
suspicious_activity_threshold: parseInt(response.suspicious_activity_threshold) || 10,
|
||||
enable_canvas_rendering: response.enable_canvas_rendering === true,
|
||||
default_fragmentation_level: parseInt(response.default_fragmentation_level) || 3,
|
||||
enable_overlay_protection: response.enable_overlay_protection !== false,
|
||||
protection_warning_message: response.protection_warning_message || settings.protection_warning_message
|
||||
};
|
||||
|
||||
setSettings(protectionSettings);
|
||||
} catch (error) {
|
||||
console.error('Failed to load protection settings:', error);
|
||||
toast.error('Failed to load protection settings');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// Convert settings to API format
|
||||
const apiSettings = Object.entries(settings).reduce((acc, [key, value]) => {
|
||||
acc[key] = typeof value === 'boolean' ? value : value.toString();
|
||||
return acc;
|
||||
}, {} as Record<string, string | boolean>);
|
||||
|
||||
await settingsService.updateSettings(apiSettings);
|
||||
toast.success('Protection settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to save protection settings:', error);
|
||||
toast.error('Failed to save protection settings');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = (key: keyof ProtectionSettings, value: any) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const protectionLevels = [
|
||||
{ value: 'basic', label: 'Basic - Minimal protection, best performance' },
|
||||
{ value: 'standard', label: 'Standard - Balanced protection and performance' },
|
||||
{ value: 'enhanced', label: 'Enhanced - Strong protection with good performance' },
|
||||
{ value: 'maximum', label: 'Maximum - Strongest protection, may impact performance' }
|
||||
];
|
||||
|
||||
const getProtectionLevelIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'basic': return <Eye className="w-4 h-4 text-green-500" />;
|
||||
case 'standard': return <Shield className="w-4 h-4 text-blue-500" />;
|
||||
case 'enhanced': return <Lock className="w-4 h-4 text-orange-500" />;
|
||||
case 'maximum': return <AlertTriangle className="w-4 h-4 text-red-500" />;
|
||||
default: return <Shield className="w-4 h-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getProtectionLevelDescription = (level: string) => {
|
||||
switch (level) {
|
||||
case 'basic':
|
||||
return 'Prevents drag/drop and basic right-click. Good for public galleries.';
|
||||
case 'standard':
|
||||
return 'Adds keyboard shortcut blocking and user selection prevention.';
|
||||
case 'enhanced':
|
||||
return 'Includes DevTools detection, rate limiting, and overlay protection.';
|
||||
case 'maximum':
|
||||
return 'Canvas rendering, image fragmentation, and comprehensive monitoring.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-200 rounded"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Shield className="w-6 h-6 text-blue-500" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Image Protection Settings</h2>
|
||||
<p className="text-sm text-gray-600">Configure security measures for photo galleries</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Protection Level */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Default Protection Level
|
||||
</label>
|
||||
<Select
|
||||
value={settings.default_protection_level}
|
||||
onChange={(value) => updateSetting('default_protection_level', value as any)}
|
||||
options={protectionLevels}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{getProtectionLevelIcon(settings.default_protection_level)}
|
||||
<span className="font-medium text-sm capitalize">
|
||||
{settings.default_protection_level} Protection
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{getProtectionLevelDescription(settings.default_protection_level)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Quality */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Default Image Quality ({settings.default_image_quality}%)
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="30"
|
||||
max="100"
|
||||
step="5"
|
||||
value={settings.default_image_quality}
|
||||
onChange={(e) => updateSetting('default_image_quality', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>Lower quality = Better protection</span>
|
||||
<span>Higher quality = Better image</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DevTools Protection */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
DevTools Protection
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Detect and respond to browser developer tools
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.enable_devtools_protection}
|
||||
onChange={(checked) => updateSetting('enable_devtools_protection', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Canvas Rendering */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Canvas Rendering
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Render images on canvas instead of img tags (stronger protection)
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.enable_canvas_rendering}
|
||||
onChange={(checked) => updateSetting('enable_canvas_rendering', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fragmentation Level */}
|
||||
{settings.enable_canvas_rendering && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Image Fragmentation Level ({settings.default_fragmentation_level})
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
step="1"
|
||||
value={settings.default_fragmentation_level}
|
||||
onChange={(e) => updateSetting('default_fragmentation_level', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>Low fragmentation</span>
|
||||
<span>High fragmentation</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rate Limiting */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Max Requests Per Minute
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="5"
|
||||
max="100"
|
||||
value={settings.max_image_requests_per_minute}
|
||||
onChange={(e) => updateSetting('max_image_requests_per_minute', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Suspicious Activity Threshold
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="3"
|
||||
max="50"
|
||||
value={settings.suspicious_activity_threshold}
|
||||
onChange={(e) => updateSetting('suspicious_activity_threshold', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay Protection */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Overlay Protection
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Add transparent overlays to prevent easy screenshot extraction
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.enable_overlay_protection}
|
||||
onChange={(checked) => updateSetting('enable_overlay_protection', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warning Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Protection Warning Message
|
||||
</label>
|
||||
<Textarea
|
||||
value={settings.protection_warning_message}
|
||||
onChange={(e) => updateSetting('protection_warning_message', e.target.value)}
|
||||
placeholder="Message shown when protection is triggered"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning Box */}
|
||||
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="w-5 h-5 text-amber-600 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-amber-800 mb-1">Important Notes</h4>
|
||||
<ul className="text-sm text-amber-700 space-y-1">
|
||||
<li>• Higher protection levels may impact page performance</li>
|
||||
<li>• Canvas rendering disables browser image caching</li>
|
||||
<li>• Maximum protection may cause accessibility issues</li>
|
||||
<li>• Test thoroughly with your target browsers</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadSettings}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={saveSettings}
|
||||
loading={isSaving}
|
||||
>
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -94,7 +94,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
|
||||
await api.post(`/admin/events/${eventId}/upload`, formData, {
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
// Calculate overall progress across all chunks
|
||||
@@ -276,4 +276,4 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
);
|
||||
};
|
||||
|
||||
PhotoUpload.displayName = 'PhotoUpload';
|
||||
PhotoUpload.displayName = 'PhotoUpload';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export const RestoreWizard: ComponentType<any>;
|
||||
@@ -37,8 +37,8 @@ export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
|
||||
rows={rows}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 text-neutral-400">
|
||||
<HelpCircle className="w-4 h-4" title="Line breaks will be preserved in emails" />
|
||||
<div className="absolute top-2 right-2 text-neutral-400" title="Line breaks will be preserved in emails">
|
||||
<HelpCircle className="w-4 h-4" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,4 +61,4 @@ export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
WelcomeMessageEditor.displayName = 'WelcomeMessageEditor';
|
||||
WelcomeMessageEditor.displayName = 'WelcomeMessageEditor';
|
||||
|
||||
@@ -6,6 +6,23 @@ interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageEleme
|
||||
fallbackSrc?: string;
|
||||
useWatermark?: boolean;
|
||||
isGallery?: boolean;
|
||||
protectFromDownload?: boolean;
|
||||
slug?: string;
|
||||
photoId?: number;
|
||||
requiresToken?: boolean;
|
||||
secureUrlTemplate?: string;
|
||||
downloadUrlTemplate?: string;
|
||||
onProtectionViolation?: (violationType: string) => void;
|
||||
watermarkText?: string;
|
||||
overlayProtection?: boolean;
|
||||
fragmentGrid?: boolean;
|
||||
scrambleFragments?: boolean;
|
||||
useCanvasRendering?: boolean;
|
||||
blockKeyboardShortcuts?: boolean;
|
||||
detectPrintScreen?: boolean;
|
||||
detectDevTools?: boolean;
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
}
|
||||
|
||||
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
@@ -14,8 +31,46 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
alt,
|
||||
useWatermark = false,
|
||||
isGallery = false,
|
||||
protectFromDownload,
|
||||
slug,
|
||||
photoId,
|
||||
requiresToken,
|
||||
secureUrlTemplate,
|
||||
downloadUrlTemplate,
|
||||
onProtectionViolation,
|
||||
watermarkText,
|
||||
overlayProtection,
|
||||
fragmentGrid,
|
||||
scrambleFragments,
|
||||
useCanvasRendering,
|
||||
blockKeyboardShortcuts,
|
||||
detectPrintScreen,
|
||||
detectDevTools,
|
||||
protectionLevel,
|
||||
useEnhancedProtection,
|
||||
...props
|
||||
}) => {
|
||||
const unusedProps = {
|
||||
protectFromDownload,
|
||||
slug,
|
||||
photoId,
|
||||
requiresToken,
|
||||
secureUrlTemplate,
|
||||
downloadUrlTemplate,
|
||||
onProtectionViolation,
|
||||
watermarkText,
|
||||
overlayProtection,
|
||||
fragmentGrid,
|
||||
scrambleFragments,
|
||||
useCanvasRendering,
|
||||
blockKeyboardShortcuts,
|
||||
detectPrintScreen,
|
||||
detectDevTools,
|
||||
protectionLevel,
|
||||
useEnhancedProtection
|
||||
};
|
||||
void unusedProps;
|
||||
|
||||
const [imageSrc, setImageSrc] = useState<string>('');
|
||||
const [error, setError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -183,7 +183,7 @@ export const PasswordGenerator: React.FC<PasswordGeneratorProps> = ({
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="xs"
|
||||
size="sm"
|
||||
onClick={() => selectPassword(password)}
|
||||
>
|
||||
{t('passwordGenerator.use')}
|
||||
@@ -206,4 +206,4 @@ export const PasswordGenerator: React.FC<PasswordGeneratorProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordGenerator;
|
||||
export default PasswordGenerator;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { ProtectionLevel } from '../../hooks/useImageProtection';
|
||||
|
||||
type VendorStyle = CSSStyleDeclaration & {
|
||||
webkitUserSelect?: string;
|
||||
webkitTouchCallout?: string;
|
||||
webkitUserDrag?: string;
|
||||
};
|
||||
|
||||
interface ProtectedImageProps extends React.CanvasHTMLAttributes<HTMLCanvasElement> {
|
||||
src: string;
|
||||
alt: string;
|
||||
@@ -31,7 +37,6 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
@@ -201,9 +206,6 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
||||
// Render normal image - ensure image is valid before drawing
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Verify the image was drawn by checking a pixel
|
||||
const pixelData = ctx.getImageData(10, 10, 1, 1).data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,11 +290,12 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
||||
}
|
||||
|
||||
// Apply CSS protection
|
||||
canvas.style.userSelect = 'none';
|
||||
canvas.style.webkitUserSelect = 'none';
|
||||
canvas.style.webkitTouchCallout = 'none';
|
||||
canvas.style.webkitUserDrag = 'none';
|
||||
canvas.style.pointerEvents = protectionLevel === 'maximum' ? 'none' : 'auto';
|
||||
const canvasStyle = canvas.style as VendorStyle;
|
||||
canvasStyle.userSelect = 'none';
|
||||
canvasStyle.webkitUserSelect = 'none';
|
||||
canvasStyle.webkitTouchCallout = 'none';
|
||||
canvasStyle.webkitUserDrag = 'none';
|
||||
canvasStyle.pointerEvents = protectionLevel === 'maximum' ? 'none' : 'auto';
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('contextmenu', handleContextMenu);
|
||||
@@ -402,4 +405,4 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -102,7 +102,7 @@ export const ProtectionWarning: React.FC<ProtectionWarningProps> = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
<style>{`
|
||||
@keyframes shrink {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
@@ -110,4 +110,4 @@ export const ProtectionWarning: React.FC<ProtectionWarningProps> = ({
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -35,7 +35,6 @@ interface GalleryLayoutProps {
|
||||
showDownloadAll?: boolean;
|
||||
onDownloadAll?: () => void;
|
||||
isDownloading?: boolean;
|
||||
isExpired?: boolean;
|
||||
headerExtra?: React.ReactNode;
|
||||
menuButton?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
@@ -49,7 +48,6 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
showDownloadAll = false,
|
||||
onDownloadAll,
|
||||
isDownloading = false,
|
||||
isExpired = false,
|
||||
headerExtra,
|
||||
menuButton,
|
||||
children,
|
||||
@@ -63,22 +61,28 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
||||
|
||||
// Calculate logo size classes based on settings
|
||||
const getLogoSizeClass = (context: 'header' | 'hero') => {
|
||||
const getLogoDimensions = (context: 'header' | 'hero'): { className: string; style?: React.CSSProperties } => {
|
||||
const size = brandingSettings?.logo_size || 'medium';
|
||||
const maxHeight = brandingSettings?.logo_max_height || 48;
|
||||
|
||||
|
||||
if (size === 'custom') {
|
||||
return { maxHeight: `${maxHeight}px`, height: 'auto' };
|
||||
return {
|
||||
className: '',
|
||||
style: { maxHeight: `${maxHeight}px`, height: 'auto' }
|
||||
};
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
|
||||
const sizeMap: Record<'small' | 'medium' | 'large' | 'xlarge', string> = {
|
||||
small: context === 'header' ? 'h-6 sm:h-8' : 'h-12 sm:h-14 lg:h-16',
|
||||
medium: context === 'header' ? 'h-8 sm:h-10 lg:h-12' : 'h-16 sm:h-20 lg:h-24',
|
||||
large: context === 'header' ? 'h-10 sm:h-12 lg:h-16' : 'h-20 sm:h-24 lg:h-32',
|
||||
xlarge: context === 'header' ? 'h-12 sm:h-16 lg:h-20' : 'h-24 sm:h-32 lg:h-40'
|
||||
};
|
||||
|
||||
return sizeMap[size] || sizeMap.medium;
|
||||
|
||||
return {
|
||||
className: sizeMap[size as keyof typeof sizeMap] || sizeMap.medium,
|
||||
style: undefined
|
||||
};
|
||||
};
|
||||
|
||||
// Determine logo position classes
|
||||
@@ -108,6 +112,9 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
const displayMode = brandingSettings?.logo_display_mode || 'logo_and_text';
|
||||
return displayMode !== 'logo_only';
|
||||
};
|
||||
|
||||
const headerLogoSize = getLogoDimensions('header');
|
||||
const heroLogoSize = getLogoDimensions('hero');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
@@ -183,8 +190,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className={`${typeof getLogoSizeClass('header') === 'string' ? getLogoSizeClass('header') : ''} w-auto object-contain`}
|
||||
style={typeof getLogoSizeClass('header') === 'object' ? getLogoSizeClass('header') : undefined}
|
||||
className={`${headerLogoSize.className} w-auto object-contain`}
|
||||
style={headerLogoSize.style}
|
||||
/>
|
||||
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
||||
<span className="hidden sm:inline text-lg font-semibold text-neutral-900">
|
||||
@@ -353,11 +360,11 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className={`${typeof getLogoSizeClass('hero') === 'string' ? getLogoSizeClass('hero') : ''} w-auto object-contain mx-auto`}
|
||||
style={typeof getLogoSizeClass('hero') === 'object' ?
|
||||
{ ...getLogoSizeClass('hero'), filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' } :
|
||||
{ filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' }
|
||||
}
|
||||
className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
|
||||
style={{
|
||||
...(heroLogoSize.style || {}),
|
||||
filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
|
||||
}}
|
||||
/>
|
||||
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
||||
<div className="mt-3 text-xl sm:text-2xl font-semibold text-white/90" style={{ textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)' }}>
|
||||
@@ -460,4 +467,4 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
GalleryLayout.displayName = 'GalleryLayout';
|
||||
GalleryLayout.displayName = 'GalleryLayout';
|
||||
|
||||
@@ -21,7 +21,6 @@ interface GallerySidebarProps {
|
||||
onDownloadAll: () => void;
|
||||
onDownloadSelected: () => void;
|
||||
isDownloading: boolean;
|
||||
isExpired?: boolean;
|
||||
allowDownloads?: boolean;
|
||||
photoCounts?: Record<number, number>;
|
||||
totalPhotos: number;
|
||||
@@ -52,7 +51,6 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
onDownloadAll,
|
||||
onDownloadSelected,
|
||||
isDownloading,
|
||||
isExpired = false,
|
||||
allowDownloads = true,
|
||||
photoCounts = {},
|
||||
totalPhotos,
|
||||
|
||||
@@ -20,7 +20,6 @@ import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { api } from '../../config/api';
|
||||
import { Upload, Menu } from 'lucide-react';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||
import type { Photo } from '../../types';
|
||||
|
||||
@@ -462,7 +461,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
onDownloadAll={handleDownloadAll}
|
||||
onDownloadSelected={handleDownloadSelected}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
isExpired={isExpired}
|
||||
allowDownloads={allowDownloads}
|
||||
photoCounts={photoCounts}
|
||||
totalPhotos={data?.photos.length || 0}
|
||||
@@ -473,7 +471,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
filterType={filterType}
|
||||
onFilterChange={setFilterType}
|
||||
likeCount={data?.photos?.filter(p => p.like_count > 0).length || 0}
|
||||
likeCount={data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0}
|
||||
ratedCount={data?.photos?.filter(p => (p.total_ratings || 0) > 0).length || 0}
|
||||
/>
|
||||
) : null}
|
||||
@@ -486,7 +484,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
showDownloadAll={!showSidebar && allowDownloads}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
isExpired={isExpired}
|
||||
menuButton={showSidebar ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -147,7 +147,6 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
value={guestName}
|
||||
onChange={(e) => setGuestName(e.target.value)}
|
||||
error={errors.guest_name}
|
||||
size="sm"
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
@@ -155,7 +154,6 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
value={guestEmail}
|
||||
onChange={(e) => setGuestEmail(e.target.value)}
|
||||
error={errors.guest_email}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -247,4 +245,4 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -73,7 +73,10 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
if (requireNameEmail && !savedIdentity) {
|
||||
setShowIdentityModal(true);
|
||||
} else {
|
||||
submitFavoriteMutation.mutate(savedIdentity || {});
|
||||
const identityPayload = savedIdentity
|
||||
? { guest_name: savedIdentity.name, guest_email: savedIdentity.email }
|
||||
: {};
|
||||
submitFavoriteMutation.mutate(identityPayload);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,4 +119,4 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import { PhotoRating } from './PhotoRating';
|
||||
import { PhotoLikes } from './PhotoLikes';
|
||||
import { PhotoComments } from './PhotoComments';
|
||||
import { Skeleton } from '../common';
|
||||
import type { FeedbackSettings } from '../../services/feedback.service';
|
||||
|
||||
interface PhotoFeedbackProps {
|
||||
photoId: string;
|
||||
@@ -33,7 +32,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
||||
});
|
||||
|
||||
// Fetch feedback data for the photo
|
||||
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
|
||||
const { data: feedbackData } = useQuery({
|
||||
queryKey: ['photo-feedback', gallerySlug, photoId],
|
||||
queryFn: () => feedbackService.getPhotoFeedback(gallerySlug, photoId),
|
||||
enabled: !!settings?.feedback_enabled,
|
||||
|
||||
@@ -100,7 +100,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
||||
|
||||
try {
|
||||
await galleryService.downloadSelectedPhotos(slug, ids);
|
||||
analyticsService.trackGalleryEvent('bulk_download_selected', { gallery: slug, photo_count: ids.length });
|
||||
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
|
||||
} catch (error) {
|
||||
toastify.error(t('gallery.downloadError'));
|
||||
} finally {
|
||||
@@ -191,6 +191,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
slug={slug}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -222,6 +223,7 @@ interface PhotoThumbnailProps {
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
slug: string; // Add slug as required prop
|
||||
feedbackEnabled?: boolean;
|
||||
}
|
||||
|
||||
const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
@@ -233,7 +235,8 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
slug
|
||||
slug,
|
||||
feedbackEnabled = false
|
||||
}) => {
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
@@ -280,18 +283,18 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||
/>
|
||||
|
||||
{/* Feedback Indicators */}
|
||||
{feedbackEnabled && (photo.has_feedback || photo.average_rating > 0 || photo.comment_count > 0) && (
|
||||
{feedbackEnabled && (photo.has_feedback || (photo.average_rating ?? 0) > 0 || (photo.comment_count ?? 0) > 0) && (
|
||||
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||
{photo.comment_count > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}>
|
||||
{(photo.comment_count ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}>
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span>
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
{photo.average_rating > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}>
|
||||
{(photo.average_rating ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating ?? 0).toFixed(1)}`}>
|
||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span>
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating ?? 0).toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -148,7 +148,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
|
||||
try {
|
||||
await galleryService.downloadSelectedPhotos(slug, ids);
|
||||
analyticsService.trackGalleryEvent('bulk_download_selected', { gallery: slug, photo_count: ids.length });
|
||||
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
|
||||
} catch (error) {
|
||||
toastify.error(t('gallery.downloadError'));
|
||||
} finally {
|
||||
|
||||
@@ -432,12 +432,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
}}
|
||||
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||
aria-label="Toggle feedback"
|
||||
title={`Photo feedback${currentPhoto.comment_count > 0 ? ` (${currentPhoto.comment_count} comments)` : ''}`}
|
||||
title={`Photo feedback${(currentPhoto.comment_count ?? 0) > 0 ? ` (${currentPhoto.comment_count ?? 0} comments)` : ''}`}
|
||||
>
|
||||
<MessageSquare className="w-5 h-5 text-white" />
|
||||
{(currentPhoto.comment_count > 0 || currentPhoto.average_rating > 0) && (
|
||||
{((currentPhoto.comment_count ?? 0) > 0 || (currentPhoto.average_rating ?? 0) > 0) && (
|
||||
<span className="absolute -top-1 -right-1 bg-primary-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||
{currentPhoto.comment_count > 0 ? currentPhoto.comment_count : '★'}
|
||||
{(currentPhoto.comment_count ?? 0) > 0 ? currentPhoto.comment_count ?? 0 : '★'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -529,7 +529,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
</div>
|
||||
<div className="p-4 flex-1 overflow-y-auto">
|
||||
<PhotoFeedback
|
||||
photoId={currentPhoto.id}
|
||||
photoId={String(currentPhoto.id)}
|
||||
gallerySlug={slug}
|
||||
showComments={true}
|
||||
className="space-y-4"
|
||||
|
||||
@@ -73,7 +73,10 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
if (requireNameEmail && !savedIdentity) {
|
||||
setShowIdentityModal(true);
|
||||
} else {
|
||||
submitLikeMutation.mutate(savedIdentity || {});
|
||||
const identityPayload = savedIdentity
|
||||
? { guest_name: savedIdentity.name, guest_email: savedIdentity.email }
|
||||
: {};
|
||||
submitLikeMutation.mutate(identityPayload);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,4 +119,4 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -68,6 +68,10 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
: animationType === 'fade'
|
||||
? 'transition-opacity duration-300'
|
||||
: '';
|
||||
const likeCount = photo.like_count ?? 0;
|
||||
const averageRating = photo.average_rating ?? 0;
|
||||
const commentCount = photo.comment_count ?? 0;
|
||||
const showFeedbackActions = feedbackEnabled && Boolean(feedbackOptions);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -99,7 +103,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
detectPrintScreen={useEnhancedProtection}
|
||||
detectDevTools={protectionLevel === 'maximum'}
|
||||
watermarkText={useEnhancedProtection ? 'Protected' : undefined}
|
||||
onProtectionViolation={(violationType) => {
|
||||
onProtectionViolation={(violationType: string) => {
|
||||
console.warn(`Protection violation on grid photo ${photo.id}: ${violationType}`);
|
||||
}}
|
||||
/>
|
||||
@@ -126,7 +130,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
)}
|
||||
{onQuickComment && (
|
||||
{showFeedbackActions && onQuickComment && (
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
|
||||
@@ -137,7 +141,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
</button>
|
||||
)}
|
||||
{/* Quick feedback actions */}
|
||||
{feedbackOptions?.allowLikes && (
|
||||
{showFeedbackActions && feedbackOptions?.allowLikes && (
|
||||
<button
|
||||
className={`p-2 rounded-full transition-colors ${liked ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
||||
onClick={async (e) => {
|
||||
@@ -189,19 +193,19 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
</button>
|
||||
|
||||
{/* Feedback Indicators (always visible, bottom-left). Show like immediately when user liked */}
|
||||
{(photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0 || liked) && (
|
||||
{(commentCount > 0 || averageRating > 0 || likeCount > 0 || liked) && (
|
||||
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-10`}>
|
||||
{(photo.like_count > 0 || liked) && (
|
||||
{(likeCount > 0 || liked) && (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||
</span>
|
||||
)}
|
||||
{photo.average_rating > 0 && (
|
||||
{averageRating > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Rated">
|
||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||
</span>
|
||||
)}
|
||||
{photo.comment_count > 0 && (
|
||||
{commentCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Commented">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||
</span>
|
||||
|
||||
@@ -269,19 +269,19 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
</button>
|
||||
|
||||
{/* Feedback indicators (always visible, bottom-left). Show like immediately when liked */}
|
||||
{(photo.like_count > 0 || likedIds.has(photo.id) || (photo.average_rating || 0) > 0 || (photo.comment_count || 0) > 0) && (
|
||||
{((photo.like_count ?? 0) > 0 || likedIds.has(photo.id) || (photo.average_rating ?? 0) > 0 || (photo.comment_count ?? 0) > 0) && (
|
||||
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-20`}>
|
||||
{(photo.like_count > 0 || likedIds.has(photo.id)) && (
|
||||
{((photo.like_count ?? 0) > 0 || likedIds.has(photo.id)) && (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||
</span>
|
||||
)}
|
||||
{(photo.average_rating || 0) > 0 && (
|
||||
{(photo.average_rating ?? 0) > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Rated">
|
||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-yellow-500 fill-current"><path d="M12 .587l3.668 7.431 8.2 1.193-5.934 5.787 1.402 8.168L12 18.897l-7.336 3.869 1.402-8.168L.132 9.211l8.2-1.193z"/></svg>
|
||||
</span>
|
||||
)}
|
||||
{(photo.comment_count || 0) > 0 && (
|
||||
{(photo.comment_count ?? 0) > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Commented">
|
||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-blue-600 fill-current"><path d="M20 2H4a2 2 0 00-2 2v18l4-4h14a2 2 0 002-2V4a2 2 0 00-2-2z"/></svg>
|
||||
</span>
|
||||
|
||||
@@ -72,24 +72,24 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
/>
|
||||
|
||||
{/* Feedback Indicators */}
|
||||
{feedbackEnabled && (photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && (
|
||||
{feedbackEnabled && ((photo.comment_count ?? 0) > 0 || (photo.average_rating ?? 0) > 0 || (photo.like_count ?? 0) > 0) && (
|
||||
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||
{photo.comment_count > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}>
|
||||
{(photo.comment_count ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}>
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span>
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
{photo.average_rating > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}>
|
||||
{(photo.average_rating ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating ?? 0).toFixed(1)}`}>
|
||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span>
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating ?? 0).toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
{photo.like_count > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.like_count} likes`}>
|
||||
{(photo.like_count ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.like_count ?? 0} likes`}>
|
||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.like_count}</span>
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.like_count ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -129,7 +129,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Feedback Indicators (bottom-left) */}
|
||||
{(photo.like_count > 0 || likedLocal) && (
|
||||
{((photo.like_count ?? 0) > 0 || likedLocal) && (
|
||||
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 z-10`}>
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||
@@ -298,6 +298,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => handlePhotoClick(currentIndex, photo.id)}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||
className=""
|
||||
allowDownloads={allowDownloads}
|
||||
slug={slug}
|
||||
|
||||
@@ -182,7 +182,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(photo.like_count > 0 || likedIds.has(photo.id)) && (
|
||||
{((photo.like_count ?? 0) > 0 || likedIds.has(photo.id)) && (
|
||||
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 z-10`}>
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ProtectionLevel } from './useImageProtection';
|
||||
|
||||
type VendorStyle = CSSStyleDeclaration & {
|
||||
webkitUserSelect?: string;
|
||||
webkitTouchCallout?: string;
|
||||
webkitUserDrag?: string;
|
||||
};
|
||||
|
||||
interface UseCSSProtectionOptions {
|
||||
enabled: boolean;
|
||||
protectionLevel: ProtectionLevel;
|
||||
@@ -63,19 +69,22 @@ export const useCSSProtection = (options: UseCSSProtectionOptions) => {
|
||||
// Apply inline styles for enhanced protection
|
||||
if (options.protectionLevel === 'enhanced' || options.protectionLevel === 'maximum') {
|
||||
// Disable various browser features
|
||||
container.style.userSelect = 'none';
|
||||
container.style.webkitUserSelect = 'none';
|
||||
container.style.webkitTouchCallout = 'none';
|
||||
container.style.webkitUserDrag = 'none';
|
||||
const containerStyle = container.style as VendorStyle;
|
||||
containerStyle.userSelect = 'none';
|
||||
containerStyle.webkitUserSelect = 'none';
|
||||
containerStyle.webkitTouchCallout = 'none';
|
||||
containerStyle.webkitUserDrag = 'none';
|
||||
|
||||
// Find all img and canvas elements and protect them
|
||||
const mediaElements = container.querySelectorAll('img, canvas');
|
||||
mediaElements.forEach(element => {
|
||||
(element as HTMLElement).draggable = false;
|
||||
(element as HTMLElement).style.userSelect = 'none';
|
||||
(element as HTMLElement).style.webkitUserSelect = 'none';
|
||||
(element as HTMLElement).style.webkitUserDrag = 'none';
|
||||
(element as HTMLElement).style.webkitTouchCallout = 'none';
|
||||
const el = element as HTMLElement;
|
||||
const elStyle = el.style as VendorStyle;
|
||||
el.draggable = false;
|
||||
elStyle.userSelect = 'none';
|
||||
elStyle.webkitUserSelect = 'none';
|
||||
elStyle.webkitUserDrag = 'none';
|
||||
elStyle.webkitTouchCallout = 'none';
|
||||
|
||||
if (options.protectionLevel === 'maximum') {
|
||||
(element as HTMLElement).style.pointerEvents = 'none';
|
||||
@@ -101,19 +110,22 @@ export const useCSSProtection = (options: UseCSSProtectionOptions) => {
|
||||
}
|
||||
|
||||
// Reset inline styles
|
||||
container.style.userSelect = '';
|
||||
container.style.webkitUserSelect = '';
|
||||
container.style.webkitTouchCallout = '';
|
||||
container.style.webkitUserDrag = '';
|
||||
const containerStyle = container.style as VendorStyle;
|
||||
containerStyle.userSelect = '';
|
||||
containerStyle.webkitUserSelect = '';
|
||||
containerStyle.webkitTouchCallout = '';
|
||||
containerStyle.webkitUserDrag = '';
|
||||
|
||||
// Reset media element styles
|
||||
const mediaElements = container.querySelectorAll('img, canvas');
|
||||
mediaElements.forEach(element => {
|
||||
(element as HTMLElement).style.userSelect = '';
|
||||
(element as HTMLElement).style.webkitUserSelect = '';
|
||||
(element as HTMLElement).style.webkitUserDrag = '';
|
||||
(element as HTMLElement).style.webkitTouchCallout = '';
|
||||
(element as HTMLElement).style.pointerEvents = '';
|
||||
const el = element as HTMLElement;
|
||||
const elStyle = el.style as VendorStyle;
|
||||
elStyle.userSelect = '';
|
||||
elStyle.webkitUserSelect = '';
|
||||
elStyle.webkitUserDrag = '';
|
||||
elStyle.webkitTouchCallout = '';
|
||||
elStyle.pointerEvents = '';
|
||||
});
|
||||
};
|
||||
}, [
|
||||
@@ -125,4 +137,4 @@ export const useCSSProtection = (options: UseCSSProtectionOptions) => {
|
||||
]);
|
||||
|
||||
return containerRef;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,7 +11,6 @@ interface UseDevToolsProtectionOptions {
|
||||
export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) => {
|
||||
const detectionTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastConsoleCountRef = useRef(0);
|
||||
const startTimeRef = useRef<number>(Date.now());
|
||||
const isDetectedRef = useRef(false);
|
||||
|
||||
const handleDevToolsDetected = useCallback(() => {
|
||||
@@ -246,4 +245,4 @@ export const useDevToolsProtection = (options: UseDevToolsProtectionOptions) =>
|
||||
isDetectedRef.current = false;
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
type VendorStyle = CSSStyleDeclaration & {
|
||||
webkitUserSelect?: string;
|
||||
webkitTouchCallout?: string;
|
||||
webkitUserDrag?: string;
|
||||
MozAppearance?: string;
|
||||
webkitAppearance?: string;
|
||||
};
|
||||
|
||||
export type ProtectionLevel = 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
|
||||
interface UseImageProtectionOptions {
|
||||
@@ -16,9 +24,9 @@ interface UseImageProtectionOptions {
|
||||
}
|
||||
|
||||
export const useImageProtection = (options: UseImageProtectionOptions) => {
|
||||
const elementRef = useRef<HTMLImageElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const elementRef = useRef<HTMLImageElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const printScreenDetectorRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const printScreenIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
@@ -195,25 +203,26 @@ export const useImageProtection = (options: UseImageProtectionOptions) => {
|
||||
}
|
||||
|
||||
// CSS protection
|
||||
element.style.userSelect = 'none';
|
||||
element.style.webkitUserSelect = 'none';
|
||||
element.style.webkitTouchCallout = 'none';
|
||||
element.style.pointerEvents = 'auto';
|
||||
element.style.webkitUserDrag = 'none';
|
||||
element.style.webkitTouchCallout = 'none';
|
||||
const elementStyle = element.style as VendorStyle;
|
||||
elementStyle.userSelect = 'none';
|
||||
elementStyle.webkitUserSelect = 'none';
|
||||
elementStyle.webkitTouchCallout = 'none';
|
||||
elementStyle.pointerEvents = 'auto';
|
||||
elementStyle.webkitUserDrag = 'none';
|
||||
element.draggable = false;
|
||||
|
||||
// Enhanced CSS protection
|
||||
if (protectionLevel !== 'basic') {
|
||||
element.style.outline = 'none';
|
||||
element.style.webkitAppearance = 'none';
|
||||
element.style.MozAppearance = 'none';
|
||||
elementStyle.outline = 'none';
|
||||
elementStyle.webkitAppearance = 'none';
|
||||
elementStyle.MozAppearance = 'none';
|
||||
|
||||
// Disable text selection on parent elements
|
||||
let parent = element.parentElement;
|
||||
while (parent) {
|
||||
parent.style.userSelect = 'none';
|
||||
parent.style.webkitUserSelect = 'none';
|
||||
const parentStyle = parent.style as VendorStyle;
|
||||
parentStyle.userSelect = 'none';
|
||||
parentStyle.webkitUserSelect = 'none';
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
}
|
||||
@@ -287,4 +296,4 @@ export const useImageProtection = (options: UseImageProtectionOptions) => {
|
||||
canvasRef,
|
||||
overlayRef
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -770,12 +770,66 @@
|
||||
"dateFormat": "Datumsformat",
|
||||
"dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden"
|
||||
},
|
||||
"publicSite": {
|
||||
"tabLabel": "Öffentliche Seite",
|
||||
"badge": "Öffentliche Landingpage",
|
||||
"title": "Öffentliche Landingpage",
|
||||
"subtitle": "Veröffentlichen Sie eine anpassbare Landingpage für Gäste, die Ihre Domain besuchen.",
|
||||
"loading": "Öffentliche Seite wird geladen...",
|
||||
"enabled": "Aktiv",
|
||||
"disabled": "Inaktiv",
|
||||
"htmlLabel": "Landingpage-HTML",
|
||||
"htmlPlaceholder": "HTML-Markup für Hero, Abschnitte und Handlungsaufrufe einfügen.",
|
||||
"htmlHelp": "Rohes HTML wird beim Speichern bereinigt. Unsichere Skripte und Attribute werden automatisch entfernt. Platzhalter wie {{company_name}}, {{company_tagline}} und {{support_email}} werden mit den Werten aus den Branding-Einstellungen ersetzt.",
|
||||
"cssLabel": "Benutzerdefiniertes CSS",
|
||||
"cssPlaceholder": "Optionale CSS-Anpassungen für Layout, Farben oder Typografie.",
|
||||
"cssHelp": "Import-Anweisungen und JavaScript-URLs werden entfernt. Verwenden Sie Standard-CSS-Selektoren.",
|
||||
"saveCta": "Öffentliche Seite speichern",
|
||||
"saving": "Speichern...",
|
||||
"saveSuccess": "Öffentliche Seite gespeichert.",
|
||||
"saveError": "Öffentliche Seite konnte nicht gespeichert werden.",
|
||||
"resetCta": "Auf Standard zurücksetzen",
|
||||
"resetting": "Setze zurück...",
|
||||
"resetSuccess": "Landingpage wurde auf die Standardvorlage zurückgesetzt.",
|
||||
"resetError": "Zurücksetzen auf Standard fehlgeschlagen.",
|
||||
"previewTitle": "Live-Vorschau",
|
||||
"previewSandboxed": "Sandbox-Vorschau",
|
||||
"previewDisabled": "Aktivieren Sie die öffentliche Seite, um die Live-Vorschau zu sehen.",
|
||||
"sanitizationNotice": "Skripte, iframes und unsichere Attribute werden vor der Veröffentlichung entfernt. Erlaubt sind sichere HTML-Elemente, Links und Bilder.",
|
||||
"htmlRequired": "Legen Sie HTML-Inhalt fest, bevor die öffentliche Seite aktiviert wird."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Speicher",
|
||||
"overview": "Speicherübersicht",
|
||||
"totalUsed": "Gesamt verwendet",
|
||||
"archiveStorage": "Archivspeicher",
|
||||
"storageLimit": "Speicherlimit",
|
||||
"storageLimitHelper": "Definieren Sie ein Limit für die Speichernutzung. Dieses Limit dient als Hinweis und wird hervorgehoben, sobald es erreicht ist.",
|
||||
"softLimitInputLabel": "Limit (GB)",
|
||||
"softLimitHelper": "Geben Sie das gewünschte maximale Speicherlimit in Gigabyte ein.",
|
||||
"recommendedSoftLimit": "Empfohlenes Limit",
|
||||
"diskCapacity": "Festplattenkapazität",
|
||||
"diskCapacityReported": "Festplattenkapazität (gemeldet)",
|
||||
"diskAvailable": "Verfügbar",
|
||||
"diskAvailableReported": "Verfügbar (gemeldet)",
|
||||
"diskFree": "Frei",
|
||||
"diskFreeReported": "Frei (gemeldet)",
|
||||
"diskMetricsUnavailable": "Speicherinformationen sind in Docker Desktop oder virtuellen Umgebungen nicht verfügbar.",
|
||||
"applyRecommended": "Empfehlung nutzen",
|
||||
"applyAvailable": "An verfügbaren Speicher anpassen",
|
||||
"invalidSoftLimit": "Bitte geben Sie eine gültige Zahl für das Limit ein.",
|
||||
"saveSoftLimit": "Limit speichern",
|
||||
"limitNotEnforced": "Dieses Limit dient nur als Hinweis und wird nicht automatisch durchgesetzt.",
|
||||
"overrideTitle": "Manuelle Kapazitätsüberschreibung",
|
||||
"diskOverrideEnvNote": "Die Speicherkapazität wird über Umgebungsvariablen gesteuert und kann hier nicht geändert werden.",
|
||||
"diskOverrideSettingsHelp": "Setzen Sie eigene Werte, wenn Docker unrealistische Zahlen meldet.",
|
||||
"overrideCapacityLabel": "Gesamtkapazität (GB)",
|
||||
"overrideCapacityHelper": "Leer lassen, um die automatische Erkennung zu verwenden.",
|
||||
"overrideAvailableLabel": "Verfügbarer Speicher (GB)",
|
||||
"overrideAvailableHelper": "Optional. Leer lassen, um aus Gesamtkapazität minus belegtem Speicher zu berechnen.",
|
||||
"saveOverride": "Kapazität speichern",
|
||||
"capacityRequiredForAvailable": "Geben Sie zuerst eine Gesamtkapazität an, bevor Sie verfügbaren Speicher setzen.",
|
||||
"availableExceedsCapacity": "Der verfügbare Speicher darf die Gesamtkapazität nicht überschreiten.",
|
||||
"storageUsage": "Speichernutzung",
|
||||
"storageByEvent": "Speicher nach Veranstaltung",
|
||||
"storageManagement": "Speicherverwaltung",
|
||||
@@ -988,7 +1042,9 @@
|
||||
"activeGalleries": "Aktive Galerien",
|
||||
"storageUsed": "Speicher verwendet",
|
||||
"totalPhotos": "Gesamte Fotos",
|
||||
"storagePercent": "{{percent}}% von {{limit}}",
|
||||
"storagePercent": "{{percent}}% des Limits {{limit}}",
|
||||
"storageSoftLimitConfigured": "Konfiguriertes Limit: {{limit}}",
|
||||
"storageSoftLimitRecommended": "Empfohlenes Limit: {{limit}}",
|
||||
"version": "Version",
|
||||
"archivedEvents": "Archivierte Veranstaltungen",
|
||||
"systemHealth": "Systemstatus",
|
||||
|
||||
@@ -456,12 +456,66 @@
|
||||
"dateFormat": "Date Format",
|
||||
"dateFormatHelp": "How dates are displayed in emails and throughout the application"
|
||||
},
|
||||
"publicSite": {
|
||||
"tabLabel": "Public Site",
|
||||
"badge": "Public Landing",
|
||||
"title": "Public Landing Page",
|
||||
"subtitle": "Publish a customized landing page for guests when they visit your domain.",
|
||||
"loading": "Loading public site settings...",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"htmlLabel": "Landing Page HTML",
|
||||
"htmlPlaceholder": "Provide the HTML markup for your landing page hero, sections, and calls to action.",
|
||||
"htmlHelp": "Raw HTML is sanitized on save. Unsafe tags, inline scripts, and disallowed attributes are removed automatically. Tokens such as {{company_name}}, {{company_tagline}}, and {{support_email}} will be substituted with values from Branding settings.",
|
||||
"cssLabel": "Custom CSS",
|
||||
"cssPlaceholder": "Optional CSS overrides to adjust layout, colors, or typography.",
|
||||
"cssHelp": "Imports and JavaScript URLs are stripped. Use standard CSS selectors to override the default styling.",
|
||||
"saveCta": "Save public site",
|
||||
"saving": "Saving...",
|
||||
"saveSuccess": "Public site settings saved.",
|
||||
"saveError": "Failed to save public site settings.",
|
||||
"resetCta": "Reset to default",
|
||||
"resetting": "Resetting...",
|
||||
"resetSuccess": "Public site template reset to defaults.",
|
||||
"resetError": "Failed to reset public site template.",
|
||||
"previewTitle": "Live Preview",
|
||||
"previewSandboxed": "Sandboxed preview",
|
||||
"previewDisabled": "Enable the public site to see the live preview.",
|
||||
"sanitizationNotice": "Scripts, embedded frames, and unsafe attributes are removed before publishing. Only safe HTML, links, and images are allowed.",
|
||||
"htmlRequired": "Provide HTML content before enabling the public site."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Storage",
|
||||
"overview": "Storage Overview",
|
||||
"totalUsed": "Total Used",
|
||||
"archiveStorage": "Archive Storage",
|
||||
"storageLimit": "Storage Limit",
|
||||
"storageLimitHelper": "Set a limit for photo storage usage. This limit is advisory and will highlight when reached.",
|
||||
"softLimitInputLabel": "Limit (GB)",
|
||||
"softLimitHelper": "Enter the preferred maximum storage usage in gigabytes.",
|
||||
"recommendedSoftLimit": "Suggested Limit",
|
||||
"diskCapacity": "Disk Capacity",
|
||||
"diskCapacityReported": "Disk Capacity (reported)",
|
||||
"diskAvailable": "Available",
|
||||
"diskAvailableReported": "Available (reported)",
|
||||
"diskFree": "Free",
|
||||
"diskFreeReported": "Free (reported)",
|
||||
"diskMetricsUnavailable": "Disk metrics are not available in Docker Desktop or virtualized environments.",
|
||||
"applyRecommended": "Use recommended",
|
||||
"applyAvailable": "Match available",
|
||||
"invalidSoftLimit": "Enter a valid number for the limit.",
|
||||
"saveSoftLimit": "Save limit",
|
||||
"limitNotEnforced": "This limit is advisory only and is not enforced automatically.",
|
||||
"overrideTitle": "Manual capacity override",
|
||||
"diskOverrideEnvNote": "Disk capacity is controlled by environment variables and cannot be edited here.",
|
||||
"diskOverrideSettingsHelp": "Set custom capacity values when Docker reports unrealistic figures.",
|
||||
"overrideCapacityLabel": "Total capacity (GB)",
|
||||
"overrideCapacityHelper": "Leave blank to use the automatic reading.",
|
||||
"overrideAvailableLabel": "Available space (GB)",
|
||||
"overrideAvailableHelper": "Optional. Leave blank to calculate from total capacity minus used storage.",
|
||||
"saveOverride": "Save capacity override",
|
||||
"capacityRequiredForAvailable": "Enter a total capacity before setting available space.",
|
||||
"availableExceedsCapacity": "Available space cannot exceed the total capacity.",
|
||||
"storageUsage": "Storage Usage",
|
||||
"storageByEvent": "Storage by Event",
|
||||
"storageManagement": "Storage Management",
|
||||
@@ -732,7 +786,9 @@
|
||||
"activeGalleries": "Active Galleries",
|
||||
"storageUsed": "Storage Used",
|
||||
"totalPhotos": "Total Photos",
|
||||
"storagePercent": "{{percent}}% of {{limit}}",
|
||||
"storagePercent": "{{percent}}% of limit {{limit}}",
|
||||
"storageSoftLimitConfigured": "Configured limit: {{limit}}",
|
||||
"storageSoftLimitRecommended": "Suggested limit: {{limit}}",
|
||||
"version": "Version",
|
||||
"archivedEvents": "Archived Events",
|
||||
"systemHealth": "System Health",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { format, parseISO } from 'date-fns';
|
||||
import { Button, Card, Loading } from '../../components/common';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminService } from '../../services/admin.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
@@ -72,6 +73,11 @@ export const AnalyticsPage: React.FC = () => {
|
||||
queryFn: () => adminService.getDashboardStats(),
|
||||
});
|
||||
|
||||
const { data: storageInfo } = useQuery({
|
||||
queryKey: ['storage-info'],
|
||||
queryFn: () => settingsService.getStorageInfo(),
|
||||
});
|
||||
|
||||
// Fetch Umami config from admin settings since we're in admin panel
|
||||
useEffect(() => {
|
||||
const fetchUmamiConfig = async () => {
|
||||
@@ -154,12 +160,12 @@ export const AnalyticsPage: React.FC = () => {
|
||||
|
||||
// Get actual download data for top galleries - sort by downloads
|
||||
const topGalleriesWithDownloads = apiData.topGalleries
|
||||
.filter(gallery => gallery.downloads > 0) // Only show galleries with downloads
|
||||
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0)) // Sort by downloads
|
||||
.filter(gallery => (gallery.downloads ?? 0) > 0) // Only show galleries with downloads
|
||||
.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0)) // Sort by downloads
|
||||
.slice(0, 5) // Take top 5
|
||||
.map(gallery => ({
|
||||
name: gallery.event_name,
|
||||
downloads: gallery.downloads || 0
|
||||
downloads: gallery.downloads ?? 0
|
||||
}));
|
||||
|
||||
return {
|
||||
@@ -429,38 +435,68 @@ export const AnalyticsPage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
{/* Storage Information */}
|
||||
{dashboardStats && (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.storageUsage')}</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-neutral-600">{t('analytics.used')}</span>
|
||||
<span className="font-medium">{adminService.formatBytes(dashboardStats.storageUsed)}</span>
|
||||
{dashboardStats && (() => {
|
||||
const softLimitBytes = storageInfo?.storage_soft_limit ?? storageInfo?.storage_limit ?? storageInfo?.recommended_soft_limit ?? null;
|
||||
const safeSoftLimit = Math.max(
|
||||
softLimitBytes ?? storageInfo?.recommended_soft_limit ?? (dashboardStats.storageUsed || 1),
|
||||
1
|
||||
);
|
||||
const usageRatio = dashboardStats.storageUsed / safeSoftLimit;
|
||||
const usagePercent = Math.round(usageRatio * 100);
|
||||
const usageWidth = Math.min(usageRatio * 100, 100);
|
||||
const overSoftLimit = softLimitBytes != null && dashboardStats.storageUsed >= softLimitBytes;
|
||||
const limitDisplay = softLimitBytes != null
|
||||
? adminService.formatBytes(softLimitBytes)
|
||||
: storageInfo?.recommended_soft_limit != null
|
||||
? adminService.formatBytes(storageInfo.recommended_soft_limit)
|
||||
: t('settings.storage.unlimited');
|
||||
const progressColor = overSoftLimit
|
||||
? 'bg-red-600'
|
||||
: usagePercent >= 90
|
||||
? 'bg-amber-500'
|
||||
: 'bg-primary-600';
|
||||
const limitDescriptor = storageInfo
|
||||
? storageInfo.soft_limit_configured
|
||||
? t('admin.storageSoftLimitConfigured', { limit: limitDisplay })
|
||||
: t('admin.storageSoftLimitRecommended', { limit: limitDisplay })
|
||||
: t('admin.storageSoftLimitRecommended', { limit: limitDisplay });
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('analytics.storageUsage')}</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-neutral-600">{t('analytics.used')}</span>
|
||||
<span className="font-medium">{adminService.formatBytes(dashboardStats.storageUsed)}</span>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
className={`${progressColor} h-2 rounded-full transition-all`}
|
||||
style={{ width: `${usageWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{usagePercent}% {t('analytics.of')} {limitDisplay}
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${overSoftLimit ? 'text-red-600 font-semibold' : 'text-red-500 font-medium'}`}>
|
||||
{limitDescriptor}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${Math.min((dashboardStats.storageUsed / (10 * 1024 * 1024 * 1024)) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{Math.round((dashboardStats.storageUsed / (10 * 1024 * 1024 * 1024)) * 100)}% {t('analytics.of')} 10 GB
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-neutral-600">{t('analytics.totalPhotos')}</span>
|
||||
<span className="font-medium">{dashboardStats.totalPhotos.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-2">
|
||||
<span className="text-neutral-600">{t('analytics.activeEvents')}</span>
|
||||
<span className="font-medium">{dashboardStats.activeEvents}</span>
|
||||
<div className="pt-2 border-t">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-neutral-600">{t('analytics.totalPhotos')}</span>
|
||||
<span className="font-medium">{dashboardStats.totalPhotos.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-2">
|
||||
<span className="text-neutral-600">{t('analytics.activeEvents')}</span>
|
||||
<span className="font-medium">{dashboardStats.activeEvents}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -480,4 +516,4 @@ export const AnalyticsPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export const BackupManagement: ComponentType<any>;
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, FileText, Globe } from 'lucide-react';
|
||||
import { FileText, Globe, Sparkles, ShieldCheck } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../components/common';
|
||||
import { CMSEditor } from '../../components/admin/CMSEditor';
|
||||
import { cmsService } from '../../services/cms.service';
|
||||
import type { CMSPage as CMSPageType } from '../../services/cms.service';
|
||||
import { settingsService, PublicSiteBranding } from '../../services/settings.service';
|
||||
|
||||
export const CMSPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -15,6 +17,11 @@ export const CMSPage: React.FC = () => {
|
||||
const [selectedPage, setSelectedPage] = useState<string>('impressum');
|
||||
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
|
||||
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
|
||||
const [publicSiteEnabled, setPublicSiteEnabled] = useState(false);
|
||||
const [publicSiteHtml, setPublicSiteHtml] = useState('');
|
||||
const [publicSiteCss, setPublicSiteCss] = useState('');
|
||||
const [publicSiteBaseCss, setPublicSiteBaseCss] = useState('');
|
||||
const [publicSiteBranding, setPublicSiteBranding] = useState<PublicSiteBranding | undefined>(undefined);
|
||||
|
||||
// Fetch CMS pages
|
||||
const { data: pages, isLoading } = useQuery({
|
||||
@@ -22,6 +29,33 @@ export const CMSPage: React.FC = () => {
|
||||
queryFn: cmsService.getPages,
|
||||
});
|
||||
|
||||
const { data: adminSettings, isLoading: isLoadingAdminSettings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
});
|
||||
|
||||
const { data: publicSiteDefaults, isLoading: isLoadingPublicDefaults } = useQuery({
|
||||
queryKey: ['public-site-defaults'],
|
||||
queryFn: () => settingsService.getPublicSiteDefaults(),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (publicSiteDefaults) {
|
||||
setPublicSiteBaseCss(publicSiteDefaults.baseCss || '');
|
||||
setPublicSiteBranding(publicSiteDefaults.branding);
|
||||
}
|
||||
}, [publicSiteDefaults]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!adminSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPublicSiteEnabled(Boolean(adminSettings.general_public_site_enabled));
|
||||
setPublicSiteHtml((adminSettings.general_public_site_html as string) || '');
|
||||
setPublicSiteCss((adminSettings.general_public_site_custom_css as string) || '');
|
||||
}, [adminSettings]);
|
||||
|
||||
// Update page mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
|
||||
@@ -62,6 +96,53 @@ export const CMSPage: React.FC = () => {
|
||||
setEditForm(prev => ({ ...prev, [field]: title }));
|
||||
};
|
||||
|
||||
const publicSiteSaveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmedHtml = publicSiteHtml.trim();
|
||||
if (publicSiteEnabled && !trimmedHtml) {
|
||||
throw new Error('PUBLIC_SITE_HTML_REQUIRED');
|
||||
}
|
||||
|
||||
await settingsService.updatePublicSite({
|
||||
enabled: publicSiteEnabled,
|
||||
html: trimmedHtml || '',
|
||||
css: publicSiteCss,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success(t('settings.publicSite.saveSuccess'));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
|
||||
]);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error?.message === 'PUBLIC_SITE_HTML_REQUIRED') {
|
||||
toast.error(t('settings.publicSite.htmlRequired'));
|
||||
return;
|
||||
}
|
||||
toast.error(t('settings.publicSite.saveError'));
|
||||
},
|
||||
});
|
||||
|
||||
const publicSiteResetMutation = useMutation({
|
||||
mutationFn: () => settingsService.resetPublicSite(),
|
||||
onSuccess: async (data) => {
|
||||
toast.success(t('settings.publicSite.resetSuccess'));
|
||||
setPublicSiteHtml(data.html || '');
|
||||
setPublicSiteCss(data.css || '');
|
||||
setPublicSiteBaseCss(data.baseCss || '');
|
||||
setPublicSiteBranding(data.branding ?? publicSiteDefaults?.branding);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
|
||||
]);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.publicSite.resetError'));
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -72,6 +153,131 @@ export const CMSPage: React.FC = () => {
|
||||
|
||||
const currentPage = pages?.find(p => p.slug === selectedPage);
|
||||
|
||||
const publicSiteSanitizedHtml = useMemo(() => DOMPurify.sanitize(publicSiteHtml || '', {
|
||||
ALLOWED_TAGS: [
|
||||
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div', 'em',
|
||||
'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
|
||||
'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span', 'strong', 'sup',
|
||||
'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul'
|
||||
],
|
||||
ALLOWED_ATTR: ['class', 'id', 'role', 'aria-label', 'aria-hidden', 'href', 'target', 'rel', 'src', 'alt', 'title', 'loading', 'decoding', 'width', 'height'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
ADD_ATTR: ['data-*'],
|
||||
}), [publicSiteHtml]);
|
||||
|
||||
const sanitizeCss = (css: string) => {
|
||||
if (!css) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let sanitized = css;
|
||||
const disallowedPatterns = [
|
||||
/@import[^;]+;?/gi,
|
||||
/@charset[^;]+;?/gi,
|
||||
/expression\s*\([^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
|
||||
];
|
||||
|
||||
disallowedPatterns.forEach((pattern) => {
|
||||
sanitized = sanitized.replace(pattern, '');
|
||||
});
|
||||
|
||||
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
||||
|
||||
const MAX_LENGTH = 100 * 1024;
|
||||
if (sanitized.length > MAX_LENGTH) {
|
||||
sanitized = sanitized.slice(0, MAX_LENGTH);
|
||||
}
|
||||
|
||||
return sanitized.trim();
|
||||
};
|
||||
|
||||
const publicSiteSanitizedCss = useMemo(() => sanitizeCss(publicSiteCss || ''), [publicSiteCss]);
|
||||
|
||||
const applyBrandTokens = (html: string, branding: PublicSiteBranding | undefined) => {
|
||||
if (!html || !branding) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const tokens: Record<string, string> = {
|
||||
company_name: branding.companyName || '',
|
||||
company_tagline: branding.companyTagline || '',
|
||||
support_email: branding.supportEmail || '',
|
||||
brand_logo_url: branding.logoUrl || '/picpeak-logo-transparent.png',
|
||||
brand_primary_hex: branding.colors.primary,
|
||||
brand_accent_hex: branding.colors.accent,
|
||||
brand_background_hex: branding.colors.background,
|
||||
brand_text_hex: branding.colors.text,
|
||||
};
|
||||
|
||||
return html.replace(/\{\{\s*(company_name|company_tagline|support_email|brand_logo_url|brand_primary_hex|brand_accent_hex|brand_background_hex|brand_text_hex)\s*\}\}/gi,
|
||||
(_, key: string) => tokens[key] || '');
|
||||
};
|
||||
|
||||
const publicSitePreview = useMemo(() => {
|
||||
const branding = publicSiteBranding || publicSiteDefaults?.branding;
|
||||
const substitutedHtml = applyBrandTokens(publicSiteSanitizedHtml, branding);
|
||||
const inlineStyles = [
|
||||
branding ? `:root {\n --brand-primary: ${branding.colors.primary};\n --brand-accent: ${branding.colors.accent};\n --brand-background: ${branding.colors.background};\n --brand-text: ${branding.colors.text};\n}` : '',
|
||||
publicSiteBaseCss,
|
||||
publicSiteSanitizedCss ? `/* Custom styles */\n${publicSiteSanitizedCss}` : ''
|
||||
].filter(Boolean).join('\n\n');
|
||||
|
||||
const logo = branding?.logoUrl ? `<img src="${branding.logoUrl}" alt="${branding.companyName || 'Brand logo'}" class="brand-logo" loading="lazy" decoding="async" />` : '';
|
||||
const tagline = branding?.companyTagline ? `<p class="brand-tagline">${branding.companyTagline}</p>` : '';
|
||||
const support = branding?.supportEmail ? `<a href="mailto:${branding.supportEmail}">${branding.supportEmail}</a>` : '';
|
||||
const footerNote = branding?.footerText ? `<p>${branding.footerText}</p>` : '';
|
||||
|
||||
const displayName = branding?.companyName || 'Celebration Stories';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>${inlineStyles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="site-shell">
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
${logo}
|
||||
<div class="brand-copy">
|
||||
<p class="brand-label">${displayName}</p>
|
||||
${tagline}
|
||||
</div>
|
||||
</div>
|
||||
<nav class="site-nav">
|
||||
<a href="#collections">Collections</a>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#stories">Stories</a>
|
||||
<a href="#contact">Contact</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="site-main">
|
||||
${substitutedHtml}
|
||||
</main>
|
||||
<footer class="site-footer" id="contact">
|
||||
<div class="footer-inner">
|
||||
<div>
|
||||
<h2>${displayName}</h2>
|
||||
${footerNote}
|
||||
</div>
|
||||
<div class="footer-contact">
|
||||
<span>${support}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}, [publicSiteBranding, publicSiteDefaults, publicSiteSanitizedHtml, publicSiteBaseCss, publicSiteSanitizedCss]);
|
||||
|
||||
const publicSiteLoading = isLoadingAdminSettings || isLoadingPublicDefaults;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
@@ -79,6 +285,135 @@ export const CMSPage: React.FC = () => {
|
||||
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-primary-600 mb-1">
|
||||
<Globe className="w-5 h-5" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">{t('settings.publicSite.badge')}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-neutral-900">{t('settings.publicSite.title')}</h2>
|
||||
<p className="text-neutral-600 mt-1 max-w-2xl">{t('settings.publicSite.subtitle')}</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={publicSiteEnabled}
|
||||
onChange={() => setPublicSiteEnabled((prev) => !prev)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
publicSiteEnabled ? 'bg-primary-600' : 'bg-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
publicSiteEnabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
{publicSiteEnabled ? t('settings.publicSite.enabled') : t('settings.publicSite.disabled')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{publicSiteLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[240px]">
|
||||
<Loading size="lg" text={t('settings.publicSite.loading')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
|
||||
<Sparkles className="w-4 h-4 text-primary-500" />
|
||||
{t('settings.publicSite.htmlLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full h-64 font-mono text-sm rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 disabled:text-neutral-500"
|
||||
value={publicSiteHtml}
|
||||
onChange={(event) => setPublicSiteHtml(event.target.value)}
|
||||
disabled={!publicSiteEnabled}
|
||||
placeholder={t('settings.publicSite.htmlPlaceholder') || ''}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.publicSite.htmlHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
|
||||
<ShieldCheck className="w-4 h-4 text-primary-500" />
|
||||
{t('settings.publicSite.cssLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full h-48 font-mono text-sm rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 disabled:text-neutral-500"
|
||||
value={publicSiteCss}
|
||||
onChange={(event) => setPublicSiteCss(event.target.value)}
|
||||
disabled={!publicSiteEnabled}
|
||||
placeholder={t('settings.publicSite.cssPlaceholder') || ''}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.publicSite.cssHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => publicSiteSaveMutation.mutate()}
|
||||
disabled={publicSiteSaveMutation.isPending}
|
||||
isLoading={publicSiteSaveMutation.isPending}
|
||||
>
|
||||
{publicSiteSaveMutation.isPending ? t('settings.publicSite.saving') : t('settings.publicSite.saveCta')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => publicSiteResetMutation.mutate()}
|
||||
disabled={publicSiteResetMutation.isPending}
|
||||
isLoading={publicSiteResetMutation.isPending}
|
||||
>
|
||||
{publicSiteResetMutation.isPending ? t('settings.publicSite.resetting') : t('settings.publicSite.resetCta')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-neutral-50 border border-neutral-200 p-3 text-xs text-neutral-600 leading-relaxed">
|
||||
<p className="font-semibold mb-1">{t('settings.publicSite.sanitizationNotice')}</p>
|
||||
<p>{t('settings.publicSite.htmlHelp')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-neutral-800 uppercase tracking-wide">
|
||||
{t('settings.publicSite.previewTitle')}
|
||||
</h3>
|
||||
<span className="text-xs text-neutral-500">{t('settings.publicSite.previewSandboxed')}</span>
|
||||
</div>
|
||||
{publicSiteEnabled ? (
|
||||
<div className="rounded-xl border border-neutral-200 overflow-hidden shadow-sm bg-white">
|
||||
<iframe
|
||||
title="public-site-preview"
|
||||
sandbox="allow-same-origin"
|
||||
className="w-full h-[480px] bg-white"
|
||||
srcDoc={publicSitePreview}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-neutral-300 bg-neutral-50 p-8 text-center text-sm text-neutral-500">
|
||||
{t('settings.publicSite.previewDisabled')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Page Selection */}
|
||||
<div className="lg:col-span-1">
|
||||
@@ -200,4 +535,4 @@ export const CMSPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, FileText, Globe, Clock } from 'lucide-react';
|
||||
import { FileText, Globe, Clock, Sparkles, ShieldCheck } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { debounce } from 'lodash';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../components/common';
|
||||
import { CMSEditor } from '../../components/admin/CMSEditor';
|
||||
import { cmsService } from '../../services/cms.service';
|
||||
import type { CMSPage as CMSPageType } from '../../services/cms.service';
|
||||
import { settingsService, PublicSiteBranding } from '../../services/settings.service';
|
||||
|
||||
export const CMSPageEnhanced: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -19,6 +21,11 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
||||
const [isAutoSaving, setIsAutoSaving] = useState(false);
|
||||
const [publicSiteEnabled, setPublicSiteEnabled] = useState(false);
|
||||
const [publicSiteHtml, setPublicSiteHtml] = useState('');
|
||||
const [publicSiteCss, setPublicSiteCss] = useState('');
|
||||
const [publicSiteBaseCss, setPublicSiteBaseCss] = useState('');
|
||||
const [publicSiteBranding, setPublicSiteBranding] = useState<PublicSiteBranding | undefined>(undefined);
|
||||
|
||||
// Fetch CMS pages
|
||||
const { data: pages, isLoading } = useQuery({
|
||||
@@ -26,6 +33,16 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
queryFn: cmsService.getPages,
|
||||
});
|
||||
|
||||
const { data: adminSettings, isLoading: isLoadingAdminSettings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
});
|
||||
|
||||
const { data: publicSiteDefaults, isLoading: isLoadingPublicDefaults } = useQuery({
|
||||
queryKey: ['public-site-defaults'],
|
||||
queryFn: () => settingsService.getPublicSiteDefaults(),
|
||||
});
|
||||
|
||||
// Update page mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
|
||||
@@ -45,6 +62,53 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const publicSiteSaveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const trimmedHtml = publicSiteHtml.trim();
|
||||
if (publicSiteEnabled && !trimmedHtml) {
|
||||
throw new Error('PUBLIC_SITE_HTML_REQUIRED');
|
||||
}
|
||||
|
||||
await settingsService.updatePublicSite({
|
||||
enabled: publicSiteEnabled,
|
||||
html: trimmedHtml || '',
|
||||
css: publicSiteCss,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success(t('settings.publicSite.saveSuccess'));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
|
||||
]);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error?.message === 'PUBLIC_SITE_HTML_REQUIRED') {
|
||||
toast.error(t('settings.publicSite.htmlRequired'));
|
||||
return;
|
||||
}
|
||||
toast.error(t('settings.publicSite.saveError'));
|
||||
},
|
||||
});
|
||||
|
||||
const publicSiteResetMutation = useMutation({
|
||||
mutationFn: () => settingsService.resetPublicSite(),
|
||||
onSuccess: async (data) => {
|
||||
toast.success(t('settings.publicSite.resetSuccess'));
|
||||
setPublicSiteHtml(data.html || '');
|
||||
setPublicSiteCss(data.css || '');
|
||||
setPublicSiteBaseCss(data.baseCss || '');
|
||||
setPublicSiteBranding(data.branding ?? publicSiteDefaults?.branding);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
|
||||
]);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.publicSite.resetError'));
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-save functionality
|
||||
const autoSave = useCallback(
|
||||
debounce(() => {
|
||||
@@ -59,6 +123,23 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
[hasUnsavedChanges, editForm, selectedPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (publicSiteDefaults) {
|
||||
setPublicSiteBaseCss(publicSiteDefaults.baseCss || '');
|
||||
setPublicSiteBranding(publicSiteDefaults.branding);
|
||||
}
|
||||
}, [publicSiteDefaults]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPublicSiteEnabled(Boolean(adminSettings.general_public_site_enabled));
|
||||
setPublicSiteHtml((adminSettings.general_public_site_html as string) || '');
|
||||
setPublicSiteCss((adminSettings.general_public_site_custom_css as string) || '');
|
||||
}, [adminSettings]);
|
||||
|
||||
// Trigger auto-save when content changes
|
||||
useEffect(() => {
|
||||
if (hasUnsavedChanges) {
|
||||
@@ -113,6 +194,126 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
const currentPage = pages?.find(p => p.slug === selectedPage);
|
||||
const publicSiteSanitizedHtml = useMemo(() => DOMPurify.sanitize(publicSiteHtml || '', {
|
||||
ALLOWED_TAGS: [
|
||||
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div', 'em',
|
||||
'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
|
||||
'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span', 'strong', 'sup',
|
||||
'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul'
|
||||
],
|
||||
ALLOWED_ATTR: ['class', 'id', 'role', 'aria-label', 'aria-hidden', 'href', 'target', 'rel', 'src', 'alt', 'title', 'loading', 'decoding', 'width', 'height'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
ADD_ATTR: ['data-*'],
|
||||
}), [publicSiteHtml]);
|
||||
|
||||
const sanitizeCss = (css: string) => {
|
||||
if (!css) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let sanitized = css;
|
||||
const disallowedPatterns = [
|
||||
/@import[^;]+;?/gi,
|
||||
/@charset[^;]+;?/gi,
|
||||
/expression\s*\([^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
|
||||
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
|
||||
];
|
||||
|
||||
disallowedPatterns.forEach((pattern) => {
|
||||
sanitized = sanitized.replace(pattern, '');
|
||||
});
|
||||
|
||||
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
||||
|
||||
const MAX_LENGTH = 100 * 1024;
|
||||
if (sanitized.length > MAX_LENGTH) {
|
||||
sanitized = sanitized.slice(0, MAX_LENGTH);
|
||||
}
|
||||
|
||||
return sanitized.trim();
|
||||
};
|
||||
|
||||
const publicSiteSanitizedCss = useMemo(() => sanitizeCss(publicSiteCss || ''), [publicSiteCss]);
|
||||
|
||||
const applyBrandTokens = (html: string, branding: PublicSiteBranding | undefined) => {
|
||||
if (!html || !branding) {
|
||||
return html;
|
||||
}
|
||||
|
||||
const tokens: Record<string, string> = {
|
||||
company_name: branding.companyName || '',
|
||||
company_tagline: branding.companyTagline || '',
|
||||
support_email: branding.supportEmail || '',
|
||||
};
|
||||
|
||||
return html.replace(/\{\{\s*(company_name|company_tagline|support_email)\s*\}\}/gi, (_, key: string) => tokens[key] || '');
|
||||
};
|
||||
|
||||
const publicSitePreview = useMemo(() => {
|
||||
const branding = publicSiteBranding || publicSiteDefaults?.branding;
|
||||
const substitutedHtml = applyBrandTokens(publicSiteSanitizedHtml, branding);
|
||||
const inlineStyles = [
|
||||
branding ? `:root {\n --brand-primary: ${branding.colors.primary};\n --brand-accent: ${branding.colors.accent};\n --brand-background: ${branding.colors.background};\n --brand-text: ${branding.colors.text};\n}` : '',
|
||||
publicSiteBaseCss,
|
||||
publicSiteSanitizedCss ? `/* Custom styles */\n${publicSiteSanitizedCss}` : ''
|
||||
].filter(Boolean).join('\n\n');
|
||||
|
||||
const logo = branding?.logoUrl ? `<img src="${branding.logoUrl}" alt="${branding.companyName || 'Brand logo'}" class="brand-logo" loading="lazy" decoding="async" />` : '';
|
||||
const tagline = branding?.companyTagline ? `<p class="brand-tagline">${branding.companyTagline}</p>` : '';
|
||||
const support = branding?.supportEmail ? `<a href="mailto:${branding.supportEmail}">${branding.supportEmail}</a>` : '';
|
||||
const footerNote = branding?.footerText ? `<p>${branding.footerText}</p>` : '';
|
||||
|
||||
const displayName = branding?.companyName || 'Celebration Stories';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style>${inlineStyles}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="site-shell">
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<div class="brand">
|
||||
${logo}
|
||||
<div class="brand-copy">
|
||||
<p class="brand-label">${displayName}</p>
|
||||
${tagline}
|
||||
</div>
|
||||
</div>
|
||||
<nav class="site-nav">
|
||||
<a href="#collections">Collections</a>
|
||||
<a href="#features">Features</a>
|
||||
<a href="#stories">Stories</a>
|
||||
<a href="#contact">Contact</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="site-main">
|
||||
${substitutedHtml}
|
||||
</main>
|
||||
<footer class="site-footer" id="contact">
|
||||
<div class="footer-inner">
|
||||
<div>
|
||||
<h2>${displayName}</h2>
|
||||
${footerNote}
|
||||
</div>
|
||||
<div class="footer-contact">
|
||||
<span>${support}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}, [publicSiteBranding, publicSiteDefaults, publicSiteSanitizedHtml, publicSiteBaseCss, publicSiteSanitizedCss]);
|
||||
|
||||
const publicSiteLoading = isLoadingAdminSettings || isLoadingPublicDefaults;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -121,8 +322,6 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const currentPage = pages?.find(p => p.slug === selectedPage);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
@@ -130,6 +329,135 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-primary-600 mb-1">
|
||||
<Globe className="w-5 h-5" />
|
||||
<span className="text-sm font-semibold uppercase tracking-wide">{t('settings.publicSite.badge')}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-neutral-900">{t('settings.publicSite.title')}</h2>
|
||||
<p className="text-neutral-600 mt-1 max-w-2xl">{t('settings.publicSite.subtitle')}</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={publicSiteEnabled}
|
||||
onChange={() => setPublicSiteEnabled((prev) => !prev)}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
publicSiteEnabled ? 'bg-primary-600' : 'bg-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
publicSiteEnabled ? 'translate-x-5' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
{publicSiteEnabled ? t('settings.publicSite.enabled') : t('settings.publicSite.disabled')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{publicSiteLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[240px]">
|
||||
<Loading size="lg" text={t('settings.publicSite.loading')} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
|
||||
<Sparkles className="w-4 h-4 text-primary-500" />
|
||||
{t('settings.publicSite.htmlLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full h-64 font-mono text-sm rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 disabled:text-neutral-500"
|
||||
value={publicSiteHtml}
|
||||
onChange={(event) => setPublicSiteHtml(event.target.value)}
|
||||
disabled={!publicSiteEnabled}
|
||||
placeholder={t('settings.publicSite.htmlPlaceholder') || ''}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.publicSite.htmlHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
|
||||
<ShieldCheck className="w-4 h-4 text-primary-500" />
|
||||
{t('settings.publicSite.cssLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
className="w-full h-48 font-mono text-sm rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 disabled:text-neutral-500"
|
||||
value={publicSiteCss}
|
||||
onChange={(event) => setPublicSiteCss(event.target.value)}
|
||||
disabled={!publicSiteEnabled}
|
||||
placeholder={t('settings.publicSite.cssPlaceholder') || ''}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.publicSite.cssHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => publicSiteSaveMutation.mutate()}
|
||||
disabled={publicSiteSaveMutation.isPending}
|
||||
isLoading={publicSiteSaveMutation.isPending}
|
||||
>
|
||||
{publicSiteSaveMutation.isPending ? t('settings.publicSite.saving') : t('settings.publicSite.saveCta')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => publicSiteResetMutation.mutate()}
|
||||
disabled={publicSiteResetMutation.isPending}
|
||||
isLoading={publicSiteResetMutation.isPending}
|
||||
>
|
||||
{publicSiteResetMutation.isPending ? t('settings.publicSite.resetting') : t('settings.publicSite.resetCta')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-neutral-50 border border-neutral-200 p-3 text-xs text-neutral-600 leading-relaxed">
|
||||
<p className="font-semibold mb-1">{t('settings.publicSite.sanitizationNotice')}</p>
|
||||
<p>{t('settings.publicSite.htmlHelp')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-neutral-800 uppercase tracking-wide">
|
||||
{t('settings.publicSite.previewTitle')}
|
||||
</h3>
|
||||
<span className="text-xs text-neutral-500">{t('settings.publicSite.previewSandboxed')}</span>
|
||||
</div>
|
||||
{publicSiteEnabled ? (
|
||||
<div className="rounded-xl border border-neutral-200 overflow-hidden shadow-sm bg-white">
|
||||
<iframe
|
||||
title="public-site-preview"
|
||||
sandbox="allow-same-origin"
|
||||
className="w-full h-[480px] bg-white"
|
||||
srcDoc={publicSitePreview}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-neutral-300 bg-neutral-50 p-8 text-center text-sm text-neutral-500">
|
||||
{t('settings.publicSite.previewDisabled')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Page Selection */}
|
||||
<div className="lg:col-span-1">
|
||||
@@ -287,4 +615,4 @@ export const CMSPageEnhanced: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ const EVENT_TYPES = [
|
||||
|
||||
export const CreateEventPageEnhanced: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const isMountedRef = useRef(true);
|
||||
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
||||
@@ -632,4 +632,4 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -29,10 +29,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { archiveService } from '../../services/archive.service';
|
||||
import { externalMediaService } from '../../services/externalMedia.service';
|
||||
import { photosService, AdminPhoto } from '../../services/photos.service';
|
||||
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.service';
|
||||
import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => void }> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -121,8 +120,12 @@ export const EventDetailsPage: React.FC = () => {
|
||||
allow_likes: true,
|
||||
allow_comments: true,
|
||||
allow_favorites: true,
|
||||
require_moderation: true,
|
||||
show_public_stats: false
|
||||
require_name_email: false,
|
||||
moderate_comments: true,
|
||||
show_feedback_to_guests: true,
|
||||
enable_rate_limiting: false,
|
||||
rate_limit_window_minutes: 15,
|
||||
rate_limit_max_requests: 10,
|
||||
});
|
||||
const [copiedLink, setCopiedLink] = useState(false);
|
||||
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
|
||||
@@ -136,10 +139,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||
|
||||
// Photo filters state
|
||||
const [photoFilters, setPhotoFilters] = useState({
|
||||
const [photoFilters, setPhotoFilters] = useState<PhotoFilterParams>({
|
||||
category_id: undefined as number | null | undefined,
|
||||
search: '',
|
||||
sort: 'date' as 'date' | 'name' | 'size' | 'rating',
|
||||
sort: 'date',
|
||||
order: 'desc' as 'asc' | 'desc'
|
||||
});
|
||||
|
||||
@@ -1004,9 +1007,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<PhotoFilters
|
||||
categories={categories}
|
||||
selectedCategory={photoFilters.category_id}
|
||||
searchTerm={photoFilters.search}
|
||||
sortBy={photoFilters.sort}
|
||||
sortOrder={photoFilters.order}
|
||||
searchTerm={photoFilters.search ?? ''}
|
||||
sortBy={photoFilters.sort ?? 'date'}
|
||||
sortOrder={photoFilters.order ?? 'desc'}
|
||||
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
|
||||
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
|
||||
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Download,
|
||||
Shield,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Trash2
|
||||
@@ -25,7 +24,7 @@ import { FeedbackSettings } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import type { PhotoFeedback, FeedbackAnalytics } from '../../services/feedback.service';
|
||||
import type { PhotoFeedback, FeedbackAnalytics, FeedbackResponse } from '../../services/feedback.service';
|
||||
|
||||
export const EventFeedbackPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -44,7 +43,7 @@ export const EventFeedbackPage: React.FC = () => {
|
||||
// Fetch event details
|
||||
const { data: event, isLoading: eventLoading } = useQuery({
|
||||
queryKey: ['event', id],
|
||||
queryFn: () => eventsService.getEvent(id!),
|
||||
queryFn: () => eventsService.getEvent(Number(id)),
|
||||
enabled: !!id
|
||||
});
|
||||
|
||||
@@ -56,14 +55,14 @@ export const EventFeedbackPage: React.FC = () => {
|
||||
});
|
||||
|
||||
// Fetch feedback list
|
||||
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
|
||||
const { data: feedbackData, isLoading: feedbackLoading } = useQuery<FeedbackResponse>({
|
||||
queryKey: ['event-feedback', id, feedbackFilter],
|
||||
queryFn: () => feedbackService.getEventFeedback(id!, feedbackFilter),
|
||||
enabled: !!id && activeTab === 'feedback'
|
||||
});
|
||||
|
||||
// Fetch analytics
|
||||
const { data: analytics, isLoading: analyticsLoading } = useQuery({
|
||||
const { data: analytics, isLoading: analyticsLoading } = useQuery<FeedbackAnalytics>({
|
||||
queryKey: ['feedback-analytics', id],
|
||||
queryFn: () => feedbackService.getEventFeedbackAnalytics(id!),
|
||||
enabled: !!id && activeTab === 'analytics'
|
||||
@@ -133,6 +132,10 @@ export const EventFeedbackPage: React.FC = () => {
|
||||
return <div>{t('events.notFound', 'Event not found')}</div>;
|
||||
}
|
||||
|
||||
const pagination = feedbackData?.pagination;
|
||||
const perPage = pagination?.per_page ?? feedbackFilter.limit ?? 20;
|
||||
const totalPages = perPage ? Math.max(1, Math.ceil((pagination?.total ?? 0) / perPage)) : 1;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
@@ -360,7 +363,7 @@ export const EventFeedbackPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{feedbackData?.pagination && feedbackData.pagination.pages > 1 && (
|
||||
{pagination && totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -373,13 +376,13 @@ export const EventFeedbackPage: React.FC = () => {
|
||||
<span className="flex items-center px-3 text-sm text-neutral-600">
|
||||
{t('common.pageOf', 'Page {{current}} of {{total}}', {
|
||||
current: feedbackFilter.page,
|
||||
total: feedbackData.pagination.pages
|
||||
total: totalPages
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={feedbackFilter.page === feedbackData.pagination.pages}
|
||||
disabled={feedbackFilter.page >= totalPages}
|
||||
onClick={() => setFeedbackFilter({ ...feedbackFilter, page: feedbackFilter.page + 1 })}
|
||||
>
|
||||
{t('common.next', 'Next')}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Download,
|
||||
Trash2,
|
||||
Calendar,
|
||||
Users,
|
||||
Image,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
@@ -562,4 +561,4 @@ export const EventsListPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
EventsListPage.displayName = 'EventsListPage';
|
||||
EventsListPage.displayName = 'EventsListPage';
|
||||
|
||||
@@ -21,6 +21,8 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
|
||||
const queryClient = useQueryClient();
|
||||
@@ -82,6 +84,12 @@ export const SettingsPage: React.FC = () => {
|
||||
umami_share_url: ''
|
||||
});
|
||||
|
||||
const [softLimitGb, setSoftLimitGb] = useState<number | ''>('');
|
||||
const [softLimitDirty, setSoftLimitDirty] = useState(false);
|
||||
const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>('');
|
||||
const [availableOverrideGb, setAvailableOverrideGb] = useState<number | ''>('');
|
||||
const [overrideDirty, setOverrideDirty] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings) {
|
||||
// Set the language if it's different from current
|
||||
@@ -109,14 +117,15 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
// Extract security settings
|
||||
setSecuritySettings({
|
||||
require_password: settings.security_require_password || true,
|
||||
password_min_length: settings.security_password_min_length || 8,
|
||||
enable_2fa: settings.security_enable_2fa || false,
|
||||
session_timeout_minutes: settings.security_session_timeout_minutes || 60,
|
||||
max_login_attempts: settings.security_max_login_attempts || 5,
|
||||
enable_recaptcha: settings.security_enable_recaptcha || false,
|
||||
recaptcha_site_key: settings.security_recaptcha_site_key || '',
|
||||
recaptcha_secret_key: settings.security_recaptcha_secret_key || ''
|
||||
require_password: settings.security_require_password ?? true,
|
||||
password_min_length: settings.security_password_min_length ?? 8,
|
||||
password_complexity: settings.security_password_complexity ?? 'moderate',
|
||||
enable_2fa: settings.security_enable_2fa ?? false,
|
||||
session_timeout_minutes: settings.security_session_timeout_minutes ?? 60,
|
||||
max_login_attempts: settings.security_max_login_attempts ?? 5,
|
||||
enable_recaptcha: settings.security_enable_recaptcha ?? false,
|
||||
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
|
||||
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
|
||||
});
|
||||
|
||||
// Extract analytics settings
|
||||
@@ -129,6 +138,66 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
}, [settings, i18n]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!settings || overrideDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const capacityOverrideBytes = settings.general_storage_capacity_override_bytes ?? null;
|
||||
const availableOverrideBytes = settings.general_storage_available_override_bytes ?? null;
|
||||
|
||||
setCapacityOverrideGb(
|
||||
capacityOverrideBytes != null
|
||||
? Number((capacityOverrideBytes / BYTES_PER_GB).toFixed(2))
|
||||
: ''
|
||||
);
|
||||
|
||||
setAvailableOverrideGb(
|
||||
availableOverrideBytes != null
|
||||
? Number((availableOverrideBytes / BYTES_PER_GB).toFixed(2))
|
||||
: ''
|
||||
);
|
||||
}, [settings, overrideDirty]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!storageInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (softLimitDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLimit = storageInfo.configured_soft_limit ?? storageInfo.storage_soft_limit ?? null;
|
||||
|
||||
if (currentLimit === null || currentLimit === undefined) {
|
||||
setSoftLimitGb('');
|
||||
return;
|
||||
}
|
||||
|
||||
const limitGb = Number((currentLimit / BYTES_PER_GB).toFixed(2));
|
||||
setSoftLimitGb(limitGb);
|
||||
}, [storageInfo, softLimitDirty]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!storageInfo || overrideDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (storageInfo.disk_override_source === 'env') {
|
||||
setCapacityOverrideGb(
|
||||
storageInfo.disk_total
|
||||
? Number((storageInfo.disk_total / BYTES_PER_GB).toFixed(2))
|
||||
: ''
|
||||
);
|
||||
setAvailableOverrideGb(
|
||||
storageInfo.disk_available
|
||||
? Number((storageInfo.disk_available / BYTES_PER_GB).toFixed(2))
|
||||
: ''
|
||||
);
|
||||
}
|
||||
}, [storageInfo, overrideDirty]);
|
||||
|
||||
// Save mutations
|
||||
const saveGeneralMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -189,6 +258,103 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const saveSoftLimitMutation = useMutation({
|
||||
mutationFn: async (limitBytes: number | null) => {
|
||||
return settingsService.updateSettings({
|
||||
general_storage_soft_limit_bytes: limitBytes,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.settingsSaved'));
|
||||
setSoftLimitDirty(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-storage-info'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['storage-info'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
}
|
||||
});
|
||||
|
||||
const handleSaveSoftLimit = () => {
|
||||
if (saveSoftLimitMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (softLimitGb === '') {
|
||||
saveSoftLimitMutation.mutate(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const numericValue = Number(softLimitGb);
|
||||
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
toast.error(t('settings.storage.invalidSoftLimit'));
|
||||
return;
|
||||
}
|
||||
|
||||
const limitBytes = Math.max(0, Math.round(numericValue * BYTES_PER_GB));
|
||||
saveSoftLimitMutation.mutate(limitBytes);
|
||||
};
|
||||
|
||||
const saveCapacityOverrideMutation = useMutation({
|
||||
mutationFn: async (payload: { capacity: number | null; available: number | null }) => {
|
||||
return settingsService.updateSettings({
|
||||
general_storage_capacity_override_bytes: payload.capacity,
|
||||
general_storage_available_override_bytes: payload.available,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.settingsSaved'));
|
||||
setOverrideDirty(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-storage-info'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['storage-info'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
}
|
||||
});
|
||||
|
||||
const handleSaveCapacityOverride = () => {
|
||||
if (saveCapacityOverrideMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (capacityOverrideGb === '' && availableOverrideGb !== '') {
|
||||
toast.error(t('settings.storage.capacityRequiredForAvailable'));
|
||||
return;
|
||||
}
|
||||
|
||||
const capacityValue = capacityOverrideGb === '' ? null : Number(capacityOverrideGb);
|
||||
const availableValue = availableOverrideGb === '' ? null : Number(availableOverrideGb);
|
||||
|
||||
if ((capacityValue !== null && !Number.isFinite(capacityValue)) || (availableValue !== null && !Number.isFinite(availableValue))) {
|
||||
toast.error(t('settings.storage.invalidSoftLimit'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (capacityValue !== null && capacityValue < 0) {
|
||||
toast.error(t('settings.storage.invalidSoftLimit'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (availableValue !== null && availableValue < 0) {
|
||||
toast.error(t('settings.storage.invalidSoftLimit'));
|
||||
return;
|
||||
}
|
||||
|
||||
const capacityBytes = capacityValue === null ? null : Math.max(0, Math.round(capacityValue * BYTES_PER_GB));
|
||||
const availableBytes = availableValue === null ? null : Math.max(0, Math.round(availableValue * BYTES_PER_GB));
|
||||
|
||||
if (capacityBytes !== null && availableBytes !== null && availableBytes > capacityBytes) {
|
||||
toast.error(t('settings.storage.availableExceedsCapacity'));
|
||||
return;
|
||||
}
|
||||
|
||||
saveCapacityOverrideMutation.mutate({ capacity: capacityBytes, available: availableBytes });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -455,52 +621,298 @@ export const SettingsPage: React.FC = () => {
|
||||
{activeTab === 'status' && (
|
||||
<div className="space-y-6">
|
||||
{/* Storage Overview */}
|
||||
{storageInfo && (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<HardDrive className="w-5 h-5" />
|
||||
{t('settings.systemStatus.storageOverview')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<p className="text-sm text-neutral-600">{t('settings.storage.totalUsed')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{settingsService.formatBytes(storageInfo.total_used)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<p className="text-sm text-neutral-600">{t('settings.storage.archiveStorage')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{settingsService.formatBytes(storageInfo.archive_storage)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<p className="text-sm text-neutral-600">{t('settings.storage.storageLimit')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{settingsService.formatBytes(storageInfo.storage_limit)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{storageInfo && (() => {
|
||||
const configuredSoftLimit = storageInfo.configured_soft_limit ?? null;
|
||||
const effectiveSoftLimit = storageInfo.storage_soft_limit || storageInfo.storage_limit || storageInfo.recommended_soft_limit || 1;
|
||||
const safeEffectiveSoftLimit = Math.max(effectiveSoftLimit, 1);
|
||||
const usageRatio = storageInfo.total_used / safeEffectiveSoftLimit;
|
||||
const usagePercentage = Math.round(usageRatio * 100);
|
||||
const usageWidth = Math.min(usageRatio * 100, 100);
|
||||
const overSoftLimit = configuredSoftLimit != null
|
||||
? storageInfo.total_used >= configuredSoftLimit
|
||||
: usagePercentage >= 100;
|
||||
const limitDisplayBytes = configuredSoftLimit ?? storageInfo.storage_soft_limit ?? storageInfo.storage_limit ?? null;
|
||||
const limitDisplay = limitDisplayBytes != null
|
||||
? settingsService.formatBytes(limitDisplayBytes)
|
||||
: t('settings.storage.unlimited');
|
||||
const diskCapacityBytes = storageInfo.disk_total ?? storageInfo.disk_total_raw ?? null;
|
||||
const diskAvailableBytes = storageInfo.disk_available ?? storageInfo.disk_available_raw ?? null;
|
||||
const diskFreeBytes = storageInfo.disk_free ?? storageInfo.disk_free_raw ?? null;
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-neutral-600">{t('settings.storage.storageUsage')}</span>
|
||||
<span className="font-medium">
|
||||
{Math.round((storageInfo.total_used / storageInfo.storage_limit) * 100)}%
|
||||
</span>
|
||||
const diskCapacityDisplay = diskCapacityBytes != null
|
||||
? settingsService.formatBytes(diskCapacityBytes)
|
||||
: null;
|
||||
const diskAvailableDisplay = diskAvailableBytes != null
|
||||
? settingsService.formatBytes(diskAvailableBytes)
|
||||
: null;
|
||||
const diskFreeDisplay = diskFreeBytes != null
|
||||
? settingsService.formatBytes(diskFreeBytes)
|
||||
: null;
|
||||
|
||||
const recommendedDisplay = storageInfo.recommended_soft_limit != null
|
||||
? settingsService.formatBytes(storageInfo.recommended_soft_limit)
|
||||
: null;
|
||||
const progressColor = overSoftLimit
|
||||
? 'bg-red-600'
|
||||
: usagePercentage >= 90
|
||||
? 'bg-amber-500'
|
||||
: 'bg-primary-600';
|
||||
const limitCardClass = overSoftLimit ? 'bg-amber-50 border border-amber-200' : 'bg-neutral-50';
|
||||
const limitValueClass = overSoftLimit ? 'text-amber-700' : 'text-neutral-900';
|
||||
const limitDescriptorClass = overSoftLimit ? 'text-amber-700 font-semibold' : 'text-neutral-600';
|
||||
const recommendedDescriptorValue = (recommendedDisplay ?? limitDisplay);
|
||||
const diskMetricsReliable = storageInfo.disk_metrics_reliable;
|
||||
const overrideSource = storageInfo.disk_override_source;
|
||||
const overrideControlled = overrideSource === 'env';
|
||||
|
||||
const diskSummaryCards: Array<{ label: string; value: string }> = [];
|
||||
if (diskCapacityDisplay && (diskMetricsReliable || overrideSource)) {
|
||||
const label = storageInfo.disk_total != null
|
||||
? t('settings.storage.diskCapacity')
|
||||
: t('settings.storage.diskCapacityReported');
|
||||
diskSummaryCards.push({ label, value: diskCapacityDisplay });
|
||||
}
|
||||
if (diskAvailableDisplay && (diskMetricsReliable || overrideSource)) {
|
||||
const label = storageInfo.disk_available != null
|
||||
? t('settings.storage.diskAvailable')
|
||||
: t('settings.storage.diskAvailableReported');
|
||||
diskSummaryCards.push({ label, value: diskAvailableDisplay });
|
||||
}
|
||||
if (diskFreeDisplay && storageInfo.disk_free == null && (diskMetricsReliable || overrideSource)) {
|
||||
diskSummaryCards.push({
|
||||
label: t('settings.storage.diskFreeReported'),
|
||||
value: diskFreeDisplay
|
||||
});
|
||||
}
|
||||
if (recommendedDisplay) {
|
||||
diskSummaryCards.push({
|
||||
label: t('settings.storage.recommendedSoftLimit'),
|
||||
value: recommendedDisplay
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<HardDrive className="w-5 h-5" />
|
||||
{t('settings.systemStatus.storageOverview')}
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<p className="text-sm text-neutral-600">{t('settings.storage.totalUsed')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{settingsService.formatBytes(storageInfo.total_used)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-neutral-50 rounded-lg p-4">
|
||||
<p className="text-sm text-neutral-600">{t('settings.storage.archiveStorage')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{settingsService.formatBytes(storageInfo.archive_storage)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`rounded-lg p-4 ${limitCardClass}`}>
|
||||
<p className="text-sm text-neutral-600">{t('settings.storage.storageLimit')}</p>
|
||||
<p className={`text-2xl font-bold ${limitValueClass}`}>
|
||||
{limitDisplay}
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${limitDescriptorClass}`}>
|
||||
{storageInfo.soft_limit_configured
|
||||
? t('admin.storageSoftLimitConfigured', { limit: limitDisplay })
|
||||
: t('admin.storageSoftLimitRecommended', { limit: recommendedDescriptorValue })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-primary-600 h-3 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min((storageInfo.total_used / storageInfo.storage_limit) * 100, 100)}%`
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-neutral-600">{t('settings.storage.storageUsage')}</span>
|
||||
<span className={`font-medium ${overSoftLimit ? 'text-red-600' : 'text-neutral-900'}`}>
|
||||
{usagePercentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 rounded-full h-3">
|
||||
<div
|
||||
className={`${progressColor} h-3 rounded-full transition-all`}
|
||||
style={{ width: `${usageWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="border-t border-neutral-200 pt-4 mt-6 space-y-4">
|
||||
<p className="text-sm text-neutral-600">
|
||||
{t('settings.storage.storageLimitHelper')}
|
||||
</p>
|
||||
|
||||
{diskSummaryCards.length > 0 && (diskMetricsReliable || overrideSource) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{diskSummaryCards.map((card) => (
|
||||
<div key={card.label} className="bg-neutral-50 rounded-lg p-4">
|
||||
<p className="text-xs text-neutral-500 uppercase tracking-wide">{card.label}</p>
|
||||
<p className="text-lg font-semibold text-neutral-900 mt-1">{card.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!diskMetricsReliable && !overrideSource && (
|
||||
<p className="text-xs text-neutral-500">
|
||||
{t('settings.storage.diskMetricsUnavailable')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)]">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.1"
|
||||
value={softLimitGb === '' ? '' : softLimitGb}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSoftLimitDirty(true);
|
||||
if (value === '') {
|
||||
setSoftLimitGb('');
|
||||
return;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (Number.isNaN(numeric)) {
|
||||
return;
|
||||
}
|
||||
setSoftLimitGb(numeric);
|
||||
}}
|
||||
label={t('settings.storage.softLimitInputLabel')}
|
||||
helperText={t('settings.storage.softLimitHelper')}
|
||||
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{t('settings.storage.limitNotEnforced')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (storageInfo.recommended_soft_limit != null) {
|
||||
const value = Number((storageInfo.recommended_soft_limit / BYTES_PER_GB).toFixed(2));
|
||||
setSoftLimitGb(value);
|
||||
setSoftLimitDirty(true);
|
||||
}
|
||||
}}
|
||||
disabled={storageInfo.recommended_soft_limit == null}
|
||||
>
|
||||
{t('settings.storage.applyRecommended')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (storageInfo.disk_available != null) {
|
||||
const value = Number((storageInfo.disk_available / BYTES_PER_GB).toFixed(2));
|
||||
setSoftLimitGb(value);
|
||||
setSoftLimitDirty(true);
|
||||
}
|
||||
}}
|
||||
disabled={storageInfo.disk_available == null}
|
||||
>
|
||||
{t('settings.storage.applyAvailable')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSaveSoftLimit}
|
||||
isLoading={saveSoftLimitMutation.isPending}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
{t('settings.storage.saveSoftLimit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-200 pt-4 mt-6 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-700">{t('settings.storage.overrideTitle')}</p>
|
||||
{overrideControlled ? (
|
||||
<p className="text-xs text-neutral-500 mt-1">{t('settings.storage.diskOverrideEnvNote')}</p>
|
||||
) : (
|
||||
<p className="text-xs text-neutral-500 mt-1">{t('settings.storage.diskOverrideSettingsHelp')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.1"
|
||||
value={capacityOverrideGb === '' ? '' : capacityOverrideGb}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setOverrideDirty(true);
|
||||
if (value === '') {
|
||||
setCapacityOverrideGb('');
|
||||
return;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (Number.isNaN(numeric)) {
|
||||
return;
|
||||
}
|
||||
setCapacityOverrideGb(numeric);
|
||||
}}
|
||||
label={t('settings.storage.overrideCapacityLabel')}
|
||||
helperText={t('settings.storage.overrideCapacityHelper')}
|
||||
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
|
||||
disabled={overrideControlled}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={0}
|
||||
step="0.1"
|
||||
value={availableOverrideGb === '' ? '' : availableOverrideGb}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setOverrideDirty(true);
|
||||
if (value === '') {
|
||||
setAvailableOverrideGb('');
|
||||
return;
|
||||
}
|
||||
const numeric = Number(value);
|
||||
if (Number.isNaN(numeric)) {
|
||||
return;
|
||||
}
|
||||
setAvailableOverrideGb(numeric);
|
||||
}}
|
||||
label={t('settings.storage.overrideAvailableLabel')}
|
||||
helperText={t('settings.storage.overrideAvailableHelper')}
|
||||
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
|
||||
disabled={overrideControlled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSaveCapacityOverride}
|
||||
isLoading={saveCapacityOverrideMutation.isPending}
|
||||
disabled={overrideControlled}
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
{t('settings.storage.saveOverride')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* System Information */}
|
||||
{systemStatus && (
|
||||
@@ -968,4 +1380,4 @@ export const SettingsPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ export const PreviewPage: React.FC = () => {
|
||||
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date');
|
||||
|
||||
const mockPhotos = useMemo(() => generateMockPhotos(12), []);
|
||||
const mockEvent = {
|
||||
@@ -77,6 +77,8 @@ export const PreviewPage: React.FC = () => {
|
||||
return a.filename.localeCompare(b.filename);
|
||||
case 'size':
|
||||
return b.size - a.size;
|
||||
case 'rating':
|
||||
return 0;
|
||||
case 'date':
|
||||
default:
|
||||
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
|
||||
@@ -129,7 +131,7 @@ export const PreviewPage: React.FC = () => {
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
sortBy={sortBy}
|
||||
onSortChange={setSortBy}
|
||||
onSortChange={(sort) => setSortBy(sort)}
|
||||
photoCount={filteredPhotos.length}
|
||||
/>
|
||||
|
||||
@@ -140,4 +142,4 @@ export const PreviewPage: React.FC = () => {
|
||||
</div>
|
||||
</GalleryLayout>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface AnalyticsData {
|
||||
event_name: string;
|
||||
slug: string;
|
||||
views: number;
|
||||
downloads?: number;
|
||||
uniqueVisitors?: number;
|
||||
}>;
|
||||
devices: {
|
||||
desktop: number;
|
||||
@@ -129,4 +131,4 @@ export const adminService = {
|
||||
async changePassword(data: { currentPassword: string; newPassword: string }): Promise<void> {
|
||||
await api.post('/admin/auth/change-password', data);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface PhotoFeedback {
|
||||
feedback_type: 'rating' | 'like' | 'comment' | 'favorite';
|
||||
rating?: number;
|
||||
comment_text?: string;
|
||||
comment?: string;
|
||||
guest_name?: string;
|
||||
guest_email?: string;
|
||||
is_approved: boolean;
|
||||
@@ -29,6 +30,7 @@ export interface PhotoFeedback {
|
||||
updated_at?: string;
|
||||
filename?: string;
|
||||
path?: string;
|
||||
photo_filename?: string;
|
||||
is_mine?: boolean;
|
||||
}
|
||||
|
||||
@@ -50,6 +52,11 @@ export interface FeedbackResponse {
|
||||
feedback: PhotoFeedback[];
|
||||
summary: FeedbackSummary;
|
||||
my_feedback: MyFeedback;
|
||||
pagination?: {
|
||||
page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FeedbackAnalytics {
|
||||
@@ -193,4 +200,4 @@ class FeedbackService {
|
||||
}
|
||||
}
|
||||
|
||||
export const feedbackService = new FeedbackService();
|
||||
export const feedbackService = new FeedbackService();
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface PhotoFilters {
|
||||
category_id?: number | null;
|
||||
type?: string;
|
||||
search?: string;
|
||||
sort?: 'date' | 'name' | 'size';
|
||||
sort?: 'date' | 'name' | 'size' | 'rating';
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
|
||||
@@ -119,8 +119,6 @@ class SecureTokenService {
|
||||
* Clear expired tokens from cache
|
||||
*/
|
||||
clearExpiredTokens(): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [key, entry] of this.tokenCache.entries()) {
|
||||
if (!this.isTokenValid(entry)) {
|
||||
this.tokenCache.delete(key);
|
||||
|
||||
@@ -53,6 +53,18 @@ export interface StorageInfo {
|
||||
size: number;
|
||||
}>;
|
||||
storage_limit: number;
|
||||
storage_soft_limit: number;
|
||||
configured_soft_limit: number | null;
|
||||
recommended_soft_limit: number | null;
|
||||
soft_limit_configured: boolean;
|
||||
disk_total: number | null;
|
||||
disk_free: number | null;
|
||||
disk_available: number | null;
|
||||
disk_total_raw: number | null;
|
||||
disk_free_raw: number | null;
|
||||
disk_available_raw: number | null;
|
||||
disk_metrics_reliable: boolean;
|
||||
disk_override_source: 'env' | 'settings' | null;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
@@ -102,6 +114,20 @@ export interface SystemStatus {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface PublicSiteBranding {
|
||||
companyName: string | null;
|
||||
companyTagline: string | null;
|
||||
supportEmail: string | null;
|
||||
logoUrl: string | null;
|
||||
footerText: string | null;
|
||||
colors: {
|
||||
primary: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const settingsService = {
|
||||
// Get all settings
|
||||
async getAllSettings(): Promise<Record<string, any>> {
|
||||
@@ -120,6 +146,28 @@ export const settingsService = {
|
||||
await api.put('/admin/settings/branding', settings);
|
||||
},
|
||||
|
||||
async getPublicSiteDefaults(): Promise<{ html: string; css: string; baseCss: string; branding?: PublicSiteBranding; meta?: { title?: string } }> {
|
||||
const response = await api.get('/admin/settings/public-site/default');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async resetPublicSite(): Promise<{ html: string; css: string; baseCss?: string; branding?: PublicSiteBranding }> {
|
||||
const response = await api.post('/admin/settings/public-site/reset');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updatePublicSite(settings: {
|
||||
enabled: boolean;
|
||||
html: string;
|
||||
css: string;
|
||||
}): Promise<void> {
|
||||
await api.put('/admin/settings/general', {
|
||||
general_public_site_enabled: settings.enabled,
|
||||
general_public_site_html: settings.html,
|
||||
general_public_site_custom_css: settings.css,
|
||||
});
|
||||
},
|
||||
|
||||
// Upload logo
|
||||
async uploadLogo(file: File): Promise<string> {
|
||||
const formData = new FormData();
|
||||
@@ -184,7 +232,7 @@ export const settingsService = {
|
||||
// Determine the endpoint based on setting keys
|
||||
const firstKey = Object.keys(settings)[0];
|
||||
let endpoint = '/admin/settings/general';
|
||||
|
||||
|
||||
if (firstKey?.startsWith('security_')) {
|
||||
endpoint = '/admin/settings/security';
|
||||
} else if (firstKey?.startsWith('analytics_')) {
|
||||
@@ -250,4 +298,4 @@ export const settingsService = {
|
||||
const response = await api.get<PasswordComplexitySettings>('/admin/settings/password/complexity');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface Event {
|
||||
total_views?: number;
|
||||
total_downloads?: number;
|
||||
unique_visitors?: number;
|
||||
source_mode?: 'managed' | 'reference' | string;
|
||||
external_path?: string | null;
|
||||
}
|
||||
|
||||
export interface GalleryInfo {
|
||||
@@ -147,4 +149,4 @@ export interface ApiError {
|
||||
}
|
||||
|
||||
// Export protection types
|
||||
export * from './protection';
|
||||
export * from './protection';
|
||||
|
||||
Reference in New Issue
Block a user