eac573c4a5
- Update COLOR_THEMES to include full theme configurations - Send theme as JSON string when creating events - Display company branding in gallery header - Add debug logging for theme application - Event themes now properly override global themes - Company name and tagline now visible in gallery header
391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
import React, { useState, useMemo, useEffect } from 'react';
|
|
import { Download, Grid, Square, LogOut, Calendar, Clock, Search, SortAsc } from 'lucide-react';
|
|
import { format, differenceInDays, parseISO } from 'date-fns';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
|
import { Button, Input, SkeletonGalleryGrid, Skeleton } from '../common';
|
|
import { useGalleryAuth, useTheme } from '../../contexts';
|
|
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
|
|
import { PhotoGrid } from './PhotoGrid';
|
|
import { ExpirationBanner } from './ExpirationBanner';
|
|
import { CountdownTimer } from './CountdownTimer';
|
|
import { analyticsService } from '../../services/analytics.service';
|
|
import { api } from '../../config/api';
|
|
|
|
interface GalleryViewProps {
|
|
slug: string;
|
|
event: {
|
|
id: number;
|
|
event_name: string;
|
|
event_type: string;
|
|
event_date: string;
|
|
welcome_message?: string;
|
|
color_theme?: string;
|
|
expires_at: string;
|
|
};
|
|
}
|
|
|
|
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|
const { logout } = useGalleryAuth();
|
|
const { setTheme } = useTheme();
|
|
const [viewMode, setViewMode] = useState<'all' | 'collages' | 'individual'>('all');
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
|
|
const [showSortMenu, setShowSortMenu] = useState(false);
|
|
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
|
|
|
// Fetch photos
|
|
const { data, isLoading, error } = useGalleryPhotos(slug);
|
|
const downloadAllMutation = useDownloadAllPhotos();
|
|
|
|
// Fetch branding settings
|
|
const { data: settingsData } = useQuery({
|
|
queryKey: ['gallery-settings'],
|
|
queryFn: async () => {
|
|
const response = await api.get('/api/public/settings');
|
|
return response.data;
|
|
},
|
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
|
});
|
|
|
|
// Apply theme and branding settings
|
|
useEffect(() => {
|
|
if (settingsData) {
|
|
// Apply branding settings
|
|
setBrandingSettings({
|
|
company_name: settingsData.branding_company_name || '',
|
|
company_tagline: settingsData.branding_company_tagline || '',
|
|
support_email: settingsData.branding_support_email || '',
|
|
footer_text: settingsData.branding_footer_text || '© 2024 Your Company. All rights reserved.',
|
|
watermark_enabled: settingsData.branding_watermark_enabled || false,
|
|
});
|
|
|
|
// Apply theme settings
|
|
if (settingsData.theme_config) {
|
|
setTheme(settingsData.theme_config);
|
|
}
|
|
}
|
|
}, [settingsData, setTheme]);
|
|
|
|
// Apply event-specific theme if available
|
|
useEffect(() => {
|
|
if (event.color_theme) {
|
|
try {
|
|
const eventTheme = JSON.parse(event.color_theme);
|
|
console.log('Applying event-specific theme:', eventTheme);
|
|
setTheme(eventTheme);
|
|
} catch (e) {
|
|
console.error('Failed to parse event theme:', e);
|
|
}
|
|
} else if (settingsData?.theme_config) {
|
|
// Fall back to global theme if no event-specific theme
|
|
console.log('No event theme, using global theme');
|
|
}
|
|
}, [event.color_theme, setTheme, settingsData]);
|
|
|
|
// Calculate days until expiration
|
|
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
|
|
const showUrgentWarning = daysUntilExpiration <= 7;
|
|
|
|
// Filter and sort photos
|
|
const filteredPhotos = useMemo(() => {
|
|
if (!data?.photos) return [];
|
|
|
|
let photos = [...data.photos];
|
|
|
|
// Apply view mode filter
|
|
if (viewMode === 'collages') {
|
|
photos = photos.filter(photo => photo.type === 'collage');
|
|
} else if (viewMode === 'individual') {
|
|
photos = photos.filter(photo => photo.type === 'individual');
|
|
}
|
|
|
|
// Apply search filter
|
|
if (searchTerm) {
|
|
const term = searchTerm.toLowerCase();
|
|
photos = photos.filter(photo =>
|
|
photo.filename.toLowerCase().includes(term)
|
|
);
|
|
}
|
|
|
|
// Apply sorting
|
|
photos.sort((a, b) => {
|
|
switch (sortBy) {
|
|
case 'name':
|
|
return a.filename.localeCompare(b.filename);
|
|
case 'size':
|
|
return b.size - a.size;
|
|
case 'date':
|
|
default:
|
|
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
|
|
}
|
|
});
|
|
|
|
return photos;
|
|
}, [data?.photos, viewMode, searchTerm, sortBy]);
|
|
|
|
const handleDownloadAll = () => {
|
|
downloadAllMutation.mutate(slug);
|
|
|
|
// Track download all action
|
|
analyticsService.trackGalleryEvent('bulk_download', {
|
|
gallery: slug,
|
|
photo_count: data?.photos.length || 0,
|
|
is_download_all: true
|
|
});
|
|
};
|
|
|
|
// Track search usage with debouncing
|
|
useEffect(() => {
|
|
if (searchTerm.length > 0) {
|
|
const timer = setTimeout(() => {
|
|
analyticsService.trackSearch(searchTerm, filteredPhotos.length, 'gallery');
|
|
}, 1000); // Debounce for 1 second
|
|
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [searchTerm, filteredPhotos.length]);
|
|
|
|
// Track expiration warning views
|
|
useEffect(() => {
|
|
if (showUrgentWarning && daysUntilExpiration > 0) {
|
|
analyticsService.trackExpirationWarning(slug, daysUntilExpiration);
|
|
}
|
|
}, [showUrgentWarning, daysUntilExpiration, slug]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-neutral-50">
|
|
{/* Header Skeleton */}
|
|
<header className="bg-white border-b border-neutral-200 sticky top-0 z-40">
|
|
<div className="container py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<Skeleton height={32} width={200} className="mb-2" />
|
|
<Skeleton height={20} width={300} />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Skeleton height={40} width={120} />
|
|
<Skeleton height={40} width={100} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Content Skeleton */}
|
|
<div className="container mt-6">
|
|
<Skeleton height={80} className="mb-6" />
|
|
<SkeletonGalleryGrid count={12} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !data) {
|
|
return (
|
|
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<p className="text-lg text-neutral-600">Failed to load photos</p>
|
|
<Button onClick={() => window.location.reload()} className="mt-4">
|
|
Try Again
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-neutral-50">
|
|
{/* Expiration Banner */}
|
|
{showUrgentWarning && (
|
|
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
|
)}
|
|
|
|
{/* Header */}
|
|
<header className="bg-white border-b border-neutral-200 sticky top-0 z-40">
|
|
<div className="container py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
{/* Company branding */}
|
|
{brandingSettings?.company_name && (
|
|
<div className="pr-4 border-r border-neutral-200">
|
|
<h2 className="text-lg font-semibold text-neutral-800">{brandingSettings.company_name}</h2>
|
|
{brandingSettings.company_tagline && (
|
|
<p className="text-xs text-neutral-600">{brandingSettings.company_tagline}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-neutral-900">{event.event_name}</h1>
|
|
<div className="flex items-center gap-4 mt-1 text-sm text-neutral-600">
|
|
<span className="flex items-center">
|
|
<Calendar className="w-4 h-4 mr-1" />
|
|
{format(parseISO(event.event_date), 'MMMM d, yyyy')}
|
|
</span>
|
|
<span className="flex items-center">
|
|
<Clock className="w-4 h-4 mr-1" />
|
|
Expires {format(parseISO(event.expires_at), 'MMM d')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{daysUntilExpiration <= 1 && daysUntilExpiration > 0 && (
|
|
<CountdownTimer expiresAt={event.expires_at} className="mr-4" />
|
|
)}
|
|
<Button
|
|
variant="primary"
|
|
size="md"
|
|
leftIcon={<Download className="w-4 h-4" />}
|
|
onClick={handleDownloadAll}
|
|
isLoading={downloadAllMutation.isPending}
|
|
className={showUrgentWarning ? 'animate-pulse' : ''}
|
|
>
|
|
Download All
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="md"
|
|
leftIcon={<LogOut className="w-4 h-4" />}
|
|
onClick={logout}
|
|
>
|
|
Logout
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Welcome Message */}
|
|
{event.welcome_message && (
|
|
<div className="container mt-6">
|
|
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4">
|
|
<p className="text-primary-900">{event.welcome_message}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Search and Filters */}
|
|
<div className="container mt-6">
|
|
<div className="flex flex-col lg:flex-row gap-4 mb-6">
|
|
{/* Search Bar */}
|
|
<div className="flex-1">
|
|
<Input
|
|
type="text"
|
|
placeholder="Search photos by filename..."
|
|
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Sort Dropdown */}
|
|
<div className="relative">
|
|
<Button
|
|
variant="outline"
|
|
size="md"
|
|
leftIcon={<SortAsc className="w-4 h-4" />}
|
|
onClick={() => setShowSortMenu(!showSortMenu)}
|
|
>
|
|
Sort by {sortBy === 'date' ? 'Date' : sortBy === 'name' ? 'Name' : 'Size'}
|
|
</Button>
|
|
|
|
{showSortMenu && (
|
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-10">
|
|
<button
|
|
onClick={() => {
|
|
setSortBy('date');
|
|
setShowSortMenu(false);
|
|
}}
|
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${sortBy === 'date' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'}`}
|
|
>
|
|
Sort by Date
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setSortBy('name');
|
|
setShowSortMenu(false);
|
|
}}
|
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${sortBy === 'name' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'}`}
|
|
>
|
|
Sort by Name
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setSortBy('size');
|
|
setShowSortMenu(false);
|
|
}}
|
|
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${sortBy === 'size' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'}`}
|
|
>
|
|
Sort by Size
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* View Mode Toggle */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant={viewMode === 'all' ? 'primary' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setViewMode('all')}
|
|
leftIcon={<Grid className="w-4 h-4" />}
|
|
>
|
|
All Photos ({data.photos.length})
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === 'collages' ? 'primary' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setViewMode('collages')}
|
|
leftIcon={<Square className="w-4 h-4" />}
|
|
>
|
|
Collages ({data.photos.filter(p => p.type === 'collage').length})
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === 'individual' ? 'primary' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setViewMode('individual')}
|
|
>
|
|
Individual ({data.photos.filter(p => p.type === 'individual').length})
|
|
</Button>
|
|
</div>
|
|
|
|
<p className="text-sm text-neutral-600">
|
|
{filteredPhotos.length} {filteredPhotos.length === 1 ? 'photo' : 'photos'}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Photo Grid */}
|
|
<PhotoGrid photos={filteredPhotos} slug={slug} />
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<footer className="mt-12 py-8 border-t border-neutral-200">
|
|
<div className="container text-center">
|
|
{brandingSettings?.support_email && (
|
|
<p className="text-sm text-neutral-600 mb-2">
|
|
Need help? Contact us at{' '}
|
|
<a
|
|
href={`mailto:${brandingSettings.support_email}`}
|
|
className="text-primary-600 hover:text-primary-700"
|
|
>
|
|
{brandingSettings.support_email}
|
|
</a>
|
|
</p>
|
|
)}
|
|
<p className="text-sm text-neutral-500">
|
|
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
|
|
</p>
|
|
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
|
|
<p className="text-xs text-neutral-400 mt-2">
|
|
{brandingSettings.company_name} - {brandingSettings.company_tagline}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}; |