feat: overhaul public landing page and backup tooling

This commit is contained in:
2025-09-19 16:07:43 +02:00
parent ad9c6d63d3
commit 2a4d38813f
72 changed files with 4332 additions and 1466 deletions
@@ -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>
);
};
};
+14 -5
View File
@@ -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>;
+3
View File
@@ -0,0 +1,3 @@
import type { ComponentType } from 'react';
export const BackupDashboard: ComponentType<any>;
+3
View File
@@ -0,0 +1,3 @@
import type { ComponentType } from 'react';
export const BackupHistory: ComponentType<any>;
+7 -6
View File
@@ -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';
+3
View File
@@ -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,
+12 -9
View File
@@ -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" />