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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user