Initial commit - Project start (July 17, 2025)
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Has been skipped
Original: feat: enhance security logging and ensure rate limit blocks are properly tracked - Add comprehensive logging for rate limit blocks with full request details - IP address (with proper proxy detection), user agent, headers, timestamps - Rate limit info (current count, limit, remaining, reset time) - Separate tracking for auth vs general endpoints - Enhance authentication failure logging - JWT validation failures with detailed error info - Admin auth attempts without token - Failed token validation with user context - All events include IP, path, method, user agent - Improve Winston logger configuration for production - Add automatic log rotation (10MB errors, 50MB combined) - Create separate security.log for auth/rate limit events - Ensure logs directory exists automatically - Add structured JSON format for log aggregation - Support container logging with LOG_TO_CONSOLE env var - Create comprehensive documentation - Security logging guide with examples - Monitoring recommendations - Configuration reference - Add test script to verify logging functionality All rate limit settings remain configurable via admin panel: - Window duration, max requests, auth limits - Skip authenticated requests option - Public endpoints only option 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
photos,
|
||||
slug,
|
||||
categoryId,
|
||||
isSelectionMode: parentSelectionMode,
|
||||
selectedPhotos: parentSelectedPhotos,
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user