Fix mobile responsiveness and implement enhanced theme system

- Fixed mobile gallery login box sizing and layout
- Fixed header button layout for mobile screens
- Fixed duplicate logo issue on logout
- Fixed '0' rendering when upload button is hidden
- Fixed horizontal scrolling on small screens

- Implemented comprehensive theme system with gallery layouts
- Added 6 different gallery layouts: Grid, Masonry, Carousel, Timeline, Hero, Mosaic
- Created enhanced theme customizer with layout selection
- Added theme presets for different event types
- Updated event creation with theme preview and customization
- Fixed all TypeScript compilation errors

- Added missing translation keys for create event page
- Added translations for theme customization features

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

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
2025-07-09 11:26:27 +02:00
co-authored by Claude
parent d8fb4c9565
commit 6438374258
23 changed files with 2798 additions and 168 deletions
@@ -0,0 +1,238 @@
import React, { useState, useEffect } from 'react';
import { Package } from 'lucide-react';
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 { galleryService } from '../../services/gallery.service';
import { analyticsService } from '../../services/analytics.service';
import { useTheme } from '../../contexts/ThemeContext';
// Import all layouts
import {
GridGalleryLayout,
MasonryGalleryLayout,
CarouselGalleryLayout,
TimelineGalleryLayout,
HeroGalleryLayout,
MosaicGalleryLayout,
} from './layouts';
interface PhotoGridWithLayoutsProps {
photos: Photo[];
slug: string;
categoryId?: number | null;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
photos,
slug,
categoryId
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const downloadPhotoMutation = useDownloadPhoto();
// Clear selection when category changes
useEffect(() => {
setSelectedPhotos(new Set());
}, [categoryId]);
const handlePhotoClick = (index: number) => {
setSelectedPhotoIndex(index);
};
const handlePhotoSelect = (photoId: number) => {
const newSelected = new Set(selectedPhotos);
if (newSelected.has(photoId)) {
newSelected.delete(photoId);
} else {
newSelected.add(photoId);
}
setSelectedPhotos(newSelected);
};
const handleDownload = (photo: Photo, e: React.MouseEvent) => {
e.stopPropagation();
// Track individual photo download
analyticsService.trackDownload(photo.id, slug, false);
downloadPhotoMutation.mutate({
slug,
photoId: photo.id,
filename: photo.filename,
});
};
const toggleSelectionMode = () => {
setIsSelectionMode(!isSelectionMode);
setSelectedPhotos(new Set());
};
const selectAll = () => {
setSelectedPhotos(new Set(photos.map(p => p.id)));
};
const deselectAll = () => {
setSelectedPhotos(new Set());
};
const handleDownloadSelected = async () => {
if (selectedPhotos.size === 0) return;
const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id));
toastify.info(t('gallery.downloading', { count: selectedPhotos.size }));
// Download each selected photo
const downloadPromises = selectedPhotosList.map(photo =>
galleryService.downloadPhoto(slug, photo.id, photo.filename)
.catch(err => {
console.error(`Failed to download ${photo.filename}:`, err);
return null;
})
);
try {
await Promise.all(downloadPromises);
toastify.success(t('gallery.downloadedPhotos', { count: selectedPhotos.size }));
// Track bulk download
analyticsService.trackGalleryEvent('bulk_download', {
gallery: slug,
photo_count: selectedPhotos.size
});
// Clear selection after download
setSelectedPhotos(new Set());
setIsSelectionMode(false);
} catch (error) {
toastify.error(t('gallery.downloadError'));
}
};
if (photos.length === 0) {
return (
<div className="text-center py-12">
<p className="text-neutral-600">{t('gallery.noPhotosFound')}</p>
</div>
);
}
// Get the current layout from theme
const galleryLayout = theme.galleryLayout || 'grid';
// Select the appropriate layout component
const layoutProps = {
photos,
slug,
onPhotoClick: handlePhotoClick,
onDownload: handleDownload,
selectedPhotos,
isSelectionMode,
onPhotoSelect: handlePhotoSelect,
};
let LayoutComponent;
switch (galleryLayout) {
case 'masonry':
LayoutComponent = MasonryGalleryLayout;
break;
case 'carousel':
LayoutComponent = CarouselGalleryLayout;
break;
case 'timeline':
LayoutComponent = TimelineGalleryLayout;
break;
case 'hero':
LayoutComponent = HeroGalleryLayout;
break;
case 'mosaic':
LayoutComponent = MosaicGalleryLayout;
break;
default:
LayoutComponent = GridGalleryLayout;
}
return (
<>
{/* Selection Mode Controls - Not shown for carousel layout */}
{photos.length > 1 && galleryLayout !== 'carousel' && (
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleSelectionMode}
title={t('gallery.selectPhotosHint')}
className="text-xs sm:text-sm"
>
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
</Button>
{!isSelectionMode && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setIsSelectionMode(true);
selectAll();
}}
className="text-xs sm:text-sm"
>
{t('gallery.selectAll')}
</Button>
)}
</div>
{isSelectionMode && (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-neutral-600">
{t('gallery.photosSelected', { count: selectedPhotos.size })}
</span>
<div className="flex items-center gap-2 flex-wrap">
<Button variant="ghost" size="sm" onClick={selectAll} className="text-xs sm:text-sm">
{t('gallery.selectAll')}
</Button>
<Button variant="ghost" size="sm" onClick={deselectAll} className="text-xs sm:text-sm">
{t('gallery.deselectAll')}
</Button>
{selectedPhotos.size > 0 && (
<Button
variant="primary"
size="sm"
leftIcon={<Package className="w-4 h-4" />}
onClick={handleDownloadSelected}
className="text-xs sm:text-sm"
>
<span className="hidden sm:inline">{t('gallery.downloadSelected', { count: selectedPhotos.size })}</span>
<span className="sm:hidden">{t('common.download')} ({selectedPhotos.size})</span>
</Button>
)}
</div>
</div>
)}
</div>
)}
{/* Render the selected layout */}
<LayoutComponent {...layoutProps} />
{/* Lightbox */}
{selectedPhotoIndex !== null && (
<PhotoLightbox
photos={photos}
initialIndex={selectedPhotoIndex}
onClose={() => setSelectedPhotoIndex(null)}
slug={slug}
/>
)}
</>
);
};