Add grid/list layout toggle to admin Photos tab

The event detail Photos tab (AdminPhotoGrid) only offered a thumbnail
grid. Add a Grid/List toggle in the action bar so admins can scan
photos in a compact, metadata-oriented list.

- New utils/photoViewPrefs.ts persists the choice per admin via
  localStorage (mirrors utils/calendarPrefs.ts), defaulting to grid
- List view is a compact <table> following the established admin
  list pattern (EventsListPage), with responsive column hiding:
  Photo (thumbnail + filename + original + Video/Hidden badges),
  Category (lg+), Uploaded date (md+, via useLocalizedDate),
  Engagement views/downloads/likes (xl+), Feedback rating/comments
  (sm+), Size, and hover Actions (download, delete)
- Rows reuse the existing selection, download, delete and category
  handlers; row click opens the photo viewer
- Toggle buttons use LayoutGrid / List icons with aria-pressed state
- Add en.json + de.json keys under admin.photos (viewMode, gridView,
  listView, columns.*)
- Tests for the persistence util and the toggle's render + persistence
This commit is contained in:
André Deuerling
2026-06-30 15:38:21 +02:00
parent 627c655a4d
commit 46ce59d82e
6 changed files with 495 additions and 6 deletions
@@ -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<AdminPhotoGridProps> = ({
categories = []
}) => {
const { t } = useTranslation();
const { format: formatDate } = useLocalizedDate();
const queryClient = useQueryClient();
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
@@ -41,6 +44,12 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
const [deletingPhotos, setDeletingPhotos] = useState<Set<number>>(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<PhotoViewMode>(() => getPhotoViewMode());
useEffect(() => {
setPhotoViewMode(viewMode);
}, [viewMode]);
const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => {
if (e) {
@@ -246,12 +255,44 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
)}
</div>
<div className="text-sm text-neutral-600 dark:text-neutral-400">
{t('gallery.photosCount', { count: photos.length })}
<div className="flex items-center gap-3">
<div className="text-sm text-neutral-600 dark:text-neutral-400">
{t('gallery.photosCount', { count: photos.length })}
</div>
{/* Layout toggle: Grid / List */}
<div className="inline-flex rounded-lg border border-neutral-300 dark:border-neutral-600 overflow-hidden" role="group" aria-label={t('admin.photos.viewMode', 'View mode')}>
<button
type="button"
onClick={() => setViewMode('grid')}
aria-pressed={viewMode === 'grid'}
title={t('admin.photos.gridView', 'Grid view')}
className={`p-1.5 transition-colors ${
viewMode === 'grid'
? 'bg-primary-500 text-white'
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700'
}`}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => setViewMode('list')}
aria-pressed={viewMode === 'list'}
title={t('admin.photos.listView', 'List view')}
className={`p-1.5 transition-colors border-l border-neutral-300 dark:border-neutral-600 ${
viewMode === 'list'
? 'bg-primary-500 text-white'
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700'
}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Photo Grid */}
{viewMode === 'grid' && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{photos.map((photo, index) => {
const isDeleting = deletingPhotos.has(photo.id);
@@ -429,6 +470,220 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
);
})}
</div>
)}
{/* Photo List */}
{viewMode === 'list' && (
<div className="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-700">
<table className="w-full">
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<tr>
<th className="w-8 px-3 py-2" />
<th className="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('admin.photos.columns.photo', 'Photo')}
</th>
<th className="hidden lg:table-cell px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('admin.photos.columns.category', 'Category')}
</th>
<th className="hidden md:table-cell px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('admin.photos.columns.uploaded', 'Uploaded')}
</th>
<th className="hidden xl:table-cell px-3 py-2 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('admin.photos.columns.engagement', 'Engagement')}
</th>
<th className="hidden sm:table-cell px-3 py-2 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('admin.photos.columns.feedback', 'Feedback')}
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('admin.photos.columns.size', 'Size')}
</th>
<th className="w-px px-3 py-2 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('admin.photos.columns.actions', 'Actions')}
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
{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 (
<tr
key={photo.id}
data-testid={`admin-photo-row-${photo.id}`}
className={`group cursor-pointer transition-colors ${
isSelected ? 'bg-primary-50 dark:bg-primary-900/20' : 'hover:bg-neutral-50 dark:hover:bg-neutral-700/50'
} ${isRowDeleting ? 'opacity-50' : ''}`}
onClick={() => !isRowDeleting && onPhotoClick(photo, index)}
>
{/* Selection checkbox */}
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<button
type="button"
aria-label={`Select ${photo.filename}`}
role="checkbox"
aria-checked={isSelected}
data-testid={`admin-photo-row-checkbox-${photo.id}`}
onClick={(e) => handlePhotoSelect(photo.id, e)}
>
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
isSelected
? 'bg-accent-dark border-accent-dark'
: 'border-neutral-300 dark:border-neutral-500 group-hover:border-neutral-400'
}`}>
{isSelected && <Check className="w-3.5 h-3.5 text-white" />}
</div>
</button>
</td>
{/* Thumbnail + filename + badges */}
<td className="px-3 py-2">
<div className="flex items-center gap-3 min-w-0">
<div className="flex-shrink-0 w-10 h-10 rounded overflow-hidden bg-neutral-100 dark:bg-neutral-700">
{status === 'pending' || status === 'processing' ? (
<div className="w-full h-full flex items-center justify-center text-amber-600 dark:text-amber-300">
<Cog className="w-4 h-4 animate-spin" />
</div>
) : status === 'failed' ? (
<div className="w-full h-full flex items-center justify-center text-red-600 dark:text-red-300">
<AlertTriangle className="w-4 h-4" />
</div>
) : photo.thumbnail_url ? (
<AdminAuthenticatedImage
src={photo.thumbnail_url}
alt={photo.filename}
className="w-full h-full object-cover"
loading="lazy"
fallback={
<div className="w-full h-full flex items-center justify-center text-neutral-400">
<Eye className="w-4 h-4" />
</div>
}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-neutral-400">
<Eye className="w-4 h-4" />
</div>
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">
{photo.filename}
</p>
{isVideo && (
<span className="flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-600 text-neutral-700 dark:text-neutral-200 text-[10px] font-medium">
<Video className="w-3 h-3" />
{t('common.video', 'Video')}
</span>
)}
{isHidden && (
<span className="flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 text-[10px] font-medium">
<EyeOff className="w-3 h-3" />
{t('admin.photos.hidden', 'Hidden')}
</span>
)}
</div>
{photo.original_filename && photo.original_filename !== photo.filename && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 truncate">
{photo.original_filename}
</p>
)}
</div>
</div>
</td>
{/* Category */}
<td className="hidden lg:table-cell px-3 py-2 max-w-[12rem] truncate text-sm text-neutral-600 dark:text-neutral-400">
{photo.category_name || '—'}
</td>
{/* Uploaded date */}
<td className="hidden md:table-cell px-3 py-2 whitespace-nowrap text-sm text-neutral-600 dark:text-neutral-400">
{photo.uploaded_at ? formatDate(photo.uploaded_at) : '—'}
</td>
{/* Engagement: views / downloads / likes */}
<td className="hidden xl:table-cell px-3 py-2 text-right text-xs text-neutral-500 dark:text-neutral-400 tabular-nums">
<div className="flex items-center justify-end gap-3">
<span className="inline-flex items-center gap-1" title={t('admin.photos.columns.views', 'Views')}>
<Eye className="w-3.5 h-3.5" />
{viewCount}
</span>
<span className="inline-flex items-center gap-1" title={t('admin.photos.columns.downloads', 'Downloads')}>
<Download className="w-3.5 h-3.5" />
{downloadCount}
</span>
<span className="inline-flex items-center gap-1" title={t('admin.photos.columns.likes', 'Likes')}>
<Heart className="w-3.5 h-3.5" />
{likeCount}
</span>
</div>
</td>
{/* Feedback: rating + comments */}
<td className="hidden sm:table-cell px-3 py-2 text-right text-xs text-neutral-600 dark:text-neutral-400">
{averageRating > 0 || commentCount > 0 ? (
<div className="flex items-center justify-end gap-2">
{averageRating > 0 && (
<span className="inline-flex items-center gap-0.5" title={`Rating: ${Number(averageRating).toFixed(1)}`}>
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
{Number(averageRating).toFixed(1)}
</span>
)}
{commentCount > 0 && (
<span className="inline-flex items-center gap-0.5" title={`${commentCount} comments`}>
<MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
{commentCount}
</span>
)}
</div>
) : '—'}
</td>
{/* Size */}
<td className="px-3 py-2 text-right text-sm text-neutral-500 dark:text-neutral-400 whitespace-nowrap tabular-nums">
{photosService.formatBytes(photo.size)}
</td>
{/* Actions */}
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
{!isSelectionMode && (
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<button
onClick={(e) => handleDownload(photo, e)}
className="p-1.5 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-600 rounded"
title={t('common.download', 'Download')}
>
<Download className="w-4 h-4" />
</button>
<button
onClick={(e) => handleDeleteSingle(photo, e)}
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 rounded disabled:opacity-50"
disabled={isRowDeleting}
title={t('common.delete', 'Delete')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{photos.length === 0 && (
<div className="text-center py-12">
@@ -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<typeof import('react-i18next')>('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 }) => <img alt={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(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
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(
<AdminPhotoGrid
photos={photos}
eventId={42}
onPhotoClick={vi.fn()}
onPhotosDeleted={vi.fn()}
/>
);
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();
});
});
+16 -1
View File
@@ -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": {
+16 -1
View File
@@ -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": {
@@ -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');
});
});
+52
View File
@@ -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<PhotoViewMode> = ['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
}
}