diff --git a/frontend/src/components/admin/AdminPhotoGrid.tsx b/frontend/src/components/admin/AdminPhotoGrid.tsx index 5f07d87e..64485699 100644 --- a/frontend/src/components/admin/AdminPhotoGrid.tsx +++ b/frontend/src/components/admin/AdminPhotoGrid.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react'; -import { Check, Download, Trash2, Eye, EyeOff, Package, MessageSquare, Star, Video, FolderOpen, Cog, AlertTriangle, RefreshCw } from 'lucide-react'; +import React, { useEffect, useState } from 'react'; +import { Check, Download, Trash2, Eye, EyeOff, Heart, Package, MessageSquare, Star, Video, FolderOpen, Cog, AlertTriangle, RefreshCw, LayoutGrid, List } from 'lucide-react'; import { toast } from 'react-toastify'; import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -7,6 +7,8 @@ import { useTranslation } from 'react-i18next'; import { AdminPhoto } from '../../services/photos.service'; import { photosService } from '../../services/photos.service'; import { uploadsService } from '../../services/uploads.service'; +import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { getPhotoViewMode, setPhotoViewMode, type PhotoViewMode } from '../../utils/photoViewPrefs'; import { Button } from '../common'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; import { BulkCategoryModal } from './BulkCategoryModal'; @@ -34,6 +36,7 @@ export const AdminPhotoGrid: React.FC = ({ categories = [] }) => { const { t } = useTranslation(); + const { format: formatDate } = useLocalizedDate(); const queryClient = useQueryClient(); const [selectedPhotos, setSelectedPhotos] = useState>(new Set()); const [isSelectionMode, setIsSelectionMode] = useState(false); @@ -41,6 +44,12 @@ export const AdminPhotoGrid: React.FC = ({ const [deletingPhotos, setDeletingPhotos] = useState>(new Set()); const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false); const [isUpdatingCategory, setIsUpdatingCategory] = useState(false); + // Layout toggle (Grid / List) persisted per admin via localStorage. + const [viewMode, setViewMode] = useState(() => getPhotoViewMode()); + + useEffect(() => { + setPhotoViewMode(viewMode); + }, [viewMode]); const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => { if (e) { @@ -246,12 +255,44 @@ export const AdminPhotoGrid: React.FC = ({ )} -
- {t('gallery.photosCount', { count: photos.length })} +
+
+ {t('gallery.photosCount', { count: photos.length })} +
+ {/* Layout toggle: Grid / List */} +
+ + +
{/* Photo Grid */} + {viewMode === 'grid' && (
{photos.map((photo, index) => { const isDeleting = deletingPhotos.has(photo.id); @@ -429,6 +470,220 @@ export const AdminPhotoGrid: React.FC = ({ ); })}
+ )} + + {/* Photo List */} + {viewMode === 'list' && ( +
+ + + + + + + + + + + + + + {photos.map((photo, index) => { + const isRowDeleting = deletingPhotos.has(photo.id); + const commentCount = photo.comment_count ?? 0; + const averageRating = photo.average_rating ?? 0; + const viewCount = photo.view_count ?? 0; + const downloadCount = photo.download_count ?? 0; + const likeCount = photo.like_count ?? 0; + const isSelected = selectedPhotos.has(photo.id); + const isVideo = (photo.media_type === 'video') || + (photo.mime_type && photo.mime_type.startsWith('video/')) || + photo.type === 'video'; + const isHidden = (photo as any).visibility === 'hidden'; + const status = (photo as any).processing_status; + return ( + !isRowDeleting && onPhotoClick(photo, index)} + > + {/* Selection checkbox */} + + + {/* Thumbnail + filename + badges */} + + + {/* Category */} + + + {/* Uploaded date */} + + + {/* Engagement: views / downloads / likes */} + + + {/* Feedback: rating + comments */} + + + {/* Size */} + + + {/* Actions */} + + + ); + })} + +
+ + {t('admin.photos.columns.photo', 'Photo')} + + {t('admin.photos.columns.category', 'Category')} + + {t('admin.photos.columns.uploaded', 'Uploaded')} + + {t('admin.photos.columns.engagement', 'Engagement')} + + {t('admin.photos.columns.feedback', 'Feedback')} + + {t('admin.photos.columns.size', 'Size')} + + {t('admin.photos.columns.actions', 'Actions')} +
e.stopPropagation()}> + + +
+
+ {status === 'pending' || status === 'processing' ? ( +
+ +
+ ) : status === 'failed' ? ( +
+ +
+ ) : photo.thumbnail_url ? ( + + +
+ } + /> + ) : ( +
+ +
+ )} +
+
+
+

+ {photo.filename} +

+ {isVideo && ( + + + )} + {isHidden && ( + + + {t('admin.photos.hidden', 'Hidden')} + + )} +
+ {photo.original_filename && photo.original_filename !== photo.filename && ( +

+ {photo.original_filename} +

+ )} +
+ +
+ {photo.category_name || '—'} + + {photo.uploaded_at ? formatDate(photo.uploaded_at) : '—'} + +
+ + + {viewCount} + + + + {downloadCount} + + + + {likeCount} + +
+
+ {averageRating > 0 || commentCount > 0 ? ( +
+ {averageRating > 0 && ( + + + {Number(averageRating).toFixed(1)} + + )} + {commentCount > 0 && ( + + + {commentCount} + + )} +
+ ) : '—'} +
+ {photosService.formatBytes(photo.size)} + e.stopPropagation()}> + {!isSelectionMode && ( +
+ + +
+ )} +
+
+ )} {photos.length === 0 && (
diff --git a/frontend/src/components/admin/__tests__/AdminPhotoGrid.viewToggle.test.tsx b/frontend/src/components/admin/__tests__/AdminPhotoGrid.viewToggle.test.tsx new file mode 100644 index 00000000..91e1360c --- /dev/null +++ b/frontend/src/components/admin/__tests__/AdminPhotoGrid.viewToggle.test.tsx @@ -0,0 +1,109 @@ +/** + * Coverage for the Grid / List layout toggle on the admin event + * Photos tab. The toggle swaps the rendered layout (grid tiles vs. + * list rows) and persists the choice to localStorage via + * utils/photoViewPrefs, so it survives a remount. These tests pin both + * behaviours so a refactor can't silently drop the list view or its + * persistence. + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactElement } from 'react'; + +import { AdminPhotoGrid } from '../AdminPhotoGrid'; +import type { AdminPhoto } from '../../../services/photos.service'; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (_key: string, fallback?: any) => + typeof fallback === 'string' ? fallback : _key, + i18n: { language: 'en' } + }) + }; +}); + +// AdminAuthenticatedImage fetches an authenticated blob; stub it to a +// plain img so the grid renders without a network layer. +vi.mock('../AdminAuthenticatedImage', () => ({ + AdminAuthenticatedImage: ({ alt }: { alt: string }) => {alt} +})); + +vi.mock('../../../services/photos.service', () => ({ + photosService: { + formatBytes: (n: number) => `${n} B` + } +})); + +const renderWithQueryClient = (ui: ReactElement) => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + return render({ui}); +}; + +const photos: AdminPhoto[] = [ + { + id: 1, filename: 'a.jpg', path: '/a.jpg', url: '/a.jpg', thumbnail_url: '/t/a.jpg', + type: 'photo', category_id: null, category_name: null, category_slug: null, + size: 1234, uploaded_at: '2026-01-01T00:00:00Z' + }, + { + id: 2, filename: 'b.jpg', path: '/b.jpg', url: '/b.jpg', thumbnail_url: '/t/b.jpg', + type: 'photo', category_id: null, category_name: null, category_slug: null, + size: 5678, uploaded_at: '2026-01-02T00:00:00Z' + } +]; + +const renderGrid = () => + renderWithQueryClient( + + ); + +describe('AdminPhotoGrid layout toggle', () => { + beforeEach(() => { + localStorage.clear(); + }); + afterEach(() => { + localStorage.clear(); + }); + + it('renders grid tiles by default', () => { + renderGrid(); + expect(screen.getByTestId('admin-photo-tile-1')).toBeInTheDocument(); + expect(screen.queryByTestId('admin-photo-row-1')).not.toBeInTheDocument(); + }); + + it('switches to list rows when the List toggle is clicked', async () => { + const user = userEvent.setup(); + renderGrid(); + + await user.click(screen.getByRole('button', { name: /list view/i })); + + expect(screen.getByTestId('admin-photo-row-1')).toBeInTheDocument(); + expect(screen.getByTestId('admin-photo-row-2')).toBeInTheDocument(); + expect(screen.queryByTestId('admin-photo-tile-1')).not.toBeInTheDocument(); + }); + + it('persists the chosen layout across a remount', async () => { + const user = userEvent.setup(); + const { unmount } = renderGrid(); + + await user.click(screen.getByRole('button', { name: /list view/i })); + expect(localStorage.getItem('picpeak.adminPhotos.view')).toBe('list'); + unmount(); + + renderGrid(); + expect(screen.getByTestId('admin-photo-row-1')).toBeInTheDocument(); + expect(screen.queryByTestId('admin-photo-tile-1')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index fabc4e7e..69825bc0 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -2514,7 +2514,22 @@ "visibleSuccess": "Fotos jetzt für Gäste sichtbar", "processingStatus": "Wird verarbeitet…", "processingFailed": "Fehlgeschlagen", - "retryQueued": "Wiederholung in Warteschlange" + "retryQueued": "Wiederholung in Warteschlange", + "viewMode": "Ansicht", + "gridView": "Rasteransicht", + "listView": "Listenansicht", + "columns": { + "photo": "Foto", + "category": "Kategorie", + "uploaded": "Hochgeladen", + "engagement": "Aktivität", + "feedback": "Feedback", + "size": "Größe", + "actions": "Aktionen", + "views": "Aufrufe", + "downloads": "Downloads", + "likes": "Likes" + } }, "events": { "tabs": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8daea867..37bac76e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2101,7 +2101,22 @@ "visibleSuccess": "Photos now visible to guests", "processingStatus": "Processing…", "processingFailed": "Failed", - "retryQueued": "Retry queued" + "retryQueued": "Retry queued", + "viewMode": "View mode", + "gridView": "Grid view", + "listView": "List view", + "columns": { + "photo": "Photo", + "category": "Category", + "uploaded": "Uploaded", + "engagement": "Engagement", + "feedback": "Feedback", + "size": "Size", + "actions": "Actions", + "views": "Views", + "downloads": "Downloads", + "likes": "Likes" + } }, "events": { "tabs": { diff --git a/frontend/src/utils/__tests__/photoViewPrefs.test.ts b/frontend/src/utils/__tests__/photoViewPrefs.test.ts new file mode 100644 index 00000000..d2764554 --- /dev/null +++ b/frontend/src/utils/__tests__/photoViewPrefs.test.ts @@ -0,0 +1,43 @@ +/** + * Coverage for the admin Photos-tab layout toggle persistence. + * + * The event detail Photos tab can render as a Grid or a List; the + * choice is stored per browser/admin via localStorage so it survives + * reloads. These tests pin the default ('grid'), the round-trip, and + * the defensive fallback on malformed / blocked storage so a refactor + * can't silently break the persisted preference. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getPhotoViewMode, setPhotoViewMode } from '../photoViewPrefs'; + +describe('photoViewPrefs', () => { + beforeEach(() => { + localStorage.clear(); + }); + afterEach(() => { + localStorage.clear(); + }); + + it('defaults to grid when nothing is stored', () => { + expect(getPhotoViewMode()).toBe('grid'); + }); + + it('round-trips a persisted view mode', () => { + setPhotoViewMode('list'); + expect(getPhotoViewMode()).toBe('list'); + setPhotoViewMode('grid'); + expect(getPhotoViewMode()).toBe('grid'); + }); + + it('falls back to grid for an unrecognised stored value', () => { + localStorage.setItem('picpeak.adminPhotos.view', 'mosaic'); + expect(getPhotoViewMode()).toBe('grid'); + }); + + it('ignores attempts to persist an invalid view mode', () => { + setPhotoViewMode('list'); + // @ts-expect-error — exercising the runtime guard against bad input + setPhotoViewMode('carousel'); + expect(getPhotoViewMode()).toBe('list'); + }); +}); diff --git a/frontend/src/utils/photoViewPrefs.ts b/frontend/src/utils/photoViewPrefs.ts new file mode 100644 index 00000000..60553f7d --- /dev/null +++ b/frontend/src/utils/photoViewPrefs.ts @@ -0,0 +1,52 @@ +/** + * photoViewPrefs — localStorage-backed admin photo-grid preferences. + * + * Single key today: the last-used layout for the event Photos tab + * (Grid or List). Stored per browser/admin pair via localStorage so the + * toggle persists across page reloads. No server round-trip. + * + * If more admin-tunable photo preferences land, extend this module with + * a JSON object keyed at `picpeak.adminPhotos.prefs` instead of more + * individual keys (see utils/calendarPrefs.ts for the same convention). + * + * The getter swallows malformed values (e.g. someone hand-edits the + * stored value) and falls back to the documented default — never + * throws on read. + */ + +const VIEW_KEY = 'picpeak.adminPhotos.view'; + +export type PhotoViewMode = 'grid' | 'list'; + +const ALLOWED_VIEWS: ReadonlyArray = ['grid', 'list']; + +/** + * Return the persisted view or the default ('grid'). + * Safe to call before localStorage exists (SSR / test envs). + */ +export function getPhotoViewMode(): PhotoViewMode { + if (typeof window === 'undefined' || !window.localStorage) return 'grid'; + try { + const raw = window.localStorage.getItem(VIEW_KEY); + if (raw && (ALLOWED_VIEWS as readonly string[]).includes(raw)) { + return raw as PhotoViewMode; + } + } catch (_) { + // ignore — fall through to default + } + return 'grid'; +} + +/** + * Persist the active view. Silently no-ops when localStorage is + * unavailable. + */ +export function setPhotoViewMode(view: PhotoViewMode): void { + if (typeof window === 'undefined' || !window.localStorage) return; + if (!(ALLOWED_VIEWS as readonly string[]).includes(view)) return; + try { + window.localStorage.setItem(VIEW_KEY, view); + } catch (_) { + // ignore — quota / disabled storage + } +}