PDF-accessibility-saas/frontend/app/(app)/jobs/[id]/page.tsx
Vadym Samoilenko eb9cdbf639
Some checks failed
CI / Backend — lint + test (push) Has been cancelled
CI / Frontend — lint + typecheck (push) Has been cancelled
CI / Build + push Docker images (push) Has been cancelled
Finalize: CLAUDE.md, report viewer, tsconfig, type fixes
- 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>
2026-05-19 15:20:43 +01:00

264 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 1560 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>
);
}