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
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:
+5
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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, 0–1+). */
|
||||
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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user