164 lines
7.5 KiB
TypeScript
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>
|
|
);
|
|
}
|