import { useEffect, useMemo, useRef, useState } from 'react'; import { useMediaScan } from '../hooks/useMediaScan'; import { useAI } from '../hooks/useAI'; import { PROVIDERS } from '../lib/providers'; import type { MediaAltStatus, MediaScanImage, MediaScanResult } from '../lib/types'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const ADMIN_URL: string = (window as any).accessmateConfig?.adminUrl ?? '/wp-admin/'; const ITEMS_PER_PAGE = 5; const CHECKLIST = [ 'Find missing or unhelpful alt text', 'Get AI-powered suggestions', 'Review and update easily', 'Improve accessibility for all users', ]; const STATUS_ORDER: Record = { missing: 0, weak: 1, ok: 2 }; const STATUS_LABEL: Record = { missing: 'Missing', weak: 'Needs review', ok: 'OK' }; const STATUS_COLOR: Record = { missing: '#ef4444', weak: '#f59e0b', ok: '#22c55e' }; const MODEL_LABELS: Record = { 'gpt-4o': 'GPT-4o', 'gpt-4o-mini': 'GPT-4o mini', 'claude-haiku-4-5-20251001': 'Claude Haiku 4.5', 'claude-sonnet-4-20250514': 'Claude Sonnet 4', 'gemini-2.5-flash': 'Gemini 2.5 Flash', 'gemini-2.5-pro': 'Gemini 2.5 Pro', 'llama3.2': 'Llama 3.2', mistral: 'Mistral', phi3: 'Phi-3', }; type MediaTab = 'all' | 'missing' | 'weak'; type SortKey = 'date' | 'filename' | 'status' | 'alt'; // ── Formatting helpers ────────────────────────────────────────── function formatDate(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return ''; return d.toLocaleString(undefined, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', }); } function formatSize(bytes: number | null): string { if (bytes === null) return ''; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function imageMeta(image: MediaScanImage): string { const parts: string[] = []; if (image.width && image.height) parts.push(`${image.width} × ${image.height}`); const size = formatSize(image.size); if (size) parts.push(size); if (image.date) parts.push(formatDate(image.date)); return parts.join(' · '); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function scanPct(progress: { scanned: number; total: number }): number { return Math.min(100, Math.round((progress.scanned / Math.max(1, progress.total)) * 100)); } /** Page numbers with '…' gaps — always shows first, last, current ± 1. */ function pageNumbers(current: number, total: number): (number | '…')[] { if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1); const keep = new Set([1, total, current - 1, current, current + 1].filter((p) => p >= 1 && p <= total)); const sorted = [...keep].sort((a, b) => a - b); const result: (number | '…')[] = []; let prev = 0; for (const p of sorted) { if (prev && p - prev > 1) result.push('…'); result.push(p); prev = p; } return result; } // ── Icons (inline SVG, no icon library in this codebase — see OnboardingHero.tsx) ── function PhotoIcon() { return ( ); } function WarningIcon() { return ( ); } function AlertCircleIcon() { return ( ); } function ExternalLinkIcon() { return ( ); } function CheckIcon() { return ( ); } function SparkleIcon({ size = 14 }: { size?: number }) { return ( ); } function SortDirIcon() { return ( ); } function RefreshIcon() { return ( ); } function DotsIcon() { return ( ); } function WarningDotIcon() { return ( ); } function ScanIllustration() { return ( ); } // ── Stat card ───────────────────────────────────────────────── function MediaStatCard({ icon, value, label, sub, variant, }: { icon: React.ReactNode; value: number | null; label: string; sub?: string; variant: 'total' | 'missing' | 'review'; }) { return (
{icon}
{value === null ? '—' : value}
{label}
{sub &&
{sub}
}
); } // ── AI provider status card ───────────────────────────────────── function AIStatusCard({ hasProvider, providerName, model }: { hasProvider: boolean; providerName: string; model: string }) { return (
{hasProvider ? 'AI suggestions enabled' : 'AI suggestions not enabled'}

{hasProvider ? `Using ${providerName}${model ? ` (${model})` : ''} for alt text suggestions.` : 'Configure an AI provider in Settings to generate alt text suggestions for your images.'}

{hasProvider && ( AI suggestions ready )} {hasProvider ? 'Manage AI Provider' : 'Configure AI Provider'}
); } // ── Filter tabs ─────────────────────────────────────────────── // ── Scanning step list item ───────────────────────────────────── function ScanStep({ index, phase, title, sub, }: { index: number; phase: number; title: string; sub: string; }) { const state = index < phase ? 'done' : index === phase ? 'active' : 'upcoming'; return (
  • {title}
    {sub}
  • ); } function MediaTab({ label, count, active, onClick, }: { label: string; count: number; active: boolean; onClick: () => void; }) { return ( ); } // ── Sortable table header (mirrors the toolbar's Sort-by control) ── function SortableHeader({ label, sortKeyValue, activeKey, onSort, }: { label: string; sortKeyValue: SortKey; activeKey: SortKey; onSort: (key: SortKey) => void; }) { const active = activeKey === sortKeyValue; return ( ); } // ── Small action-list dropdown (bulk actions / per-row menu) ── function ActionMenu({ trigger, items, align = 'right', }: { trigger: React.ReactNode; items: { label: string; onClick: () => void; disabled?: boolean }[]; align?: 'left' | 'right'; }) { const [open, setOpen] = useState(false); 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); }, []); return (
    {open && (
    {items.map((item) => ( ))}
    )}
    ); } // ── Results table row ───────────────────────────────────────── function ImageRow({ image, draft, onDraftChange, onApply, hasProvider, selected, onToggleSelect, }: { image: MediaScanImage; draft: string; onDraftChange: (text: string) => void; onApply: (id: number, altText: string) => Promise<{ status: MediaAltStatus; reason: string }>; hasProvider: boolean; selected: boolean; onToggleSelect: () => void; }) { const { run } = useAI(); const [saving, setSaving] = useState(false); const [suggestError, setSuggestError] = useState(null); const [applyMessage, setApplyMessage] = useState<{ kind: 'success' | 'error'; text: string } | null>(null); async function suggest() { setSuggestError(null); const context = `Filename: ${image.filename}. Title: ${image.title || 'none'}.` + (image.caption ? ` Caption: ${image.caption}.` : ''); try { // attachment_id lets the backend send the actual image to a vision-capable // model instead of just this text — without it (or for an unsupported // format/provider) it falls back to guessing from context alone. const res = await run.mutateAsync({ task: 'alt_text', params: { context, attachment_id: String(image.id) } }); if (res.text) onDraftChange(res.text.trim()); else setSuggestError(res.error || 'No suggestion returned.'); } catch (err) { setSuggestError(err instanceof Error ? err.message : 'Failed to get a suggestion.'); } } async function apply() { setSaving(true); setApplyMessage(null); try { const { status, reason } = await onApply(image.id, draft); setApplyMessage( status === 'ok' ? { kind: 'success', text: 'Alt text updated.' } : { kind: 'success', text: `Alt text saved, but ${reason || 'still flagged for review'}.` } ); } catch (err) { setApplyMessage({ kind: 'error', text: err instanceof Error ? err.message : 'Failed to update alt text.' }); } finally { setSaving(false); } } const meta = imageMeta(image); return ( {image.thumb ? :