From 3bcded78a448f5b099e87a73c7f1e44e859882aa Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:57:08 +0200 Subject: [PATCH] feat(gallery): multi-select feedback filters + sort direction controls (#889) (#929) * feat(gallery): multi-select feedback filters + sort direction controls (#889) * fix(gallery): keep mobile sidebar open while combining feedback filters (#889) * fix(gallery): generic sort icon when direction is uncontrolled (#889) --------- Co-authored-by: Paul Nothaft --- .../src/components/gallery/GalleryFilter.tsx | 39 ++++---- .../src/components/gallery/GallerySidebar.tsx | 48 ++++++++-- .../src/components/gallery/GalleryView.tsx | 89 ++++++++++--------- .../src/components/gallery/PhotoFilterBar.tsx | 73 +++++++++++---- .../PhotoFilterBar.feedbackChips.test.tsx | 4 +- 5 files changed, 170 insertions(+), 83 deletions(-) diff --git a/frontend/src/components/gallery/GalleryFilter.tsx b/frontend/src/components/gallery/GalleryFilter.tsx index efd43820..d27e2323 100644 --- a/frontend/src/components/gallery/GalleryFilter.tsx +++ b/frontend/src/components/gallery/GalleryFilter.tsx @@ -4,9 +4,13 @@ import { Button } from '../common'; import { useTranslation } from 'react-i18next'; export type FilterType = 'all' | 'liked' | 'favorited' | 'rated' | 'commented'; +// Multi-select feedback filters (#889): the active set holds the concrete +// filters; an empty set means "All". +export type FeedbackFilterType = Exclude; interface GalleryFilterProps { - currentFilter: FilterType; + activeFilters: FeedbackFilterType[]; + // Clicking a filter toggles it in the parent's set; 'all' clears the set. onFilterChange: (filter: FilterType) => void; feedbackEnabled: boolean; likeCount?: number; @@ -18,7 +22,7 @@ interface GalleryFilterProps { } export const GalleryFilter: React.FC = ({ - currentFilter, + activeFilters, onFilterChange, feedbackEnabled, likeCount = 0, @@ -30,6 +34,9 @@ export const GalleryFilter: React.FC = ({ }) => { const { t } = useTranslation(); + const isActive = (filter: FilterType) => + filter === 'all' ? activeFilters.length === 0 : activeFilters.includes(filter); + if (!feedbackEnabled) { return null; } @@ -44,7 +51,7 @@ export const GalleryFilter: React.FC = ({
+ +
+ )} )} diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 9826956d..821ef102 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -17,7 +17,7 @@ import { UserPhotoUpload } from './UserPhotoUpload'; import { GuestNamePromptModal } from './GuestNamePromptModal'; import { GuestRecoveryModal } from './GuestRecoveryModal'; import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext'; -import type { FilterType } from './GalleryFilter'; +import type { FilterType, FeedbackFilterType } from './GalleryFilter'; import { analyticsService } from '../../services/analytics.service'; import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; import { api } from '../../config/api'; @@ -90,7 +90,18 @@ export const GalleryView: React.FC = ({ slug, event }) => { useGalleryCustomCss(slug); const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard'); - const [filterType, setFilterType] = useState('all'); + // Multi-select feedback filters (#889): OR-combined; empty = "All". + // Clicking a filter toggles it, clicking "All" clears the set. + const [activeFilters, setActiveFilters] = useState([]); + 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(''); const [staticHeroPhoto, setStaticHeroPhoto] = useState(null); @@ -258,7 +269,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { // // 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 filterType would leave "Liked (0)" stale until the user + // — 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 = ({ slug, event }) => { const [defaultHeroPhoto, setDefaultHeroPhoto] = useState(null); useEffect(() => { - if (!defaultHeroPhoto && data?.photos && filterType === 'all') { + if (!defaultHeroPhoto && data?.photos && activeFilters.length === 0) { let hero: Photo | null = null; const heroId = data?.event?.hero_photo_id || null; if (heroId) { @@ -363,7 +374,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { setStaticHeroPhoto(hero); } } - }, [data?.photos, data?.event?.hero_photo_id, filterType, defaultHeroPhoto]); + }, [data?.photos, data?.event?.hero_photo_id, activeFilters, defaultHeroPhoto]); // Switch hero photo when a category with its own hero image is selected useEffect(() => { @@ -504,36 +515,30 @@ export const GalleryView: React.FC = ({ slug, event }) => { ); } - // Apply feedback filter. In guest identity mode the 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. - switch (filterType) { - case 'liked': - photos = isGuestIdentityMode - ? photos.filter(photo => myFeedbackPhotoIds.liked.has(photo.id)) - : photos.filter(photo => (photo.like_count || 0) > 0); - break; - case 'favorited': - photos = isGuestIdentityMode - ? photos.filter(photo => myFeedbackPhotoIds.favorited.has(photo.id)) - : photos.filter(photo => (photo.favorite_count || 0) > 0); - break; - case 'rated': - photos = isGuestIdentityMode - ? photos.filter(photo => myFeedbackPhotoIds.rated.has(photo.id)) - : photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0); - break; - case 'commented': - photos = isGuestIdentityMode - ? photos.filter(photo => myFeedbackPhotoIds.commented.has(photo.id)) - : photos.filter(photo => (photo.comment_count || 0) > 0); - break; - default: - break; + // 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 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 sorting @@ -576,7 +581,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { } return photos; - }, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, filterType, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds]); + }, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, activeFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds]); // Counts shown in the filter chips ("Liked (N)", etc.). In guest // mode these need to mirror the per-guest filter behaviour above — @@ -862,6 +867,8 @@ export const GalleryView: React.FC = ({ slug, event }) => { onSearchChange={setSearchTerm} sortBy={sortBy} onSortChange={setSortBy} + sortDesc={sortDesc} + onSortDescChange={setSortDesc} isSelectionMode={isSelectionMode} onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)} selectedCount={selectedPhotos.size} @@ -876,8 +883,8 @@ export const GalleryView: React.FC = ({ slug, event }) => { allowUploads={data?.event?.allow_user_uploads || event?.allow_user_uploads || false} onUploadClick={() => setShowUploadModal(true)} feedbackEnabled={feedbackEnabled} - filterType={filterType} - onFilterChange={setFilterType} + activeFilters={activeFilters} + onFilterChange={handleFilterChange} mediaFilter={mediaFilter} onMediaFilterChange={setMediaFilter} showMediaFilter={showMediaFilter} @@ -1015,11 +1022,13 @@ export const GalleryView: React.FC = ({ slug, event }) => { onSearchChange={setSearchTerm} sortBy={sortBy} onSortChange={setSortBy} + sortDesc={sortDesc} + onSortDescChange={setSortDesc} photoCount={filteredPhotos.length} // Feedback filter props feedbackEnabled={feedbackEnabled} - currentFilter={filterType} - onFilterChange={setFilterType} + activeFilters={activeFilters} + onFilterChange={handleFilterChange} mediaFilter={mediaFilter} onMediaFilterChange={setMediaFilter} showMediaFilter={showMediaFilter} diff --git a/frontend/src/components/gallery/PhotoFilterBar.tsx b/frontend/src/components/gallery/PhotoFilterBar.tsx index 239cc850..c329c355 100644 --- a/frontend/src/components/gallery/PhotoFilterBar.tsx +++ b/frontend/src/components/gallery/PhotoFilterBar.tsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; -import { Search, SortAsc, Grid, Heart, Star, MessageSquare, Bookmark } from 'lucide-react'; +import { Search, SortAsc, SortDesc, Grid, Heart, Star, MessageSquare, Bookmark } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button, Input } from '../common'; -import type { FilterType } from './GalleryFilter'; +import type { FilterType, FeedbackFilterType } from './GalleryFilter'; interface PhotoCategory { id: number | string; @@ -27,10 +27,15 @@ interface PhotoFilterBarProps { onSearchChange: (term: string) => void; sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date'; onSortChange: (sort: 'date' | 'name' | 'size' | 'rating' | 'capture_date') => void; + // Sort direction (#889). Direction controls only render when the + // callback is provided (PreviewPage doesn't pass it). + sortDesc?: boolean; + onSortDescChange?: (desc: boolean) => void; photoCount: number; - // Feedback filter props + // Feedback filter props. Multi-select (#889): empty array = "All"; + // clicking a filter toggles it in the parent's set. feedbackEnabled?: boolean; - currentFilter?: FilterType; + activeFilters?: FeedbackFilterType[]; onFilterChange?: (filter: FilterType) => void; mediaFilter?: 'all' | 'photo' | 'video'; onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void; @@ -46,9 +51,11 @@ export const PhotoFilterBar: React.FC = ({ onSearchChange, sortBy, onSortChange, + sortDesc = true, + onSortDescChange, photoCount, feedbackEnabled = false, - currentFilter = 'all', + activeFilters = [], onFilterChange, mediaFilter = 'all', onMediaFilterChange, @@ -56,6 +63,8 @@ export const PhotoFilterBar: React.FC = ({ }) => { const { t } = useTranslation(); const [showSortMenu, setShowSortMenu] = useState(false); + const isActive = (filter: FilterType) => + filter === 'all' ? activeFilters.length === 0 : activeFilters.includes(filter as FeedbackFilterType); return (
{/* Search and Sort */} @@ -77,7 +86,9 @@ export const PhotoFilterBar: React.FC = ({ + + {/* Sort direction (#889) */} + {onSortDescChange && ( +
+ + +
+ )}
)} @@ -202,7 +243,7 @@ export const PhotoFilterBar: React.FC = ({