Fix language setting not being saved to database on admin settings page

- Added default_language field to general settings state in SettingsPage
- Replaced LanguageSelector component with simple select dropdown on settings page
- Fixed public settings endpoint to read general_default_language from database
- Language setting now properly saved when clicking Save Settings button
- Setting is correctly used by gallery login page and legal pages

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-08 09:49:45 +02:00
parent cfa0b0da69
commit 2012b0bab9
91 changed files with 6183 additions and 577 deletions
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { Clock, AlertCircle } from 'lucide-react';
import { differenceInSeconds } from 'date-fns';
import { useTranslation } from 'react-i18next';
interface CountdownTimerProps {
expiresAt: string;
@@ -8,6 +9,7 @@ interface CountdownTimerProps {
}
export const CountdownTimer: React.FC<CountdownTimerProps> = ({ expiresAt, className = '' }) => {
const { t } = useTranslation();
const [timeLeft, setTimeLeft] = useState<{
hours: number;
minutes: number;
@@ -43,7 +45,7 @@ export const CountdownTimer: React.FC<CountdownTimerProps> = ({ expiresAt, class
return (
<div className={`flex items-center gap-2 text-red-600 ${className}`}>
<AlertCircle className="w-5 h-5" />
<span className="font-semibold">Gallery Expired</span>
<span className="font-semibold">{t('gallery.expired')}</span>
</div>
);
}
@@ -69,7 +71,7 @@ export const CountdownTimer: React.FC<CountdownTimerProps> = ({ expiresAt, class
{String(timeLeft.seconds).padStart(2, '0')}
</div>
</div>
<span className="text-sm text-orange-600 font-medium">remaining</span>
<span className="text-sm text-orange-600 font-medium">{t('gallery.remaining')}</span>
</div>
);
};
@@ -2,6 +2,7 @@ import React from 'react';
import { AlertTriangle, Download } from 'lucide-react';
import Countdown from 'react-countdown';
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
interface ExpirationBannerProps {
daysRemaining: number;
@@ -12,11 +13,12 @@ export const ExpirationBanner: React.FC<ExpirationBannerProps> = ({
daysRemaining,
expiresAt
}) => {
const { t } = useTranslation();
const expirationDate = parseISO(expiresAt);
const countdownRenderer = ({ days, hours, minutes, completed }: any) => {
if (completed) {
return <span>Gallery has expired</span>;
return <span>{t('gallery.expired')}</span>;
} else {
return (
<span className="font-mono">
@@ -39,12 +41,12 @@ export const ExpirationBanner: React.FC<ExpirationBannerProps> = ({
<div className="flex items-center">
<AlertTriangle className="w-5 h-5 mr-2 animate-pulse" />
<span className="font-medium">
Gallery expires in <Countdown date={expirationDate} renderer={countdownRenderer} />
{t('gallery.expiresIn', { count: daysRemaining })} <Countdown date={expirationDate} renderer={countdownRenderer} />
</span>
</div>
<div className="flex items-center text-sm">
<Download className="w-4 h-4 mr-1" />
<span>Download your photos now!</span>
<span>{t('gallery.downloadBefore')}</span>
</div>
</div>
</div>
@@ -0,0 +1,171 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Calendar, Clock, Download, LogOut } from 'lucide-react';
import { format, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { Button, LanguageSelector } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon';
interface GalleryLayoutProps {
event: {
event_name: string;
event_type?: string;
event_date?: string;
expires_at?: string;
};
brandingSettings?: {
company_name?: string;
company_tagline?: string;
support_email?: string;
footer_text?: string;
favicon_url?: string;
logo_url?: string;
};
showLogout?: boolean;
onLogout?: () => void;
showDownloadAll?: boolean;
onDownloadAll?: () => void;
isDownloading?: boolean;
headerExtra?: React.ReactNode;
children: React.ReactNode;
}
export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
event,
brandingSettings,
showLogout = false,
onLogout,
showDownloadAll = false,
onDownloadAll,
isDownloading = false,
headerExtra,
children,
}) => {
const { t } = useTranslation();
return (
<div className="min-h-screen bg-neutral-50">
{/* Dynamic Favicon */}
<DynamicFavicon />
{/* 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 logo */}
{brandingSettings?.logo_url && (
<div className="pr-4 border-r border-neutral-200">
<img
src={brandingSettings.logo_url}
alt={brandingSettings.company_name || 'Company Logo'}
className="h-12 w-auto object-contain"
/>
</div>
)}
{/* Company branding */}
{!brandingSettings?.logo_url && 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>
{(event.event_date || event.expires_at) && (
<div className="flex items-center gap-4 mt-1 text-sm text-neutral-600">
{event.event_date && (
<span className="flex items-center">
<Calendar className="w-4 h-4 mr-1" />
{format(parseISO(event.event_date), 'MMMM d, yyyy')}
</span>
)}
{event.expires_at && (
<span className="flex items-center">
<Clock className="w-4 h-4 mr-1" />
{t('gallery.expires')} {format(parseISO(event.expires_at), 'MMM d')}
</span>
)}
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
{headerExtra}
<LanguageSelector />
{showDownloadAll && onDownloadAll && (
<Button
variant="primary"
size="md"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
isLoading={isDownloading}
>
{t('gallery.downloadAll')}
</Button>
)}
{showLogout && onLogout && (
<Button
variant="outline"
size="md"
leftIcon={<LogOut className="w-4 h-4" />}
onClick={onLogout}
>
{t('common.logout')}
</Button>
)}
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="container">{children}</main>
{/* 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">
{t('gallery.needHelp')}{' '}
<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>
)}
{/* Legal Links */}
<div className="mt-4 flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
</div>
</footer>
</div>
);
};
GalleryLayout.displayName = 'GalleryLayout';
+82 -209
View File
@@ -1,14 +1,16 @@
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 React, { useState, useMemo, useEffect, useRef } from 'react';
import { differenceInDays, parseISO } from 'date-fns';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button, Input, SkeletonGalleryGrid, Skeleton } from '../common';
import { Button, 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 { GalleryLayout } from './GalleryLayout';
import { PhotoFilterBar } from './PhotoFilterBar';
import { analyticsService } from '../../services/analytics.service';
import { api } from '../../config/api';
@@ -26,13 +28,14 @@ interface GalleryViewProps {
}
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { t } = useTranslation();
const { logout } = useGalleryAuth();
const { setTheme } = useTheme();
const [viewMode, setViewMode] = useState<'all' | 'collages' | 'individual'>('all');
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
const [showSortMenu, setShowSortMenu] = useState(false);
const [brandingSettings, setBrandingSettings] = useState<any>(null);
const themeAppliedRef = useRef(false);
// Fetch photos
const { data, isLoading, error } = useGalleryPhotos(slug);
@@ -48,40 +51,56 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Apply theme and branding settings
// Apply 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,
logo_url: settingsData.branding_logo_url || null,
});
// Apply theme settings
if (settingsData.theme_config) {
setTheme(settingsData.theme_config);
}
}
}, [settingsData, setTheme]);
}, [settingsData]);
// Apply event-specific theme if available
// Apply theme only once when component mounts and settings are loaded
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);
if (!themeAppliedRef.current && settingsData) {
let themeToApply = null;
if (event.color_theme) {
try {
// Check if it's a valid JSON string
if (event.color_theme.startsWith('{')) {
const eventTheme = JSON.parse(event.color_theme);
themeToApply = eventTheme;
} else {
// Handle legacy theme names - use global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
} catch (e) {
console.error('Failed to parse event theme:', e);
// Fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
} else if (settingsData.theme_config) {
// No event theme, use global theme
themeToApply = settingsData.theme_config;
}
// Apply theme only once
if (themeToApply) {
themeAppliedRef.current = true;
setTheme(themeToApply);
}
} 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]);
}, [settingsData]); // Only depend on settingsData, not setTheme or event
// Calculate days until expiration
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
@@ -93,11 +112,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
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 category filter
if (selectedCategoryId) {
photos = photos.filter(photo => photo.category_id === selectedCategoryId);
}
// Apply search filter
@@ -122,7 +139,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
});
return photos;
}, [data?.photos, viewMode, searchTerm, sortBy]);
}, [data?.photos, selectedCategoryId, searchTerm, sortBy]);
const handleDownloadAll = () => {
downloadAllMutation.mutate(slug);
@@ -185,9 +202,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
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>
<p className="text-lg text-neutral-600">{t('gallery.failedToLoad')}</p>
<Button onClick={() => window.location.reload()} className="mt-4">
Try Again
{t('gallery.tryAgain')}
</Button>
</div>
</div>
@@ -195,71 +212,28 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
return (
<div className="min-h-screen bg-neutral-50">
<GalleryLayout
event={event}
brandingSettings={brandingSettings}
showLogout={true}
onLogout={logout}
showDownloadAll={true}
onDownloadAll={handleDownloadAll}
isDownloading={downloadAllMutation.isPending}
headerExtra={
daysUntilExpiration <= 1 && daysUntilExpiration > 0 ? (
<CountdownTimer expiresAt={event.expires_at} className="mr-4" />
) : null
}
>
{/* 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="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>
@@ -267,125 +241,24 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
)}
{/* 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>
<div className="mt-6">
<PhotoFilterBar
categories={data.categories}
photos={data.photos}
selectedCategoryId={selectedCategoryId}
onCategoryChange={setSelectedCategoryId}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortBy={sortBy}
onSortChange={setSortBy}
photoCount={filteredPhotos.length}
/>
{/* 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 className="mt-6">
<PhotoGrid photos={filteredPhotos} slug={slug} categoryId={selectedCategoryId} />
</div>
</footer>
</div>
</div>
</GalleryLayout>
);
};
@@ -0,0 +1,148 @@
import React, { useState } from 'react';
import { Search, SortAsc, Grid } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../common';
interface PhotoCategory {
id: number;
name: string;
slug: string;
is_global: boolean;
}
interface Photo {
id: number;
category_id?: number;
}
interface PhotoFilterBarProps {
categories?: PhotoCategory[];
photos: Photo[];
selectedCategoryId: number | null;
onCategoryChange: (categoryId: number | null) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size';
onSortChange: (sort: 'date' | 'name' | 'size') => void;
photoCount: number;
}
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
categories = [],
photos,
selectedCategoryId,
onCategoryChange,
searchTerm,
onSearchChange,
sortBy,
onSortChange,
photoCount,
}) => {
const { t } = useTranslation();
const [showSortMenu, setShowSortMenu] = useState(false);
return (
<div className="space-y-4">
{/* Search and Sort */}
<div className="flex flex-col lg:flex-row gap-4">
{/* Search Bar */}
<div className="flex-1">
<Input
type="text"
placeholder={t('gallery.searchPhotos')}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
{/* Sort Dropdown */}
<div className="relative">
<Button
variant="outline"
size="md"
leftIcon={<SortAsc className="w-4 h-4" />}
onClick={() => setShowSortMenu(!showSortMenu)}
>
{t('common.sortBy')} {sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') : sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') : t('gallery.sortBySize').replace('Sort by ', '')}
</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={() => {
onSortChange('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'
}`}
>
{t('gallery.sortByDate')}
</button>
<button
onClick={() => {
onSortChange('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'
}`}
>
{t('gallery.sortByName')}
</button>
<button
onClick={() => {
onSortChange('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'
}`}
>
{t('gallery.sortBySize')}
</button>
</div>
)}
</div>
</div>
{/* Category Filter */}
{categories && categories.length > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-wrap">
<Button
variant={selectedCategoryId === null ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(null)}
leftIcon={<Grid className="w-4 h-4" />}
>
{t('gallery.allPhotos')} ({photos.length})
</Button>
{categories.map((category) => {
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
if (categoryPhotoCount === 0) return null;
return (
<Button
key={category.id}
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(category.id)}
>
{category.name} ({categoryPhotoCount})
</Button>
);
})}
</div>
<p className="text-sm text-neutral-600">
{photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')}
</p>
</div>
)}
</div>
);
};
PhotoFilterBar.displayName = 'PhotoFilterBar';
+63 -28
View File
@@ -1,28 +1,48 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Download, Maximize2, Check, Package } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { toast } from 'react-toastify';
import { toast as toastify } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { PhotoLightbox } from './PhotoLightbox';
import { Button } from '../common';
import { Button, AuthenticatedImage } from '../common';
import { galleryService } from '../../services/gallery.service';
import { analyticsService } from '../../services/analytics.service';
interface PhotoGridProps {
photos: Photo[];
slug: string;
categoryId?: number | null;
}
export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId }) => {
const { t } = useTranslation();
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const downloadPhotoMutation = useDownloadPhoto();
const handlePhotoClick = (index: number) => {
if (isSelectionMode) {
// Clear selection when category changes
useEffect(() => {
setSelectedPhotos(new Set());
}, [categoryId]);
const handlePhotoClick = (index: number, e?: React.MouseEvent) => {
// Check for ctrl/cmd+click for quick selection
if (e && (e.ctrlKey || e.metaKey)) {
if (!isSelectionMode) {
setIsSelectionMode(true);
}
const newSelected = new Set(selectedPhotos);
if (newSelected.has(photos[index].id)) {
newSelected.delete(photos[index].id);
} else {
newSelected.add(photos[index].id);
}
setSelectedPhotos(newSelected);
} else if (isSelectionMode) {
const newSelected = new Set(selectedPhotos);
if (newSelected.has(photos[index].id)) {
newSelected.delete(photos[index].id);
@@ -66,7 +86,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id));
toast.info(`Downloading ${selectedPhotos.size} photos...`);
toastify.info(t('gallery.downloading', { count: selectedPhotos.size }));
// Download each selected photo
const downloadPromises = selectedPhotosList.map(photo =>
@@ -79,7 +99,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
try {
await Promise.all(downloadPromises);
toast.success(`Downloaded ${selectedPhotos.size} photos!`);
toastify.success(t('gallery.downloadedPhotos', { count: selectedPhotos.size }));
// Track bulk download
analyticsService.trackGalleryEvent('bulk_download', {
@@ -91,14 +111,14 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
setSelectedPhotos(new Set());
setIsSelectionMode(false);
} catch (error) {
toast.error('Some photos failed to download');
toastify.error(t('gallery.downloadError'));
}
};
if (photos.length === 0) {
return (
<div className="text-center py-12">
<p className="text-neutral-600">No photos found</p>
<p className="text-neutral-600">{t('gallery.noPhotosFound')}</p>
</div>
);
}
@@ -108,24 +128,39 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
{/* Selection Mode Controls */}
{photos.length > 1 && (
<div className="mb-4 flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={toggleSelectionMode}
>
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectionMode}
title={t('gallery.selectPhotosHint')}
>
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
</Button>
{!isSelectionMode && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsSelectionMode(true);
selectAll();
}}
>
{t('gallery.selectAll')}
</Button>
)}
</div>
{isSelectionMode && (
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-600">
{selectedPhotos.size} selected
{t('gallery.photosSelected', { count: selectedPhotos.size })}
</span>
<Button variant="ghost" size="sm" onClick={selectAll}>
Select All
{t('gallery.selectAll')}
</Button>
<Button variant="ghost" size="sm" onClick={deselectAll}>
Deselect All
{t('gallery.deselectAll')}
</Button>
{selectedPhotos.size > 0 && (
<Button
@@ -134,7 +169,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
leftIcon={<Package className="w-4 h-4" />}
onClick={handleDownloadSelected}
>
Download {selectedPhotos.size} Selected
{t('gallery.downloadSelected', { count: selectedPhotos.size })}
</Button>
)}
</div>
@@ -150,7 +185,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug }) => {
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => handlePhotoClick(index)}
onClick={(e) => handlePhotoClick(index, e)}
onDownload={(e) => handleDownload(photo, e)}
/>
))}
@@ -173,7 +208,7 @@ interface PhotoThumbnailProps {
photo: Photo;
isSelected: boolean;
isSelectionMode: boolean;
onClick: () => void;
onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void;
}
@@ -192,12 +227,12 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
return (
<div
ref={ref}
className="relative group cursor-pointer"
onClick={onClick}
className="relative group cursor-pointer aspect-square"
onClick={(e) => onClick(e)}
>
{inView ? (
<>
<img
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg transition-transform duration-200 group-hover:scale-105"
@@ -212,7 +247,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onClick();
onClick(e);
}}
aria-label="View full size"
>
@@ -232,7 +267,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
{/* Selection checkbox */}
{isSelectionMode && (
<div className={`absolute top-2 right-2 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center`}>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/80 border-white'} flex items-center justify-center transition-colors`}>
{isSelected && <Check className="w-4 h-4 text-white" />}
</div>
</div>
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut } from 'lucide-react';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
interface PhotoLightboxProps {
photos: Photo[];
@@ -241,7 +242,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
onTouchEnd={handleTouchEnd}
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
>
<img
<AuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
className="max-w-full max-h-full object-contain select-none"
@@ -250,6 +251,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
transition: isDragging ? 'none' : 'transform 0.2s',
}}
draggable={false}
useWatermark={true}
/>
</div>
+3 -1
View File
@@ -2,4 +2,6 @@ export { GalleryView } from './GalleryView';
export { PhotoGrid } from './PhotoGrid';
export { PhotoLightbox } from './PhotoLightbox';
export { ExpirationBanner } from './ExpirationBanner';
export { CountdownTimer } from './CountdownTimer';
export { CountdownTimer } from './CountdownTimer';
export { GalleryLayout } from './GalleryLayout';
export { PhotoFilterBar } from './PhotoFilterBar';