feat: photo visibility control with client access (#172)
Add two-tier gallery access system allowing clients (e.g., wedding couples) to review and hide photos before the gallery is shared with guests. Backend: - Migration 074: add visibility column to photos, client_access_enabled/ client_password_hash/client_share_token to events - Client login endpoint (POST /auth/gallery/:slug/client-login) with bcrypt PIN - Gallery photo list filters hidden photos for guests, shows all for clients - Visibility toggle endpoints (single + bulk) for client access level - Admin event CRUD supports client access fields - Email template includes client access link + PIN (EN/DE/RU/PT) Frontend: - ClientAccessPage: PIN entry form at /gallery/:slug/client-access - GalleryView: client mode banner, visibility counter, toggle controls - GridGalleryLayout: eye/eye-off overlay per photo for clients - AdminPhotoGrid: visibility badge, bulk Hide/Show buttons - EventDetailsPage: Client Access settings section (toggle, PIN, link) - CreateEventPage: client access toggle + PIN in event creation form - GalleryAuthContext: accessLevel/isClient/clientLogin support - New complete pt-BR locale (pt.json) with all translations - Client access i18n keys for EN, DE, RU, PT
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react';
|
||||
import { Check, Download, Trash2, Eye, EyeOff, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -201,6 +201,34 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
>
|
||||
{t('photos.moveToCategory', 'Move to Category')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'hidden' });
|
||||
toast.success(t('admin.photos.hiddenSuccess', 'Photos hidden'));
|
||||
onPhotosDeleted();
|
||||
} catch { toast.error(t('common.error')); }
|
||||
}}
|
||||
leftIcon={<EyeOff className="w-4 h-4" />}
|
||||
>
|
||||
{t('admin.photos.hideSelected', 'Hide')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'visible' });
|
||||
toast.success(t('admin.photos.visibleSuccess', 'Photos visible'));
|
||||
onPhotosDeleted();
|
||||
} catch { toast.error(t('common.error')); }
|
||||
}}
|
||||
leftIcon={<Eye className="w-4 h-4" />}
|
||||
>
|
||||
{t('admin.photos.showSelected', 'Show')}
|
||||
</Button>
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={isDeleting}
|
||||
@@ -260,6 +288,16 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Visibility badge (#172) */}
|
||||
{(photo as any).visibility === 'hidden' && (
|
||||
<div className="absolute top-2 left-2 z-20">
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-500/90 text-white text-[10px] font-medium">
|
||||
<EyeOff className="w-3 h-3" />
|
||||
{t('admin.photos.hidden', 'Hidden')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-square">
|
||||
{photo.thumbnail_url ? (
|
||||
|
||||
@@ -17,11 +17,13 @@ import type { FilterType } from './GalleryFilter';
|
||||
import { analyticsService } from '../../services/analytics.service';
|
||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||
import { api } from '../../config/api';
|
||||
import { Upload, Menu } from 'lucide-react';
|
||||
import { Upload, Menu, Eye, EyeOff, Shield } from 'lucide-react';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
|
||||
import type { Photo } from '../../types';
|
||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
interface GalleryViewProps {
|
||||
slug: string;
|
||||
@@ -42,8 +44,9 @@ interface GalleryViewProps {
|
||||
|
||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const { t } = useTranslation();
|
||||
const { logout } = useGalleryAuth();
|
||||
const { theme } = useTheme();
|
||||
const { logout, isClient } = useGalleryAuth();
|
||||
const { setTheme, theme } = useTheme();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
|
||||
@@ -274,6 +277,93 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
setStaticHeroPhoto(defaultHeroPhoto);
|
||||
}, [selectedCategoryId, data?.categories, data?.photos, defaultHeroPhoto]);
|
||||
|
||||
// Apply theme when settings are loaded
|
||||
useEffect(() => {
|
||||
if (settingsData && data?.event) {
|
||||
let themeToApply = null;
|
||||
const fullEvent = data.event; // Use the full event data from API
|
||||
|
||||
if (fullEvent.color_theme) {
|
||||
try {
|
||||
// Check if it's a valid JSON string
|
||||
if (fullEvent.color_theme.startsWith('{')) {
|
||||
const eventTheme = JSON.parse(fullEvent.color_theme);
|
||||
themeToApply = eventTheme;
|
||||
} else {
|
||||
// Handle legacy theme names - check if it's a preset
|
||||
const preset = GALLERY_THEME_PRESETS[fullEvent.color_theme];
|
||||
if (preset) {
|
||||
themeToApply = preset.config;
|
||||
} else {
|
||||
// Unknown theme name, fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid theme format - use default
|
||||
// Fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
}
|
||||
} else if (settingsData.theme_config) {
|
||||
// No event theme, use global theme
|
||||
themeToApply = settingsData.theme_config;
|
||||
}
|
||||
|
||||
// Apply theme with a small delay to ensure it overrides any global theme
|
||||
if (themeToApply) {
|
||||
// Use setTimeout to ensure this runs after any global theme application
|
||||
const timer = setTimeout(() => {
|
||||
// If there's a hero photo, add it to gallery settings
|
||||
if (fullEvent.hero_photo_id && themeToApply.gallerySettings) {
|
||||
themeToApply.gallerySettings.heroImageId = fullEvent.hero_photo_id;
|
||||
// Apply hero photo ID to existing gallery settings
|
||||
} else if (fullEvent.hero_photo_id) {
|
||||
themeToApply.gallerySettings = { heroImageId: fullEvent.hero_photo_id };
|
||||
// Create gallery settings with hero photo ID
|
||||
}
|
||||
setTheme(themeToApply);
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [settingsData, data, setTheme]); // Use data instead of event prop
|
||||
|
||||
// Client visibility toggle handler (#172)
|
||||
const handleToggleVisibility = async (photoId: number, currentVisibility: string) => {
|
||||
const newVisibility = currentVisibility === 'hidden' ? 'visible' : 'hidden';
|
||||
try {
|
||||
await galleryService.togglePhotoVisibility(slug, photoId, newVisibility);
|
||||
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle visibility:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkVisibility = async (visibility: 'visible' | 'hidden') => {
|
||||
if (selectedPhotos.size === 0) return;
|
||||
try {
|
||||
await galleryService.bulkToggleVisibility(slug, Array.from(selectedPhotos), visibility);
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
|
||||
} catch (error) {
|
||||
console.error('Failed to bulk toggle visibility:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Client visibility stats
|
||||
const visibleCount = useMemo(() => {
|
||||
if (!isClient || !data?.photos) return 0;
|
||||
return data.photos.filter(p => p.visibility !== 'hidden').length;
|
||||
}, [isClient, data?.photos]);
|
||||
|
||||
const totalCount = data?.photos?.length || 0;
|
||||
|
||||
// Calculate days until expiration (null means never expires)
|
||||
const daysUntilExpiration = event.expires_at
|
||||
? differenceInDays(parseISO(event.expires_at), new Date())
|
||||
@@ -677,6 +767,43 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
|
||||
)}
|
||||
|
||||
{/* Client Access Banner (#172) */}
|
||||
{isClient && (
|
||||
<div className="mt-4 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
{t('clientAccess.banner')}
|
||||
</span>
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400 ml-2">
|
||||
{t('clientAccess.visibleCount', { visible: visibleCount, total: totalCount })}
|
||||
</span>
|
||||
</div>
|
||||
{isSelectionMode && selectedPhotos.size > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<EyeOff className="w-4 h-4" />}
|
||||
onClick={() => handleBulkVisibility('hidden')}
|
||||
>
|
||||
{t('clientAccess.hideSelected')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Eye className="w-4 h-4" />}
|
||||
onClick={() => handleBulkVisibility('visible')}
|
||||
>
|
||||
{t('clientAccess.showSelected')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filters - Only for grid layout */}
|
||||
{!showSidebar ? (
|
||||
<div className="mt-6">
|
||||
@@ -739,6 +866,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
||||
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
||||
welcomeMessage={event.welcome_message}
|
||||
isClient={isClient}
|
||||
onToggleVisibility={isClient ? handleToggleVisibility : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -68,6 +68,9 @@ interface PhotoGridWithLayoutsProps {
|
||||
welcomeMessage?: string;
|
||||
// Logout callback for full-page layouts
|
||||
onLogout?: () => void;
|
||||
// Client visibility controls (#172)
|
||||
isClient?: boolean;
|
||||
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
|
||||
}
|
||||
|
||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
@@ -100,7 +103,9 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
heroDividerStyle = 'wave',
|
||||
heroImageAnchor = 'center',
|
||||
welcomeMessage,
|
||||
onLogout
|
||||
onLogout,
|
||||
isClient = false,
|
||||
onToggleVisibility
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
@@ -231,6 +236,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
heroLogoPosition,
|
||||
welcomeMessage,
|
||||
onLogout,
|
||||
isClient,
|
||||
onToggleVisibility,
|
||||
};
|
||||
|
||||
// Determine if we should show hero header (decoupled from layout)
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface BaseGalleryLayoutProps {
|
||||
};
|
||||
// Logout callback for full-page layouts
|
||||
onLogout?: () => void;
|
||||
// Client visibility controls (#172)
|
||||
isClient?: boolean;
|
||||
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
|
||||
}
|
||||
|
||||
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react';
|
||||
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video, Eye, EyeOff } from 'lucide-react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
@@ -365,7 +365,9 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
useEnhancedProtection = false,
|
||||
useCanvasRendering = false,
|
||||
feedbackEnabled = false,
|
||||
feedbackOptions
|
||||
feedbackOptions,
|
||||
isClient = false,
|
||||
onToggleVisibility
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
@@ -388,40 +390,61 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
|
||||
return (
|
||||
<div className={gridClass}>
|
||||
{photos.map((photo, index) => (
|
||||
<GridPhoto
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => onPhotoClick(index)}
|
||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
animationType={animation}
|
||||
allowDownloads={allowDownloads}
|
||||
slug={slug}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
feedbackOptions={feedbackOptions}
|
||||
savedIdentity={savedIdentity}
|
||||
onRequireIdentity={(action, photoId) => {
|
||||
setPendingAction({ type: action, photoId });
|
||||
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;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{photos.map((photo, index) => {
|
||||
const isHidden = photo.visibility === 'hidden';
|
||||
return (
|
||||
<div key={photo.id} className={`relative ${isClient && isHidden ? 'opacity-40' : ''}`}>
|
||||
<GridPhoto
|
||||
photo={photo}
|
||||
isSelected={selectedPhotos.has(photo.id)}
|
||||
isSelectionMode={isSelectionMode}
|
||||
onClick={() => onPhotoClick(index)}
|
||||
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
|
||||
onDownload={(e) => onDownload(photo, e)}
|
||||
animationType={animation}
|
||||
allowDownloads={allowDownloads}
|
||||
slug={slug}
|
||||
protectionLevel={protectionLevel}
|
||||
useEnhancedProtection={useEnhancedProtection}
|
||||
useCanvasRendering={useCanvasRendering}
|
||||
feedbackEnabled={feedbackEnabled}
|
||||
feedbackOptions={feedbackOptions}
|
||||
savedIdentity={savedIdentity}
|
||||
onRequireIdentity={(action, photoId) => {
|
||||
setPendingAction({ type: action, photoId });
|
||||
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;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{/* Client visibility toggle overlay (#172) */}
|
||||
{isClient && onToggleVisibility && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleVisibility(photo.id, photo.visibility || 'visible');
|
||||
}}
|
||||
className={`absolute top-2 left-2 z-10 p-1.5 rounded-full shadow-md transition-colors ${
|
||||
isHidden
|
||||
? 'bg-red-500/90 text-white hover:bg-red-600'
|
||||
: 'bg-white/90 text-neutral-700 hover:bg-white dark:bg-neutral-800/90 dark:text-neutral-200 dark:hover:bg-neutral-700'
|
||||
}`}
|
||||
title={isHidden ? 'Hidden from guests' : 'Visible to guests'}
|
||||
>
|
||||
{isHidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<FeedbackIdentityModal
|
||||
isOpen={showIdentityModal}
|
||||
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
|
||||
|
||||
Reference in New Issue
Block a user