Files
session-viewer/src/client/components/ExportButton.tsx
teernisse bba678568a Polish: simplify formatTimestamp and tone down export button
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>
2026-01-30 09:26:35 -05:00

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