Merge pull request #707 from andredlng/feat/photos-list-view
Add grid/list layout toggle to admin Photos tab
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Check, Download, Trash2, Eye, EyeOff, Package, MessageSquare, Star, Video, FolderOpen, Cog, AlertTriangle, RefreshCw } from 'lucide-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 { toast } from 'react-toastify';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -7,6 +7,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { AdminPhoto } from '../../services/photos.service';
|
import { AdminPhoto } from '../../services/photos.service';
|
||||||
import { photosService } from '../../services/photos.service';
|
import { photosService } from '../../services/photos.service';
|
||||||
import { uploadsService } from '../../services/uploads.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 { Button } from '../common';
|
||||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||||
import { BulkCategoryModal } from './BulkCategoryModal';
|
import { BulkCategoryModal } from './BulkCategoryModal';
|
||||||
@@ -34,6 +36,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
categories = []
|
categories = []
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { format: formatDate } = useLocalizedDate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||||
@@ -41,6 +44,16 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
const [deletingPhotos, setDeletingPhotos] = useState<Set<number>>(new Set());
|
const [deletingPhotos, setDeletingPhotos] = useState<Set<number>>(new Set());
|
||||||
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
|
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
|
||||||
const [isUpdatingCategory, setIsUpdatingCategory] = useState(false);
|
const [isUpdatingCategory, setIsUpdatingCategory] = useState(false);
|
||||||
|
// Layout toggle (Grid / List) persisted per admin via localStorage.
|
||||||
|
const [viewMode, setViewMode] = useState<PhotoViewMode>(() => getPhotoViewMode());
|
||||||
|
|
||||||
|
// Persist on user action only — writing in an effect would re-save the
|
||||||
|
// value on every mount (i.e. each time the Photos tab is opened), even
|
||||||
|
// when the user never touched the toggle.
|
||||||
|
const selectView = (mode: PhotoViewMode) => {
|
||||||
|
setViewMode(mode);
|
||||||
|
setPhotoViewMode(mode);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => {
|
const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => {
|
||||||
if (e) {
|
if (e) {
|
||||||
@@ -246,12 +259,47 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
<div className="flex items-center gap-3">
|
||||||
{t('gallery.photosCount', { count: photos.length })}
|
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t('gallery.photosCount', { count: photos.length })}
|
||||||
|
</div>
|
||||||
|
{/* Layout toggle: Grid / List — radiogroup so a screen reader
|
||||||
|
announces the two options as one mutually-exclusive set. */}
|
||||||
|
<div className="inline-flex rounded-lg border border-neutral-300 dark:border-neutral-600 overflow-hidden" role="radiogroup" aria-label={t('admin.photos.viewMode', 'View mode')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
onClick={() => selectView('grid')}
|
||||||
|
aria-checked={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"
|
||||||
|
role="radio"
|
||||||
|
onClick={() => selectView('list')}
|
||||||
|
aria-checked={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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Photo Grid */}
|
{/* Photo Grid */}
|
||||||
|
{viewMode === 'grid' && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
{photos.map((photo, index) => {
|
{photos.map((photo, index) => {
|
||||||
const isDeleting = deletingPhotos.has(photo.id);
|
const isDeleting = deletingPhotos.has(photo.id);
|
||||||
@@ -429,6 +477,220 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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 && (
|
{photos.length === 0 && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* 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('does not write to localStorage on mount (only on user toggle)', () => {
|
||||||
|
renderGrid();
|
||||||
|
// Opening the tab must not persist the value it just read.
|
||||||
|
expect(localStorage.getItem('picpeak.adminPhotos.view')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to list rows when the List toggle is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderGrid();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('radio', { 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('radio', { 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",
|
"visibleSuccess": "Fotos jetzt für Gäste sichtbar",
|
||||||
"processingStatus": "Wird verarbeitet…",
|
"processingStatus": "Wird verarbeitet…",
|
||||||
"processingFailed": "Fehlgeschlagen",
|
"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": {
|
"events": {
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
@@ -2101,7 +2101,22 @@
|
|||||||
"visibleSuccess": "Photos now visible to guests",
|
"visibleSuccess": "Photos now visible to guests",
|
||||||
"processingStatus": "Processing…",
|
"processingStatus": "Processing…",
|
||||||
"processingFailed": "Failed",
|
"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": {
|
"events": {
|
||||||
"tabs": {
|
"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