fix: enforce gallery access and consolidate gallery workflows

Apply shared session, permission, ownership and lifecycle checks across
gallery access, media grants and session restoration. Validate mutation
origins, pin webhook DNS resolution and redact token-bearing request URLs.

Consolidate gallery creation and queries, extract frontend state hooks,
fix hook ordering and resource cleanup, and repair the fresh event schema.
Update affected dependencies and restore excluded CI suites with regression
and cross-database coverage.
This commit is contained in:
Paul Nothaft
2026-09-08 11:10:03 +02:00
parent 895e5ab3cc
commit d1c582396a
115 changed files with 6756 additions and 8512 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ export default tseslint.config([
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'react-hooks/rules-of-hooks': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'no-useless-escape': 'off',
'no-case-declarations': 'off',
+11
View File
@@ -1,3 +1,11 @@
# URLs and Referer can contain gallery, image and customer bearer tokens.
map $uri $request_surface {
~*^/api/(?<picpeak_surface>[a-z-]+)(?:/|$) /api/$picpeak_surface;
default /;
}
log_format picpeak_safe '$remote_addr [$time_local] "$request_method $request_surface" '
'$status $body_bytes_sent $request_time';
# Honour the outer reverse proxy's X-Forwarded-Proto when present (e.g. NPM,
# Traefik, Caddy in front of PicPeak). Falls back to nginx's own $scheme when
# the header is absent (direct access / no outer proxy). Without this the
@@ -10,6 +18,9 @@ map $http_x_forwarded_proto $real_proto {
}
server {
access_log /var/log/nginx/access.log picpeak_safe;
# Request error logs expose raw token URLs; use safe access statuses.
error_log /dev/null;
listen 80;
server_name localhost;
server_tokens off;
+11
View File
@@ -1,3 +1,11 @@
# URLs and Referer can contain gallery, image and customer bearer tokens.
map $uri $request_surface {
~*^/api/(?<picpeak_surface>[a-z-]+)(?:/|$) /api/$picpeak_surface;
default /;
}
log_format picpeak_safe '$remote_addr [$time_local] "$request_method $request_surface" '
'$status $body_bytes_sent $request_time';
# Honour outer reverse-proxy's X-Forwarded-Proto when present (see #547 /
# frontend/nginx.conf for full rationale).
map $http_x_forwarded_proto $real_proto {
@@ -6,6 +14,9 @@ map $http_x_forwarded_proto $real_proto {
}
server {
access_log /var/log/nginx/access.log picpeak_safe;
# Request error logs expose raw token URLs; use safe access statuses.
error_log /dev/null;
listen 80;
server_name localhost;
root /usr/share/nginx/html;
+344 -472
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -22,14 +22,14 @@
"@fullcalendar/react": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
"@tiptap/extension-code-block-lowlight": "^2.26.1",
"@tiptap/extension-hard-break": "^2.26.1",
"@tiptap/extension-link": "^2.25.0",
"@tiptap/extension-placeholder": "^2.26.1",
"@tiptap/extension-text-align": "^2.26.1",
"@tiptap/react": "^2.25.0",
"@tiptap/starter-kit": "^2.25.0",
"@tiptap/extension-character-count": "^3.31.3",
"@tiptap/extension-code-block-lowlight": "^3.31.3",
"@tiptap/extension-hard-break": "^3.31.3",
"@tiptap/extension-link": "^3.31.3",
"@tiptap/extension-placeholder": "^3.31.3",
"@tiptap/extension-text-align": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
"@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.20",
"@types/react-google-recaptcha": "^2.1.9",
@@ -57,7 +57,7 @@
"react-image-gallery": "^1.2.11",
"react-intersection-observer": "^9.4.3",
"react-photo-album": "^3.4.0",
"react-router-dom": "^6.8.0",
"react-router-dom": "^7.18.3",
"react-toastify": "11.0.5",
"signature_pad": "^5.1.3",
"swiper": "^12.1.0",
@@ -1,3 +1,4 @@
import { usePhotoSelection } from '../../hooks/usePhotoSelection';
import React, { useState } from 'react';
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
@@ -29,15 +30,21 @@ interface AdminPhotoViewerProps {
categories: Array<{ id: number; name: string; slug: string }>;
}
export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
photos,
initialIndex,
eventId,
onClose,
onPhotoDeleted,
categories
export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = (props) => {
const selection = usePhotoSelection(props.photos, props.initialIndex);
if (!selection.currentPhoto) return null;
return <AdminPhotoViewerContent {...props} {...selection} currentPhoto={selection.currentPhoto} />;
};
type ViewerContentProps = AdminPhotoViewerProps & {
currentPhoto: AdminPhoto;
currentIndex: number;
setCurrentIndex: React.Dispatch<React.SetStateAction<number>>;
};
const AdminPhotoViewerContent: React.FC<ViewerContentProps> = ({
photos, eventId, onClose, onPhotoDeleted, categories, currentPhoto, currentIndex, setCurrentIndex
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [isDeleting, setIsDeleting] = useState(false);
const { t } = useTranslation();
// The photographer's own triage mark (#1044 follow-up). Held locally and
@@ -49,7 +56,6 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
const queryClient = useQueryClient();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const currentPhoto = photos[currentIndex];
const isVideo = currentPhoto
? (currentPhoto.media_type === 'video' ||
(currentPhoto.mime_type && String(currentPhoto.mime_type).startsWith('video/')) ||
@@ -59,10 +65,6 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
const likeCount = currentPhoto?.like_count ?? 0;
const favoriteCount = currentPhoto?.favorite_count ?? 0;
if (!currentPhoto) {
return null;
}
// Fetch feedback for current photo
const { data: feedbackData } = useQuery<AdminFeedbackResponse>({
queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id],
@@ -231,7 +233,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentIndex]);
}, [currentIndex, setCurrentIndex, photos.length, onClose]);
return (
<div className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center">
+27 -22
View File
@@ -52,6 +52,29 @@ interface CMSEditorProps {
type ViewMode = 'edit' | 'preview' | 'split';
const MenuButton: React.FC<{
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title: string;
disabled?: boolean;
}> = ({ onClick, active, children, title, disabled }) => (
<button
onMouseDown={event => event.preventDefault()}
onClick={onClick}
disabled={disabled}
className={`p-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors ${
active
? 'bg-accent-dark/15 text-accent-dark'
: 'text-neutral-700 dark:text-neutral-200'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={title}
type="button"
>
{children}
</button>
);
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave, isSaving }) => {
const { t } = useTranslation();
const [linkUrl, setLinkUrl] = useState('');
@@ -64,8 +87,10 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
const toolbarRef = React.useRef<HTMLDivElement | null>(null);
const editor = useEditor({
shouldRerenderOnTransaction: true,
extensions: [
StarterKit.configure({
link: false,
hardBreak: false, // We'll use the separate HardBreak extension
codeBlock: false, // We'll use CodeBlockLowlight instead
}),
@@ -148,7 +173,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
React.useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content);
editor.commands.setContent(content, { emitUpdate: false });
}
}, [content, editor]);
@@ -164,27 +189,7 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
}
};
const MenuButton: React.FC<{
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title: string;
disabled?: boolean;
}> = ({ onClick, active, children, title, disabled }) => (
<button
onClick={onClick}
disabled={disabled}
className={`p-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors ${
active
? 'bg-accent-dark/15 text-accent-dark'
: 'text-neutral-700 dark:text-neutral-200'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={title}
type="button"
>
{children}
</button>
);
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
@@ -31,6 +31,29 @@ interface EmailTemplateEditorProps {
variables?: string[];
}
const MenuButton: React.FC<{
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title: string;
disabled?: boolean;
}> = ({ onClick, active, children, title, disabled }) => (
<button
onMouseDown={event => event.preventDefault()}
onClick={onClick}
disabled={disabled}
className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-600 transition-colors ${
active
? 'bg-accent-dark/15 text-accent-dark'
: 'text-neutral-700 dark:text-neutral-300'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={title}
type="button"
>
{children}
</button>
);
export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
content,
onChange,
@@ -44,8 +67,10 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
const [showVariables, setShowVariables] = useState(false);
const editor = useEditor({
shouldRerenderOnTransaction: true,
extensions: [
StarterKit.configure({
link: false,
hardBreak: false,
}),
HardBreak.configure({
@@ -75,7 +100,7 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
// Sync editor when content prop changes externally
React.useEffect(() => {
if (editor && !isSourceMode && content !== editor.getHTML()) {
editor.commands.setContent(content);
editor.commands.setContent(content, { emitUpdate: false });
setSourceContent(content);
}
}, [content, editor, isSourceMode]);
@@ -134,27 +159,7 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
return null;
}
const MenuButton: React.FC<{
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title: string;
disabled?: boolean;
}> = ({ onClick, active, children, title, disabled }) => (
<button
onClick={onClick}
disabled={disabled}
className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-600 transition-colors ${
active
? 'bg-accent-dark/15 text-accent-dark'
: 'text-neutral-700 dark:text-neutral-300'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={title}
type="button"
>
{children}
</button>
);
return (
<div className="border border-neutral-300 dark:border-neutral-600 rounded-lg overflow-hidden">
+18 -247
View File
@@ -1,4 +1,8 @@
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useGalleryFiltering, resolveMediaType } from './hooks/useGalleryFiltering';
import { useGalleryUpload } from './hooks/useGalleryUpload';
import { useGallerySelection } from './hooks/useGallerySelection';
import { UploadProcessingNotice } from './UploadProcessingNotice';
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { differenceInDays, parseISO } from 'date-fns';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -34,9 +38,7 @@ import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext';
import type { FilterType, FeedbackFilterType } from './GalleryFilter';
import { analyticsService } from '../../services/analytics.service';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { api } from '../../config/api';
import { Upload, Menu, Eye, EyeOff, Shield, X, Download, ChevronLeft, Loader2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { Upload, Menu, Eye, EyeOff, Shield, X, Download, ChevronLeft } from 'lucide-react';
import { galleryService } from '../../services/gallery.service';
import { feedbackService, type ColorLabel } from '../../services/feedback.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
@@ -122,8 +124,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
const [brandingSettings, setBrandingSettings] = useState<any>(null);
const [showUploadModal, setShowUploadModal] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [feedbackEnabled, setFeedbackEnabled] = useState(false);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const { watermarkEnabled } = useWatermarkSettings();
@@ -188,19 +188,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
const [guestId, setGuestId] = useState<string>('');
const [staticHeroPhoto, setStaticHeroPhoto] = useState<Photo | null>(null);
const resolveMediaType = (photo: Photo) => {
if (photo.media_type === 'video' || photo.media_type === 'photo') {
return photo.media_type;
}
if (photo.mime_type && photo.mime_type.startsWith('video/')) {
return 'video';
}
if ((photo as any).type === 'video') {
return 'video';
}
return 'photo';
};
// Generate a unique guest ID for this session
useEffect(() => {
// Use existing guest ID from localStorage or generate new one
@@ -215,6 +202,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
// Fetch photos WITHOUT filter (always get all photos, filter on frontend)
// This ensures counts are always calculated from the full dataset
const { data, isLoading, error, refetch } = useGalleryPhotos(slug, 'all', guestId);
const { isSelectionMode, setIsSelectionMode, selectedPhotos, setSelectedPhotos } = useGallerySelection(data?.photos);
// Set protection level when data is available
useEffect(() => {
@@ -251,109 +239,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
return () => { timers.forEach(clearTimeout); clearInterval(interval); };
}, [hiddenUntilReveal, revealArmed, revealAtMs, refetch]);
// Post-upload refresh (P4-E.01). A guest upload is *queued*: the route
// answers 202 and the row lands as `processing_status: 'pending'`, while
// the photo list only returns completed rows. A single immediate refetch
// therefore comes back with a byte-identical payload (which the browser is
// answered with a 304), so the guest saw their upload silently vanish until
// they hard-reloaded.
//
// The first fix polled the photo list blind against a count baseline, which
// cannot tell a slow worker from a photo that failed processing — it just
// stopped after 60s with nothing on screen either way. Poll the upload
// group's real processing status instead (B7): it drives the "processing…"
// notice, refetches the grid as photos land rather than only at the end, and
// reports a failure instead of a silence.
const uploadRefreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [uploadProcessing, setUploadProcessing] = useState<{ complete: number; total: number } | null>(null);
const stopUploadRefresh = () => {
if (uploadRefreshTimerRef.current) {
clearInterval(uploadRefreshTimerRef.current);
uploadRefreshTimerRef.current = null;
}
};
useEffect(() => stopUploadRefresh, []);
const { uploadProcessing, handleUploadComplete } = useGalleryUpload(slug, refetch, () => setShowUploadModal(false));
const handleUploadComplete = (uploadIds: string[] = []) => {
setShowUploadModal(false);
stopUploadRefresh();
// Nothing to follow (no id came back, e.g. every file failed on the wire).
// Refetch once rather than polling something unknowable.
if (uploadIds.length === 0) {
void refetch();
return;
}
setUploadProcessing({ complete: 0, total: uploadIds.length });
const deadline = Date.now() + 120_000;
let lastComplete = 0;
let inFlight = false;
const finish = async (announce?: () => void) => {
stopUploadRefresh();
setUploadProcessing(null);
await refetch();
announce?.();
};
const poll = async () => {
// The interval keeps firing while a slow request is open; without this
// the requests stack up for the whole deadline.
if (inFlight) return;
inFlight = true;
try {
const status = await galleryService.getUploadStatus(slug, uploadIds);
setUploadProcessing({
complete: status.complete + status.failed,
total: status.total || uploadIds.length,
});
// Refetch as each photo lands, not only once the batch settles, so a
// large upload fills the grid progressively.
if (status.complete > lastComplete) {
lastComplete = status.complete;
void refetch();
}
if (status.pending === 0 && status.processing === 0) {
await finish(() => {
if (status.failed > 0) {
toast.error(t('upload.processingFailed', { count: status.failed }));
}
});
} else if (Date.now() > deadline) {
// Bounded. The worker is genuinely still running, so say that rather
// than leaving the guest with a grid that quietly never updated.
await finish(() => toast.info(t('upload.processingStillRunning')));
}
} catch {
// The status signal is a convenience — the photos are stored either
// way — so a failing status call degrades to the plain refetch.
await finish();
} finally {
inFlight = false;
}
};
uploadRefreshTimerRef.current = setInterval(poll, 2000);
void poll();
};
// The two layout branches below that render the photo grid have no shared
// wrapper, so the notice is shared as a value rather than as markup.
const uploadProcessingNotice = uploadProcessing ? (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 rounded-full bg-neutral-900/90 px-4 py-2 text-sm text-white shadow-lg">
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
<span>
{t('upload.processing')}{' '}
{t('upload.processingProgress', {
complete: uploadProcessing.complete,
total: uploadProcessing.total,
})}
</span>
</div>
) : null;
const uploadProcessingNotice = <UploadProcessingNotice processing={uploadProcessing} />;
// Get individual protection settings from event
const disableRightClick = data?.event?.disable_right_click === true;
@@ -427,8 +315,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
queryFn: async () => {
try {
// Use public endpoint to get feedback settings
const response = await api.get(`/gallery/${slug}/feedback-settings`);
return response.data;
return await feedbackService.getGalleryFeedbackSettings(slug);
} catch (error) {
console.error('Error fetching feedback settings:', error);
// If endpoint doesn't exist or returns error, default to disabled
@@ -755,7 +642,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
setSelectedPersonIds([]);
setPeopleMatchAny(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
}, []);
}, [setSelectedPhotos]);
// The address bar is the source of truth, so Back/Forward walk in and out of
// folders instead of leaving the gallery.
@@ -769,130 +656,14 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
}, [setSelectedPhotos]);
// Filter and sort photos
const filteredPhotos = useMemo(() => {
if (!data?.photos) return [];
// Folder containment (#1160) comes FIRST: at root this drops every photo that
// lives in a folder, inside a folder it keeps only that folder's photos.
// Everything below narrows within that scope, so a search or a feedback chip
// never reaches across a folder boundary.
let photos = photosInScope(data.photos, data.categories, openFolder?.id ?? null);
if (mediaFilter === 'photo') {
photos = photos.filter(photo => resolveMediaType(photo) !== 'video');
} else if (mediaFilter === 'video') {
photos = photos.filter(photo => resolveMediaType(photo) === 'video');
}
// Apply category filter. Only meaningful at root — inside a folder every
// photo already shares the folder's category.
if (selectedCategoryId && !openFolder) {
photos = photos.filter(photo => photo.category_id === selectedCategoryId);
}
// Apply search filter
if (searchTerm) {
const term = searchTerm.toLowerCase();
photos = photos.filter(photo =>
photo.filename.toLowerCase().includes(term)
);
}
// Apply feedback filters. Multi-select (#889): a photo matching ANY
// active filter passes (OR-combined); an empty set means no feedback
// filtering. In guest identity mode each filter has to scope to the
// *current guest's* interactions (#538 bug 1) — the aggregate counts
// on each photo row are global across all guests, which gave an empty
// grid when the guest had liked photos that nobody else had touched.
// Falls back to the aggregate-count check in simple/non-guest mode
// where there's no per-person identity to scope by.
if (activeFilters.length > 0) {
const matchers: Record<FeedbackFilterType, (photo: Photo) => boolean> = {
liked: (photo) => isGuestIdentityMode
? myFeedbackPhotoIds.liked.has(photo.id)
: (photo.like_count || 0) > 0,
favorited: (photo) => isGuestIdentityMode
? myFeedbackPhotoIds.favorited.has(photo.id)
: (photo.favorite_count || 0) > 0,
rated: (photo) => isGuestIdentityMode
? myFeedbackPhotoIds.rated.has(photo.id)
: (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0,
commented: (photo) => isGuestIdentityMode
? myFeedbackPhotoIds.commented.has(photo.id)
: (photo.comment_count || 0) > 0,
};
photos = photos.filter(photo => activeFilters.some(filter => matchers[filter](photo)));
}
// Apply people filter (#1074). Composes with every filter above rather
// than replacing them, so "photos of Anna that I liked" works.
//
// Two people selected means AND by default ("photos with both Anna and
// Ben") — that is what someone picking a second face is almost always
// asking for. `peopleMatchAny` flips it to OR for the couple-shots case.
if (selectedPersonIds.length > 0) {
photos = photos.filter(photo => {
const ids = photo.person_ids || [];
return peopleMatchAny
? selectedPersonIds.some(id => ids.includes(id))
: selectedPersonIds.every(id => ids.includes(id));
});
}
// Apply colour-label filters (#1044). Guest-scoped by construction:
// `my_color_label` is the requesting viewer's own label, which is what a
// proofing client means by "show me my greens". Composes with (ANDs
// against) every filter above, like the people filter.
if (activeColorFilters.length > 0) {
photos = photos.filter(photo =>
!!photo.my_color_label && activeColorFilters.includes(photo.my_color_label as ColorLabel)
);
}
// Apply sorting
// Each comparator defaults to its natural order (desc for dates/size/rating, asc for name).
// The flip multiplier reverses that when sortDesc differs from the natural order.
const flip = sortDesc ? 1 : -1;
photos.sort((a, b) => {
switch (sortBy) {
case 'name':
// Natural order is ascending (A-Z); flip when sortDesc=true
return (sortDesc ? -1 : 1) * a.filename.localeCompare(b.filename);
case 'size':
return flip * (b.size - a.size);
case 'rating': {
const ratingA = a.average_rating || 0;
const ratingB = b.average_rating || 0;
if (ratingA !== ratingB) {
return flip * (ratingB - ratingA);
}
return flip * ((b.comment_count || 0) - (a.comment_count || 0));
}
case 'capture_date': {
const captureDateA = a.captured_at || a.uploaded_at;
const captureDateB = b.captured_at || b.uploaded_at;
return flip * (new Date(captureDateB).getTime() - new Date(captureDateA).getTime());
}
case 'date':
default:
return flip * (new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime());
}
});
// Transform full-size URLs for watermarks if enabled
// Note: Thumbnails are watermarked server-side at the thumbnail endpoint
if (watermarkEnabled) {
photos = photos.map(photo => ({
...photo,
url: `/api/gallery/${slug}/photo/${photo.id}`
}));
}
return photos;
}, [data?.photos, data?.categories, openFolder, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, activeFilters, activeColorFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds, selectedPersonIds, peopleMatchAny]);
const filteredPhotos = useGalleryFiltering({
sourcePhotos: data?.photos, categories: data?.categories, folderId: openFolder?.id ?? null,
selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug,
activeFilters, activeColorFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds,
selectedPersonIds, peopleMatchAny,
});
// Counts shown in the filter chips ("Liked (N)", etc.). In guest
// mode these need to mirror the per-guest filter behaviour above —
@@ -336,14 +336,6 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
// Gallery Premium and Gallery Story layouts have their own integrated hero/header
const isFullPageLayout = galleryLayout === 'gallery-premium' || galleryLayout === 'gallery-story';
// Folder-only root (#1160). The full-bleed layouts own the hero/logout chrome,
// so they are mounted even with an empty set. Every other layout is skipped
// instead: CarouselGalleryLayout returns before four of its useState calls, so
// driving one instance between empty and non-empty changes its hook count and
// React throws. Skipping only the child keeps this component's own HeroHeader
// and welcome message on screen.
const skipEmptyLayoutChild = photos.length === 0 && suppressEmptyState && !isFullPageLayout;
return (
<>
{/* Hero Header - shown when headerStyle is 'hero' (skip for full-page layouts with integrated hero) */}
@@ -432,7 +424,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
)}
{/* Render the selected layout */}
{skipEmptyLayoutChild ? null : <LayoutComponent {...layoutProps} />}
<LayoutComponent {...layoutProps} />
{/* Lightbox - skip for full-page layouts which have their own lightbox */}
{selectedPhotoIndex !== null && !isFullPageLayout && (
@@ -0,0 +1,17 @@
import { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export function UploadProcessingNotice({ processing }: { processing: { complete: number; total: number } | null }) {
const { t } = useTranslation();
return processing ? (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 rounded-full bg-neutral-900/90 px-4 py-2 text-sm text-white shadow-lg">
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
<span>
{t('upload.processing')}{' '}
{t('upload.processingProgress', {
complete: processing.complete,
total: processing.total,
})}
</span>
</div>
) : null;
}
@@ -0,0 +1,18 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { expect, it, vi } from 'vitest';
import type { Photo } from '../../../types';
vi.mock('../../../contexts/ThemeContext', () => ({ useTheme: () => ({ theme: { gallerySettings: { carouselShowThumbnails: false } } }) }));
vi.mock('../../common', () => ({ AuthenticatedImage: ({ alt }: { alt: string }) => <img alt={alt} />, Button: ({ children, ...props }: any) => <button {...props}>{children}</button> }));
vi.mock('../../../contexts/GuestIdentityContext', () => ({ useGuestIdentityOptional: () => null }));
import { CarouselGalleryLayout } from '../layouts/CarouselGalleryLayout';
const photo = (id: number) => ({ id, filename: `photo-${id}`, url: '/photo' } as Photo);
it('keeps the carousel usable when a refetch empties, reorders or removes photos', () => {
const props = { slug: 'g', onPhotoClick: vi.fn(), onDownload: vi.fn(), photos: [] as Photo[] };
const { rerender } = render(<CarouselGalleryLayout {...props} />);
rerender(<CarouselGalleryLayout {...props} photos={[photo(1), photo(2)]} />);
fireEvent.click(screen.getByLabelText('Next photo')); expect(screen.getByAltText('photo-2')).toBeTruthy();
rerender(<CarouselGalleryLayout {...props} photos={[photo(2), photo(1)]} />); expect(screen.getByAltText('photo-2')).toBeTruthy();
rerender(<CarouselGalleryLayout {...props} photos={[photo(1)]} />); expect(screen.getByAltText('photo-1')).toBeTruthy();
rerender(<CarouselGalleryLayout {...props} photos={[]} />);
rerender(<CarouselGalleryLayout {...props} photos={[photo(3)]} />); expect(screen.getByAltText('photo-3')).toBeTruthy();
});
@@ -1,95 +1,92 @@
/**
* A guest upload must show up in the grid on its own — and say so while it is
* still being worked on.
*
* Guest uploads are queued: `POST /gallery/:id/upload` answers 202 and the row
* lands as `processing_status: 'pending'`, while `GET /gallery/:slug/photos`
* only returns completed rows. The old handler refetched exactly once (via a
* full `window.location.reload()`), which always raced the background worker —
* the payload was still byte-identical, the browser was answered 304, and the
* guest's photo silently vanished until they hard-reloaded (QA P4-E.01).
*
* The follow-up (B7) replaced the blind count-baseline poll with one driven by
* the real processing status of the guest's own upload group, so the UI can
* show "processing…" and report a failure instead of timing out in silence.
*
* GalleryView needs its providers, the router and a dozen child components to
* render, so this pins the contract at source level (same approach as
* facePreviewRendition.test.ts).
*/
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { galleryService } from '../../../services/gallery.service';
import { toast } from 'react-toastify';
import { useGalleryUpload } from '../hooks/useGalleryUpload';
const read = (...parts: string[]) =>
fs.readFileSync(path.join(__dirname, '..', ...parts), 'utf8');
vi.mock('../../../services/gallery.service', () => ({ galleryService: { getUploadStatus: vi.fn() } }));
vi.mock('react-toastify', () => ({ toast: { error: vi.fn(), info: vi.fn() } }));
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
const pending = { total: 2, pending: 1, processing: 1, complete: 0, failed: 0 };
const status = vi.mocked(galleryService.getUploadStatus);
beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); status.mockReset().mockResolvedValue(pending); });
afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); });
function setup(slug = 'wedding') {
const refetch = vi.fn().mockResolvedValue(undefined);
const close = vi.fn();
return { ...renderHook(({ slug }) => useGalleryUpload(slug, refetch, close), { initialProps: { slug } }), refetch, close };
}
async function tick(ms = 2000) { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); }
const source = read('GalleryView.tsx');
const uploadSource = read('UserPhotoUpload.tsx');
const serviceSource = fs.readFileSync(
path.join(__dirname, '..', '..', '..', 'services', 'gallery.service.ts'),
'utf8'
);
const handler = source.slice(
source.indexOf('const handleUploadComplete'),
source.indexOf('const uploadProcessingNotice')
);
describe('post-upload photo refresh', () => {
it('never reloads the page to pick up an upload', () => {
expect(source).not.toContain('window.location.reload');
describe('post-upload refresh', () => {
it('tracks uploads, refreshes progressively and stops after completion', async () => {
const { result, refetch, close, unmount } = setup();
await act(async () => { result.current.handleUploadComplete(['one', 'two']); });
expect(close).toHaveBeenCalledOnce();
expect(status).toHaveBeenLastCalledWith('wedding', ['one', 'two']);
expect(result.current.uploadProcessing).toEqual({ complete: 0, total: 2 });
status.mockResolvedValue({ ...pending, pending: 0, complete: 1 });
await tick();
expect(refetch).toHaveBeenCalledOnce();
expect(result.current.uploadProcessing).toEqual({ complete: 1, total: 2 });
status.mockResolvedValue({ ...pending, pending: 0, processing: 0, complete: 2 });
await tick();
expect(result.current.uploadProcessing).toBeNull();
const calls = status.mock.calls.length;
await tick(10_000);
expect(status).toHaveBeenCalledTimes(calls);
expect(toast.error).not.toHaveBeenCalled();
unmount();
});
it('drives the refresh off the upload group\'s processing status', () => {
expect(handler).toContain('galleryService.getUploadStatus(slug, uploadIds)');
// Refetch as photos land, not only once the whole batch settles.
expect(handler).toContain('status.complete > lastComplete');
expect(handler).toMatch(/setInterval\(poll/);
it('reports failed processing after the batch settles', async () => {
status.mockResolvedValue({ ...pending, pending: 0, processing: 0, failed: 2 });
const { result, refetch, unmount } = setup();
await act(async () => { result.current.handleUploadComplete(['one', 'two']); });
expect(toast.error).toHaveBeenCalledWith('upload.processingFailed');
expect(result.current.uploadProcessing).toBeNull();
expect(refetch).toHaveBeenCalledOnce();
unmount();
});
it('stops on the real terminal condition rather than a count baseline', () => {
expect(handler).toContain('status.pending === 0 && status.processing === 0');
// Still bounded, so a wedged worker can never leave a poll running forever.
expect(handler).toContain('Date.now() > deadline');
it('bounds a pending batch and announces ongoing processing', async () => {
const { result, refetch, unmount } = setup();
await act(async () => { result.current.handleUploadComplete(['one', 'two']); });
await tick(122_000);
expect(result.current.uploadProcessing).toBeNull();
expect(toast.info).toHaveBeenCalledWith('upload.processingStillRunning');
expect(refetch).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
unmount();
});
it('tells the guest when a photo failed processing or is still queued', () => {
expect(handler).toContain("toast.error(t('upload.processingFailed'");
expect(handler).toContain("toast.info(t('upload.processingStillRunning')");
// ...and renders a "processing…" notice while the poll runs.
expect(source).toContain("t('upload.processing')");
expect(source).toContain("t('upload.processingProgress'");
it('falls back to one refresh if status cannot be read', async () => {
status.mockRejectedValue(new Error('offline'));
const { result, refetch, unmount } = setup();
await act(async () => { result.current.handleUploadComplete(['one']); });
expect(refetch).toHaveBeenCalledOnce();
expect(result.current.uploadProcessing).toBeNull();
expect(vi.getTimerCount()).toBe(0);
unmount();
});
it('degrades to a plain refetch when the status call itself fails', () => {
expect(handler).toContain('} catch {');
expect(handler).toContain('await finish();');
it('refreshes once without polling when there are no accepted uploads', async () => {
const { result, refetch, unmount } = setup();
await act(async () => { result.current.handleUploadComplete([]); });
expect(refetch).toHaveBeenCalledOnce();
expect(status).not.toHaveBeenCalled();
unmount();
});
it('wires the polling handler into the upload modals that render the grid', () => {
const wired = source.match(/onUploadComplete=\{handleUploadComplete\}/g) || [];
expect(wired.length).toBeGreaterThanOrEqual(2);
// The notice is rendered next to each of them; the two layout branches
// have no shared wrapper to hang it on.
const shown = source.match(/\{uploadProcessingNotice\}/g) || [];
expect(shown.length).toBe(wired.length);
});
it('clears the poll when the gallery unmounts', () => {
expect(source).toContain('useEffect(() => stopUploadRefresh, [])');
});
});
describe('upload id plumbing', () => {
it('hands the 202 upload ids to the gallery', () => {
expect(uploadSource).toContain('onUploadComplete: (uploadIds: string[]) => void');
expect(uploadSource).toContain('uploadIds.push(response.data.upload_id)');
expect(uploadSource).toContain('onUploadComplete(uploadIds)');
});
it('asks the gallery-scoped status route, batching the ids into one request', () => {
expect(serviceSource).toContain('`/gallery/${slug}/uploads/status`');
expect(serviceSource).toContain("params: { ids: uploadIds.join(',') }");
it.each(['unmount', 'navigate', 'new batch'] as const)('ignores an old request after %s', async (action) => {
let resolve!: (value: typeof pending) => void;
status.mockReturnValueOnce(new Promise(done => { resolve = done; }));
const { result, refetch, rerender, unmount } = setup();
await act(async () => { result.current.handleUploadComplete(['old']); });
await tick(6000);
expect(status).toHaveBeenCalledOnce();
if (action === 'unmount') unmount();
else if (action === 'navigate') rerender({ slug: 'another' });
else await act(async () => { result.current.handleUploadComplete(['new']); });
await act(async () => { resolve({ ...pending, pending: 0, processing: 0, complete: 1, failed: 1 }); });
expect(refetch).not.toHaveBeenCalled();
expect(toast.error).not.toHaveBeenCalled();
if (action !== 'unmount') unmount();
expect(vi.getTimerCount()).toBe(0);
});
});
@@ -0,0 +1,138 @@
import { useMemo } from 'react';
import type { Photo, PhotoCategory } from '../../../types';
import type { ColorLabel } from '../../../services/feedback.service';
import type { FeedbackFilterType } from '../GalleryFilter';
import { photosInScope } from '../folders';
export type GallerySort = 'date' | 'name' | 'size' | 'rating' | 'capture_date';
export interface GalleryFilterOptions {
sourcePhotos?: Photo[]; categories?: PhotoCategory[]; folderId: number | string | null;
selectedCategoryId: number | string | null; searchTerm: string; sortBy: GallerySort; sortDesc: boolean;
watermarkEnabled: boolean; slug: string; activeFilters: FeedbackFilterType[]; activeColorFilters: ColorLabel[];
mediaFilter: 'all' | 'photo' | 'video'; isGuestIdentityMode: boolean;
myFeedbackPhotoIds: Record<FeedbackFilterType, Set<number>>; selectedPersonIds: number[]; peopleMatchAny: boolean;
}
export const resolveMediaType = (photo: Photo): 'photo' | 'video' =>
photo.media_type === 'video' || photo.mime_type?.startsWith('video/') || photo.type === 'video' ? 'video' : 'photo';
export function useGalleryFiltering({ sourcePhotos, categories, folderId, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, activeFilters, activeColorFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds, selectedPersonIds, peopleMatchAny }: GalleryFilterOptions) {
return useMemo(() => {
if (!sourcePhotos) return [];
// Folder containment (#1160) comes FIRST: at root this drops every photo that
// lives in a folder, inside a folder it keeps only that folder's photos.
// Everything below narrows within that scope, so a search or a feedback chip
// never reaches across a folder boundary.
let photos = photosInScope(sourcePhotos, categories, folderId);
if (mediaFilter === 'photo') {
photos = photos.filter(photo => resolveMediaType(photo) !== 'video');
} else if (mediaFilter === 'video') {
photos = photos.filter(photo => resolveMediaType(photo) === 'video');
}
// Apply category filter. Only meaningful at root — inside a folder every
// photo already shares the folder's category.
if (selectedCategoryId && !folderId) {
photos = photos.filter(photo => photo.category_id === selectedCategoryId);
}
// Apply search filter
if (searchTerm) {
const term = searchTerm.toLowerCase();
photos = photos.filter(photo =>
photo.filename.toLowerCase().includes(term)
);
}
// Apply feedback filters. Multi-select (#889): a photo matching ANY
// active filter passes (OR-combined); an empty set means no feedback
// filtering. In guest identity mode each filter has to scope to the
// *current guest's* interactions (#538 bug 1) — the aggregate counts
// on each photo row are global across all guests, which gave an empty
// grid when the guest had liked photos that nobody else had touched.
// Falls back to the aggregate-count check in simple/non-guest mode
// where there's no per-person identity to scope by.
if (activeFilters.length > 0) {
const matchers: Record<FeedbackFilterType, (photo: Photo) => boolean> = {
liked: (photo) => isGuestIdentityMode
? myFeedbackPhotoIds.liked.has(photo.id)
: (photo.like_count || 0) > 0,
favorited: (photo) => isGuestIdentityMode
? myFeedbackPhotoIds.favorited.has(photo.id)
: (photo.favorite_count || 0) > 0,
rated: (photo) => isGuestIdentityMode
? myFeedbackPhotoIds.rated.has(photo.id)
: (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0,
commented: (photo) => isGuestIdentityMode
? myFeedbackPhotoIds.commented.has(photo.id)
: (photo.comment_count || 0) > 0,
};
photos = photos.filter(photo => activeFilters.some(filter => matchers[filter](photo)));
}
// Apply people filter (#1074). Composes with every filter above rather
// than replacing them, so "photos of Anna that I liked" works.
//
// Two people selected means AND by default ("photos with both Anna and
// Ben") — that is what someone picking a second face is almost always
// asking for. `peopleMatchAny` flips it to OR for the couple-shots case.
if (selectedPersonIds.length > 0) {
photos = photos.filter(photo => {
const ids = photo.person_ids || [];
return peopleMatchAny
? selectedPersonIds.some(id => ids.includes(id))
: selectedPersonIds.every(id => ids.includes(id));
});
}
// Apply colour-label filters (#1044). Guest-scoped by construction:
// `my_color_label` is the requesting viewer's own label, which is what a
// proofing client means by "show me my greens". Composes with (ANDs
// against) every filter above, like the people filter.
if (activeColorFilters.length > 0) {
photos = photos.filter(photo =>
!!photo.my_color_label && activeColorFilters.includes(photo.my_color_label as ColorLabel)
);
}
// Apply sorting
// Each comparator defaults to its natural order (desc for dates/size/rating, asc for name).
// The flip multiplier reverses that when sortDesc differs from the natural order.
const flip = sortDesc ? 1 : -1;
photos.sort((a, b) => {
switch (sortBy) {
case 'name':
// Natural order is ascending (A-Z); flip when sortDesc=true
return (sortDesc ? -1 : 1) * a.filename.localeCompare(b.filename);
case 'size':
return flip * (b.size - a.size);
case 'rating': {
const ratingA = a.average_rating || 0;
const ratingB = b.average_rating || 0;
if (ratingA !== ratingB) {
return flip * (ratingB - ratingA);
}
return flip * ((b.comment_count || 0) - (a.comment_count || 0));
}
case 'capture_date': {
const captureDateA = a.captured_at || a.uploaded_at;
const captureDateB = b.captured_at || b.uploaded_at;
return flip * (new Date(captureDateB).getTime() - new Date(captureDateA).getTime());
}
case 'date':
default:
return flip * (new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime());
}
});
// Transform full-size URLs for watermarks if enabled
// Note: Thumbnails are watermarked server-side at the thumbnail endpoint
if (watermarkEnabled) {
photos = photos.map(photo => ({
...photo,
url: `/api/gallery/${slug}/photo/${photo.id}`
}));
}
return photos;
}, [sourcePhotos, categories, folderId, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, activeFilters, activeColorFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds, selectedPersonIds, peopleMatchAny]);
}
@@ -0,0 +1,16 @@
import { useEffect, useState } from 'react';
import type { Photo } from '../../../types';
/** Selections survive a refetch but never retain deleted/restricted photo IDs. */
export function useGallerySelection(photos?: Photo[]) {
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
useEffect(() => {
if (!photos) return;
const ids = new Set(photos.map(photo => photo.id));
setSelectedPhotos(previous => {
const next = new Set([...previous].filter(id => ids.has(id)));
return next.size === previous.size ? previous : next;
});
}, [photos]);
return { isSelectionMode, setIsSelectionMode, selectedPhotos, setSelectedPhotos };
}
@@ -0,0 +1,89 @@
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { galleryService } from '../../../services/gallery.service';
export function useGalleryUpload(slug: string, refetch: () => Promise<unknown>, onClose: () => void) {
const { t } = useTranslation();
const uploadRefreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [uploadProcessing, setUploadProcessing] = useState<{ complete: number; total: number } | null>(null);
const generation = useRef(0);
const stopUploadRefresh = () => {
generation.current++;
if (uploadRefreshTimerRef.current) {
clearInterval(uploadRefreshTimerRef.current);
uploadRefreshTimerRef.current = null;
}
};
useEffect(() => stopUploadRefresh, [slug]);
const handleUploadComplete = (uploadIds: string[] = []) => {
onClose();
stopUploadRefresh();
// Nothing to follow (no id came back, e.g. every file failed on the wire).
// Refetch once rather than polling something unknowable.
if (uploadIds.length === 0) {
void refetch();
return;
}
setUploadProcessing({ complete: 0, total: uploadIds.length });
const batch = generation.current;
const deadline = Date.now() + 120_000;
let lastComplete = 0;
let inFlight = false;
const finish = async (announce?: () => void) => {
stopUploadRefresh();
setUploadProcessing(null);
await refetch();
if (generation.current === batch + 1) announce?.();
};
const poll = async () => {
// The interval keeps firing while a slow request is open; without this
// the requests stack up for the whole deadline.
if (inFlight) return;
inFlight = true;
try {
const status = await galleryService.getUploadStatus(slug, uploadIds);
if (batch !== generation.current) return;
setUploadProcessing({
complete: status.complete + status.failed,
total: status.total || uploadIds.length,
});
// Refetch as each photo lands, not only once the batch settles, so a
// large upload fills the grid progressively.
if (status.complete > lastComplete) {
lastComplete = status.complete;
void refetch();
}
if (status.pending === 0 && status.processing === 0) {
await finish(() => {
if (status.failed > 0) {
toast.error(t('upload.processingFailed', { count: status.failed }));
}
});
} else if (Date.now() > deadline) {
// Bounded. The worker is genuinely still running, so say that rather
// than leaving the guest with a grid that quietly never updated.
await finish(() => toast.info(t('upload.processingStillRunning')));
}
} catch {
if (batch !== generation.current) return;
// The status signal is a convenience — the photos are stored either
// way — so a failing status call degrades to the plain refetch.
await finish();
} finally {
inFlight = false;
}
};
uploadRefreshTimerRef.current = setInterval(poll, 2000);
void poll();
};
return { uploadProcessing, handleUploadComplete };
}
@@ -1,3 +1,4 @@
import { usePhotoSelection } from '../../../hooks/usePhotoSelection';
import React, { useState, useEffect, useRef } from 'react';
import { ChevronLeft, ChevronRight, Download, Maximize2, Play, Pause, Heart, MessageSquare } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
@@ -19,7 +20,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
feedbackOptions
}) => {
const { theme } = useTheme();
const [currentIndex, setCurrentIndex] = useState(0);
const { currentPhoto, currentIndex, setCurrentIndex } = usePhotoSelection(photos);
const [isPlaying, setIsPlaying] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -41,7 +42,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isPlaying, photos.length, interval]);
}, [isPlaying, photos.length, interval, setCurrentIndex]);
// Start autoplay if enabled
useEffect(() => {
@@ -62,9 +63,6 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
setIsPlaying(!isPlaying);
};
if (photos.length === 0) return null;
const currentPhoto = photos[currentIndex];
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
@@ -80,6 +78,8 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}, [photos]);
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
if (!currentPhoto) return null;
return (
<div className="photo-grid relative">
{/* Main Carousel */}
@@ -0,0 +1,15 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { usePhotoSelection } from '../usePhotoSelection';
describe('photo selection through changing query results', () => {
it('handles empty → populated → reordered → removed → empty without changing hook order', () => {
const { result, rerender } = renderHook(({ photos }) => usePhotoSelection(photos, 1), { initialProps: { photos: [] as { id: number }[] } });
expect(result.current.currentPhoto).toBeUndefined();
rerender({ photos: [{ id: 1 }, { id: 2 }, { id: 3 }] }); expect(result.current.currentPhoto?.id).toBe(2);
rerender({ photos: [{ id: 3 }, { id: 2 }, { id: 1 }] }); expect(result.current.currentPhoto?.id).toBe(2);
act(() => result.current.setCurrentIndex(2)); expect(result.current.currentPhoto?.id).toBe(1);
rerender({ photos: [{ id: 3 }, { id: 2 }] }); expect(result.current.currentPhoto?.id).toBe(2);
rerender({ photos: [] }); expect(result.current.currentPhoto).toBeUndefined();
rerender({ photos: [{ id: 2 }, { id: 4 }] }); expect(result.current.currentPhoto?.id).toBe(2);
});
});
+1 -1
View File
@@ -26,7 +26,7 @@ export const useGalleryPhotos = (
return useQuery({
queryKey: ['gallery-photos', slug, filter, guestId],
// Pass guestId so backend can filter per-guest views when needed
queryFn: () => galleryService.getGalleryPhotos(slug, filter, guestId),
queryFn: ({ signal }) => galleryService.getGalleryPhotos(slug, filter, guestId, signal),
enabled,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
+26
View File
@@ -0,0 +1,26 @@
import { useCallback, useEffect, useState, type SetStateAction } from 'react';
/** Keep the selected photo through reordering/refetches; clamp after removal. */
export function usePhotoSelection<T extends { id: number }>(photos: T[], initialIndex = 0) {
const [selection, setSelection] = useState(() => ({ id: photos[initialIndex]?.id, index: initialIndex }));
const found = photos.findIndex(photo => photo.id === selection.id);
const currentIndex = found >= 0 ? found : Math.max(0, Math.min(selection.index, photos.length - 1));
const currentPhoto = photos[currentIndex];
useEffect(() => {
if (currentPhoto && (currentPhoto.id !== selection.id || currentIndex !== selection.index)) {
setSelection({ id: currentPhoto.id, index: currentIndex });
}
}, [currentPhoto, currentIndex, selection.id, selection.index]);
const setCurrentIndex = useCallback((next: SetStateAction<number>) => {
setSelection(previous => {
const previousIndex = photos.findIndex(photo => photo.id === previous.id);
const index = previousIndex >= 0 ? previousIndex : Math.max(0, Math.min(previous.index, photos.length - 1));
const requested = typeof next === 'function' ? next(index) : next;
const clamped = Math.max(0, Math.min(requested, photos.length - 1));
return { id: photos[clamped]?.id, index: clamped };
});
}, [photos]);
return { currentPhoto, currentIndex, setCurrentIndex };
}
@@ -658,25 +658,7 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.watermarkDownloads', 'Add watermark to downloads')}</span>
</label>
<label
className={`flex items-center ${editForm.watermark_downloads ? 'opacity-50 cursor-not-allowed' : ''}`}
title={editForm.watermark_downloads
? 'Disabled while watermarks are on — presigned URLs bypass the watermark pipeline.'
: 'When the backend uses STORAGE_BACKEND=s3, "Download All" returns a 5-minute presigned S3 URL instead of streaming through the backend. Saves bandwidth on huge galleries; bypasses watermarking.'
}
>
<input
type="checkbox"
checked={!!editForm.allow_presigned_download}
disabled={editForm.watermark_downloads}
onChange={(e) => setEditForm(prev => ({ ...prev, allow_presigned_download: e.target.checked }))}
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
<Download className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{t('events.allowPresignedDownload', 'Allow direct S3 download (no watermark, S3 mode only)')}
</span>
</label>
<label className="flex items-center">
<input
@@ -0,0 +1,26 @@
import { beforeEach, expect, it, vi } from 'vitest';
import { api } from '../../config/api';
import { galleryService } from '../gallery.service';
vi.mock('../../config/api', () => ({ api: { get: vi.fn() } }));
const get = vi.mocked(api.get);
beforeEach(() => get.mockReset());
it('joins bounded pages without losing filter, identity, cancellation or unique photo IDs', async () => {
const signal = new AbortController().signal;
get.mockResolvedValueOnce({ data: { event: { require_password: 0 }, photos: [{ id: 1 }, { id: 2 }], pagination: { page: 1, has_more: true } } })
.mockResolvedValueOnce({ data: { photos: [{ id: 2 }, { id: 3 }], pagination: { page: 2, has_more: false } } });
const result = await galleryService.getGalleryPhotos('wedding', 'liked', 'guest', signal);
expect(result.photos.map(photo => photo.id)).toEqual([1, 2, 3]);
expect(result.event.require_password).toBe(false);
expect(get).toHaveBeenNthCalledWith(1, '/gallery/wedding/photos', { params: { limit: 250, page: 1, filter: 'liked', guest_id: 'guest' }, signal });
expect(get).toHaveBeenNthCalledWith(2, '/gallery/wedding/photos', { params: { limit: 250, page: 2, filter: 'liked', guest_id: 'guest' }, signal });
});
it('accepts legacy unpaged responses', async () => {
get.mockResolvedValueOnce({ data: { event: {}, photos: [{ id: 1 }] } });
expect((await galleryService.getGalleryPhotos('legacy')).photos).toEqual([{ id: 1 }]);
expect(get).toHaveBeenCalledOnce();
});
it('propagates a cancelled page instead of returning an incomplete gallery', async () => {
get.mockResolvedValueOnce({ data: { event: {}, photos: [{ id: 1 }], pagination: { page: 1, has_more: true } } })
.mockRejectedValueOnce(new DOMException('Aborted', 'AbortError'));
await expect(galleryService.getGalleryPhotos('wedding')).rejects.toMatchObject({ name: 'AbortError' });
});
+16 -3
View File
@@ -75,17 +75,29 @@ export const galleryService = {
async getGalleryPhotos(
slug: string,
filter?: 'liked' | 'favorited' | 'commented' | 'rated' | 'all',
guestId?: string
guestId?: string,
signal?: AbortSignal
): Promise<GalleryData> {
const params: any = {};
const params: Record<string, string | number> = { limit: 250, page: 1 };
if (filter && filter !== 'all') {
params.filter = filter;
if (guestId) {
params.guest_id = guestId;
}
}
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params });
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params: { ...params }, signal });
const data = response.data;
// Existing filter/folder/lightbox consumers require the complete set.
// Fetch bounded pages so the API only hydrates feedback and faces for 250
// photos at once. A cancelled gallery query also cancels later pages.
const photos = new Map(data.photos.map(photo => [photo.id, photo]));
let pagination = data.pagination;
while (pagination?.has_more) {
params.page = pagination.page + 1;
const next = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params: { ...params }, signal });
next.data.photos.forEach(photo => photos.set(photo.id, photo));
pagination = next.data.pagination;
}
const normalizedEvent = data?.event
? {
...data.event,
@@ -94,6 +106,7 @@ export const galleryService = {
: data.event;
return {
...data,
photos: [...photos.values()],
event: normalizedEvent,
};
},
+1
View File
@@ -263,6 +263,7 @@ export interface PhotoCategory {
}
export interface GalleryData {
pagination?: { page: number; limit: number; total: number; has_more: boolean };
event: {
id: number;
event_name: string;