Files
picpeak/frontend/src/components/gallery/PhotoGridWithLayouts.tsx
T
paul 81c55261d6
Mirror to GitHub / mirror (push) Successful in 43s
Test and Lint / backend-test (push) Successful in 1m45s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 44s
Version and Release / trigger-drone (push) Successful in 3s
feat: add feedback management enhancements
- Add German translations for event dropdown menu actions
- Add feedback settings to event edit form
- Hide comment button in gallery when feedback is disabled
- Add feedback moderation panel to event details page

Implements:
1. German translation for three dots menu actions (viewDetails, archiveEventAction, etc.)
2. Feedback enable option now visible when editing existing events
3. Comment button in photo lightbox only shows when feedback is enabled
4. Inline comment moderation in admin event detail view

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-24 13:46:51 +02:00

269 lines
8.2 KiB
TypeScript

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;
isSelectionMode?: boolean;
selectedPhotos?: Set<number>;
onSelectionChange?: (photos: Set<number>) => void;
onToggleSelectionMode?: () => void;
showSelectionControls?: boolean;
eventName?: string;
eventLogo?: string | null;
eventDate?: string;
expiresAt?: string;
feedbackEnabled?: boolean;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
photos,
slug,
categoryId,
isSelectionMode: parentSelectionMode,
selectedPhotos: parentSelectedPhotos,
feedbackEnabled,
onSelectionChange,
onToggleSelectionMode: parentToggleSelectionMode,
showSelectionControls = true,
eventName,
eventLogo,
eventDate,
expiresAt
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
const [localSelectedPhotos, setLocalSelectedPhotos] = useState<Set<number>>(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());
}, [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 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 => {
// Download failed - error handled by UI
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());
if (parentToggleSelectionMode) {
parentToggleSelectionMode();
} else {
setLocalSelectionMode(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,
eventName,
eventLogo,
eventDate,
expiresAt,
};
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 or when controls are hidden */}
{showSelectionControls && 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={() => {
toggleSelectionMode();
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}
feedbackEnabled={feedbackEnabled || false}
/>
)}
</>
);
};