src/components/letters/letters-panel.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import { useRef, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { FileSignatureIcon, PlusIcon, PrinterIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import type { LetterKind } from "@prisma/client";
import { LETTER_KINDS, LETTER_LABEL, LETTER_PLACEHOLDER } from "@/lib/letter-kinds";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { DateField } from "@/components/ui/date-field";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { SaveIndicator } from "@/components/save-indicator";
import { useAutosave } from "@/hooks/use-autosave";
import { StaleNotice } from "@/components/stale-notice";
import { useViewerZone } from "@/components/viewer-zone";
import { civilDay, shortCivilDay } from "@/lib/time";
import { cn } from "@/lib/utils";
import { createLetterAction, deleteLetterAction, updateLetterAction } from "@/server/actions";
/**
* Everything you write that is not a resume, and the editor for it.
*
* One component, two homes: the Letters tab under Me holds the whole library,
* and the same thing renders on an opened application filtered to that job.
* A letter is prose, so the editor is a textarea that autosaves — there is no
* document schema here and there should not be one.
*
* The list stays beside the editor rather than behind a route because the
* reason to open this screen is usually "what did I already say to them", and
* that is answered by reading two letters next to each other.
*/
export type LetterRow = {
id: string;
kind: LetterKind;
title: string;
body: string;
recipient: string;
sentAt: string | null;
updatedAt: string;
application: { id: string; company: string; roleTitle: string } | null;
contact: { id: string; name: string } | null;
};
export function LettersPanel({
letters,
applicationId,
compact = false,
emptyHint,
}: {
letters: LetterRow[];
/** Set on an application: every letter written here is filed under that job. */
applicationId?: string;
compact?: boolean;
emptyHint?: string;
}) {
const zone = useViewerZone();
const router = useRouter();
const [openId, setOpenId] = useState<string | null>(letters[0]?.id ?? null);
const [pending, start] = useTransition();
const open = letters.find((letter) => letter.id === openId) ?? null;
const create = (kind: LetterKind) =>
start(async () => {
try {
const id = await createLetterAction({ kind, applicationId: applicationId ?? null, body: "" });
setOpenId(id);
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not start that.");
}
});
const remove = (id: string) =>
start(async () => {
try {
await deleteLetterAction(id);
if (openId === id) setOpenId(null);
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not delete that.");
}
});
return (
<div className={cn("@container", compact ? "space-y-3" : "space-y-4")}>
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground text-xs">
{letters.length === 0
? (emptyHint ?? "Nothing written yet.")
: `${letters.length} ${letters.length === 1 ? "letter" : "letters"}`}
</p>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant={compact ? "outline" : "default"} disabled={pending}>
<PlusIcon /> Write one
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{LETTER_KINDS.map((kind) => (
<DropdownMenuItem key={kind} onSelect={() => create(kind)}>
{LETTER_LABEL[kind]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{letters.length === 0 ? (
<EmptyState compact={compact} />
) : (
<div
className={cn(
"grid gap-4",
compact ? "grid-cols-1" : "@min-[52rem]:grid-cols-[18rem_minmax(0,1fr)]",
)}
>
<ul className={cn("space-y-1", compact && "max-h-56 overflow-y-auto")}>
{letters.map((letter) => (
<li key={letter.id}>
<button
type="button"
onClick={() => setOpenId(letter.id === openId ? null : letter.id)}
className={cn(
"hover:bg-muted/60 w-full rounded-md px-2.5 py-2 text-left transition-colors",
letter.id === openId && "bg-muted",
)}
>
<div className="flex items-center gap-2">
<span className="truncate text-[13px] font-medium">
{letter.title || LETTER_LABEL[letter.kind]}
</span>
{letter.sentAt === null && (
<Badge variant="outline" className="text-[10px]">
Draft
</Badge>
)}
</div>
<div className="text-faint mt-0.5 truncate text-[11px]">
{[
LETTER_LABEL[letter.kind],
letter.application?.company,
letter.contact?.name,
shortCivilDay(civilDay(new Date(letter.updatedAt), zone)),
]
.filter(Boolean)
.join(" · ")}
</div>
</button>
</li>
))}
</ul>
{open && (
<LetterEditor
key={open.id}
letter={open}
showLinks={!applicationId}
onDelete={() => remove(open.id)}
busy={pending}
/>
)}
</div>
)}
</div>
);
}
function EmptyState({ compact }: { compact: boolean }) {
return (
<div className="border-border/70 rounded-lg border border-dashed px-4 py-6 text-center">
<FileSignatureIcon className="text-faint mx-auto mb-2 size-5" />
<p className="text-muted-foreground mx-auto max-w-sm text-xs">
Cover letters, cold messages, referral asks and thank-yous live here. The quickest way to
get a good one is to ask Claude: it reads the posting, your own material and the letters
you have already written before it drafts anything.
</p>
{!compact && (
<Button asChild variant="link" size="sm" className="mt-1">
<Link href="/docs">How to connect it</Link>
</Button>
)}
</div>
);
}
function LetterEditor({
letter,
showLinks,
onDelete,
busy,
}: {
letter: LetterRow;
showLinks: boolean;
onDelete: () => void;
busy: boolean;
}) {
const [values, setValues] = useState({
kind: letter.kind,
title: letter.title,
recipient: letter.recipient,
body: letter.body,
sentAt: letter.sentAt ?? "",
});
// Letters keep no versions, so this check is the only thing between an
// open panel and an assistant's update_letter being typed over.
const version = useRef(letter.updatedAt);
const save = useAutosave<typeof values>(async (next) => {
const result = await updateLetterAction(
letter.id,
{
kind: next.kind,
title: next.title,
recipient: next.recipient,
body: next.body,
sentAt: next.sentAt,
},
version.current,
);
if ("updatedAt" in result) version.current = result.updatedAt;
return result;
});
const set = (patch: Partial<typeof values>) => {
const next = { ...values, ...patch };
setValues(next);
save.push(next);
};
return (
<div className="min-w-0 space-y-3">
{save.state === "conflict" && <StaleNotice what="letter" />}
<div className="flex items-center justify-between gap-3">
<SaveIndicator state={save.state} />
<div className="flex items-center gap-1">
{/* The print page rather than /api/letters/[id]/pdf, which is what the
resume editor links to when the host has a Chromium. That editor
is rendered by a server page and is handed the answer; this panel
is inside a client component two levels down, so knowing it would
mean threading a boolean through the application detail for one
icon. The print page works on every host and its own button says
"Save as PDF", so the worst case is one extra keystroke. The
server-rendered file is a tool call away — export_letter_pdf. */}
<Button
asChild
variant="ghost"
size="icon-sm"
className="text-faint hover:text-foreground"
>
<a
href={`/print/letter/${letter.id}`}
target="_blank"
rel="noreferrer"
aria-label="Print this letter or save it as a PDF"
>
<PrinterIcon />
</a>
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-faint hover:text-destructive"
onClick={onDelete}
disabled={busy}
aria-label="Delete this letter"
>
<Trash2Icon />
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-3 @min-[34rem]:grid-cols-2">
<div className="space-y-1.5">
<Label>Title</Label>
<Input
value={values.title}
onChange={(event) => set({ title: event.target.value })}
placeholder={LETTER_LABEL[values.kind]}
/>
</div>
<div className="space-y-1.5">
<Label>To</Label>
<Input
value={values.recipient}
onChange={(event) => set({ recipient: event.target.value })}
placeholder="Priya, engineering manager"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-3 @min-[34rem]:grid-cols-2">
<div className="space-y-1.5">
<Label>Kind</Label>
<Select value={values.kind} onValueChange={(kind) => set({ kind: kind as LetterKind })}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LETTER_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{LETTER_LABEL[kind]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Sent</Label>
<DateField
value={values.sentAt}
onChange={(sentAt) => set({ sentAt })}
ariaLabel="Date sent"
placeholder="Still a draft"
/>
</div>
</div>
<div className="space-y-1.5">
<Label>The letter</Label>
<Textarea
value={values.body}
onChange={(event) => set({ body: event.target.value })}
placeholder={LETTER_PLACEHOLDER[values.kind]}
className="min-h-72 leading-relaxed"
/>
</div>
{showLinks && (letter.application || letter.contact) && (
<p className="text-faint text-xs">
Filed under{" "}
{letter.application && (
<Link href={`/applications/${letter.application.id}`} className="hover:underline">
{letter.application.company} — {letter.application.roleTitle}
</Link>
)}
{letter.application && letter.contact && " · "}
{letter.contact && (
<Link href={`/crm/contacts/${letter.contact.id}`} className="hover:underline">
{letter.contact.name}
</Link>
)}
</p>
)}
</div>
);
}