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
@@ -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" />