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 <[email protected]>
This commit is contained in:
Paul Nothaft
2026-07-31 08:57:08 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 08ff9f20e7
commit 3bcded78a4
5 changed files with 170 additions and 83 deletions
@@ -4,9 +4,13 @@ import { Button } from '../common';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
export type FilterType = 'all' | 'liked' | 'favorited' | 'rated' | 'commented'; 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<FilterType, 'all'>;
interface GalleryFilterProps { interface GalleryFilterProps {
currentFilter: FilterType; activeFilters: FeedbackFilterType[];
// Clicking a filter toggles it in the parent's set; 'all' clears the set.
onFilterChange: (filter: FilterType) => void; onFilterChange: (filter: FilterType) => void;
feedbackEnabled: boolean; feedbackEnabled: boolean;
likeCount?: number; likeCount?: number;
@@ -18,7 +22,7 @@ interface GalleryFilterProps {
} }
export const GalleryFilter: React.FC<GalleryFilterProps> = ({ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
currentFilter, activeFilters,
onFilterChange, onFilterChange,
feedbackEnabled, feedbackEnabled,
likeCount = 0, likeCount = 0,
@@ -30,6 +34,9 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const isActive = (filter: FilterType) =>
filter === 'all' ? activeFilters.length === 0 : activeFilters.includes(filter);
if (!feedbackEnabled) { if (!feedbackEnabled) {
return null; return null;
} }
@@ -44,7 +51,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant={currentFilter === 'all' ? 'primary' : 'outline'} variant={isActive('all') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('all')} onClick={() => onFilterChange('all')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -53,7 +60,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-current"><path d="M3 3h8v8H3V3zm10 0h8v8h-8V3zM3 13h8v8H3v-8zm10 8v-8h8v8h-8z"/></svg> <svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-current"><path d="M3 3h8v8H3V3zm10 0h8v8h-8V3zM3 13h8v8H3v-8zm10 8v-8h8v8h-8z"/></svg>
</Button> </Button>
<Button <Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'} variant={isActive('liked') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('liked')} onClick={() => onFilterChange('liked')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -62,7 +69,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
<Heart className="w-3.5 h-3.5" /> <Heart className="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'} variant={isActive('favorited') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('favorited')} onClick={() => onFilterChange('favorited')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -71,7 +78,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
<Bookmark className="w-3.5 h-3.5" /> <Bookmark className="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant={currentFilter === 'rated' ? 'primary' : 'outline'} variant={isActive('rated') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('rated')} onClick={() => onFilterChange('rated')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -80,7 +87,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
<Star className="w-3.5 h-3.5" /> <Star className="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant={currentFilter === 'commented' ? 'primary' : 'outline'} variant={isActive('commented') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('commented')} onClick={() => onFilterChange('commented')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -104,7 +111,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
variant={currentFilter === 'all' ? 'primary' : 'outline'} variant={isActive('all') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('all')} onClick={() => onFilterChange('all')}
className="text-xs flex-1 min-w-[80px]" className="text-xs flex-1 min-w-[80px]"
@@ -113,7 +120,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
</Button> </Button>
<Button <Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'} variant={isActive('liked') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('liked')} onClick={() => onFilterChange('liked')}
className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1" className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1"
@@ -123,7 +130,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
</Button> </Button>
<Button <Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'} variant={isActive('favorited') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('favorited')} onClick={() => onFilterChange('favorited')}
className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1" className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1"
@@ -133,7 +140,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
</Button> </Button>
<Button <Button
variant={currentFilter === 'rated' ? 'primary' : 'outline'} variant={isActive('rated') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('rated')} onClick={() => onFilterChange('rated')}
className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1" className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1"
@@ -151,7 +158,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
</span> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant={currentFilter === 'all' ? 'primary' : 'outline'} variant={isActive('all') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('all')} onClick={() => onFilterChange('all')}
className="text-xs sm:text-sm" className="text-xs sm:text-sm"
@@ -160,7 +167,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
</Button> </Button>
<Button <Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'} variant={isActive('liked') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('liked')} onClick={() => onFilterChange('liked')}
className="text-xs sm:text-sm flex items-center gap-1" className="text-xs sm:text-sm flex items-center gap-1"
@@ -175,7 +182,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
</Button> </Button>
<Button <Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'} variant={isActive('favorited') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('favorited')} onClick={() => onFilterChange('favorited')}
className="text-xs sm:text-sm flex items-center gap-1" className="text-xs sm:text-sm flex items-center gap-1"
@@ -190,7 +197,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
</Button> </Button>
<Button <Button
variant={currentFilter === 'rated' ? 'primary' : 'outline'} variant={isActive('rated') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('rated')} onClick={() => onFilterChange('rated')}
className="text-xs sm:text-sm flex items-center gap-1" className="text-xs sm:text-sm flex items-center gap-1"
@@ -205,7 +212,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
</Button> </Button>
<Button <Button
variant={currentFilter === 'commented' ? 'primary' : 'outline'} variant={isActive('commented') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('commented')} onClick={() => onFilterChange('commented')}
className="text-xs sm:text-sm flex items-center gap-1" className="text-xs sm:text-sm flex items-center gap-1"
@@ -1,9 +1,9 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star, Upload, Camera } from 'lucide-react'; import { X, Download, Filter, SortAsc, SortDesc, Search, Calendar, Type, HardDrive, Check, Star, Upload, Camera } from 'lucide-react';
import { Button } from '../common'; import { Button } from '../common';
import { PhotoCategory } from '../../types'; import { PhotoCategory } from '../../types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { GalleryFilter, type FilterType } from './GalleryFilter'; import { GalleryFilter, type FilterType, type FeedbackFilterType } from './GalleryFilter';
interface GallerySidebarProps { interface GallerySidebarProps {
isOpen: boolean; isOpen: boolean;
@@ -15,6 +15,9 @@ interface GallerySidebarProps {
onSearchChange: (term: string) => void; onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date'; sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date';
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating' | 'capture_date') => void; onSortChange: (sort: 'date' | 'name' | 'size' | 'rating' | 'capture_date') => void;
// Sort direction (#889)
sortDesc?: boolean;
onSortDescChange?: (desc: boolean) => void;
isSelectionMode: boolean; isSelectionMode: boolean;
onToggleSelectionMode: () => void; onToggleSelectionMode: () => void;
selectedCount: number; selectedCount: number;
@@ -29,7 +32,8 @@ interface GallerySidebarProps {
allowUploads?: boolean; allowUploads?: boolean;
onUploadClick?: () => void; onUploadClick?: () => void;
feedbackEnabled?: boolean; feedbackEnabled?: boolean;
filterType?: FilterType; // Multi-select feedback filters (#889): empty array = "All".
activeFilters?: FeedbackFilterType[];
onFilterChange?: (filter: FilterType) => void; onFilterChange?: (filter: FilterType) => void;
likeCount?: number; likeCount?: number;
favoriteCount?: number; favoriteCount?: number;
@@ -49,6 +53,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
onSearchChange, onSearchChange,
sortBy, sortBy,
onSortChange, onSortChange,
sortDesc = true,
onSortDescChange,
isSelectionMode, isSelectionMode,
onToggleSelectionMode, onToggleSelectionMode,
selectedCount, selectedCount,
@@ -63,7 +69,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
allowUploads, allowUploads,
onUploadClick, onUploadClick,
feedbackEnabled = false, feedbackEnabled = false,
filterType = 'all', activeFilters = [],
onFilterChange, onFilterChange,
likeCount = 0, likeCount = 0,
favoriteCount = 0, favoriteCount = 0,
@@ -223,11 +229,11 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
{feedbackEnabled && onFilterChange && ( {feedbackEnabled && onFilterChange && (
<div className="gallery-sidebar-section gallery-sidebar-feedback p-4 border-b border-surface"> <div className="gallery-sidebar-section gallery-sidebar-feedback p-4 border-b border-surface">
<GalleryFilter <GalleryFilter
currentFilter={filterType} activeFilters={activeFilters}
onFilterChange={(filter) => { // Unlike the single-select category/sort buttons, feedback
onFilterChange(filter); // filters are multi-select toggles (#889) — keep the mobile
if (isMobile) onClose(); // sidebar open so several can be combined in one visit.
}} onFilterChange={onFilterChange}
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
likeCount={likeCount} likeCount={likeCount}
favoriteCount={favoriteCount} favoriteCount={favoriteCount}
@@ -374,6 +380,30 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
); );
})} })}
</div> </div>
{/* Sort direction (#889) */}
{onSortDescChange && (
<div className="flex items-center gap-2 mt-3">
<Button
variant={!sortDesc ? 'primary' : 'outline'}
size="sm"
leftIcon={<SortAsc className="w-4 h-4" />}
onClick={() => onSortDescChange(false)}
className="gallery-btn flex-1"
>
{t('gallery.sortAscending', 'Sort ascending')}
</Button>
<Button
variant={sortDesc ? 'primary' : 'outline'}
size="sm"
leftIcon={<SortDesc className="w-4 h-4" />}
onClick={() => onSortDescChange(true)}
className="gallery-btn flex-1"
>
{t('gallery.sortDescending', 'Sort descending')}
</Button>
</div>
)}
</div> </div>
)} )}
</div> </div>
+49 -40
View File
@@ -17,7 +17,7 @@ import { UserPhotoUpload } from './UserPhotoUpload';
import { GuestNamePromptModal } from './GuestNamePromptModal'; import { GuestNamePromptModal } from './GuestNamePromptModal';
import { GuestRecoveryModal } from './GuestRecoveryModal'; import { GuestRecoveryModal } from './GuestRecoveryModal';
import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext'; import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext';
import type { FilterType } from './GalleryFilter'; import type { FilterType, FeedbackFilterType } from './GalleryFilter';
import { analyticsService } from '../../services/analytics.service'; import { analyticsService } from '../../services/analytics.service';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { api } from '../../config/api'; import { api } from '../../config/api';
@@ -90,7 +90,18 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
useGalleryCustomCss(slug); useGalleryCustomCss(slug);
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard'); const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
const [filterType, setFilterType] = useState<FilterType>('all'); // Multi-select feedback filters (#889): OR-combined; empty = "All".
// Clicking a filter toggles it, clicking "All" clears the set.
const [activeFilters, setActiveFilters] = useState<FeedbackFilterType[]>([]);
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 [mediaFilter, setMediaFilter] = useState<'all' | 'photo' | 'video'>('all');
const [guestId, setGuestId] = useState<string>(''); const [guestId, setGuestId] = useState<string>('');
const [staticHeroPhoto, setStaticHeroPhoto] = useState<Photo | null>(null); const [staticHeroPhoto, setStaticHeroPhoto] = useState<Photo | null>(null);
@@ -258,7 +269,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// //
// Always-on in guest mode (not gated on the active filter) because // Always-on in guest mode (not gated on the active filter) because
// the chip counts render whether or not a feedback filter is selected // 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. // clicks the chip, which is the same UX cliff bug 1 was reporting.
const isGuestIdentityMode = feedbackSettings?.identity_mode === 'guest'; const isGuestIdentityMode = feedbackSettings?.identity_mode === 'guest';
const { data: myFeedbackRows } = useQuery<Array<{ const { data: myFeedbackRows } = useQuery<Array<{
@@ -348,7 +359,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const [defaultHeroPhoto, setDefaultHeroPhoto] = useState<Photo | null>(null); const [defaultHeroPhoto, setDefaultHeroPhoto] = useState<Photo | null>(null);
useEffect(() => { useEffect(() => {
if (!defaultHeroPhoto && data?.photos && filterType === 'all') { if (!defaultHeroPhoto && data?.photos && activeFilters.length === 0) {
let hero: Photo | null = null; let hero: Photo | null = null;
const heroId = data?.event?.hero_photo_id || null; const heroId = data?.event?.hero_photo_id || null;
if (heroId) { if (heroId) {
@@ -363,7 +374,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
setStaticHeroPhoto(hero); 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 // Switch hero photo when a category with its own hero image is selected
useEffect(() => { useEffect(() => {
@@ -504,36 +515,30 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
); );
} }
// Apply feedback filter. In guest identity mode the filter has to // Apply feedback filters. Multi-select (#889): a photo matching ANY
// scope to the *current guest's* interactions (#538 bug 1) — the // active filter passes (OR-combined); an empty set means no feedback
// aggregate counts on each photo row are global across all guests, // filtering. In guest identity mode each filter has to scope to the
// which gave an empty grid when the guest had liked photos that // *current guest's* interactions (#538 bug 1) — the aggregate counts
// nobody else had touched. Falls back to the aggregate-count check // on each photo row are global across all guests, which gave an empty
// in simple/non-guest mode where there's no per-person identity to // grid when the guest had liked photos that nobody else had touched.
// scope by. // Falls back to the aggregate-count check in simple/non-guest mode
switch (filterType) { // where there's no per-person identity to scope by.
case 'liked': if (activeFilters.length > 0) {
photos = isGuestIdentityMode const matchers: Record<FeedbackFilterType, (photo: Photo) => boolean> = {
? photos.filter(photo => myFeedbackPhotoIds.liked.has(photo.id)) liked: (photo) => isGuestIdentityMode
: photos.filter(photo => (photo.like_count || 0) > 0); ? myFeedbackPhotoIds.liked.has(photo.id)
break; : (photo.like_count || 0) > 0,
case 'favorited': favorited: (photo) => isGuestIdentityMode
photos = isGuestIdentityMode ? myFeedbackPhotoIds.favorited.has(photo.id)
? photos.filter(photo => myFeedbackPhotoIds.favorited.has(photo.id)) : (photo.favorite_count || 0) > 0,
: photos.filter(photo => (photo.favorite_count || 0) > 0); rated: (photo) => isGuestIdentityMode
break; ? myFeedbackPhotoIds.rated.has(photo.id)
case 'rated': : (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0,
photos = isGuestIdentityMode commented: (photo) => isGuestIdentityMode
? photos.filter(photo => myFeedbackPhotoIds.rated.has(photo.id)) ? myFeedbackPhotoIds.commented.has(photo.id)
: photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0); : (photo.comment_count || 0) > 0,
break; };
case 'commented': photos = photos.filter(photo => activeFilters.some(filter => matchers[filter](photo)));
photos = isGuestIdentityMode
? photos.filter(photo => myFeedbackPhotoIds.commented.has(photo.id))
: photos.filter(photo => (photo.comment_count || 0) > 0);
break;
default:
break;
} }
// Apply sorting // Apply sorting
@@ -576,7 +581,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
} }
return photos; 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 // Counts shown in the filter chips ("Liked (N)", etc.). In guest
// mode these need to mirror the per-guest filter behaviour above — // mode these need to mirror the per-guest filter behaviour above —
@@ -862,6 +867,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
onSearchChange={setSearchTerm} onSearchChange={setSearchTerm}
sortBy={sortBy} sortBy={sortBy}
onSortChange={setSortBy} onSortChange={setSortBy}
sortDesc={sortDesc}
onSortDescChange={setSortDesc}
isSelectionMode={isSelectionMode} isSelectionMode={isSelectionMode}
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)} onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
selectedCount={selectedPhotos.size} selectedCount={selectedPhotos.size}
@@ -876,8 +883,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
allowUploads={data?.event?.allow_user_uploads || event?.allow_user_uploads || false} allowUploads={data?.event?.allow_user_uploads || event?.allow_user_uploads || false}
onUploadClick={() => setShowUploadModal(true)} onUploadClick={() => setShowUploadModal(true)}
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
filterType={filterType} activeFilters={activeFilters}
onFilterChange={setFilterType} onFilterChange={handleFilterChange}
mediaFilter={mediaFilter} mediaFilter={mediaFilter}
onMediaFilterChange={setMediaFilter} onMediaFilterChange={setMediaFilter}
showMediaFilter={showMediaFilter} showMediaFilter={showMediaFilter}
@@ -1015,11 +1022,13 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
onSearchChange={setSearchTerm} onSearchChange={setSearchTerm}
sortBy={sortBy} sortBy={sortBy}
onSortChange={setSortBy} onSortChange={setSortBy}
sortDesc={sortDesc}
onSortDescChange={setSortDesc}
photoCount={filteredPhotos.length} photoCount={filteredPhotos.length}
// Feedback filter props // Feedback filter props
feedbackEnabled={feedbackEnabled} feedbackEnabled={feedbackEnabled}
currentFilter={filterType} activeFilters={activeFilters}
onFilterChange={setFilterType} onFilterChange={handleFilterChange}
mediaFilter={mediaFilter} mediaFilter={mediaFilter}
onMediaFilterChange={setMediaFilter} onMediaFilterChange={setMediaFilter}
showMediaFilter={showMediaFilter} showMediaFilter={showMediaFilter}
@@ -1,8 +1,8 @@
import React, { useState } from 'react'; 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 { useTranslation } from 'react-i18next';
import { Button, Input } from '../common'; import { Button, Input } from '../common';
import type { FilterType } from './GalleryFilter'; import type { FilterType, FeedbackFilterType } from './GalleryFilter';
interface PhotoCategory { interface PhotoCategory {
id: number | string; id: number | string;
@@ -27,10 +27,15 @@ interface PhotoFilterBarProps {
onSearchChange: (term: string) => void; onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date'; sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date';
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating' | 'capture_date') => void; 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; 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; feedbackEnabled?: boolean;
currentFilter?: FilterType; activeFilters?: FeedbackFilterType[];
onFilterChange?: (filter: FilterType) => void; onFilterChange?: (filter: FilterType) => void;
mediaFilter?: 'all' | 'photo' | 'video'; mediaFilter?: 'all' | 'photo' | 'video';
onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void; onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void;
@@ -46,9 +51,11 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
onSearchChange, onSearchChange,
sortBy, sortBy,
onSortChange, onSortChange,
sortDesc = true,
onSortDescChange,
photoCount, photoCount,
feedbackEnabled = false, feedbackEnabled = false,
currentFilter = 'all', activeFilters = [],
onFilterChange, onFilterChange,
mediaFilter = 'all', mediaFilter = 'all',
onMediaFilterChange, onMediaFilterChange,
@@ -56,6 +63,8 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showSortMenu, setShowSortMenu] = useState(false); const [showSortMenu, setShowSortMenu] = useState(false);
const isActive = (filter: FilterType) =>
filter === 'all' ? activeFilters.length === 0 : activeFilters.includes(filter as FeedbackFilterType);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Search and Sort */} {/* Search and Sort */}
@@ -77,7 +86,9 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
<Button <Button
variant="outline" variant="outline"
size="md" size="md"
leftIcon={<SortAsc className="w-4 h-4" />} // Direction-aware icon only when the parent controls direction —
// PreviewPage renders this bar without it and sorts its own way.
leftIcon={onSortDescChange && sortDesc ? <SortDesc className="w-4 h-4" /> : <SortAsc className="w-4 h-4" />}
onClick={() => setShowSortMenu(!showSortMenu)} onClick={() => setShowSortMenu(!showSortMenu)}
className="w-full md:w-auto text-sm md:text-base" className="w-full md:w-auto text-sm md:text-base"
> >
@@ -147,6 +158,36 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
> >
{t('gallery.sortByRating', 'Sort by Rating')} {t('gallery.sortByRating', 'Sort by Rating')}
</button> </button>
{/* Sort direction (#889) */}
{onSortDescChange && (
<div className="border-t border-surface mt-1 pt-1">
<button
onClick={() => {
onSortDescChange(false);
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 flex items-center gap-2 ${
!sortDesc ? 'bg-accent-dark text-white' : 'text-muted-theme'
}`}
>
<SortAsc className="w-4 h-4" />
{t('gallery.sortAscending', 'Sort ascending')}
</button>
<button
onClick={() => {
onSortDescChange(true);
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 flex items-center gap-2 ${
sortDesc ? 'bg-accent-dark text-white' : 'text-muted-theme'
}`}
>
<SortDesc className="w-4 h-4" />
{t('gallery.sortDescending', 'Sort descending')}
</button>
</div>
)}
</div> </div>
)} )}
</div> </div>
@@ -202,7 +243,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant={currentFilter === 'all' ? 'primary' : 'outline'} variant={isActive('all') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('all')} onClick={() => onFilterChange('all')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -211,7 +252,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
<Grid className="w-3.5 h-3.5" /> <Grid className="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'} variant={isActive('liked') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('liked')} onClick={() => onFilterChange('liked')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -220,7 +261,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
<Heart className="w-3.5 h-3.5" /> <Heart className="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'} variant={isActive('favorited') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('favorited')} onClick={() => onFilterChange('favorited')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -229,7 +270,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
<Bookmark className="w-3.5 h-3.5" /> <Bookmark className="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant={currentFilter === 'rated' ? 'primary' : 'outline'} variant={isActive('rated') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('rated')} onClick={() => onFilterChange('rated')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -238,7 +279,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
<Star className="w-3.5 h-3.5" /> <Star className="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant={currentFilter === 'commented' ? 'primary' : 'outline'} variant={isActive('commented') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('commented')} onClick={() => onFilterChange('commented')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -301,7 +342,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant={currentFilter === 'all' ? 'primary' : 'outline'} variant={isActive('all') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('all')} onClick={() => onFilterChange('all')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -310,7 +351,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
<Grid className="w-3.5 h-3.5" /> <Grid className="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'} variant={isActive('liked') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('liked')} onClick={() => onFilterChange('liked')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -319,7 +360,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
<Heart className="w-3.5 h-3.5" /> <Heart className="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'} variant={isActive('favorited') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('favorited')} onClick={() => onFilterChange('favorited')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -328,7 +369,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
<Bookmark className="w-3.5 h-3.5" /> <Bookmark className="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant={currentFilter === 'rated' ? 'primary' : 'outline'} variant={isActive('rated') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('rated')} onClick={() => onFilterChange('rated')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -337,7 +378,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
<Star className="w-3.5 h-3.5" /> <Star className="w-3.5 h-3.5" />
</Button> </Button>
<Button <Button
variant={currentFilter === 'commented' ? 'primary' : 'outline'} variant={isActive('commented') ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onFilterChange('commented')} onClick={() => onFilterChange('commented')}
className="p-1 w-8 h-8 flex items-center justify-center" className="p-1 w-8 h-8 flex items-center justify-center"
@@ -41,7 +41,7 @@ describe('PhotoFilterBar feedback chips (#802)', () => {
<PhotoFilterBar <PhotoFilterBar
{...baseProps} {...baseProps}
feedbackEnabled feedbackEnabled
currentFilter="all" activeFilters={[]}
onFilterChange={vi.fn()} onFilterChange={vi.fn()}
/> />
); );
@@ -58,7 +58,7 @@ describe('PhotoFilterBar feedback chips (#802)', () => {
categories={[{ id: 1, name: 'Ceremony', slug: 'ceremony' }]} categories={[{ id: 1, name: 'Ceremony', slug: 'ceremony' }]}
photos={[{ id: 1, category_id: 1 } as never]} photos={[{ id: 1, category_id: 1 } as never]}
feedbackEnabled feedbackEnabled
currentFilter="all" activeFilters={[]}
onFilterChange={vi.fn()} onFilterChange={vi.fn()}
/> />
); );