Merge pull request #164 from the-luap/feat/new-features
feat: gallery layouts, hero customization, bulk categories & event types
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, X, Loader2 } from 'lucide-react';
|
||||
import { Plus, X, Loader2, Image as ImageIcon, Check } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { Button } from '../common';
|
||||
import { photosService, type AdminPhoto } from '../../services/photos.service';
|
||||
import { Button, Card, AuthenticatedImage } from '../common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface EventCategoryManagerProps {
|
||||
@@ -15,6 +16,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
const { t } = useTranslation();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [heroPickerCategoryId, setHeroPickerCategoryId] = useState<number | null>(null);
|
||||
|
||||
// Fetch categories for this event
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
@@ -22,16 +24,23 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
});
|
||||
|
||||
// Fetch photos for hero selection
|
||||
const { data: photos = [] } = useQuery({
|
||||
queryKey: ['admin-event-photos', eventId, {}],
|
||||
queryFn: () => photosService.getEventPhotos(eventId, {}),
|
||||
enabled: heroPickerCategoryId !== null,
|
||||
});
|
||||
|
||||
// Filter to show only event-specific categories
|
||||
const eventCategories = categories.filter(cat => !cat.is_global);
|
||||
|
||||
// Create category mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
categoriesService.createCategory({
|
||||
name,
|
||||
mutationFn: (name: string) =>
|
||||
categoriesService.createCategory({
|
||||
name,
|
||||
is_global: false,
|
||||
event_id: eventId
|
||||
event_id: eventId
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
|
||||
@@ -56,6 +65,20 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
},
|
||||
});
|
||||
|
||||
// Set hero photo mutation
|
||||
const heroMutation = useMutation({
|
||||
mutationFn: ({ categoryId, photoId }: { categoryId: number; photoId: number | null }) =>
|
||||
categoriesService.setCategoryHeroPhoto(categoryId, photoId),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
|
||||
setHeroPickerCategoryId(null);
|
||||
toast.success(variables.photoId ? t('categories.coverPhotoSet') : t('categories.coverPhotoRemoved'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || t('categories.failedToSetCoverPhoto'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
if (newCategoryName.trim()) {
|
||||
createMutation.mutate(newCategoryName.trim());
|
||||
@@ -68,6 +91,14 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHeroPhoto = (categoryId: number, photoId: number) => {
|
||||
heroMutation.mutate({ categoryId, photoId });
|
||||
};
|
||||
|
||||
const handleRemoveHeroPhoto = (categoryId: number) => {
|
||||
heroMutation.mutate({ categoryId, photoId: null });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-4">
|
||||
@@ -135,45 +166,172 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
{t('categories.noEventSpecificCategories')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{eventCategories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center justify-between px-3 py-2 bg-neutral-50 rounded-md"
|
||||
>
|
||||
<span className="text-sm text-neutral-700">{category.name}</span>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
|
||||
title={t('categories.deleteCategoryTitle')}
|
||||
disabled={deleteMutation.isPending}
|
||||
<div className="space-y-2">
|
||||
{eventCategories.map((category) => {
|
||||
const heroPhoto = category.hero_photo_id
|
||||
? photos.find(p => p.id === category.hero_photo_id)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center justify-between px-3 py-2 bg-neutral-50 rounded-md"
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{/* Hero photo thumbnail */}
|
||||
<button
|
||||
onClick={() => setHeroPickerCategoryId(category.id)}
|
||||
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 overflow-hidden bg-neutral-100 hover:border-primary-400 transition-colors flex items-center justify-center"
|
||||
title={t('categories.setCoverPhoto')}
|
||||
>
|
||||
{heroPhoto ? (
|
||||
<AuthenticatedImage
|
||||
src={heroPhoto.thumbnail_url || heroPhoto.url}
|
||||
alt={category.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : category.hero_photo_id ? (
|
||||
<ImageIcon className="w-4 h-4 text-primary-400" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 text-neutral-300" />
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-neutral-700 truncate">{category.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 hover:text-red-600 transition-colors"
|
||||
title={t('categories.deleteCategoryTitle')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show available global categories */}
|
||||
<div className="mt-4 pt-3 border-t border-neutral-200">
|
||||
<p className="text-xs font-medium text-neutral-500 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="space-y-2">
|
||||
{categories
|
||||
.filter(cat => cat.is_global)
|
||||
.map(cat => (
|
||||
<span key={cat.id} className="px-2 py-1 text-xs bg-neutral-100 text-neutral-600 rounded">
|
||||
{cat.name}
|
||||
</span>
|
||||
))}
|
||||
.map(cat => {
|
||||
const heroPhoto = cat.hero_photo_id
|
||||
? photos.find(p => p.id === cat.hero_photo_id)
|
||||
: null;
|
||||
return (
|
||||
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 rounded-md">
|
||||
<button
|
||||
onClick={() => setHeroPickerCategoryId(cat.id)}
|
||||
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 overflow-hidden bg-neutral-100 hover:border-primary-400 transition-colors flex items-center justify-center"
|
||||
title={t('categories.setCoverPhoto')}
|
||||
>
|
||||
{heroPhoto ? (
|
||||
<AuthenticatedImage
|
||||
src={heroPhoto.thumbnail_url || heroPhoto.url}
|
||||
alt={cat.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : cat.hero_photo_id ? (
|
||||
<ImageIcon className="w-4 h-4 text-primary-400" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 text-neutral-300" />
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-neutral-600">{cat.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero Photo Picker Modal */}
|
||||
{heroPickerCategoryId !== null && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
|
||||
<div className="p-6 border-b border-neutral-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{t('categories.setCoverPhoto')}</h2>
|
||||
<button
|
||||
onClick={() => setHeroPickerCategoryId(null)}
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
|
||||
{photos.length === 0 ? (
|
||||
<p className="text-center text-neutral-500 py-8">
|
||||
{t('events.noPhotosAvailable')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{photos.map((photo) => {
|
||||
const currentCategory = categories.find(c => c.id === heroPickerCategoryId);
|
||||
const isSelected = photo.id === currentCategory?.hero_photo_id;
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
onClick={() => handleSelectHeroPhoto(heroPickerCategoryId, photo.id)}
|
||||
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-primary-500 ring-2 ring-primary-500 ring-offset-2'
|
||||
: 'border-transparent hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-square bg-neutral-100">
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
|
||||
<p className="text-white text-xs truncate">{photo.filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-neutral-200 flex justify-between gap-3">
|
||||
{categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleRemoveHeroPhoto(heroPickerCategoryId)}
|
||||
disabled={heroMutation.isPending}
|
||||
>
|
||||
{t('categories.removeCoverPhoto')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setHeroPickerCategoryId(null)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EventCategoryManager.displayName = 'EventCategoryManager';
|
||||
EventCategoryManager.displayName = 'EventCategoryManager';
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuthenticatedImage, Button } from '../common';
|
||||
|
||||
interface FocalPointPickerProps {
|
||||
imageUrl: string;
|
||||
currentValue: string;
|
||||
onChange: (value: string) => void;
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
/** Convert legacy keyword to percentage pair */
|
||||
const keywordToPercent = (value: string): string => {
|
||||
switch (value) {
|
||||
case 'top': return '50% 0%';
|
||||
case 'center': return '50% 50%';
|
||||
case 'bottom': return '50% 100%';
|
||||
default: return value || '50% 50%';
|
||||
}
|
||||
};
|
||||
|
||||
/** Parse an anchor value (keyword or "X% Y%") into [x, y] numbers 0-100 */
|
||||
const parseAnchor = (value: string): [number, number] => {
|
||||
const pct = keywordToPercent(value);
|
||||
const match = pct.match(/^(\d{1,3})%\s+(\d{1,3})%$/);
|
||||
if (match) return [parseInt(match[1]), parseInt(match[2])];
|
||||
return [50, 50];
|
||||
};
|
||||
|
||||
export const FocalPointPicker: React.FC<FocalPointPickerProps> = ({
|
||||
imageUrl,
|
||||
currentValue,
|
||||
onChange,
|
||||
slug,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [x, y] = parseAnchor(currentValue);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const px = Math.round(Math.min(100, Math.max(0, ((e.clientX - rect.left) / rect.width) * 100)));
|
||||
const py = Math.round(Math.min(100, Math.max(0, ((e.clientY - rect.top) / rect.height) * 100)));
|
||||
onChange(`${px}% ${py}%`);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const presets: { label: string; value: string }[] = [
|
||||
{ label: t('events.heroImageAnchorTop', 'Top'), value: '50% 0%' },
|
||||
{ label: t('events.heroImageAnchorCenter', 'Center'), value: '50% 50%' },
|
||||
{ label: t('events.heroImageAnchorBottom', 'Bottom'), value: '50% 100%' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Clickable image preview */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={handleClick}
|
||||
className="relative w-full h-48 rounded-lg overflow-hidden cursor-crosshair border border-neutral-300"
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={imageUrl}
|
||||
alt={t('events.heroPreview', 'Hero preview')}
|
||||
className="w-full h-full object-cover pointer-events-none"
|
||||
style={{ objectPosition: `${x}% ${y}%` }}
|
||||
slug={slug}
|
||||
/>
|
||||
|
||||
{/* Crosshair marker */}
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={{ left: `${x}%`, top: `${y}%`, transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
{/* Outer ring (dark) for contrast on light areas */}
|
||||
<div className="w-6 h-6 rounded-full border-2 border-black/50" />
|
||||
{/* Inner ring (white) for contrast on dark areas */}
|
||||
<div className="absolute inset-0 m-px w-6 h-6 rounded-full border-2 border-white" />
|
||||
{/* Center dot */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white shadow-sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coordinate label */}
|
||||
<span className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 text-[10px] font-mono leading-none text-white bg-black/60 rounded">
|
||||
{x}% {y}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Preset buttons */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
{presets.map((p) => (
|
||||
<Button
|
||||
key={p.value}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onChange(p.value)}
|
||||
className={
|
||||
keywordToPercent(currentValue) === p.value
|
||||
? 'bg-primary-50 border-primary-300 text-primary-700'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
FocalPointPicker.displayName = 'FocalPointPicker';
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Camera } from 'lucide-react';
|
||||
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
|
||||
import { Camera, Calendar } from 'lucide-react';
|
||||
import { ThemeConfig, GalleryLayoutType, HeroDividerStyle } from '../../types/theme.types';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
interface GalleryPreviewBranding {
|
||||
@@ -92,6 +92,39 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
? 'justify-end text-right flex-row-reverse'
|
||||
: 'justify-start text-left';
|
||||
|
||||
// Check if hero header style is selected
|
||||
const isHeroHeader = theme.headerStyle === 'hero';
|
||||
const heroDividerStyle: HeroDividerStyle = theme.heroDividerStyle || 'wave';
|
||||
|
||||
// Render hero divider based on style
|
||||
const renderHeroDivider = () => {
|
||||
const bgColor = theme.backgroundColor || '#fafafa';
|
||||
switch (heroDividerStyle) {
|
||||
case 'wave':
|
||||
return (
|
||||
<svg className="w-full h-6" viewBox="0 0 1200 120" preserveAspectRatio="none">
|
||||
<path d="M0,60 C150,90 350,30 600,60 C850,90 1050,30 1200,60 L1200,120 L0,120 Z" fill={bgColor} />
|
||||
</svg>
|
||||
);
|
||||
case 'curve':
|
||||
return (
|
||||
<svg className="w-full h-6" viewBox="0 0 1200 120" preserveAspectRatio="none">
|
||||
<path d="M0,120 Q600,0 1200,120 L1200,120 L0,120 Z" fill={bgColor} />
|
||||
</svg>
|
||||
);
|
||||
case 'angle':
|
||||
return (
|
||||
<svg className="w-full h-6" viewBox="0 0 1200 120" preserveAspectRatio="none">
|
||||
<path d="M0,120 L600,40 L1200,120 L1200,120 L0,120 Z" fill={bgColor} />
|
||||
</svg>
|
||||
);
|
||||
case 'straight':
|
||||
case 'none':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderLayout = () => {
|
||||
const spacing = theme.gallerySettings?.spacing || 'normal';
|
||||
const gapClass = spacing === 'tight' ? 'gap-1' : spacing === 'relaxed' ? 'gap-4' : 'gap-2';
|
||||
@@ -169,7 +202,7 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
className={`bg-white rounded-lg shadow-sm overflow-hidden ${className}`}
|
||||
style={{
|
||||
backgroundColor: theme.backgroundColor || '#ffffff',
|
||||
@@ -177,45 +210,101 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
fontFamily: theme.fontFamily || 'Inter, sans-serif',
|
||||
}}
|
||||
>
|
||||
{/* Preview Header */}
|
||||
<div
|
||||
className="px-4 py-3 border-b space-y-2"
|
||||
style={{
|
||||
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
|
||||
}}
|
||||
>
|
||||
<div className={`flex items-center gap-3 ${brandFlexClass}`}>
|
||||
{showLogo && (
|
||||
resolvedLogoUrl ? (
|
||||
<img
|
||||
src={resolvedLogoUrl}
|
||||
alt={brandName}
|
||||
className="h-8 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center">
|
||||
<Camera className="w-4 h-4 text-neutral-500" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{showText && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">{brandName}</p>
|
||||
{brandTagline && (
|
||||
<p className="text-xs text-neutral-500 leading-tight">{brandTagline}</p>
|
||||
{/* Hero Header - shown when headerStyle is 'hero' */}
|
||||
{isHeroHeader && (
|
||||
<div
|
||||
className="relative text-white overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: theme.accentColor || theme.primaryColor || '#22c55e',
|
||||
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'40\' height=\'40\' viewBox=\'0 0 40 40\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'%23ffffff\' fill-opacity=\'0.03\'%3E%3Cpath d=\'M0 40L40 0H20L0 20M40 40V20L20 40\'/%3E%3C/g%3E%3C/svg%3E")',
|
||||
}}
|
||||
>
|
||||
<div className="py-8 px-4 relative z-10">
|
||||
<div className="text-center max-w-md mx-auto">
|
||||
{/* Logo in Hero */}
|
||||
{showLogo && (
|
||||
<div className="mb-3">
|
||||
{resolvedLogoUrl ? (
|
||||
<img
|
||||
src={resolvedLogoUrl}
|
||||
alt={brandName}
|
||||
className="h-10 w-auto object-contain mx-auto"
|
||||
style={{ filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-full bg-white/20 flex items-center justify-center mx-auto">
|
||||
<Camera className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Event Name */}
|
||||
<h1
|
||||
className="text-xl font-bold mb-2"
|
||||
style={{
|
||||
fontFamily: theme.headingFontFamily || theme.fontFamily || 'Inter, sans-serif',
|
||||
textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)'
|
||||
}}
|
||||
>
|
||||
Sample Event
|
||||
</h1>
|
||||
{/* Event Date */}
|
||||
<div className="flex items-center justify-center text-white/80 text-sm" style={{ textShadow: '0 1px 3px rgba(0, 0, 0, 0.3)' }}>
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
<span>January 15, 2026</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!showLogo && !showText && (
|
||||
<p className="text-sm font-semibold">{brandName}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Divider */}
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
{renderHeroDivider()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 flex justify-between">
|
||||
<span>Gallery preview</span>
|
||||
<span className="capitalize">{activeLayout} layout</span>
|
||||
)}
|
||||
|
||||
{/* Standard Header - shown when headerStyle is NOT 'hero' */}
|
||||
{!isHeroHeader && (
|
||||
<div
|
||||
className="px-4 py-3 border-b space-y-2"
|
||||
style={{
|
||||
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
|
||||
}}
|
||||
>
|
||||
<div className={`flex items-center gap-3 ${brandFlexClass}`}>
|
||||
{showLogo && (
|
||||
resolvedLogoUrl ? (
|
||||
<img
|
||||
src={resolvedLogoUrl}
|
||||
alt={brandName}
|
||||
className="h-8 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center">
|
||||
<Camera className="w-4 h-4 text-neutral-500" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{showText && (
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">{brandName}</p>
|
||||
{brandTagline && (
|
||||
<p className="text-xs text-neutral-500 leading-tight">{brandTagline}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!showLogo && !showText && (
|
||||
<p className="text-sm font-semibold">{brandName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Layout info bar */}
|
||||
<div className="px-4 py-1 border-b text-xs text-neutral-500 flex justify-between" style={{ borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb' }}>
|
||||
<span>Gallery preview</span>
|
||||
<span className="capitalize">{isHeroHeader ? `Hero + ${activeLayout}` : `${activeLayout} layout`}</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Preview Content */}
|
||||
<div className="p-4" style={{ maxHeight: '400px', overflowY: 'auto' }}>
|
||||
{renderLayout()}
|
||||
|
||||
@@ -96,12 +96,30 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
// For large uploads, chunk the files to prevent memory issues
|
||||
const CHUNK_SIZE = Math.max(1, Math.min(50, maxFilesPerUpload)); // Upload up to 50 (or limit) files at a time
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
|
||||
chunks.push(selectedFiles.slice(i, i + CHUNK_SIZE));
|
||||
// For large uploads, chunk the files by both count AND size to prevent memory/network issues
|
||||
const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk
|
||||
const MAX_BYTES_PER_CHUNK = 500 * 1024 * 1024; // Max 500MB per chunk (nginx limit is 1GB)
|
||||
const chunks: File[][] = [];
|
||||
|
||||
let currentChunk: File[] = [];
|
||||
let currentChunkSize = 0;
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
// Start a new chunk if adding this file would exceed limits
|
||||
if (currentChunk.length >= MAX_FILES_PER_CHUNK ||
|
||||
(currentChunkSize + file.size > MAX_BYTES_PER_CHUNK && currentChunk.length > 0)) {
|
||||
chunks.push(currentChunk);
|
||||
currentChunk = [];
|
||||
currentChunkSize = 0;
|
||||
}
|
||||
|
||||
currentChunk.push(file);
|
||||
currentChunkSize += file.size;
|
||||
}
|
||||
|
||||
// Don't forget the last chunk
|
||||
if (currentChunk.length > 0) {
|
||||
chunks.push(currentChunk);
|
||||
}
|
||||
|
||||
setTotalChunks(chunks.length);
|
||||
|
||||
@@ -22,6 +22,7 @@ export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||
export { ThemeDisplay } from './ThemeDisplay';
|
||||
export { ThemeEditorModal } from './ThemeEditorModal';
|
||||
export { HeroPhotoSelector } from './HeroPhotoSelector';
|
||||
export { FocalPointPicker } from './FocalPointPicker';
|
||||
export { PhotoUploadModal } from './PhotoUploadModal';
|
||||
export { GalleryPreview } from './GalleryPreview';
|
||||
export { BackupDashboard } from './BackupDashboard';
|
||||
|
||||
@@ -1,51 +1,62 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import { ProtectedImage } from '../ProtectedImage';
|
||||
|
||||
// Mock canvas and image APIs
|
||||
const mockCanvas = {
|
||||
getContext: jest.fn(() => ({
|
||||
clearRect: jest.fn(),
|
||||
drawImage: jest.fn(),
|
||||
getImageData: jest.fn(() => ({
|
||||
data: new Uint8ClampedArray(4).fill(255)
|
||||
})),
|
||||
putImageData: jest.fn(),
|
||||
fillRect: jest.fn(),
|
||||
fillText: jest.fn(),
|
||||
strokeText: jest.fn(),
|
||||
measureText: jest.fn(() => ({ width: 100 }))
|
||||
// Create a stable mock context (same reference for all getContext calls)
|
||||
const mockContext = {
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => ({
|
||||
data: new Uint8ClampedArray(400).fill(255)
|
||||
})),
|
||||
width: 100,
|
||||
height: 100,
|
||||
style: {},
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn()
|
||||
putImageData: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
strokeText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 100 })),
|
||||
globalAlpha: 1.0,
|
||||
globalCompositeOperation: 'source-over',
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
textAlign: 'center',
|
||||
textBaseline: 'middle',
|
||||
shadowColor: 'transparent',
|
||||
shadowBlur: 0,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
};
|
||||
|
||||
// Mock HTMLCanvasElement
|
||||
// Mock HTMLCanvasElement.getContext to always return our stable context
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||
value: () => mockCanvas.getContext()
|
||||
value: () => mockContext,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock Image constructor
|
||||
global.Image = class {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
src = '';
|
||||
naturalWidth = 100;
|
||||
naturalHeight = 100;
|
||||
width = 100;
|
||||
height = 100;
|
||||
crossOrigin = '';
|
||||
// Default Image mock that simulates successful loading
|
||||
const createSuccessImage = () => {
|
||||
return class {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
src = '';
|
||||
naturalWidth = 100;
|
||||
naturalHeight = 100;
|
||||
width = 100;
|
||||
height = 100;
|
||||
crossOrigin = '';
|
||||
complete = true;
|
||||
|
||||
constructor() {
|
||||
// Simulate image loading
|
||||
setTimeout(() => {
|
||||
if (this.onload) this.onload();
|
||||
}, 10);
|
||||
}
|
||||
} as any;
|
||||
constructor() {
|
||||
setTimeout(() => {
|
||||
if (this.onload) this.onload();
|
||||
}, 10);
|
||||
}
|
||||
} as unknown as typeof Image;
|
||||
};
|
||||
|
||||
global.Image = createSuccessImage();
|
||||
|
||||
describe('ProtectedImage', () => {
|
||||
const defaultProps = {
|
||||
@@ -54,28 +65,34 @@ describe('ProtectedImage', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
// Reset Image mock to success variant
|
||||
global.Image = createSuccessImage();
|
||||
});
|
||||
|
||||
it('renders loading state initially', () => {
|
||||
it('renders canvas with loading styles initially', () => {
|
||||
render(<ProtectedImage {...defaultProps} />);
|
||||
expect(screen.getByRole('img', { name: /loading test image/i })).toBeInTheDocument();
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toBeInTheDocument();
|
||||
// While loading, canvas has opacity 0
|
||||
expect(canvas).toHaveStyle({ opacity: '0' });
|
||||
});
|
||||
|
||||
it('renders canvas after image loads', async () => {
|
||||
render(<ProtectedImage {...defaultProps} />);
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toHaveStyle({ opacity: '1' });
|
||||
});
|
||||
});
|
||||
|
||||
it('applies protection level classes and events', async () => {
|
||||
const onViolation = jest.fn();
|
||||
|
||||
const onViolation = vi.fn();
|
||||
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
protectionLevel="enhanced"
|
||||
onProtectionViolation={onViolation}
|
||||
/>
|
||||
@@ -89,31 +106,32 @@ describe('ProtectedImage', () => {
|
||||
// Test context menu blocking
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
fireEvent.contextMenu(canvas);
|
||||
|
||||
|
||||
expect(onViolation).toHaveBeenCalledWith('canvas_context_menu');
|
||||
});
|
||||
|
||||
it('applies watermark text when specified', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
watermarkText="Test Watermark"
|
||||
protectionLevel="standard"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toHaveStyle({ opacity: '1' });
|
||||
});
|
||||
|
||||
// Verify canvas context methods were called for watermark
|
||||
expect(mockCanvas.getContext().fillText).toHaveBeenCalled();
|
||||
expect(mockContext.fillText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles fragment grid rendering', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
fragmentGrid={true}
|
||||
gridSize={4}
|
||||
protectionLevel="enhanced"
|
||||
@@ -121,19 +139,20 @@ describe('ProtectedImage', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toHaveStyle({ opacity: '1' });
|
||||
});
|
||||
|
||||
// Verify multiple drawImage calls for fragments
|
||||
expect(mockCanvas.getContext().drawImage).toHaveBeenCalled();
|
||||
expect(mockContext.drawImage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks interactions in maximum protection mode', async () => {
|
||||
const onViolation = jest.fn();
|
||||
|
||||
const onViolation = vi.fn();
|
||||
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
protectionLevel="maximum"
|
||||
onProtectionViolation={onViolation}
|
||||
/>
|
||||
@@ -142,7 +161,7 @@ describe('ProtectedImage', () => {
|
||||
await waitFor(() => {
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toBeInTheDocument();
|
||||
|
||||
|
||||
// Test click blocking
|
||||
fireEvent.click(canvas);
|
||||
expect(onViolation).toHaveBeenCalledWith('canvas_interaction_blocked');
|
||||
@@ -150,26 +169,37 @@ describe('ProtectedImage', () => {
|
||||
});
|
||||
|
||||
it('handles image loading errors gracefully', async () => {
|
||||
// Mock image error
|
||||
// Track how many times src is set to detect fallback attempts
|
||||
let loadAttempt = 0;
|
||||
|
||||
global.Image = class {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
src = '';
|
||||
private _src = '';
|
||||
naturalWidth = 0;
|
||||
naturalHeight = 0;
|
||||
width = 0;
|
||||
height = 0;
|
||||
crossOrigin = '';
|
||||
complete = false;
|
||||
|
||||
constructor() {
|
||||
get src() { return this._src; }
|
||||
set src(value: string) {
|
||||
this._src = value;
|
||||
loadAttempt++;
|
||||
setTimeout(() => {
|
||||
if (this.onerror) this.onerror();
|
||||
}, 10);
|
||||
}
|
||||
} as any;
|
||||
} as unknown as typeof Image;
|
||||
|
||||
const onViolation = jest.fn();
|
||||
|
||||
const onViolation = vi.fn();
|
||||
|
||||
// Render WITHOUT fallbackSrc so error state is reached immediately
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
onProtectionViolation={onViolation}
|
||||
fallbackSrc="/fallback.jpg"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -182,8 +212,8 @@ describe('ProtectedImage', () => {
|
||||
|
||||
it('applies invisible watermark for enhanced protection', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
watermarkText="Hidden"
|
||||
invisibleWatermark={true}
|
||||
protectionLevel="enhanced"
|
||||
@@ -191,18 +221,19 @@ describe('ProtectedImage', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toHaveStyle({ opacity: '1' });
|
||||
});
|
||||
|
||||
// Verify getImageData and putImageData called for steganography
|
||||
expect(mockCanvas.getContext().getImageData).toHaveBeenCalled();
|
||||
expect(mockCanvas.getContext().putImageData).toHaveBeenCalled();
|
||||
expect(mockContext.getImageData).toHaveBeenCalled();
|
||||
expect(mockContext.putImageData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrambles fragments when enabled', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
fragmentGrid={true}
|
||||
scrambleFragments={true}
|
||||
protectionLevel="maximum"
|
||||
@@ -210,27 +241,29 @@ describe('ProtectedImage', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toHaveStyle({ opacity: '1' });
|
||||
});
|
||||
|
||||
// Fragment scrambling should result in multiple drawImage calls
|
||||
expect(mockCanvas.getContext().drawImage).toHaveBeenCalled();
|
||||
expect(mockContext.drawImage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds random noise in maximum protection', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
protectionLevel="maximum"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toHaveStyle({ opacity: '1' });
|
||||
});
|
||||
|
||||
// Noise injection requires getImageData and putImageData
|
||||
expect(mockCanvas.getContext().getImageData).toHaveBeenCalled();
|
||||
expect(mockCanvas.getContext().putImageData).toHaveBeenCalled();
|
||||
expect(mockContext.getImageData).toHaveBeenCalled();
|
||||
expect(mockContext.putImageData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ interface GalleryLayoutProps {
|
||||
isDownloading?: boolean;
|
||||
headerExtra?: React.ReactNode;
|
||||
menuButton?: React.ReactNode;
|
||||
headerStyle?: HeaderStyleType;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -52,14 +53,15 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
isDownloading = false,
|
||||
headerExtra,
|
||||
menuButton,
|
||||
headerStyle: headerStyleProp,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { theme } = useTheme();
|
||||
|
||||
// Determine header style - check theme.headerStyle first, then fall back to legacy behavior
|
||||
const headerStyle: HeaderStyleType = theme.headerStyle || 'standard';
|
||||
// Determine header style - use prop first (from event data), then theme, then fall back to 'standard'
|
||||
const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard';
|
||||
const isHeroHeader = headerStyle === 'hero';
|
||||
|
||||
// Non-grid layouts that need the sidebar (excluding layouts using hero header)
|
||||
|
||||
@@ -584,6 +584,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
<GalleryLayout
|
||||
event={event}
|
||||
brandingSettings={brandingSettings}
|
||||
headerStyle={data?.event?.header_style || theme.headerStyle}
|
||||
showLogout={true}
|
||||
onLogout={logout}
|
||||
showDownloadAll={!showSidebar && allowDownloads}
|
||||
@@ -696,6 +697,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
heroLogoPosition={data?.event?.hero_logo_position || 'top'}
|
||||
headerStyle={data?.event?.header_style || theme.headerStyle}
|
||||
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
||||
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ interface HeroHeaderProps {
|
||||
useEnhancedProtection?: boolean;
|
||||
useCanvasRendering?: boolean;
|
||||
onScrollToContent?: () => void;
|
||||
// Hero image anchor position (#162) – keyword or "X% Y%" focal point
|
||||
heroImageAnchor?: string;
|
||||
}
|
||||
|
||||
export const HeroHeader: React.FC<HeroHeaderProps> = ({
|
||||
@@ -45,7 +47,8 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
onScrollToContent
|
||||
onScrollToContent,
|
||||
heroImageAnchor = 'center'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
@@ -138,6 +141,7 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
|
||||
fallbackSrc={heroPhoto.thumbnail_url || undefined}
|
||||
alt={heroPhoto.filename}
|
||||
className="w-full h-full object-cover"
|
||||
style={{ objectPosition: heroImageAnchor }}
|
||||
isGallery={true}
|
||||
slug={slug}
|
||||
photoId={heroPhoto.id}
|
||||
|
||||
@@ -60,6 +60,8 @@ interface PhotoGridWithLayoutsProps {
|
||||
// Header style (decoupled from layout)
|
||||
headerStyle?: HeaderStyleType;
|
||||
heroDividerStyle?: HeroDividerStyle;
|
||||
// Hero image anchor position (#162) – keyword or "X% Y%" focal point
|
||||
heroImageAnchor?: string;
|
||||
}
|
||||
|
||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
@@ -89,7 +91,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
heroLogoSize = 'medium',
|
||||
heroLogoPosition = 'top',
|
||||
headerStyle,
|
||||
heroDividerStyle = 'wave'
|
||||
heroDividerStyle = 'wave',
|
||||
heroImageAnchor = 'center'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
@@ -108,7 +111,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
// Clear selection when category changes
|
||||
useEffect(() => {
|
||||
setSelectedPhotos(new Set());
|
||||
}, [categoryId]);
|
||||
}, [categoryId, setSelectedPhotos]);
|
||||
|
||||
const handlePhotoClick = (index: number) => {
|
||||
setOpenFeedbackInitially(false);
|
||||
@@ -168,7 +171,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
try {
|
||||
await galleryService.downloadSelectedPhotos(slug, ids);
|
||||
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toastify.error(t('gallery.downloadError'));
|
||||
} finally {
|
||||
setSelectedPhotos(new Set());
|
||||
@@ -260,6 +263,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering}
|
||||
heroImageAnchor={heroImageAnchor}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -696,7 +696,12 @@
|
||||
"noCategory": "Keine Kategorie",
|
||||
"noCategoriesYet": "Noch keine Kategorien. Erstellen Sie Ihre erste Kategorie, um Fotos zu organisieren.",
|
||||
"deleteConfirm": "Sind Sie sicher, dass Sie \"{{name}}\" löschen möchten?",
|
||||
"cannotDelete": "Kategorie mit Fotos kann nicht gelöscht werden. Bitte weisen Sie die Fotos zuerst neu zu."
|
||||
"cannotDelete": "Kategorie mit Fotos kann nicht gelöscht werden. Bitte weisen Sie die Fotos zuerst neu zu.",
|
||||
"setCoverPhoto": "Titelbild festlegen",
|
||||
"removeCoverPhoto": "Titelbild entfernen",
|
||||
"coverPhotoSet": "Titelbild erfolgreich festgelegt",
|
||||
"coverPhotoRemoved": "Titelbild entfernt",
|
||||
"failedToSetCoverPhoto": "Titelbild konnte nicht festgelegt werden"
|
||||
},
|
||||
"events": {
|
||||
"noStatisticsAvailableYet": "Noch keine Statistiken verfügbar",
|
||||
@@ -863,6 +868,12 @@
|
||||
"selectHeroPhoto": "Hero-Foto auswählen",
|
||||
"noHeroPhotoSelected": "Kein Hero-Foto ausgewählt",
|
||||
"heroPhotoSelected": "Hero-Foto ausgewählt",
|
||||
"heroImageAnchor": "Hero-Bild Zuschneideposition",
|
||||
"heroImageAnchorDescription": "Klicken Sie auf das Bild, um den Fokuspunkt für den Zuschnitt festzulegen.",
|
||||
"heroImageAnchorTop": "Oben",
|
||||
"heroImageAnchorCenter": "Mitte",
|
||||
"heroImageAnchorBottom": "Unten",
|
||||
"heroPreview": "Hero-Vorschau",
|
||||
"noPhotosAvailable": "Keine Fotos verfügbar",
|
||||
"processingRequest": "Ihre Anfrage wird verarbeitet...",
|
||||
"eventTypeWedding": "Hochzeit",
|
||||
|
||||
@@ -309,7 +309,12 @@
|
||||
"noCategory": "No category",
|
||||
"noCategoriesYet": "No categories yet. Create your first category to organize photos.",
|
||||
"deleteConfirm": "Are you sure you want to delete \"{{name}}\"?",
|
||||
"cannotDelete": "Cannot delete category with photos. Please reassign photos first."
|
||||
"cannotDelete": "Cannot delete category with photos. Please reassign photos first.",
|
||||
"setCoverPhoto": "Set Cover Photo",
|
||||
"removeCoverPhoto": "Remove Cover Photo",
|
||||
"coverPhotoSet": "Cover photo set successfully",
|
||||
"coverPhotoRemoved": "Cover photo removed",
|
||||
"failedToSetCoverPhoto": "Failed to set cover photo"
|
||||
},
|
||||
"events": {
|
||||
"title": "Events",
|
||||
@@ -489,6 +494,12 @@
|
||||
"selectHeroPhoto": "Select Hero Photo",
|
||||
"noHeroPhotoSelected": "No hero photo selected",
|
||||
"heroPhotoSelected": "Hero photo selected",
|
||||
"heroImageAnchor": "Hero Image Crop Position",
|
||||
"heroImageAnchorDescription": "Click on the image to set the focal point for cropping.",
|
||||
"heroImageAnchorTop": "Top",
|
||||
"heroImageAnchorCenter": "Center",
|
||||
"heroImageAnchorBottom": "Bottom",
|
||||
"heroPreview": "Hero preview",
|
||||
"noPhotosAvailable": "No photos available",
|
||||
"processingRequest": "Processing your request...",
|
||||
"eventTypeWedding": "Wedding",
|
||||
|
||||
@@ -52,9 +52,10 @@ import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu } from '../../components/admin';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { publicSettingsService } from '../../services/publicSettings.service';
|
||||
import { api } from '../../config/api';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
|
||||
@@ -89,6 +90,7 @@ const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => v
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => { load(currentPath || ''); }, []);
|
||||
|
||||
const navigateUp = () => {
|
||||
@@ -168,6 +170,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
hero_logo_visible: boolean;
|
||||
hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
hero_logo_position: 'top' | 'center' | 'bottom';
|
||||
// Hero image anchor position (#162) – keyword or "X% Y%" focal point
|
||||
hero_image_anchor: string;
|
||||
};
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -196,6 +200,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top',
|
||||
// Hero image anchor position (#162)
|
||||
hero_image_anchor: 'center',
|
||||
});
|
||||
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
||||
feedback_enabled: false,
|
||||
@@ -291,7 +297,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
|
||||
const mediaTypes = useMemo(() => {
|
||||
const types = new Set<'photo' | 'video'>();
|
||||
photos.forEach((p: any) => {
|
||||
photos.forEach((p) => {
|
||||
const mediaType = (p.media_type as 'photo' | 'video' | undefined)
|
||||
|| ((p.mime_type && String(p.mime_type).startsWith('video/')) || p.type === 'video' ? 'video' : 'photo');
|
||||
if (mediaType === 'video' || mediaType === 'photo') {
|
||||
@@ -309,6 +315,13 @@ export const EventDetailsPage: React.FC = () => {
|
||||
}
|
||||
}, [showMediaFilter, photoFilters.media_type]);
|
||||
|
||||
// Fetch public settings (for field requirement checks like expiration)
|
||||
const { data: publicSettings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: () => publicSettingsService.getPublicSettings(),
|
||||
});
|
||||
const requireExpiration = publicSettings?.event_require_expiration !== false;
|
||||
|
||||
// Fetch categories for the event
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['admin-event-categories', id],
|
||||
@@ -402,6 +415,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
hero_logo_visible: event.hero_logo_visible ?? true,
|
||||
hero_logo_size: event.hero_logo_size || 'medium',
|
||||
hero_logo_position: event.hero_logo_position || 'top',
|
||||
// Hero image anchor position (#162)
|
||||
hero_image_anchor: event.hero_image_anchor || 'center',
|
||||
});
|
||||
|
||||
setShowNewPassword(false);
|
||||
@@ -430,7 +445,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
setCurrentPresetName(event.color_theme);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
setCurrentTheme(GALLERY_THEME_PRESETS.default.config);
|
||||
setCurrentPresetName('default');
|
||||
}
|
||||
@@ -510,10 +525,15 @@ export const EventDetailsPage: React.FC = () => {
|
||||
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (requireExpiration && !editForm.expires_at) {
|
||||
toast.error(t('validation.expirationRequired', 'Expiration date is required.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up the data - remove undefined values
|
||||
const updateData: any = {
|
||||
expires_at: editForm.expires_at,
|
||||
expires_at: editForm.expires_at || null,
|
||||
allow_user_uploads: editForm.allow_user_uploads,
|
||||
require_password: editForm.require_password,
|
||||
css_template_id: editForm.css_template_id,
|
||||
@@ -528,6 +548,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
hero_logo_visible: editForm.hero_logo_visible,
|
||||
hero_logo_size: editForm.hero_logo_size,
|
||||
hero_logo_position: editForm.hero_logo_position,
|
||||
// Hero image anchor position (#162)
|
||||
hero_image_anchor: editForm.hero_image_anchor,
|
||||
};
|
||||
|
||||
// Only include fields that have defined values
|
||||
@@ -570,7 +592,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
// Update feedback settings separately
|
||||
try {
|
||||
await feedbackService.updateEventFeedbackSettings(id!, feedbackSettings);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Error already handled by mutation
|
||||
}
|
||||
};
|
||||
@@ -859,6 +881,29 @@ export const EventDetailsPage: React.FC = () => {
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
|
||||
{/* Hero Image Focal Point Picker (#162) */}
|
||||
{editForm.hero_photo_id && (() => {
|
||||
const heroPhoto = (photos || []).find((p) => p.id === editForm.hero_photo_id);
|
||||
const heroImageUrl = heroPhoto?.thumbnail_url || heroPhoto?.url;
|
||||
if (!heroImageUrl) return null;
|
||||
return (
|
||||
<div className="ml-6 mt-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.heroImageAnchor', 'Hero Image Crop Position')}
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 mb-2">
|
||||
{t('events.heroImageAnchorDescription', 'Click on the image to set the focal point for cropping.')}
|
||||
</p>
|
||||
<FocalPointPicker
|
||||
imageUrl={heroImageUrl}
|
||||
currentValue={editForm.hero_image_anchor}
|
||||
onChange={(value) => setEditForm(prev => ({ ...prev, hero_image_anchor: value }))}
|
||||
slug={event.slug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div>
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
@@ -1446,7 +1491,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
try {
|
||||
await eventsService.resendCreationEmail(event.id);
|
||||
toast.success(t('events.creationEmailResent'));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t('events.failedToResendEmail'));
|
||||
}
|
||||
}}
|
||||
@@ -1622,7 +1667,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
toast.info(t('events.downloadingArchive', { name: event.event_name }));
|
||||
await archiveService.downloadArchive(Number(id), `${event.slug}-archive.zip`);
|
||||
toast.success(t('events.downloadStarted'));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t('events.failedToDownloadArchive'));
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface PhotoCategory {
|
||||
slug: string;
|
||||
is_global: boolean;
|
||||
event_id: number | null;
|
||||
hero_photo_id?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -41,6 +42,12 @@ export const categoriesService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Set category hero photo (#163)
|
||||
async setCategoryHeroPhoto(id: number, heroPhotoId: number | null): Promise<PhotoCategory> {
|
||||
const response = await api.put<PhotoCategory>(`/admin/categories/${id}/hero`, { hero_photo_id: heroPhotoId });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete a category
|
||||
async deleteCategory(id: number): Promise<void> {
|
||||
await api.delete(`/admin/categories/${id}`);
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface Event {
|
||||
// Header style settings (decoupled from layout)
|
||||
header_style?: 'hero' | 'standard' | 'minimal' | 'none';
|
||||
hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none';
|
||||
// Hero image anchor position (#162) – keyword or "X% Y%" focal point
|
||||
hero_image_anchor?: string;
|
||||
// CSS Template
|
||||
css_template_id?: number | null;
|
||||
}
|
||||
@@ -100,6 +102,7 @@ export interface PhotoCategory {
|
||||
name: string;
|
||||
slug: string;
|
||||
is_global: boolean;
|
||||
hero_photo_id?: number | null;
|
||||
}
|
||||
|
||||
export interface GalleryData {
|
||||
@@ -133,6 +136,8 @@ export interface GalleryData {
|
||||
// Header style settings (decoupled from layout)
|
||||
header_style?: 'hero' | 'standard' | 'minimal' | 'none';
|
||||
hero_divider_style?: 'wave' | 'straight' | 'angle' | 'curve' | 'none';
|
||||
// Hero image anchor position (#162) – keyword or "X% Y%" focal point
|
||||
hero_image_anchor?: string;
|
||||
};
|
||||
categories?: PhotoCategory[];
|
||||
photos: Photo[];
|
||||
|
||||
@@ -43,7 +43,6 @@ export interface GalleryLayoutSettings {
|
||||
|
||||
// Hero specific
|
||||
heroImageId?: number;
|
||||
heroImagePosition?: 'top' | 'center' | 'bottom';
|
||||
heroOverlayOpacity?: number;
|
||||
|
||||
// Mosaic specific
|
||||
|
||||
Reference in New Issue
Block a user