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:
Paul Nothaft
2026-03-17 13:04:41 +01:00
parent 999c66dbbf
commit e1b6e43e52
25 changed files with 2217 additions and 1112 deletions
+6
View File
@@ -8,6 +8,7 @@ import { analyticsService } from './services/analytics.service';
import { GalleryAuthProvider, MaintenanceProvider } from './contexts';
import { ThemeProvider } from './contexts/ThemeContext';
import { GalleryPage } from './pages/GalleryPage';
import { ClientAccessPage } from './pages/ClientAccessPage';
import { PreviewPage } from './pages/gallery/PreviewPage';
import { LegalPage } from './pages/public/LegalPage';
import {
@@ -121,6 +122,11 @@ function App() {
<Routes>
{/* Public gallery routes */}
<Route path="/gallery/preview" element={<PreviewPage />} />
<Route path="/gallery/:slug/client-access" element={
<GalleryAuthProvider>
<ClientAccessPage />
</GalleryAuthProvider>
} />
<Route path="/gallery/:slug/:token?" element={
<GalleryAuthProvider>
<GalleryPage />
@@ -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 ? (
+132 -3
View File
@@ -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); }}
@@ -11,6 +11,7 @@ import {
setActiveGallerySlug,
storeGalleryToken,
} from '../utils/galleryAuthStorage';
import type { GalleryAccessLevel } from '../types';
interface GalleryEvent {
id: number;
@@ -37,7 +38,10 @@ const normalizeEvent = (incoming: GalleryEvent | null | undefined): GalleryEvent
interface GalleryAuthContextType {
isAuthenticated: boolean;
event: GalleryEvent | null;
accessLevel: GalleryAccessLevel;
isClient: boolean;
login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
clientLogin: (slug: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
error: string | null;
@@ -60,6 +64,7 @@ interface GalleryAuthProviderProps {
export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [event, setEvent] = useState<GalleryEvent | null>(null);
const [accessLevel, setAccessLevel] = useState<GalleryAccessLevel>('guest');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [routeError, setRouteError] = useState<string | null>(null);
@@ -188,6 +193,14 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
}
}
// Restore access level from session storage
const storedAccessLevel = sessionStorage.getItem(`gallery_access_level_${currentSlug}`);
if (storedAccessLevel === 'client') {
setAccessLevel('client');
} else {
setAccessLevel('guest');
}
const initialise = async () => {
try {
setIsLoading(true);
@@ -285,15 +298,43 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
}
};
const clientLoginFn = async (slug: string, password: string) => {
try {
setRouteError(null);
setError(null);
setIsLoading(true);
const response = await authService.clientLogin(slug, password);
if (response.token) {
storeGalleryToken(slug, response.token);
}
setActiveGallerySlug(slug);
const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
}
setAccessLevel(response.accessLevel || 'client');
sessionStorage.setItem(`gallery_access_level_${slug}`, 'client');
setIsAuthenticated(true);
} catch (err: any) {
setError(err.response?.data?.error || 'Invalid PIN');
throw err;
} finally {
setIsLoading(false);
}
};
const logout = () => {
const currentSlug = routeInfo.slug;
if (currentSlug) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
sessionStorage.removeItem(`gallery_access_level_${currentSlug}`);
clearGalleryToken(currentSlug);
}
authService.galleryLogout(currentSlug || undefined);
setIsAuthenticated(false);
setEvent(null);
setAccessLevel('guest');
clearActiveGallerySlug();
};
@@ -302,7 +343,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
value={{
isAuthenticated,
event,
accessLevel,
isClient: accessLevel === 'client',
login,
clientLogin: clientLoginFn,
logout,
isLoading,
error: routeError ?? error,
+32
View File
@@ -1758,6 +1758,13 @@
"username": "Benutzernamen wählen",
"password": "Passwort erstellen",
"submit": "Konto erstellen"
},
"photos": {
"hidden": "Versteckt",
"hideSelected": "Ausblenden",
"showSelected": "Einblenden",
"hiddenSuccess": "Fotos für Gäste ausgeblendet",
"visibleSuccess": "Fotos jetzt für Gäste sichtbar"
}
},
"permissions": {
@@ -2576,6 +2583,31 @@
"exportFiltered": "Gefilterte Fotos exportieren",
"hint": "Fotos auswählen oder Filter anwenden zum Exportieren"
},
"clientAccess": {
"title": "Kundenzugang",
"description": "Geben Sie Ihre PIN ein, um Fotos vor der Veröffentlichung zu überprüfen und auszuwählen.",
"pinLabel": "Kunden-PIN",
"pinPlaceholder": "PIN eingeben",
"enterPin": "Geben Sie die PIN ein, die Sie erhalten haben",
"invalidPin": "Ungültige PIN. Bitte versuchen Sie es erneut.",
"loginFailed": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"loginButton": "Zugang erhalten",
"guestHint": "Nur Fotos ansehen?",
"guestLink": "Zur Gästegalerie",
"banner": "Kundenmodus",
"visibleCount": "{{visible}} von {{total}} Fotos für Gäste sichtbar",
"hideSelected": "Ausgewählte ausblenden",
"showSelected": "Ausgewählte einblenden",
"adminTitle": "Kundenzugang",
"enableToggle": "Kundenzugang aktivieren",
"enableDescription": "Ermöglichen Sie Kunden, Fotos zu überprüfen und auszublenden, bevor die Galerie mit den Gästen geteilt wird.",
"pinHelperText": "Der Kunde verwendet diese PIN, um die Überprüfungsseite aufzurufen.",
"pinUpdated": "Kunden-PIN aktualisiert",
"setPin": "PIN festlegen",
"linkLabel": "Kundenzugangs-Link",
"regenerateToken": "Link neu generieren",
"tokenRegenerated": "Kundenzugangs-Link neu generiert"
},
"adminLogin": {
"title": "Admin-Anmeldung",
"subtitle": "Melden Sie sich an, um Ihre Fotogalerien zu verwalten",
+32
View File
@@ -1471,6 +1471,13 @@
"username": "Choose a Username",
"password": "Create Password",
"submit": "Create Account"
},
"photos": {
"hidden": "Hidden",
"hideSelected": "Hide",
"showSelected": "Show",
"hiddenSuccess": "Photos hidden from guests",
"visibleSuccess": "Photos now visible to guests"
}
},
"permissions": {
@@ -2337,5 +2344,30 @@
"needHelp": "Need help? Contact",
"poweredBy": "Powered by PicPeak",
"devModeHint": "Development Mode: Use email: [email protected], password: admin123"
},
"clientAccess": {
"title": "Client Access",
"description": "Review photos and manage visibility before the gallery is shared with guests.",
"pinLabel": "Client PIN",
"pinPlaceholder": "Enter your client PIN",
"enterPin": "Please enter your client PIN",
"invalidPin": "Invalid PIN. Please try again.",
"loginFailed": "Authentication failed. Please try again.",
"loginButton": "Access Gallery",
"guestHint": "Looking for the guest gallery?",
"guestLink": "Go to guest view",
"banner": "Client Mode",
"visibleCount": "{{visible}} of {{total}} photos visible to guests",
"hideSelected": "Hide Selected",
"showSelected": "Show Selected",
"adminTitle": "Client Access",
"enableToggle": "Enable Client Access",
"enableDescription": "Allow clients to review and hide photos before the gallery is shared with guests.",
"pinHelperText": "The client will use this PIN to access the review page.",
"pinUpdated": "Client PIN updated",
"setPin": "Set PIN",
"linkLabel": "Client Access Link",
"regenerateToken": "Regenerate link",
"tokenRegenerated": "Client access link regenerated"
}
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -1449,6 +1449,13 @@
"username": "Выберите имя пользователя",
"password": "Создайте пароль",
"submit": "Создать аккаунт"
},
"photos": {
"hidden": "Скрыто",
"hideSelected": "Скрыть",
"showSelected": "Показать",
"hiddenSuccess": "Фотографии скрыты от гостей",
"visibleSuccess": "Фотографии теперь видны гостям"
}
},
"permissions": {
@@ -2312,5 +2319,30 @@
"needHelp": "Нужна помощь? Свяжитесь",
"poweredBy": "Работает на PicPeak",
"devModeHint": "Режим разработки: используйте email: [email protected], пароль: admin123"
},
"clientAccess": {
"title": "Доступ клиента",
"description": "Введите PIN-код для просмотра и выбора фотографий перед публикацией.",
"pinLabel": "PIN-код клиента",
"pinPlaceholder": "Введите PIN-код",
"enterPin": "Введите PIN-код, который вы получили",
"invalidPin": "Неверный PIN-код. Попробуйте ещё раз.",
"loginFailed": "Не удалось войти. Попробуйте ещё раз.",
"loginButton": "Получить доступ",
"guestHint": "Просто хотите посмотреть фотографии?",
"guestLink": "Перейти в гостевую галерею",
"banner": "Режим клиента",
"visibleCount": "{{visible}} из {{total}} фотографий видны гостям",
"hideSelected": "Скрыть выбранные",
"showSelected": "Показать выбранные",
"adminTitle": "Доступ клиента",
"enableToggle": "Включить доступ клиента",
"enableDescription": "Позволить клиентам просматривать и скрывать фотографии до того, как галерея будет открыта для гостей.",
"pinHelperText": "Клиент будет использовать этот PIN-код для доступа к странице проверки.",
"pinUpdated": "PIN-код клиента обновлён",
"setPin": "Установить PIN",
"linkLabel": "Ссылка для клиента",
"regenerateToken": "Сгенерировать новую ссылку",
"tokenRegenerated": "Ссылка для клиента обновлена"
}
}
+207
View File
@@ -0,0 +1,207 @@
import React, { useState } from 'react';
import { useParams, useSearchParams, useNavigate, Link } from 'react-router-dom';
import { AlertCircle, Lock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, Input, Button, Loading } from '../components/common';
import { useGalleryAuth } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { api } from '../config/api';
import { buildResourceUrl } from '../utils/url';
export const ClientAccessPage: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { isAuthenticated, isClient, clientLogin, isLoading: authLoading } = useGalleryAuth();
const { t } = useTranslation();
const [pin, setPin] = useState('');
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug);
const { data: settingsData } = useQuery({
queryKey: ['gallery-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000,
});
// If already authenticated as client, redirect to gallery
React.useEffect(() => {
if (isAuthenticated && isClient && slug) {
navigate(`/gallery/${slug}`, { replace: true });
}
}, [isAuthenticated, isClient, slug, navigate]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!pin.trim()) {
setLoginError(t('clientAccess.enterPin'));
return;
}
if (!slug) {
setLoginError(t('errors.galleryNotFound'));
return;
}
try {
setIsLoggingIn(true);
setLoginError(null);
await clientLogin(slug, pin);
navigate(`/gallery/${slug}`, { replace: true });
} catch (error: any) {
const statusCode = error.response?.status;
if (statusCode === 401) {
setLoginError(t('clientAccess.invalidPin'));
} else if (statusCode === 423) {
setLoginError(t('auth.tooManyAttempts'));
} else {
setLoginError(t('clientAccess.loginFailed'));
}
} finally {
setIsLoggingIn(false);
}
};
if (isLoadingInfo || authLoading) {
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex items-center justify-center">
<Loading size="lg" text={t('gallery.loading')} />
</div>
</div>
);
}
if (infoError || !galleryInfo) {
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={buildResourceUrl(settingsData.branding_logo_url)}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">{t('errors.galleryNotFound')}</h2>
<p className="text-neutral-600">{t('errors.galleryNotFoundMessage')}</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{/* Logo */}
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={buildResourceUrl(settingsData.branding_logo_url)}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
)}
<div className="flex-1 flex items-center justify-center px-4">
<Card className="max-w-md w-full">
<CardContent className="p-8">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
<Lock className="w-8 h-8 text-amber-600 dark:text-amber-400" />
</div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{t('clientAccess.title')}
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-2">
{galleryInfo.event_name}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-500 mt-1">
{t('clientAccess.description')}
</p>
</div>
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="password"
label={t('clientAccess.pinLabel')}
placeholder={t('clientAccess.pinPlaceholder')}
value={pin}
onChange={(e) => {
setPin(e.target.value);
setLoginError(null);
}}
error={loginError || undefined}
leftIcon={<Lock className="w-5 h-5" />}
autoFocus
/>
<Button
type="submit"
variant="primary"
className="w-full"
isLoading={isLoggingIn}
disabled={isLoggingIn}
>
{t('clientAccess.loginButton')}
</Button>
</form>
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700 text-center">
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('clientAccess.guestHint')}{' '}
<Link
to={`/gallery/${slug}`}
className="text-primary-600 dark:text-primary-400 hover:underline"
>
{t('clientAccess.guestLink')}
</Link>
</p>
</div>
</CardContent>
</Card>
</div>
{/* Footer */}
<div className="p-8 text-center">
<div className="flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
);
};
+49 -2
View File
@@ -9,7 +9,8 @@ import {
Palette,
Eye,
EyeOff,
Image
Image,
Key
} from 'lucide-react';
import { addDays } from 'date-fns';
import { toast } from 'react-toastify';
@@ -59,6 +60,9 @@ interface FormData {
rate_limit_window_minutes?: number;
rate_limit_max_requests?: number;
};
// Client access (#172)
client_access_enabled: boolean;
client_password: string;
}
// Fallback event types (used when API is unavailable)
@@ -114,8 +118,10 @@ export const CreateEventPage: React.FC = () => {
rate_limit_window_minutes: 15,
rate_limit_max_requests: 10,
},
client_access_enabled: false,
client_password: '',
});
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
const [showPassword, setShowPassword] = useState(false);
@@ -310,6 +316,9 @@ export const CreateEventPage: React.FC = () => {
require_name_email: feedbackSettings.require_name_email,
moderate_comments: feedbackSettings.moderate_comments,
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
// Client access (#172)
client_access_enabled: formData.client_access_enabled,
client_password: formData.client_access_enabled ? formData.client_password : undefined,
};
createMutation.mutate(payload);
@@ -742,6 +751,44 @@ export const CreateEventPage: React.FC = () => {
</div>
</div>
{/* Client Access (#172) */}
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700">
<label className="flex items-start gap-2">
<input
type="checkbox"
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
checked={formData.client_access_enabled}
onChange={(e) => setFormData(prev => ({
...prev,
client_access_enabled: e.target.checked,
client_password: e.target.checked ? prev.client_password : '',
}))}
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('clientAccess.enableToggle')}
</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('clientAccess.enableDescription')}
</p>
</div>
</label>
{formData.client_access_enabled && (
<div className="mt-3">
<Input
type="text"
label={t('clientAccess.pinLabel')}
placeholder={t('clientAccess.pinPlaceholder')}
value={formData.client_password}
onChange={handleInputChange('client_password')}
leftIcon={<Key className="w-5 h-5" />}
helperText={t('clientAccess.pinHelperText')}
/>
</div>
)}
</div>
{/* User Upload Settings */}
<div className="pt-4 border-t border-neutral-200 dark:border-neutral-700">
<label className="flex items-center gap-3">
+131 -1
View File
@@ -221,6 +221,8 @@ export const EventDetailsPage: React.FC = () => {
rate_limit_max_requests: 10,
});
const [copiedLink, setCopiedLink] = useState(false);
const [copiedClientLink, setCopiedClientLink] = useState(false);
const [clientPin, setClientPin] = useState('');
const [showPhotoUpload, setShowPhotoUpload] = useState(false);
const [showExternalImport, setShowExternalImport] = useState(false);
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
@@ -263,7 +265,7 @@ export const EventDetailsPage: React.FC = () => {
const [selectedPhotoIds, setSelectedPhotoIds] = useState<number[]>([]);
// Fetch event details
const { data: event, isLoading: eventLoading } = useQuery({
const { data: event, isLoading: eventLoading, refetch: refetchEvent } = useQuery({
queryKey: ['admin-event', id],
queryFn: () => eventsService.getEvent(parseInt(id!)),
enabled: !!id,
@@ -1533,7 +1535,135 @@ export const EventDetailsPage: React.FC = () => {
)}
</Card>
{/* Client Access (#172) */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
<Shield className="w-5 h-5" />
{t('clientAccess.adminTitle')}
</h2>
<div className="space-y-4">
<label className="flex items-start gap-2">
<input
type="checkbox"
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
checked={!!event?.client_access_enabled}
onChange={async (e) => {
try {
await eventsService.updateEvent(event.id, { client_access_enabled: e.target.checked });
refetchEvent();
} catch {
toast.error(t('common.error'));
}
}}
disabled={event?.is_archived}
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('clientAccess.enableToggle')}
</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('clientAccess.enableDescription')}
</p>
</div>
</label>
{event?.client_access_enabled && (
<>
{/* Set/Change PIN */}
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('clientAccess.pinLabel')}
</label>
<input
type="text"
value={clientPin}
onChange={(e) => setClientPin(e.target.value)}
placeholder={t('clientAccess.pinPlaceholder')}
className="w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm"
/>
</div>
<Button
variant="outline"
size="md"
leftIcon={<Key className="w-4 h-4" />}
onClick={async () => {
if (!clientPin.trim()) return;
try {
await eventsService.updateEvent(event.id, { client_password: clientPin });
setClientPin('');
toast.success(t('clientAccess.pinUpdated'));
refetchEvent();
} catch {
toast.error(t('common.error'));
}
}}
disabled={!clientPin.trim()}
>
{t('clientAccess.setPin')}
</Button>
</div>
{/* Client access link */}
{event?.client_share_token && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('clientAccess.linkLabel')}
</label>
<div className="flex items-center gap-2">
<input
type="text"
value={`${window.location.origin}/gallery/${event.slug}/client-access?token=${event.client_share_token}`}
readOnly
className="flex-1 px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm"
/>
<Button
variant="outline"
size="md"
leftIcon={copiedClientLink ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
onClick={async () => {
const link = `${window.location.origin}/gallery/${event.slug}/client-access?token=${event.client_share_token}`;
try {
await navigator.clipboard.writeText(link);
} catch {
const textArea = document.createElement('textarea');
textArea.value = link;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
setCopiedClientLink(true);
setTimeout(() => setCopiedClientLink(false), 2000);
}}
>
{copiedClientLink ? t('events.copied') : t('events.copy')}
</Button>
</div>
<Button
variant="ghost"
size="sm"
className="mt-2 text-xs"
onClick={async () => {
try {
await eventsService.updateEvent(event.id, { regenerate_client_token: true });
toast.success(t('clientAccess.tokenRegenerated'));
refetchEvent();
} catch {
toast.error(t('common.error'));
}
}}
>
{t('clientAccess.regenerateToken')}
</Button>
</div>
)}
</>
)}
</div>
</Card>
{/* Actions */}
{!event.is_archived && (
+7
View File
@@ -46,6 +46,13 @@ export const authService = {
return normalizeGalleryResponse(response.data);
},
async clientLogin(slug: string, password: string): Promise<GalleryAuthResponse> {
const response = await api.post<GalleryAuthResponse>(`/auth/gallery/${slug}/client-login`, {
password
});
return normalizeGalleryResponse(response.data);
},
async shareLinkLogin(slug: string, token: string): Promise<GalleryAuthResponse> {
const response = await api.post<GalleryAuthResponse>('/auth/gallery/share-login', {
slug,
+10
View File
@@ -114,6 +114,16 @@ export const galleryService = {
window.URL.revokeObjectURL(url);
},
// Toggle photo visibility (client-only)
async togglePhotoVisibility(slug: string, photoId: number, visibility: 'visible' | 'hidden'): Promise<void> {
await api.patch(`/gallery/${slug}/photos/${photoId}/visibility`, { visibility });
},
// Bulk toggle photo visibility (client-only)
async bulkToggleVisibility(slug: string, photoIds: number[], visibility: 'visible' | 'hidden'): Promise<void> {
await api.patch(`/gallery/${slug}/photos/visibility/bulk`, { photoIds, visibility });
},
// Get gallery statistics
async getGalleryStats(slug: string): Promise<GalleryStats> {
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
+9 -2
View File
@@ -73,12 +73,19 @@ class PhotosService {
}
async updatePhotosCategory(eventId: number, photoIds: number[], categoryId: number | null): Promise<void> {
await api.post(`/admin/events/${eventId}/photos/bulk-update`, {
photoIds,
await api.post(`/admin/events/${eventId}/photos/bulk-update`, {
photoIds,
updates: { category_id: categoryId }
});
}
async bulkUpdatePhotos(eventId: number, photoIds: number[], updates: Record<string, unknown>): Promise<void> {
await api.post(`/admin/events/${eventId}/photos/bulk-update`, {
photoIds,
updates
});
}
async downloadPhoto(eventId: number, photoId: number, filename: string): Promise<void> {
const response = await api.get(`/admin/events/${eventId}/photos/${photoId}/download`, {
responseType: 'blob'
+8
View File
@@ -55,8 +55,13 @@ export interface Event {
css_template_id?: number | null;
// Photo cap
photo_cap?: number | null;
// Client access (#172)
client_access_enabled?: boolean;
client_share_token?: string;
}
export type GalleryAccessLevel = 'guest' | 'client';
export interface GalleryInfo {
event_name: string;
event_type: string;
@@ -92,6 +97,8 @@ export interface Photo {
audio_codec?: string;
width?: number;
height?: number;
// Visibility (#172)
visibility?: 'visible' | 'hidden';
// Feedback fields
has_feedback?: boolean;
average_rating?: number;
@@ -206,6 +213,7 @@ export interface GalleryAuthResponse {
require_password?: boolean;
photo_cap?: number | null;
};
accessLevel?: GalleryAccessLevel;
}
// API Error type