import { useEffect, useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { apiFetch } from '../lib/api'; import ScanSlideIn from './ScanSlideIn'; import { PageScannerSkeleton } from './Skeleton'; import type { PageScanSummary, ScannableUrl } from '../lib/types'; const ITEMS_PER_PAGE = 20; // ── Pluralise a post-type slug dynamically ──────────────────── function pluralize(type: string): string { if (!type) return type; const t = type.toLowerCase(); // Known irregulars const irregulars: Record = { category: 'Categories', taxonomy: 'Taxonomies', media: 'Media', person: 'People', // WordPress special page types — always singular home: 'Home', front_page: 'Front Page', search: 'Search', '404': '404', archive: 'Archive', singular: 'Singular', }; if (irregulars[t]) return irregulars[t]; // Rules — longest-match first if (t.endsWith('quiz')) return cap(t) + 'zes'; if (t.endsWith('s') || t.endsWith('x') || t.endsWith('z') || t.endsWith('ch') || t.endsWith('sh')) return cap(t) + 'es'; if (/[^aeiou]y$/.test(t)) return cap(t.slice(0, -1)) + 'ies'; if (t.endsWith('fe')) return cap(t.slice(0, -2)) + 'ves'; if (t.endsWith('f') && !t.endsWith('ff')) return cap(t.slice(0, -1)) + 'ves'; return cap(t) + 's'; } function cap(s: string): string { // Capitalise each word in hyphenated / underscored slugs return s.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } // ── Type filter searchable dropdown ────────────────────────── function TypeFilterDropdown({ types, urls, value, onChange, }: { types: string[]; urls: ScannableUrl[]; value: string; onChange: (type: string) => void; }) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const ref = useRef(null); // Close on outside click useEffect(() => { const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, []); const typeLabel = (t: string) => t === 'all' ? 'All Types' : pluralize(t); const filtered = types.filter((t) => t === 'all' || typeLabel(t).toLowerCase().includes(search.toLowerCase()), ); const triggerLabel = typeLabel(value); const count = value === 'all' ? urls.length : urls.filter((u) => u.type === value).length; return (
Filter by:
{open && (
setSearch(e.target.value)} autoFocus />
    {filtered.length === 0 && (
  • No types match
  • )} {filtered.map((type) => { const c = type === 'all' ? urls.length : urls.filter((u) => u.type === type).length; return (
  • { onChange(type); setOpen(false); setSearch(''); }} > {typeLabel(type)} {c}
  • ); })}
)}
); } // ── Main component ──────────────────────────────────────────── export default function PageScanner() { const [selectedScan, setSelectedScan] = useState(null); // Filters + pagination const [typeFilter, setTypeFilter] = useState('all'); const [searchInput, setSearchInput] = useState(''); const [search, setSearch] = useState(''); const [currentPage, setCurrentPage] = useState(0); const { data: urls = [], isLoading: urlsLoading } = useQuery({ queryKey: ['scannable-urls', search], queryFn: () => apiFetch(search ? `/scannable-urls?search=${encodeURIComponent(search)}` : '/scannable-urls'), }); const { data: pageScans = {} } = useQuery({ queryKey: ['page-scans'], queryFn: () => apiFetch>('/page-scans'), }); // Derive unique post types from url list for the filter bar const uniqueTypes = useMemo( () => ['all', ...Array.from(new Set(urls.map((u) => u.type)))], [urls], ); // Filter by type client-side (server already filtered by search) const filteredUrls = useMemo( () => typeFilter === 'all' ? urls : urls.filter((u) => u.type === typeFilter), [urls, typeFilter], ); const totalPages = Math.max(1, Math.ceil(filteredUrls.length / ITEMS_PER_PAGE)); const safePage = Math.min(currentPage, totalPages - 1); const pagedUrls = filteredUrls.slice(safePage * ITEMS_PER_PAGE, (safePage + 1) * ITEMS_PER_PAGE); function handleTypeFilter(type: string) { setTypeFilter(type); setCurrentPage(0); setSelectedScan(null); } function commitSearch() { setSearch(searchInput.trim()); setCurrentPage(0); } function handleSearchKey(e: React.KeyboardEvent) { if (e.key === 'Enter') commitSearch(); } function clearSearch() { setSearchInput(''); setSearch(''); setCurrentPage(0); } function scoreColor(s: number) { return s >= 90 ? '#22c55e' : s >= 75 ? '#84cc16' : s >= 55 ? '#f59e0b' : s >= 35 ? '#f97316' : '#ef4444'; } if (urlsLoading) return ; const scannedCount = Object.keys(pageScans).length; return (
{/* ── Header ── */}

Pages & Posts

{urls.length} URLs · {scannedCount} scanned ·{' '} axe-core powered

{/* ── Search + type filter row ── */}
setSearchInput(e.target.value)} onKeyDown={handleSearchKey} aria-label="Search pages and posts" /> {searchInput && ( )}
{/* ── Results slide-in ── */} setSelectedScan(null)} /> {/* ── Table ── */}
{pagedUrls.length === 0 && ( )} {pagedUrls.map((item) => { const result = pageScans[item.url]; return ( ); })}
Page / Post Type Score Issues Last Scanned
{search ? `No results for "${search}"` : 'No pages found.'}
{item.label}
{item.url}
{item.type} {result ? {result.grade} · {result.score} : } {result ? 0 ? '#ef4444' : '#22c55e', fontWeight: 600 }}> {result.failed} : } {result ? new Date(result.scanned_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }) : '—'} {result && ( )}
{/* ── Pagination ── */} {totalPages > 1 && (
Page {safePage + 1} of {totalPages}
)}
); }