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
+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'