* 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:
co-authored by
Paul Nothaft
parent
08ff9f20e7
commit
3bcded78a4
@@ -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>
|
||||||
|
|||||||
@@ -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()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user