✨ 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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user