+
+ {/* Logo - Show custom logo or fallback to PicPeak logo */}
+
- {/* Header extra content (upload button, countdown) */}
- {headerExtra && headerExtra}
+
+ )}
+
+
+ {/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
+ {isNonGridLayout && (
+
= ({
)}
- {brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
+ {brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'} | Powered by PicPeak
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
diff --git a/frontend/src/components/gallery/GallerySidebar.tsx b/frontend/src/components/gallery/GallerySidebar.tsx
new file mode 100644
index 0000000..be55e17
--- /dev/null
+++ b/frontend/src/components/gallery/GallerySidebar.tsx
@@ -0,0 +1,274 @@
+import React, { useEffect, useRef } from 'react';
+import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check } from 'lucide-react';
+import { Button } from '../common';
+import { PhotoCategory } from '../../types';
+import { useTranslation } from 'react-i18next';
+
+interface GallerySidebarProps {
+ isOpen: boolean;
+ onClose: () => void;
+ categories: PhotoCategory[];
+ selectedCategoryId: number | null;
+ onCategoryChange: (categoryId: number | null) => void;
+ searchTerm: string;
+ onSearchChange: (term: string) => void;
+ sortBy: 'date' | 'name' | 'size';
+ onSortChange: (sort: 'date' | 'name' | 'size') => void;
+ isSelectionMode: boolean;
+ onToggleSelectionMode: () => void;
+ selectedCount: number;
+ onDownloadAll: () => void;
+ onDownloadSelected: () => void;
+ isDownloading: boolean;
+ photoCounts?: Record;
+ totalPhotos: number;
+ isMobile: boolean;
+ galleryLayout?: string;
+}
+
+export const GallerySidebar: React.FC = ({
+ isOpen,
+ onClose,
+ categories,
+ selectedCategoryId,
+ onCategoryChange,
+ searchTerm,
+ onSearchChange,
+ sortBy,
+ onSortChange,
+ isSelectionMode,
+ onToggleSelectionMode,
+ selectedCount,
+ onDownloadAll,
+ onDownloadSelected,
+ isDownloading,
+ photoCounts = {},
+ totalPhotos,
+ isMobile,
+ galleryLayout
+}) => {
+ const { t } = useTranslation();
+ const sidebarRef = useRef(null);
+
+ // Close sidebar when clicking outside on mobile
+ useEffect(() => {
+ if (isMobile && isOpen) {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (sidebarRef.current && !sidebarRef.current.contains(event.target as Node)) {
+ onClose();
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }
+ }, [isMobile, isOpen, onClose]);
+
+ // Prevent body scroll when sidebar is open on mobile
+ useEffect(() => {
+ if (isMobile && isOpen) {
+ document.body.style.overflow = 'hidden';
+ return () => {
+ document.body.style.overflow = 'unset';
+ };
+ }
+ }, [isMobile, isOpen]);
+
+ const sortOptions = [
+ { value: 'date', label: t('gallery.sortByDate'), icon: Calendar },
+ { value: 'name', label: t('gallery.sortByName'), icon: Type },
+ { value: 'size', label: t('gallery.sortBySize'), icon: HardDrive }
+ ];
+
+ return (
+ <>
+ {/* Backdrop for mobile */}
+ {isMobile && isOpen && (
+
+ )}
+
+ {/* Sidebar */}
+
+ {/* Header */}
+
+
{t('gallery.filters')}
+
+
+
+
+
+ {/* Content */}
+
+ {/* Search Section - Hidden for carousel layout */}
+ {galleryLayout !== 'carousel' && (
+
+
+
+ onSearchChange(e.target.value)}
+ placeholder={t('gallery.searchPlaceholder')}
+ className="w-full pl-10 pr-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
+ />
+
+
+ )}
+
+ {/* Download Section */}
+
+
+
+ {t('gallery.download')}
+
+
+
+ }
+ onClick={onDownloadAll}
+ disabled={isDownloading || totalPhotos === 0}
+ className="w-full"
+ >
+ {t('gallery.downloadAll')} ({totalPhotos})
+
+
+
+ {isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
+
+
+ {isSelectionMode && selectedCount > 0 && (
+ }
+ onClick={onDownloadSelected}
+ disabled={isDownloading}
+ className="w-full"
+ >
+ {t('gallery.downloadSelected')} ({selectedCount})
+
+ )}
+
+
+
+ {/* Categories Section - Hidden for carousel layout */}
+ {galleryLayout !== 'carousel' && categories.length > 0 && (
+
+
+
+ {t('gallery.categories')}
+
+
+
+ {
+ onCategoryChange(null);
+ if (isMobile) onClose();
+ }}
+ className={`
+ w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
+ ${selectedCategoryId === null
+ ? 'bg-primary-50 text-primary-700'
+ : 'hover:bg-neutral-50 text-neutral-700'
+ }
+ `}
+ >
+ {t('gallery.allCategories')}
+ {totalPhotos}
+
+
+ {categories.map((category) => {
+ const count = photoCounts[category.id] || 0;
+ const isSelected = selectedCategoryId === category.id;
+
+ return (
+ {
+ onCategoryChange(category.id);
+ if (isMobile) onClose();
+ }}
+ className={`
+ w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
+ ${isSelected
+ ? 'bg-primary-50 text-primary-700'
+ : 'hover:bg-neutral-50 text-neutral-700'
+ }
+ `}
+ >
+
+ {isSelected && }
+ {category.name}
+
+ {count}
+
+ );
+ })}
+
+
+ )}
+
+ {/* Sort Section - Hidden for carousel and timeline layouts */}
+ {galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
+
+
+
+ {t('gallery.sortBy')}
+
+
+
+ {sortOptions.map((option) => {
+ const Icon = option.icon;
+ const isSelected = sortBy === option.value;
+
+ return (
+ {
+ onSortChange(option.value as 'date' | 'name' | 'size');
+ if (isMobile) onClose();
+ }}
+ className={`
+ w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
+ ${isSelected
+ ? 'bg-primary-50 text-primary-700'
+ : 'hover:bg-neutral-50 text-neutral-700'
+ }
+ `}
+ >
+
+ {option.label}
+ {isSelected && }
+
+ );
+ })}
+
+
+ )}
+
+
+
+ >
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx
index 02b8228..4935f31 100644
--- a/frontend/src/components/gallery/GalleryView.tsx
+++ b/frontend/src/components/gallery/GalleryView.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useMemo, useEffect, useRef } from 'react';
+import React, { useState, useMemo, useEffect } from 'react';
import { differenceInDays, parseISO } from 'date-fns';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -10,11 +10,14 @@ import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
import { ExpirationBanner } from './ExpirationBanner';
import { CountdownTimer } from './CountdownTimer';
import { GalleryLayout } from './GalleryLayout';
+import { GallerySidebar } from './GallerySidebar';
import { PhotoFilterBar } from './PhotoFilterBar';
import { UserPhotoUpload } from './UserPhotoUpload';
import { analyticsService } from '../../services/analytics.service';
+import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { api } from '../../config/api';
-import { Upload } from 'lucide-react';
+import { Upload, Menu } from 'lucide-react';
+import { galleryService } from '../../services/gallery.service';
interface GalleryViewProps {
slug: string;
@@ -28,24 +31,37 @@ interface GalleryViewProps {
expires_at: string;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
+ hero_photo_id?: number | null;
};
}
export const GalleryView: React.FC = ({ slug, event }) => {
const { t } = useTranslation();
const { logout } = useGalleryAuth();
- const { setTheme } = useTheme();
+ const { setTheme, theme } = useTheme();
const [selectedCategoryId, setSelectedCategoryId] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
const [brandingSettings, setBrandingSettings] = useState(null);
const [showUploadModal, setShowUploadModal] = useState(false);
- const themeAppliedRef = useRef(false);
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+ const [isSelectionMode, setIsSelectionMode] = useState(false);
+ const [selectedPhotos, setSelectedPhotos] = useState>(new Set());
+ const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
// Fetch photos
const { data, isLoading, error } = useGalleryPhotos(slug);
const downloadAllMutation = useDownloadAllPhotos();
+ // Handle window resize
+ useEffect(() => {
+ const handleResize = () => {
+ setIsMobile(window.innerWidth < 768);
+ };
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, []);
+
// Fetch branding settings
const { data: settingsData } = useQuery({
queryKey: ['gallery-settings'],
@@ -70,9 +86,9 @@ export const GalleryView: React.FC = ({ slug, event }) => {
}
}, [settingsData]);
- // Apply theme only once when component mounts and settings are loaded
+ // Apply theme when settings are loaded
useEffect(() => {
- if (!themeAppliedRef.current && settingsData) {
+ if (settingsData && event) {
let themeToApply = null;
if (event.color_theme) {
@@ -82,9 +98,15 @@ export const GalleryView: React.FC = ({ slug, event }) => {
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;
+ // Handle legacy theme names - check if it's a preset
+ const preset = GALLERY_THEME_PRESETS[event.color_theme];
+ if (preset) {
+ themeToApply = preset.config;
+ } else {
+ // Unknown theme name, fall back to global theme
+ if (settingsData.theme_config) {
+ themeToApply = settingsData.theme_config;
+ }
}
}
} catch (e) {
@@ -99,13 +121,23 @@ export const GalleryView: React.FC = ({ slug, event }) => {
themeToApply = settingsData.theme_config;
}
- // Apply theme only once
+ // Apply theme with a small delay to ensure it overrides any global theme
if (themeToApply) {
- themeAppliedRef.current = true;
- setTheme(themeToApply);
+ // Use setTimeout to ensure this runs after any global theme application
+ const timer = setTimeout(() => {
+ // If there's a hero photo, add it to gallery settings
+ if (event.hero_photo_id && themeToApply.gallerySettings) {
+ themeToApply.gallerySettings.heroImageId = event.hero_photo_id;
+ } else if (event.hero_photo_id) {
+ themeToApply.gallerySettings = { heroImageId: event.hero_photo_id };
+ }
+ setTheme(themeToApply);
+ }, 0);
+
+ return () => clearTimeout(timer);
}
}
- }, [settingsData]); // Only depend on settingsData, not setTheme or event
+ }, [settingsData, event, setTheme]); // Include all dependencies
// Calculate days until expiration
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
@@ -157,6 +189,39 @@ export const GalleryView: React.FC = ({ slug, event }) => {
});
};
+ const handleDownloadSelected = async () => {
+ if (selectedPhotos.size === 0) return;
+
+ const selectedPhotosList = filteredPhotos.filter(p => selectedPhotos.has(p.id));
+
+ // Track bulk download
+ analyticsService.trackGalleryEvent('bulk_download', {
+ gallery: slug,
+ photo_count: selectedPhotos.size
+ });
+
+ // Download each selected photo
+ for (const photo of selectedPhotosList) {
+ await galleryService.downloadPhoto(slug, photo.id, photo.filename);
+ }
+
+ // Clear selection after download
+ setSelectedPhotos(new Set());
+ setIsSelectionMode(false);
+ };
+
+ // Calculate photo counts per category
+ const photoCounts = useMemo(() => {
+ if (!data?.photos) return {};
+ const counts: Record = {};
+ data.photos.forEach(photo => {
+ if (photo.category_id) {
+ counts[photo.category_id] = (counts[photo.category_id] || 0) + 1;
+ }
+ });
+ return counts;
+ }, [data?.photos]);
+
// Track search usage with debouncing
useEffect(() => {
if (searchTerm.length > 0) {
@@ -216,23 +281,71 @@ export const GalleryView: React.FC = ({ slug, event }) => {
);
}
+ const showSidebar = theme.galleryLayout !== 'grid';
+
return (
- 0) || event.allow_user_uploads ? (
- <>
- {daysUntilExpiration <= 1 && daysUntilExpiration > 0 && (
-
- )}
- {event.allow_user_uploads && (
+ <>
+ {/* Sidebar for non-grid layouts */}
+ {showSidebar ? (
+ setSidebarOpen(!sidebarOpen)}
+ categories={(data?.categories || []).filter(cat => photoCounts[cat.id] > 0)}
+ selectedCategoryId={selectedCategoryId}
+ onCategoryChange={setSelectedCategoryId}
+ searchTerm={searchTerm}
+ onSearchChange={setSearchTerm}
+ sortBy={sortBy}
+ onSortChange={setSortBy}
+ isSelectionMode={isSelectionMode}
+ onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
+ selectedCount={selectedPhotos.size}
+ onDownloadAll={handleDownloadAll}
+ onDownloadSelected={handleDownloadSelected}
+ isDownloading={downloadAllMutation.isPending}
+ photoCounts={photoCounts}
+ totalPhotos={data?.photos.length || 0}
+ isMobile={isMobile}
+ galleryLayout={theme.galleryLayout}
+ />
+ ) : null}
+
+ {
+ const items = [];
+
+ if (showSidebar) {
+ items.push(
}
+ onClick={() => setSidebarOpen(!sidebarOpen)}
+ aria-label={t('gallery.toggleMenu')}
+ >
+ {t('common.menu')}
+
+ );
+ }
+
+ if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
+ items.push(
+
+ );
+ }
+
+ if (event.allow_user_uploads) {
+ items.push(
+ }
@@ -242,50 +355,64 @@ export const GalleryView: React.FC = ({ slug, event }) => {
{t('upload.uploadPhotos')}
{t('common.upload')}
- )}
- >
- ) : null
- }
- >
- {/* Expiration Banner */}
- {showUrgentWarning && (
-
- )}
+ );
+ }
+
+ return <>{items}>;
+ })()}
+ >
+ {/* Expiration Banner */}
+ {showUrgentWarning && (
+
+ )}
-
- {/* Search and Filters */}
-
-
+ {/* Search and Filters - Only for grid layout */}
+ {!showSidebar ? (
+
+ ) : null}
{/* Photo Grid */}
-
-
+
+
setIsSelectionMode(!isSelectionMode)}
+ showSelectionControls={!showSidebar}
+ eventName={event.event_name}
+ eventLogo={brandingSettings?.logo_url}
+ />
-
- {/* Upload Modal */}
- {showUploadModal && (
-
{
- setShowUploadModal(false);
- // Refetch photos after upload
- window.location.reload(); // Simple reload for now
- }}
- onClose={() => setShowUploadModal(false)}
- />
- )}
-
+ {/* Upload Modal */}
+ {showUploadModal && (
+ {
+ setShowUploadModal(false);
+ // Refetch photos after upload
+ window.location.reload(); // Simple reload for now
+ }}
+ onClose={() => setShowUploadModal(false)}
+ />
+ )}
+
+ >
);
};
\ No newline at end of file
diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx
index c738bd5..3adc911 100644
--- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx
+++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx
@@ -25,20 +25,40 @@ interface PhotoGridWithLayoutsProps {
photos: Photo[];
slug: string;
categoryId?: number | null;
+ isSelectionMode?: boolean;
+ selectedPhotos?: Set;
+ onSelectionChange?: (photos: Set) => void;
+ onToggleSelectionMode?: () => void;
+ showSelectionControls?: boolean;
+ eventName?: string;
+ eventLogo?: string | null;
}
export const PhotoGridWithLayouts: React.FC = ({
photos,
slug,
- categoryId
+ categoryId,
+ isSelectionMode: parentSelectionMode,
+ selectedPhotos: parentSelectedPhotos,
+ onSelectionChange,
+ onToggleSelectionMode: parentToggleSelectionMode,
+ showSelectionControls = true,
+ eventName,
+ eventLogo
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState(null);
- const [selectedPhotos, setSelectedPhotos] = useState>(new Set());
- const [isSelectionMode, setIsSelectionMode] = useState(false);
+ const [localSelectedPhotos, setLocalSelectedPhotos] = useState>(new Set());
+ const [localSelectionMode, setLocalSelectionMode] = useState(false);
const downloadPhotoMutation = useDownloadPhoto();
+ // Use parent state if provided, otherwise use local state
+ const selectedPhotos = parentSelectedPhotos ?? localSelectedPhotos;
+ const isSelectionMode = parentSelectionMode ?? localSelectionMode;
+ const setSelectedPhotos = onSelectionChange ?? setLocalSelectedPhotos;
+ const toggleSelectionMode = parentToggleSelectionMode ?? (() => setLocalSelectionMode(!localSelectionMode));
+
// Clear selection when category changes
useEffect(() => {
setSelectedPhotos(new Set());
@@ -71,10 +91,6 @@ export const PhotoGridWithLayouts: React.FC = ({
});
};
- const toggleSelectionMode = () => {
- setIsSelectionMode(!isSelectionMode);
- setSelectedPhotos(new Set());
- };
const selectAll = () => {
setSelectedPhotos(new Set(photos.map(p => p.id)));
@@ -112,7 +128,11 @@ export const PhotoGridWithLayouts: React.FC = ({
// Clear selection after download
setSelectedPhotos(new Set());
- setIsSelectionMode(false);
+ if (parentToggleSelectionMode) {
+ parentToggleSelectionMode();
+ } else {
+ setLocalSelectionMode(false);
+ }
} catch (error) {
toastify.error(t('gallery.downloadError'));
}
@@ -138,6 +158,8 @@ export const PhotoGridWithLayouts: React.FC = ({
selectedPhotos,
isSelectionMode,
onPhotoSelect: handlePhotoSelect,
+ eventName,
+ eventLogo,
};
let LayoutComponent;
@@ -163,8 +185,8 @@ export const PhotoGridWithLayouts: React.FC = ({
return (
<>
- {/* Selection Mode Controls - Not shown for carousel layout */}
- {photos.length > 1 && galleryLayout !== 'carousel' && (
+ {/* Selection Mode Controls - Not shown for carousel layout or when controls are hidden */}
+ {showSelectionControls && photos.length > 1 && galleryLayout !== 'carousel' && (
= ({
variant="ghost"
size="sm"
onClick={() => {
- setIsSelectionMode(true);
+ toggleSelectionMode();
selectAll();
}}
className="text-xs sm:text-sm"
diff --git a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx
index 6ec2112..936c503 100644
--- a/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx
+++ b/frontend/src/components/gallery/layouts/BaseGalleryLayout.tsx
@@ -9,6 +9,8 @@ export interface BaseGalleryLayoutProps {
selectedPhotos?: Set;
isSelectionMode?: boolean;
onPhotoSelect?: (photoId: number) => void;
+ eventName?: string;
+ eventLogo?: string | null;
}
export abstract class BaseGalleryLayout extends React.Component {
diff --git a/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx b/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx
index c628d45..98ea554 100644
--- a/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx
+++ b/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx
@@ -1,17 +1,24 @@
import React, { useState, useEffect } from 'react';
import { Download, Maximize2, Check, ChevronDown } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
-import { AuthenticatedImage, Button } from '../../common';
+import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
-export const HeroGalleryLayout: React.FC = ({
+interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
+ eventName?: string;
+ eventLogo?: string | null;
+}
+
+export const HeroGalleryLayout: React.FC = ({
photos,
onPhotoClick,
onDownload,
selectedPhotos = new Set(),
isSelectionMode = false,
- onPhotoSelect
+ onPhotoSelect,
+ eventName,
+ eventLogo
}) => {
const { theme } = useTheme();
const [heroPhoto, setHeroPhoto] = useState(null);
@@ -50,30 +57,25 @@ export const HeroGalleryLayout: React.FC = ({
{/* Hero Content */}
-
-
- {heroPhoto.category_name || 'Featured Photo'}
-
-
- }
- onClick={() => onPhotoClick(0)}
- className="bg-white/20 backdrop-blur-sm hover:bg-white/30"
- >
- View Gallery
-
- }
- onClick={(e) => onDownload(heroPhoto, e)}
- className="border-white text-white hover:bg-white/20"
- >
- Download
-
-
+
+ {/* Logo */}
+ {eventLogo && (
+
+
+
+ )}
+
+ {/* Event Title */}
+ {eventName && (
+
+ {eventName}
+
+ )}
diff --git a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx
index d7f33a5..3603ea7 100644
--- a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx
+++ b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx
@@ -24,16 +24,21 @@ const MosaicPhoto: React.FC
= ({
}) => {
return (
{
+ e.stopPropagation();
+ onClick(e);
+ }}
>
-
+
{!isSelectionMode && (
@@ -90,96 +95,6 @@ export const MosaicGalleryLayout: React.FC
= ({
// const gallerySettings = theme.gallerySettings || {};
// const pattern = gallerySettings.mosaicPattern || 'structured';
- // Create mosaic patterns
- const renderStructuredPattern = () => {
- const patterns = [
- // Pattern 1: Large left, 2 small right
-
- {photos[0] && (
-
handlePhotoClick(0, photos[0].id)}
- onDownload={(e) => onDownload(photos[0], e)}
- className="col-span-1 row-span-2"
- />
- )}
-
- {photos[1] && (
- handlePhotoClick(1, photos[1].id)}
- onDownload={(e) => onDownload(photos[1], e)}
- />
- )}
- {photos[2] && (
- handlePhotoClick(2, photos[2].id)}
- onDownload={(e) => onDownload(photos[2], e)}
- />
- )}
-
- ,
-
- // Pattern 2: 3 equal columns
-
- {photos.slice(3, 6).map((photo, idx) => {
- const index = idx + 3;
- return photo ? (
- handlePhotoClick(index, photo.id)}
- onDownload={(e) => onDownload(photo, e)}
- />
- ) : null;
- })}
-
,
-
- // Pattern 3: Large center with sides
-
- {photos[6] && (
- handlePhotoClick(6, photos[6].id)}
- onDownload={(e) => onDownload(photos[6], e)}
- />
- )}
- {photos[7] && (
- handlePhotoClick(7, photos[7].id)}
- onDownload={(e) => onDownload(photos[7], e)}
- className="row-span-2"
- />
- )}
- {photos[8] && (
- handlePhotoClick(8, photos[8].id)}
- onDownload={(e) => onDownload(photos[8], e)}
- />
- )}
-
- ];
-
- return patterns;
- };
-
const handlePhotoClick = (index: number, photoId: number) => {
if (isSelectionMode && onPhotoSelect) {
onPhotoSelect(photoId);
@@ -188,21 +103,147 @@ export const MosaicGalleryLayout: React.FC = ({
}
};
- // For now, we'll use the structured pattern
- // You can implement random and alternating patterns as needed
- const mosaicElements = renderStructuredPattern();
-
- // Add remaining photos in a regular grid
- const remainingPhotos = photos.slice(9);
-
- return (
-
- {mosaicElements}
+ // Create a more structured mosaic layout
+ const renderMosaicLayout = () => {
+ const elements = [];
+ let photoIndex = 0;
+ let patternIndex = 0;
+
+ while (photoIndex < photos.length) {
+ const remainingPhotos = photos.length - photoIndex;
- {remainingPhotos.length > 0 && (
-
+ // Choose pattern based on rotation and remaining photos
+ if (patternIndex % 3 === 0 && remainingPhotos >= 3) {
+ // Pattern 1: Large left, 2 small right
+ // Capture indices immediately to avoid closure issues
+ const idx0 = photoIndex;
+ const idx1 = photoIndex + 1;
+ const idx2 = photoIndex + 2;
+ const photo0 = photos[idx0];
+ const photo1 = photos[idx1];
+ const photo2 = photos[idx2];
+
+ elements.push(
+
+ {photo0 && (
+
handlePhotoClick(idx0, photo0.id)}
+ onDownload={(e) => onDownload(photo0, e)}
+ className="col-span-1"
+ />
+ )}
+
+ {photo1 && (
+ handlePhotoClick(idx1, photo1.id)}
+ onDownload={(e) => onDownload(photo1, e)}
+ className=""
+ />
+ )}
+ {photo2 && (
+ handlePhotoClick(idx2, photo2.id)}
+ onDownload={(e) => onDownload(photo2, e)}
+ className=""
+ />
+ )}
+
+
+ );
+ photoIndex += 3;
+ } else if (patternIndex % 3 === 1 && remainingPhotos >= 3) {
+ // Pattern 2: 3 equal columns
+ elements.push(
+
+ {[0, 1, 2].map(offset => {
+ const currentIndex = photoIndex + offset;
+ const photo = photos[currentIndex];
+ return photo ? (
+ handlePhotoClick(currentIndex, photo.id)}
+ onDownload={(e) => onDownload(photo, e)}
+ className=""
+ />
+ ) : null;
+ })}
+
+ );
+ photoIndex += 3;
+ } else if (patternIndex % 3 === 2 && remainingPhotos >= 3) {
+ // Pattern 3: Large span-2 with 2 small on right
+ // Capture indices immediately to avoid closure issues
+ const idx0 = photoIndex;
+ const idx1 = photoIndex + 1;
+ const idx2 = photoIndex + 2;
+ const photo0 = photos[idx0];
+ const photo1 = photos[idx1];
+ const photo2 = photos[idx2];
+
+ elements.push(
+
+ {photo0 && (
+
handlePhotoClick(idx0, photo0.id)}
+ onDownload={(e) => onDownload(photo0, e)}
+ className="col-span-2"
+ />
+ )}
+
+ {photo1 && (
+ handlePhotoClick(idx1, photo1.id)}
+ onDownload={(e) => onDownload(photo1, e)}
+ className=""
+ />
+ )}
+ {photo2 && (
+ handlePhotoClick(idx2, photo2.id)}
+ onDownload={(e) => onDownload(photo2, e)}
+ className=""
+ />
+ )}
+
+
+ );
+ photoIndex += 3;
+ } else {
+ // Handle remaining photos that don't fit patterns
+ break;
+ }
+
+ patternIndex++;
+ }
+
+ // Add remaining photos in a regular grid
+ if (photoIndex < photos.length) {
+ const remainingPhotos = photos.slice(photoIndex);
+ elements.push(
+
{remainingPhotos.map((photo, idx) => {
- const index = idx + 9;
+ const index = photoIndex + idx;
return (
= ({
);
})}
- )}
+ );
+ }
+
+ return elements;
+ };
+
+ return (
+
+ {renderMosaicLayout()}
);
};
\ No newline at end of file
diff --git a/frontend/src/contexts/ThemeContext.tsx b/frontend/src/contexts/ThemeContext.tsx
index 04b9093..71fd258 100644
--- a/frontend/src/contexts/ThemeContext.tsx
+++ b/frontend/src/contexts/ThemeContext.tsx
@@ -170,13 +170,17 @@ export const ThemeProvider: React.FC
= ({
}
}, []);
- // Save theme to localStorage when it changes
+ // Save theme to localStorage when it changes (but not in gallery views)
useEffect(() => {
- // Only save if theme has actually changed
- const currentSaved = localStorage.getItem('gallery-theme');
- const newValue = JSON.stringify({ name: themeName, config: theme });
- if (currentSaved !== newValue) {
- localStorage.setItem('gallery-theme', newValue);
+ // Don't save theme in gallery views to avoid conflicts
+ const isGalleryView = window.location.pathname.includes('/gallery/');
+ if (!isGalleryView) {
+ // Only save if theme has actually changed
+ const currentSaved = localStorage.getItem('gallery-theme');
+ const newValue = JSON.stringify({ name: themeName, config: theme });
+ if (currentSaved !== newValue) {
+ localStorage.setItem('gallery-theme', newValue);
+ }
}
}, [theme, themeName]);
diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts
index 62e44dc..de0d183 100644
--- a/frontend/src/hooks/index.ts
+++ b/frontend/src/hooks/index.ts
@@ -1,3 +1,4 @@
export * from './useSessionTimeout';
export * from './useOnClickOutside';
-export * from './useLocalizedDate';
\ No newline at end of file
+export * from './useLocalizedDate';
+export * from './useLocalizedTimeAgo';
\ No newline at end of file
diff --git a/frontend/src/hooks/useLocalizedTimeAgo.ts b/frontend/src/hooks/useLocalizedTimeAgo.ts
new file mode 100644
index 0000000..7e79872
--- /dev/null
+++ b/frontend/src/hooks/useLocalizedTimeAgo.ts
@@ -0,0 +1,72 @@
+import { useTranslation } from 'react-i18next';
+
+export const useLocalizedTimeAgo = () => {
+ const { i18n } = useTranslation();
+
+ const formatTimeAgo = (date: Date | string): string => {
+ const dateObj = typeof date === 'string' ? new Date(date) : date;
+ const now = new Date();
+ const seconds = Math.floor((now.getTime() - dateObj.getTime()) / 1000);
+
+ const isGerman = i18n.language === 'de';
+
+ // Less than a minute
+ if (seconds < 60) {
+ return isGerman ? 'gerade eben' : 'just now';
+ }
+
+ // Minutes
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) {
+ if (minutes === 1) {
+ return isGerman ? 'vor 1 Minute' : '1 minute ago';
+ }
+ return isGerman ? `vor ${minutes} Minuten` : `${minutes} minutes ago`;
+ }
+
+ // Hours
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) {
+ if (hours === 1) {
+ return isGerman ? 'vor 1 Stunde' : '1 hour ago';
+ }
+ return isGerman ? `vor ${hours} Stunden` : `${hours} hours ago`;
+ }
+
+ // Days
+ const days = Math.floor(hours / 24);
+ if (days < 7) {
+ if (days === 1) {
+ return isGerman ? 'vor 1 Tag' : '1 day ago';
+ }
+ return isGerman ? `vor ${days} Tagen` : `${days} days ago`;
+ }
+
+ // Weeks
+ const weeks = Math.floor(days / 7);
+ if (weeks < 4) {
+ if (weeks === 1) {
+ return isGerman ? 'vor 1 Woche' : '1 week ago';
+ }
+ return isGerman ? `vor ${weeks} Wochen` : `${weeks} weeks ago`;
+ }
+
+ // Months
+ const months = Math.floor(days / 30);
+ if (months < 12) {
+ if (months === 1) {
+ return isGerman ? 'vor 1 Monat' : '1 month ago';
+ }
+ return isGerman ? `vor ${months} Monaten` : `${months} months ago`;
+ }
+
+ // Years
+ const years = Math.floor(days / 365);
+ if (years === 1) {
+ return isGerman ? 'vor 1 Jahr' : '1 year ago';
+ }
+ return isGerman ? `vor ${years} Jahren` : `${years} years ago`;
+ };
+
+ return { formatTimeAgo };
+};
\ No newline at end of file
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index c1124db..bf8da7a 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -17,6 +17,9 @@
"previous": "Zurück",
"close": "Schließen",
"logout": "Abmelden",
+ "menu": "Menü",
+ "change": "Ändern",
+ "remove": "Entfernen",
"download": "Herunterladen",
"downloadAll": "Alle herunterladen",
"uploading": "Wird hochgeladen...",
@@ -150,12 +153,30 @@
"deselectAll": "Auswahl aufheben",
"downloadSelected": "{{count}} ausgewählte herunterladen",
"remaining": "verbleibend",
- "selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen"
+ "selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen",
+ "filters": "Filter",
+ "openFilters": "Filter öffnen",
+ "toggleSidebar": "Seitenleiste umschalten",
+ "toggleMenu": "Menü umschalten",
+ "allCategories": "Alle Kategorien",
+ "categories": "Kategorien",
+ "download": "Herunterladen",
+ "searchPlaceholder": "Fotos suchen...",
+ "sortBy": "Sortieren nach"
},
"categories": {
"title": "Fotokategorien",
"global": "Globale Kategorien",
"eventSpecific": "Veranstaltungsspezifische Kategorien",
+ "eventSpecificCategories": "Veranstaltungsspezifische Kategorien",
+ "organizationInfo": "Organisieren Sie Ihre Fotos in Kategorien. Kategorien helfen Gästen, bestimmte Fototypen zu navigieren und zu finden.",
+ "noEventSpecificCategories": "Keine veranstaltungsspezifischen Kategorien. Globale Kategorien sind standardmäßig verfügbar.",
+ "globalCategoriesAlwaysAvailable": "Globale Kategorien (immer verfügbar):",
+ "deleteCategoryTitle": "Kategorie löschen",
+ "categoryCreatedSuccess": "Kategorie erfolgreich erstellt",
+ "categoryDeletedSuccess": "Kategorie erfolgreich gelöscht",
+ "failedToCreateCategory": "Fehler beim Erstellen der Kategorie",
+ "failedToDeleteCategory": "Fehler beim Löschen der Kategorie",
"addCategory": "Kategorie hinzufügen",
"categoryName": "Kategoriename",
"noCategory": "Keine Kategorie",
@@ -164,6 +185,17 @@
"cannotDelete": "Kategorie mit Fotos kann nicht gelöscht werden. Bitte weisen Sie die Fotos zuerst neu zu."
},
"events": {
+ "noStatisticsAvailableYet": "Noch keine Statistiken verfügbar",
+ "totalPhotos": "Gesamtfotos",
+ "totalViews": "Gesamtaufrufe",
+ "totalDownloads": "Gesamte Downloads",
+ "uniqueVisitors": "Eindeutige Besucher",
+ "addPlus": "Hinzufügen+",
+ "galleryTheme": "Galerie-Design",
+ "customizeTheme": "Design anpassen",
+ "noThemeSet": "Kein Design konfiguriert",
+ "customizingTheme": "Galerie-Design anpassen",
+ "customizingThemeFor": "Design für {{event}} anpassen",
"title": "Veranstaltungen",
"create": "Veranstaltung erstellen",
"createEvent": "Veranstaltung erstellen",
@@ -172,6 +204,8 @@
"eventType": "Veranstaltungstyp",
"eventDate": "Veranstaltungsdatum",
"hostEmail": "Gastgeber-E-Mail",
+ "hostName": "Name des Gastgebers",
+ "hostNamePlaceholder": "Max Mustermann",
"adminEmail": "Admin-E-Mail",
"expirationDate": "Ablaufdatum",
"active": "Aktiv",
@@ -199,6 +233,7 @@
"eventInformation": "Veranstaltungsinformationen",
"welcomeMessage": "Willkommensnachricht",
"noWelcomeMessage": "Keine Willkommensnachricht festgelegt",
+ "noWelcomeMessageSet": "Keine Willkommensnachricht festgelegt",
"created": "Erstellt",
"expires": "Läuft ab",
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
@@ -232,6 +267,7 @@
"galleryExpiration": "Galerie-Ablauf",
"galleryExpiresIn": "Galerie läuft ab in",
"daysAfterEvent": "Tage nach der Veranstaltung",
+ "expiresOn": "Läuft ab am",
"themeAndStyle": "Design & Stil",
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
"expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.",
@@ -340,7 +376,10 @@
"defaultLanguage": "Standardsprache",
"defaultLanguageHelp": "Sprache, die Gästen vor der Anmeldung angezeigt wird",
"saveSettings": "Allgemeine Einstellungen speichern",
- "saveGeneralSettings": "Allgemeine Einstellungen speichern"
+ "saveGeneralSettings": "Allgemeine Einstellungen speichern",
+ "dateTimeFormat": "Datums- & Zeitformat",
+ "dateFormat": "Datumsformat",
+ "dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden"
},
"storage": {
"title": "Speicher",
@@ -467,7 +506,80 @@
"saveChanges": "Änderungen speichern",
"applyLivePreview": "Änderungen sofort anwenden (Live-Vorschau)",
"eventSpecificThemes": "Veranstaltungsspezifische Themen",
- "eventThemesInfo": "Sie können diese globalen Theme-Einstellungen für einzelne Veranstaltungen beim Erstellen oder Bearbeiten überschreiben."
+ "eventThemesInfo": "Sie können diese globalen Theme-Einstellungen für einzelne Veranstaltungen beim Erstellen oder Bearbeiten überschreiben.",
+ "themePresets": "Theme-Vorlagen",
+ "galleryLayout": "Galerie-Layout",
+ "layoutDescriptions": {
+ "grid": "Klassisches Rasterlayout mit einheitlichen Fotogrößen",
+ "masonry": "Pinterest-ähnliches Layout mit variablen Höhen",
+ "carousel": "Vollbild-Diashow mit Navigation",
+ "timeline": "Nach Datum organisierte Fotos",
+ "hero": "Hervorgehobenes Bild mit Raster darunter",
+ "mosaic": "Künstlerisches Layout mit gemischten Größen"
+ },
+ "layoutSettings": "Layout-Einstellungen",
+ "photoSpacing": "Foto-Abstand",
+ "spacing": {
+ "tight": "Eng",
+ "normal": "Normal",
+ "relaxed": "Locker"
+ },
+ "photoAnimation": "Foto-Animation",
+ "animation": {
+ "none": "Keine",
+ "fade": "Einblenden",
+ "scale": "Skalieren",
+ "slide": "Gleiten"
+ },
+ "columns": "Spalten",
+ "mobile": "Mobil",
+ "tablet": "Tablet",
+ "desktop": "Desktop",
+ "enableAutoplay": "Autoplay aktivieren",
+ "autoplayInterval": "Autoplay-Intervall (Sekunden)",
+ "groupPhotosBy": "Fotos gruppieren nach",
+ "grouping": {
+ "day": "Tag",
+ "week": "Woche",
+ "month": "Monat"
+ },
+ "typographyAndStyle": "Typografie & Stil",
+ "bodyFont": "Fließtext-Schriftart",
+ "headingFont": "Überschriften-Schriftart",
+ "sameAsBody": "Wie Fließtext",
+ "fontSize": "Schriftgröße",
+ "fontSizes": {
+ "small": "Klein",
+ "normal": "Normal",
+ "large": "Groß"
+ },
+ "borderRadius": "Eckenradius",
+ "borderRadiusOptions": {
+ "none": "Keine",
+ "small": "Klein",
+ "medium": "Mittel",
+ "large": "Groß"
+ },
+ "shadowStyle": "Schattenstil",
+ "shadowOptions": {
+ "none": "Kein",
+ "subtle": "Dezent",
+ "normal": "Normal",
+ "dramatic": "Dramatisch"
+ },
+ "backgroundPattern": "Hintergrund",
+ "backgroundOptions": {
+ "none": "Keiner",
+ "dots": "Punkte",
+ "grid": "Raster",
+ "waves": "Wellen"
+ },
+ "customCSSHelp": "Erweitert: Fügen Sie benutzerdefiniertes CSS hinzu, um das Erscheinungsbild weiter anzupassen",
+ "resetToDefault": "Auf Standard zurücksetzen",
+ "applyTheme": "Theme anwenden",
+ "customTheme": "Benutzerdefiniertes Design",
+ "customizeTheme": "Design anpassen",
+ "saveTheme": "Design speichern"
},
"admin": {
"title": "Admin-Panel",
@@ -483,6 +595,48 @@
"notifications": "Benachrichtigungen",
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
"noNotifications": "Keine neuen Benachrichtigungen",
+ "markAllRead": "Alle als gelesen markieren",
+ "clearOld": "Alte löschen",
+ "close": "Schließen",
+ "noNotificationsMessage": "Keine Benachrichtigungen",
+ "notificationMessages": {
+ "eventCreated": "Neues Event \"{{eventName}}\" wurde erstellt",
+ "eventArchived": "Event \"{{eventName}}\" wurde archiviert",
+ "eventUpdated": "Event \"{{eventName}}\" wurde aktualisiert",
+ "eventDeleted": "Event \"{{eventName}}\" wurde gelöscht",
+ "photosUploaded": "{{count}} Fotos zu \"{{eventName}}\" hochgeladen",
+ "photoDeleted": "Foto aus \"{{eventName}}\" gelöscht",
+ "photosBulkDeleted": "{{count}} Fotos aus \"{{eventName}}\" gelöscht",
+ "eventExpiring": "Event \"{{eventName}}\" läuft in {{days}} Tagen ab",
+ "eventExpired": "Event \"{{eventName}}\" ist abgelaufen",
+ "passwordChanged": "Passwort geändert von {{actorName}}",
+ "passwordReset": "Passwort zurückgesetzt für \"{{eventName}}\"",
+ "settingsUpdated": "{{type}}-Einstellungen aktualisiert",
+ "emailTemplateUpdated": "E-Mail-Vorlage \"{{template}}\" aktualisiert",
+ "bulkDownload": "{{count}} Fotos von \"{{eventName}}\" heruntergeladen",
+ "storageWarning": "Speichernutzung bei {{percentage}}%",
+ "adminLogout": "Admin {{actorName}} hat sich abgemeldet",
+ "categoryCreated": "Kategorie \"{{name}}\" für \"{{eventName}}\" erstellt",
+ "categoryUpdated": "Kategorie \"{{name}}\" für \"{{eventName}}\" aktualisiert",
+ "categoryDeleted": "Kategorie \"{{name}}\" aus \"{{eventName}}\" gelöscht",
+ "cmsPageUpdated": "CMS-Seite \"{{slug}}\" aktualisiert",
+ "emailConfigUpdated": "E-Mail-Konfiguration aktualisiert",
+ "faviconUploaded": "Favicon hochgeladen",
+ "brandingUpdated": "Branding-Einstellungen aktualisiert",
+ "generalSettingsUpdated": "Allgemeine Einstellungen aktualisiert",
+ "securitySettingsUpdated": "Sicherheitseinstellungen aktualisiert",
+ "themeUpdated": "Theme-Einstellungen aktualisiert",
+ "archiveDownloaded": "Archiv für \"{{eventName}}\" heruntergeladen",
+ "archiveDeleted": "Archiv für \"{{eventName}}\" gelöscht",
+ "archiveRestored": "Archiv für \"{{eventName}}\" wiederhergestellt",
+ "systemActivity": "Systemaktivität: {{type}}"
+ },
+ "notificationToasts": {
+ "markedAllRead": "Alle Benachrichtigungen als gelesen markiert",
+ "clearedOld": "{{count}} alte Benachrichtigungen gelöscht"
+ },
+ "viewAllNotifications": "Alle Benachrichtigungen anzeigen",
+ "noNotifications": "Keine neuen Benachrichtigungen",
"markAsRead": "Als gelesen markieren",
"markAllAsRead": "Alle als gelesen markieren",
"notificationSettings": "Benachrichtigungseinstellungen",
@@ -663,6 +817,7 @@
"validation": {
"eventNameRequired": "Veranstaltungsname ist erforderlich",
"hostEmailRequired": "Gastgeber-E-Mail ist erforderlich",
+ "hostNameRequired": "Der Name des Gastgebers ist erforderlich",
"adminEmailRequired": "Admin-E-Mail ist erforderlich",
"invalidEmailFormat": "Ungültiges E-Mail-Format",
"passwordRequired": "Passwort ist erforderlich",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 3d4583f..ee64daf 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -17,6 +17,9 @@
"previous": "Previous",
"close": "Close",
"logout": "Logout",
+ "menu": "Menu",
+ "change": "Change",
+ "remove": "Remove",
"download": "Download",
"downloadAll": "Download All",
"uploading": "Uploading...",
@@ -150,13 +153,30 @@
"deselectAll": "Deselect All",
"downloadSelected": "Download {{count}} Selected",
"remaining": "remaining",
- "selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos"
+ "selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos",
+ "filters": "Filters",
+ "openFilters": "Open filters",
+ "toggleSidebar": "Toggle sidebar",
+ "toggleMenu": "Toggle menu",
+ "allCategories": "All Categories",
+ "categories": "Categories",
+ "download": "Download",
+ "searchPlaceholder": "Search photos..."
},
"categories": {
"title": "Photo Categories",
"global": "Global Categories",
"eventSpecific": "Event-Specific Categories",
"addCategory": "Add Category",
+ "organizationInfo": "Organize your photos into categories. Categories help guests navigate and find specific types of photos.",
+ "eventSpecificCategories": "Event-Specific Categories",
+ "noEventSpecificCategories": "No event-specific categories. Global categories are available by default.",
+ "globalCategoriesAlwaysAvailable": "Global Categories (always available):",
+ "deleteCategoryTitle": "Delete category",
+ "categoryCreatedSuccess": "Category created successfully",
+ "categoryDeletedSuccess": "Category deleted successfully",
+ "failedToCreateCategory": "Failed to create category",
+ "failedToDeleteCategory": "Failed to delete category",
"categoryName": "Category name",
"noCategory": "No category",
"noCategoriesYet": "No categories yet. Create your first category to organize photos.",
@@ -166,6 +186,9 @@
"events": {
"title": "Events",
"createEvent": "Create Event",
+ "totalViews": "Total Views",
+ "totalDownloads": "Total Downloads",
+ "uniqueVisitors": "Unique Visitors",
"createNewEvent": "Create New Event",
"setupNewGallery": "Set up a new photo gallery for your event",
"createNewEventSubtitle": "Set up a new photo gallery for your event",
@@ -197,6 +220,8 @@
"eventType": "Event Type",
"eventDate": "Event Date",
"hostEmail": "Host Email",
+ "hostName": "Host Name",
+ "hostNamePlaceholder": "John Smith",
"adminEmail": "Admin Email",
"adminNotificationEmail": "Admin Notification Email",
"expirationDate": "Expiration Date",
@@ -230,6 +255,7 @@
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
"resetGalleryPassword": "Reset Gallery Password",
"photoStatistics": "Photo Statistics",
+ "totalPhotos": "Total Photos",
"managePhotos": "Manage Photos",
"actions": "Actions",
"archivingInfo": "Archiving will create a ZIP file of all photos and remove the gallery from public access.",
@@ -264,6 +290,12 @@
"selectCategory": "Select a category for user uploads",
"uploadCategoryHelp": "All user-uploaded photos will be added to this category",
"userUploadWarning": "User uploads will be moderated and can be removed by admins at any time.",
+ "heroPhoto": "Hero Photo",
+ "heroPhotoHelp": "Select a featured photo for the hero gallery layout",
+ "selectHeroPhoto": "Select Hero Photo",
+ "noHeroPhotoSelected": "No hero photo selected",
+ "heroPhotoSelected": "Hero photo selected",
+ "noPhotosAvailable": "No photos available",
"processingRequest": "Processing your request...",
"eventTypeWedding": "Wedding",
"eventTypeBirthday": "Birthday",
@@ -317,8 +349,8 @@
"adminEmail": "Admin Email",
"createdOn": "Created",
"expires": "Expires",
- "daysLeft": "({{days}} days left)",
- "daysLeft_plural": "({{days}} days left)",
+ "daysLeft": "({{count}} day left)",
+ "daysLeft_plural": "({{count}} days left)",
"shareLink": "Share Link",
"copy": "Copy",
"copied": "Copied!",
@@ -332,7 +364,14 @@
"downloadStarted": "Download started",
"failedToDownloadArchive": "Failed to download archive",
"statisticsNotAvailable": "No statistics available yet",
- "photoFilters": "Photo Filters"
+ "photoFilters": "Photo Filters",
+ "noStatisticsAvailableYet": "No statistics available yet",
+ "addPlus": "Add+",
+ "galleryTheme": "Gallery Theme",
+ "customizeTheme": "Customize Theme",
+ "noThemeSet": "No theme configured",
+ "customizingTheme": "Customizing gallery theme",
+ "customizingThemeFor": "Customizing theme for {{event}}"
},
"settings": {
"title": "System Settings",
@@ -358,7 +397,10 @@
"defaultLanguage": "Default Language",
"defaultLanguageHelp": "Language shown to guests before login",
"saveSettings": "Save General Settings",
- "saveGeneralSettings": "Save General Settings"
+ "saveGeneralSettings": "Save General Settings",
+ "dateTimeFormat": "Date & Time Format",
+ "dateFormat": "Date Format",
+ "dateFormatHelp": "How dates are displayed in emails and throughout the application"
},
"storage": {
"title": "Storage",
@@ -519,7 +561,80 @@
"saveChanges": "Save Changes",
"applyLivePreview": "Apply changes immediately (Live Preview)",
"eventSpecificThemes": "Event-Specific Themes",
- "eventThemesInfo": "You can override these global theme settings for individual events when creating or editing them."
+ "eventThemesInfo": "You can override these global theme settings for individual events when creating or editing them.",
+ "themePresets": "Theme Presets",
+ "galleryLayout": "Gallery Layout",
+ "layoutDescriptions": {
+ "grid": "Classic grid layout with consistent photo sizes",
+ "masonry": "Pinterest-style layout with varied heights",
+ "carousel": "Full-screen slideshow with navigation",
+ "timeline": "Photos organized by date",
+ "hero": "Featured image with grid below",
+ "mosaic": "Artistic layout with mixed sizes"
+ },
+ "layoutSettings": "Layout Settings",
+ "photoSpacing": "Photo Spacing",
+ "spacing": {
+ "tight": "Tight",
+ "normal": "Normal",
+ "relaxed": "Relaxed"
+ },
+ "photoAnimation": "Photo Animation",
+ "animation": {
+ "none": "None",
+ "fade": "Fade",
+ "scale": "Scale",
+ "slide": "Slide"
+ },
+ "columns": "Columns",
+ "mobile": "Mobile",
+ "tablet": "Tablet",
+ "desktop": "Desktop",
+ "enableAutoplay": "Enable Autoplay",
+ "autoplayInterval": "Autoplay Interval (seconds)",
+ "groupPhotosBy": "Group Photos By",
+ "grouping": {
+ "day": "Day",
+ "week": "Week",
+ "month": "Month"
+ },
+ "typographyAndStyle": "Typography & Style",
+ "bodyFont": "Body Font",
+ "headingFont": "Heading Font",
+ "sameAsBody": "Same as body",
+ "fontSize": "Font Size",
+ "fontSizes": {
+ "small": "Small",
+ "normal": "Normal",
+ "large": "Large"
+ },
+ "borderRadius": "Border Radius",
+ "borderRadiusOptions": {
+ "none": "None",
+ "small": "Small",
+ "medium": "Medium",
+ "large": "Large"
+ },
+ "shadowStyle": "Shadow Style",
+ "shadowOptions": {
+ "none": "None",
+ "subtle": "Subtle",
+ "normal": "Normal",
+ "dramatic": "Dramatic"
+ },
+ "backgroundPattern": "Background",
+ "backgroundOptions": {
+ "none": "None",
+ "dots": "Dots",
+ "grid": "Grid",
+ "waves": "Waves"
+ },
+ "customCSSHelp": "Advanced: Add custom CSS to further customize the appearance",
+ "resetToDefault": "Reset to Default",
+ "applyTheme": "Apply Theme",
+ "customTheme": "Custom Theme",
+ "customizeTheme": "Customize Theme",
+ "saveTheme": "Save Theme"
},
"admin": {
"title": "Admin Panel",
@@ -535,6 +650,46 @@
"notifications": "Notifications",
"viewAllNotifications": "View all notifications",
"noNotifications": "No new notifications",
+ "markAllRead": "Mark all read",
+ "clearOld": "Clear old",
+ "close": "Close",
+ "noNotificationsMessage": "No notifications",
+ "notificationMessages": {
+ "eventCreated": "New event \"{{eventName}}\" was created",
+ "eventArchived": "Event \"{{eventName}}\" was archived",
+ "eventUpdated": "Event \"{{eventName}}\" was updated",
+ "eventDeleted": "Event \"{{eventName}}\" was deleted",
+ "photosUploaded": "{{count}} photos uploaded to \"{{eventName}}\"",
+ "photoDeleted": "Photo deleted from \"{{eventName}}\"",
+ "photosBulkDeleted": "{{count}} photos deleted from \"{{eventName}}\"",
+ "eventExpiring": "Event \"{{eventName}}\" expires in {{days}} days",
+ "eventExpired": "Event \"{{eventName}}\" has expired",
+ "passwordChanged": "Password changed by {{actorName}}",
+ "passwordReset": "Password reset for \"{{eventName}}\"",
+ "settingsUpdated": "{{type}} settings updated",
+ "emailTemplateUpdated": "Email template \"{{template}}\" updated",
+ "bulkDownload": "{{count}} photos downloaded from \"{{eventName}}\"",
+ "storageWarning": "Storage usage at {{percentage}}%",
+ "adminLogout": "Admin {{actorName}} logged out",
+ "categoryCreated": "Category \"{{name}}\" created for \"{{eventName}}\"",
+ "categoryUpdated": "Category \"{{name}}\" updated for \"{{eventName}}\"",
+ "categoryDeleted": "Category \"{{name}}\" deleted from \"{{eventName}}\"",
+ "cmsPageUpdated": "CMS page \"{{slug}}\" updated",
+ "emailConfigUpdated": "Email configuration updated",
+ "faviconUploaded": "Favicon uploaded",
+ "brandingUpdated": "Branding settings updated",
+ "generalSettingsUpdated": "General settings updated",
+ "securitySettingsUpdated": "Security settings updated",
+ "themeUpdated": "Theme settings updated",
+ "archiveDownloaded": "Archive downloaded for \"{{eventName}}\"",
+ "archiveDeleted": "Archive deleted for \"{{eventName}}\"",
+ "archiveRestored": "Archive restored for \"{{eventName}}\"",
+ "systemActivity": "System activity: {{type}}"
+ },
+ "notificationToasts": {
+ "markedAllRead": "All notifications marked as read",
+ "clearedOld": "Cleared {{count}} old notifications"
+ },
"markAsRead": "Mark as read",
"markAllAsRead": "Mark all as read",
"notificationSettings": "Notification Settings",
@@ -584,6 +739,7 @@
"validation": {
"eventNameRequired": "Event name is required",
"hostEmailRequired": "Host email is required",
+ "hostNameRequired": "Host name is required",
"adminEmailRequired": "Admin email is required",
"invalidEmailFormat": "Invalid email format",
"passwordRequired": "Password is required",
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 6856725..077ba35 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,4 +1,4 @@
-@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans:wght@300;400;500;600;700&display=swap');
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Noto+Sans:wght@300;400;500;600;700&family=Poppins:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx
index c1397ac..80471e0 100644
--- a/frontend/src/pages/GalleryPage.tsx
+++ b/frontend/src/pages/GalleryPage.tsx
@@ -1,23 +1,25 @@
import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
-import { Camera, Calendar, AlertCircle, Clock } from 'lucide-react';
+import { Calendar, AlertCircle, Clock } from 'lucide-react';
import { differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../hooks/useLocalizedDate';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, Input, Button, Loading, ReCaptcha } from '../components/common';
-import { useGalleryAuth } from '../contexts';
+import { useGalleryAuth, useTheme } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery';
import { analyticsService } from '../services/analytics.service';
import { api } from '../config/api';
+import { GALLERY_THEME_PRESETS } from '../types/theme.types';
export const GalleryPage: React.FC = () => {
const { slug, token } = useParams<{ slug: string; token?: string }>();
const { isAuthenticated, login, event } = useGalleryAuth();
const { t, i18n } = useTranslation();
const { format } = useLocalizedDate();
+ const { setTheme } = useTheme();
const [password, setPassword] = useState('');
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState(null);
@@ -43,6 +45,47 @@ export const GalleryPage: React.FC = () => {
}
}, [settingsData, isAuthenticated, i18n]);
+ // Apply theme for login page
+ React.useEffect(() => {
+ if (!isAuthenticated && galleryInfo && settingsData) {
+ let themeToApply = null;
+
+ if (galleryInfo.color_theme) {
+ try {
+ // Check if it's a valid JSON string
+ if (galleryInfo.color_theme.startsWith('{')) {
+ themeToApply = JSON.parse(galleryInfo.color_theme);
+ } else {
+ // Handle legacy theme names - check if it's a preset
+ const preset = GALLERY_THEME_PRESETS[galleryInfo.color_theme];
+ if (preset) {
+ themeToApply = preset.config;
+ } else {
+ // Unknown theme name, fall back to 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
+ if (themeToApply) {
+ setTheme(themeToApply);
+ }
+ }
+ }, [galleryInfo, settingsData, isAuthenticated, setTheme]);
+
// Calculate days until expiration
const daysUntilExpiration = galleryInfo
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
@@ -159,6 +202,9 @@ export const GalleryPage: React.FC = () => {
{t('legal.datenschutz')}
+
+ Powered by PicPeak
+
@@ -213,6 +259,9 @@ export const GalleryPage: React.FC = () => {
{t('legal.datenschutz')}
+
+ Powered by PicPeak
+
@@ -231,17 +280,14 @@ export const GalleryPage: React.FC = () => {
{/* Logo/Header */}
- {settingsData?.branding_logo_url ? (
-
- ) : (
-
-
-
- )}
+
{galleryInfo?.event_name}
@@ -325,6 +371,9 @@ export const GalleryPage: React.FC = () => {
{t('legal.datenschutz')}
+
+ Powered by PicPeak
+