Replace try/catch with isNaN guard in the HTML exporter's formatTimestamp, matching the same cleanup applied client-side. Downgrade the export button from btn-primary to btn-secondary so it doesn't compete visually with the main content area. The primary blue gradient was overly prominent for a utility action. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
114 lines
3.8 KiB
TypeScript
114 lines
3.8 KiB
TypeScript
import React, { useState, useEffect, useRef } from "react";
|
|
import type { SessionDetailResponse } from "../lib/types";
|
|
|
|
interface Props {
|
|
session: SessionDetailResponse;
|
|
visibleMessageUuids: string[];
|
|
redactedMessageUuids: string[];
|
|
autoRedactEnabled: boolean;
|
|
}
|
|
|
|
export function ExportButton({
|
|
session,
|
|
visibleMessageUuids,
|
|
redactedMessageUuids,
|
|
autoRedactEnabled,
|
|
}: Props) {
|
|
const [state, setState] = useState<"idle" | "exporting" | "success" | "error">("idle");
|
|
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
};
|
|
}, []);
|
|
|
|
async function handleExport() {
|
|
setState("exporting");
|
|
try {
|
|
const res = await fetch("/api/export", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
session,
|
|
visibleMessageUuids,
|
|
redactedMessageUuids,
|
|
autoRedactEnabled,
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error(`Export failed: HTTP ${res.status}`);
|
|
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `session-${session.id}.html`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
setState("success");
|
|
timerRef.current = setTimeout(() => setState("idle"), 2000);
|
|
} catch (err) {
|
|
console.error("Export failed:", err);
|
|
setState("error");
|
|
timerRef.current = setTimeout(() => setState("idle"), 3000);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<button
|
|
onClick={handleExport}
|
|
disabled={state === "exporting"}
|
|
className={`
|
|
btn btn-sm flex-shrink-0 gap-1.5 transition-all duration-200
|
|
${state === "success" ? "text-white shadow-glow-success" : ""}
|
|
${state === "error" ? "text-white" : ""}
|
|
${state === "idle" || state === "exporting" ? "btn-secondary" : ""}
|
|
`}
|
|
style={
|
|
state === "success"
|
|
? { background: "linear-gradient(135deg, #22c55e, #16a34a)" }
|
|
: state === "error"
|
|
? { background: "linear-gradient(135deg, #dc2626, #b91c1c)" }
|
|
: undefined
|
|
}
|
|
>
|
|
{state === "idle" && (
|
|
<>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
|
</svg>
|
|
Export
|
|
</>
|
|
)}
|
|
{state === "exporting" && (
|
|
<>
|
|
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
</svg>
|
|
Exporting...
|
|
</>
|
|
)}
|
|
{state === "success" && (
|
|
<>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
</svg>
|
|
Downloaded
|
|
</>
|
|
)}
|
|
{state === "error" && (
|
|
<>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
|
</svg>
|
|
Failed
|
|
</>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|