fix: enforce gallery access and consolidate gallery workflows (#1357)

Harden gallery authentication and authorization, consolidate gallery workflows, and prevent token-bearing URLs from leaking through nginx request error logs.
This commit is contained in:
Paul Nothaft
2026-09-08 15:34:09 +02:00
committed by GitHub
parent 895e5ab3cc
commit f0e6d2dfb1
120 changed files with 7147 additions and 8525 deletions
@@ -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">
+30 -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,13 @@ 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,
// v2 StarterKit had no TrailingNode; v3 would append an empty <p> to
// documents ending in a heading/list/code block and persist it.
trailingNode: false,
hardBreak: false, // We'll use the separate HardBreak extension
codeBlock: false, // We'll use CodeBlockLowlight instead
}),
@@ -148,7 +176,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 +192,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,13 @@ export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
const [showVariables, setShowVariables] = useState(false);
const editor = useEditor({
shouldRerenderOnTransaction: true,
extensions: [
StarterKit.configure({
link: false,
// v2 StarterKit had no TrailingNode; v3 would append an empty <p> to
// templates ending in a heading/list and persist it in the email HTML.
trailingNode: false,
hardBreak: false,
}),
HardBreak.configure({
@@ -75,7 +103,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 +162,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,91 @@
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: (options?: { cancelRefetch?: boolean }) => 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;
// Default cancelRefetch aborts the multi-page fetch still in flight
// from the previous poll, so a large gallery would never fill in.
void refetch({ cancelRefetch: false });
}
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 };
}
@@ -433,7 +433,6 @@ export const EventDetailsPage: React.FC = () => {
disable_right_click: event.disable_right_click ?? true,
allow_downloads: event.allow_downloads ?? true,
watermark_downloads: event.watermark_downloads ?? false,
allow_presigned_download: (event as { allow_presigned_download?: boolean }).allow_presigned_download ?? false,
enable_devtools_protection: event.enable_devtools_protection ?? true,
use_canvas_rendering: event.use_canvas_rendering ?? false,
// Load hero logo settings from event. Preserve null = "inherit global"
@@ -574,7 +573,6 @@ export const EventDetailsPage: React.FC = () => {
disable_right_click: editForm.disable_right_click,
allow_downloads: editForm.allow_downloads,
watermark_downloads: editForm.watermark_downloads,
allow_presigned_download: editForm.allow_presigned_download,
enable_devtools_protection: editForm.enable_devtools_protection,
use_canvas_rendering: editForm.use_canvas_rendering,
// Hero logo settings
@@ -647,10 +647,6 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
onChange={(e) => setEditForm(prev => ({
...prev,
watermark_downloads: e.target.checked,
// Watermarking and presigned URLs are mutually
// exclusive — presigned URLs serve raw bytes from
// S3 without going through the watermark pipeline.
allow_presigned_download: e.target.checked ? false : prev.allow_presigned_download,
}))}
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
/>
@@ -658,25 +654,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
@@ -25,7 +25,6 @@ export type EditFormState = {
disable_right_click: boolean;
allow_downloads: boolean;
watermark_downloads: boolean;
allow_presigned_download: boolean;
enable_devtools_protection: boolean;
use_canvas_rendering: boolean;
// Hero logo settings. null = inherit the global branding toggle (#756).
@@ -81,7 +80,6 @@ export const INITIAL_EDIT_FORM: EditFormState = {
disable_right_click: true,
allow_downloads: true,
watermark_downloads: false,
allow_presigned_download: false,
enable_devtools_protection: true,
use_canvas_rendering: false,
// Hero logo settings — null = inherit global branding toggle (#756)
@@ -0,0 +1,66 @@
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);
// Braces matter: a returned mock would be treated as a cleanup hook and called.
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('keeps four pages in flight and preserves server order when responses finish out of order', async () => {
const page = (n: number) => ({ data: { event: {}, photos: [{ id: n }], pagination: { page: n, limit: 250, total: 2250, has_more: n < 9 } } });
const pending = new Map<number, () => void>();
let active = 0;
let peak = 0;
get.mockImplementation((_url, config) => {
const n = Number((config as { params: { page: number } }).params.page);
if (n === 1) return Promise.resolve(page(n));
active++;
peak = Math.max(peak, active);
return new Promise<ReturnType<typeof page>>(resolve => {
pending.set(n, () => {
pending.delete(n);
active--;
resolve(page(n));
});
});
});
const resultPromise = galleryService.getGalleryPhotos('wedding');
// No response after page 1 has resolved: serial fetching fails here, and
// unbounded fetching would request all nine pages instead of just five.
await vi.waitFor(() => expect(get).toHaveBeenCalledTimes(5));
expect([...pending.keys()]).toEqual([2, 3, 4, 5]);
expect(active).toBe(4);
// Keep pages 24 pending while each released slot starts exactly one page.
for (const n of [5, 6, 7, 8]) {
pending.get(n)!();
await vi.waitFor(() => expect(get).toHaveBeenCalledTimes(n + 1));
expect(active).toBe(4);
}
for (const n of [9, 4, 3, 2]) pending.get(n)!();
const result = await resultPromise;
expect(peak).toBe(4);
expect(active).toBe(0);
expect(result.photos.map(photo => photo.id)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
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' });
});
+41 -3
View File
@@ -7,6 +7,9 @@ import type {
import { normalizeRequirePassword } from '../utils/accessControl';
import { parseContentDispositionFilename } from '../utils/contentDisposition';
// Gallery pages beyond the first are fetched this many at a time (#1357).
const PAGE_FETCH_CONCURRENCY = 4;
// Admin preview (#868): the preview tab carries `?admin_preview=1`. Browser-native
// download navigations (a real `<a href>` / `api.getUri`) bypass the axios request
// interceptor that forwards the flag on API calls, so append it to those URLs
@@ -75,17 +78,51 @@ 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;
if (pagination?.has_more) {
const fetchPage = async (page: number) =>
(await api.get<GalleryData>(`/gallery/${slug}/photos`, { params: { ...params, page }, signal })).data;
// Page 1 reports the total, so the remaining pages are known up front
// and fetched a few at a time instead of one round-trip after another.
const pageSize = pagination.limit || Number(params.limit);
const lastKnownPage = pagination.total ? Math.ceil(pagination.total / pageSize) : pagination.page + 1;
const firstPage = pagination.page;
const pending = Array.from({ length: Math.max(0, lastKnownPage - firstPage) }, (_, i) => firstPage + 1 + i);
const fetched = new Map<number, GalleryData>();
await Promise.all(Array.from({ length: Math.min(PAGE_FETCH_CONCURRENCY, pending.length) }, async () => {
for (let page = pending.shift(); page !== undefined; page = pending.shift()) {
fetched.set(page, await fetchPage(page));
}
}));
// Insert in page order: the Map keeps the server's sort.
for (const page of [...fetched.keys()].sort((a, b) => a - b)) {
const result = fetched.get(page) as GalleryData;
result.photos.forEach(photo => photos.set(photo.id, photo));
pagination = result.pagination;
}
// Photos added while paging push the total past what page 1 reported.
while (pagination?.has_more) {
const next = await fetchPage(pagination.page + 1);
next.photos.forEach(photo => photos.set(photo.id, photo));
pagination = next.pagination;
}
}
const normalizedEvent = data?.event
? {
...data.event,
@@ -94,6 +131,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;