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:
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 2–4 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' });
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user