"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(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 (

{letters.length === 0 ? (emptyHint ?? "Nothing written yet.") : `${letters.length} ${letters.length === 1 ? "letter" : "letters"}`}

{LETTER_KINDS.map((kind) => ( create(kind)}> {LETTER_LABEL[kind]} ))}
{letters.length === 0 ? ( ) : (
    {letters.map((letter) => (
  • ))}
{open && ( remove(open.id)} busy={pending} /> )}
)}
); } function EmptyState({ compact }: { compact: boolean }) { return (

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.

{!compact && ( )}
); } 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(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) => { const next = { ...values, ...patch }; setValues(next); save.push(next); }; return (
{save.state === "conflict" && }
{/* 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. */}
set({ title: event.target.value })} placeholder={LETTER_LABEL[values.kind]} />
set({ recipient: event.target.value })} placeholder="Priya, engineering manager" />
set({ sentAt })} ariaLabel="Date sent" placeholder="Still a draft" />