img tags don't send Authorization headers; AuthImage component fetches via axios (with Bearer token) and renders a blob URL. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
176 lines
5.9 KiB
TypeScript
176 lines
5.9 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useParams, useNavigate } from "react-router-dom";
|
|
import { useProject } from "../hooks/useProjects";
|
|
import { getAnalysisImageUrl } from "../api/analysis";
|
|
import { AuthImage } from "../components/AuthImage";
|
|
import Card from "../components/common/Card";
|
|
import Button from "../components/common/Button";
|
|
import LoadingSpinner from "../components/common/LoadingSpinner";
|
|
|
|
export default function ComparisonView() {
|
|
const { comparisonId } = useParams<{ comparisonId: string }>();
|
|
const navigate = useNavigate();
|
|
const { data: project, isLoading } = useProject(comparisonId);
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
|
|
const analyses = (project?.analyses || []).filter(
|
|
(a) => a.status === "completed",
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (analyses.length >= 2 && selectedIds.length === 0) {
|
|
setSelectedIds(analyses.slice(0, 2).map((a) => a.id));
|
|
}
|
|
}, [analyses, selectedIds.length]);
|
|
|
|
const toggleSelection = (id: string) => {
|
|
setSelectedIds((prev) =>
|
|
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
|
|
);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <LoadingSpinner size="lg" message="Loading comparison..." />;
|
|
}
|
|
|
|
if (!project) {
|
|
return (
|
|
<div className="text-center py-12">
|
|
<p className="text-red-500 mb-4">Project not found.</p>
|
|
<Button variant="secondary" onClick={() => navigate("/")}>
|
|
Back to Dashboard
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const selected = analyses.filter((a) => selectedIds.includes(a.id));
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold" style={{ color: "#1a1a2e" }}>
|
|
Compare Analyses
|
|
</h1>
|
|
<p className="text-gray-500 text-sm mt-1">
|
|
{project.name} -- Side-by-side heatmap comparison
|
|
</p>
|
|
</div>
|
|
|
|
{/* Selection */}
|
|
<Card>
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">
|
|
Select analyses to compare:
|
|
</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{analyses.map((a) => (
|
|
<button
|
|
key={a.id}
|
|
onClick={() => toggleSelection(a.id)}
|
|
className={`px-3 py-1.5 rounded-lg text-sm border transition-colors ${
|
|
selectedIds.includes(a.id)
|
|
? "border-[#ffc407] bg-[#ffc407]/10 text-[#ffc407] font-medium"
|
|
: "border-gray-300 text-gray-600 hover:border-gray-400"
|
|
}`}
|
|
>
|
|
{a.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Side-by-side view */}
|
|
{selected.length >= 2 ? (
|
|
<div
|
|
className="grid gap-6"
|
|
style={{
|
|
gridTemplateColumns: `repeat(${Math.min(selected.length, 3)}, 1fr)`,
|
|
}}
|
|
>
|
|
{selected.map((analysis) => (
|
|
<Card key={analysis.id} padding={false} className="overflow-hidden">
|
|
<div className="p-3 border-b border-gray-100">
|
|
<h4
|
|
className="font-medium text-sm truncate"
|
|
style={{ color: "#1a1a2e" }}
|
|
>
|
|
{analysis.name}
|
|
</h4>
|
|
<p className="text-xs text-gray-400">{analysis.model}</p>
|
|
</div>
|
|
<div className="aspect-video bg-gray-100">
|
|
<AuthImage
|
|
src={getAnalysisImageUrl(analysis.id, "heatmap")}
|
|
alt={`Heatmap: ${analysis.name}`}
|
|
className="w-full h-full object-contain"
|
|
/>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card className="text-center py-8">
|
|
<p className="text-gray-400">
|
|
Select at least 2 completed analyses to compare.
|
|
</p>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Comparison table */}
|
|
{selected.length >= 2 && (
|
|
<Card>
|
|
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">
|
|
Comparison Metrics
|
|
</h3>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200">
|
|
<th className="text-left py-2 px-3 text-gray-500 font-medium">
|
|
Metric
|
|
</th>
|
|
{selected.map((a) => (
|
|
<th
|
|
key={a.id}
|
|
className="text-right py-2 px-3 text-gray-500 font-medium"
|
|
>
|
|
{a.name}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr className="border-b border-gray-100">
|
|
<td className="py-2 px-3 text-gray-700">Model</td>
|
|
{selected.map((a) => (
|
|
<td key={a.id} className="py-2 px-3 text-right text-gray-600">
|
|
{a.model}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
<tr className="border-b border-gray-100">
|
|
<td className="py-2 px-3 text-gray-700">Status</td>
|
|
{selected.map((a) => (
|
|
<td key={a.id} className="py-2 px-3 text-right">
|
|
<span className="text-green-600 font-medium">
|
|
{a.status}
|
|
</span>
|
|
</td>
|
|
))}
|
|
</tr>
|
|
<tr className="border-b border-gray-100">
|
|
<td className="py-2 px-3 text-gray-700">Date</td>
|
|
{selected.map((a) => (
|
|
<td key={a.id} className="py-2 px-3 text-right text-gray-600">
|
|
{new Date(a.created_at).toLocaleDateString()}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|