Files
craftvia/src/components/lotse/voice-note-client.tsx
T

164 lines
7.5 KiB
TypeScript

"use client";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { CheckCircle2, Compass, Loader2, MinusCircle, Pencil, Sparkles, XCircle, type LucideIcon } from "lucide-react";
import { LOTSE_IDLE, type LotseActionState } from "@/lib/lotse/action-state";
import { cn } from "@/lib/utils";
import { adoptSummaryAction, saveTranscriptAction, summarizeVoiceNoteAction } from "@/server/actions/lotse/assist";
type Status = "pending" | "done" | "failed" | "disabled";
const STATUS: Record<Status, { icon: LucideIcon; cls: string }> = {
pending: { icon: Loader2, cls: "bg-[color-mix(in_oklch,var(--info)_12%,transparent)] text-[var(--info)]" },
done: { icon: CheckCircle2, cls: "bg-[color-mix(in_oklch,var(--ok)_12%,transparent)] text-[var(--ok)]" },
failed: { icon: XCircle, cls: "bg-[color-mix(in_oklch,var(--risk)_12%,transparent)] text-[var(--risk)]" },
disabled: { icon: MinusCircle, cls: "bg-muted text-muted-foreground" },
};
const btnOutline =
"inline-flex min-h-12 items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 font-heading text-[15px] font-semibold text-primary hover:bg-muted disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50";
function ErrorLine({ state }: { state: LotseActionState }) {
const t = useTranslations("lotse");
if (state.status !== "error") return null;
return (
<p role="alert" className="flex items-center gap-2 text-[13.5px] font-semibold text-[var(--risk)]">
<XCircle className="size-4 shrink-0" aria-hidden />
{t(`errors.${state.code}`)}
</p>
);
}
export function VoiceNoteLotse({
voiceNoteId,
status,
transcript,
canEdit,
canSummarize,
latestSummary,
}: {
voiceNoteId: string;
status: Status;
transcript: string | null;
canEdit: boolean;
canSummarize: boolean;
latestSummary: { generationId: string; summary: string } | null;
}) {
const t = useTranslations("lotse");
const router = useRouter();
// timestamps instead of effects: a newer successful action result closes the editor / re-shows the summary
const [editingAt, setEditingAt] = useState<number | null>(null);
const [hiddenAt, setHiddenAt] = useState<number | null>(null);
const [saveState, save, saving] = useActionState(saveTranscriptAction, LOTSE_IDLE);
const [sumState, summarize, summarizing] = useActionState(summarizeVoiceNoteAction, LOTSE_IDLE);
const [adoptState, adopt, adopting] = useActionState(adoptSummaryAction, LOTSE_IDLE);
const S = STATUS[status];
const editing = editingAt !== null && !(saveState.status === "ok" && saveState.at > editingAt);
const hidden = hiddenAt !== null && !(sumState.status === "ok" && sumState.at > hiddenAt);
useEffect(() => {
if (saveState.status === "ok") router.refresh();
}, [saveState, router]);
useEffect(() => {
if (adoptState.status === "ok") router.refresh();
}, [adoptState, router]);
const summary = sumState.status === "ok" && sumState.summary ? { generationId: sumState.generationId ?? "", summary: sumState.summary } : latestSummary;
return (
<div className="mt-2 space-y-2">
<span className={cn("inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[13px] font-semibold", S.cls)}>
<S.icon className={cn("size-4", status === "pending" && "animate-spin")} aria-hidden />
{t(`voice.status.${status}`)}
</span>
{editing ? (
<form action={save} className="space-y-2">
<input type="hidden" name="voiceNoteId" value={voiceNoteId} />
<label htmlFor={`tr-${voiceNoteId}`} className="block text-[13px] font-semibold">
{t("voice.transcript")}
</label>
<textarea
id={`tr-${voiceNoteId}`}
name="transcript"
defaultValue={transcript ?? ""}
rows={5}
maxLength={10_000}
className="min-h-28 w-full rounded-xl border border-input bg-card px-3 py-2 text-base outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
<ErrorLine state={saveState} />
<div className="flex gap-2">
<button type="submit" disabled={saving} className={cn(btnOutline, "flex-1 border-primary bg-primary text-primary-foreground hover:bg-primary/90")}>
{t("voice.save")}
</button>
<button type="button" onClick={() => setEditingAt(null)} className={cn(btnOutline, "flex-1")}>
{t("voice.cancel")}
</button>
</div>
</form>
) : (
<>
{transcript ? (
<p className="whitespace-pre-line text-[15px]">{transcript}</p>
) : (
status !== "pending" && <p className="text-[14px] text-muted-foreground">{t("voice.noTranscript")}</p>
)}
{saveState.status === "ok" && <p role="status" className="text-[13px] font-semibold text-[var(--ok)]">{t("voice.saved")}</p>}
<div className="flex flex-col gap-2 sm:flex-row">
{canEdit && status !== "pending" && (
<button type="button" onClick={() => setEditingAt(Date.now())} className={cn(btnOutline, "flex-1")}>
<Pencil className="size-4" aria-hidden />
{t("voice.edit")}
</button>
)}
{canSummarize && transcript && (
<form action={summarize} className="flex flex-1">
<input type="hidden" name="voiceNoteId" value={voiceNoteId} />
<button type="submit" disabled={summarizing} className={cn(btnOutline, "flex-1")}>
{summarizing ? <Loader2 className="size-4 animate-spin" aria-hidden /> : <Compass className="size-4 text-[var(--ui-accent)]" aria-hidden />}
{summarizing ? t("voice.summarizing") : t("voice.summarize")}
</button>
</form>
)}
</div>
<ErrorLine state={sumState} />
</>
)}
{summary && !hidden && (
<div className="space-y-2 rounded-xl border border-dashed border-[var(--ui-accent)] bg-[color-mix(in_oklch,var(--ui-accent)_6%,transparent)] p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[13.5px] font-semibold">{t("voice.summaryTitle")}</p>
<span className="inline-flex items-center gap-1 text-[12px] font-semibold">
<Sparkles className="size-3.5 text-[var(--ui-accent)]" aria-hidden />
{t("suggestionBadge")}
</span>
</div>
<p className="whitespace-pre-line text-[15px]">{summary.summary}</p>
{adoptState.status === "ok" ? (
<p role="status" className="text-[13px] font-semibold text-[var(--ok)]">{t("voice.adopted")}</p>
) : (
<div className="flex flex-col gap-2 sm:flex-row">
{canEdit && summary.generationId && (
<form action={adopt} className="flex flex-1">
<input type="hidden" name="generationId" value={summary.generationId} />
<button type="submit" disabled={adopting} className={cn(btnOutline, "flex-1")}>
{t("voice.adopt")}
</button>
</form>
)}
<button type="button" onClick={() => setHiddenAt(Date.now())} className={cn(btnOutline, "flex-1 text-foreground")}>
{t("voice.dismiss")}
</button>
</div>
)}
<ErrorLine state={adoptState} />
</div>
)}
</div>
);
}