feat(gallery/filters): add Rated and Commented filters (UI + backend).\n\n- UI: add star (Rated) and message (Commented) buttons to feedback filter bars (desktop + mobile)\n- Backend: support filter=rated, commented, and combinations via aggregate counts/queries
This commit is contained in:
@@ -110,12 +110,35 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
// Apply filtering if requested (global, based on aggregate counts)
|
// Apply filtering if requested (global, based on aggregate counts)
|
||||||
if (filter) {
|
if (filter) {
|
||||||
const f = String(filter).toLowerCase();
|
const f = String(filter).toLowerCase();
|
||||||
if (f === 'liked') {
|
const parts = f.split(',').map(s => s.trim());
|
||||||
photos = photos.filter(p => (p.like_count || 0) > 0);
|
const include = new Set();
|
||||||
} else if (f === 'favorited') {
|
|
||||||
photos = photos.filter(p => (p.favorite_count || 0) > 0);
|
// Helper to include IDs for a predicate
|
||||||
} else if (f === 'liked,favorited' || f === 'favorited,liked') {
|
const includeBy = (predicate) => {
|
||||||
photos = photos.filter(p => (p.like_count || 0) > 0 || (p.favorite_count || 0) > 0);
|
photos.forEach(p => { if (predicate(p)) include.add(p.id); });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parts.includes('liked')) {
|
||||||
|
includeBy(p => (p.like_count || 0) > 0);
|
||||||
|
}
|
||||||
|
if (parts.includes('favorited')) {
|
||||||
|
includeBy(p => (p.favorite_count || 0) > 0);
|
||||||
|
}
|
||||||
|
if (parts.includes('rated')) {
|
||||||
|
includeBy(p => (p.average_rating || 0) > 0);
|
||||||
|
}
|
||||||
|
if (parts.includes('commented')) {
|
||||||
|
// Query commented photo IDs
|
||||||
|
const commented = await db('photo_feedback')
|
||||||
|
.where({ event_id: req.event.id, feedback_type: 'comment', is_approved: true, is_hidden: false })
|
||||||
|
.groupBy('photo_id')
|
||||||
|
.select('photo_id');
|
||||||
|
const commentedIds = new Set(commented.map(c => c.photo_id));
|
||||||
|
includeBy(p => commentedIds.has(p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (include.size > 0) {
|
||||||
|
photos = photos.filter(p => include.has(p.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Heart, Star } from 'lucide-react';
|
import { Heart, Star, Bookmark, MessageSquare } from 'lucide-react';
|
||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export type FilterType = 'all' | 'liked' | 'favorited';
|
export type FilterType = 'all' | 'liked' | 'favorited' | 'rated' | 'commented';
|
||||||
|
|
||||||
interface GalleryFilterProps {
|
interface GalleryFilterProps {
|
||||||
currentFilter: FilterType;
|
currentFilter: FilterType;
|
||||||
@@ -150,14 +150,34 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
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"
|
||||||
>
|
>
|
||||||
<Star className="w-3 h-3 sm:w-4 sm:h-4" />
|
<Bookmark className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||||
<span className="hidden sm:inline">{t('gallery.favorited', 'Favorites')}</span>
|
<span className="hidden sm:inline">{t('gallery.favorites', 'Favorites')}</span>
|
||||||
{favoriteCount > 0 && (
|
{favoriteCount > 0 && (
|
||||||
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
|
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
|
||||||
{favoriteCount}
|
{favoriteCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('rated')}
|
||||||
|
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.rated', 'Rated')}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'commented' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('commented')}
|
||||||
|
className="text-xs sm:text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||||
|
<span className="hidden sm:inline">{t('gallery.commented', 'Commented')}</span>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Search, SortAsc, Grid, Heart, Star } from 'lucide-react';
|
import { Search, SortAsc, Grid, Heart, Star, Bookmark, MessageSquare } 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 } from './GalleryFilter';
|
||||||
@@ -205,9 +205,27 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
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"
|
||||||
aria-label={t('feedback.favorites', 'Favorites')}
|
aria-label={t('feedback.favorites', 'Favorites')}
|
||||||
|
>
|
||||||
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('rated')}
|
||||||
|
className="p-1 w-8 h-8 flex items-center justify-center"
|
||||||
|
aria-label={t('gallery.rated', 'Rated')}
|
||||||
>
|
>
|
||||||
<Star className="w-3.5 h-3.5" />
|
<Star className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'commented' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('commented')}
|
||||||
|
className="p-1 w-8 h-8 flex items-center justify-center"
|
||||||
|
aria-label={t('gallery.commented', 'Commented')}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -249,9 +267,27 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
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"
|
||||||
aria-label={t('feedback.favorites', 'Favorites')}
|
aria-label={t('feedback.favorites', 'Favorites')}
|
||||||
|
>
|
||||||
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('rated')}
|
||||||
|
className="p-1 w-8 h-8 flex items-center justify-center"
|
||||||
|
aria-label={t('gallery.rated', 'Rated')}
|
||||||
>
|
>
|
||||||
<Star className="w-3.5 h-3.5" />
|
<Star className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'commented' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('commented')}
|
||||||
|
className="p-1 w-8 h-8 flex items-center justify-center"
|
||||||
|
aria-label={t('gallery.commented', 'Commented')}
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user