Files
picpeak/frontend/src/components/gallery/GalleryView.tsx
T
Paul Nothaft e4a8be8e7e fix(gallery): no Logout button on galleries that don't require a password (#1149) (#1152)
showLogout was hard-coded true, so a gallery with no password showed a Logout button. Logging out of it is meaningless — no credential to drop, nothing to return to — and it stranded the visitor: GalleryPage's auto-login is a one-shot latch, so clearing the session left the page on its skeleton until a manual reload. That is the 'turns blank' in the report.

The button is gated on requiresPassword || isClient || viaCustomer at both call sites. The full-page layouts render it on the callback being present rather than on a flag, so withholding the callback is how the gate reaches them.

Session kind now comes from /auth/session rather than sessionStorage, which is per-tab while the cookie is per-browser: a gallery reopened in a second tab lost 'client' while the backend kept serving it as one. viaCustomer marks a portal token, which bypasses reveal mode and so is a credential that does not look like one.

The public-gallery branch no longer returns the skeleton unconditionally — once auto-login has run and left us unauthenticated it shows the reason and a Retry. That state was otherwise unrecoverable, and it also swallowed loginError entirely.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:06:47 +02:00

1519 lines
64 KiB
TypeScript

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';
import { Button } from '../common';
import { GallerySkeleton } from './GallerySkeleton';
import { useGalleryAuth, useTheme } from '../../contexts';
import { useGalleryPhotos, useDownloadAllPhotos } from '../../hooks/useGallery';
import { PhotoGridWithLayouts } from './PhotoGridWithLayouts';
import { DownloadResolutionModal } from './DownloadResolutionModal';
import { ExpirationBanner } from './ExpirationBanner';
import { CountdownTimer } from './CountdownTimer';
import { GalleryLayout } from './GalleryLayout';
import { GallerySidebar } from './GallerySidebar';
import { PhotoFilterBar } from './PhotoFilterBar';
import { UserPhotoUpload } from './UserPhotoUpload';
import { GuestNamePromptModal } from './GuestNamePromptModal';
import { GuestRecoveryModal } from './GuestRecoveryModal';
import { PeopleStrip } from './PeopleStrip';
import { PeopleSheet } from './PeopleSheet';
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 } from 'lucide-react';
import { galleryService } from '../../services/gallery.service';
import { feedbackService, type ColorLabel } from '../../services/feedback.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import type { Photo } from '../../types';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { useQueryClient } from '@tanstack/react-query';
interface GalleryViewProps {
slug: string;
event: {
id: number;
event_name: string;
event_type: string;
event_date: string | null;
welcome_message?: string;
color_theme?: string;
expires_at: string | null;
allow_user_uploads?: boolean;
upload_category_id?: number | null;
hero_photo_id?: number | null;
allow_downloads?: boolean;
// Banner overrides come from /gallery/:slug/info (the /photos response
// does NOT carry them) and are spread straight through to GalleryLayout.
promo_mode?: 'inherit' | 'custom' | 'off';
promo_markdown?: string | null;
info_mode?: 'inherit' | 'custom' | 'off';
info_markdown?: string | null;
};
/**
* Whether this gallery is password-protected (#1149).
*
* Drives the Logout button. Logging out of a gallery that asks for nothing
* is meaningless — there is no credential to drop and nothing to return to
* — and it used to strand the visitor: GalleryPage's auto-login is a
* one-shot latch, so clearing the session left the page rendering its
* skeleton until a manual reload.
*
* A client (PIN) session still gets the button on a public gallery: that
* one IS a credential, and it is the only way back to the guest view. So is
* a customer-portal session — see `via_customer` on the /photos response.
*/
requiresPassword?: boolean;
}
// Convert default_photo_sort DB value to internal sortBy state
const parseDefaultPhotoSort = (defaultSort?: string): { sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date'; sortDesc: boolean } => {
switch (defaultSort) {
case 'upload_date_asc':
return { sortBy: 'date', sortDesc: false };
case 'capture_date_desc':
return { sortBy: 'capture_date', sortDesc: true };
case 'capture_date_asc':
return { sortBy: 'capture_date', sortDesc: false };
case 'filename_asc':
return { sortBy: 'name', sortDesc: false };
case 'filename_desc':
return { sortBy: 'name', sortDesc: true };
case 'upload_date_desc':
default:
return { sortBy: 'date', sortDesc: true };
}
};
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresPassword = true }) => {
const { t } = useTranslation();
const { logout, isClient, viaCustomer } = useGalleryAuth();
const { setTheme, theme } = useTheme();
const queryClient = useQueryClient();
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
// Download size picker (#858). `showResolutionPicker` covers "download all";
// `resolutionPickerIds` covers a selection (sidebar / full-page layouts).
const [showResolutionPicker, setShowResolutionPicker] = useState(false);
const [resolutionPickerIds, setResolutionPickerIds] = useState<number[] | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
const [sortDesc, setSortDesc] = useState(true);
const [defaultSortApplied, setDefaultSortApplied] = useState(false);
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();
// Load and inject custom CSS for this gallery
useGalleryCustomCss(slug);
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
// Multi-select feedback filters (#889): OR-combined; empty = "All".
// Clicking a filter toggles it, clicking "All" clears the set.
const [activeFilters, setActiveFilters] = useState<FeedbackFilterType[]>([]);
// Colour-label filters (#1044) live in their own slice rather than as five
// more members of FeedbackFilterType: every exhaustive
// Record<FeedbackFilterType, …> in this file and the chip components would
// otherwise have to grow to ten keys.
const [activeColorFilters, setActiveColorFilters] = useState<ColorLabel[]>([]);
// People filter (#1074). Multi-select, AND by default — see the filter
// block below. `peopleMatchAny` only becomes reachable once a second
// person is picked, since the toggle is meaningless for one.
const [selectedPersonIds, setSelectedPersonIds] = useState<number[]>([]);
const [peopleMatchAny, setPeopleMatchAny] = useState(false);
const [showPeopleSheet, setShowPeopleSheet] = useState(false);
// Dismissal is per gallery: a guest who hides the bar in one gallery has
// said nothing about the next one.
const [peopleCollapsed, setPeopleCollapsed] = useState<boolean>(() => {
try {
return localStorage.getItem(`picpeak_people_collapsed_${slug}`) === '1';
} catch {
return false;
}
});
const handlePeopleCollapsedChange = (collapsed: boolean) => {
setPeopleCollapsed(collapsed);
try {
localStorage.setItem(`picpeak_people_collapsed_${slug}`, collapsed ? '1' : '0');
} catch {
// Private-mode Safari throws on setItem; the in-memory state still works.
}
};
const togglePerson = (personId: number) => {
setSelectedPersonIds((prev) => {
const next = prev.includes(personId)
? prev.filter((id) => id !== personId)
: [...prev, personId];
// Dropping back below two people makes the any/all toggle meaningless;
// reset it so it doesn't silently persist into the next selection.
if (next.length < 2) setPeopleMatchAny(false);
return next;
});
};
const handleFilterChange = (filter: FilterType) => {
if (filter === 'all') {
setActiveFilters([]);
} else {
setActiveFilters(prev => prev.includes(filter)
? prev.filter(f => f !== filter)
: [...prev, filter]);
}
};
const [mediaFilter, setMediaFilter] = useState<'all' | 'photo' | 'video'>('all');
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
let storedGuestId = localStorage.getItem('gallery_guest_id');
if (!storedGuestId) {
storedGuestId = `guest_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
localStorage.setItem('gallery_guest_id', storedGuestId);
}
setGuestId(storedGuestId);
}, []);
// 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);
// Set protection level when data is available
useEffect(() => {
if (data?.event?.protection_level) {
setProtectionLevel(data.event.protection_level);
}
}, [data?.event?.protection_level]);
// Apply default photo sort from event settings
useEffect(() => {
if (!defaultSortApplied && data?.event?.default_photo_sort) {
const { sortBy: defaultSortBy, sortDesc: defaultSortDesc } = parseDefaultPhotoSort(data.event.default_photo_sort);
setSortBy(defaultSortBy);
setSortDesc(defaultSortDesc);
setDefaultSortApplied(true);
}
}, [data?.event?.default_photo_sort, defaultSortApplied]);
// Reveal mode (#838): an already-open view must follow reveal-state
// changes in BOTH directions — hidden→visible at reveal_at (or manual
// "Reveal now"), and visible→hidden on a re-hide. Refetch right at
// reveal_at plus a 60s poll while the mode is armed; there is no push
// channel.
const hiddenUntilReveal = data?.hidden_until_reveal === true;
const revealArmed = (data?.event as { reveal_armed?: boolean } | undefined)?.reveal_armed === true;
const revealAtMs = data?.reveal_at ? new Date(data.reveal_at).getTime() : null;
useEffect(() => {
if (!hiddenUntilReveal && !revealArmed) return undefined;
const timers: Array<ReturnType<typeof setTimeout>> = [];
if (revealAtMs && revealAtMs > Date.now()) {
timers.push(setTimeout(() => { refetch(); }, Math.min(revealAtMs - Date.now() + 1000, 2 ** 31 - 1)));
}
const interval = setInterval(() => { refetch(); }, 60_000);
return () => { timers.forEach(clearTimeout); clearInterval(interval); };
}, [hiddenUntilReveal, revealArmed, revealAtMs, refetch]);
// Get individual protection settings from event
const disableRightClick = data?.event?.disable_right_click === true;
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
const useCanvasRendering = data?.event?.use_canvas_rendering === true;
// #508 — surface original camera filenames in the lightbox when the
// admin has flipped the same toggle that drives original-name downloads.
const showOriginalFilename = data?.event?.use_original_filenames === true;
// DevTools protection - enabled by individual setting OR legacy protection level
const devToolsEnabled = enableDevtoolsProtection || protectionLevel === 'enhanced' || protectionLevel === 'maximum';
useDevToolsProtection({
enabled: devToolsEnabled,
detectionSensitivity: protectionLevel === 'maximum' ? 'high' : 'medium',
onDevToolsDetected: () => {
console.warn('DevTools detected in gallery view');
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('gallery_devtools_detected', {
gallery: slug,
protectionLevel,
eventId: data?.event?.id
});
}
// For maximum protection, redirect away from gallery
if (protectionLevel === 'maximum') {
setTimeout(() => {
window.location.href = '/';
}, 100);
}
},
redirectOnDetection: protectionLevel === 'maximum',
redirectUrl: '/'
});
// Right-click blocking - separate from DevTools protection
useEffect(() => {
if (!disableRightClick) return;
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
return false;
};
document.addEventListener('contextmenu', handleContextMenu);
return () => {
document.removeEventListener('contextmenu', handleContextMenu);
};
}, [disableRightClick]);
// Data updates are handled by React Query
const downloadAllMutation = useDownloadAllPhotos();
// Handle window resize
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < 768);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const { data: settingsData } = usePublicSettings();
// Fetch feedback settings
const { data: feedbackSettings } = useQuery({
queryKey: ['gallery-feedback-settings', event.id],
queryFn: async () => {
try {
// Use public endpoint to get feedback settings
const response = await api.get(`/gallery/${slug}/feedback-settings`);
return response.data;
} catch (error) {
console.error('Error fetching feedback settings:', error);
// If endpoint doesn't exist or returns error, default to disabled
return { feedback_enabled: false };
}
},
enabled: !!event.id,
});
// People in this gallery (#1074).
//
// Gated on people_enabled so an install without the feature never fires the
// request at all. Polls only while a backfill is running — a finished
// gallery has a stable people list, and polling it forever would be a
// request per guest per interval for no new information.
// From the /photos payload, not the prop: the prop's event shape comes
// from /info, which does not carry this flag.
const peopleEnabled = data?.event?.people_enabled === true;
const { data: peopleData } = useQuery({
queryKey: ['gallery-people', slug],
queryFn: () => galleryService.getPeople(slug),
enabled: peopleEnabled,
refetchInterval: (query) => (query.state.data?.scan?.in_progress ? 5000 : false),
staleTime: 30_000,
});
const people = peopleData?.people || [];
// The strip comes from /people, but FILTERING uses photo.person_ids, which
// rides on the one-shot /photos response. During a backfill those drift
// apart: new faces appear in the strip while the photo memberships behind
// them are still the set fetched on page load, so tapping a person yields
// zero or a partial result until a manual reload — including after the scan
// has finished.
//
// Refetch the photos whenever the scan's progress changes, and once more on
// the transition to finished.
const scanProgress = peopleData?.scan
? `${peopleData.scan.in_progress}:${peopleData.scan.scanned}`
: null;
useEffect(() => {
if (!peopleEnabled || !scanProgress) return;
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
}, [scanProgress, peopleEnabled, slug, queryClient]);
// Update feedbackEnabled when settings change
useEffect(() => {
if (feedbackSettings) {
setFeedbackEnabled(feedbackSettings.feedback_enabled || false);
}
}, [feedbackSettings]);
// In guest identity mode both the "Liked / Favorited / Rated /
// Commented" filters AND the matching chip-count labels need to scope
// to the *current guest's* interactions, not the global aggregates on
// each photo row (#538 bug 1). Pull the current guest's feedback via
// /my-feedback (already keyed by x-guest-token in the api interceptor)
// and build per-type photo-id sets so both consumers below can do
// O(1) lookups.
//
// Always-on in guest mode (not gated on the active filter) because
// the chip counts render whether or not a feedback filter is selected
// — gating on activeFilters would leave "Liked (0)" stale until the user
// clicks the chip, which is the same UX cliff bug 1 was reporting.
const isGuestIdentityMode = feedbackSettings?.identity_mode === 'guest';
const { data: myFeedbackRows } = useQuery<Array<{
photo_id: number;
feedback_type: 'like' | 'favorite' | 'rating' | 'comment';
}>>({
queryKey: ['my-feedback', slug],
queryFn: () => feedbackService.getMyFeedback(slug),
enabled: isGuestIdentityMode && !!slug,
staleTime: 30 * 1000,
});
const myFeedbackPhotoIds = useMemo(() => {
const sets = {
liked: new Set<number>(),
favorited: new Set<number>(),
rated: new Set<number>(),
commented: new Set<number>(),
};
if (!myFeedbackRows) return sets;
for (const row of myFeedbackRows) {
if (row.feedback_type === 'like') sets.liked.add(row.photo_id);
else if (row.feedback_type === 'favorite') sets.favorited.add(row.photo_id);
else if (row.feedback_type === 'rating') sets.rated.add(row.photo_id);
else if (row.feedback_type === 'comment') sets.commented.add(row.photo_id);
}
return sets;
}, [myFeedbackRows]);
// Apply branding settings
useEffect(() => {
if (settingsData) {
setBrandingSettings({
company_name: settingsData.branding_company_name || '',
company_tagline: settingsData.branding_company_tagline || '',
support_email: settingsData.branding_support_email || '',
footer_text: settingsData.branding_footer_text || '',
watermark_enabled: settingsData.branding_watermark_enabled || false,
logo_url: settingsData.branding_logo_url || null,
logo_url_dark: settingsData.branding_logo_url_dark || null,
logo_size: settingsData.branding_logo_size || 'medium',
logo_max_height: settingsData.branding_logo_max_height || 48,
logo_position: settingsData.branding_logo_position || 'left',
logo_display_header: settingsData.branding_logo_display_header !== false,
logo_display_hero: settingsData.branding_logo_display_hero !== false,
logo_display_mode: settingsData.branding_logo_display_mode || 'logo_and_text',
// Footer overhaul (#441 + #440). All five socials are optional;
// empty strings → that icon is hidden. promo_markdown is the
// global default; per-event override happens in GalleryLayout.
facebook_url: settingsData.branding_facebook_url || '',
instagram_url: settingsData.branding_instagram_url || '',
whatsapp_url: settingsData.branding_whatsapp_url || '',
twitter_url: settingsData.branding_twitter_url || '',
youtube_url: settingsData.branding_youtube_url || '',
promo_markdown: settingsData.branding_promo_markdown || '',
info_markdown: settingsData.branding_info_markdown || '',
promo_position: settingsData.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer',
// Promo alignment (#482). Defaults to 'center' to match the
// gallery footer; see GalleryLayout.
promo_alignment: ['left', 'center', 'right'].includes(settingsData.branding_promo_alignment)
? settingsData.branding_promo_alignment
: 'center',
});
}
}, [settingsData]);
const availableMediaTypes = useMemo(() => {
const types = new Set<'photo' | 'video'>();
(data?.photos || []).forEach((photo) => {
const mediaType = resolveMediaType(photo);
if (mediaType === 'photo' || mediaType === 'video') {
types.add(mediaType);
}
});
return types;
}, [data?.photos]);
const showMediaFilter = availableMediaTypes.has('photo') && availableMediaTypes.has('video');
useEffect(() => {
if (!showMediaFilter && mediaFilter !== 'all') {
setMediaFilter('all');
}
}, [showMediaFilter, mediaFilter]);
// Determine the default hero photo from the initial (unfiltered) load
const [defaultHeroPhoto, setDefaultHeroPhoto] = useState<Photo | null>(null);
useEffect(() => {
if (!defaultHeroPhoto && data?.photos && activeFilters.length === 0) {
let hero: Photo | null = null;
const heroId = data?.event?.hero_photo_id || null;
if (heroId) {
hero = data.photos.find(p => p.id === heroId) || null;
}
if (!hero && data.photos.length > 0) {
const firstPhoto = data.photos.find(p => resolveMediaType(p) === 'photo');
hero = firstPhoto || data.photos[0];
}
if (hero) {
setDefaultHeroPhoto(hero);
setStaticHeroPhoto(hero);
}
}
}, [data?.photos, data?.event?.hero_photo_id, activeFilters, defaultHeroPhoto]);
// Switch hero photo when a category with its own hero image is selected
useEffect(() => {
if (!data?.photos || !defaultHeroPhoto) return;
if (selectedCategoryId) {
const category = (data.categories || []).find(c => c.id === selectedCategoryId);
if (category?.hero_photo_id) {
const categoryHero = data.photos.find(p => p.id === category.hero_photo_id);
if (categoryHero) {
setStaticHeroPhoto(categoryHero);
return;
}
}
}
// No category selected or category has no hero — revert to default
setStaticHeroPhoto(defaultHeroPhoto);
}, [selectedCategoryId, data?.categories, data?.photos, defaultHeroPhoto]);
// Apply theme when settings are loaded
useEffect(() => {
if (settingsData && data?.event) {
let themeToApply = null;
const fullEvent = data.event; // Use the full event data from API
if (fullEvent.color_theme) {
try {
// Check if it's a valid JSON string
if (fullEvent.color_theme.startsWith('{')) {
const eventTheme = JSON.parse(fullEvent.color_theme);
themeToApply = eventTheme;
} else {
// Handle legacy theme names - check if it's a preset
const preset = GALLERY_THEME_PRESETS[fullEvent.color_theme];
if (preset) {
themeToApply = preset.config;
} else {
// Unknown theme name, fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
}
} catch {
// Invalid theme format - use default
// Fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
} else if (settingsData.theme_config) {
// No event theme, use global theme
themeToApply = settingsData.theme_config;
}
// Apply theme with a small delay to ensure it overrides any global theme.
// Instance-wide force color mode is enforced inside ThemeContext.applyTheme,
// so callers don't have to wrap the theme themselves.
if (themeToApply) {
// Use setTimeout to ensure this runs after any global theme application
const timer = setTimeout(() => {
// If there's a hero photo, add it to gallery settings
if (fullEvent.hero_photo_id && themeToApply.gallerySettings) {
themeToApply.gallerySettings.heroImageId = fullEvent.hero_photo_id;
// Apply hero photo ID to existing gallery settings
} else if (fullEvent.hero_photo_id) {
themeToApply.gallerySettings = { heroImageId: fullEvent.hero_photo_id };
// Create gallery settings with hero photo ID
}
setTheme(themeToApply);
}, 0);
return () => clearTimeout(timer);
}
}
}, [settingsData, data, setTheme]); // Use data instead of event prop
// Client visibility toggle handler (#172)
const handleToggleVisibility = async (photoId: number, currentVisibility: string) => {
const newVisibility = currentVisibility === 'hidden' ? 'visible' : 'hidden';
try {
await galleryService.togglePhotoVisibility(slug, photoId, newVisibility);
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
} catch (error) {
console.error('Failed to toggle visibility:', error);
}
};
const handleBulkVisibility = async (visibility: 'visible' | 'hidden') => {
if (selectedPhotos.size === 0) return;
try {
await galleryService.bulkToggleVisibility(slug, Array.from(selectedPhotos), visibility);
setSelectedPhotos(new Set());
setIsSelectionMode(false);
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
} catch (error) {
console.error('Failed to bulk toggle visibility:', error);
}
};
// Client visibility stats
const visibleCount = useMemo(() => {
if (!isClient || !data?.photos) return 0;
return data.photos.filter(p => p.visibility !== 'hidden').length;
}, [isClient, data?.photos]);
const totalCount = data?.photos?.length || 0;
// Calculate days until expiration (null means never expires)
const daysUntilExpiration = event.expires_at
? differenceInDays(parseISO(event.expires_at), new Date())
: null;
const showUrgentWarning = daysUntilExpiration !== null && daysUntilExpiration <= 7;
const isExpired = daysUntilExpiration !== null && daysUntilExpiration < 0;
// Filter and sort photos
const filteredPhotos = useMemo(() => {
if (!data?.photos) return [];
let photos = [...data.photos];
if (mediaFilter === 'photo') {
photos = photos.filter(photo => resolveMediaType(photo) !== 'video');
} else if (mediaFilter === 'video') {
photos = photos.filter(photo => resolveMediaType(photo) === 'video');
}
// Apply category filter
if (selectedCategoryId) {
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, 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 —
// otherwise the chip says "Liked (5)" globally but clicking it
// surfaces 3 (the guest's own subset), which is the same confusing
// mismatch #538 reported for the filter itself. Fall back to the
// global aggregate in simple mode where no per-person identity
// exists.
const likeCount = useMemo(() => {
if (isGuestIdentityMode) return myFeedbackPhotoIds.liked.size;
return data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0;
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
const favoriteCount = useMemo(() => {
if (isGuestIdentityMode) return myFeedbackPhotoIds.favorited.size;
return data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0;
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
const ratedCount = useMemo(() => {
if (isGuestIdentityMode) return myFeedbackPhotoIds.rated.size;
return data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0;
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
// Per-colour chip counts (#1044) — the viewer's own labels, matching what
// the filter actually selects.
const colorLabelCounts = useMemo(() => {
const counts: Partial<Record<ColorLabel, number>> = {};
for (const photo of data?.photos || []) {
const label = photo.my_color_label as ColorLabel | null | undefined;
if (!label) continue;
counts[label] = (counts[label] || 0) + 1;
}
return counts;
}, [data?.photos]);
const handleColorFilterToggle = useCallback((color: ColorLabel) => {
setActiveColorFilters(prev =>
prev.includes(color) ? prev.filter(c => c !== color) : [...prev, color]
);
}, []);
// Check if downloads are allowed (both event setting and not expired)
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
// Resolution picker choices (#858). More than one option means there is an
// actual choice to make; a single option is just the standard size, so skip
// the modal and download straight away.
const downloadChoices = data?.event?.download_resolution?.picker_enabled
? (data.event.download_resolution.choices || [])
: [];
const handleDownloadAll = () => {
// Prevent downloads if gallery is expired or downloads disabled
if (!allowDownloads) {
return;
}
// Hand off to the picker; it builds the archive as a job and downloads it.
if (downloadChoices.length > 1) {
setShowResolutionPicker(true);
return;
}
downloadAllMutation.mutate({ slug, zipReady: data?.event?.download_zip_ready });
// Track download all action
analyticsService.trackGalleryEvent('bulk_download', {
gallery: slug,
photo_count: data?.photos.length || 0,
is_download_all: true
});
};
const handleDownloadSelected = async () => {
if (selectedPhotos.size === 0) return;
// Prevent downloads if gallery is expired or downloads disabled
if (!allowDownloads) {
return;
}
// Resolution picker (#858): sidebar-driven selections get the same choice
// as the grid's own control, rather than silently downloading at the
// gallery standard.
if (downloadChoices.length > 1) {
setResolutionPickerIds(Array.from(selectedPhotos));
return;
}
const selectedPhotosList = filteredPhotos.filter(p => selectedPhotos.has(p.id));
// Track bulk download
analyticsService.trackGalleryEvent('bulk_download', {
gallery: slug,
photo_count: selectedPhotos.size
});
// Download each selected photo
for (const photo of selectedPhotosList) {
await galleryService.downloadPhoto(slug, photo.id, photo.filename);
}
// Clear selection after download
setSelectedPhotos(new Set());
setIsSelectionMode(false);
};
// "Download these N" (#1074) — the payoff of the people filter.
//
// Deliberately NO new endpoint or person_id selector: the filtered photo
// ids go through the same path as a manual selection, and the server
// re-applies the access level and per-category permissions on the way
// through. One less thing to authorize.
//
// Photos in a category with downloads disabled (#640) are excluded HERE as
// well as server-side, so the number on the button is the number the guest
// actually receives rather than an optimistic one.
const peopleDownloadableIds = useMemo(() => {
if (selectedPersonIds.length === 0) return [];
return filteredPhotos
.filter((photo) => photo.category_allow_downloads !== false)
.map((photo) => photo.id);
}, [filteredPhotos, selectedPersonIds]);
const handleDownloadPeopleFiltered = async () => {
if (!allowDownloads || peopleDownloadableIds.length === 0) return;
// Same resolution-picker behaviour as every other multi-photo download.
if (downloadChoices.length > 1) {
setResolutionPickerIds(peopleDownloadableIds);
return;
}
analyticsService.trackGalleryEvent('bulk_download', {
gallery: slug,
photo_count: peopleDownloadableIds.length,
});
await galleryService.downloadSelectedPhotos(slug, peopleDownloadableIds);
};
// Calculate photo counts per category
const photoCounts = useMemo(() => {
if (!data?.photos) return {};
const counts: Record<number | string, number> = {};
data.photos
.filter(photo => {
if (mediaFilter === 'photo') return resolveMediaType(photo) !== 'video';
if (mediaFilter === 'video') return resolveMediaType(photo) === 'video';
return true;
})
.forEach(photo => {
if (photo.category_id) {
counts[photo.category_id] = (counts[photo.category_id] || 0) + 1;
}
});
return counts;
}, [data?.photos, mediaFilter]);
// Track search usage with debouncing
useEffect(() => {
if (searchTerm.length > 0) {
const timer = setTimeout(() => {
analyticsService.trackSearch(searchTerm, filteredPhotos.length, 'gallery');
}, 1000); // Debounce for 1 second
return () => clearTimeout(timer);
}
}, [searchTerm, filteredPhotos.length]);
// Track expiration warning views
useEffect(() => {
if (showUrgentWarning && daysUntilExpiration > 0) {
analyticsService.trackExpirationWarning(slug, daysUntilExpiration);
}
}, [showUrgentWarning, daysUntilExpiration, slug]);
if (isLoading) {
return <GallerySkeleton />;
}
if (error || !data) {
// Check if it's an authentication error (401)
const is401Error = (error as any)?.response?.status === 401;
if (is401Error) {
// Authentication failed - logout and let the parent component handle re-authentication
logout();
return null;
}
return (
<div className="min-h-screen bg-surface flex items-center justify-center">
<div className="text-center">
<p className="text-lg text-muted-theme">{t('gallery.failedToLoad')}</p>
<Button onClick={() => refetch()} className="mt-4">
{t('gallery.tryAgain')}
</Button>
</div>
</div>
);
}
// Determine controls style (sidebar vs classic inline filter bar).
// Decoupled from layout — only an explicit controlsStyle === 'sidebar' on
// the theme renders the sidebar. Default (unset or 'classic') is the inline
// filter bar for every layout, so the gallery header/filters look identical
// regardless of whether the photos render as grid, masonry, carousel, etc.
const headerStyle = data?.event?.header_style || theme.headerStyle || 'standard';
const isHeroHeader = headerStyle === 'hero';
const showSidebar = theme.controlsStyle === 'sidebar';
const filterBarShown = !showSidebar
&& settingsData?.gallery_show_filter_bar !== false
&& (data?.photos?.length ?? 0) > 0;
// Reveal mode (#838): the server returned the event shell with no photos —
// render the upload-only view for EVERY layout. Enforcement is server-side
// (the photo endpoints refuse plain guests); this is the friendly face.
if (hiddenUntilReveal) {
const uploadsOn = Boolean(data?.event?.allow_user_uploads || event?.allow_user_uploads);
return (
<GalleryLayout
event={{
...event,
// The reveal-hidden view still renders the chrome, so BOTH
// banners resolve here too. The context `event` comes from the
// gallery login response and carries no banner fields, which would
// silently downgrade a per-event 'off' to 'inherit' and show the
// global banner on a gallery the admin muted (#440 promo / #932 info).
promo_mode: (data?.event as { promo_mode?: 'inherit' | 'custom' | 'off' })?.promo_mode,
promo_markdown: (data?.event as { promo_markdown?: string | null })?.promo_markdown,
info_mode: (data?.event as { info_mode?: 'inherit' | 'custom' | 'off' })?.info_mode,
info_markdown: (data?.event as { info_markdown?: string | null })?.info_markdown,
}}
brandingSettings={brandingSettings}
>
<div className="max-w-xl mx-auto text-center py-16 px-4">
<div className="mx-auto mb-5 w-16 h-16 rounded-full bg-surface flex items-center justify-center">
<EyeOff className="w-8 h-8 text-muted-theme" />
</div>
<h2 className="text-2xl font-semibold mb-3" style={{ color: 'var(--color-text, #171717)' }}>
{t('gallery.revealPendingTitle', 'The photos are still a surprise')}
</h2>
<p className="text-muted-theme mb-2">
{t('gallery.revealPendingMessage', 'The host will reveal the gallery later — check back soon!')}
</p>
{data.reveal_at && (
<p className="text-sm text-muted-theme mb-6">
{t('gallery.revealScheduledFor', 'Reveal scheduled for {{date}}', {
date: new Date(data.reveal_at).toLocaleString(),
})}
</p>
)}
{uploadsOn && (
<div className="mt-6">
<p className="text-sm text-muted-theme mb-3">
{t('gallery.revealUploadHint', 'You can already add your own photos to the collection:')}
</p>
<Button
variant="primary"
size="lg"
leftIcon={<Upload className="w-5 h-5" />}
onClick={() => setShowUploadModal(true)}
>
{t('upload.uploadPhotos', 'Upload Photos')}
</Button>
</div>
)}
</div>
{showUploadModal && uploadsOn && (
<UserPhotoUpload
eventId={data?.event?.id || event?.id}
categoryId={data?.event?.upload_category_id || event?.upload_category_id}
onUploadComplete={() => setShowUploadModal(false)}
onClose={() => setShowUploadModal(false)}
/>
)}
{/* Download size picker (#858) — "download all", or a selection. */}
{(showResolutionPicker || resolutionPickerIds) && (
<DownloadResolutionModal
slug={slug}
choices={downloadChoices}
standardResolution={data?.event?.download_resolution?.standard}
photoIds={resolutionPickerIds || undefined}
onClose={() => {
setShowResolutionPicker(false);
setResolutionPickerIds(null);
}}
/>
)}
</GalleryLayout>
);
}
// Full-page layouts (gallery-premium, gallery-story) have their own integrated UI
// Skip all wrapper elements (header, footer, sidebar, filters) for these layouts
const isFullPageLayout = theme.galleryLayout === 'gallery-premium' || theme.galleryLayout === 'gallery-story';
// Does this session hold something worth dropping? A password gallery and a
// PIN client obviously do. So does a customer-portal session: its token
// bypasses reveal mode, so it opens galleries a plain visitor cannot, and it
// lives for 24h in a cookie the customer logout does not clear. Hiding the
// control would remove the only way to drop it (#1149).
//
// Read from the auth context, which resolves this from /auth/session on
// mount — NOT from the photos payload. That response is cached by React
// Query for five minutes on a key that knows nothing about the session, so
// opening a gallery as a guest and then from the portal would have reused
// the guest answer, and vice versa.
const showLogoutControl = requiresPassword || isClient || viaCustomer;
// For full-page layouts, render just the PhotoGridWithLayouts without any wrappers
if (isFullPageLayout) {
return (
<>
<PhotoGridWithLayouts
photos={filteredPhotos}
slug={slug}
people={peopleEnabled ? people : undefined}
onSelectPerson={togglePerson}
categoryId={selectedCategoryId}
onFeedbackChange={() => {
refetch();
// Guest-mode Rated/Liked filter membership + chip counts come
// from my-feedback, not the photo rows (#538) — refresh it too
// so e.g. a cleared rating (#884) leaves the Rated filter.
queryClient.invalidateQueries({ queryKey: ['my-feedback', slug] });
}}
heroPhotoOverride={staticHeroPhoto}
feedbackEnabled={feedbackEnabled}
feedbackOptions={{
allowLikes: !!feedbackSettings?.allow_likes,
allowFavorites: !!feedbackSettings?.allow_favorites,
allowRatings: !!feedbackSettings?.allow_ratings,
allowComments: !!feedbackSettings?.allow_comments,
allowReactions: !!feedbackSettings?.allow_reactions,
requireNameEmail: !!feedbackSettings?.require_name_email,
}}
isSelectionMode={isSelectionMode}
selectedPhotos={selectedPhotos}
onSelectionChange={setSelectedPhotos}
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
showSelectionControls={false}
eventName={event.event_name}
eventLogo={data?.event?.hero_logo_url || brandingSettings?.logo_url}
eventDate={event.event_date}
expiresAt={event.expires_at}
allowDownloads={allowDownloads}
downloadChoices={downloadChoices}
downloadStandard={data?.event?.download_resolution?.standard}
protectionLevel={protectionLevel}
useEnhancedProtection={protectionLevel !== 'basic'}
disableRightClick={disableRightClick}
enableDevtoolsProtection={enableDevtoolsProtection}
useCanvasRendering={useCanvasRendering}
heroLogoVisible={data?.event?.hero_logo_visible !== false}
heroLogoSize={data?.event?.hero_logo_size || 'medium'}
heroLogoPosition={data?.event?.hero_logo_position || 'top'}
headerStyle={data?.event?.header_style || theme.headerStyle}
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
welcomeMessage={event.welcome_message}
// Same gate as the standard layout below (#1149). These layouts
// render the button on the callback being present rather than on a
// showLogout flag, so withholding it is how the gate reaches them.
onLogout={showLogoutControl ? logout : undefined}
showOriginalFilename={showOriginalFilename}
/>
{/* Upload Modal for full-page layouts */}
{showUploadModal && (data?.event?.allow_user_uploads || event?.allow_user_uploads) && (
<UserPhotoUpload
eventId={data?.event?.id || event?.id}
categoryId={data?.event?.upload_category_id || event?.upload_category_id}
onUploadComplete={() => {
setShowUploadModal(false);
window.location.reload();
}}
onClose={() => setShowUploadModal(false)}
/>
)}
{/* Download size picker (#858) — "download all", or a selection. */}
{(showResolutionPicker || resolutionPickerIds) && (
<DownloadResolutionModal
slug={slug}
choices={downloadChoices}
standardResolution={data?.event?.download_resolution?.standard}
photoIds={resolutionPickerIds || undefined}
onClose={() => {
setShowResolutionPicker(false);
setResolutionPickerIds(null);
}}
/>
)}
</>
);
}
const identityMode: 'simple' | 'guest' =
feedbackSettings?.identity_mode === 'guest' ? 'guest' : 'simple';
return (
<GuestIdentityProvider slug={slug} identityMode={identityMode}>
<>
<GuestNamePromptModal requireEmail={!!feedbackSettings?.require_name_email} />
<GuestRecoveryModal />
{/* Sidebar for non-grid layouts */}
{showSidebar ? (
<GallerySidebar
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(!sidebarOpen)}
categories={(data?.categories || []).filter(cat => photoCounts[cat.id] > 0)}
selectedCategoryId={selectedCategoryId}
onCategoryChange={setSelectedCategoryId}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortBy={sortBy}
onSortChange={setSortBy}
sortDesc={sortDesc}
onSortDescChange={setSortDesc}
isSelectionMode={isSelectionMode}
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
selectedCount={selectedPhotos.size}
onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected}
isDownloading={downloadAllMutation.isPending}
allowDownloads={allowDownloads}
photoCounts={photoCounts}
totalPhotos={data?.photos.length || 0}
isMobile={isMobile}
galleryLayout={theme.galleryLayout}
allowUploads={data?.event?.allow_user_uploads || event?.allow_user_uploads || false}
onUploadClick={() => setShowUploadModal(true)}
feedbackEnabled={feedbackEnabled}
activeFilters={activeFilters}
onFilterChange={handleFilterChange}
mediaFilter={mediaFilter}
onMediaFilterChange={setMediaFilter}
showMediaFilter={showMediaFilter}
likeCount={likeCount}
favoriteCount={favoriteCount}
ratedCount={ratedCount}
colorLabelsEnabled={!!feedbackSettings?.allow_color_labels}
activeColorFilters={activeColorFilters}
onColorFilterChange={handleColorFilterToggle}
colorLabelCounts={colorLabelCounts}
/>
) : null}
<GalleryLayout
event={{
...event,
// Per-event promo override (#440).
promo_mode: (data?.event as { promo_mode?: 'inherit' | 'custom' | 'off' })?.promo_mode,
promo_markdown: (data?.event as { promo_markdown?: string | null })?.promo_markdown,
// Per-event info-banner override (#932). Read from the /photos
// response rather than the context event: the context is seeded from
// the gallery LOGIN response, which carries only a small identity
// subset, so anything not in that subset is undefined right after a
// guest signs in. /photos is the payload that refreshes on every
// gallery load, which is why the fields were added there too.
info_mode: (data?.event as { info_mode?: 'inherit' | 'custom' | 'off' })?.info_mode,
info_markdown: (data?.event as { info_markdown?: string | null })?.info_markdown,
}}
brandingSettings={brandingSettings}
heroLogoVisible={data?.event?.hero_logo_visible !== false}
heroLogoSize={data?.event?.hero_logo_size || undefined}
headerStyle={data?.event?.header_style || theme.headerStyle}
showLogout={showLogoutControl}
onLogout={logout}
// Old Download All header button is replaced by the new
// showHeaderDownload below — accent-coloured, always visible when
// downloads are allowed, sits right before Logout (#386).
showDownloadAll={false}
onDownloadAll={handleDownloadAll}
isDownloading={downloadAllMutation.isPending}
menuButton={
// Menu icon is shown when the event theme uses the sidebar
// controls style. The button is icon-only — the redundant
// "Menu" text label was dropped (#386). The wrapper aligns the
// icon to the very left of the header so the logo lines up
// with the leftmost gallery image.
showSidebar ? (
<Button
variant="ghost"
size="sm"
className="gallery-btn p-2"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label={t('gallery.toggleMenu')}
>
<Menu className="w-5 h-5" />
</Button>
) : undefined
}
showHeaderDownload={allowDownloads}
onHeaderDownload={handleDownloadAll}
headerExtra={(() => {
const items = [];
if (daysUntilExpiration !== null && daysUntilExpiration <= 1 && daysUntilExpiration > 0 && event.expires_at) {
items.push(
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
);
}
// Upload button - always show when uploads are allowed (regardless of layout/theme loading state)
const allowUploads = data?.event?.allow_user_uploads || event?.allow_user_uploads;
if (allowUploads) {
items.push(
<Button
key="upload-button"
variant="outline"
size="sm"
leftIcon={<Upload className="w-4 h-4" />}
onClick={() => setShowUploadModal(true)}
className={!showSidebar ? 'flex-1 sm:flex-initial' : ''}
>
<span className="hidden sm:inline">{t('upload.uploadPhotos')}</span>
<span className="sm:hidden">{t('common.upload')}</span>
</Button>
);
}
return items.length > 0 ? <>{items}</> : null;
})()}
>
{/* Expiration Banner */}
{showUrgentWarning && event.expires_at && (
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
)}
{/* Client Access Banner (#172) */}
{isClient && (
<div className="mt-4 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-amber-600 dark:text-amber-400" />
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
{t('clientAccess.banner')}
</span>
<span className="text-xs text-amber-600 dark:text-amber-400 ml-2">
{t('clientAccess.visibleCount', { visible: visibleCount, total: totalCount })}
</span>
</div>
{isSelectionMode && selectedPhotos.size > 0 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
leftIcon={<EyeOff className="w-4 h-4" />}
onClick={() => handleBulkVisibility('hidden')}
>
{t('clientAccess.hideSelected')}
</Button>
<Button
variant="outline"
size="sm"
leftIcon={<Eye className="w-4 h-4" />}
onClick={() => handleBulkVisibility('visible')}
>
{t('clientAccess.showSelected')}
</Button>
</div>
)}
</div>
</div>
)}
{/* Search and Filters - Only for grid layout, when admin enables the
filter bar globally, and when the gallery actually has photos
(avoids the empty "Search photos by filename" row in the screenshot
from discussion #317). */}
{filterBarShown ? (
<div className="mt-6">
<PhotoFilterBar
categories={data.categories}
photos={data.photos}
selectedCategoryId={selectedCategoryId}
onCategoryChange={setSelectedCategoryId}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortBy={sortBy}
onSortChange={setSortBy}
sortDesc={sortDesc}
onSortDescChange={setSortDesc}
photoCount={filteredPhotos.length}
// Feedback filter props
feedbackEnabled={feedbackEnabled}
activeFilters={activeFilters}
onFilterChange={handleFilterChange}
mediaFilter={mediaFilter}
onMediaFilterChange={setMediaFilter}
showMediaFilter={showMediaFilter}
colorLabelsEnabled={!!feedbackSettings?.allow_color_labels}
activeColorFilters={activeColorFilters}
onColorFilterChange={handleColorFilterToggle}
colorLabelCounts={colorLabelCounts}
/>
</div>
) : null}
{/* People in this gallery (#1074). Sits between the filter bar and
the grid. Renders nothing at all unless the photographer enabled
detection AND left it visible to guests — people_enabled carries
both decisions plus the global feature flag. */}
{peopleEnabled && (
<div className="mt-4">
<PeopleStrip
people={people}
photos={data.photos}
slug={slug}
selectedPersonIds={selectedPersonIds}
onToggle={togglePerson}
onShowAll={() => setShowPeopleSheet(true)}
scan={peopleData?.scan}
collapsed={peopleCollapsed}
onCollapsedChange={handlePeopleCollapsedChange}
/>
{/* Active people filter. The chip row is the single place the
current selection is stated, so "why am I seeing 97 photos"
is always answerable at a glance. */}
{selectedPersonIds.length > 0 && (
<div
className="flex flex-wrap items-center gap-2 py-2 border-t"
style={{ borderColor: 'var(--color-surface-border)' }}
>
{selectedPersonIds.map((id) => {
const person = people.find((p) => p.id === id);
if (!person) return null;
return (
<button
key={id}
type="button"
onClick={() => togglePerson(id)}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary-50 text-primary-700 text-sm hover:bg-primary-100"
>
{person.label || t('gallery.people.unnamedCount', {
count: person.face_count,
defaultValue: `${person.face_count} photos`,
})}
<X size={14} />
</button>
);
})}
{/* Only meaningful with two or more people picked. */}
{selectedPersonIds.length > 1 && (
<button
type="button"
onClick={() => setPeopleMatchAny((v) => !v)}
className="px-2.5 py-1 rounded-full border text-xs"
style={{
color: 'var(--color-text)',
borderColor: 'var(--color-surface-border)',
}}
>
{peopleMatchAny
? t('gallery.people.matchAny', { defaultValue: 'Either person' })
: t('gallery.people.matchAll', { defaultValue: 'Both people' })}
</button>
)}
{/* ml-auto only once there's room for it — at 390px the count
and Clear were pushed against the right edge and clipped. */}
<span className="text-sm sm:ml-auto" style={{ color: 'var(--color-muted-text)' }}>
{t('gallery.people.matchCount', {
count: filteredPhotos.length,
total: totalCount,
defaultValue: `${filteredPhotos.length} of ${totalCount} photos`,
})}
</span>
{/* Hidden entirely when downloads are off for the gallery,
rather than shown-and-failing. */}
{allowDownloads && peopleDownloadableIds.length > 0 && (
<Button
variant="primary"
size="sm"
onClick={handleDownloadPeopleFiltered}
leftIcon={<Download className="w-4 h-4" />}
>
{t('gallery.people.downloadThese', {
count: peopleDownloadableIds.length,
defaultValue: `Download these ${peopleDownloadableIds.length}`,
})}
</Button>
)}
<button
type="button"
onClick={() => { setSelectedPersonIds([]); setPeopleMatchAny(false); }}
className="text-sm underline"
style={{ color: 'var(--color-muted-text)' }}
>
{t('gallery.people.clear', { defaultValue: 'Clear' })}
</button>
</div>
)}
</div>
)}
{/* Photo Grid — when the hero header sits directly under the filter
bar, double the wrapper margin (mt-12) so the hero's decorative
`-mt-6` bleed leaves a visible gap instead of gluing the filter
bar to the hero image (issue #624). */}
<div className={filterBarShown && isHeroHeader ? "mt-12" : "mt-6"}>
<PhotoGridWithLayouts
photos={filteredPhotos}
slug={slug}
people={peopleEnabled ? people : undefined}
onSelectPerson={togglePerson}
categoryId={selectedCategoryId}
onFeedbackChange={() => {
refetch();
// Guest-mode Rated/Liked filter membership + chip counts come
// from my-feedback, not the photo rows (#538) — refresh it too
// so e.g. a cleared rating (#884) leaves the Rated filter.
queryClient.invalidateQueries({ queryKey: ['my-feedback', slug] });
}}
heroPhotoOverride={staticHeroPhoto}
feedbackEnabled={feedbackEnabled}
feedbackOptions={{
allowLikes: !!feedbackSettings?.allow_likes,
allowFavorites: !!feedbackSettings?.allow_favorites,
allowRatings: !!feedbackSettings?.allow_ratings,
allowComments: !!feedbackSettings?.allow_comments,
allowReactions: !!feedbackSettings?.allow_reactions,
requireNameEmail: !!feedbackSettings?.require_name_email,
}}
isSelectionMode={isSelectionMode}
selectedPhotos={selectedPhotos}
onSelectionChange={setSelectedPhotos}
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
showSelectionControls={!showSidebar}
eventName={event.event_name}
eventLogo={data?.event?.hero_logo_url || brandingSettings?.logo_url}
eventDate={event.event_date}
expiresAt={event.expires_at}
allowDownloads={allowDownloads}
downloadChoices={downloadChoices}
downloadStandard={data?.event?.download_resolution?.standard}
protectionLevel={protectionLevel}
useEnhancedProtection={protectionLevel !== 'basic'}
disableRightClick={disableRightClick}
enableDevtoolsProtection={enableDevtoolsProtection}
useCanvasRendering={useCanvasRendering}
heroLogoVisible={data?.event?.hero_logo_visible !== false}
heroLogoSize={data?.event?.hero_logo_size || 'medium'}
heroLogoPosition={data?.event?.hero_logo_position || 'top'}
headerStyle={data?.event?.header_style || theme.headerStyle}
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
welcomeMessage={event.welcome_message}
isClient={isClient}
onToggleVisibility={isClient ? handleToggleVisibility : undefined}
showOriginalFilename={showOriginalFilename}
/>
</div>
{/* Upload Modal */}
{showUploadModal && (data?.event?.allow_user_uploads || event?.allow_user_uploads) && (
<UserPhotoUpload
eventId={data?.event?.id || event?.id}
categoryId={data?.event?.upload_category_id || event?.upload_category_id}
onUploadComplete={() => {
setShowUploadModal(false);
// Refetch photos after upload
window.location.reload(); // Simple reload for now
}}
onClose={() => setShowUploadModal(false)}
/>
)}
{/* Download size picker (#858) — "download all", or a selection. */}
{(showResolutionPicker || resolutionPickerIds) && (
<DownloadResolutionModal
slug={slug}
choices={downloadChoices}
standardResolution={data?.event?.download_resolution?.standard}
photoIds={resolutionPickerIds || undefined}
onClose={() => {
setShowResolutionPicker(false);
setResolutionPickerIds(null);
}}
/>
)}
{/* "Show all" people (#1074) — a bottom sheet on mobile. */}
{peopleEnabled && (
<PeopleSheet
open={showPeopleSheet}
onClose={() => setShowPeopleSheet(false)}
people={people}
photos={data?.photos || []}
slug={slug}
selectedPersonIds={selectedPersonIds}
onToggle={togglePerson}
/>
)}
</GalleryLayout>
</>
</GuestIdentityProvider>
);
};