Sprint D: hardening e2e, /search redirect, scroll restore fix.
CI / skip-ci-check (pull_request) Successful in 29s
CI / docker-ci (pull_request) Successful in 32s
CI / python-lint (pull_request) Successful in 32s
CI / secret-scan (pull_request) Successful in 37s
CI / viewer-unit (pull_request) Successful in 1m53s
CI / admin-unit (pull_request) Successful in 2m39s
CI / e2e (pull_request) Failing after 3m19s

Admin review smoke tests, CWV budgets, unified gallery at /, and explicit
body unlock when closing the photo modal so scroll position restores.
This commit is contained in:
2026-08-04 22:17:43 -04:00
parent 071bda243b
commit 26faf33a95
15 changed files with 265 additions and 342 deletions
+5 -4
View File
@@ -36,6 +36,7 @@ Living plan for product quality, auth/email reliability, and automation.
- [x] **UX Sprint A — match-all filters, slim search payloads, DEV `next start`** — see [UX sprint](#ux-sprint--trust-mobile-admin-throughput-2026-08)
- [x] **UX Sprint B — mobile gallery UX** — see [UX sprint](#ux-sprint--trust-mobile-admin-throughput-2026-08)
- [x] **UX Sprint C — admin throughput** — see [UX sprint](#ux-sprint--trust-mobile-admin-throughput-2026-08)
- [x] **UX Sprint D — hardening** — see [UX sprint](#ux-sprint--trust-mobile-admin-throughput-2026-08)
## Next (near-term)
@@ -91,11 +92,11 @@ Living plan for product quality, auth/email reliability, and automation.
- [x] **Approve Identified pagination** — API `page` / `page_size` + batched face→photo lookup; UI 25 rows/page
- [x] **Approve in-app photo preview** — lightbox instead of `window.open`
#### Sprint D — hardening (backlog)
#### Sprint D — hardening (shipped)
- [ ] **Admin e2e** — Identify, Auto-Match, Approve, modal scroll restore
- [ ] **Core Web Vitals budget** — LCP/CLS/INP on home + viewer
- [ ] **Unify `/` and `/search`**single gallery surface; fix `/search` URL sync for `peopleMode` / `tagsMode`
- [x] **Admin e2e** — Identify, Auto-Match, Approve pages + gallery scroll restore (`admin.review-pages.spec.ts`, `viewer.scroll-restore.spec.ts`)
- [x] **Core Web Vitals budget** — LCP + CLS gates on home gallery (`viewer.web-vitals.spec.ts`, `web-vitals-budgets.ts`)
- [x] **Unify `/` and `/search`**`/search` redirects to `/?…` with full query string (`peopleMode`, `tagsMode`, etc.)
## Later
+5
View File
@@ -22,6 +22,7 @@ Never commit lockfile URLs that embed tokens.
| Variable | Required | Purpose |
|----------|----------|---------|
| `PLAYKIT_BASE_URL` | yes (default in `env-defaults.json`) | Public viewer host |
| `PLAYKIT_ADMIN_BASE_URL` | no (default `punimtagadmindev.levkin.ca`) | FastAPI admin UI for `admin.review-pages.spec.ts` |
| `PLAYKIT_API_BASE_URL` | no | LAN API (see `env-defaults.json`) |
| `E2E_ADMIN_EMAIL` | for auth specs | Prefer `e2e@levkine.ca`**not** `admin@admin.com` |
| `E2E_ADMIN_PASSWORD` | for auth specs | Dedicated e2e password (Vaultwarden / Infisical) |
@@ -65,6 +66,10 @@ not enabled in this CI job yet.
19. PROD smoke (`prod.smoke.spec.ts`, opt-in via `PROD_BASE_URL`): `/api/health` + login page load on the public PROD host — dormant until the PROD LXC exists (see ROADMAP)
20. Manage Users real CRUD + causal cross-session effect (`admin.manage-users-actions.spec.ts`): admin creates/edits/deletes a user through the actual panel (not just open/close), and deactivating a user via the UI immediately revokes that user's *already open* NextAuth session (jwt callback re-checks `isActive`, Kolby #57 fix) — every run creates a disposable `e2e-manage-test-*` account and deletes it in a `finally`, never touching the shared punimtagdev/MirrorMatch DB's real accounts
21. Timing budgets (`timing-budgets.ts`): every `timings.measure()` call site is followed by `expectWithinBudget()` against a shared `BUDGET_MS` bucket — a step getting order-of-magnitude slower reds the test, not just a Pushgateway sample nobody's watching
22. Gallery scroll restore (`viewer.scroll-restore.spec.ts`): scroll down, open `?photo=` modal, Escape closes and restores scroll Y
23. Core Web Vitals budget (`viewer.web-vitals.spec.ts`, `web-vitals-budgets.ts`): LCP + CLS gates on anonymous home gallery load
24. Admin review pages smoke (`admin.review-pages.spec.ts`): FastAPI admin login on `punimtagadmindev` → Identify, Auto-Match, Approve Identified load
25. `/search` legacy URLs redirect to `/` with query string preserved (incl. `peopleMode` / `tagsMode`)
See repo root [`ROADMAP.md`](../ROADMAP.md) for gaps and next steps.
+1
View File
@@ -1,5 +1,6 @@
{
"DEFAULT_BASE_URL": "https://punimtagdev.levkin.ca",
"DEFAULT_ADMIN_BASE_URL": "https://punimtagadmindev.levkin.ca",
"DEFAULT_API_BASE_URL": "http://<backend-host>:8000",
"DEFAULT_PROJECT": "punimtag",
"DEFAULT_ENV": "dev"
+1
View File
@@ -30,6 +30,7 @@ function loadLocalOverrides(): Partial<typeof checkedInDefaults> {
const defaults = { ...checkedInDefaults, ...loadLocalOverrides() };
export const DEFAULT_BASE_URL = defaults.DEFAULT_BASE_URL;
export const DEFAULT_ADMIN_BASE_URL = defaults.DEFAULT_ADMIN_BASE_URL;
export const DEFAULT_API_BASE_URL = defaults.DEFAULT_API_BASE_URL;
export const DEFAULT_PROJECT = defaults.DEFAULT_PROJECT;
export const DEFAULT_ENV = defaults.DEFAULT_ENV;
+19
View File
@@ -0,0 +1,19 @@
import type { Page } from '@playwright/test';
export class AdminLoginPage {
constructor(
private readonly page: Page,
private readonly baseUrl: string,
) {}
async openLogin(): Promise<void> {
await this.page.goto(`${this.baseUrl}/login`);
}
async signIn(username: string, password: string): Promise<void> {
await this.page.locator('#username').fill(username);
await this.page.locator('#password').fill(password);
await this.page.getByRole('button', { name: /^Login$/i }).click();
await this.page.waitForURL((url) => !url.pathname.endsWith('/login'), { timeout: 30_000 });
}
}
+55
View File
@@ -0,0 +1,55 @@
import { test, expect } from '../fixtures';
import { AdminLoginPage } from '../pages/AdminLoginPage';
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
import { DEFAULT_ADMIN_BASE_URL } from '../env-defaults';
/**
* FastAPI-admin UI smoke: core review workflows load without error.
* Uses E2E_API_USERNAME/PASSWORD (admin FastAPI user — not NextAuth viewer creds).
*/
test.describe('admin review pages @smoke', () => {
const adminBaseUrl = process.env.PLAYKIT_ADMIN_BASE_URL || DEFAULT_ADMIN_BASE_URL;
test('identify, auto-match, and approve pages load for admin', async ({
page,
timings,
}) => {
const username = process.env.E2E_API_USERNAME || '';
const password = process.env.E2E_API_PASSWORD || '';
test.skip(!username || !password, 'E2E_API_USERNAME/PASSWORD required for admin UI');
const login = new AdminLoginPage(page, adminBaseUrl);
await timings.measure('admin_login', async () => {
await login.openLogin();
await login.signIn(username, password);
await expect(page.getByRole('heading', { name: /Home Page/i })).toBeVisible({
timeout: 20_000,
});
});
expectWithinBudget(timings, 'admin_login', BUDGET_MS.uiLogin);
await timings.measure('admin_identify', async () => {
await page.goto(`${adminBaseUrl}/identify`);
await expect(page.getByRole('heading', { name: /Identify/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Identify Faces/i })).toBeVisible();
});
expectWithinBudget(timings, 'admin_identify', BUDGET_MS.uiAction);
await timings.measure('admin_auto_match', async () => {
await page.goto(`${adminBaseUrl}/auto-match`);
await expect(page.getByRole('heading', { name: /Auto-Match/i })).toBeVisible();
await expect(page.getByRole('button', { name: /Run Auto-Match/i })).toBeVisible();
});
expectWithinBudget(timings, 'admin_auto_match', BUDGET_MS.uiAction);
await timings.measure('admin_approve', async () => {
await page.goto(`${adminBaseUrl}/approve-identified`);
await expect(page.getByRole('heading', { name: /Approve Identified/i })).toBeVisible();
await expect(
page.getByText(/Total pending identifications/i).or(page.getByText(/Loading identified people/i)),
).toBeVisible({ timeout: 20_000 });
});
expectWithinBudget(timings, 'admin_approve', BUDGET_MS.uiAction);
});
});
+4 -4
View File
@@ -18,7 +18,7 @@ test.describe('gallery filters (authed) @smoke', () => {
timings,
}) => {
await timings.measure('open_search', async () => {
await page.goto(`${playkitConfig.baseUrl}/search`);
await page.goto(`${playkitConfig.baseUrl}/`);
});
expectWithinBudget(timings, 'open_search', BUDGET_MS.uiAction);
@@ -31,7 +31,7 @@ test.describe('gallery filters (authed) @smoke', () => {
await expect(favoritesCheckbox).toBeChecked();
await expect(page).toHaveURL(/favoritesOnly=true/);
await expect(
page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(),
page.getByText(/Found \d+ photo|Showing \d+ photo|No photos found/i).first(),
).toBeVisible({ timeout: 20_000 });
// Toggling back off should drop the param again.
@@ -54,7 +54,7 @@ test.describe('gallery filters (authed) @smoke', () => {
expect(person).toBeTruthy();
await timings.measure('open_search', async () => {
await page.goto(`${playkitConfig.baseUrl}/search`);
await page.goto(`${playkitConfig.baseUrl}/`);
});
expectWithinBudget(timings, 'open_search', BUDGET_MS.uiAction);
@@ -67,7 +67,7 @@ test.describe('gallery filters (authed) @smoke', () => {
await expect(page).toHaveURL(new RegExp(`people=${person.id}(&|$)`));
await expect(
page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(),
page.getByText(/Found \d+ photo|Showing \d+ photo|No photos found/i).first(),
).toBeVisible({ timeout: 20_000 });
});
});
+9 -2
View File
@@ -133,7 +133,7 @@ test.describe('gallery search filters @smoke', () => {
expect(tag).toBeTruthy();
await timings.measure('open_search', async () => {
await page.goto(`${playkitConfig.baseUrl}/search`);
await page.goto(`${playkitConfig.baseUrl}/`);
});
expectWithinBudget(timings, 'open_search', BUDGET_MS.uiAction);
@@ -158,7 +158,14 @@ test.describe('gallery search filters @smoke', () => {
await expect(page).toHaveURL(new RegExp(`tags=${tag.id}(&|$)`));
await expect(
page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(),
page.getByText(/Found \d+ photo|Showing \d+ photo|No photos found/i).first(),
).toBeVisible({ timeout: 20_000 });
});
test('/search legacy URL redirects to home with filters preserved', async ({ page, playkitConfig }) => {
await page.goto(`${playkitConfig.baseUrl}/search?tags=4&peopleMode=all`);
await expect(page).toHaveURL(/tags=4/);
await expect(page).toHaveURL(/peopleMode=all/);
expect(new URL(page.url()).pathname).toBe('/');
});
});
+2 -2
View File
@@ -42,9 +42,9 @@ test.describe('network errors (public pages) @smoke', () => {
const net = startNetworkErrorMonitor(page, { excludePatterns: excludeAnonSession });
try {
await timings.measure('search_tag_filter', async () => {
await page.goto(`${playkitConfig.baseUrl}/search?tags=${tag.id}`);
await page.goto(`${playkitConfig.baseUrl}/?tags=${tag.id}`);
await expect(
page.getByText(/Found \d+ photos?|No photos found matching your filters/i).first(),
page.getByText(/Found \d+ photo|Showing \d+ photo|No photos found/i).first(),
).toBeVisible({ timeout: 20_000 });
});
expectWithinBudget(timings, 'search_tag_filter', BUDGET_MS.uiAction);
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from '../fixtures';
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
test.describe('gallery scroll restore @smoke', () => {
test('closing photo modal restores scroll position', async ({ page, playkitConfig, timings }) => {
await timings.measure('open_gallery', async () => {
await page.goto(`${playkitConfig.baseUrl}/`);
await expect(
page.getByLabel('Account menu').or(page.getByRole('button', { name: /Sign in/i })).first(),
).toBeVisible({ timeout: 20_000 });
});
expectWithinBudget(timings, 'open_gallery', BUDGET_MS.uiAction);
for (let i = 0; i < 4; i++) {
await page.mouse.wheel(0, 700);
await page.waitForTimeout(150);
}
const scrollBefore = await page.evaluate(() => window.scrollY);
expect(scrollBefore).toBeGreaterThan(200);
const photoButton = page.locator('main .aspect-square button').first();
await expect(photoButton).toBeVisible({ timeout: 15_000 });
await photoButton.click();
await expect(page.getByRole('dialog', { name: /Photo viewer/i })).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/[?&]photo=\d+/, { timeout: 15_000 });
await page.getByRole('dialog', { name: /Photo viewer/i }).getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('dialog', { name: /Photo viewer/i })).toBeHidden({ timeout: 15_000 });
await expect(page).not.toHaveURL(/[?&]photo=\d+/, { timeout: 15_000 });
await expect
.poll(async () => page.evaluate(() => window.scrollY), { timeout: 5_000 })
.toBeGreaterThan(scrollBefore - 80);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { test } from '../fixtures';
import { BUDGET_MS, expectWithinBudget } from '../timing-budgets';
import {
CWV_BUDGET,
expectWithinCwvBudget,
installWebVitalsCollector,
readWebVitals,
} from '../web-vitals-budgets';
test.describe('viewer web vitals @smoke', () => {
test.beforeEach(async ({ page }) => {
await installWebVitalsCollector(page);
});
test('home gallery LCP and CLS within budget', async ({ page, playkitConfig, timings }) => {
await timings.measure('open_gallery_cwv', async () => {
await page.goto(`${playkitConfig.baseUrl}/`);
await page
.getByLabel('Account menu')
.or(page.getByRole('button', { name: /Sign in/i }))
.first()
.waitFor({ state: 'visible', timeout: 20_000 });
});
expectWithinBudget(timings, 'open_gallery_cwv', BUDGET_MS.uiAction);
const vitals = await readWebVitals(page);
expectWithinCwvBudget(vitals);
test.info().annotations.push({
type: 'cwv',
description: `LCP=${Math.round(vitals.lcp)}ms CLS=${vitals.cls.toFixed(3)} budgets LCP<=${CWV_BUDGET.lcp} CLS<=${CWV_BUDGET.cls}`,
});
});
});
+65
View File
@@ -0,0 +1,65 @@
import { expect, type Page } from '@playwright/test';
/**
* Core Web Vitals budget gates for the public viewer gallery.
* Generous for shared CI + DEV LXC — catches order-of-magnitude regressions.
*/
export const CWV_BUDGET = {
/** Largest Contentful Paint (ms) after navigation settles. */
lcp: 5_000,
/** Cumulative Layout Shift (unitless, 01+). */
cls: 0.2,
} as const;
export type WebVitalsSample = {
lcp: number;
cls: number;
};
/** Install observers before navigation (call once per test). */
export async function installWebVitalsCollector(page: Page): Promise<void> {
await page.addInitScript(() => {
const w = window as Window & { __punimtagCwv?: WebVitalsSample };
w.__punimtagCwv = { lcp: 0, cls: 0 };
try {
new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries[entries.length - 1] as PerformanceEntry & { startTime: number };
if (last) w.__punimtagCwv!.lcp = last.startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true as boolean });
} catch {
/* unsupported */
}
try {
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as PerformanceEntry & { value?: number; hadRecentInput?: boolean };
if (shift.hadRecentInput) continue;
w.__punimtagCwv!.cls += shift.value ?? 0;
}
}).observe({ type: 'layout-shift', buffered: true as boolean });
} catch {
/* unsupported */
}
});
}
export async function readWebVitals(page: Page, settleMs = 2_000): Promise<WebVitalsSample> {
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(settleMs);
return page.evaluate(() => {
const w = window as Window & { __punimtagCwv?: WebVitalsSample };
return w.__punimtagCwv ?? { lcp: 0, cls: 0 };
});
}
export function expectWithinCwvBudget(sample: WebVitalsSample): void {
expect(sample.lcp, `LCP ${Math.round(sample.lcp)}ms exceeds budget ${CWV_BUDGET.lcp}ms`).toBeLessThanOrEqual(
CWV_BUDGET.lcp,
);
expect(sample.cls, `CLS ${sample.cls.toFixed(3)} exceeds budget ${CWV_BUDGET.cls}`).toBeLessThanOrEqual(
CWV_BUDGET.cls,
);
}
+15 -3
View File
@@ -654,6 +654,19 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent
scrollYBeforeModalRef.current ??
parseInt(sessionStorage.getItem('homePageScrollY') || '0', 10);
const unlockBodyAndRestoreScroll = (y: number) => {
document.body.style.position = '';
document.body.style.top = '';
document.body.style.width = '';
document.body.style.overflow = '';
window.scrollTo({ top: y, behavior: 'instant' });
sessionStorage.setItem('homePageScrollY', String(y));
scrollRestoredRef.current = true;
};
// Body is position:fixed while ?photo= is active — scrollTo is a no-op until unlocked.
unlockBodyAndRestoreScroll(restoreY);
// Clear modal state immediately (no reload, instant close)
setModalPhoto(null);
setModalPhotos([]);
@@ -674,13 +687,12 @@ export function HomePageContent({ initialPhotos, people, tags }: HomePageContent
// Belt-and-suspenders: modal effect cleanup also restores, but Next can
// still nudge scroll — pin it again after the URL settles.
requestAnimationFrame(() => {
window.scrollTo({ top: restoreY, behavior: 'instant' });
sessionStorage.setItem('homePageScrollY', String(restoreY));
unlockBodyAndRestoreScroll(restoreY);
});
setTimeout(() => {
isClosingModalRef.current = false;
window.scrollTo({ top: restoreY, behavior: 'instant' });
unlockBodyAndRestoreScroll(restoreY);
}, 100);
};
@@ -1,208 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Person, Tag, Photo } from '@prisma/client';
import { FilterPanel, SearchFilters } from '@/components/search/FilterPanel';
import { PhotoGrid } from '@/components/PhotoGrid';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
interface SearchContentProps {
people: Person[];
tags: Tag[];
}
export function SearchContent({ people, tags }: SearchContentProps) {
const router = useRouter();
const searchParams = useSearchParams();
// Initialize filters from URL params
const [filters, setFilters] = useState<SearchFilters>(() => {
const peopleParam = searchParams.get('people');
const tagsParam = searchParams.get('tags');
const dateFromParam = searchParams.get('dateFrom');
const dateToParam = searchParams.get('dateTo');
const mediaTypeParam = searchParams.get('mediaType');
const favoritesOnlyParam = searchParams.get('favoritesOnly');
return {
people: peopleParam ? peopleParam.split(',').map(Number).filter(Boolean) : [],
tags: tagsParam ? tagsParam.split(',').map(Number).filter(Boolean) : [],
dateFrom: dateFromParam ? new Date(dateFromParam) : undefined,
dateTo: dateToParam ? new Date(dateToParam) : undefined,
mediaType: (mediaTypeParam as 'all' | 'photos' | 'videos') || 'all',
favoritesOnly: favoritesOnlyParam === 'true',
};
});
const [photos, setPhotos] = useState<Photo[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
// Update URL when filters change
useEffect(() => {
const params = new URLSearchParams();
if (filters.people.length > 0) {
params.set('people', filters.people.join(','));
}
if (filters.tags.length > 0) {
params.set('tags', filters.tags.join(','));
}
if (filters.dateFrom) {
params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]);
}
if (filters.dateTo) {
params.set('dateTo', filters.dateTo.toISOString().split('T')[0]);
}
if (filters.mediaType && filters.mediaType !== 'all') {
params.set('mediaType', filters.mediaType);
}
if (filters.favoritesOnly) {
params.set('favoritesOnly', 'true');
}
const newUrl = params.toString() ? `/search?${params.toString()}` : '/search';
router.replace(newUrl, { scroll: false });
}, [filters, router]);
// Reset to page 1 when filters change
useEffect(() => {
setPage(1);
}, [filters.people, filters.tags, filters.dateFrom, filters.dateTo, filters.mediaType, filters.favoritesOnly]);
// Fetch photos when filters or page change
useEffect(() => {
const fetchPhotos = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filters.people.length > 0) {
params.set('people', filters.people.join(','));
if (filters.peopleMode) {
params.set('peopleMode', filters.peopleMode);
}
}
if (filters.tags.length > 0) {
params.set('tags', filters.tags.join(','));
if (filters.tagsMode) {
params.set('tagsMode', filters.tagsMode);
}
}
if (filters.dateFrom) {
params.set('dateFrom', filters.dateFrom.toISOString().split('T')[0]);
}
if (filters.dateTo) {
params.set('dateTo', filters.dateTo.toISOString().split('T')[0]);
}
if (filters.mediaType && filters.mediaType !== 'all') {
params.set('mediaType', filters.mediaType);
}
if (filters.favoritesOnly) {
params.set('favoritesOnly', 'true');
}
params.set('page', page.toString());
params.set('pageSize', '30');
const response = await fetch(`/api/search?${params.toString()}`);
if (!response.ok) throw new Error('Failed to search photos');
const data = await response.json();
setPhotos(data.photos);
setTotal(data.total);
} catch (error) {
console.error('Error searching photos:', error);
} finally {
setLoading(false);
}
};
fetchPhotos();
}, [filters, page]);
const hasActiveFilters =
filters.people.length > 0 ||
filters.tags.length > 0 ||
filters.dateFrom ||
filters.dateTo ||
(filters.mediaType && filters.mediaType !== 'all') ||
filters.favoritesOnly === true;
return (
<div className="grid grid-cols-1 gap-8 lg:grid-cols-4">
{/* Filter Panel */}
<div className="lg:col-span-1">
<FilterPanel
people={people}
tags={tags}
filters={filters}
onFiltersChange={setFilters}
/>
</div>
{/* Results */}
<div className="lg:col-span-3">
{loading ? (
<div role="status" aria-label="Loading photos" className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-gray-400 dark:text-gray-500" />
</div>
) : (
<>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{total === 0 ? (
hasActiveFilters ? (
'No photos found matching your filters'
) : (
'Start by selecting filters to search photos'
)
) : (
`Found ${total} photo${total !== 1 ? 's' : ''}`
)}
</div>
</div>
{photos.length > 0 ? (
<>
<PhotoGrid photos={photos} />
{total > 30 && (
<div className="mt-8 flex justify-center gap-2">
<Button
variant="outline"
disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<span className="flex items-center px-4 text-sm text-gray-600 dark:text-gray-400">
Page {page} of {Math.ceil(total / 30)}
</span>
<Button
variant="outline"
disabled={page >= Math.ceil(total / 30)}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
)}
</>
) : hasActiveFilters ? (
<div className="flex items-center justify-center py-12">
<p className="text-gray-500 dark:text-gray-400">No photos found matching your filters</p>
</div>
) : (
<div className="flex items-center justify-center py-12">
<p className="text-gray-500 dark:text-gray-400">Select filters to search photos</p>
</div>
)}
</>
)}
</div>
</div>
);
}
+16 -119
View File
@@ -1,124 +1,21 @@
import { Suspense } from 'react';
import { prisma } from '@/lib/db';
import { SearchContent } from './SearchContent';
import { PhotoGrid } from '@/components/PhotoGrid';
import { redirect } from 'next/navigation';
// Force dynamic rendering to prevent database queries during build
/** Legacy `/search` URLs → home gallery with the same query string. */
export const dynamic = 'force-dynamic';
async function getAllPeople() {
try {
return await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
email: true,
phone: true,
created_date: true,
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted person data detected, attempting fallback query');
try {
// Try with minimal fields first, but include all required fields for type compatibility
return await prisma.person.findMany({
select: {
id: true,
first_name: true,
last_name: true,
middle_name: true,
maiden_name: true,
date_of_birth: true,
email: true,
phone: true,
created_date: true,
},
orderBy: [
{ first_name: 'asc' },
{ last_name: 'asc' },
],
});
} catch (fallbackError: any) {
console.error('Fallback person query also failed:', fallbackError);
// Return empty array as last resort to prevent page crash
return [];
}
}
// Re-throw if it's a different error
throw error;
type SearchPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export default async function SearchPage({ searchParams }: SearchPageProps) {
const params = await searchParams;
const qs = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined) continue;
qs.set(key, Array.isArray(value) ? value.join(',') : value);
}
const query = qs.toString();
redirect(query ? `/?${query}` : '/');
}
async function getAllTags() {
try {
return await prisma.tag.findMany({
select: {
id: true,
tag_name: true,
created_date: true,
},
orderBy: { tag_name: 'asc' },
});
} catch (error: any) {
// Handle corrupted data errors (P2023)
if (error?.code === 'P2023' || error?.message?.includes('Conversion failed')) {
console.warn('Corrupted tag data detected, attempting fallback query');
try {
// Try with minimal fields, but include all required fields for type compatibility
return await prisma.tag.findMany({
select: {
id: true,
tag_name: true,
created_date: true,
},
orderBy: { tag_name: 'asc' },
});
} catch (fallbackError: any) {
console.error('Fallback tag query also failed:', fallbackError);
// Return empty array as last resort to prevent page crash
return [];
}
}
// Re-throw if it's a different error
throw error;
}
}
export default async function SearchPage() {
const [people, tags] = await Promise.all([
getAllPeople(),
getAllTags(),
]);
return (
<main id="main-content" className="w-full px-4 py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-secondary dark:text-gray-50">
Search Photos
</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">
Find photos by people, dates, and tags
</p>
</div>
<Suspense fallback={
<div role="status" className="flex items-center justify-center py-12">
<div className="text-gray-500 dark:text-gray-400">Loading search...</div>
</div>
}>
<SearchContent people={people} tags={tags} />
</Suspense>
</main>
);
}