import { useEffect, useMemo, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { apiFetch, isPremium } from '../lib/api'; import { useHeadlessScan } from '../hooks/useHeadlessScan'; 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); 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 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 qc = useQueryClient(); 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); // Bulk "scan all" (Pro only — gated by isPremium below; individual scans aren't restricted) const [scanAllActive, setScanAllActive] = useState(false); const [scanAllProgress, setScanAllProgress] = useState<{ current: number; total: number } | null>(null); // Per-row headless scans (hidden iframe + ?aai_scan=, see class-admin-bar.php) const { scanning, scanErrors, startScan, scanUrlAsync } = useHeadlessScan(); const { data: urls = [], isLoading: urlsLoading } = useQuery({ // Same key as the unfiltered query below when there's no search term, so // React Query treats them as one request instead of firing a duplicate. queryKey: search ? ['scannable-urls', search] : ['scannable-urls'], queryFn: () => apiFetch(search ? `/scannable-urls?search=${encodeURIComponent(search)}` : '/scannable-urls'), }); // Unfiltered list, used only to populate the "Filter By" dropdown so its // options stay stable even when a search keyword matches nothing (urls // above would otherwise go empty and the dropdown would lose every option). const { data: allUrls = [] } = useQuery({ queryKey: ['scannable-urls'], queryFn: () => apiFetch('/scannable-urls'), }); const { data: pageScans = {} } = useQuery({ queryKey: ['page-scans'], queryFn: () => apiFetch>('/page-scans'), staleTime: 0, }); // Derive unique post types from the unfiltered url list for the filter bar const uniqueTypes = useMemo( () => ['all', ...Array.from(new Set(allUrls.map((u) => u.type)))], [allUrls], ); // 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 visibleUrls = filteredUrls; const totalPages = Math.max(1, Math.ceil(visibleUrls.length / ITEMS_PER_PAGE)); const safePage = Math.min(currentPage, totalPages - 1); const pagedUrls = visibleUrls.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'; } async function handleScanAll() { setScanAllActive(true); for (let i = 0; i < visibleUrls.length; i++) { setScanAllProgress({ current: i, total: visibleUrls.length }); await scanUrlAsync(visibleUrls[i].url).catch(() => {}); if (i < visibleUrls.length - 1) await new Promise((r) => setTimeout(r, 600)); } setScanAllActive(false); setScanAllProgress(null); qc.invalidateQueries({ queryKey: ['page-scans'] }); } if (urlsLoading) return ; const scannedCount = Object.keys(pageScans).length; return (
{/* ── Header ── */}

Pages & Posts

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

{isPremium && ( )}
{/* ── Bulk scan progress ── */} {scanAllActive && scanAllProgress && (
)} {/* ── 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]; const total = result ? result.issues.length : 0; return ( ); })}
Page / Post Type Score Issues Last Scanned Actions
{search ? `No results for "${search}"` : 'No pages found.'}
{item.label}
{item.url}
{cap(item.type)} {result ? {result.grade} · {result.score} : —} {result ? 0 ? '#ef4444' : '#22c55e', fontWeight: 600 }}> {total} : —} {result ? new Date(result.scanned_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }) : '—'}
{result && ( )} {scanErrors[item.url] && ( {scanErrors[item.url]} )}
{/* ── Pagination ── */} {totalPages > 1 && (
Page {safePage + 1} of {totalPages} {' '}({visibleUrls.length} total)
)}
); }