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 19:17:26 +02:00
parent 627c655a4d
commit 46ce59d82e
6 changed files with 495 additions and 6 deletions
@@ -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
}
}