- Update CLAUDE.md with full project structure and conventions - Add report viewer page: score ring, WCAG compliance cards, issue list with severity filter, next steps, export + auto-fix buttons, real-time polling - Add Alembic script.py.mako template (required for alembic revision --autogenerate) - Add frontend/tsconfig.json + postcss.config.js (Next.js build requirements) - Fix TypeScript error in supabase/server.ts (explicit CookieOptions type) - Upgrade Next.js 15.3.2 → 15.5.18 (cache poisoning CVE fix) - Update .gitignore: frontend build artifacts, backend venv, tsbuildinfo TypeScript: 0 errors (npx tsc --noEmit passes) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
264 lines
11 KiB
TypeScript
264 lines
11 KiB
TypeScript
"use client";
|
||
import { useEffect, useState } from "react";
|
||
import { useParams } from "next/navigation";
|
||
|
||
interface Issue {
|
||
severity: string;
|
||
category: string;
|
||
description: string;
|
||
recommendation: string;
|
||
wcag_criterion: string;
|
||
page_number?: number;
|
||
}
|
||
|
||
interface JobResult {
|
||
id: string;
|
||
filename: string;
|
||
status: string;
|
||
accessibility_score: number | null;
|
||
result: {
|
||
accessibility_score: number;
|
||
severity_counts: Record<string, number>;
|
||
wcag_compliance: { level_a: boolean; level_aa: boolean };
|
||
issues: Issue[];
|
||
next_steps: Array<{ priority: number; category: string; action: string; wcag: string }>;
|
||
score_breakdown?: { adjusted: boolean };
|
||
stats?: { duration: number; api_calls: number; total_cost_estimate: number };
|
||
} | null;
|
||
error_message: string | null;
|
||
}
|
||
|
||
const SEV_COLOR: Record<string, string> = {
|
||
CRITICAL: "bg-red-100 text-red-700 border-red-200",
|
||
ERROR: "bg-orange-100 text-orange-700 border-orange-200",
|
||
WARNING: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||
INFO: "bg-blue-100 text-blue-700 border-blue-200",
|
||
};
|
||
|
||
const SEV_ICON: Record<string, string> = {
|
||
CRITICAL: "🚨", ERROR: "❌", WARNING: "⚠️", INFO: "ℹ️",
|
||
};
|
||
|
||
function ScoreRing({ score }: { score: number }) {
|
||
const color = score >= 80 ? "#10b981" : score >= 60 ? "#f59e0b" : "#ef4444";
|
||
const grade = score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : score >= 60 ? "D" : "F";
|
||
return (
|
||
<div className="flex items-center gap-6">
|
||
<div className="relative w-24 h-24 flex items-center justify-center">
|
||
<svg className="absolute" width="96" height="96" viewBox="0 0 96 96">
|
||
<circle cx="48" cy="48" r="42" fill="none" stroke="#e5e7eb" strokeWidth="8" />
|
||
<circle
|
||
cx="48" cy="48" r="42" fill="none"
|
||
stroke={color} strokeWidth="8"
|
||
strokeDasharray={`${(score / 100) * 263.9} 263.9`}
|
||
strokeLinecap="round"
|
||
transform="rotate(-90 48 48)"
|
||
/>
|
||
</svg>
|
||
<div className="text-center">
|
||
<div className="text-2xl font-extrabold text-gray-900">{score}</div>
|
||
<div className="text-xs text-gray-400 font-medium">/ 100</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-5xl font-extrabold" style={{ color }}>{grade}</div>
|
||
<div className="text-sm text-gray-500 mt-1">Grade</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function JobReportPage() {
|
||
const { id } = useParams<{ id: string }>();
|
||
const [job, setJob] = useState<JobResult | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [filter, setFilter] = useState<string>("ALL");
|
||
|
||
useEffect(() => {
|
||
let interval: NodeJS.Timeout;
|
||
const poll = async () => {
|
||
const res = await fetch(`/api/v1/jobs/${id}`, { credentials: "include" });
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
setJob(data);
|
||
setLoading(false);
|
||
if (data.status === "completed" || data.status === "failed") {
|
||
clearInterval(interval);
|
||
}
|
||
};
|
||
poll();
|
||
interval = setInterval(poll, 3000);
|
||
return () => clearInterval(interval);
|
||
}, [id]);
|
||
|
||
async function handleRemediate() {
|
||
await fetch(`/api/v1/jobs/${id}/remediate`, { method: "POST", credentials: "include" });
|
||
}
|
||
|
||
async function downloadReport(format: "html" | "json") {
|
||
const res = await fetch(`/api/v1/jobs/${id}?format=${format}`, { credentials: "include" });
|
||
const blob = await res.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `report-${id}.${format}`;
|
||
a.click();
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="text-center text-gray-400">
|
||
<div className="text-4xl mb-3 animate-spin">⚙️</div>
|
||
<p>Loading report...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!job) return <div className="text-red-500">Job not found</div>;
|
||
|
||
if (job.status === "pending" || job.status === "processing") {
|
||
return (
|
||
<div className="max-w-lg mx-auto mt-16 text-center">
|
||
<div className="text-5xl mb-4 animate-pulse">🔍</div>
|
||
<h2 className="text-xl font-bold text-gray-900 mb-2">Checking accessibility...</h2>
|
||
<p className="text-gray-500 text-sm">{job.filename}</p>
|
||
<div className="mt-6 bg-gray-100 rounded-full h-2 overflow-hidden">
|
||
<div className="bg-brand-500 h-2 rounded-full animate-pulse w-2/3" />
|
||
</div>
|
||
<p className="text-xs text-gray-400 mt-3">Running 30+ WCAG checks · This takes 15–60 seconds</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (job.status === "failed") {
|
||
return (
|
||
<div className="max-w-lg bg-red-50 border border-red-200 rounded-2xl p-8 text-center mt-8">
|
||
<div className="text-4xl mb-3">❌</div>
|
||
<h2 className="text-lg font-bold text-red-800">Check failed</h2>
|
||
<p className="text-red-700 text-sm mt-2">{job.error_message || "Unknown error"}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const r = job.result!;
|
||
const sc = r.severity_counts || {};
|
||
const issues = (r.issues || []).filter((i) => filter === "ALL" || i.severity === filter);
|
||
|
||
return (
|
||
<div className="max-w-4xl">
|
||
{/* Header */}
|
||
<div className="flex items-start justify-between mb-6">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900 truncate max-w-lg">{job.filename}</h1>
|
||
<p className="text-sm text-gray-400 mt-1">WCAG 2.1 AA + PDF/UA-1 Accessibility Report</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button onClick={() => downloadReport("html")} className="text-sm px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50">Export HTML</button>
|
||
<button onClick={() => downloadReport("json")} className="text-sm px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50">Export JSON</button>
|
||
<button onClick={handleRemediate} className="text-sm px-3 py-1.5 bg-brand-500 text-white rounded-lg hover:bg-brand-600">Auto-fix PDF</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Score + WCAG compliance */}
|
||
<div className="grid md:grid-cols-2 gap-4 mb-6">
|
||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||
<ScoreRing score={r.accessibility_score} />
|
||
<div className="flex gap-4 mt-4 text-sm">
|
||
{Object.entries(sc).map(([sev, count]) => (
|
||
<div key={sev} className="text-center">
|
||
<div className="font-bold text-gray-900">{count as number}</div>
|
||
<div className="text-gray-400 text-xs">{sev.toLowerCase()}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||
<h3 className="font-semibold text-gray-900 mb-4">WCAG 2.1 Conformance</h3>
|
||
{[
|
||
{ label: "Level A", pass: r.wcag_compliance?.level_a },
|
||
{ label: "Level AA", pass: r.wcag_compliance?.level_aa },
|
||
].map((lvl) => (
|
||
<div key={lvl.label} className={`flex items-center justify-between p-3 rounded-xl mb-2 ${lvl.pass ? "bg-green-50 border border-green-200" : "bg-red-50 border border-red-200"}`}>
|
||
<span className={`font-semibold text-sm ${lvl.pass ? "text-green-800" : "text-red-800"}`}>WCAG 2.1 {lvl.label}</span>
|
||
<span className={`font-bold text-lg ${lvl.pass ? "text-green-600" : "text-red-600"}`}>{lvl.pass ? "✓ Pass" : "✗ Fail"}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Issues */}
|
||
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||
<h3 className="font-semibold text-gray-900">Issues ({r.issues?.length || 0})</h3>
|
||
<div className="flex gap-1">
|
||
{["ALL", "CRITICAL", "ERROR", "WARNING", "INFO"].map((sev) => (
|
||
<button
|
||
key={sev}
|
||
onClick={() => setFilter(sev)}
|
||
className={`text-xs px-3 py-1 rounded-full font-medium transition-colors ${
|
||
filter === sev ? "bg-brand-500 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||
}`}
|
||
>
|
||
{sev === "ALL" ? `All (${r.issues?.length || 0})` : `${SEV_ICON[sev]} ${sev} (${sc[sev] || 0})`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{issues.length === 0 ? (
|
||
<div className="py-12 text-center text-gray-400 text-sm">No issues for this filter</div>
|
||
) : (
|
||
<div className="divide-y divide-gray-100">
|
||
{issues.map((issue, i) => (
|
||
<div key={i} className="px-6 py-4 hover:bg-gray-50 transition-colors">
|
||
<div className="flex items-start gap-3">
|
||
<span className={`mt-0.5 text-xs px-2 py-0.5 rounded-full border font-medium whitespace-nowrap ${SEV_COLOR[issue.severity] || "bg-gray-100 text-gray-600"}`}>
|
||
{SEV_ICON[issue.severity]} {issue.severity}
|
||
</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className="text-xs font-medium text-gray-500">{issue.category}</span>
|
||
{issue.wcag_criterion && (
|
||
<span className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded font-mono">{issue.wcag_criterion}</span>
|
||
)}
|
||
{issue.page_number && (
|
||
<span className="text-xs text-gray-400">Page {issue.page_number}</span>
|
||
)}
|
||
</div>
|
||
<p className="text-sm text-gray-900">{issue.description}</p>
|
||
{issue.recommendation && (
|
||
<p className="text-xs text-gray-500 mt-1 italic">{issue.recommendation}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Next steps */}
|
||
{r.next_steps && r.next_steps.length > 0 && (
|
||
<div className="bg-white rounded-2xl border border-gray-200 mt-4 overflow-hidden">
|
||
<div className="px-6 py-4 border-b border-gray-100">
|
||
<h3 className="font-semibold text-gray-900">Recommended Next Steps</h3>
|
||
</div>
|
||
<div className="divide-y divide-gray-100">
|
||
{r.next_steps.slice(0, 10).map((step, i) => (
|
||
<div key={i} className="px-6 py-3 flex items-start gap-3 hover:bg-gray-50">
|
||
<span className="text-xs font-bold text-gray-400 mt-0.5 w-4">{i + 1}</span>
|
||
<div className="flex-1">
|
||
<span className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded font-mono mr-2">{step.wcag}</span>
|
||
<span className="text-sm text-gray-700">{step.action}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|