From 85170b883f504d83f1d862abb3f4e46741074826 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Fri, 23 Jan 2026 22:01:02 +0100 Subject: [PATCH] feat: add per-event custom logo upload with bug fixes Add event-level custom logo upload/delete endpoints and UI, allowing per-event logos to override the global branding logo in gallery views. Also fixes several bugs discovered during testing: - fix: category_id 'individual' parsed as NaN causing photo upload failures - fix: gallery auth race condition where photos query fired before token stored - fix: gallery-photos query not invalidated after favorite/like mutations - fix: e2e test race conditions with View Gallery button detachment --- .../core/063_add_event_custom_logo.js | 53 +++++++ backend/src/routes/adminEvents.js | 142 +++++++++++++++++- backend/src/routes/adminPhotos.js | 5 +- backend/src/routes/gallery.js | 7 +- .../src/components/gallery/GalleryView.tsx | 2 +- .../src/components/gallery/PhotoFavorites.tsx | 1 + .../src/components/gallery/PhotoLikes.tsx | 1 + frontend/src/contexts/GalleryAuthContext.tsx | 23 +-- frontend/src/i18n/locales/de.json | 9 ++ frontend/src/i18n/locales/en.json | 9 ++ frontend/src/pages/admin/EventDetailsPage.tsx | 106 ++++++++++++- tests/e2e/external-media-gallery.spec.ts | 20 +-- tests/e2e/gallery-grid-actions.spec.ts | 6 +- 13 files changed, 356 insertions(+), 28 deletions(-) create mode 100644 backend/migrations/core/063_add_event_custom_logo.js diff --git a/backend/migrations/core/063_add_event_custom_logo.js b/backend/migrations/core/063_add_event_custom_logo.js new file mode 100644 index 00000000..5714d643 --- /dev/null +++ b/backend/migrations/core/063_add_event_custom_logo.js @@ -0,0 +1,53 @@ +/** + * Migration: Add custom logo support to events table + * + * Allows per-event custom logo that overrides the global branding logo: + * - hero_logo_url: Public path to the uploaded custom logo + * - hero_logo_path: Full filesystem path to the custom logo + * + * Logo priority: Event custom logo > Global branding logo > Default PicPeak logo + * + * Addresses GitHub Issue #138: Per-event custom logo option + */ + +exports.up = async function (knex) { + console.log('Adding custom logo columns to events table...'); + + const hasUrlColumn = await knex.schema.hasColumn('events', 'hero_logo_url'); + if (!hasUrlColumn) { + await knex.schema.table('events', (table) => { + table.string('hero_logo_url', 500).nullable().defaultTo(null); + }); + console.log('Added hero_logo_url column'); + } + + const hasPathColumn = await knex.schema.hasColumn('events', 'hero_logo_path'); + if (!hasPathColumn) { + await knex.schema.table('events', (table) => { + table.string('hero_logo_path', 500).nullable().defaultTo(null); + }); + console.log('Added hero_logo_path column'); + } + + console.log('Migration 063_add_event_custom_logo completed successfully'); +}; + +exports.down = async function (knex) { + console.log('Rolling back custom logo columns...'); + + const hasUrlColumn = await knex.schema.hasColumn('events', 'hero_logo_url'); + if (hasUrlColumn) { + await knex.schema.table('events', (table) => { + table.dropColumn('hero_logo_url'); + }); + } + + const hasPathColumn = await knex.schema.hasColumn('events', 'hero_logo_path'); + if (hasPathColumn) { + await knex.schema.table('events', (table) => { + table.dropColumn('hero_logo_path'); + }); + } + + console.log('Custom logo columns dropped'); +}; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index cdd84912..6426b8bc 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -9,6 +9,7 @@ const bcrypt = require('bcrypt'); const crypto = require('crypto'); const fs = require('fs').promises; const path = require('path'); +const multer = require('multer'); const { archiveEvent } = require('../services/archiveService'); const { queueEmail } = require('../services/emailProcessor'); const { escapeLikePattern } = require('../utils/sqlSecurity'); @@ -18,6 +19,36 @@ const logger = require('../utils/logger'); const { buildShareLinkVariants } = require('../services/shareLinkService'); const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers'); const eventTypeService = require('../services/eventTypeService'); +const { validateFileType } = require('../utils/fileSecurityUtils'); + +// Get storage path from environment or default +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +// Configure multer for event logo uploads +const eventLogoStorage = multer.diskStorage({ + destination: async (req, file, cb) => { + const uploadDir = path.join(getStoragePath(), 'uploads/logos/events'); + await fs.mkdir(uploadDir, { recursive: true }); + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`); + } +}); + +const eventLogoUpload = multer({ + storage: eventLogoStorage, + limits: { fileSize: 5 * 1024 * 1024 }, // 5MB + fileFilter: (req, file, cb) => { + const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml']; + if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { + return cb(null, true); + } else { + cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed')); + } + } +}); // Helper to get event field requirements from settings const getEventFieldRequirements = async () => { @@ -809,7 +840,7 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req, if (event.archive_path) { const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const archivePath = path.join(storagePath, event.archive_path); - + try { const fsPromises = require('fs').promises; await fsPromises.unlink(archivePath); @@ -818,6 +849,16 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req, // Don't fail the transaction if file deletion fails } } + + // Delete custom event logo if exists + if (event.hero_logo_path) { + try { + const fsPromises = require('fs').promises; + await fsPromises.unlink(event.hero_logo_path); + } catch (err) { + logger.warn('Failed to delete event logo file during event deletion', { path: event.hero_logo_path, error: err.message }); + } + } }); // Log activity (outside transaction) @@ -1145,4 +1186,103 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [ } }); +// Upload event custom logo +router.post('/:id/logo', adminAuth, requirePermission('events.edit'), eventLogoUpload.single('logo'), async (req, res) => { + try { + const { id } = req.params; + + // Check if event exists + let eventQuery = db('events').where('id', id); + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + if (!req.file) { + return res.status(400).json({ error: 'No logo file provided' }); + } + + // Delete old logo file if exists + if (event.hero_logo_path) { + try { + await fs.unlink(event.hero_logo_path); + logger.debug('Deleted old event logo file', { path: event.hero_logo_path }); + } catch (err) { + logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message }); + } + } + + const logoUrl = `/uploads/logos/events/${req.file.filename}`; + const logoPath = req.file.path; + + await db('events') + .where('id', id) + .update({ + hero_logo_url: logoUrl, + hero_logo_path: logoPath + }); + + await logActivity('event_logo_uploaded', + { eventName: event.event_name, filename: req.file.filename }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ + message: 'Event logo uploaded successfully', + hero_logo_url: logoUrl + }); + } catch (error) { + logger.error('Error uploading event logo:', { error: error.message, eventId: req.params.id }); + res.status(500).json({ error: 'Failed to upload event logo' }); + } +}); + +// Delete event custom logo +router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), async (req, res) => { + try { + const { id } = req.params; + + let eventQuery = db('events').where('id', id); + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + // Delete logo file if exists + if (event.hero_logo_path) { + try { + await fs.unlink(event.hero_logo_path); + logger.debug('Deleted event logo file', { path: event.hero_logo_path }); + } catch (err) { + logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message }); + } + } + + await db('events') + .where('id', id) + .update({ + hero_logo_url: null, + hero_logo_path: null + }); + + await logActivity('event_logo_removed', + { eventName: event.event_name }, + id, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ message: 'Event logo removed successfully' }); + } catch (error) { + logger.error('Error deleting event logo:', { error: error.message, eventId: req.params.id }); + res.status(500).json({ error: 'Failed to delete event logo' }); + } +}); + module.exports = router; diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index b04046a4..fc312d68 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -176,8 +176,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u return res.status(400).json({ error: 'No files uploaded' }); } - // Parse category_id to number if provided - const parsedCategoryId = category_id ? parseInt(category_id, 10) : null; + // Parse category_id to number if provided (handle string values like 'individual', 'collage') + const rawParsed = category_id ? parseInt(category_id, 10) : NaN; + const parsedCategoryId = !isNaN(rawParsed) ? rawParsed : null; // Determine photo type and category name let photoType = 'individual'; // default diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index eb86f923..43ec6a61 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -118,7 +118,8 @@ router.get('/:slug/info', async (req, res) => { 'use_canvas_rendering', 'hero_logo_visible', 'hero_logo_size', - 'hero_logo_position' + 'hero_logo_position', + 'hero_logo_url' ) .first(); @@ -168,7 +169,8 @@ router.get('/:slug/info', async (req, res) => { use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1', hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0', hero_logo_size: event.hero_logo_size || 'medium', - hero_logo_position: event.hero_logo_position || 'top' + hero_logo_position: event.hero_logo_position || 'top', + hero_logo_url: event.hero_logo_url || null }); } catch (error) { console.error('Error fetching gallery info:', error); @@ -341,6 +343,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0', hero_logo_size: req.event.hero_logo_size || 'medium', hero_logo_position: req.event.hero_logo_position || 'top', + hero_logo_url: req.event.hero_logo_url || null, ...protectionSettings }, categories: categories, diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index bd4d95ab..726d7eb8 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -682,7 +682,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)} showSelectionControls={!showSidebar} eventName={event.event_name} - eventLogo={brandingSettings?.logo_url} + eventLogo={data?.event?.hero_logo_url || brandingSettings?.logo_url} eventDate={event.event_date} expiresAt={event.expires_at} allowDownloads={allowDownloads} diff --git a/frontend/src/components/gallery/PhotoFavorites.tsx b/frontend/src/components/gallery/PhotoFavorites.tsx index fbbff042..609132fa 100644 --- a/frontend/src/components/gallery/PhotoFavorites.tsx +++ b/frontend/src/components/gallery/PhotoFavorites.tsx @@ -49,6 +49,7 @@ export const PhotoFavorites: React.FC = ({ }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] }); + queryClient.invalidateQueries({ queryKey: ['gallery-photos'] }); }, onError: (error: any) => { // Revert optimistic update diff --git a/frontend/src/components/gallery/PhotoLikes.tsx b/frontend/src/components/gallery/PhotoLikes.tsx index db1e0113..9d52bdac 100644 --- a/frontend/src/components/gallery/PhotoLikes.tsx +++ b/frontend/src/components/gallery/PhotoLikes.tsx @@ -49,6 +49,7 @@ export const PhotoLikes: React.FC = ({ }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] }); + queryClient.invalidateQueries({ queryKey: ['gallery-photos'] }); }, onError: (error: any) => { // Revert optimistic update diff --git a/frontend/src/contexts/GalleryAuthContext.tsx b/frontend/src/contexts/GalleryAuthContext.tsx index c1960b4f..93ab37ac 100644 --- a/frontend/src/contexts/GalleryAuthContext.tsx +++ b/frontend/src/contexts/GalleryAuthContext.tsx @@ -218,16 +218,18 @@ export const GalleryAuthProvider: React.FC = ({ childr if (verify?.valid) { const response = await authService.shareLinkLogin(currentSlug, routeInfo.token); if (response?.event) { - const normalizedEvent = normalizeEvent(response.event); - setEvent(normalizedEvent); - setIsAuthenticated(true); - if (normalizedEvent) { - sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent)); - } + // Store token and slug BEFORE setting authenticated state to avoid + // race condition where photo queries fire before token is available if (response.token) { storeGalleryToken(currentSlug, response.token); } setActiveGallerySlug(currentSlug); + const normalizedEvent = normalizeEvent(response.event); + setEvent(normalizedEvent); + if (normalizedEvent) { + sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent)); + } + setIsAuthenticated(true); return; } } @@ -263,17 +265,18 @@ export const GalleryAuthProvider: React.FC = ({ childr setError(null); setIsLoading(true); const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken); - const normalizedEvent = normalizeEvent(response.event); - setEvent(normalizedEvent); - setIsAuthenticated(true); + // Store token and slug BEFORE setting authenticated state to avoid + // race condition where photo queries fire before token is available if (response.token) { storeGalleryToken(slug, response.token); } setActiveGallerySlug(slug); - + const normalizedEvent = normalizeEvent(response.event); + setEvent(normalizedEvent); if (normalizedEvent) { sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent)); } + setIsAuthenticated(true); } catch (err: any) { setError(err.response?.data?.error || 'Invalid password'); throw err; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index cfc6dfd0..1204c26f 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -845,6 +845,15 @@ "heroLogoPositionCenter": "Mitte (zwischen Titel und Datum)", "heroLogoPositionBottom": "Unten (unter dem Datum)", "heroLogoInfo": "Diese Einstellungen gelten für das Hero-Layout der Galerie. Sie können das Logo ausblenden oder Größe und Position anpassen.", + "eventCustomLogo": "Individuelles Event-Logo", + "eventCustomLogoDescription": "Laden Sie ein individuelles Logo für dieses Event hoch. Dies überschreibt das globale Branding-Logo nur für diese Galerie.", + "uploadEventLogo": "Logo hochladen", + "replaceLogo": "Ersetzen", + "removeLogo": "Entfernen", + "eventLogoUploaded": "Event-Logo erfolgreich hochgeladen", + "eventLogoUploadFailed": "Event-Logo konnte nicht hochgeladen werden", + "eventLogoRemoved": "Event-Logo erfolgreich entfernt", + "eventLogoRemoveFailed": "Event-Logo konnte nicht entfernt werden", "heroLogoVisibleLabel": "Logo sichtbar", "heroLogoSizeLabel": "Größe", "heroLogoPositionLabel": "Position", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 47eed774..9ffc4174 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -471,6 +471,15 @@ "heroLogoPositionCenter": "Center (between title and dates)", "heroLogoPositionBottom": "Bottom (below dates)", "heroLogoInfo": "These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.", + "eventCustomLogo": "Custom Event Logo", + "eventCustomLogoDescription": "Upload a custom logo for this event. This overrides the global branding logo for this gallery only.", + "uploadEventLogo": "Upload Logo", + "replaceLogo": "Replace", + "removeLogo": "Remove", + "eventLogoUploaded": "Event logo uploaded successfully", + "eventLogoUploadFailed": "Failed to upload event logo", + "eventLogoRemoved": "Event logo removed successfully", + "eventLogoRemoveFailed": "Failed to remove event logo", "heroLogoVisibleLabel": "Logo visible", "heroLogoSizeLabel": "Size", "heroLogoPositionLabel": "Position", diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index c2867881..be477b42 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -26,7 +26,8 @@ import { Monitor, Droplets, MousePointer, - Layout + Layout, + Trash2 } from 'lucide-react'; import { parseISO, differenceInDays, isValid } from 'date-fns'; @@ -54,6 +55,8 @@ import { Button, Input, Card, Loading } from '../../components/common'; import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu } from '../../components/admin'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; +import { api } from '../../config/api'; +import { buildResourceUrl } from '../../utils/url'; import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl'; import { archiveService } from '../../services/archive.service'; import { externalMediaService } from '../../services/externalMedia.service'; @@ -217,6 +220,7 @@ export const EventDetailsPage: React.FC = () => { const [showPasswordReset, setShowPasswordReset] = useState(false); const [showNewPassword, setShowNewPassword] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false); + const [logoUploading, setLogoUploading] = useState(false); const [currentTheme, setCurrentTheme] = useState(null); const [currentPresetName, setCurrentPresetName] = useState('default'); const [cssTemplates, setCssTemplates] = useState([]); @@ -460,6 +464,38 @@ export const EventDetailsPage: React.FC = () => { setIsEditing(true); }; + const handleEventLogoUpload = async (file: File) => { + if (!id) return; + setLogoUploading(true); + try { + const formData = new FormData(); + formData.append('logo', file); + const response = await api.post(`/admin/events/${id}/logo`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + toast.success(t('events.eventLogoUploaded', 'Event logo uploaded successfully')); + queryClient.invalidateQueries({ queryKey: ['event', id] }); + } catch (error: any) { + toast.error(error?.response?.data?.error || t('events.eventLogoUploadFailed', 'Failed to upload event logo')); + } finally { + setLogoUploading(false); + } + }; + + const handleEventLogoRemove = async () => { + if (!id) return; + setLogoUploading(true); + try { + await api.delete(`/admin/events/${id}/logo`); + toast.success(t('events.eventLogoRemoved', 'Event logo removed successfully')); + queryClient.invalidateQueries({ queryKey: ['event', id] }); + } catch (error: any) { + toast.error(error?.response?.data?.error || t('events.eventLogoRemoveFailed', 'Failed to remove event logo')); + } finally { + setLogoUploading(false); + } + }; + const handleSaveEdit = async () => { // Prepare color_theme - if we have a custom theme, serialize it let themeToSave = editForm.color_theme; @@ -1134,6 +1170,74 @@ export const EventDetailsPage: React.FC = () => { + + {/* Custom Event Logo Upload */} +
+ +

+ {t('events.eventCustomLogoDescription', 'Upload a custom logo for this event. This overrides the global branding logo for this gallery only.')} +

+ + {event.hero_logo_url ? ( +
+
+ {t('events.eventCustomLogo', +
+
+ + +
+ {logoUploading && } +
+ ) : ( +
+ + {logoUploading && } +
+ )} +
)} diff --git a/tests/e2e/external-media-gallery.spec.ts b/tests/e2e/external-media-gallery.spec.ts index fdc571e8..6e4e267f 100644 --- a/tests/e2e/external-media-gallery.spec.ts +++ b/tests/e2e/external-media-gallery.spec.ts @@ -136,7 +136,11 @@ test.describe('External media gallery behavior', () => { await passwordField.fill(GALLERY_PASSWORD); const viewButton = page.getByRole('button', { name: /View Gallery/i }); if (await viewButton.count()) { - await viewButton.click({ noWaitAfter: true, timeout: 2000 }); + try { + await viewButton.click({ noWaitAfter: true, timeout: 5000 }); + } catch { + // Auto-auth via share token may have already navigated to gallery view + } } } @@ -160,20 +164,16 @@ test.describe('External media gallery behavior', () => { const ariaLabel = await favoritesButtonInLightbox.getAttribute('aria-label'); const isAlreadyFavorited = ariaLabel ? /Remove from favorites/i.test(ariaLabel) : false; - const refetchPromise = page.waitForResponse((res) => { - return res.request().method() === 'GET' && res.url().includes(`/api/gallery/${slug}/photos`); - }); if (!isAlreadyFavorited) { - const favResponsePromise = page.waitForResponse((res) => { - return res.request().method() === 'POST' && res.url().includes(`/api/gallery/${slug}/photos/`); - }); await favoritesButtonInLightbox.click(); - await Promise.all([favResponsePromise, refetchPromise]); - } else { - await refetchPromise; + // Wait for the mutation to complete and the subsequent refetch with updated counts + // The onSuccess handler invalidates gallery-photos, triggering a fresh refetch + await page.waitForTimeout(500); + await page.waitForLoadState('networkidle'); } await page.getByRole('button', { name: 'Close', exact: true }).click(); + await page.waitForLoadState('networkidle'); await page.getByRole('button', { name: 'Favorited' }).click(); await expect(page.locator('.relative.group')).toHaveCount(1, { timeout: 15000 }); diff --git a/tests/e2e/gallery-grid-actions.spec.ts b/tests/e2e/gallery-grid-actions.spec.ts index 38273893..c55538c3 100644 --- a/tests/e2e/gallery-grid-actions.spec.ts +++ b/tests/e2e/gallery-grid-actions.spec.ts @@ -101,7 +101,11 @@ test.describe('Gallery grid tile quick actions', () => { const passwordField = gallery.getByPlaceholder(/gallery password/i).first(); if (await passwordField.count()) { await passwordField.fill(GALLERY_PASSWORD); - await gallery.getByRole('button', { name: /View Gallery/i }).click(); + try { + await gallery.getByRole('button', { name: /View Gallery/i }).click({ noWaitAfter: true, timeout: 5000 }); + } catch { + // Auto-auth via share token may have already navigated to gallery view + } await gallery.waitForLoadState('networkidle'); }