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:
@@ -97,12 +97,42 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
// Get all photos
|
// Get all photos
|
||||||
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// Get filter parameters from query
|
||||||
|
const { filter, guest_id } = req.query;
|
||||||
|
const feedbackService = require('../services/feedbackService');
|
||||||
|
|
||||||
// First get all photos
|
// First get all photos
|
||||||
const photos = await db('photos')
|
let photos = await db('photos')
|
||||||
.where('photos.event_id', req.event.id)
|
.where('photos.event_id', req.event.id)
|
||||||
.select('photos.*')
|
.select('photos.*')
|
||||||
.orderBy('photos.uploaded_at', 'desc');
|
.orderBy('photos.uploaded_at', 'desc');
|
||||||
|
|
||||||
|
// Apply filtering if requested
|
||||||
|
if (filter && guest_id) {
|
||||||
|
let filters = {};
|
||||||
|
|
||||||
|
// Parse filter parameter
|
||||||
|
if (filter === 'liked') {
|
||||||
|
filters.liked = true;
|
||||||
|
} else if (filter === 'favorited') {
|
||||||
|
filters.favorited = true;
|
||||||
|
} else if (filter === 'liked,favorited' || filter === 'favorited,liked') {
|
||||||
|
filters.liked = true;
|
||||||
|
filters.favorited = true;
|
||||||
|
filters.operator = 'OR';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get filtered photo IDs
|
||||||
|
const filteredPhotoIds = await feedbackService.getFilteredPhotos(
|
||||||
|
req.event.id,
|
||||||
|
guest_id,
|
||||||
|
filters
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter photos to only include those with feedback
|
||||||
|
photos = photos.filter(photo => filteredPhotoIds.includes(photo.id));
|
||||||
|
}
|
||||||
|
|
||||||
// Then get comment counts separately
|
// Then get comment counts separately
|
||||||
const commentCounts = await db('photo_feedback')
|
const commentCounts = await db('photo_feedback')
|
||||||
.whereIn('photo_id', photos.map(p => p.id))
|
.whereIn('photo_id', photos.map(p => p.id))
|
||||||
@@ -150,13 +180,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
overlay_protection: req.event.overlay_protection !== false
|
overlay_protection: req.event.overlay_protection !== false
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[Gallery Photos] Event data:', {
|
|
||||||
id: req.event.id,
|
|
||||||
slug: req.params.slug,
|
|
||||||
protection_level: req.event.protection_level,
|
|
||||||
calculated_protection: protectionSettings.protection_level,
|
|
||||||
is_basic_or_standard: (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard')
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
event: {
|
event: {
|
||||||
@@ -181,8 +204,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
|
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
|
||||||
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
|
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
|
||||||
|
|
||||||
console.log(`[Photo ${photo.id}] Protection: ${protectionSettings.protection_level}, Use JWT: ${useJwtUrl}, URL: ${photoUrl}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: photo.id,
|
id: photo.id,
|
||||||
filename: photo.filename,
|
filename: photo.filename,
|
||||||
@@ -363,14 +384,6 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test route
|
|
||||||
router.get('/:slug/photo-test/:photoId',
|
|
||||||
verifyGalleryAccess,
|
|
||||||
(req, res) => {
|
|
||||||
console.log('TEST ROUTE EXECUTED!');
|
|
||||||
res.json({ message: 'Test route works!', photoId: req.params.photoId });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// View single photo (with watermark if enabled)
|
// View single photo (with watermark if enabled)
|
||||||
router.get('/:slug/photo/:photoId',
|
router.get('/:slug/photo/:photoId',
|
||||||
|
|||||||
@@ -390,6 +390,76 @@ class FeedbackService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filtered photos based on feedback criteria
|
||||||
|
* @param {number} eventId - Event ID
|
||||||
|
* @param {string} guestIdentifier - Guest identifier
|
||||||
|
* @param {object} filters - Filter criteria
|
||||||
|
* @param {boolean} filters.liked - Include liked photos
|
||||||
|
* @param {boolean} filters.favorited - Include favorited photos
|
||||||
|
* @param {string} filters.operator - 'AND' or 'OR' for multiple filters
|
||||||
|
* @returns {Promise<number[]>} Array of photo IDs that match criteria
|
||||||
|
*/
|
||||||
|
async getFilteredPhotos(eventId, guestIdentifier, filters = {}) {
|
||||||
|
try {
|
||||||
|
const { liked, favorited, operator = 'OR' } = filters;
|
||||||
|
|
||||||
|
// If no filters specified, return all photos
|
||||||
|
if (!liked && !favorited) {
|
||||||
|
const allPhotos = await db('photos')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.select('id');
|
||||||
|
return allPhotos.map(p => p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query based on filters
|
||||||
|
let query = db('photo_feedback')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.where('guest_identifier', guestIdentifier)
|
||||||
|
.where('is_hidden', false);
|
||||||
|
|
||||||
|
// Apply filter logic
|
||||||
|
if (operator === 'AND' && liked && favorited) {
|
||||||
|
// For AND operation, we need photos that have both types of feedback
|
||||||
|
const likedPhotos = await db('photo_feedback')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.where('guest_identifier', guestIdentifier)
|
||||||
|
.where('feedback_type', 'like')
|
||||||
|
.where('is_hidden', false)
|
||||||
|
.select('photo_id');
|
||||||
|
|
||||||
|
const favoritedPhotos = await db('photo_feedback')
|
||||||
|
.where('event_id', eventId)
|
||||||
|
.where('guest_identifier', guestIdentifier)
|
||||||
|
.where('feedback_type', 'favorite')
|
||||||
|
.where('is_hidden', false)
|
||||||
|
.select('photo_id');
|
||||||
|
|
||||||
|
const likedIds = new Set(likedPhotos.map(p => p.photo_id));
|
||||||
|
const favoritedIds = new Set(favoritedPhotos.map(p => p.photo_id));
|
||||||
|
|
||||||
|
// Return intersection of both sets
|
||||||
|
return Array.from(likedIds).filter(id => favoritedIds.has(id));
|
||||||
|
} else {
|
||||||
|
// OR operation or single filter
|
||||||
|
const feedbackTypes = [];
|
||||||
|
if (liked) feedbackTypes.push('like');
|
||||||
|
if (favorited) feedbackTypes.push('favorite');
|
||||||
|
|
||||||
|
query.whereIn('feedback_type', feedbackTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredPhotos = await query
|
||||||
|
.distinct('photo_id')
|
||||||
|
.select('photo_id');
|
||||||
|
|
||||||
|
return filteredPhotos.map(p => p.photo_id);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting filtered photos:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new FeedbackService();
|
module.exports = new FeedbackService();
|
||||||
@@ -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 { GallerySidebar } from './GallerySidebar';
|
||||||
import { PhotoFilterBar } from './PhotoFilterBar';
|
import { PhotoFilterBar } from './PhotoFilterBar';
|
||||||
import { UserPhotoUpload } from './UserPhotoUpload';
|
import { UserPhotoUpload } from './UserPhotoUpload';
|
||||||
|
import type { FilterType } 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 { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
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 [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
const { watermarkEnabled } = useWatermarkSettings();
|
const { watermarkEnabled } = useWatermarkSettings();
|
||||||
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
|
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
|
||||||
|
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||||
|
const [guestId, setGuestId] = useState<string>('');
|
||||||
|
|
||||||
// Fetch photos
|
// Generate a unique guest ID for this session
|
||||||
const { data, isLoading, error, refetch } = useGalleryPhotos(slug);
|
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
|
// Set protection level when data is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,7 +136,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
try {
|
try {
|
||||||
// Use public endpoint to get feedback settings
|
// Use public endpoint to get feedback settings
|
||||||
const response = await api.get(`/gallery/${slug}/feedback-settings`);
|
const response = await api.get(`/gallery/${slug}/feedback-settings`);
|
||||||
console.log('Feedback settings response:', response.data);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching feedback settings:', 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
|
// Update feedbackEnabled when settings change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (feedbackSettings) {
|
if (feedbackSettings) {
|
||||||
console.log('Setting feedbackEnabled to:', feedbackSettings.feedback_enabled);
|
|
||||||
setFeedbackEnabled(feedbackSettings.feedback_enabled || false);
|
setFeedbackEnabled(feedbackSettings.feedback_enabled || false);
|
||||||
}
|
}
|
||||||
}, [feedbackSettings]);
|
}, [feedbackSettings]);
|
||||||
@@ -424,6 +436,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
galleryLayout={theme.galleryLayout}
|
galleryLayout={theme.galleryLayout}
|
||||||
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}
|
||||||
|
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}
|
) : null}
|
||||||
|
|
||||||
@@ -510,6 +527,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
onSortChange={setSortBy}
|
onSortChange={setSortBy}
|
||||||
photoCount={filteredPhotos.length}
|
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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
queryKey: ['gallery-feedback-settings', gallerySlug],
|
queryKey: ['gallery-feedback-settings', gallerySlug],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = await feedbackService.getGalleryFeedbackSettings(gallerySlug);
|
const data = await feedbackService.getGalleryFeedbackSettings(gallerySlug);
|
||||||
console.log('PhotoFeedback received settings:', data);
|
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Button, Input } from '../common';
|
import { Button, Input } from '../common';
|
||||||
|
import type { FilterType } from './GalleryFilter';
|
||||||
|
|
||||||
interface PhotoCategory {
|
interface PhotoCategory {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -13,6 +14,8 @@ interface PhotoCategory {
|
|||||||
interface Photo {
|
interface Photo {
|
||||||
id: number;
|
id: number;
|
||||||
category_id?: number;
|
category_id?: number;
|
||||||
|
like_count?: number;
|
||||||
|
favorite_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PhotoFilterBarProps {
|
interface PhotoFilterBarProps {
|
||||||
@@ -25,6 +28,12 @@ interface PhotoFilterBarProps {
|
|||||||
sortBy: 'date' | 'name' | 'size' | 'rating';
|
sortBy: 'date' | 'name' | 'size' | 'rating';
|
||||||
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void;
|
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void;
|
||||||
photoCount: number;
|
photoCount: number;
|
||||||
|
// Feedback filter props
|
||||||
|
feedbackEnabled?: boolean;
|
||||||
|
currentFilter?: FilterType;
|
||||||
|
onFilterChange?: (filter: FilterType) => void;
|
||||||
|
likeCount?: number;
|
||||||
|
favoriteCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||||
@@ -37,6 +46,11 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
sortBy,
|
sortBy,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
photoCount,
|
photoCount,
|
||||||
|
feedbackEnabled = false,
|
||||||
|
currentFilter = 'all',
|
||||||
|
onFilterChange,
|
||||||
|
likeCount = 0,
|
||||||
|
favoriteCount = 0,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||||
@@ -44,7 +58,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Search and Sort */}
|
{/* 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 */}
|
{/* Search Bar */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Input
|
<Input
|
||||||
@@ -53,7 +67,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
className="text-sm sm:text-base"
|
className="text-sm md:text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,9 +78,9 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
size="md"
|
size="md"
|
||||||
leftIcon={<SortAsc className="w-4 h-4" />}
|
leftIcon={<SortAsc className="w-4 h-4" />}
|
||||||
onClick={() => setShowSortMenu(!showSortMenu)}
|
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 === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') :
|
||||||
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
|
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
|
||||||
sortBy === 'size' ? t('gallery.sortBySize').replace('Sort by ', '') :
|
sortBy === 'size' ? t('gallery.sortBySize').replace('Sort by ', '') :
|
||||||
@@ -74,7 +88,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{showSortMenu && (
|
{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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSortChange('date');
|
onSortChange('date');
|
||||||
@@ -124,18 +138,19 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category Filter */}
|
{/* Category and Feedback Filters */}
|
||||||
{categories && categories.length > 0 && (
|
<div className="space-y-3">
|
||||||
<div className="space-y-3">
|
{/* Categories Row */}
|
||||||
<div className="flex items-start sm:items-center justify-between flex-col sm:flex-row gap-3">
|
{categories && categories.length > 0 && (
|
||||||
<div className="w-full sm:w-auto overflow-x-auto pb-2 sm:pb-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">
|
<div className="flex items-center gap-2 min-w-max">
|
||||||
<Button
|
<Button
|
||||||
variant={selectedCategoryId === null ? 'primary' : 'outline'}
|
variant={selectedCategoryId === null ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onCategoryChange(null)}
|
onClick={() => onCategoryChange(null)}
|
||||||
leftIcon={<Grid className="w-3 h-3 sm:w-4 sm:h-4" />}
|
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
|
||||||
className="text-xs sm:text-sm whitespace-nowrap"
|
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
|
||||||
>
|
>
|
||||||
{t('gallery.allPhotos')} ({photos.length})
|
{t('gallery.allPhotos')} ({photos.length})
|
||||||
</Button>
|
</Button>
|
||||||
@@ -149,21 +164,92 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
|
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onCategoryChange(category.id)}
|
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})
|
{category.name} ({categoryPhotoCount})
|
||||||
</Button>
|
</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>
|
||||||
</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')}
|
{photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
||||||
const [showFeedback, setShowFeedback] = useState(false);
|
const [showFeedback, setShowFeedback] = useState(false);
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
console.log('PhotoLightbox feedbackEnabled:', feedbackEnabled);
|
|
||||||
|
|
||||||
const downloadPhotoMutation = useDownloadPhoto();
|
const downloadPhotoMutation = useDownloadPhoto();
|
||||||
const currentPhoto = photos[currentIndex];
|
const currentPhoto = photos[currentIndex];
|
||||||
|
|
||||||
@@ -288,7 +285,6 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
{feedbackEnabled && (
|
{feedbackEnabled && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log('Feedback button clicked, current feedbackEnabled:', feedbackEnabled);
|
|
||||||
setShowFeedback(!showFeedback);
|
setShowFeedback(!showFeedback);
|
||||||
}}
|
}}
|
||||||
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||||
|
|||||||
@@ -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({
|
return useQuery({
|
||||||
queryKey: ['gallery-photos', slug],
|
queryKey: ['gallery-photos', slug, filter, guestId],
|
||||||
queryFn: () => galleryService.getGalleryPhotos(slug),
|
queryFn: () => galleryService.getGalleryPhotos(slug, filter, guestId),
|
||||||
enabled,
|
enabled,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
|||||||
@@ -134,6 +134,12 @@
|
|||||||
"sortByName": "Sort by Name",
|
"sortByName": "Sort by Name",
|
||||||
"sortBySize": "Sort by Size",
|
"sortBySize": "Sort by Size",
|
||||||
"allPhotos": "All Photos",
|
"allPhotos": "All Photos",
|
||||||
|
"filter": "Filter",
|
||||||
|
"feedbackFilter": "Feedback Filter",
|
||||||
|
"all": "All",
|
||||||
|
"liked": "Liked",
|
||||||
|
"favorited": "Favorited",
|
||||||
|
"favorites": "Favorites",
|
||||||
"downloadSelected": "Download Selected",
|
"downloadSelected": "Download Selected",
|
||||||
"shareGallery": "Share Gallery",
|
"shareGallery": "Share Gallery",
|
||||||
"needHelp": "Need help? Contact us at",
|
"needHelp": "Need help? Contact us at",
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ export const galleryService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Get gallery photos (requires auth)
|
// Get gallery photos (requires auth)
|
||||||
async getGalleryPhotos(slug: string): Promise<GalleryData> {
|
async getGalleryPhotos(slug: string, filter?: 'liked' | 'favorited' | 'all', guestId?: string): Promise<GalleryData> {
|
||||||
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`);
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user