feat: implement feedback filter for liked/favorited photos (Issue #17)

Implemented Feature Request 1 from github.com/the-luap/picpeak/issues/17:
- Added filter functionality to display only liked or favorited photos
- Integrated feedback filter directly into PhotoFilterBar component
- Implemented responsive design with proper mobile/tablet/desktop layouts
- Filter only shows when feedback is enabled for the gallery
- Added proper count display for liked and favorited photos

Improvements:
- Fixed responsive breakpoints (mobile <768px, tablet 768-1023px, desktop ≥1024px)
- Feedback filter shows inline with categories on desktop with vertical divider
- On mobile/tablet, filter appears below categories to prevent layout issues
- Added horizontal scrolling for category buttons to prevent cut-off

Code cleanup:
- Removed all debug console.log statements from production code
- Removed test route from backend gallery.js
- Cleaned up unnecessary logging in frontend components

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-02 22:44:10 +02:00
parent f7a8765f58
commit 41857ec499
10 changed files with 369 additions and 49 deletions
@@ -0,0 +1,122 @@
import React from 'react';
import { Heart, Star } from 'lucide-react';
import { Button } from '../common';
import { useTranslation } from 'react-i18next';
export type FilterType = 'all' | 'liked' | 'favorited';
interface GalleryFilterProps {
currentFilter: FilterType;
onFilterChange: (filter: FilterType) => void;
feedbackEnabled: boolean;
likeCount?: number;
favoriteCount?: number;
className?: string;
isMobile?: boolean;
}
export const GalleryFilter: React.FC<GalleryFilterProps> = ({
currentFilter,
onFilterChange,
feedbackEnabled,
likeCount = 0,
favoriteCount = 0,
className = '',
isMobile = false
}) => {
const { t } = useTranslation();
if (!feedbackEnabled) {
return null;
}
return (
<div className={`${className}`}>
{/* Mobile-optimized vertical layout */}
{isMobile ? (
<div className="space-y-2">
<div className="text-xs text-neutral-600 font-medium">
{t('gallery.feedbackFilter', 'Feedback Filter')}
</div>
<div className="flex flex-wrap gap-2">
<Button
variant={currentFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('all')}
className="text-xs flex-1 min-w-[80px]"
>
{t('gallery.all', 'All')}
</Button>
<Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('liked')}
className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1"
>
<Heart className="w-3 h-3" />
<span>{likeCount > 0 ? likeCount : t('gallery.liked', 'Liked')}</span>
</Button>
<Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('favorited')}
className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1"
>
<Star className="w-3 h-3" />
<span>{favoriteCount > 0 ? favoriteCount : t('gallery.favorites', 'Favorites')}</span>
</Button>
</div>
</div>
) : (
/* Desktop layout - inline with categories */
<div className="flex items-center gap-3">
<span className="text-sm text-neutral-600 font-medium whitespace-nowrap">
{t('gallery.feedbackFilter', 'Feedback Filter')}:
</span>
<div className="flex gap-2">
<Button
variant={currentFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('all')}
className="text-xs sm:text-sm"
>
{t('gallery.all', 'All')}
</Button>
<Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('liked')}
className="text-xs sm:text-sm flex items-center gap-1"
>
<Heart className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('gallery.liked', 'Liked')}</span>
{likeCount > 0 && (
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
{likeCount}
</span>
)}
</Button>
<Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('favorited')}
className="text-xs sm:text-sm flex items-center gap-1"
>
<Star className="w-3 h-3 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{t('gallery.favorited', 'Favorites')}</span>
{favoriteCount > 0 && (
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
{favoriteCount}
</span>
)}
</Button>
</div>
</div>
)}
</div>
);
};
@@ -13,6 +13,7 @@ import { GalleryLayout } from './GalleryLayout';
import { GallerySidebar } from './GallerySidebar';
import { PhotoFilterBar } from './PhotoFilterBar';
import { UserPhotoUpload } from './UserPhotoUpload';
import type { FilterType } from './GalleryFilter';
import { analyticsService } from '../../services/analytics.service';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
@@ -55,9 +56,22 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const { watermarkEnabled } = useWatermarkSettings();
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
const [filterType, setFilterType] = useState<FilterType>('all');
const [guestId, setGuestId] = useState<string>('');
// Fetch photos
const { data, isLoading, error, refetch } = useGalleryPhotos(slug);
// 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 with filter support
const { data, isLoading, error, refetch } = useGalleryPhotos(slug, filterType, guestId);
// Set protection level when data is available
useEffect(() => {
@@ -122,7 +136,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
try {
// Use public endpoint to get feedback settings
const response = await api.get(`/gallery/${slug}/feedback-settings`);
console.log('Feedback settings response:', response.data);
return response.data;
} catch (error) {
console.error('Error fetching feedback settings:', error);
@@ -136,7 +149,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// Update feedbackEnabled when settings change
useEffect(() => {
if (feedbackSettings) {
console.log('Setting feedbackEnabled to:', feedbackSettings.feedback_enabled);
setFeedbackEnabled(feedbackSettings.feedback_enabled || false);
}
}, [feedbackSettings]);
@@ -424,6 +436,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
galleryLayout={theme.galleryLayout}
allowUploads={data?.event?.allow_user_uploads || event?.allow_user_uploads || false}
onUploadClick={() => setShowUploadModal(true)}
feedbackEnabled={feedbackEnabled}
filterType={filterType}
onFilterChange={setFilterType}
likeCount={data?.photos?.filter(p => p.like_count > 0).length || 0}
favoriteCount={data?.photos?.filter(p => p.favorite_count > 0).length || 0}
/>
) : null}
@@ -510,6 +527,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
sortBy={sortBy}
onSortChange={setSortBy}
photoCount={filteredPhotos.length}
// Feedback filter props
feedbackEnabled={feedbackEnabled}
currentFilter={filterType}
onFilterChange={setFilterType}
likeCount={data?.photos?.filter(p => p.like_count > 0).length || 0}
favoriteCount={data?.photos?.filter(p => p.favorite_count > 0).length || 0}
/>
</div>
) : null}
@@ -28,7 +28,6 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
queryKey: ['gallery-feedback-settings', gallerySlug],
queryFn: async () => {
const data = await feedbackService.getGalleryFeedbackSettings(gallerySlug);
console.log('PhotoFeedback received settings:', data);
return data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
@@ -1,7 +1,8 @@
import React, { useState } from 'react';
import { Search, SortAsc, Grid } from 'lucide-react';
import { Search, SortAsc, Grid, Heart, Star } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../common';
import type { FilterType } from './GalleryFilter';
interface PhotoCategory {
id: number;
@@ -13,6 +14,8 @@ interface PhotoCategory {
interface Photo {
id: number;
category_id?: number;
like_count?: number;
favorite_count?: number;
}
interface PhotoFilterBarProps {
@@ -25,6 +28,12 @@ interface PhotoFilterBarProps {
sortBy: 'date' | 'name' | 'size' | 'rating';
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void;
photoCount: number;
// Feedback filter props
feedbackEnabled?: boolean;
currentFilter?: FilterType;
onFilterChange?: (filter: FilterType) => void;
likeCount?: number;
favoriteCount?: number;
}
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
@@ -37,6 +46,11 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
sortBy,
onSortChange,
photoCount,
feedbackEnabled = false,
currentFilter = 'all',
onFilterChange,
likeCount = 0,
favoriteCount = 0,
}) => {
const { t } = useTranslation();
const [showSortMenu, setShowSortMenu] = useState(false);
@@ -44,7 +58,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
return (
<div className="space-y-4">
{/* Search and Sort */}
<div className="flex flex-col sm:flex-row gap-3 sm:gap-4">
<div className="flex flex-col md:flex-row gap-3 md:gap-4">
{/* Search Bar */}
<div className="flex-1">
<Input
@@ -53,7 +67,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="text-sm sm:text-base"
className="text-sm md:text-base"
/>
</div>
@@ -64,9 +78,9 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
size="md"
leftIcon={<SortAsc className="w-4 h-4" />}
onClick={() => setShowSortMenu(!showSortMenu)}
className="w-full sm:w-auto text-sm sm:text-base"
className="w-full md:w-auto text-sm md:text-base"
>
<span className="hidden sm:inline">{t('common.sortBy')} </span>
<span className="hidden md:inline">{t('common.sortBy')} </span>
{sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') :
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
sortBy === 'size' ? t('gallery.sortBySize').replace('Sort by ', '') :
@@ -74,7 +88,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
</Button>
{showSortMenu && (
<div className="absolute right-0 sm:right-auto sm:left-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-10">
<div className="absolute right-0 md:right-auto md:left-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-10">
<button
onClick={() => {
onSortChange('date');
@@ -124,18 +138,19 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
</div>
</div>
{/* Category Filter */}
{categories && categories.length > 0 && (
<div className="space-y-3">
<div className="flex items-start sm:items-center justify-between flex-col sm:flex-row gap-3">
<div className="w-full sm:w-auto overflow-x-auto pb-2 sm:pb-0">
{/* Category and Feedback Filters */}
<div className="space-y-3">
{/* Categories Row */}
{categories && categories.length > 0 && (
<div className="flex items-start lg:items-center justify-between flex-col lg:flex-row gap-3">
<div className="w-full overflow-x-auto pb-2 lg:pb-0">
<div className="flex items-center gap-2 min-w-max">
<Button
variant={selectedCategoryId === null ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(null)}
leftIcon={<Grid className="w-3 h-3 sm:w-4 sm:h-4" />}
className="text-xs sm:text-sm whitespace-nowrap"
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
>
{t('gallery.allPhotos')} ({photos.length})
</Button>
@@ -149,21 +164,92 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
size="sm"
onClick={() => onCategoryChange(category.id)}
className="text-xs sm:text-sm whitespace-nowrap"
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
>
{category.name} ({categoryPhotoCount})
</Button>
);
})}
{/* Feedback Filter - Inline on desktop, below on mobile/tablet */}
{feedbackEnabled && onFilterChange && (
<>
{/* Desktop: Divider and inline filter - only on larger screens */}
<div className="hidden lg:flex items-center gap-2 ml-2 pl-2 border-l border-neutral-300">
<span className="text-sm text-neutral-600 whitespace-nowrap">{t('gallery.feedbackFilter')}:</span>
<Button
variant={currentFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('all')}
className="text-xs sm:text-sm"
>
{t('gallery.all')}
</Button>
<Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('liked')}
className="text-xs sm:text-sm flex items-center gap-1"
>
<Heart className="w-3 h-3" />
{likeCount > 0 && <span>{likeCount}</span>}
</Button>
<Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('favorited')}
className="text-xs sm:text-sm flex items-center gap-1"
>
<Star className="w-3 h-3" />
{favoriteCount > 0 && <span>{favoriteCount}</span>}
</Button>
</div>
</>
)}
</div>
</div>
<p className="text-xs sm:text-sm text-neutral-600 flex-shrink-0">
<p className="text-xs md:text-sm text-neutral-600 flex-shrink-0 ml-auto">
{photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')}
</p>
</div>
</div>
)}
)}
{/* Mobile/Tablet: Feedback Filter below categories */}
{feedbackEnabled && onFilterChange && (
<div className="flex lg:hidden items-center gap-2">
<span className="text-xs text-neutral-600">{t('gallery.feedbackFilter')}:</span>
<div className="flex gap-1 flex-1">
<Button
variant={currentFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('all')}
className="text-xs flex-1"
>
{t('gallery.all')}
</Button>
<Button
variant={currentFilter === 'liked' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('liked')}
className="text-xs flex-1 flex items-center justify-center gap-1"
>
<Heart className="w-3 h-3" />
{likeCount > 0 && <span>{likeCount}</span>}
</Button>
<Button
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
size="sm"
onClick={() => onFilterChange('favorited')}
className="text-xs flex-1 flex items-center justify-center gap-1"
>
<Star className="w-3 h-3" />
{favoriteCount > 0 && <span>{favoriteCount}</span>}
</Button>
</div>
</div>
)}
</div>
</div>
);
};
@@ -35,9 +35,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const [touchDistance, setTouchDistance] = useState<number | null>(null);
const [showFeedback, setShowFeedback] = useState(false);
// Debug logging
console.log('PhotoLightbox feedbackEnabled:', feedbackEnabled);
const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex];
@@ -288,7 +285,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{feedbackEnabled && (
<button
onClick={() => {
console.log('Feedback button clicked, current feedbackEnabled:', feedbackEnabled);
setShowFeedback(!showFeedback);
}}
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
+3 -3
View File
@@ -11,10 +11,10 @@ export const useGalleryInfo = (slug: string, token?: string) => {
});
};
export const useGalleryPhotos = (slug: string, enabled: boolean = true) => {
export const useGalleryPhotos = (slug: string, filter?: 'liked' | 'favorited' | 'all', guestId?: string, enabled: boolean = true) => {
return useQuery({
queryKey: ['gallery-photos', slug],
queryFn: () => galleryService.getGalleryPhotos(slug),
queryKey: ['gallery-photos', slug, filter, guestId],
queryFn: () => galleryService.getGalleryPhotos(slug, filter, guestId),
enabled,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
+6
View File
@@ -134,6 +134,12 @@
"sortByName": "Sort by Name",
"sortBySize": "Sort by Size",
"allPhotos": "All Photos",
"filter": "Filter",
"feedbackFilter": "Feedback Filter",
"all": "All",
"liked": "Liked",
"favorited": "Favorited",
"favorites": "Favorites",
"downloadSelected": "Download Selected",
"shareGallery": "Share Gallery",
"needHelp": "Need help? Contact us at",
+7 -2
View File
@@ -16,8 +16,13 @@ export const galleryService = {
},
// Get gallery photos (requires auth)
async getGalleryPhotos(slug: string): Promise<GalleryData> {
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`);
async getGalleryPhotos(slug: string, filter?: 'liked' | 'favorited' | 'all', guestId?: string): Promise<GalleryData> {
const params: any = {};
if (filter && filter !== 'all' && guestId) {
params.filter = filter;
params.guest_id = guestId;
}
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params });
return response.data;
},