Refine gallery feedback actions
Mirror to GitHub / mirror (push) Successful in 2m10s
Test and Lint / backend-test (push) Successful in 1m48s
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Failing after 50s
Version and Release / trigger-drone (push) Has been skipped
Mirror to GitHub / mirror (push) Successful in 2m10s
Test and Lint / backend-test (push) Successful in 1m48s
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Failing after 50s
Version and Release / trigger-drone (push) Has been skipped
This commit is contained in:
@@ -393,6 +393,87 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Download selected photos as ZIP
|
||||||
|
router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check if downloads are allowed for this event
|
||||||
|
if (req.event.allow_downloads === false) {
|
||||||
|
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = Array.isArray(req.body?.photo_ids) ? req.body.photo_ids : [];
|
||||||
|
if (!ids.length) {
|
||||||
|
return res.status(400).json({ error: 'photo_ids is required (non-empty array)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean IDs
|
||||||
|
const photoIds = ids
|
||||||
|
.map((v) => parseInt(v, 10))
|
||||||
|
.filter((v) => Number.isInteger(v))
|
||||||
|
.slice(0, 500);
|
||||||
|
|
||||||
|
if (photoIds.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'No valid photo IDs provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch photos
|
||||||
|
const photos = await db('photos')
|
||||||
|
.where('photos.event_id', req.event.id)
|
||||||
|
.whereIn('photos.id', photoIds)
|
||||||
|
.select('photos.*')
|
||||||
|
.orderBy('photos.uploaded_at', 'desc');
|
||||||
|
|
||||||
|
if (photos.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'No photos found for selected IDs' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const archiveName = `${req.event.slug}-selected.zip`;
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
|
||||||
|
|
||||||
|
const archive = archiver('zip', { zlib: { level: 5 } });
|
||||||
|
archive.on('error', (err) => {
|
||||||
|
console.error('Zip error:', err);
|
||||||
|
try { res.status(500).end(); } catch (e) {}
|
||||||
|
});
|
||||||
|
archive.pipe(res);
|
||||||
|
|
||||||
|
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||||
|
const fs = require('fs');
|
||||||
|
// Check watermark settings similar to download-all
|
||||||
|
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||||
|
for (const photo of photos) {
|
||||||
|
try {
|
||||||
|
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||||
|
if (filePath && fs.existsSync(filePath)) {
|
||||||
|
const name = photo.filename || `photo-${photo.id}.jpg`;
|
||||||
|
if (watermarkSettings && watermarkSettings.enabled) {
|
||||||
|
// Apply watermark like download-all
|
||||||
|
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||||
|
archive.append(watermarkedBuffer, { name });
|
||||||
|
} else {
|
||||||
|
archive.file(filePath, { name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// skip missing/inaccessible files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await archive.finalize();
|
||||||
|
|
||||||
|
await db('access_logs').insert({
|
||||||
|
event_id: req.event.id,
|
||||||
|
ip_address: req.ip,
|
||||||
|
user_agent: req.headers['user-agent'],
|
||||||
|
action: 'download_selected'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in download-selected:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to download selected photos' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// View single photo (with watermark if enabled)
|
// View single photo (with watermark if enabled)
|
||||||
router.get('/:slug/photo/:photoId',
|
router.get('/:slug/photo/:photoId',
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Heart, Star, Bookmark, MessageSquare } from 'lucide-react';
|
import { Heart, Star, 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' | 'rated' | 'commented';
|
export type FilterType = 'all' | 'liked' | 'rated' | 'commented';
|
||||||
|
|
||||||
interface GalleryFilterProps {
|
interface GalleryFilterProps {
|
||||||
currentFilter: FilterType;
|
currentFilter: FilterType;
|
||||||
onFilterChange: (filter: FilterType) => void;
|
onFilterChange: (filter: FilterType) => void;
|
||||||
feedbackEnabled: boolean;
|
feedbackEnabled: boolean;
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
favoriteCount?: number;
|
ratedCount?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
variant?: 'default' | 'compact';
|
variant?: 'default' | 'compact';
|
||||||
@@ -21,7 +21,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
onFilterChange,
|
onFilterChange,
|
||||||
feedbackEnabled,
|
feedbackEnabled,
|
||||||
likeCount = 0,
|
likeCount = 0,
|
||||||
favoriteCount = 0,
|
ratedCount = 0,
|
||||||
className = '',
|
className = '',
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
variant = 'default'
|
variant = 'default'
|
||||||
@@ -60,14 +60,23 @@ 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={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onFilterChange('favorited')}
|
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"
|
||||||
aria-label={t('feedback.favorites', 'Favorites')}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,13 +112,13 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onFilterChange('favorited')}
|
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"
|
||||||
>
|
>
|
||||||
<Star className="w-3 h-3" />
|
<Star className="w-3 h-3" />
|
||||||
<span>{favoriteCount > 0 ? favoriteCount : t('gallery.favorites', 'Favorites')}</span>
|
<span>{ratedCount > 0 ? ratedCount : t('gallery.rated', 'Rated')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,21 +153,6 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onFilterChange('favorited')}
|
|
||||||
className="text-xs sm:text-sm flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<Bookmark className="w-3 h-3 sm:w-4 sm:h-4" />
|
|
||||||
<span className="hidden sm:inline">{t('gallery.favorites', 'Favorites')}</span>
|
|
||||||
{favoriteCount > 0 && (
|
|
||||||
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
|
|
||||||
{favoriteCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -167,6 +161,11 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
>
|
>
|
||||||
<Star className="w-3 h-3 sm:w-4 sm:h-4" />
|
<Star className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||||
<span className="hidden sm:inline">{t('gallery.rated', 'Rated')}</span>
|
<span className="hidden sm:inline">{t('gallery.rated', 'Rated')}</span>
|
||||||
|
{ratedCount > 0 && (
|
||||||
|
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
|
||||||
|
{ratedCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ interface GallerySidebarProps {
|
|||||||
filterType?: FilterType;
|
filterType?: FilterType;
|
||||||
onFilterChange?: (filter: FilterType) => void;
|
onFilterChange?: (filter: FilterType) => void;
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
favoriteCount?: number;
|
ratedCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||||
@@ -64,7 +64,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
filterType = 'all',
|
filterType = 'all',
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
likeCount = 0,
|
likeCount = 0,
|
||||||
favoriteCount = 0
|
ratedCount = 0
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
const sidebarRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -223,7 +223,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
}}
|
}}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
likeCount={likeCount}
|
likeCount={likeCount}
|
||||||
favoriteCount={favoriteCount}
|
ratedCount={ratedCount}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Upload, Menu } from 'lucide-react';
|
|||||||
import { galleryService } from '../../services/gallery.service';
|
import { galleryService } from '../../services/gallery.service';
|
||||||
import { feedbackService } from '../../services/feedback.service';
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||||
|
import type { Photo } from '../../types';
|
||||||
|
|
||||||
interface GalleryViewProps {
|
interface GalleryViewProps {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -58,6 +59,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
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 [filterType, setFilterType] = useState<FilterType>('all');
|
||||||
const [guestId, setGuestId] = useState<string>('');
|
const [guestId, setGuestId] = useState<string>('');
|
||||||
|
const [staticHeroPhoto, setStaticHeroPhoto] = useState<Photo | null>(null);
|
||||||
|
|
||||||
// Generate a unique guest ID for this session
|
// Generate a unique guest ID for this session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -167,6 +169,23 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
}
|
}
|
||||||
}, [settingsData]);
|
}, [settingsData]);
|
||||||
|
|
||||||
|
// Determine a stable hero photo from the initial (unfiltered) load
|
||||||
|
useEffect(() => {
|
||||||
|
if (!staticHeroPhoto && data?.photos && filterType === 'all') {
|
||||||
|
let hero: Photo | null = null;
|
||||||
|
const heroId = data?.event?.hero_photo_id || null;
|
||||||
|
if (heroId) {
|
||||||
|
hero = data.photos.find(p => p.id === heroId) || null;
|
||||||
|
}
|
||||||
|
if (!hero && data.photos.length > 0) {
|
||||||
|
hero = data.photos[0];
|
||||||
|
}
|
||||||
|
if (hero) {
|
||||||
|
setStaticHeroPhoto(hero);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data?.photos, data?.event?.hero_photo_id, filterType, staticHeroPhoto]);
|
||||||
|
|
||||||
// Apply theme when settings are loaded
|
// Apply theme when settings are loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsData && data?.event) {
|
if (settingsData && data?.event) {
|
||||||
@@ -440,7 +459,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
filterType={filterType}
|
filterType={filterType}
|
||||||
onFilterChange={setFilterType}
|
onFilterChange={setFilterType}
|
||||||
likeCount={data?.photos?.filter(p => p.like_count > 0).length || 0}
|
likeCount={data?.photos?.filter(p => p.like_count > 0).length || 0}
|
||||||
favoriteCount={data?.photos?.filter(p => p.favorite_count > 0).length || 0}
|
ratedCount={data?.photos?.filter(p => (p.total_ratings || 0) > 0).length || 0}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -531,8 +550,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
currentFilter={filterType}
|
currentFilter={filterType}
|
||||||
onFilterChange={setFilterType}
|
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}
|
||||||
@@ -543,6 +560,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
photos={filteredPhotos}
|
photos={filteredPhotos}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
categoryId={selectedCategoryId}
|
categoryId={selectedCategoryId}
|
||||||
|
onFeedbackChange={() => refetch()}
|
||||||
|
heroPhotoOverride={staticHeroPhoto}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
feedbackOptions={{
|
feedbackOptions={{
|
||||||
allowLikes: !!feedbackSettings?.allow_likes,
|
allowLikes: !!feedbackSettings?.allow_likes,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { feedbackService } from '../../services/feedback.service';
|
|||||||
import { PhotoRating } from './PhotoRating';
|
import { PhotoRating } from './PhotoRating';
|
||||||
import { PhotoLikes } from './PhotoLikes';
|
import { PhotoLikes } from './PhotoLikes';
|
||||||
import { PhotoComments } from './PhotoComments';
|
import { PhotoComments } from './PhotoComments';
|
||||||
import { PhotoFavorites } from './PhotoFavorites';
|
|
||||||
import { Skeleton } from '../common';
|
import { Skeleton } from '../common';
|
||||||
import type { FeedbackSettings } from '../../services/feedback.service';
|
import type { FeedbackSettings } from '../../services/feedback.service';
|
||||||
|
|
||||||
@@ -43,18 +42,14 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
// Local state for optimistic updates
|
// Local state for optimistic updates
|
||||||
const [currentRating, setCurrentRating] = useState(0);
|
const [currentRating, setCurrentRating] = useState(0);
|
||||||
const [isLiked, setIsLiked] = useState(false);
|
const [isLiked, setIsLiked] = useState(false);
|
||||||
const [isFavorited, setIsFavorited] = useState(false);
|
|
||||||
const [likeCount, setLikeCount] = useState(0);
|
const [likeCount, setLikeCount] = useState(0);
|
||||||
const [favoriteCount, setFavoriteCount] = useState(0);
|
|
||||||
|
|
||||||
// Update local state when data loads
|
// Update local state when data loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (feedbackData) {
|
if (feedbackData) {
|
||||||
setCurrentRating(feedbackData.my_feedback.rating || 0);
|
setCurrentRating(feedbackData.my_feedback.rating || 0);
|
||||||
setIsLiked(feedbackData.my_feedback.liked);
|
setIsLiked(feedbackData.my_feedback.liked);
|
||||||
setIsFavorited(feedbackData.my_feedback.favorited);
|
|
||||||
setLikeCount(feedbackData.summary.like_count);
|
setLikeCount(feedbackData.summary.like_count);
|
||||||
setFavoriteCount(feedbackData.summary.favorite_count);
|
|
||||||
}
|
}
|
||||||
}, [feedbackData]);
|
}, [feedbackData]);
|
||||||
|
|
||||||
@@ -70,12 +65,6 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
if (onFeedbackUpdate) onFeedbackUpdate();
|
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFavoriteChange = (favorited: boolean) => {
|
|
||||||
setIsFavorited(favorited);
|
|
||||||
setFavoriteCount(prev => favorited ? prev + 1 : Math.max(0, prev - 1));
|
|
||||||
if (onFeedbackUpdate) onFeedbackUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (settingsLoading) {
|
if (settingsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-3 ${className}`}>
|
<div className={`space-y-3 ${className}`}>
|
||||||
@@ -90,7 +79,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
|
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
|
||||||
settings.allow_comments || settings.allow_favorites;
|
settings.allow_comments;
|
||||||
|
|
||||||
if (!hasAnyFeedbackType) {
|
if (!hasAnyFeedbackType) {
|
||||||
return null;
|
return null;
|
||||||
@@ -113,7 +102,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
{(settings.allow_likes || settings.allow_favorites) && (
|
{settings.allow_likes && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{settings.allow_likes && (
|
{settings.allow_likes && (
|
||||||
<PhotoLikes
|
<PhotoLikes
|
||||||
@@ -126,17 +115,6 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
onLikeChange={handleLikeChange}
|
onLikeChange={handleLikeChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{settings.allow_favorites && (
|
|
||||||
<PhotoFavorites
|
|
||||||
photoId={photoId}
|
|
||||||
gallerySlug={gallerySlug}
|
|
||||||
isFavorited={isFavorited}
|
|
||||||
favoriteCount={favoriteCount}
|
|
||||||
isEnabled={true}
|
|
||||||
requireNameEmail={settings.require_name_email || false}
|
|
||||||
onFavoriteChange={handleFavoriteChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Search, SortAsc, Grid, Heart, Star, Bookmark, MessageSquare } from 'lucide-react';
|
import { Search, SortAsc, Grid, Heart, Star, 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';
|
||||||
@@ -32,8 +32,6 @@ interface PhotoFilterBarProps {
|
|||||||
feedbackEnabled?: boolean;
|
feedbackEnabled?: boolean;
|
||||||
currentFilter?: FilterType;
|
currentFilter?: FilterType;
|
||||||
onFilterChange?: (filter: FilterType) => void;
|
onFilterChange?: (filter: FilterType) => void;
|
||||||
likeCount?: number;
|
|
||||||
favoriteCount?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||||
@@ -49,8 +47,6 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
feedbackEnabled = false,
|
feedbackEnabled = false,
|
||||||
currentFilter = 'all',
|
currentFilter = 'all',
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
likeCount = 0,
|
|
||||||
favoriteCount = 0,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||||
@@ -199,15 +195,6 @@ 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
|
|
||||||
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onFilterChange('favorited')}
|
|
||||||
className="p-1 w-8 h-8 flex items-center justify-center"
|
|
||||||
aria-label={t('feedback.favorites', 'Favorites')}
|
|
||||||
>
|
|
||||||
<Bookmark className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -261,15 +248,6 @@ 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
|
|
||||||
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onFilterChange('favorited')}
|
|
||||||
className="p-1 w-8 h-8 flex items-center justify-center"
|
|
||||||
aria-label={t('feedback.favorites', 'Favorites')}
|
|
||||||
>
|
|
||||||
<Bookmark className="w-3.5 h-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -95,35 +95,17 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
|||||||
|
|
||||||
const handleDownloadSelected = async () => {
|
const handleDownloadSelected = async () => {
|
||||||
if (selectedPhotos.size === 0) return;
|
if (selectedPhotos.size === 0) return;
|
||||||
|
const ids = Array.from(selectedPhotos);
|
||||||
const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id));
|
toastify.info(t('gallery.downloading', { count: ids.length }));
|
||||||
|
|
||||||
toastify.info(t('gallery.downloading', { count: selectedPhotos.size }));
|
|
||||||
|
|
||||||
// Download each selected photo
|
|
||||||
const downloadPromises = selectedPhotosList.map(photo =>
|
|
||||||
galleryService.downloadPhoto(slug, photo.id, photo.filename)
|
|
||||||
.catch(err => {
|
|
||||||
// Download failed - error handled by UI
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(downloadPromises);
|
await galleryService.downloadSelectedPhotos(slug, ids);
|
||||||
toastify.success(t('gallery.downloadedPhotos', { count: selectedPhotos.size }));
|
analyticsService.trackGalleryEvent('bulk_download_selected', { gallery: slug, photo_count: ids.length });
|
||||||
|
|
||||||
// Track bulk download
|
|
||||||
analyticsService.trackGalleryEvent('bulk_download', {
|
|
||||||
gallery: slug,
|
|
||||||
photo_count: selectedPhotos.size
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear selection after download
|
|
||||||
setSelectedPhotos(new Set());
|
|
||||||
setIsSelectionMode(false);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastify.error(t('gallery.downloadError'));
|
toastify.error(t('gallery.downloadError'));
|
||||||
|
} finally {
|
||||||
|
setSelectedPhotos(new Set());
|
||||||
|
setIsSelectionMode(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -316,7 +298,7 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Overlay on hover/tap - Always visible on mobile for better UX */}
|
{/* Overlay on hover/tap - Always visible on mobile for better UX */}
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
|
<div className="absolute inset-0 bg-black/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
|
||||||
{!isSelectionMode && (
|
{!isSelectionMode && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ interface PhotoGridWithLayoutsProps {
|
|||||||
photos: Photo[];
|
photos: Photo[];
|
||||||
slug: string;
|
slug: string;
|
||||||
categoryId?: number | null;
|
categoryId?: number | null;
|
||||||
|
// When provided, the hero layout will use this photo
|
||||||
|
// instead of deriving from the filtered photo list.
|
||||||
|
heroPhotoOverride?: Photo | null;
|
||||||
isSelectionMode?: boolean;
|
isSelectionMode?: boolean;
|
||||||
selectedPhotos?: Set<number>;
|
selectedPhotos?: Set<number>;
|
||||||
onSelectionChange?: (photos: Set<number>) => void;
|
onSelectionChange?: (photos: Set<number>) => void;
|
||||||
@@ -45,16 +48,19 @@ interface PhotoGridWithLayoutsProps {
|
|||||||
allowComments?: boolean;
|
allowComments?: boolean;
|
||||||
requireNameEmail?: boolean;
|
requireNameEmail?: boolean;
|
||||||
};
|
};
|
||||||
|
onFeedbackChange?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||||
photos,
|
photos,
|
||||||
slug,
|
slug,
|
||||||
categoryId,
|
categoryId,
|
||||||
|
heroPhotoOverride,
|
||||||
isSelectionMode: parentSelectionMode,
|
isSelectionMode: parentSelectionMode,
|
||||||
selectedPhotos: parentSelectedPhotos,
|
selectedPhotos: parentSelectedPhotos,
|
||||||
feedbackEnabled,
|
feedbackEnabled,
|
||||||
feedbackOptions,
|
feedbackOptions,
|
||||||
|
onFeedbackChange,
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
@@ -69,6 +75,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
|
||||||
|
const [openFeedbackInitially, setOpenFeedbackInitially] = useState<boolean>(false);
|
||||||
const [localSelectedPhotos, setLocalSelectedPhotos] = useState<Set<number>>(new Set());
|
const [localSelectedPhotos, setLocalSelectedPhotos] = useState<Set<number>>(new Set());
|
||||||
const [localSelectionMode, setLocalSelectionMode] = useState(false);
|
const [localSelectionMode, setLocalSelectionMode] = useState(false);
|
||||||
const downloadPhotoMutation = useDownloadPhoto();
|
const downloadPhotoMutation = useDownloadPhoto();
|
||||||
@@ -85,6 +92,12 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
}, [categoryId]);
|
}, [categoryId]);
|
||||||
|
|
||||||
const handlePhotoClick = (index: number) => {
|
const handlePhotoClick = (index: number) => {
|
||||||
|
setOpenFeedbackInitially(false);
|
||||||
|
setSelectedPhotoIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenWithFeedback = (index: number) => {
|
||||||
|
setOpenFeedbackInitially(true);
|
||||||
setSelectedPhotoIndex(index);
|
setSelectedPhotoIndex(index);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,39 +143,21 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
|
|
||||||
const handleDownloadSelected = async () => {
|
const handleDownloadSelected = async () => {
|
||||||
if (selectedPhotos.size === 0) return;
|
if (selectedPhotos.size === 0) return;
|
||||||
|
const ids = Array.from(selectedPhotos);
|
||||||
const selectedPhotosList = photos.filter(p => selectedPhotos.has(p.id));
|
toastify.info(t('gallery.downloading', { count: ids.length }));
|
||||||
|
|
||||||
toastify.info(t('gallery.downloading', { count: selectedPhotos.size }));
|
|
||||||
|
|
||||||
// Download each selected photo
|
|
||||||
const downloadPromises = selectedPhotosList.map(photo =>
|
|
||||||
galleryService.downloadPhoto(slug, photo.id, photo.filename)
|
|
||||||
.catch(err => {
|
|
||||||
// Download failed - error handled by UI
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(downloadPromises);
|
await galleryService.downloadSelectedPhotos(slug, ids);
|
||||||
toastify.success(t('gallery.downloadedPhotos', { count: selectedPhotos.size }));
|
analyticsService.trackGalleryEvent('bulk_download_selected', { gallery: slug, photo_count: ids.length });
|
||||||
|
} catch (error) {
|
||||||
// Track bulk download
|
toastify.error(t('gallery.downloadError'));
|
||||||
analyticsService.trackGalleryEvent('bulk_download', {
|
} finally {
|
||||||
gallery: slug,
|
|
||||||
photo_count: selectedPhotos.size
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear selection after download
|
|
||||||
setSelectedPhotos(new Set());
|
setSelectedPhotos(new Set());
|
||||||
if (parentToggleSelectionMode) {
|
if (parentToggleSelectionMode) {
|
||||||
parentToggleSelectionMode();
|
parentToggleSelectionMode();
|
||||||
} else {
|
} else {
|
||||||
setLocalSelectionMode(false);
|
setLocalSelectionMode(false);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
toastify.error(t('gallery.downloadError'));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,7 +177,10 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
photos,
|
photos,
|
||||||
slug,
|
slug,
|
||||||
onPhotoClick: handlePhotoClick,
|
onPhotoClick: handlePhotoClick,
|
||||||
|
onOpenPhotoWithFeedback: handleOpenWithFeedback,
|
||||||
|
onFeedbackChange: onFeedbackChange,
|
||||||
onDownload: handleDownload,
|
onDownload: handleDownload,
|
||||||
|
heroPhotoOverride,
|
||||||
selectedPhotos,
|
selectedPhotos,
|
||||||
allowDownloads,
|
allowDownloads,
|
||||||
protectionLevel,
|
protectionLevel,
|
||||||
@@ -292,6 +290,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
protectionLevel={protectionLevel}
|
protectionLevel={protectionLevel}
|
||||||
useEnhancedProtection={useEnhancedProtection}
|
useEnhancedProtection={useEnhancedProtection}
|
||||||
|
initialShowFeedback={openFeedbackInitially}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare } from 'lucide-react';
|
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
|
||||||
import type { Photo } from '../../types';
|
import type { Photo } from '../../types';
|
||||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||||
import { AuthenticatedImage } from '../common';
|
import { AuthenticatedImage } from '../common';
|
||||||
import { PhotoFeedback } from './PhotoFeedback';
|
import { PhotoFeedback } from './PhotoFeedback';
|
||||||
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
|
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
|
||||||
|
|
||||||
interface PhotoLightboxProps {
|
interface PhotoLightboxProps {
|
||||||
photos: Photo[];
|
photos: Photo[];
|
||||||
@@ -37,6 +39,20 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
||||||
const [showFeedback, setShowFeedback] = useState(initialShowFeedback);
|
const [showFeedback, setShowFeedback] = useState(initialShowFeedback);
|
||||||
const [isSmallScreen, setIsSmallScreen] = useState<boolean>(typeof window !== 'undefined' ? window.innerWidth < 640 : false);
|
const [isSmallScreen, setIsSmallScreen] = useState<boolean>(typeof window !== 'undefined' ? window.innerWidth < 640 : false);
|
||||||
|
const [feedbackSettings, setFeedbackSettings] = useState<{
|
||||||
|
feedback_enabled?: boolean;
|
||||||
|
allow_likes?: boolean;
|
||||||
|
allow_ratings?: boolean;
|
||||||
|
require_name_email?: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
const [myLiked, setMyLiked] = useState<boolean>(false);
|
||||||
|
const [myRating, setMyRating] = useState<number>(0);
|
||||||
|
const [likeCount, setLikeCount] = useState<number>(0);
|
||||||
|
const [avgRating, setAvgRating] = useState<number>(0);
|
||||||
|
const [totalRatings, setTotalRatings] = useState<number>(0);
|
||||||
|
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||||
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||||
|
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
|
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
|
||||||
@@ -120,6 +136,81 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
};
|
};
|
||||||
}, [currentIndex]);
|
}, [currentIndex]);
|
||||||
|
|
||||||
|
// Load feedback settings once
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const settings = await feedbackService.getGalleryFeedbackSettings(slug);
|
||||||
|
if (mounted) setFeedbackSettings(settings as any);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { mounted = false; };
|
||||||
|
}, [slug]);
|
||||||
|
|
||||||
|
// Load my feedback for the current photo
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (!feedbackSettings?.feedback_enabled) return;
|
||||||
|
const data = await feedbackService.getPhotoFeedback(slug, String(currentPhoto.id));
|
||||||
|
if (!mounted) return;
|
||||||
|
setMyLiked(!!data.my_feedback.liked);
|
||||||
|
setMyRating(data.my_feedback.rating || 0);
|
||||||
|
setLikeCount(Number(data.summary?.like_count) || 0);
|
||||||
|
setAvgRating(Number(data.summary?.average_rating) || 0);
|
||||||
|
setTotalRatings(Number(data.summary?.total_ratings) || 0);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { mounted = false; };
|
||||||
|
}, [slug, currentPhoto.id, feedbackSettings?.feedback_enabled]);
|
||||||
|
|
||||||
|
const submitLike = async () => {
|
||||||
|
const needIdentity = feedbackSettings?.require_name_email && !savedIdentity;
|
||||||
|
if (needIdentity) {
|
||||||
|
setPendingAction({ type: 'like' });
|
||||||
|
setShowIdentityModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||||
|
feedback_type: 'like',
|
||||||
|
guest_name: savedIdentity?.name,
|
||||||
|
guest_email: savedIdentity?.email,
|
||||||
|
});
|
||||||
|
setMyLiked(prev => {
|
||||||
|
const next = !prev;
|
||||||
|
setLikeCount(c => Math.max(0, c + (next ? 1 : -1)));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRating = async (value: number) => {
|
||||||
|
const needIdentity = feedbackSettings?.require_name_email && !savedIdentity;
|
||||||
|
if (needIdentity) {
|
||||||
|
setPendingAction({ type: 'rating', rating: value });
|
||||||
|
setShowIdentityModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||||
|
feedback_type: 'rating',
|
||||||
|
rating: value,
|
||||||
|
guest_name: savedIdentity?.name,
|
||||||
|
guest_email: savedIdentity?.email,
|
||||||
|
});
|
||||||
|
setMyRating(value);
|
||||||
|
// Refresh current summary to reflect average and totals
|
||||||
|
try {
|
||||||
|
const fresh = await feedbackService.getPhotoFeedback(slug, String(currentPhoto.id));
|
||||||
|
setAvgRating(Number(fresh.summary?.average_rating) || 0);
|
||||||
|
setTotalRatings(Number(fresh.summary?.total_ratings) || 0);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
const goToPrevious = () => {
|
const goToPrevious = () => {
|
||||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
||||||
resetZoom();
|
resetZoom();
|
||||||
@@ -293,6 +384,39 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Inline Like */}
|
||||||
|
{feedbackEnabled && feedbackSettings?.allow_likes && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={submitLike}
|
||||||
|
className={`p-2 rounded-full transition-colors ${myLiked ? 'bg-red-500/80 hover:bg-red-500' : 'bg-white/10 hover:bg-white/20'}`}
|
||||||
|
aria-label={myLiked ? 'Unlike photo' : 'Like photo'}
|
||||||
|
title={myLiked ? 'Unlike' : 'Like'}
|
||||||
|
>
|
||||||
|
<Heart className={`w-5 h-5 ${myLiked ? 'text-white' : 'text-white'}`} />
|
||||||
|
</button>
|
||||||
|
<span className="text-white text-xs min-w-[1.5rem] text-center select-none">{likeCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inline Rating */}
|
||||||
|
{feedbackEnabled && feedbackSettings?.allow_ratings && (
|
||||||
|
<div className="flex items-center gap-1 ml-1" aria-label="Rate photo">
|
||||||
|
{[1,2,3,4,5].map((i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => submitRating(i)}
|
||||||
|
className="p-1"
|
||||||
|
aria-label={`Rate ${i} star${i>1?'s':''}`}
|
||||||
|
title={`Rate ${i}`}
|
||||||
|
>
|
||||||
|
<Star className={`w-5 h-5 ${myRating >= i ? 'text-yellow-400 fill-yellow-400' : 'text-white/70'}`} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="text-white/90 text-xs ml-2 select-none">{avgRating.toFixed(1)} ({totalRatings})</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Feedback button with indicator */}
|
{/* Feedback button with indicator */}
|
||||||
{feedbackEnabled && (
|
{feedbackEnabled && (
|
||||||
<button
|
<button
|
||||||
@@ -403,6 +527,34 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Identity Modal for required name/email */}
|
||||||
|
<FeedbackIdentityModal
|
||||||
|
isOpen={showIdentityModal}
|
||||||
|
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
|
||||||
|
onSubmit={async (name, email) => {
|
||||||
|
setSavedIdentity({ name, email });
|
||||||
|
setShowIdentityModal(false);
|
||||||
|
if (pendingAction?.type === 'like') {
|
||||||
|
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||||
|
feedback_type: 'like',
|
||||||
|
guest_name: name,
|
||||||
|
guest_email: email,
|
||||||
|
});
|
||||||
|
setMyLiked(true);
|
||||||
|
} else if (pendingAction?.type === 'rating' && pendingAction.rating) {
|
||||||
|
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
|
||||||
|
feedback_type: 'rating',
|
||||||
|
rating: pendingAction.rating,
|
||||||
|
guest_name: name,
|
||||||
|
guest_email: email,
|
||||||
|
});
|
||||||
|
setMyRating(pendingAction.rating);
|
||||||
|
}
|
||||||
|
setPendingAction(null);
|
||||||
|
}}
|
||||||
|
feedbackType={pendingAction?.type === 'rating' ? 'rating' : 'like'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,4 +10,3 @@ export { PhotoFeedback } from './PhotoFeedback';
|
|||||||
export { PhotoRating } from './PhotoRating';
|
export { PhotoRating } from './PhotoRating';
|
||||||
export { PhotoLikes } from './PhotoLikes';
|
export { PhotoLikes } from './PhotoLikes';
|
||||||
export { PhotoComments } from './PhotoComments';
|
export { PhotoComments } from './PhotoComments';
|
||||||
export { PhotoFavorites } from './PhotoFavorites';
|
|
||||||
@@ -5,6 +5,10 @@ export interface BaseGalleryLayoutProps {
|
|||||||
photos: Photo[];
|
photos: Photo[];
|
||||||
slug: string;
|
slug: string;
|
||||||
onPhotoClick: (index: number) => void;
|
onPhotoClick: (index: number) => void;
|
||||||
|
// Optional: open the lightbox with feedback panel visible
|
||||||
|
onOpenPhotoWithFeedback?: (index: number) => void;
|
||||||
|
// Notify parent that feedback (like/favorite/rating/comment) changed
|
||||||
|
onFeedbackChange?: () => void;
|
||||||
onDownload: (photo: Photo, e: React.MouseEvent) => void;
|
onDownload: (photo: Photo, e: React.MouseEvent) => void;
|
||||||
selectedPhotos?: Set<number>;
|
selectedPhotos?: Set<number>;
|
||||||
isSelectionMode?: boolean;
|
isSelectionMode?: boolean;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, Download, Maximize2, Play, Pause, Heart, Bookmark } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Download, Maximize2, Play, Pause, Heart, MessageSquare } from 'lucide-react';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { AuthenticatedImage, Button } from '../../common';
|
import { AuthenticatedImage, Button } from '../../common';
|
||||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||||
@@ -10,6 +10,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
photos,
|
photos,
|
||||||
slug,
|
slug,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
|
onOpenPhotoWithFeedback,
|
||||||
onDownload,
|
onDownload,
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
feedbackEnabled = false,
|
feedbackEnabled = false,
|
||||||
@@ -63,8 +64,10 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
|
|
||||||
const currentPhoto = photos[currentIndex];
|
const currentPhoto = photos[currentIndex];
|
||||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
|
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
|
||||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||||
|
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||||
|
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -142,7 +145,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
<Download className="w-5 h-5" />
|
<Download className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
{feedbackOptions?.allowLikes && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -152,38 +155,32 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
setShowIdentityModal(true);
|
setShowIdentityModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setLikedIds(prev => new Set(prev).add(currentPhoto.id));
|
||||||
|
try {
|
||||||
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
|
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
|
||||||
feedback_type: 'like',
|
feedback_type: 'like',
|
||||||
guest_name: savedIdentity?.name,
|
guest_name: savedIdentity?.name,
|
||||||
guest_email: savedIdentity?.email,
|
guest_email: savedIdentity?.email,
|
||||||
});
|
});
|
||||||
|
} catch (_) {}
|
||||||
}}
|
}}
|
||||||
className="text-white hover:bg-white/20"
|
className={`hover:bg-white/20 ${likedIds.has(currentPhoto.id) ? 'text-red-400' : 'text-white'}`}
|
||||||
title="Like photo"
|
title="Like photo"
|
||||||
|
aria-pressed={likedIds.has(currentPhoto.id)}
|
||||||
>
|
>
|
||||||
<Heart className="w-5 h-5" />
|
<Heart className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{feedbackEnabled && feedbackOptions?.allowFavorites && (
|
{canQuickComment && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={async () => {
|
onClick={() => { onOpenPhotoWithFeedback?.(currentIndex); }}
|
||||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
|
||||||
setPendingAction({ type: 'favorite', photoId: currentPhoto.id });
|
|
||||||
setShowIdentityModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
|
|
||||||
feedback_type: 'favorite',
|
|
||||||
guest_name: savedIdentity?.name,
|
|
||||||
guest_email: savedIdentity?.email,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="text-white hover:bg-white/20"
|
className="text-white hover:bg-white/20"
|
||||||
title="Favorite photo"
|
title="Comment"
|
||||||
|
aria-label="Comment on photo"
|
||||||
>
|
>
|
||||||
<Bookmark className="w-5 h-5" />
|
<MessageSquare className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -246,7 +243,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'}
|
feedbackType="like"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Bookmark } from 'lucide-react';
|
import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react';
|
||||||
import { useInView } from 'react-intersection-observer';
|
import { useInView } from 'react-intersection-observer';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { AuthenticatedImage } from '../../common';
|
import { AuthenticatedImage } from '../../common';
|
||||||
@@ -23,13 +23,17 @@ interface GridPhotoProps {
|
|||||||
feedbackEnabled?: boolean;
|
feedbackEnabled?: boolean;
|
||||||
feedbackOptions?: {
|
feedbackOptions?: {
|
||||||
allowLikes?: boolean;
|
allowLikes?: boolean;
|
||||||
allowFavorites?: boolean;
|
|
||||||
allowRatings?: boolean;
|
allowRatings?: boolean;
|
||||||
allowComments?: boolean;
|
allowComments?: boolean;
|
||||||
requireNameEmail?: boolean;
|
requireNameEmail?: boolean;
|
||||||
};
|
};
|
||||||
savedIdentity?: { name: string; email: string } | null;
|
savedIdentity?: { name: string; email: string } | null;
|
||||||
onRequireIdentity?: (action: 'like' | 'favorite', photoId: number) => void;
|
onRequireIdentity?: (action: 'like', photoId: number) => void;
|
||||||
|
onQuickComment?: () => void;
|
||||||
|
onFeedbackChange?: () => void;
|
||||||
|
// Immediate UI like state and callback
|
||||||
|
liked?: boolean;
|
||||||
|
onLikeSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GridPhoto: React.FC<GridPhotoProps> = ({
|
const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||||
@@ -45,7 +49,13 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
|||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
feedbackEnabled = false,
|
feedbackEnabled = false,
|
||||||
feedbackOptions
|
feedbackOptions,
|
||||||
|
savedIdentity,
|
||||||
|
onRequireIdentity,
|
||||||
|
onQuickComment,
|
||||||
|
onFeedbackChange,
|
||||||
|
liked = false,
|
||||||
|
onLikeSuccess
|
||||||
}) => {
|
}) => {
|
||||||
// handled by parent layout; kept here for type completeness but not used
|
// handled by parent layout; kept here for type completeness but not used
|
||||||
const { ref, inView } = useInView({
|
const { ref, inView } = useInView({
|
||||||
@@ -94,7 +104,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
|
<div className="absolute inset-0 bg-black/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
|
||||||
{!isSelectionMode && (
|
{!isSelectionMode && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -116,47 +126,45 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
|||||||
<Download className="w-5 h-5 text-neutral-800" />
|
<Download className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onQuickComment && (
|
||||||
|
<button
|
||||||
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
|
||||||
|
aria-label="Comment on photo"
|
||||||
|
title="Comment"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-5 h-5 text-neutral-800" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{/* Quick feedback actions */}
|
{/* Quick feedback actions */}
|
||||||
{feedbackOptions?.allowLikes && (
|
{feedbackOptions?.allowLikes && (
|
||||||
<button
|
<button
|
||||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
className={`p-2 rounded-full transition-colors ${liked ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
|
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
|
||||||
onRequireIdentity('like', photo.id);
|
onRequireIdentity('like', photo.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Optimistic UI: mark as liked immediately
|
||||||
|
if (onLikeSuccess) onLikeSuccess();
|
||||||
|
try {
|
||||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||||
feedback_type: 'like',
|
feedback_type: 'like',
|
||||||
guest_name: savedIdentity?.name,
|
guest_name: savedIdentity?.name,
|
||||||
guest_email: savedIdentity?.email,
|
guest_email: savedIdentity?.email,
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Keep optimistic state; a refresh will reconcile
|
||||||
|
console.warn('Like submit failed, keeping optimistic UI', err);
|
||||||
|
}
|
||||||
|
if (onFeedbackChange) onFeedbackChange();
|
||||||
}}
|
}}
|
||||||
aria-label="Like photo"
|
aria-label="Like photo"
|
||||||
|
aria-pressed={liked}
|
||||||
title="Like"
|
title="Like"
|
||||||
>
|
>
|
||||||
<Heart className="w-5 h-5 text-neutral-800" />
|
<Heart className={`w-5 h-5 ${liked ? 'text-white fill-white' : 'text-neutral-800'}`} />
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{feedbackOptions?.allowFavorites && (
|
|
||||||
<button
|
|
||||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
|
|
||||||
onRequireIdentity('favorite', photo.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
|
||||||
feedback_type: 'favorite',
|
|
||||||
guest_name: savedIdentity?.name,
|
|
||||||
guest_email: savedIdentity?.email,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
aria-label="Favorite photo"
|
|
||||||
title="Favorite"
|
|
||||||
>
|
|
||||||
<Bookmark className="w-5 h-5 text-neutral-800" />
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -180,10 +188,10 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Feedback Indicators (always visible, bottom-left) */}
|
{/* Feedback Indicators (always visible, bottom-left). Show like immediately when user liked */}
|
||||||
{feedbackEnabled && (photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && (
|
{(photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0 || liked) && (
|
||||||
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-10`}>
|
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-10`}>
|
||||||
{photo.like_count > 0 && (
|
{(photo.like_count > 0 || liked) && (
|
||||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
||||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||||
</span>
|
</span>
|
||||||
@@ -220,6 +228,8 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
photos,
|
photos,
|
||||||
slug,
|
slug,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
|
onOpenPhotoWithFeedback,
|
||||||
|
onFeedbackChange,
|
||||||
onDownload,
|
onDownload,
|
||||||
selectedPhotos = new Set(),
|
selectedPhotos = new Set(),
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
@@ -237,7 +247,8 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
const animation = gallerySettings.photoAnimation || 'fade';
|
const animation = gallerySettings.photoAnimation || 'fade';
|
||||||
|
|
||||||
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
|
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
|
||||||
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
|
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
|
||||||
|
const [likedPhotoIds, setLikedPhotoIds] = React.useState<Set<number>>(new Set());
|
||||||
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
|
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
|
||||||
|
|
||||||
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
|
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
|
||||||
@@ -271,6 +282,16 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
setPendingAction({ type: action, photoId });
|
setPendingAction({ type: action, photoId });
|
||||||
setShowIdentityModal(true);
|
setShowIdentityModal(true);
|
||||||
}}
|
}}
|
||||||
|
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(index)}
|
||||||
|
onFeedbackChange={onFeedbackChange}
|
||||||
|
liked={likedPhotoIds.has(photo.id)}
|
||||||
|
onLikeSuccess={() => {
|
||||||
|
setLikedPhotoIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(photo.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<FeedbackIdentityModal
|
<FeedbackIdentityModal
|
||||||
@@ -285,10 +306,18 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
guest_name: name,
|
guest_name: name,
|
||||||
guest_email: email,
|
guest_email: email,
|
||||||
});
|
});
|
||||||
|
// Immediately reflect like UI
|
||||||
|
if (pendingAction.type === 'like') {
|
||||||
|
setLikedPhotoIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(pendingAction.photoId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'}
|
feedbackType="like"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Download, Maximize2, Check, ChevronDown, Calendar, Clock, Heart, Bookmark } from 'lucide-react';
|
import { Download, Maximize2, Check, ChevronDown, Calendar, Clock, Heart, MessageSquare } from 'lucide-react';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||||
@@ -16,12 +16,15 @@ interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
|
|||||||
eventLogo?: string | null;
|
eventLogo?: string | null;
|
||||||
eventDate?: string;
|
eventDate?: string;
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
|
// Use a static hero photo independent of current filter
|
||||||
|
heroPhotoOverride?: Photo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||||
photos,
|
photos,
|
||||||
slug,
|
slug,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
|
onOpenPhotoWithFeedback,
|
||||||
onDownload,
|
onDownload,
|
||||||
selectedPhotos = new Set(),
|
selectedPhotos = new Set(),
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
@@ -30,6 +33,7 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
eventLogo,
|
eventLogo,
|
||||||
eventDate,
|
eventDate,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
|
heroPhotoOverride,
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
feedbackEnabled = false,
|
feedbackEnabled = false,
|
||||||
feedbackOptions
|
feedbackOptions
|
||||||
@@ -40,10 +44,20 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
|
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
|
||||||
const [hasInitialized, setHasInitialized] = useState(false);
|
const [hasInitialized, setHasInitialized] = useState(false);
|
||||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
|
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
|
||||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||||
const gallerySettings = theme.gallerySettings || {};
|
const gallerySettings = theme.gallerySettings || {};
|
||||||
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
||||||
|
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||||
|
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
||||||
|
|
||||||
|
// If an override is provided, always use it and skip initialization logic
|
||||||
|
useEffect(() => {
|
||||||
|
if (heroPhotoOverride) {
|
||||||
|
setHeroPhoto(heroPhotoOverride);
|
||||||
|
setHasInitialized(true);
|
||||||
|
}
|
||||||
|
}, [heroPhotoOverride]);
|
||||||
|
|
||||||
// Reset initialization when heroImageId changes
|
// Reset initialization when heroImageId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -54,14 +68,14 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
|
|
||||||
// Select hero photo (admin-selected or first photo only if gallery was empty)
|
// Select hero photo (admin-selected or first photo only if gallery was empty)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// When an override is provided, the effect above has already set the hero.
|
||||||
|
if (heroPhotoOverride) return;
|
||||||
|
|
||||||
if (photos.length > 0) {
|
if (photos.length > 0) {
|
||||||
const heroId = gallerySettings.heroImageId;
|
const heroId = gallerySettings.heroImageId;
|
||||||
// Process hero layout with provided photos
|
// If admin has selected a specific hero image, always use it when available
|
||||||
|
|
||||||
// If admin has selected a specific hero image, always use it
|
|
||||||
if (heroId) {
|
if (heroId) {
|
||||||
const adminSelectedHero = photos.find(p => p.id === heroId);
|
const adminSelectedHero = photos.find(p => p.id === heroId);
|
||||||
// Hero photo selected by admin
|
|
||||||
if (adminSelectedHero) {
|
if (adminSelectedHero) {
|
||||||
setHeroPhoto(adminSelectedHero);
|
setHeroPhoto(adminSelectedHero);
|
||||||
setHasInitialized(true);
|
setHasInitialized(true);
|
||||||
@@ -69,14 +83,13 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only auto-select first photo on initial load when gallery was empty
|
// Only auto-select first photo on initial load
|
||||||
// This prevents changing the hero when new photos are uploaded
|
|
||||||
if (!hasInitialized) {
|
if (!hasInitialized) {
|
||||||
setHeroPhoto(photos[0]);
|
setHeroPhoto(photos[0]);
|
||||||
setHasInitialized(true);
|
setHasInitialized(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [photos, gallerySettings.heroImageId, hasInitialized]);
|
}, [photos, gallerySettings.heroImageId, hasInitialized, heroPhotoOverride]);
|
||||||
|
|
||||||
if (!heroPhoto) return null;
|
if (!heroPhoto) return null;
|
||||||
|
|
||||||
@@ -198,9 +211,9 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
<Download className="w-5 h-5 text-neutral-800" />
|
<Download className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
{feedbackOptions?.allowLikes && (
|
||||||
<button
|
<button
|
||||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||||
@@ -208,38 +221,30 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
setShowIdentityModal(true);
|
setShowIdentityModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setLikedIds(prev => new Set(prev).add(photo.id));
|
||||||
|
try {
|
||||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||||
feedback_type: 'like',
|
feedback_type: 'like',
|
||||||
guest_name: savedIdentity?.name,
|
guest_name: savedIdentity?.name,
|
||||||
guest_email: savedIdentity?.email,
|
guest_email: savedIdentity?.email,
|
||||||
});
|
});
|
||||||
|
} catch (_) {}
|
||||||
}}
|
}}
|
||||||
aria-label="Like photo"
|
aria-label="Like photo"
|
||||||
|
aria-pressed={likedIds.has(photo.id)}
|
||||||
title="Like"
|
title="Like"
|
||||||
>
|
>
|
||||||
<Heart className="w-5 h-5 text-neutral-800" />
|
<Heart className={`w-5 h-5 ${likedIds.has(photo.id) ? 'text-white fill-white' : 'text-neutral-800'}`} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{feedbackEnabled && feedbackOptions?.allowFavorites && (
|
{canQuickComment && (
|
||||||
<button
|
<button
|
||||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
onClick={async (e) => {
|
onClick={(e) => { e.stopPropagation(); onOpenPhotoWithFeedback?.(actualIndex); }}
|
||||||
e.stopPropagation();
|
aria-label="Comment on photo"
|
||||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
title="Comment"
|
||||||
setPendingAction({ type: 'favorite', photoId: photo.id });
|
|
||||||
setShowIdentityModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
|
||||||
feedback_type: 'favorite',
|
|
||||||
guest_name: savedIdentity?.name,
|
|
||||||
guest_email: savedIdentity?.email,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
aria-label="Favorite photo"
|
|
||||||
title="Favorite"
|
|
||||||
>
|
>
|
||||||
<Bookmark className="w-5 h-5 text-neutral-800" />
|
<MessageSquare className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -263,18 +268,16 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Feedback indicators (always visible, bottom-left) */}
|
{/* Feedback indicators (always visible, bottom-left). Show like immediately when liked */}
|
||||||
{(feedbackEnabled && (photo.like_count > 0 || (photo.average_rating || 0) > 0 || (photo.comment_count || 0) > 0)) && (
|
{(photo.like_count > 0 || likedIds.has(photo.id) || (photo.average_rating || 0) > 0 || (photo.comment_count || 0) > 0) && (
|
||||||
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-20`}>
|
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 flex items-center gap-1 z-20`}>
|
||||||
{photo.like_count > 0 && (
|
{(photo.like_count > 0 || likedIds.has(photo.id)) && (
|
||||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
||||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(photo.average_rating || 0) > 0 && (
|
{(photo.average_rating || 0) > 0 && (
|
||||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Rated">
|
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Rated">
|
||||||
<Bookmark className="hidden" />
|
|
||||||
{/* Using star icon to indicate rating */}
|
|
||||||
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-yellow-500 fill-current"><path d="M12 .587l3.668 7.431 8.2 1.193-5.934 5.787 1.402 8.168L12 18.897l-7.336 3.869 1.402-8.168L.132 9.211l8.2-1.193z"/></svg>
|
<svg viewBox="0 0 24 24" className="w-3.5 h-3.5 text-yellow-500 fill-current"><path d="M12 .587l3.668 7.431 8.2 1.193-5.934 5.787 1.402 8.168L12 18.897l-7.336 3.869 1.402-8.168L.132 9.211l8.2-1.193z"/></svg>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -305,7 +308,7 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'}
|
feedbackType="like"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Bookmark } from 'lucide-react';
|
import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { AuthenticatedImage } from '../../common';
|
import { AuthenticatedImage } from '../../common';
|
||||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||||
@@ -20,9 +20,10 @@ interface MasonryPhotoProps {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
feedbackOptions?: {
|
feedbackOptions?: {
|
||||||
allowLikes?: boolean;
|
allowLikes?: boolean;
|
||||||
allowFavorites?: boolean;
|
allowComments?: boolean;
|
||||||
requireNameEmail?: boolean;
|
requireNameEmail?: boolean;
|
||||||
};
|
};
|
||||||
|
onQuickComment?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||||
@@ -36,11 +37,12 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
|||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
feedbackEnabled = false,
|
feedbackEnabled = false,
|
||||||
slug,
|
slug,
|
||||||
feedbackOptions
|
feedbackOptions,
|
||||||
|
onQuickComment
|
||||||
}) => {
|
}) => {
|
||||||
const [imageHeight, setImageHeight] = useState<number>(200);
|
const [imageHeight, setImageHeight] = useState<number>(200);
|
||||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
|
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
|
||||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||||
|
|
||||||
// Generate random heights for masonry effect
|
// Generate random heights for masonry effect
|
||||||
@@ -115,6 +117,16 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
|||||||
<Download className="w-5 h-5 text-neutral-800" />
|
<Download className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onQuickComment && (
|
||||||
|
<button
|
||||||
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
|
||||||
|
aria-label="Comment on photo"
|
||||||
|
title="Comment"
|
||||||
|
>
|
||||||
|
<MessageSquare className="w-5 h-5 text-neutral-800" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{feedbackOptions?.allowLikes && (
|
{feedbackOptions?.allowLikes && (
|
||||||
<button
|
<button
|
||||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
@@ -137,28 +149,6 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
|||||||
<Heart className="w-5 h-5 text-neutral-800" />
|
<Heart className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{feedbackOptions?.allowFavorites && (
|
|
||||||
<button
|
|
||||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
|
||||||
setPendingAction({ type: 'favorite', photoId: photo.id });
|
|
||||||
setShowIdentityModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
|
||||||
feedback_type: 'favorite',
|
|
||||||
guest_name: savedIdentity?.name,
|
|
||||||
guest_email: savedIdentity?.email,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
aria-label="Favorite photo"
|
|
||||||
title="Favorite"
|
|
||||||
>
|
|
||||||
<Bookmark className="w-5 h-5 text-neutral-800" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +169,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
|||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'}
|
feedbackType="like"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Selection Checkbox (visible on hover or when selected) */}
|
{/* Selection Checkbox (visible on hover or when selected) */}
|
||||||
@@ -214,6 +204,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
photos,
|
photos,
|
||||||
slug,
|
slug,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
|
onOpenPhotoWithFeedback,
|
||||||
onDownload,
|
onDownload,
|
||||||
selectedPhotos = new Set(),
|
selectedPhotos = new Set(),
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
@@ -278,6 +269,7 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
feedbackOptions={feedbackOptions}
|
feedbackOptions={feedbackOptions}
|
||||||
|
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(originalIndex)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Download, Maximize2, Check, Heart, Bookmark } from 'lucide-react';
|
import { Download, Maximize2, Check, Heart, MessageSquare } from 'lucide-react';
|
||||||
// import { useTheme } from '../../../contexts/ThemeContext';
|
// import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { AuthenticatedImage } from '../../common';
|
import { AuthenticatedImage } from '../../common';
|
||||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||||
@@ -20,9 +20,10 @@ interface MosaicPhotoProps {
|
|||||||
feedbackEnabled?: boolean;
|
feedbackEnabled?: boolean;
|
||||||
feedbackOptions?: {
|
feedbackOptions?: {
|
||||||
allowLikes?: boolean;
|
allowLikes?: boolean;
|
||||||
allowFavorites?: boolean;
|
allowComments?: boolean;
|
||||||
requireNameEmail?: boolean;
|
requireNameEmail?: boolean;
|
||||||
};
|
};
|
||||||
|
onQuickComment?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||||
@@ -35,12 +36,16 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
|||||||
className = '',
|
className = '',
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
slug,
|
slug,
|
||||||
feedbackEnabled,
|
feedbackEnabled = false,
|
||||||
feedbackOptions
|
feedbackOptions,
|
||||||
|
onQuickComment
|
||||||
}) => {
|
}) => {
|
||||||
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
|
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
|
||||||
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
|
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
|
||||||
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
|
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
|
||||||
|
const [likedLocal, setLikedLocal] = React.useState(false);
|
||||||
|
const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -83,9 +88,9 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
|||||||
<Download className="w-5 h-5 text-neutral-800" />
|
<Download className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
{feedbackOptions?.allowLikes && (
|
||||||
<button
|
<button
|
||||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
className={`p-2 rounded-full transition-colors ${likedLocal ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||||
@@ -93,44 +98,45 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
|||||||
setShowIdentityModal(true);
|
setShowIdentityModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setLikedLocal(true);
|
||||||
|
try {
|
||||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||||
feedback_type: 'like',
|
feedback_type: 'like',
|
||||||
guest_name: savedIdentity?.name,
|
guest_name: savedIdentity?.name,
|
||||||
guest_email: savedIdentity?.email,
|
guest_email: savedIdentity?.email,
|
||||||
});
|
});
|
||||||
|
} catch (_) {}
|
||||||
}}
|
}}
|
||||||
aria-label="Like photo"
|
aria-label="Like photo"
|
||||||
|
aria-pressed={likedLocal}
|
||||||
title="Like"
|
title="Like"
|
||||||
>
|
>
|
||||||
<Heart className="w-5 h-5 text-neutral-800" />
|
<Heart className={`w-5 h-5 ${likedLocal ? 'text-white fill-white' : 'text-neutral-800'}`} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{feedbackEnabled && feedbackOptions?.allowFavorites && (
|
{canComment && (
|
||||||
<button
|
<button
|
||||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
onClick={async (e) => {
|
onClick={(e) => { e.stopPropagation(); onQuickComment?.(); }}
|
||||||
e.stopPropagation();
|
aria-label="Comment on photo"
|
||||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
title="Comment"
|
||||||
setPendingAction({ type: 'favorite', photoId: photo.id });
|
|
||||||
setShowIdentityModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
|
||||||
feedback_type: 'favorite',
|
|
||||||
guest_name: savedIdentity?.name,
|
|
||||||
guest_email: savedIdentity?.email,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
aria-label="Favorite photo"
|
|
||||||
title="Favorite"
|
|
||||||
>
|
>
|
||||||
<Bookmark className="w-5 h-5 text-neutral-800" />
|
<MessageSquare className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Feedback Indicators (bottom-left) */}
|
||||||
|
{(photo.like_count > 0 || likedLocal) && (
|
||||||
|
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 z-10`}>
|
||||||
|
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
||||||
|
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Selection Checkbox (visible on hover or when selected) */}
|
{/* Selection Checkbox (visible on hover or when selected) */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -171,7 +177,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
|||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'}
|
feedbackType="like"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -181,6 +187,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
photos,
|
photos,
|
||||||
slug,
|
slug,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
|
onOpenPhotoWithFeedback,
|
||||||
onDownload,
|
onDownload,
|
||||||
selectedPhotos = new Set(),
|
selectedPhotos = new Set(),
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
@@ -236,6 +243,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
slug={slug}
|
slug={slug}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
feedbackOptions={feedbackOptions}
|
feedbackOptions={feedbackOptions}
|
||||||
|
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx0); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-rows-2 gap-2">
|
<div className="grid grid-rows-2 gap-2">
|
||||||
@@ -252,6 +260,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
slug={slug}
|
slug={slug}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
feedbackOptions={feedbackOptions}
|
feedbackOptions={feedbackOptions}
|
||||||
|
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx1); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{photo2 && (
|
{photo2 && (
|
||||||
@@ -267,6 +276,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
slug={slug}
|
slug={slug}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
feedbackOptions={feedbackOptions}
|
feedbackOptions={feedbackOptions}
|
||||||
|
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx2); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -290,6 +300,10 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
onDownload={(e) => onDownload(photo, e)}
|
onDownload={(e) => onDownload(photo, e)}
|
||||||
className=""
|
className=""
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
|
slug={slug}
|
||||||
|
feedbackEnabled={feedbackEnabled}
|
||||||
|
feedbackOptions={feedbackOptions}
|
||||||
|
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(currentIndex); }}
|
||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
})}
|
})}
|
||||||
@@ -321,6 +335,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
slug={slug}
|
slug={slug}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
feedbackOptions={feedbackOptions}
|
feedbackOptions={feedbackOptions}
|
||||||
|
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx0); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-rows-2 gap-2">
|
<div className="grid grid-rows-2 gap-2">
|
||||||
@@ -337,6 +352,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
slug={slug}
|
slug={slug}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
feedbackOptions={feedbackOptions}
|
feedbackOptions={feedbackOptions}
|
||||||
|
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx1); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{photo2 && (
|
{photo2 && (
|
||||||
@@ -352,6 +368,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
slug={slug}
|
slug={slug}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
feedbackOptions={feedbackOptions}
|
feedbackOptions={feedbackOptions}
|
||||||
|
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(idx2); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -387,6 +404,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
slug={slug}
|
slug={slug}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
feedbackOptions={feedbackOptions}
|
feedbackOptions={feedbackOptions}
|
||||||
|
onQuickComment={() => { if (typeof onOpenPhotoWithFeedback !== 'undefined' && onOpenPhotoWithFeedback) onOpenPhotoWithFeedback(index); }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { Download, Maximize2, Check, Calendar, Heart, Bookmark } from 'lucide-react';
|
import { Download, Maximize2, Check, Calendar, Heart, MessageSquare } from 'lucide-react';
|
||||||
import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns';
|
import { format, parseISO, startOfDay, startOfWeek, startOfMonth } from 'date-fns';
|
||||||
import { useTheme } from '../../../contexts/ThemeContext';
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
import { AuthenticatedImage } from '../../common';
|
import { AuthenticatedImage } from '../../common';
|
||||||
@@ -12,6 +12,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
photos,
|
photos,
|
||||||
slug,
|
slug,
|
||||||
onPhotoClick,
|
onPhotoClick,
|
||||||
|
onOpenPhotoWithFeedback,
|
||||||
onDownload,
|
onDownload,
|
||||||
selectedPhotos = new Set(),
|
selectedPhotos = new Set(),
|
||||||
isSelectionMode = false,
|
isSelectionMode = false,
|
||||||
@@ -21,12 +22,14 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
feedbackOptions
|
feedbackOptions
|
||||||
}) => {
|
}) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'favorite'; photoId: number }>(null);
|
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
|
||||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||||
const gallerySettings = theme.gallerySettings || {};
|
const gallerySettings = theme.gallerySettings || {};
|
||||||
const grouping = gallerySettings.timelineGrouping || 'day';
|
const grouping = gallerySettings.timelineGrouping || 'day';
|
||||||
const showDates = gallerySettings.timelineShowDates !== false;
|
const showDates = gallerySettings.timelineShowDates !== false;
|
||||||
|
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
||||||
|
|
||||||
// Group photos by date
|
// Group photos by date
|
||||||
const groupedPhotos = useMemo(() => {
|
const groupedPhotos = useMemo(() => {
|
||||||
@@ -139,9 +142,9 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
<Download className="w-5 h-5 text-neutral-800" />
|
<Download className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
{feedbackOptions?.allowLikes && (
|
||||||
<button
|
<button
|
||||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
||||||
@@ -149,44 +152,44 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
setShowIdentityModal(true);
|
setShowIdentityModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setLikedIds(prev => new Set(prev).add(photo.id));
|
||||||
|
try {
|
||||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
||||||
feedback_type: 'like',
|
feedback_type: 'like',
|
||||||
guest_name: savedIdentity?.name,
|
guest_name: savedIdentity?.name,
|
||||||
guest_email: savedIdentity?.email,
|
guest_email: savedIdentity?.email,
|
||||||
});
|
});
|
||||||
|
} catch (_) {}
|
||||||
}}
|
}}
|
||||||
aria-label="Like photo"
|
aria-label="Like photo"
|
||||||
|
aria-pressed={likedIds.has(photo.id)}
|
||||||
title="Like"
|
title="Like"
|
||||||
>
|
>
|
||||||
<Heart className="w-5 h-5 text-neutral-800" />
|
<Heart className={`w-5 h-5 ${likedIds.has(photo.id) ? 'text-white fill-white' : 'text-neutral-800'}`} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{feedbackEnabled && feedbackOptions?.allowFavorites && (
|
{canQuickComment && (
|
||||||
<button
|
<button
|
||||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||||
onClick={async (e) => {
|
onClick={(e) => { e.stopPropagation(); onOpenPhotoWithFeedback?.(actualIndex); }}
|
||||||
e.stopPropagation();
|
aria-label="Comment on photo"
|
||||||
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
|
title="Comment"
|
||||||
setPendingAction({ type: 'favorite', photoId: photo.id });
|
|
||||||
setShowIdentityModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await feedbackService.submitFeedback(slug!, String(photo.id), {
|
|
||||||
feedback_type: 'favorite',
|
|
||||||
guest_name: savedIdentity?.name,
|
|
||||||
guest_email: savedIdentity?.email,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
aria-label="Favorite photo"
|
|
||||||
title="Favorite"
|
|
||||||
>
|
>
|
||||||
<Bookmark className="w-5 h-5 text-neutral-800" />
|
<MessageSquare className="w-5 h-5 text-neutral-800" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(photo.like_count > 0 || likedIds.has(photo.id)) && (
|
||||||
|
<div className={`absolute ${photo.type === 'collage' ? 'bottom-8' : 'bottom-2'} left-2 z-10`}>
|
||||||
|
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-white/90 backdrop-blur-sm" title="Liked">
|
||||||
|
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Selection Checkbox (visible on hover or when selected) */}
|
{/* Selection Checkbox (visible on hover or when selected) */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -225,7 +228,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
feedbackType={pendingAction?.type === 'favorite' ? 'favorite' : 'like'}
|
feedbackType="like"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,11 +11,16 @@ export const useGalleryInfo = (slug: string, token?: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useGalleryPhotos = (slug: string, filter?: 'liked' | 'favorited' | 'all', guestId?: string, enabled: boolean = true) => {
|
export const useGalleryPhotos = (
|
||||||
|
slug: string,
|
||||||
|
filter?: 'liked' | 'commented' | 'rated' | 'all',
|
||||||
|
guestId?: string,
|
||||||
|
enabled: boolean = true
|
||||||
|
) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['gallery-photos', slug, filter, guestId],
|
queryKey: ['gallery-photos', slug, filter, guestId],
|
||||||
// Do not pass guestId for now; backend will apply global filters when guest_id is absent
|
// Pass guestId so backend can filter per-guest views when needed
|
||||||
queryFn: () => galleryService.getGalleryPhotos(slug, filter, undefined),
|
queryFn: () => galleryService.getGalleryPhotos(slug, filter, guestId),
|
||||||
enabled,
|
enabled,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ export const galleryService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Get gallery photos (requires auth)
|
// Get gallery photos (requires auth)
|
||||||
async getGalleryPhotos(slug: string, filter?: 'liked' | 'favorited' | 'all', guestId?: string): Promise<GalleryData> {
|
async getGalleryPhotos(
|
||||||
|
slug: string,
|
||||||
|
filter?: 'liked' | 'commented' | 'rated' | 'all',
|
||||||
|
guestId?: string
|
||||||
|
): Promise<GalleryData> {
|
||||||
const params: any = {};
|
const params: any = {};
|
||||||
if (filter && filter !== 'all' && guestId) {
|
if (filter && filter !== 'all' && guestId) {
|
||||||
params.filter = filter;
|
params.filter = filter;
|
||||||
@@ -28,11 +32,10 @@ export const galleryService = {
|
|||||||
|
|
||||||
// Download single photo
|
// Download single photo
|
||||||
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
||||||
|
try {
|
||||||
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
|
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create download link
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -41,6 +44,24 @@ export const galleryService = {
|
|||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback: use the view endpoint if direct download fails (e.g., missing original)
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', filename);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
throw fallbackErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Download all photos as ZIP
|
// Download all photos as ZIP
|
||||||
@@ -60,6 +81,22 @@ export const galleryService = {
|
|||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Download selected photos as ZIP
|
||||||
|
async downloadSelectedPhotos(slug: string, photoIds: number[]): Promise<void> {
|
||||||
|
const response = await api.post(`/gallery/${slug}/download-selected`, { photo_ids: photoIds }, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `${slug}-selected.zip`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
|
||||||
// Get gallery statistics
|
// Get gallery statistics
|
||||||
async getGalleryStats(slug: string): Promise<GalleryStats> {
|
async getGalleryStats(slug: string): Promise<GalleryStats> {
|
||||||
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
|
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
|
||||||
|
|||||||
Generated
+64
@@ -10,6 +10,7 @@
|
|||||||
"node-fetch": "^2.7.0"
|
"node-fetch": "^2.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.48.2",
|
||||||
"puppeteer": "^24.17.0"
|
"puppeteer": "^24.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -38,6 +39,22 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.55.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
|
||||||
|
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.55.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@puppeteer/browsers": {
|
"node_modules/@puppeteer/browsers": {
|
||||||
"version": "2.10.7",
|
"version": "2.10.7",
|
||||||
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.7.tgz",
|
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.7.tgz",
|
||||||
@@ -704,6 +721,21 @@
|
|||||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-caller-file": {
|
"node_modules/get-caller-file": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
@@ -1083,6 +1115,38 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.55.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
|
||||||
|
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.55.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.55.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
|
||||||
|
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prebuild-install": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
|
|||||||
+5
-1
@@ -1,10 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"scripts": {
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
"canvas": "^3.2.0",
|
"canvas": "^3.2.0",
|
||||||
"node-fetch": "^2.7.0"
|
"node-fetch": "^2.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"puppeteer": "^24.17.0"
|
"puppeteer": "^24.17.0",
|
||||||
|
"@playwright/test": "^1.48.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user