diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 205b2239..06757d03 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect, useCallback } from 'react'; +import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import { differenceInDays, parseISO } from 'date-fns'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -250,6 +250,37 @@ export const GalleryView: React.FC = ({ 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. Poll for a short while until the queued photos finish + // processing instead of refetching — or reloading the page — exactly once. + const uploadRefreshTimerRef = useRef | null>(null); + const stopUploadRefresh = () => { + if (uploadRefreshTimerRef.current) { + clearInterval(uploadRefreshTimerRef.current); + uploadRefreshTimerRef.current = null; + } + }; + useEffect(() => stopUploadRefresh, []); + + const handleUploadComplete = () => { + setShowUploadModal(false); + const baseline = data?.photos?.length ?? 0; + const deadline = Date.now() + 60_000; + stopUploadRefresh(); + const poll = async () => { + const result = await refetch(); + if ((result.data?.photos?.length ?? 0) > baseline || Date.now() > deadline) { + stopUploadRefresh(); + } + }; + uploadRefreshTimerRef.current = setInterval(poll, 2000); + poll(); + }; + // Get individual protection settings from event const disableRightClick = data?.event?.disable_right_click === true; const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true; @@ -1356,10 +1387,7 @@ export const GalleryView: React.FC = ({ slug, event, requiresP { - setShowUploadModal(false); - window.location.reload(); - }} + onUploadComplete={handleUploadComplete} onClose={() => setShowUploadModal(false)} /> )} @@ -1766,11 +1794,7 @@ export const GalleryView: React.FC = ({ slug, event, requiresP { - setShowUploadModal(false); - // Refetch photos after upload - window.location.reload(); // Simple reload for now - }} + onUploadComplete={handleUploadComplete} onClose={() => setShowUploadModal(false)} /> )} diff --git a/frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts b/frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts new file mode 100644 index 00000000..2e1bcc85 --- /dev/null +++ b/frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts @@ -0,0 +1,51 @@ +/** + * A guest upload must show up in the grid on its own. + * + * 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). + * + * 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'; + +const source = fs.readFileSync( + path.join(__dirname, '..', 'GalleryView.tsx'), + 'utf8' +); + +describe('post-upload photo refresh', () => { + it('never reloads the page to pick up an upload', () => { + expect(source).not.toContain('window.location.reload'); + }); + + it('keeps refetching until the queued photos appear', () => { + const handler = source.slice( + source.indexOf('const handleUploadComplete'), + source.indexOf('// Get individual protection settings') + ); + + expect(handler).toContain('await refetch()'); + expect(handler).toMatch(/setInterval\(poll/); + // Bounded: stop once the new photos land, and stop regardless after the + // deadline so a failed background job can't leave a poll running forever. + expect(handler).toContain('> baseline'); + expect(handler).toContain('Date.now() > deadline'); + }); + + 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); + }); + + it('clears the poll when the gallery unmounts', () => { + expect(source).toContain('useEffect(() => stopUploadRefresh, [])'); + }); +});