import { useI18n } from "@/i18n/I18nProvider";
import type { TKey } from "@/i18n/dict";
import { dateFnsLocale } from "@/lib/dateFns";
import type { Severity, WorkerEvent } from "@/types";
import { formatDistanceToNow } from "date-fns";
import { Check, Clipboard, ClipboardCheck, Undo2 } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
const SEV_COLOR: Record<Severity, string> = {
info: "text-muted-foreground border-muted",
suggest: "text-cyan-700 border-cyan-300 bg-cyan-50",
warn: "text-yellow-700 border-yellow-300 bg-yellow-50",
block: "text-red-700 border-red-300 bg-red-50",
};
type Props = {
event: WorkerEvent;
onAck?: (event: WorkerEvent) => void;
onUnack?: (event: WorkerEvent) => void;
};
/**
* Card layout for the Inbox view: header row with project + severity +
* time + actions, body row with derived title + full advice text. The
* full body spans the card width for easier reading.
*/
export default function InboxCard({ event, onAck, onUnack }: Props) {
const { t, lang } = useI18n();
const [copied, setCopied] = useState(false);
const text = extractText(event) ?? "";
const title = deriveTitle(text);
const acked = !!event.acked_at;
const sevKey = `severity.${event.severity}` as TKey;
const ts = formatDistanceToNow(new Date(event.created_at), {
addSuffix: true,
locale: dateFnsLocale(lang),
});
async function handleCopy(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* clipboard blocked */
}
}
return (
<Link
to={`/dashboard/projects/${encodeURIComponent(event.project)}`}
className={`block border-b last:border-b-0 hover:bg-muted/30 ${
acked ? "opacity-60" : ""
}`}
>
<article className="px-4 py-3 space-y-2">
{/* Header row: severity · project · path | time + actions */}
<header className="flex items-center justify-between gap-3 text-xs">
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<span
className={`uppercase font-semibold border rounded px-1.5 py-0.5 ${SEV_COLOR[event.severity]}`}
>
{t(sevKey)}
</span>
<span className="font-medium text-foreground truncate">
{event.project}
</span>
{event.path && (
<>
<span className="text-muted-foreground">·</span>
<span className="font-mono text-muted-foreground truncate">
{event.path}
</span>
</>
)}
{acked && (
<span className="uppercase text-green-600 border border-green-300 rounded px-1">
{t("event.acked")}
{event.acked_by ? ` ${t("event.acked.by", { who: event.acked_by })}` : ""}
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<span className="text-muted-foreground whitespace-nowrap">{ts}</span>
{text && (
<button
type="button"
onClick={handleCopy}
title={t("event.action.copy")}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
>
{copied ? (
<ClipboardCheck size={13} className="text-green-600" />
) : (
<Clipboard size={13} />
)}
</button>
)}
{onAck && !acked && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onAck(event);
}}
title={t("event.action.ack")}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
>
<Check size={13} />
</button>
)}
{onUnack && acked && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onUnack(event);
}}
title={t("event.action.unack")}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
>
<Undo2 size={13} />
</button>
)}
</div>
</header>
{/* Body — title + full text, spans full card width */}
{text && (
<div className="space-y-1">
{title && (
<div className={`text-xs font-medium ${acked ? "line-through" : ""}`}>
{title}
</div>
)}
{text !== title && (
<p className="text-xs text-muted-foreground leading-relaxed whitespace-pre-wrap">
{text}
</p>
)}
</div>
)}
</article>
</Link>
);
}
function extractText(ev: WorkerEvent): string | null {
const p = ev.payload as Record<string, unknown>;
if (typeof p?.text === "string") return p.text;
if (typeof p?.error === "string") return `error: ${p.error}`;
return null;
}
/** Use the first sentence (up to 80 chars) of the advice as a title. */
function deriveTitle(text: string): string {
if (!text) return "";
const oneLine = text.split(/\n/, 1)[0]?.trim() ?? "";
// Stop at first sentence end if it's short enough; otherwise truncate.
const sentenceEnd = oneLine.search(/[.!?。!?]\s|$/);
const candidate = sentenceEnd > 0 ? oneLine.slice(0, sentenceEnd + 1) : oneLine;
return candidate.length > 80 ? `${candidate.slice(0, 80)}…` : candidate;
}