feat(web): letters on the editor shell, with drafting, History and both files to download

This commit is contained in:
Amruth Pillai
2026-09-28 23:08:10 +02:00
parent aa9a5113c0
commit 82fa327900
39 changed files with 2899 additions and 1074 deletions
@@ -8,8 +8,7 @@ import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const mocks = vi.hoisted(() => ({ draft: vi.fn(), other: vi.fn() }));
type MockEditorDialogProps = { letterId: string };
const mocks = vi.hoisted(() => ({ draft: vi.fn(), other: vi.fn(), navigate: vi.fn() }));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
applications: {
@@ -22,9 +21,12 @@ vi.mock("@/libs/orpc/client", () => ({
coverLetters: { list: { key: () => ["cover-letters"] } },
},
}));
vi.mock("@/features/cover-letters/editor-dialog", () => ({
CoverLetterEditorDialog: ({ letterId }: MockEditorDialogProps) => <div role="dialog">Saved letter {letterId}</div>,
}));
vi.mock("@tanstack/react-router", () => ({ useNavigate: () => mocks.navigate }));
const opened = (coverLetterId: string) =>
waitFor(() =>
expect(mocks.navigate).toHaveBeenCalledWith({ to: "/builder/letter/$coverLetterId", params: { coverLetterId } }),
);
const { ApplicationAiCopilot } = await import("./application-ai-copilot");
const application: Application = {
@@ -38,6 +40,7 @@ const application: Application = {
closedReason: null,
coverLetterId: null,
sentResumeVersionId: null,
sentCoverLetterVersionId: null,
sentCheckScore: null,
requirements: [],
resumeId: null,
@@ -102,7 +105,7 @@ it.each(["cover-letter", "follow-up"] as const)(
kind === "cover-letter" ? { text: "Letter", coverLetterId: "letter-one" } : { text: "Follow-up" },
),
);
if (kind === "cover-letter") expect(await screen.findByRole("dialog")).toHaveTextContent("letter-one");
if (kind === "cover-letter") await opened("letter-one");
else expect(await screen.findByText("Follow-up")).toBeVisible();
expect(coverLetter).toBeEnabled();
expect(followUp).toBeEnabled();
@@ -125,6 +128,6 @@ it("prevents duplicate saved letters after an earlier follow-up completed", asyn
await userEvent.click(coverLetter);
expect(mocks.draft).toHaveBeenCalledTimes(2);
await act(async () => request.resolve({ text: "Saved letter", coverLetterId: "letter-two" }));
expect(await screen.findByRole("dialog")).toHaveTextContent("letter-two");
await opened("letter-two");
expect(screen.queryByText("Earlier follow-up")).not.toBeInTheDocument();
});
@@ -12,10 +12,10 @@ import {
SpinnerGapIcon,
} from "@phosphor-icons/react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useCallback, useState } from "react";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { CoverLetterEditorDialog } from "@/features/cover-letters/editor-dialog";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
@@ -103,7 +103,7 @@ type Props = { application: Application };
export function ApplicationAiCopilot({ application }: Props) {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<{ kind: string; text: string } | null>(null);
const [coverLetterId, setCoverLetterId] = useState<string | null>(null);
const navigate = useNavigate();
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
@@ -132,8 +132,9 @@ export function ApplicationAiCopilot({ application }: Props) {
onSuccess: (result, variables) => {
if (result.coverLetterId) {
setDraft(null);
setCoverLetterId(result.coverLetterId);
void queryClient.invalidateQueries({ queryKey: orpc.coverLetters.list.key() });
// The saved letter opens in the letter editor.
void navigate({ to: "/builder/letter/$coverLetterId", params: { coverLetterId: result.coverLetterId } });
} else {
setDraft({ kind: variables.kind, text: result.text });
}
@@ -284,7 +285,6 @@ export function ApplicationAiCopilot({ application }: Props) {
</p>
</div>
)}
{coverLetterId && <CoverLetterEditorDialog letterId={coverLetterId} onClose={() => setCoverLetterId(null)} />}
</section>
);
}
@@ -15,8 +15,6 @@ import { PIPELINE } from "../../stages";
import { useInvalidateApplications } from "../../use-application-actions";
import { FileAttachmentField } from "../file-attachment-field";
const escapeHtml = (text: string) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
/** When the application was sent: its first stage at Applied or beyond. */
function sentOn(application: Application) {
const applied = PIPELINE.indexOf("applied");
@@ -52,17 +50,16 @@ export function SentDocuments({ application, disabled }: SentDocumentsProps) {
const letter = documents?.find((document) => document.type === "letter" && document.id === application.coverLetterId);
const date = sentOn(application).toLocaleDateString(i18n.locale, { month: "short", day: "numeric" });
// A structured letter: the server fills the recipient from the application and makes it the application's letter.
const writeLetter = async () => {
try {
const created = await createLetter.mutateAsync({
name: t`Cover letter — ${application.company}`.slice(0, 100),
recipient: `<p>${escapeHtml(t`Hiring team, ${application.company}`)}</p>`,
applicationId: application.id,
...(application.resumeId ? { resumeId: application.resumeId } : {}),
});
await update.mutateAsync({ id: application.id, coverLetterId: created.id });
toast.add({ description: t`Letter created with the recipient filled in` });
void navigate({ to: "/dashboard", search: { letter: created.id } });
invalidate(application.id);
void navigate({ to: "/builder/letter/$coverLetterId", params: { coverLetterId: created.id } });
} catch (error) {
toast.add({
type: "error",
@@ -113,12 +110,17 @@ export function SentDocuments({ application, disabled }: SentDocumentsProps) {
<div className="grid min-w-0 flex-1">
<span className="truncate font-medium text-sm">{letter?.name ?? t`Cover letter`}</span>
<span className="text-ink-3 text-xs">
<Trans>Cover letter</Trans>
{application.sentCoverLetterVersionId ? (
<Trans>Version sent {date}</Trans>
) : (
<Trans>Linked · not sent yet</Trans>
)}
</span>
</div>
<Link
to="/dashboard"
search={{ letter: application.coverLetterId }}
to="/builder/letter/$coverLetterId"
params={{ coverLetterId: application.coverLetterId }}
search={application.sentCoverLetterVersionId ? { version: application.sentCoverLetterVersionId } : {}}
className={buttonVariants({ size: "sm", variant: "secondary" })}
>
<Trans>Open</Trans>
@@ -11,6 +11,7 @@ const application: Application = {
closedReason: null,
coverLetterId: null,
sentResumeVersionId: null,
sentCoverLetterVersionId: null,
sentCheckScore: null,
requirements: [],
location: "Berlin",
@@ -1,398 +0,0 @@
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ClientOnly, useBlocker } from "@tanstack/react-router";
import { lazy, Suspense, useCallback, useId, useMemo, useRef, useState } from "react";
import { createCoverLetterResumeData } from "@reactive-resume/resume/cover-letter";
import { templateSchema } from "@reactive-resume/schema/templates";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { Label } from "@reactive-resume/ui/components/label";
import { Separator } from "@reactive-resume/ui/components/separator";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { toast } from "@reactive-resume/ui/components/toast";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { Combobox } from "@/components/ui/combobox";
import { templates } from "@/dialogs/resume/template/data";
import { useConfirm } from "@/hooks/use-confirm";
import { getReadableErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { CoverLetterEditor } from "./editor";
const PdfViewer = lazy(() =>
import("@/features/resume/public/pdf-viewer").then((module) => ({ default: module.PdfViewer })),
);
type CoverLetterEditorDialogProps = {
letterId: string;
onClose: () => void;
activeResumeId?: string;
resumeReady?: boolean;
};
export function CoverLetterEditorDialog({
letterId,
onClose,
activeResumeId,
resumeReady = true,
}: CoverLetterEditorDialogProps) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const [editState, setEditState] = useState({ dirty: false, pending: false });
const [reloadVersion, setReloadVersion] = useState(0);
const [busy, setBusy] = useState(false);
const running = useRef(false);
const query = useQuery(orpc.coverLetters.getById.queryOptions({ input: { id: letterId } }));
const onDirtyChange = useCallback((dirty: boolean, pending: boolean) => setEditState({ dirty, pending }), []);
const canClose = () => {
if (editState.pending || running.current) return false;
if (!editState.dirty) return true;
return confirm(t`Discard unsaved changes?`, {
description: t`Your saved cover letter will remain unchanged.`,
confirmText: t`Discard`,
cancelText: t`Keep editing`,
});
};
// Closing can navigate (Documents keeps the open letter in the URL); once decided, the blocker lets it through.
const closing = useRef(false);
const close = () => {
closing.current = true;
onClose();
};
const requestClose = async () => {
if (await canClose()) close();
};
useBlocker({
shouldBlockFn: async () => !closing.current && !(await canClose()),
enableBeforeUnload: editState.dirty || editState.pending || busy,
});
const remember = (letter: CoverLetter) => {
queryClient.setQueryData(orpc.coverLetters.getById.queryKey({ input: { id: letter.id } }), letter);
void queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
};
const run = async (action: () => Promise<void>) => {
if (running.current) return;
running.current = true;
setBusy(true);
try {
await action();
} catch (error) {
toast.add({
type: "error",
description: getReadableErrorMessage(error, t`Could not complete this action. Please try again.`),
});
} finally {
running.current = false;
setBusy(false);
}
};
const reload = async () => {
if (!(await canClose())) return;
await run(async () => {
remember(await orpc.coverLetters.getById.call({ id: letterId }));
setReloadVersion((version) => version + 1);
});
};
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) void requestClose();
}}
>
<DialogContent className="lg:max-w-3xl xl:max-w-4xl" showCloseButton={false}>
<DialogHeader>
<DialogTitle>
<Trans>Edit cover letter</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Changes are shared between your library and the resume builder.</Trans>
</DialogDescription>
</DialogHeader>
{query.isPending ? (
<Spinner />
) : query.error ? (
<div className="space-y-3">
<p role="alert">{getReadableErrorMessage(query.error, t`Could not load this cover letter.`)}</p>
<Button onClick={() => void query.refetch()}>
<Trans>Retry</Trans>
</Button>
<Button variant="secondary" onClick={close}>
<Trans>Close</Trans>
</Button>
</div>
) : (
<>
<CoverLetterEditor
key={`${letterId}:${reloadVersion}`}
letter={query.data}
disabled={busy}
onClose={() => void requestClose()}
onDirtyChange={onDirtyChange}
onSave={async (changes) => {
const updated = await orpc.coverLetters.update.call({ id: letterId, ...changes });
remember(updated);
return updated;
}}
actions={(letter, disabled) => (
<CoverLetterActions
letter={letter}
activeResumeId={activeResumeId}
resumeReady={resumeReady}
disabled={disabled || busy}
run={run}
onUpdated={remember}
onDeleted={close}
/>
)}
/>
<Button variant="ghost" disabled={editState.pending || busy} onClick={() => void reload()}>
<Trans>Reload latest version</Trans>
</Button>
</>
)}
</DialogContent>
</Dialog>
);
}
type CoverLetterActionsProps = {
letter: CoverLetter;
activeResumeId?: string;
resumeReady: boolean;
disabled: boolean;
run: (action: () => Promise<void>) => Promise<void>;
onUpdated: (letter: CoverLetter) => void;
onDeleted: () => void;
};
function CoverLetterActions({
letter,
activeResumeId,
resumeReady,
disabled,
run,
onUpdated,
onDeleted,
}: CoverLetterActionsProps) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const styleId = useId();
const templateId = useId();
const applicationId = useId();
const [resumeId, setResumeId] = useState<string | null>(letter.sourceResumeId);
const [selectedApplicationId, setSelectedApplicationId] = useState<string | null>(letter.sourceApplicationId);
const [preview, setPreview] = useState(false);
const sourceReady = resumeId !== activeResumeId || resumeReady;
const resumes = useQuery(orpc.resume.list.queryOptions({ input: {} }));
const applications = useQuery(orpc.applications.list.queryOptions({ input: { includeArchived: false } }));
const data = useMemo(() => createCoverLetterResumeData(letter), [letter]);
const templateOptions = templateSchema.options.map((value) => ({ value, label: templates[value].name }));
const createPdf = async () => {
const { createResumePdfBlob } = await import("@/features/resume/export/pdf-document");
return createResumePdfBlob(data, undefined, { includeCoverLetterHeader: true });
};
return (
<div className="space-y-4">
<Separator />
<fieldset disabled={disabled} className="min-w-0 space-y-4">
<div className="flex flex-wrap gap-2">
<Button type="button" variant="secondary" onClick={() => setPreview(!preview)}>
{preview ? <Trans>Hide preview</Trans> : <Trans>Preview PDF</Trans>}
</Button>
<Button
type="button"
variant="secondary"
onClick={() =>
void run(async () => downloadWithAnchor(await createPdf(), generateFilename(letter.name, "pdf")))
}
>
<Trans>Download PDF</Trans>
</Button>
<Button
type="button"
variant="secondary"
onClick={() =>
void run(async () => {
const document = await orpc.coverLetters.export.call({ id: letter.id });
downloadWithAnchor(
new Blob([JSON.stringify(document, null, 2)], { type: "application/json" }),
generateFilename(letter.name, "json"),
);
})
}
>
<Trans>Export JSON</Trans>
</Button>
<Button
type="button"
variant="secondary"
onClick={() =>
void run(async () => {
await orpc.coverLetters.duplicate.call({ id: letter.id });
await queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
toast.add({ type: "success", description: t`Copy saved to your cover-letter library.` });
})
}
>
<Trans>Duplicate</Trans>
</Button>
<Button
type="button"
variant="danger"
// Undoable from Trash (30 days), so it doesn't ask first. The editor closes once the action is done.
onClick={async () => {
let trashed = false;
await run(async () => {
await orpc.coverLetters.delete.call({ id: letter.id, expectedRevision: letter.revision });
await queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
toast.add({ description: t`“${letter.name}” moved to Trash` });
trashed = true;
});
if (trashed) onDeleted();
}}
>
<Trans>Move to Trash</Trans>
</Button>
</div>
<div className="space-y-2">
<Label htmlFor={templateId}>
<Trans>Template</Trans>
</Label>
<Combobox
id={templateId}
className="w-full"
disabled={disabled}
options={templateOptions}
value={letter.style.metadata.template}
onValueChange={(value) => {
const template = templateSchema.safeParse(value);
if (!template.success) return;
void run(async () => {
onUpdated(
await orpc.coverLetters.update.call({
id: letter.id,
expectedRevision: letter.revision,
template: template.data,
}),
);
});
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor={styleId}>
<Trans>Sender details</Trans>
</Label>
<div className="flex flex-wrap gap-2">
<Combobox
id={styleId}
className="min-w-48 flex-1"
disabled={disabled}
options={(resumes.data ?? []).map((resume) => ({ value: resume.id, label: resume.name }))}
value={resumeId}
onValueChange={setResumeId}
placeholder={t`Choose a resume`}
/>
<Button
type="button"
variant="secondary"
disabled={disabled || !resumeId || !sourceReady}
onClick={() =>
void run(async () => {
if (!resumeId || !sourceReady) return;
onUpdated(
await orpc.coverLetters.refreshStyle.call({
id: letter.id,
expectedRevision: letter.revision,
resumeId,
}),
);
toast.add({
type: "success",
description: t`Styling and sender details refreshed from the selected resume.`,
});
})
}
>
<Trans>Copy from resume</Trans>
</Button>
</div>
<p className="text-muted-foreground text-xs">
<Trans>Styling and sender details are copied. Future resume edits apply only when you refresh.</Trans>
</p>
{!sourceReady && (
<p role="status" className="text-muted-foreground text-sm">
<Trans>Save resume changes before copying its content or styling.</Trans>
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor={applicationId}>
<Trans>Application</Trans>
</Label>
<div className="flex flex-wrap gap-2">
<Combobox
id={applicationId}
className="min-w-48 flex-1"
disabled={disabled}
options={(applications.data ?? []).map((application) => ({
value: application.id,
label: `${application.company} — ${application.role}`,
}))}
value={selectedApplicationId}
onValueChange={setSelectedApplicationId}
placeholder={t`Choose an application`}
/>
<Button
type="button"
variant="secondary"
disabled={disabled || !selectedApplicationId}
onClick={() =>
void run(async () => {
if (!selectedApplicationId) return;
const application = applications.data?.find((item) => item.id === selectedApplicationId);
if (
application?.coverLetterUrl &&
!(await confirm(t`Replace the attached cover letter?`, { confirmText: t`Replace` }))
)
return;
const file = new File([await createPdf()], generateFilename(letter.name, "pdf"), {
type: "application/pdf",
});
await orpc.applications.attachDocument.call({
id: selectedApplicationId,
kind: "cover-letter",
file,
});
await queryClient.invalidateQueries({ queryKey: orpc.applications.key() });
toast.add({ type: "success", description: t`PDF snapshot attached to the application.` });
})
}
>
<Trans>Attach PDF</Trans>
</Button>
</div>
</div>
</fieldset>
{preview && (
<ClientOnly fallback={<Spinner />}>
<Suspense fallback={<Spinner />}>
<PdfViewer data={data} includeCoverLetterHeader />
</Suspense>
</ClientOnly>
)}
</div>
);
}
@@ -1,126 +0,0 @@
// @vitest-environment happy-dom
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import type { ReactNode } from "react";
import { cleanup, fireEvent, render as renderComponent, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { ORPCError } from "@orpc/client";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { CoverLetterEditor } from "./editor";
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
afterEach(cleanup);
type WrapperProps = { children: ReactNode };
function Wrapper({ children }: WrapperProps) {
return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
}
const render = (ui: ReactNode) => renderComponent(ui, { wrapper: Wrapper });
vi.mock("@/components/input/rich-input", () => ({
RichInput: ({
value,
onChange,
"aria-label": label,
editable,
}: {
value: string;
onChange: (value: string) => void;
"aria-label": string;
editable?: boolean;
}) => (
<textarea
aria-label={label}
value={value}
disabled={editable === false}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
const { notes: _notes, layout: _layout, ...metadata } = defaultResumeData.metadata;
const letter: CoverLetter = {
id: "letter-1",
name: "Engineer application",
recipient: "Hiring team",
content: "Original letter",
style: {
basics: defaultResumeData.basics,
picture: defaultResumeData.picture,
metadata,
sectionId: "section-1",
itemId: "item-1",
},
sourceResumeId: null,
sourceApplicationId: null,
revision: 3,
createdAt: new Date("2026-09-05T12:00:00Z"),
updatedAt: new Date("2026-09-05T12:00:00Z"),
};
describe("CoverLetterEditor", () => {
it("keeps edits after a failed save", async () => {
const onSave = vi.fn().mockRejectedValue(new Error("Save failed"));
render(<CoverLetterEditor letter={letter} onSave={onSave} onClose={() => {}} />);
fireEvent.change(screen.getByLabelText("Content"), { target: { value: "My unsaved draft" } });
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
await screen.findByRole("alert");
expect(screen.getByLabelText("Content")).toHaveValue("My unsaved draft");
expect(screen.getByRole("alert")).toHaveTextContent("Save failed");
});
it("saves current content with original revision and uses returned revision for the next edit", async () => {
const onSave = vi.fn().mockImplementation(async (changes) => ({ ...letter, ...changes, revision: 4 }));
render(<CoverLetterEditor letter={letter} onSave={onSave} onClose={() => {}} />);
fireEvent.change(screen.getByLabelText("Content"), { target: { value: "New letter" } });
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Save Changes" })).toBeDisabled());
fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Updated name" } });
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(2));
expect(onSave.mock.calls[0]?.[0]).toMatchObject({ content: "New letter", expectedRevision: 3 });
expect(onSave.mock.calls[1]?.[0]).toMatchObject({
name: "Updated name",
content: "New letter",
expectedRevision: 4,
});
});
it("keeps local input and revision when another editor changed the document", async () => {
const onSave = vi.fn().mockRejectedValue(new ORPCError("CONFLICT"));
const view = render(<CoverLetterEditor letter={letter} onSave={onSave} onClose={() => {}} />);
fireEvent.change(screen.getByLabelText("Content"), { target: { value: "Local draft" } });
view.rerender(
<CoverLetterEditor
letter={{ ...letter, content: "Other editor", revision: 4 }}
onSave={onSave}
onClose={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
await screen.findByRole("alert");
expect(screen.getByLabelText("Content")).toHaveValue("Local draft");
expect(onSave.mock.calls[0]?.[0]).toMatchObject({ expectedRevision: 3 });
expect(screen.getByRole("alert")).toHaveTextContent(/changed elsewhere/i);
});
it("disables editing and duplicate submission while saving", async () => {
let resolveSave!: (result: CoverLetter) => void;
const onSave = vi.fn(
() =>
new Promise<CoverLetter>((resolve) => {
resolveSave = resolve;
}),
);
render(<CoverLetterEditor letter={letter} onSave={onSave} onClose={() => {}} />);
fireEvent.change(screen.getByLabelText("Content"), { target: { value: "Pending letter" } });
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
expect(screen.getByLabelText("Name")).toBeDisabled();
expect(screen.getByLabelText("Content")).toBeDisabled();
fireEvent.click(screen.getByRole("button", { name: "Save Changes" }));
expect(onSave).toHaveBeenCalledTimes(1);
resolveSave({ ...letter, content: "Pending letter", revision: 4 });
await waitFor(() => expect(screen.getByLabelText("Name")).not.toBeDisabled());
});
});
@@ -1,147 +0,0 @@
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ORPCError } from "@orpc/client";
import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { RichInput } from "@/components/input/rich-input";
import { getReadableErrorMessage } from "@/libs/error-message";
type CoverLetterChanges = Pick<CoverLetter, "name" | "recipient" | "content"> & { expectedRevision: number };
export type CoverLetterEditorProps = {
letter: CoverLetter;
disabled?: boolean;
onSave: (changes: CoverLetterChanges) => Promise<CoverLetter>;
onClose: () => void;
onDirtyChange?: (dirty: boolean, pending: boolean) => void;
actions?: (letter: CoverLetter, disabled: boolean) => ReactNode;
};
const editableFields = (letter: CoverLetter) => ({
name: letter.name,
recipient: letter.recipient,
content: letter.content,
});
export function CoverLetterEditor({
letter,
disabled = false,
onSave,
onClose,
onDirtyChange,
actions,
}: CoverLetterEditorProps) {
const nameId = useId();
const [saved, setSaved] = useState(letter);
const [draft, setDraft] = useState(() => editableFields(letter));
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const saving = useRef(false);
const dirty = draft.name !== saved.name || draft.recipient !== saved.recipient || draft.content !== saved.content;
useEffect(() => {
onDirtyChange?.(dirty, pending);
}, [dirty, pending, onDirtyChange]);
useEffect(() => {
// Refresh saved styling only when no local edit would be overwritten.
if (!dirty && !pending && letter.revision > saved.revision) {
setSaved(letter);
setDraft(editableFields(letter));
}
}, [letter, saved.revision, dirty, pending]);
const save = async () => {
if (saving.current || disabled || !dirty) return;
if (!draft.name.trim()) {
setError(t`Enter a name for this cover letter.`);
return;
}
saving.current = true;
setPending(true);
setError("");
try {
const updated = await onSave({ ...draft, name: draft.name.trim(), expectedRevision: saved.revision });
setSaved(updated);
setDraft(editableFields(updated));
} catch (cause) {
setError(
cause instanceof ORPCError && cause.code === "CONFLICT"
? t`This cover letter changed elsewhere. Your edits are still here. Reload the latest version before saving again.`
: getReadableErrorMessage(cause, t`Could not save the cover letter. Please try again.`),
);
} finally {
saving.current = false;
setPending(false);
}
};
return (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
<fieldset disabled={pending || disabled} className="min-w-0 space-y-4">
<div className="space-y-2">
<Label htmlFor={nameId}>
<Trans>Name</Trans>
</Label>
<Input
id={nameId}
value={draft.name}
maxLength={100}
required
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
/>
</div>
<fieldset className="min-w-0 space-y-2">
<legend className="font-medium text-sm">
<Trans>Recipient</Trans>
</legend>
<RichInput
aria-label={t`Recipient`}
value={draft.recipient}
editable={!pending && !disabled}
onChange={(recipient) => setDraft((current) => ({ ...current, recipient }))}
/>
</fieldset>
<fieldset className="min-w-0 space-y-2">
<legend className="font-medium text-sm">
<Trans>Content</Trans>
</legend>
<RichInput
aria-label={t`Content`}
value={draft.content}
editable={!pending && !disabled}
onChange={(content) => setDraft((current) => ({ ...current, content }))}
/>
</fieldset>
</fieldset>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="secondary" disabled={pending || disabled} onClick={onClose}>
<Trans>Close</Trans>
</Button>
<Button type="submit" disabled={!dirty || pending || disabled}>
<Trans>Save Changes</Trans>
</Button>
</div>
{dirty && (
<p className="text-muted-foreground text-sm">
<Trans>Save changes before previewing, exporting, or changing styling.</Trans>
</p>
)}
{actions?.(saved, dirty || pending || disabled)}
</form>
);
}
@@ -17,7 +17,6 @@ import { ResumeThumbnail } from "./resume-thumbnail";
export type DocumentItemProps = {
document: DocumentSummary;
onOpenLetter: (id: string) => void;
onTags: (document: DocumentSummary) => void;
onLink: (document: DocumentSummary) => void;
};
@@ -31,46 +30,28 @@ function useDocumentMeta(document: DocumentSummary) {
return t`${type} · Edited ${formatRelativeTime(document.updatedAt, formatter)}`;
}
/** Opens the document: resumes in the editor, letters in the letter editor. */
/** Opens the document in its editor: resumes and letters share the editor shell. */
function OpenLink({
document,
onOpenLetter,
className,
children,
label,
}: Pick<DocumentItemProps, "document" | "onOpenLetter"> & {
}: Pick<DocumentItemProps, "document"> & {
className?: string;
children: React.ReactNode;
label?: string;
}) {
const markOpened = useNewDocumentsStore((state) => state.markOpened);
const common = { "aria-label": label, className, onClick: () => markOpened(document.id) };
if (document.type === "resume") {
return (
<Link
to="/builder/$resumeId"
params={{ resumeId: document.id }}
aria-label={label}
className={className}
onClick={() => markOpened(document.id)}
>
{children}
</Link>
);
}
return (
<button
type="button"
aria-label={label}
className={className}
onClick={() => {
markOpened(document.id);
onOpenLetter(document.id);
}}
>
return document.type === "resume" ? (
<Link to="/builder/$resumeId" params={{ resumeId: document.id }} {...common}>
{children}
</button>
</Link>
) : (
<Link to="/builder/letter/$coverLetterId" params={{ coverLetterId: document.id }} {...common}>
{children}
</Link>
);
}
@@ -122,8 +103,8 @@ function LetterThumbnail({ name }: { name: string }) {
}
/** A 204px card: the real first page, title with ⋯, "Resume · Edited 2h ago" and the linked application. */
export function DocumentCard({ document, onOpenLetter, onTags, onLink }: DocumentItemProps) {
const openDocument = useOpenDocument(onOpenLetter);
export function DocumentCard({ document, onTags, onLink }: DocumentItemProps) {
const openDocument = useOpenDocument();
const [renaming, setRenaming] = useState(false);
const isNew = useNewDocumentsStore((state) => state.ids.includes(document.id)) && !document.trashedAt;
const meta = useDocumentMeta(document);
@@ -142,7 +123,6 @@ export function DocumentCard({ document, onOpenLetter, onTags, onLink }: Documen
>
<OpenLink
document={document}
onOpenLetter={onOpenLetter}
label={document.name}
className={cn(
"relative block aspect-page overflow-hidden rounded-[6px] shadow-[0_0_0_1px_var(--line),var(--shadow-1)] transition-[transform,box-shadow] duration-quick hover:-translate-y-0.5 hover:shadow-[0_0_0_1px_var(--line),var(--shadow-2)]",
@@ -208,9 +188,9 @@ export function DocumentCard({ document, onOpenLetter, onTags, onLink }: Documen
}
/** The list view's row: Name, Type, Application, Edited, ⋯. */
export function DocumentRow({ document, onOpenLetter, onTags, onLink }: DocumentItemProps) {
export function DocumentRow({ document, onTags, onLink }: DocumentItemProps) {
const { i18n } = useLingui();
const openDocument = useOpenDocument(onOpenLetter);
const openDocument = useOpenDocument();
const [renaming, setRenaming] = useState(false);
const isNew = useNewDocumentsStore((state) => state.ids.includes(document.id)) && !document.trashedAt;
const formatter = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
@@ -242,7 +222,6 @@ export function DocumentRow({ document, onOpenLetter, onTags, onLink }: Document
) : (
<OpenLink
document={document}
onOpenLetter={onOpenLetter}
className="min-w-0 truncate text-start font-semibold text-sm hover:underline"
>
{document.name}
@@ -289,11 +268,14 @@ export function DocumentRow({ document, onOpenLetter, onTags, onLink }: Document
}
/** Open from a menu: resumes in the editor, letters in the letter editor. */
function useOpenDocument(onOpenLetter: (id: string) => void) {
function useOpenDocument() {
const navigate = useNavigate();
return (document: DocumentSummary) => {
useNewDocumentsStore.getState().markOpened(document.id);
if (document.type === "letter") return onOpenLetter(document.id);
if (document.type === "letter") {
void navigate({ to: "/builder/letter/$coverLetterId", params: { coverLetterId: document.id } });
return;
}
void navigate({ to: "/builder/$resumeId", params: { resumeId: document.id } });
};
}
@@ -12,7 +12,6 @@ import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/comp
import { Tabs, TabsCount, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { cn } from "@reactive-resume/utils/style";
import { useDialogStore } from "@/dialogs/store";
import { CoverLetterEditorDialog } from "@/features/cover-letters/editor-dialog";
import { isEditableElementFocused } from "@/features/resume/builder/draft";
import { orpc } from "@/libs/orpc/client";
import { LinkApplicationDialog, TagsDialog } from "./document-actions";
@@ -26,8 +25,6 @@ export type DocumentsSearch = {
tags: string[];
sort: DocumentSort;
view?: "grid" | "list" | undefined;
/** A letter open in the letter editor. */
letter?: string | undefined;
};
type DocumentsPageProps = {
@@ -67,7 +64,6 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
};
const filtered = search.q.trim() !== "" || search.tags.length > 0;
const itemProps = {
onOpenLetter: (id: string) => onSearchChange({ letter: id }),
onTags: setTagsFor,
onLink: setLinkFor,
};
@@ -245,9 +241,6 @@ export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
<DropToImport />
<TagsDialog document={tagsFor} onClose={() => setTagsFor(null)} />
<LinkApplicationDialog document={linkFor} onClose={() => setLinkFor(null)} />
{search.letter && (
<CoverLetterEditorDialog letterId={search.letter} onClose={() => onSearchChange({ letter: undefined })} />
)}
</div>
);
}
@@ -64,9 +64,9 @@ export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | und
closeDialog();
void navigate({ to: "/builder/$resumeId", params: { resumeId } });
};
const openLetter = (letterId: string) => {
const openLetter = (coverLetterId: string) => {
closeDialog();
void navigate({ to: "/dashboard", search: { type: "letter", letter: letterId } });
void navigate({ to: "/builder/letter/$coverLetterId", params: { coverLetterId } });
};
const importFile = async (file: File) => {
@@ -375,9 +375,19 @@ export function useStartDocument() {
},
newLetter: async () => {
try {
const letter = await createLetter({ name: t`Untitled letter`, recipient: "", content: "" });
// A new letter takes its sender details and design from the resume edited most recently.
const documents = queryClient.getQueryData(orpc.documents.list.queryKey({ input: { trashed: false } }));
const resume = documents
?.filter((document) => document.type === "resume")
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())[0];
const letter = await createLetter({
name: t`Untitled letter`,
recipient: "",
content: "",
...(resume ? { resumeId: resume.id } : {}),
});
created(letter.id);
void navigate({ to: "/dashboard", search: { type: "letter", letter: letter.id } });
void navigate({ to: "/builder/letter/$coverLetterId", params: { coverLetterId: letter.id } });
} catch (error) {
failed(error);
}
@@ -65,13 +65,7 @@ export function TrashPage() {
</thead>
<tbody>
{documents?.map((document) => (
<DocumentRow
key={`${document.type}:${document.id}`}
document={document}
onOpenLetter={noop}
onTags={noop}
onLink={noop}
/>
<DocumentRow key={`${document.type}:${document.id}`} document={document} onTags={noop} onLink={noop} />
))}
</tbody>
</table>
+35
View File
@@ -0,0 +1,35 @@
import type { LetterWords } from "@reactive-resume/resume/cover-letter";
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { useMemo } from "react";
import { composeCoverLetter, createCoverLetterResumeData } from "@reactive-resume/resume/cover-letter";
/**
* The words structured letters are composed with. They follow the app's language, like the rest of what the app
* writes for you.
*/
export function useLetterWords(): LetterWords {
const { i18n } = useLingui();
return useMemo(
() => ({
greeting: (name: string) => t`Dear ${name},`,
teamGreeting: t`Dear hiring team,`,
hiringTeam: t`Hiring team`,
signOff: t`Kind regards,`,
formatDate: (date: string) =>
new Date(`${date}T12:00:00Z`).toLocaleDateString(i18n.locale, {
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC",
}),
}),
[i18n.locale],
);
}
/** The letter as a one-section document the PDF renderer draws: sender header, recipient, greeting, body, sign-off. */
export const letterPageData = (letter: CoverLetter, words: LetterWords) =>
createCoverLetterResumeData({ ...letter, ...composeCoverLetter(letter, words) });
+25
View File
@@ -0,0 +1,25 @@
import type { LetterWords } from "@reactive-resume/resume/cover-letter";
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import type { ExportFormat } from "@/features/resume/export/use-resume-export";
import { createExportFile, getDefaultFileName } from "@/features/resume/export/use-resume-export";
import { client } from "@/libs/orpc/client";
import { letterPageData } from "./compose";
const asDocument = (letter: CoverLetter, words: LetterWords) => ({
name: letter.name,
slug: "",
data: letterPageData(letter, words),
});
/** "First-Last-Cover-Letter", from the sender's name. */
export const letterFileName = (letter: CoverLetter, words: LetterWords) =>
getDefaultFileName(asDocument(letter, words), "cover-letter");
/** The letter as a file: the page with its sender header, or JSON that imports back as a letter. */
export async function createLetterFile(letter: CoverLetter, words: LetterWords, format: ExportFormat): Promise<Blob> {
if (format === "json") {
const document = await client.coverLetters.export({ id: letter.id });
return new Blob([JSON.stringify(document, null, 2)], { type: "application/json" });
}
return createExportFile(asDocument(letter, words), format, "cover-letter", { includeCoverLetterHeader: true });
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { countWords, letterLength } from "./length";
describe("letter length", () => {
it("counts the words of the body's text, not its markup", () => {
expect(countWords("")).toBe(0);
expect(countWords("<p></p><p>&nbsp;</p>")).toBe(0);
expect(countWords("<p>I'm applying for the <strong>Senior</strong> role.</p><p>Thanks,<br />Jordan</p>")).toBe(8);
});
it("is short below 180 words, comfortable up to 320 and long after", () => {
expect(letterLength(0)).toBe("empty");
expect(letterLength(179)).toBe("short");
expect(letterLength(180)).toBe("comfortable");
expect(letterLength(320)).toBe("comfortable");
expect(letterLength(321)).toBe("long");
});
});
+19
View File
@@ -0,0 +1,19 @@
/** Most recruiters read 180 to 320 words; the bar runs to 450. */
export const LETTER_LENGTH = { min: 180, max: 320, scale: 450 } as const;
export type LetterLength = "empty" | "short" | "comfortable" | "long";
// ponytail: counts whitespace-separated words, so text without spaces (Chinese, Japanese) counts low.
export function countWords(html: string): number {
const text = html
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;|&#160;/g, " ")
.trim();
return text ? text.split(/\s+/).length : 0;
}
export function letterLength(words: number): LetterLength {
if (words === 0) return "empty";
if (words < LETTER_LENGTH.min) return "short";
return words <= LETTER_LENGTH.max ? "comfortable" : "long";
}
+100
View File
@@ -0,0 +1,100 @@
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ORPCError } from "@orpc/client";
import { copyCoverLetterStyle } from "@reactive-resume/resume/cover-letter";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
const mocks = vi.hoisted(() => ({ update: vi.fn(), draft: vi.fn() }));
vi.mock("@/libs/orpc/client", () => ({
client: { coverLetters: { update: mocks.update } },
streamClient: { coverLetters: { draft: mocks.draft } },
}));
const { discardLetterDraft, startLetterDraft, useLetterEditorStore } = await import("./store");
const letter: CoverLetter = {
id: "letter",
name: "Letter",
recipient: "",
content: "<p>Mine</p>",
style: copyCoverLetterStyle(defaultResumeData),
layout: "structured",
recipientName: "",
recipientCompany: "",
letterDate: null,
sourceResumeId: "resume",
sourceApplicationId: null,
senderLinked: true,
designLinked: true,
isLocked: false,
revision: 1,
createdAt: new Date(),
updatedAt: new Date(),
};
const store = () => useLetterEditorStore.getState();
function* chunks(...parts: unknown[]) {
for (const part of parts) {
if (part instanceof Error) throw part;
yield part as string;
}
}
beforeEach(() => {
vi.resetAllMocks();
store().load(letter);
});
describe("saving", () => {
it("keeps what was typed while the server's copy comes back trimmed, and bumps the revision", async () => {
mocks.update.mockImplementation(async (input: { recipientName: string }) => ({
...letter,
recipientName: input.recipientName.trim(),
revision: 2,
}));
store().edit({ recipientName: "Dana " });
await store().flush();
expect(mocks.update).toHaveBeenCalledWith(
expect.objectContaining({ id: "letter", expectedRevision: 1, recipientName: "Dana " }),
);
expect(store().letter).toMatchObject({ recipientName: "Dana ", revision: 2 });
expect(store().status).toBe("saved");
});
it("stops saving after a conflict and keeps the edits", async () => {
mocks.update.mockRejectedValue(new ORPCError("CONFLICT"));
store().edit({ content: "<p>Changed</p>" });
await store().flush();
expect(store().status).toBe("conflict");
expect(store().pending).toEqual({ content: "<p>Changed</p>" });
store().edit({ content: "<p>More</p>" });
await store().flush();
expect(mocks.update).toHaveBeenCalledTimes(1);
});
});
describe("drafts", () => {
it("streams a draft beside the body, which only changes when the draft is kept", async () => {
mocks.draft.mockResolvedValue(chunks("I'm applying", " for the role."));
await startLetterDraft();
expect(mocks.draft).toHaveBeenCalledWith({ id: "letter", variant: "draft" }, expect.anything());
expect(store().draft).toEqual({ phase: "ready", text: "I'm applying for the role." });
expect(store().letter?.content).toBe("<p>Mine</p>");
discardLetterDraft();
expect(store().draft).toEqual({ phase: "idle" });
expect(store().letter?.content).toBe("<p>Mine</p>");
});
it("revises the draft it's given and names the provider when it stops", async () => {
mocks.draft.mockResolvedValue(chunks("Partial", new ORPCError("BAD_GATEWAY", { data: { provider: "OpenAI" } })));
await startLetterDraft("shorter", "The earlier draft");
expect(mocks.draft).toHaveBeenCalledWith(
{ id: "letter", variant: "shorter", previous: "The earlier draft" },
expect.anything(),
);
expect(store().draft).toMatchObject({ phase: "failed", provider: "OpenAI" });
expect(store().letter?.content).toBe("<p>Mine</p>");
});
});
+163
View File
@@ -0,0 +1,163 @@
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import { ORPCError } from "@orpc/client";
import { create } from "zustand/react";
import { generateId } from "@reactive-resume/utils/string";
import { client, streamClient } from "@/libs/orpc/client";
/** What's typed in the letter editor: saved once typing pauses. */
export type LetterEdits = Partial<
Pick<CoverLetter, "name" | "recipient" | "content" | "recipientName" | "recipientCompany" | "letterDate">
>;
/** `conflict`: the letter changed somewhere else, so saving stops until it's reloaded. */
type LetterSaveStatus = "saved" | "saving" | "error" | "conflict";
/**
* A draft from the posting, kept apart from the letter: streaming, ready to keep, or failed. The body changes only
* when a draft is kept, so a failed or discarded draft leaves the letter as it was.
*/
export type LetterDraft =
| { phase: "idle" }
| { phase: "streaming" | "ready"; text: string }
| { phase: "failed"; provider: string | null; error: unknown };
const SAVE_DELAY_MS = 800;
type LetterEditorStore = {
letter: CoverLetter | null;
status: LetterSaveStatus;
/** Edits not sent yet; they stay on top of whatever the server returns. */
pending: LetterEdits;
/** One editing visit: its saves share one History version. */
sessionId: string;
draft: LetterDraft;
load: (letter: CoverLetter) => void;
edit: (edits: LetterEdits) => void;
/** Saves what's pending now; resolves once everything typed so far is saved (or has failed). */
flush: () => Promise<void>;
/**
* Saves what's pending, then makes a change that returns the letter (a link, a template, a restore). Throws what
* the change throws, so the caller can say what went wrong.
*/
change: (action: (letter: CoverLetter) => Promise<CoverLetter>) => Promise<CoverLetter>;
reset: () => void;
};
const idleDraft: LetterDraft = { phase: "idle" };
let timer: ReturnType<typeof setTimeout> | undefined;
let inflight: Promise<void> | null = null;
let drafting: AbortController | null = null;
const hasEdits = (edits: LetterEdits) => Object.keys(edits).length > 0;
export const useLetterEditorStore = create<LetterEditorStore>()((set, get) => ({
letter: null,
status: "saved",
pending: {},
sessionId: generateId(),
draft: idleDraft,
load: (letter) => set({ letter, status: "saved", pending: {}, sessionId: generateId(), draft: idleDraft }),
edit: (edits) => {
set((state) =>
state.letter
? {
letter: { ...state.letter, ...edits },
pending: { ...state.pending, ...edits },
status: state.status === "conflict" ? "conflict" : "saving",
}
: state,
);
clearTimeout(timer);
timer = setTimeout(() => void get().flush(), SAVE_DELAY_MS);
},
flush: async () => {
clearTimeout(timer);
if (inflight) await inflight;
const { letter, pending, sessionId, status } = get();
if (!letter || !hasEdits(pending) || status === "conflict") return;
set({ pending: {}, status: "saving" });
inflight = (async () => {
try {
const saved = await client.coverLetters.update({
id: letter.id,
expectedRevision: letter.revision,
sessionId,
...pending,
});
// What was typed stays as typed (the server trims and cleans what it stores), and so does anything typed
// since.
set((state) => ({
letter: { ...saved, ...pending, ...state.pending },
status: hasEdits(state.pending) ? "saving" : "saved",
}));
} catch (error) {
const conflict = error instanceof ORPCError && error.code === "CONFLICT";
set((state) => ({ pending: { ...pending, ...state.pending }, status: conflict ? "conflict" : "error" }));
} finally {
inflight = null;
}
})();
await inflight;
// Typed while that save was on its way.
if (get().status === "saving" && hasEdits(get().pending)) await get().flush();
},
change: async (action) => {
await get().flush();
const { letter } = get();
if (!letter) throw new Error("No letter is open.");
const next = await action(letter);
set((state) => ({ letter: { ...next, ...state.pending } }));
return next;
},
reset: () => {
clearTimeout(timer);
drafting?.abort();
set({ letter: null, status: "saved", pending: {}, draft: idleDraft });
},
}));
/** Streams a draft from the posting (or a Shorter / More personal revision of `previous`) into `draft`. */
export async function startLetterDraft(variant: "draft" | "shorter" | "personal" = "draft", previous?: string) {
const letter = useLetterEditorStore.getState().letter;
if (!letter) return;
drafting?.abort();
const controller = new AbortController();
drafting = controller;
const setDraft = (draft: LetterDraft) => {
if (drafting === controller) useLetterEditorStore.setState({ draft });
};
setDraft({ phase: "streaming", text: "" });
try {
const stream = await streamClient.coverLetters.draft(
{ id: letter.id, variant, ...(previous ? { previous } : {}) },
{ signal: controller.signal },
);
let text = "";
for await (const chunk of stream) {
text += chunk;
setDraft({ phase: "streaming", text });
}
if (!text.trim()) throw new Error("The draft came back empty.");
setDraft({ phase: "ready", text: text.trim() });
} catch (error) {
if (controller.signal.aborted) return;
const data = error instanceof ORPCError ? (error.data as { provider?: unknown } | undefined) : undefined;
setDraft({ phase: "failed", provider: typeof data?.provider === "string" ? data.provider : null, error });
}
}
export function discardLetterDraft() {
drafting?.abort();
drafting = null;
useLetterEditorStore.setState({ draft: idleDraft });
}
@@ -0,0 +1,44 @@
import { getRouteApi } from "@tanstack/react-router";
import { useCallback, useEffect } from "react";
import { useEditorStore } from "@/features/resume/editor/store";
export type LetterMode = "write" | "design";
const routeApi = getRouteApi("/builder/letter/$coverLetterId");
/** The mode lives in the URL (`?mode=design`), so refreshing keeps it. */
export function useLetterMode() {
const { mode = "write" } = routeApi.useSearch();
const navigate = routeApi.useNavigate();
const setMode = useCallback(
(next: LetterMode) =>
void navigate({
to: ".",
search: (current: ReturnType<typeof routeApi.useSearch>) => ({
...current,
mode: next === "write" ? undefined : next,
}),
replace: true,
}),
[navigate],
);
return [mode, setMode] as const;
}
/** `?version=` opens History on that version (the page shows it read-only), then leaves the URL. */
export function useOpenLetterVersionFromUrl() {
const { version } = routeApi.useSearch();
const navigate = routeApi.useNavigate();
useEffect(() => {
if (!version) return;
const editor = useEditorStore.getState();
editor.setShareTab("history");
editor.setHistoryVersion(version);
void navigate({
to: ".",
search: (current: ReturnType<typeof routeApi.useSearch>) => ({ ...current, version: undefined }),
replace: true,
});
}, [version, navigate]);
}
@@ -0,0 +1,158 @@
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { useHotkey } from "@tanstack/react-hotkeys";
import { Link } from "@tanstack/react-router";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { buttonVariants } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { cn } from "@reactive-resume/utils/style";
import { useEditorStore, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from "./store";
// The editor chrome resumes and letters share: the back link and drawer controls in the bar, the canvas width
// and the zoom bar.
const LANDSCAPE_QUERY = "(orientation: landscape)";
function subscribeToOrientation(onChange: () => void) {
const list = window.matchMedia(LANDSCAPE_QUERY);
list.addEventListener("change", onChange);
return () => list.removeEventListener("change", onChange);
}
/** Tablets in landscape can pin the panel beside the page. */
export const useIsLandscape = () =>
useSyncExternalStore(
subscribeToOrientation,
() => window.matchMedia(LANDSCAPE_QUERY).matches,
() => false,
);
// Page widths in PDF points; 1pt renders as 1 CSS px at 100%.
export const PAGE_WIDTH = { a4: 595.28, letter: 612, "free-form": 595.28 } as const;
// Horizontal room the canvas keeps around the page: 40px each side, 16px on phones.
export const CANVAS_GUTTER = { wide: 80, narrow: 32 } as const;
export function useCanvasWidth() {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver(([entry]) => setWidth(entry?.contentRect.width ?? 0));
observer.observe(element);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
}
/** Leaving the editor is navigation, so it's a link styled as an icon button. */
export function BackLink() {
const label = t`Back to documents`;
return (
<Tooltip>
<TooltipTrigger
render={
<Link
to="/dashboard"
aria-label={label}
className={buttonVariants({ variant: "ghost", size: "icon", className: "text-ink-2" })}
/>
}
>
<Icon name="arrow_back" />
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
}
/** Tablet: show or hide the panel drawer and, in landscape, pin it beside the page. */
export function DrawerControls({ pinnable }: { pinnable: boolean }) {
const open = useEditorStore((state) => state.drawerOpen);
const pinned = useEditorStore((state) => state.drawerPinned) && pinnable;
const setOpen = useEditorStore((state) => state.setDrawerOpen);
const setPinned = useEditorStore((state) => state.setDrawerPinned);
return (
<>
{!pinned && (
<IconButton
icon={open ? "left_panel_close" : "left_panel_open"}
label={open ? t`Hide panel` : t`Show panel`}
aria-expanded={open}
className="text-ink-2"
onClick={() => setOpen(!open)}
/>
)}
{pinnable && (open || pinned) && (
<IconButton
icon="vertical_split"
label={t`Keep the panel beside the page`}
aria-pressed={pinned}
className={cn("text-ink-2", pinned && "bg-accent-soft text-accent-text")}
onClick={() => {
// Unpinning leaves the drawer open over the page.
setPinned(!pinned);
setOpen(true);
}}
/>
)}
</>
);
}
type ZoomBarProps = {
fitScale: number;
pageCount: number;
};
/** − · Fit · + and the page count, floating 18px above the bottom of the canvas. Zoom runs from 60% to 150%. */
export function ZoomBar({ fitScale, pageCount }: ZoomBarProps) {
const zoom = useEditorStore((state) => state.zoom);
const setZoom = useEditorStore((state) => state.setZoom);
const current = zoom === "fit" ? fitScale : zoom;
useHotkey("Mod+0", () => setZoom("fit"));
const buttonClassName =
"flex size-8 items-center justify-center rounded-[7px] text-ink-2 transition-colors duration-quick hover:bg-hover disabled:text-ink-3 disabled:hover:bg-transparent";
return (
<div className="absolute bottom-[18px] left-1/2 flex -translate-x-1/2 items-center gap-0.5 rounded-lg border border-line bg-raised p-1 shadow-e2">
<button
type="button"
aria-label={t`Zoom out`}
disabled={current <= ZOOM_MIN}
className={buttonClassName}
onClick={() => setZoom(current - ZOOM_STEP)}
>
<Icon name="remove" />
</button>
<button
type="button"
aria-label={t`Fit page to width`}
className="h-8 min-w-[52px] rounded-[7px] px-1.5 font-medium font-mono text-ink text-xs transition-colors duration-quick hover:bg-hover"
onClick={() => setZoom("fit")}
>
{zoom === "fit" ? <Trans>Fit</Trans> : `${Math.round(current * 100)}%`}
</button>
<button
type="button"
aria-label={t`Zoom in`}
disabled={current >= ZOOM_MAX}
className={buttonClassName}
onClick={() => setZoom(current + ZOOM_STEP)}
>
<Icon name="add" />
</button>
<span className="whitespace-nowrap px-2 font-mono text-ink-3 text-xs">
<Plural value={pageCount} one="# page" other="# pages" />
</span>
</div>
);
}
@@ -15,7 +15,7 @@ export const FONT_PAIRINGS = [
export type FontPairingId = (typeof FONT_PAIRINGS)[number]["id"];
export function matchFontPairing(metadata: Metadata): FontPairingId | null {
export function matchFontPairing(metadata: Pick<Metadata, "typography">): FontPairingId | null {
const { heading, body } = metadata.typography;
return (
FONT_PAIRINGS.find((pairing) => pairing.heading === heading.fontFamily && pairing.body === body.fontFamily)?.id ??
@@ -8,6 +8,7 @@ import { msg, t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo } from "react";
import { Badge } from "@reactive-resume/ui/components/badge";
import {
@@ -247,6 +248,7 @@ type EntryMenuProps = { section: WriteSection; entry: Entry };
/** Hide from page, Duplicate, Move to… and Delete. */
function EntryMenu({ section, entry }: EntryMenuProps) {
const resume = useCurrentResume();
const navigate = useNavigate();
const updateResumeData = useUpdateResumeData();
const customSectionId = section.kind === "custom" ? section.id : undefined;
const moveTargets = useMemo(
@@ -281,7 +283,15 @@ function EntryMenu({ section, entry }: EntryMenuProps) {
copyLetter(
{ resumeId: resume.id, sectionId: section.id, itemId: entry.id },
{
onSuccess: () => toast.add({ description: t`Copied to Documents` }),
onSuccess: (letter) =>
toast.add({
description: t`Copied to Documents`,
actionProps: {
children: t`Open`,
onClick: () =>
void navigate({ to: "/builder/letter/$coverLetterId", params: { coverLetterId: letter.id } }),
},
}),
onError: () => toast.add({ type: "error", description: t`Couldn't copy the letter. Save and try again.` }),
},
);
@@ -82,13 +82,26 @@ type RichTextEditorProps = {
hint?: ReactNode;
disabled?: boolean;
className?: string;
/** The text area's id, so other controls can focus it. */
id?: string;
/** Replaces the text area's default height (88px to 360px). */
heightClassName?: string;
};
/**
* Rich text for descriptions: the toolbar and footer show while the text has focus, the toolbar never
* takes focus from it, and Markdown shortcuts work ("- " starts a list, "**bold**").
*/
export function RichTextEditor({ label, value, onChange, hint, disabled = false, className }: RichTextEditorProps) {
export function RichTextEditor({
label,
value,
onChange,
hint,
disabled = false,
className,
id,
heightClassName = "max-h-[360px] min-h-[88px]",
}: RichTextEditorProps) {
const [focused, setFocused] = useState(false);
const actions = useToolbarActions();
const readOnlyTable = useMemo(() => hasUnsupportedTableMarkup(value), [value]);
@@ -101,12 +114,14 @@ export function RichTextEditor({ label, value, onChange, hint, disabled = false,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
...(id ? { id } : {}),
"aria-label": label,
"aria-multiline": "true",
role: "textbox",
spellcheck: "true",
class: cn(
"wysiwyg max-h-[360px] min-h-[88px] overflow-y-auto px-3 py-2 text-sm outline-none",
"wysiwyg overflow-y-auto px-3 py-2 text-sm outline-none",
heightClassName,
"[&_[data-resume-whitespace=preserve]]:whitespace-pre-wrap",
),
},
@@ -16,7 +16,7 @@ import { WritePanel } from "./write-panel";
const routerParams = vi.hoisted(() => ({ resumeId: "write-panel" }));
const toastState = vi.hoisted(() => ({ add: vi.fn() }));
vi.mock("@tanstack/react-router", () => ({ useParams: () => routerParams }));
vi.mock("@tanstack/react-router", () => ({ useParams: () => routerParams, useNavigate: () => vi.fn() }));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
@@ -117,6 +117,7 @@ export function ResumePreviewClient({
renderPageCaption,
renderPageOverlay,
onRender,
includeCoverLetterHeader = false,
}: ResolvedResumePreviewProps) {
const builderResumeData = useResumeData();
const resumeData = data ?? builderResumeData;
@@ -141,11 +142,16 @@ export function ResumePreviewClient({
try {
if (cancelled || requestId !== requestIdRef.current) return;
let pageMap: PageMap | undefined;
const blob = await createResumePdfBlob(resumeData, undefined, undefined, {
onPageMap: (map) => {
pageMap = map;
const blob = await createResumePdfBlob(
resumeData,
undefined,
includeCoverLetterHeader ? { includeCoverLetterHeader } : undefined,
{
onPageMap: (map) => {
pageMap = map;
},
},
});
);
if (!cancelled && requestId === requestIdRef.current) {
const nextPdf = createPreviewPdf(
@@ -177,7 +183,7 @@ export function ResumePreviewClient({
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [paused, resumeData]);
}, [paused, resumeData, includeCoverLetterHeader]);
const activeLayer = getActivePreviewLayer(previewLayers);
const activePageCount = activeLayer?.numPages ?? 0;
@@ -13,6 +13,8 @@ export type ResumePreviewProps = {
pageScale?: number;
pageClassName?: string;
showPageNumbers?: boolean;
/** Letters show the sender's header, which a document with only a letter otherwise leaves out. */
includeCoverLetterHeader?: boolean;
/** Drawn above each page (e.g. "Page 1 · Letter"). Replaces the small page-number caption. */
renderPageCaption?: (page: { pageNumber: number; totalPages: number }) => ReactNode;
/** Drawn over each page, in page-relative coordinates; receives the page map of the render on screen. */
@@ -1,10 +1,13 @@
import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections";
import type { IconName } from "@reactive-resume/ui/components/icon";
import type { ReactNode } from "react";
import type { Resume } from "@/features/resume/builder/draft";
import type { ExportFormat } from "@/features/resume/export/use-resume-export";
import { Radio } from "@base-ui/react/radio";
import { RadioGroup } from "@base-ui/react/radio-group";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { useId, useState } from "react";
import { resumeHasCoverLetter } from "@reactive-resume/resume/export-sections";
import { Button } from "@reactive-resume/ui/components/button";
@@ -14,14 +17,25 @@ import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/comp
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { toast } from "@reactive-resume/ui/components/toast";
import { downloadWithAnchor } from "@reactive-resume/utils/file";
import { cn } from "@reactive-resume/utils/style";
import { applicationsListQueryOptions } from "@/features/applications/queries";
import { useLetterWords } from "@/features/letters/compose";
import { createLetterFile, letterFileName } from "@/features/letters/export";
import { useCurrentResume } from "@/features/resume/builder/draft";
import { useOpenIssueCount } from "@/features/resume/editor/check/use-check";
import { createExportFile, getDefaultFileName, sanitizeFileName } from "@/features/resume/export/use-resume-export";
import { client } from "@/libs/orpc/client";
type Format = { id: ExportFormat; label: string; extension: string; icon: IconName; description: string };
export type DownloadFormat<Id extends string = ExportFormat> = {
id: Id;
label: string;
/** ".pdf", or what's made ("2 files"). */
extension: string;
icon: IconName;
description: string;
disabled?: boolean;
};
const getFormats = (): Format[] => [
export const getExportFormats = (): DownloadFormat[] => [
{
id: "pdf",
label: "PDF",
@@ -52,6 +66,148 @@ const getFormats = (): Format[] => [
},
];
type FormatRadioGroupProps<Id extends string> = {
formats: DownloadFormat<Id>[];
value: Id;
onChange: (value: Id) => void;
};
/** Format radio cards: icon, name, extension and when to use it. PDF is marked "Best for applying". */
export function FormatRadioGroup<Id extends string>({ formats, value, onChange }: FormatRadioGroupProps<Id>) {
return (
<RadioGroup
aria-label={t`Format`}
value={value}
onValueChange={(next) => onChange(next as Id)}
className="grid gap-1.5"
>
{formats.map((option) => (
<Radio.Root
key={option.id}
value={option.id}
disabled={option.disabled}
className="group/format flex cursor-pointer items-start gap-3 rounded-[10px] border border-line p-3 text-start transition-colors duration-quick hover:border-line-2 data-disabled:cursor-not-allowed data-checked:border-accent data-checked:bg-accent-soft data-disabled:opacity-45"
>
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-sunken text-ink-2">
<Icon name={option.icon} size={22} />
</span>
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-sm">{option.label}</span>
<span className="font-medium font-mono text-[11px] text-ink-3">{option.extension}</span>
{option.id === "pdf" && (
<span className="rounded bg-accent-soft px-1.5 font-semibold text-[11px] text-accent-text leading-[18px]">
<Trans>Best for applying</Trans>
</span>
)}
</span>
<span className="text-[13px] text-ink-2 leading-[18px]">{option.description}</span>
</span>
<span className="mt-0.5 grid size-[18px] shrink-0 place-items-center rounded-full border-[1.5px] border-line-2 group-data-checked/format:border-accent">
<span className="size-2 rounded-full bg-accent opacity-0 group-data-checked/format:opacity-100" />
</span>
</Radio.Root>
))}
</RadioGroup>
);
}
type FileNameFieldProps = { value: string; extension: string; hint: ReactNode; onChange: (value: string) => void };
/** The file name recruiters see, with the extension shown after it. Characters file systems reject are dropped. */
export function FileNameField({ value, extension, hint, onChange }: FileNameFieldProps) {
const id = useId();
return (
<div className="grid gap-1.5">
<label htmlFor={id} className="font-medium text-ink-2 text-xs">
<Trans>File name</Trans>
</label>
<div className="flex h-[38px] items-center overflow-hidden rounded-lg border border-line-2 bg-raised focus-within:border-accent focus-within:ring-3 focus-within:ring-accent-soft">
<input
id={id}
value={value}
spellCheck={false}
aria-describedby={`${id}-hint`}
onChange={(event) => onChange(sanitizeFileName(event.target.value))}
className="h-full min-w-0 flex-1 bg-transparent ps-2.5 font-medium font-mono text-[13px] text-ink outline-none"
/>
<span className="px-2.5 font-medium font-mono text-[13px] text-ink-3">{extension}</span>
</div>
<span id={`${id}-hint`} className="text-ink-3 text-xs">
{hint}
</span>
</div>
);
}
export type DownloadState = "idle" | "busy" | "error";
type DownloadActionsProps = {
state: DownloadState;
label: string;
onDownload: () => void;
/** Offered when a file other than a PDF fails. */
onDownloadPdf?: (() => void) | undefined;
};
/** The failure alert (with PDF as the fallback) and the 44px button that shows its progress. */
export function DownloadActions({ state, label, onDownload, onDownloadPdf }: DownloadActionsProps) {
return (
<>
{state === "error" && (
<div
role="alert"
className="flex flex-wrap items-start gap-2.5 rounded-[10px] bg-danger-soft px-3 py-2.5 text-[13px] text-danger-text leading-[19px]"
>
<Icon name="error" size={20} />
<span className="min-w-0 flex-1">
<Trans>The {label} file couldn't be generated. Try again, or download PDF instead.</Trans>
</span>
{onDownloadPdf && (
<Button size="sm" variant="secondary" onClick={onDownloadPdf}>
<Trans>Download PDF instead</Trans>
</Button>
)}
</div>
)}
<Button
className="h-11 gap-2 text-[15px]"
aria-busy={state === "busy"}
disabled={state === "busy"}
onClick={onDownload}
>
{state === "busy" ? (
<>
<Spinner decorative className="size-4" />
<Trans>Preparing {label} file…</Trans>
</>
) : state === "error" ? (
<>
<Icon name="refresh" />
<Trans>Try again</Trans>
</>
) : (
<>
<Icon name="download" />
<Trans>Download {label}</Trans>
</>
)}
</Button>
</>
);
}
/** The letter written for this resume's application, which can be downloaded along with it. */
function useLinkedLetter(resume: Resume) {
const { data: applications } = useQuery(applicationsListQueryOptions());
const application = applications?.find(
(item) => item.coverLetterId && (item.id === resume.applicationId || item.resumeId === resume.id),
);
return application?.coverLetterId ? { id: application.coverLetterId, company: application.company } : null;
}
type DownloadTabProps = {
/** Opens Check; the note about open issues links there. */
onReview: () => void;
@@ -59,24 +215,31 @@ type DownloadTabProps = {
/**
* Download: every format explained by when to use it, the file name recruiters see, and a button that shows
* its progress. Open Check issues are mentioned but never block. A failed file offers PDF instead.
* its progress. Open Check issues are mentioned but never block. A failed file offers PDF instead. With a letter
* written for the resume's application, that letter can come along as a second file.
*/
export function DownloadTab({ onReview }: DownloadTabProps) {
const resume = useCurrentResume();
const issues = useOpenIssueCount();
const fileNameId = useId();
const words = useLetterWords();
const linkedLetter = useLinkedLetter(resume);
const headerId = useId();
const hasLetter = resumeHasCoverLetter(resume.data);
const [target, setTarget] = useState<ResumeExportTarget>("resume");
const [format, setFormat] = useState<ExportFormat>("pdf");
const [includeHeader, setIncludeHeader] = useState(false);
const [withLetter, setWithLetter] = useState(false);
const [fileName, setFileName] = useState<string | null>(null);
const [state, setState] = useState<"idle" | "busy" | "error">("idle");
const [state, setState] = useState<DownloadState>("idle");
const activeTarget = hasLetter ? target : "resume";
// JSON is the whole document's data, so it isn't offered for the letter on its own.
const activeFormat = activeTarget === "cover-letter" && format === "json" ? "pdf" : format;
const formats = getFormats();
const selected = formats.find((option) => option.id === activeFormat) ?? (formats[0] as Format);
const formats = getExportFormats().map((option) => ({
...option,
disabled: option.id === "json" && activeTarget === "cover-letter",
}));
const selected = formats.find((option) => option.id === activeFormat) ?? (formats[0] as DownloadFormat);
const name = fileName ?? getDefaultFileName(resume, activeTarget);
const download = async (as: ExportFormat) => {
@@ -86,8 +249,14 @@ export function DownloadTab({ onReview }: DownloadTabProps) {
const blob = await createExportFile(resume, as, activeTarget, { includeCoverLetterHeader: includeHeader });
const file = `${sanitizeFileName(name) || getDefaultFileName(resume, activeTarget)}${extension}`;
downloadWithAnchor(blob, file);
if (withLetter && linkedLetter && activeTarget === "resume") {
const letter = await client.coverLetters.getById({ id: linkedLetter.id });
downloadWithAnchor(await createLetterFile(letter, words, as), `${letterFileName(letter, words)}${extension}`);
toast.add({ description: t`Downloaded ${file} and the cover letter` });
} else {
toast.add({ description: t`Downloaded ${file}` });
}
setState("idle");
toast.add({ description: t`Downloaded ${file}` });
} catch {
setState("error");
}
@@ -117,11 +286,11 @@ export function DownloadTab({ onReview }: DownloadTabProps) {
{activeTarget === "cover-letter" && (
<div className="flex items-center gap-2.5 text-sm">
<Checkbox
id={`${fileNameId}-header`}
id={`${headerId}-header`}
checked={includeHeader}
onCheckedChange={(checked) => setIncludeHeader(checked === true)}
/>
<label htmlFor={`${fileNameId}-header`} className="cursor-pointer">
<label htmlFor={`${headerId}-header`} className="cursor-pointer">
<Trans>Include the resume's header</Trans>
</label>
</div>
@@ -129,66 +298,34 @@ export function DownloadTab({ onReview }: DownloadTabProps) {
</div>
)}
<RadioGroup
aria-label={t`Format`}
<FormatRadioGroup
formats={formats}
value={activeFormat}
onValueChange={(value) => {
setFormat(value as ExportFormat);
onChange={(value) => {
setFormat(value);
setState("idle");
}}
className="grid gap-1.5"
>
{formats.map((option) => {
const unavailable = option.id === "json" && activeTarget === "cover-letter";
return (
<Radio.Root
key={option.id}
value={option.id}
disabled={unavailable}
className="group/format flex cursor-pointer items-start gap-3 rounded-[10px] border border-line p-3 text-start transition-colors duration-quick hover:border-line-2 data-disabled:cursor-not-allowed data-checked:border-accent data-checked:bg-accent-soft data-disabled:opacity-45"
>
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-sunken text-ink-2">
<Icon name={option.icon} size={22} />
</span>
<span className="grid min-w-0 flex-1 gap-0.5">
<span className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-sm">{option.label}</span>
<span className="font-medium font-mono text-[11px] text-ink-3">{option.extension}</span>
{option.id === "pdf" && (
<span className="rounded bg-accent-soft px-1.5 font-semibold text-[11px] text-accent-text leading-[18px]">
<Trans>Best for applying</Trans>
</span>
)}
</span>
<span className="text-[13px] text-ink-2 leading-[18px]">{option.description}</span>
</span>
<span className="mt-0.5 grid size-[18px] shrink-0 place-items-center rounded-full border-[1.5px] border-line-2 group-data-checked/format:border-accent">
<span className="size-2 rounded-full bg-accent opacity-0 group-data-checked/format:opacity-100" />
</span>
</Radio.Root>
);
})}
</RadioGroup>
/>
<div className="grid gap-1.5">
<label htmlFor={fileNameId} className="font-medium text-ink-2 text-xs">
<Trans>File name</Trans>
</label>
<div className="flex h-[38px] items-center overflow-hidden rounded-lg border border-line-2 bg-raised focus-within:border-accent focus-within:ring-3 focus-within:ring-accent-soft">
<input
id={fileNameId}
value={name}
spellCheck={false}
aria-describedby={`${fileNameId}-hint`}
onChange={(event) => setFileName(sanitizeFileName(event.target.value))}
className="h-full min-w-0 flex-1 bg-transparent ps-2.5 font-medium font-mono text-[13px] text-ink outline-none"
<FileNameField
value={name}
extension={selected.extension}
hint={<Trans>Recruiters see this name. Your name plus “Resume” works well.</Trans>}
onChange={setFileName}
/>
{linkedLetter && activeTarget === "resume" && (
<div className="flex items-center gap-2.5 text-sm">
<Checkbox
id={`${headerId}-letter`}
checked={withLetter}
onCheckedChange={(checked) => setWithLetter(checked === true)}
/>
<span className="px-2.5 font-medium font-mono text-[13px] text-ink-3">{selected.extension}</span>
<label htmlFor={`${headerId}-letter`} className="cursor-pointer">
<Trans>Also download the {linkedLetter.company} cover letter</Trans>
</label>
</div>
<span id={`${fileNameId}-hint`} className="text-ink-3 text-xs">
<Trans>Recruiters see this name. Your name plus “Resume” works well.</Trans>
</span>
</div>
)}
{issues > 0 && (
<div className="flex gap-2.5 rounded-[10px] bg-warn-soft px-3 py-2.5 text-[13px] text-warn-text leading-[19px]">
@@ -206,46 +343,12 @@ export function DownloadTab({ onReview }: DownloadTabProps) {
</div>
)}
{state === "error" && (
<div
role="alert"
className="flex flex-wrap items-start gap-2.5 rounded-[10px] bg-danger-soft px-3 py-2.5 text-[13px] text-danger-text leading-[19px]"
>
<Icon name="error" size={20} />
<span className="min-w-0 flex-1">
<Trans>The {selected.label} file couldn't be generated. Try again, or download PDF instead.</Trans>
</span>
{activeFormat !== "pdf" && (
<Button size="sm" variant="secondary" onClick={() => void download("pdf")}>
<Trans>Download PDF instead</Trans>
</Button>
)}
</div>
)}
<Button
className={cn("h-11 gap-2 text-[15px]")}
aria-busy={state === "busy"}
disabled={state === "busy"}
onClick={() => void download(activeFormat)}
>
{state === "busy" ? (
<>
<Spinner decorative className="size-4" />
<Trans>Preparing {selected.label} file…</Trans>
</>
) : state === "error" ? (
<>
<Icon name="refresh" />
<Trans>Try again</Trans>
</>
) : (
<>
<Icon name="download" />
<Trans>Download {selected.label}</Trans>
</>
)}
</Button>
<DownloadActions
state={state}
label={selected.label}
onDownload={() => void download(activeFormat)}
onDownloadPdf={activeFormat === "pdf" ? undefined : () => void download("pdf")}
/>
</div>
);
}
@@ -3,7 +3,7 @@ import type { VersionSummary } from "./format";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import {
@@ -25,60 +25,117 @@ import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { formatVersionMoment, formatVersionTime, getVersionDetail, getVersionTitle } from "./format";
/**
* History: name the current state, or pick any version to see it on the page, read-only, then restore it or
* go back to now. Restoring saves the current state as "Before restore" first, so it can be undone.
*/
/** A version as History lists it; resumes and letters share it. */
type HistoryVersion = Pick<VersionSummary, "id" | "kind" | "name" | "createdAt">;
/** What History shows and does for one document: its versions, and saving, restoring, renaming and deleting them. */
export type HistorySource = {
versions: HistoryVersion[] | undefined;
loading: boolean;
/** Locked documents can't be restored over. */
locked: boolean;
/** "The resume as it is", under Now. */
nowDetail: string;
/** Saves the current state as a named version. Throws what the request throws. */
save: (name: string) => Promise<void>;
/** Restores a version (the current state is kept as "Before restore" first). Throws what the request throws. */
restore: (versionId: string) => Promise<void>;
rename: (versionId: string, name: string) => Promise<void>;
remove: (versionId: string) => Promise<void>;
/** How a failure reads. */
errorMessage: (error: unknown) => string;
};
/** History for the resume in the editor. */
export function HistoryTab() {
return <HistoryTimeline source={useResumeHistory()} />;
}
function useResumeHistory(): HistorySource {
const resume = useCurrentResume();
const queryClient = useQueryClient();
const { i18n } = useLingui();
const selectedId = useEditorStore((state) => state.historyVersionId);
const setVersion = useEditorStore((state) => state.setHistoryVersion);
const [name, setName] = useState("");
const listKey = orpc.resume.listVersions.queryKey({ input: { resumeId: resume.id } });
const { data: versions, isPending: loading } = useQuery(
orpc.resume.listVersions.queryOptions({ input: { resumeId: resume.id } }),
);
const createVersion = useMutation(orpc.resume.createVersion.mutationOptions());
const restoreVersion = useMutation(orpc.resume.restoreVersion.mutationOptions());
const refresh = () => queryClient.invalidateQueries({ queryKey: listKey });
const selected = versions?.find((version) => version.id === selectedId) ?? null;
const when = (version: VersionSummary) => formatVersionTime(version.createdAt, i18n.locale);
const save = async () => {
const trimmed = name.trim();
if (!trimmed) return;
try {
return {
versions,
loading,
locked: resume.isLocked,
nowDetail: t`The resume as it is`,
errorMessage: getResumeErrorMessage,
save: async (name) => {
await savePendingChanges(resume.id);
await createVersion.mutateAsync({ resumeId: resume.id, name: trimmed });
setName("");
void queryClient.invalidateQueries({ queryKey: listKey });
toast.add({ description: t`Saved “${trimmed}”` });
} catch (error) {
toast.add({ type: "error", description: getResumeErrorMessage(error) });
}
};
const restore = async (version: VersionSummary) => {
try {
await orpc.resume.createVersion.call({ resumeId: resume.id, name });
void refresh();
},
restore: async (versionId) => {
await savePendingChanges(resume.id);
const restored = await restoreVersion.mutateAsync({ resumeId: resume.id, versionId: version.id });
const restored = await orpc.resume.restoreVersion.call({ resumeId: resume.id, versionId });
useResumeStore.getState().replaceResumeFromServer(restored as Resume);
queryClient.setQueryData(orpc.resume.getById.queryKey({ input: { id: resume.id } }), {
...restored,
applicationId: resume.applicationId ?? null,
});
void refresh();
},
rename: async (versionId, name) => {
await orpc.resume.renameVersion.call({ resumeId: resume.id, versionId, name });
void refresh();
},
remove: async (versionId) => {
await orpc.resume.deleteVersion.call({ resumeId: resume.id, versionId });
void refresh();
},
};
}
/**
* History: name the current state, or pick any version to see it on the page, read-only, then restore it or
* go back to now. Restoring saves the current state as "Before restore" first, so it can be undone.
*/
export function HistoryTimeline({ source }: { source: HistorySource }) {
const { i18n } = useLingui();
const selectedId = useEditorStore((state) => state.historyVersionId);
const setVersion = useEditorStore((state) => state.setHistoryVersion);
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const { versions, loading } = source;
const selected = versions?.find((version) => version.id === selectedId) ?? null;
const when = (version: HistoryVersion) => formatVersionTime(version.createdAt, i18n.locale);
const run = async (action: () => Promise<void>) => {
setBusy(true);
try {
await action();
} catch (error) {
toast.add({ type: "error", description: source.errorMessage(error) });
} finally {
setBusy(false);
}
};
const save = () => {
const trimmed = name.trim();
if (!trimmed) return;
void run(async () => {
await source.save(trimmed);
setName("");
toast.add({ description: t`Saved “${trimmed}”` });
});
};
const restore = (version: HistoryVersion) =>
void run(async () => {
await source.restore(version.id);
setVersion(null);
void queryClient.invalidateQueries({ queryKey: listKey });
toast.add({
description: t`Restored the version from ${formatVersionMoment(version.createdAt, i18n.locale)}. Your previous state is saved as “Before restore”.`,
});
} catch (error) {
toast.add({ type: "error", description: getResumeErrorMessage(error) });
}
};
});
return (
<div className="grid gap-3.5">
@@ -86,7 +143,7 @@ export function HistoryTab() {
className="flex gap-1.5"
onSubmit={(event) => {
event.preventDefault();
void save();
save();
}}
>
<Input
@@ -97,7 +154,7 @@ export function HistoryTab() {
onChange={(event) => setName(event.target.value)}
className="h-9"
/>
<Button type="submit" variant="secondary" disabled={!name.trim() || createVersion.isPending}>
<Button type="submit" variant="secondary" disabled={!name.trim() || busy}>
<Trans>Save</Trans>
</Button>
</form>
@@ -111,11 +168,7 @@ export function HistoryTab() {
</Trans>
</span>
<span className="flex flex-wrap gap-2">
<Button
size="sm"
disabled={resume.isLocked || restoreVersion.isPending}
onClick={() => void restore(selected)}
>
<Button size="sm" disabled={source.locked || busy} onClick={() => restore(selected)}>
<Trans>Restore this version</Trans>
</Button>
<Button
@@ -133,7 +186,7 @@ export function HistoryTab() {
<ol className="grid" aria-label={t`Versions`}>
<TimelineItem
title={t`Now`}
detail={t`The resume as it is`}
detail={source.nowDetail}
selected={!selected}
current
last={!versions?.length}
@@ -148,7 +201,7 @@ export function HistoryTab() {
selected={version.id === selectedId}
last={index === versions.length - 1}
onSelect={() => setVersion(version.id)}
menu={version.kind === "named" ? <NamedVersionMenu version={version} /> : null}
menu={version.kind === "named" ? <NamedVersionMenu version={version} source={source} /> : null}
/>
))}
</ol>
@@ -217,28 +270,18 @@ function TimelineItem({ title, detail, selected, last, onSelect, current, named,
}
/** Named versions are the user's own: they can be renamed or deleted. The rest expire on their own. */
function NamedVersionMenu({ version }: { version: VersionSummary }) {
const resume = useCurrentResume();
const queryClient = useQueryClient();
function NamedVersionMenu({ version, source }: { version: HistoryVersion; source: HistorySource }) {
const prompt = usePrompt();
const confirm = useConfirm();
const selectedId = useEditorStore((state) => state.historyVersionId);
const setVersion = useEditorStore((state) => state.setHistoryVersion);
const renameVersion = useMutation(orpc.resume.renameVersion.mutationOptions());
const deleteVersion = useMutation(orpc.resume.deleteVersion.mutationOptions());
const refresh = () =>
queryClient.invalidateQueries({ queryKey: orpc.resume.listVersions.queryKey({ input: { resumeId: resume.id } }) });
const title = getVersionTitle(version);
const failed = (error: unknown) => toast.add({ type: "error", description: source.errorMessage(error) });
const rename = async () => {
const name = (await prompt(t`Rename version`, { defaultValue: version.name ?? "" }))?.trim();
if (!name || name === version.name) return;
try {
await renameVersion.mutateAsync({ resumeId: resume.id, versionId: version.id, name });
void refresh();
} catch (error) {
toast.add({ type: "error", description: getResumeErrorMessage(error) });
}
await source.rename(version.id, name).catch(failed);
};
const remove = async () => {
@@ -248,12 +291,11 @@ function NamedVersionMenu({ version }: { version: VersionSummary }) {
});
if (!confirmed) return;
try {
await deleteVersion.mutateAsync({ resumeId: resume.id, versionId: version.id });
await source.remove(version.id);
if (selectedId === version.id) setVersion(null);
void refresh();
toast.add({ description: t`Deleted “${title}”` });
} catch (error) {
toast.add({ type: "error", description: getResumeErrorMessage(error) });
failed(error);
}
};
+22
View File
@@ -36,6 +36,7 @@ import { Route as DashboardSettingsRouteRouteImport } from "./routes/dashboard/s
import { Route as DashboardTrashRouteImport } from "./routes/dashboard/trash";
import { Route as TemplatesSplatRouteImport } from "./routes/templates/$";
import { Route as BuilderResumeIdIndexRouteImport } from "./routes/builder/$resumeId/index";
import { Route as BuilderLetterCoverLetterIdRouteImport } from "./routes/builder/letter/$coverLetterId";
import { Route as DashboardApplicationsIndexRouteImport } from "./routes/dashboard/applications/index";
import { Route as DashboardResumesIndexRouteImport } from "./routes/dashboard/resumes/index";
import { Route as DashboardSettingsAccountRouteImport } from "./routes/dashboard/settings/account";
@@ -180,6 +181,12 @@ const BuilderResumeIdIndexRoute = BuilderResumeIdIndexRouteImport.update({
path: "/",
getParentRoute: () => BuilderResumeIdRouteRoute,
} as any);
const BuilderLetterCoverLetterIdRoute =
BuilderLetterCoverLetterIdRouteImport.update({
id: "/builder/letter/$coverLetterId",
path: "/builder/letter/$coverLetterId",
getParentRoute: () => rootRouteImport,
} as any);
const DashboardApplicationsIndexRoute =
DashboardApplicationsIndexRouteImport.update({
id: "/applications/",
@@ -261,6 +268,7 @@ export interface FileRoutesByFullPath {
"/auth/": typeof AuthIndexRoute;
"/dashboard/": typeof DashboardIndexRoute;
"/dashboard/settings/integrations": typeof DashboardSettingsIntegrationsRouteRoute;
"/builder/letter/$coverLetterId": typeof BuilderLetterCoverLetterIdRoute;
"/dashboard/settings/account": typeof DashboardSettingsAccountRoute;
"/dashboard/settings/api-keys": typeof DashboardSettingsApiKeysRoute;
"/dashboard/settings/job-search": typeof DashboardSettingsJobSearchRoute;
@@ -294,6 +302,7 @@ export interface FileRoutesByTo {
"/auth": typeof AuthIndexRoute;
"/dashboard": typeof DashboardIndexRoute;
"/dashboard/settings/integrations": typeof DashboardSettingsIntegrationsRouteRoute;
"/builder/letter/$coverLetterId": typeof BuilderLetterCoverLetterIdRoute;
"/dashboard/settings/account": typeof DashboardSettingsAccountRoute;
"/dashboard/settings/api-keys": typeof DashboardSettingsApiKeysRoute;
"/dashboard/settings/job-search": typeof DashboardSettingsJobSearchRoute;
@@ -333,6 +342,7 @@ export interface FileRoutesById {
"/auth/": typeof AuthIndexRoute;
"/dashboard/": typeof DashboardIndexRoute;
"/dashboard/settings/integrations": typeof DashboardSettingsIntegrationsRouteRoute;
"/builder/letter/$coverLetterId": typeof BuilderLetterCoverLetterIdRoute;
"/dashboard/settings/account": typeof DashboardSettingsAccountRoute;
"/dashboard/settings/api-keys": typeof DashboardSettingsApiKeysRoute;
"/dashboard/settings/job-search": typeof DashboardSettingsJobSearchRoute;
@@ -372,6 +382,7 @@ export interface FileRouteTypes {
| "/auth/"
| "/dashboard/"
| "/dashboard/settings/integrations"
| "/builder/letter/$coverLetterId"
| "/dashboard/settings/account"
| "/dashboard/settings/api-keys"
| "/dashboard/settings/job-search"
@@ -405,6 +416,7 @@ export interface FileRouteTypes {
| "/auth"
| "/dashboard"
| "/dashboard/settings/integrations"
| "/builder/letter/$coverLetterId"
| "/dashboard/settings/account"
| "/dashboard/settings/api-keys"
| "/dashboard/settings/job-search"
@@ -443,6 +455,7 @@ export interface FileRouteTypes {
| "/auth/"
| "/dashboard/"
| "/dashboard/settings/integrations"
| "/builder/letter/$coverLetterId"
| "/dashboard/settings/account"
| "/dashboard/settings/api-keys"
| "/dashboard/settings/job-search"
@@ -462,6 +475,7 @@ export interface RootRouteChildren {
BuilderResumeIdRouteRoute: typeof BuilderResumeIdRouteRouteWithChildren;
UsernameSlugRoute: typeof UsernameSlugRoute;
TemplatesSplatRoute: typeof TemplatesSplatRoute;
BuilderLetterCoverLetterIdRoute: typeof BuilderLetterCoverLetterIdRoute;
}
declare module "@tanstack/react-router" {
@@ -655,6 +669,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof BuilderResumeIdIndexRouteImport;
parentRoute: typeof BuilderResumeIdRouteRoute;
};
"/builder/letter/$coverLetterId": {
id: "/builder/letter/$coverLetterId";
path: "/builder/letter/$coverLetterId";
fullPath: "/builder/letter/$coverLetterId";
preLoaderRoute: typeof BuilderLetterCoverLetterIdRouteImport;
parentRoute: typeof rootRouteImport;
};
"/dashboard/applications/": {
id: "/dashboard/applications/";
path: "/applications";
@@ -850,6 +871,7 @@ const rootRouteChildren: RootRouteChildren = {
BuilderResumeIdRouteRoute: BuilderResumeIdRouteRouteWithChildren,
UsernameSlugRoute: UsernameSlugRoute,
TemplatesSplatRoute: TemplatesSplatRoute,
BuilderLetterCoverLetterIdRoute: BuilderLetterCoverLetterIdRoute,
};
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
@@ -1,16 +1,14 @@
import type { EditorMode } from "@/features/resume/editor/store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router";
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
import { Button } from "@reactive-resume/ui/components/button";
import { ButtonGroup } from "@reactive-resume/ui/components/button-group";
import { Icon } from "@reactive-resume/ui/components/icon";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft";
import { useOpenIssueCount } from "@/features/resume/editor/check/use-check";
import { BackLink, DrawerControls } from "@/features/resume/editor/chrome";
import { useEditorStore } from "@/features/resume/editor/store";
import { useResumeExport } from "@/features/resume/export/use-resume-export";
import { BuilderAiAssistant } from "./ai-assistant";
@@ -60,28 +58,6 @@ export function EditorBar({ layout, pinnable }: EditorBarProps) {
);
}
/** Leaving the editor is navigation, so it's a link styled as an icon button. */
function BackLink() {
const label = t`Back to documents`;
return (
<Tooltip>
<TooltipTrigger
render={
<Link
to="/dashboard"
aria-label={label}
className={buttonVariants({ variant: "ghost", size: "icon", className: "text-ink-2" })}
/>
}
>
<Icon name="arrow_back" />
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
}
const MODE_ICONS = { write: "edit", design: "palette", check: "fact_check" } as const;
function ModeTabs() {
@@ -132,41 +108,6 @@ function UndoButton() {
);
}
/** Tablet: show or hide the panel drawer and, in landscape, pin it beside the page. */
function DrawerControls({ pinnable }: { pinnable: boolean }) {
const open = useEditorStore((state) => state.drawerOpen);
const pinned = useEditorStore((state) => state.drawerPinned) && pinnable;
const setOpen = useEditorStore((state) => state.setDrawerOpen);
const setPinned = useEditorStore((state) => state.setDrawerPinned);
return (
<>
{!pinned && (
<IconButton
icon={open ? "left_panel_close" : "left_panel_open"}
label={open ? t`Hide panel` : t`Show panel`}
aria-expanded={open}
className="text-ink-2"
onClick={() => setOpen(!open)}
/>
)}
{pinnable && (open || pinned) && (
<IconButton
icon="vertical_split"
label={t`Keep the panel beside the page`}
aria-pressed={pinned}
className={cn("text-ink-2", pinned && "bg-accent-soft text-accent-text")}
onClick={() => {
// Unpinning leaves the drawer open over the page.
setPinned(!pinned);
setOpen(true);
}}
/>
)}
</>
);
}
/** History lives in the Share & export sheet; the clock opens it there. */
function HistoryButton() {
const setShareTab = useEditorStore((state) => state.setShareTab);
@@ -3,13 +3,14 @@ import type { EditorMode, MobileView } from "@/features/resume/editor/store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { getRouteApi, Outlet } from "@tanstack/react-router";
import { useEffect, useSyncExternalStore } from "react";
import { useEffect } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Tabs, TabsContent } from "@reactive-resume/ui/components/tabs";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { usePreviewPausedStore } from "@/features/resume/builder/draft";
import { IssueStepper } from "@/features/resume/editor/check/page-layer";
import { useIsLandscape } from "@/features/resume/editor/chrome";
import { useEditorStore } from "@/features/resume/editor/store";
import { useEditorMode } from "@/features/resume/editor/use-editor-mode";
import { revealSelectionInPanel } from "@/features/resume/editor/write/reveal";
@@ -19,21 +20,6 @@ import { ModePanel } from "./mode-panels";
import { ShareSheet } from "./share-sheet";
import { useEditorHotkeys } from "./use-editor-hotkeys";
const LANDSCAPE_QUERY = "(orientation: landscape)";
function subscribeToOrientation(onChange: () => void) {
const list = window.matchMedia(LANDSCAPE_QUERY);
list.addEventListener("change", onChange);
return () => list.removeEventListener("change", onChange);
}
const useIsLandscape = () =>
useSyncExternalStore(
subscribeToOrientation,
() => window.matchMedia(LANDSCAPE_QUERY).matches,
() => false,
);
/**
* The editor: a 56px bar over a 400px panel and the page canvas (desktop). On tablets the panel is a 380px
* drawer over the page, pinnable beside it in landscape; on phones one view shows at a time, switched by
@@ -2,9 +2,8 @@ import type { EditorSelection } from "@/features/resume/editor/store";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Plural, Trans } from "@lingui/react/macro";
import { useHotkey } from "@tanstack/react-hotkeys";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useState } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
@@ -12,36 +11,17 @@ import { templates } from "@/dialogs/resume/template/data";
import { useCurrentBuilderResumeSelector, useResumeData } from "@/features/resume/builder/draft";
import { CheckPageLayer, PageViewToggle } from "@/features/resume/editor/check/page-layer";
import { ParserView } from "@/features/resume/editor/check/parser-view";
import { CANVAS_GUTTER, PAGE_WIDTH, useCanvasWidth, ZoomBar } from "@/features/resume/editor/chrome";
import { measureOverflow, runFit } from "@/features/resume/editor/design/fit";
import { PageOverlay } from "@/features/resume/editor/page-overlay";
import { markProposals } from "@/features/resume/editor/proposals/proposals";
import { useEditorStore, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from "@/features/resume/editor/store";
import { useEditorStore, ZOOM_MAX } from "@/features/resume/editor/store";
import { useEditorMode } from "@/features/resume/editor/use-editor-mode";
import { revealSelectionInPanel } from "@/features/resume/editor/write/reveal";
import { ResumePreview } from "@/features/resume/preview/preview";
import { formatVersionTime, getVersionTitle } from "@/features/resume/share/format";
import { orpc } from "@/libs/orpc/client";
// Page widths in PDF points; 1pt renders as 1 CSS px at 100%.
const PAGE_WIDTH = { a4: 595.28, letter: 612, "free-form": 595.28 } as const;
// Horizontal room the canvas keeps around the page: 40px each side, 16px on phones.
const CANVAS_GUTTER = { wide: 80, narrow: 32 } as const;
function useCanvasWidth() {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver(([entry]) => setWidth(entry?.contentRect.width ?? 0));
observer.observe(element);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
}
/**
* The page canvas: the live resume on the sunken desk, with a caption above the first page, a pointer layer
* that links lines to entries, and the zoom bar.
@@ -234,54 +214,3 @@ function OverflowChip({ authored, lines }: OverflowChipProps) {
</span>
);
}
type ZoomBarProps = {
fitScale: number;
pageCount: number;
};
/** − · Fit · + and the page count, floating 18px above the bottom of the canvas. Zoom runs from 60% to 150%. */
function ZoomBar({ fitScale, pageCount }: ZoomBarProps) {
const zoom = useEditorStore((state) => state.zoom);
const setZoom = useEditorStore((state) => state.setZoom);
const current = zoom === "fit" ? fitScale : zoom;
useHotkey("Mod+0", () => setZoom("fit"));
const buttonClassName =
"flex size-8 items-center justify-center rounded-[7px] text-ink-2 transition-colors duration-quick hover:bg-hover disabled:text-ink-3 disabled:hover:bg-transparent";
return (
<div className="absolute bottom-[18px] left-1/2 flex -translate-x-1/2 items-center gap-0.5 rounded-lg border border-line bg-raised p-1 shadow-e2">
<button
type="button"
aria-label={t`Zoom out`}
disabled={current <= ZOOM_MIN}
className={buttonClassName}
onClick={() => setZoom(current - ZOOM_STEP)}
>
<Icon name="remove" />
</button>
<button
type="button"
aria-label={t`Fit page to width`}
className="h-8 min-w-[52px] rounded-[7px] px-1.5 font-medium font-mono text-ink text-xs transition-colors duration-quick hover:bg-hover"
onClick={() => setZoom("fit")}
>
{zoom === "fit" ? <Trans>Fit</Trans> : `${Math.round(current * 100)}%`}
</button>
<button
type="button"
aria-label={t`Zoom in`}
disabled={current >= ZOOM_MAX}
className={buttonClassName}
onClick={() => setZoom(current + ZOOM_STEP)}
>
<Icon name="add" />
</button>
<span className="whitespace-nowrap px-2 font-mono text-ink-3 text-xs">
<Plural value={pageCount} one="# page" other="# pages" />
</span>
</div>
);
}
@@ -0,0 +1,69 @@
import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useEffect } from "react";
import z from "zod";
import { useLetterEditorStore } from "@/features/letters/store";
import { orpc } from "@/libs/orpc/client";
import { createNoindexFollowMeta } from "@/libs/seo";
import { LetterShell } from "./-components/letter-shell";
const searchSchema = z.object({
// Write is the default and stays out of the URL. Letters have no Check.
mode: z.enum(["write", "design"]).optional().catch(undefined),
// Opens History on this version, read-only; Applications' "Open" on what was sent links here.
version: z.string().optional().catch(undefined),
});
export const Route = createFileRoute("/builder/letter/$coverLetterId")({
component: RouteComponent,
validateSearch: searchSchema,
beforeLoad: ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
loader: async ({ params, context }) => {
// Always fetched fresh: linked details come from the resume as it reads now.
const letter = await context.queryClient.fetchQuery({
...orpc.coverLetters.getById.queryOptions({ input: { id: params.coverLetterId } }),
staleTime: 0,
});
return { name: letter.name };
},
head: ({ loaderData }) => ({
meta: loaderData
? [{ title: `${loaderData.name} - Reactive Resume` }, createNoindexFollowMeta()]
: [createNoindexFollowMeta()],
}),
});
function RouteComponent() {
const { coverLetterId } = Route.useParams();
const queryClient = useQueryClient();
const { data: letter } = useSuspenseQuery(orpc.coverLetters.getById.queryOptions({ input: { id: coverLetterId } }));
const loaded = useLetterEditorStore((state) => state.letter?.id === coverLetterId);
// The editor owns the letter from here; later refetches don't overwrite what's being typed.
useEffect(() => {
if (useLetterEditorStore.getState().letter?.id !== letter.id) useLetterEditorStore.getState().load(letter);
}, [letter]);
// Leaving saves what's pending, then lets lists (Documents, Applications) catch up.
useEffect(
() => () => {
void useLetterEditorStore
.getState()
.flush()
.finally(() => {
if (useLetterEditorStore.getState().letter?.id === coverLetterId) useLetterEditorStore.getState().reset();
void queryClient.invalidateQueries({ queryKey: orpc.coverLetters.key() });
void queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
void queryClient.invalidateQueries({ queryKey: orpc.applications.key() });
});
},
[coverLetterId, queryClient],
);
if (!loaded) return null;
return <LetterShell />;
}
@@ -0,0 +1,161 @@
import type { Template } from "@reactive-resume/schema/templates";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useEffect } from "react";
import { templateSchema } from "@reactive-resume/schema/templates";
import { buttonVariants } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { SwitchRow } from "@reactive-resume/ui/components/switch";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { templates } from "@/dialogs/resume/template/data";
import { useLetterEditorStore } from "@/features/letters/store";
import { useEditorStore } from "@/features/resume/editor/store";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
const failed = (error: unknown) =>
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Couldn't change the design. Try again.` }),
});
const updateDesign = (changes: { designLinked?: boolean; template?: Template }) =>
useLetterEditorStore.getState().change((letter) =>
client.coverLetters.update({
id: letter.id,
expectedRevision: letter.revision,
sessionId: useLetterEditorStore.getState().sessionId,
...changes,
}),
);
/**
* Design for letters: by default a letter matches its resume's design, changed on the resume. Turned off, the
* letter keeps a design of its own, starting with its template: hovering one previews it on the page, a click
* applies it.
*/
export function LetterDesignPanel() {
const letter = useLetterEditorStore((state) => state.letter);
const setPreview = useEditorStore((state) => state.setPreviewTemplate);
const { data: resumes } = useQuery(orpc.resume.list.queryOptions({ input: {} }));
// Leaving Design never leaves a preview on the page.
useEffect(() => () => setPreview(null), [setPreview]);
if (!letter) return null;
const resume = resumes?.find((item) => item.id === letter.sourceResumeId);
const current = letter.style.metadata.template;
const disabled = letter.isLocked;
const setLinked = (designLinked: boolean) =>
void updateDesign({ designLinked })
.then(() =>
toast.add({
description: designLinked
? t`The letter matches the resume's design again`
: t`The letter keeps this design as its own`,
}),
)
.catch(failed);
return (
<div className="divide-y divide-line">
<section aria-labelledby="letter-design-match" className="grid gap-3 px-4 py-5">
<h2 id="letter-design-match" className="font-semibold text-[15px]">
<Trans>Matching the resume</Trans>
</h2>
{letter.sourceResumeId ? (
<>
<SwitchRow
label={t`Match “${resume?.name ?? t`the resume`}”`}
description={
letter.designLinked
? t`Template, type and colors follow the resume, so the pair always matches.`
: t`The letter keeps a design of its own.`
}
checked={letter.designLinked}
disabled={disabled}
onCheckedChange={setLinked}
/>
{letter.designLinked && (
<Link
to="/builder/$resumeId"
params={{ resumeId: letter.sourceResumeId }}
search={{ mode: "design" }}
className={buttonVariants({ variant: "secondary", size: "sm", className: "w-fit" })}
>
<Icon name="palette" size={16} />
<Trans>Change the resume's design</Trans>
</Link>
)}
</>
) : (
<p className="text-ink-2 text-sm">
<Trans>Choose a resume in Write to match its design. Until then, the letter has its own.</Trans>
</p>
)}
</section>
{!letter.designLinked && (
<section aria-labelledby="letter-design-template" className="grid gap-3 px-4 py-5">
<h2 id="letter-design-template" className="font-semibold text-[15px]">
<Trans>Template</Trans>
</h2>
<fieldset
disabled={disabled}
className="m-0 grid min-w-0 grid-cols-2 gap-3 border-0 p-0"
onMouseLeave={() => setPreview(null)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setPreview(null);
}}
>
<legend className="sr-only">
<Trans>Template</Trans>
</legend>
{templateSchema.options.map((id) => {
const selected = id === current;
return (
<button
key={id}
type="button"
aria-pressed={selected}
onMouseEnter={() => setPreview(id === current ? null : id)}
onFocus={() => setPreview(id === current ? null : id)}
onClick={() => {
setPreview(null);
if (!selected) void updateDesign({ template: id }).catch(failed);
}}
className="group/template grid gap-1.5 text-start disabled:cursor-not-allowed disabled:opacity-60"
>
<span
className={cn(
"relative block aspect-[1/1.414] overflow-hidden rounded-md border bg-white transition-shadow duration-quick",
selected
? "border-accent shadow-[0_0_0_2px_var(--accent)]"
: "border-line group-hover/template:shadow-e2",
)}
>
<img
src={templates[id].imageUrl}
alt=""
loading="lazy"
className="size-full object-cover object-top"
/>
{selected && (
<span className="absolute end-1.5 top-1.5 grid size-5 place-items-center rounded-full bg-accent text-on-accent">
<Icon name="check" size={14} />
</span>
)}
</span>
<span className="font-medium text-sm leading-4">{templates[id].name}</span>
</button>
);
})}
</fieldset>
</section>
)}
</div>
);
}
@@ -0,0 +1,313 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { ButtonGroup } from "@reactive-resume/ui/components/button-group";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Icon } from "@reactive-resume/ui/components/icon";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { toast } from "@reactive-resume/ui/components/toast";
import { downloadWithAnchor } from "@reactive-resume/utils/file";
import { useLetterWords } from "@/features/letters/compose";
import { createLetterFile, letterFileName } from "@/features/letters/export";
import { useLetterEditorStore } from "@/features/letters/store";
import { BackLink, DrawerControls } from "@/features/resume/editor/chrome";
import { useEditorStore } from "@/features/resume/editor/store";
import { usePrompt } from "@/hooks/use-prompt";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
type LetterBarProps = {
layout: "desktop" | "tablet" | "mobile";
/** Tablet in landscape: the panel can be pinned beside the page. */
pinnable: boolean;
};
/**
* The 56px bar: back, the letter's name and save state · Write/Design · History, Share and Download PDF. The mode
* switch stays centered through a `1fr auto 1fr` grid.
*/
export function LetterBar({ layout, pinnable }: LetterBarProps) {
const setShareTab = useEditorStore((state) => state.setShareTab);
return (
<header className="grid h-(--editor-bar) grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-line border-b bg-surface px-3">
<div className="flex min-w-0 items-center gap-1.5">
<BackLink />
{layout === "tablet" && <DrawerControls pinnable={pinnable} />}
<div className="flex min-w-0 flex-col items-start">
<LetterMenu />
</div>
</div>
{layout === "mobile" ? (
<span />
) : (
<TabsList aria-label={t`Editor mode`} className="h-9">
<TabsTrigger value="write" className="px-4">
<Icon name="edit" size={18} />
<Trans>Write</Trans>
</TabsTrigger>
<TabsTrigger value="design" className="px-4">
<Icon name="palette" size={18} />
<Trans>Design</Trans>
</TabsTrigger>
</TabsList>
)}
<div className="flex items-center justify-end gap-1">
{layout === "desktop" && (
<IconButton icon="history" label={t`History`} className="text-ink-2" onClick={() => setShareTab("history")} />
)}
{layout === "desktop" ? (
<Button variant="secondary" className="gap-1.5" onClick={() => setShareTab("download")}>
<Icon name="ios_share" />
<Trans>Share</Trans>
</Button>
) : (
<IconButton icon="ios_share" label={t`Share`} onClick={() => setShareTab("download")} />
)}
<DownloadButtons compact={layout === "mobile"} />
</div>
</header>
);
}
/** Download PDF: the letter as it prints, with the sender's header, named First-Last-Cover-Letter.pdf. */
export function useDownloadLetter() {
const words = useLetterWords();
const [busy, setBusy] = useState(false);
const run = async () => {
const letter = useLetterEditorStore.getState().letter;
if (!letter || busy) return;
setBusy(true);
const toastId = toast.add({ type: "loading", description: t`Generating your PDF...` });
try {
const blob = await createLetterFile(letter, words, "pdf");
downloadWithAnchor(blob, `${letterFileName(letter, words)}.pdf`);
} catch {
toast.add({ type: "error", description: t`Could not generate the PDF. Please try again.` });
} finally {
setBusy(false);
toast.close(toastId);
}
};
return { run, busy };
}
function DownloadButtons({ compact }: { compact: boolean }) {
const download = useDownloadLetter();
const setShareTab = useEditorStore((state) => state.setShareTab);
if (compact) {
return (
<IconButton
icon="download"
label={t`Download PDF`}
shortcut="⌘P"
disabled={download.busy}
onClick={() => void download.run()}
/>
);
}
return (
<ButtonGroup aria-label={t`Download`}>
<Button loading={download.busy} className="gap-1.5" onClick={() => void download.run()}>
{!download.busy && <Icon name="download" />}
{download.busy ? <Trans>Preparing…</Trans> : <Trans>Download PDF</Trans>}
</Button>
<Button
size="icon"
disabled={download.busy}
aria-label={t`More download formats`}
className="w-8 border-s border-s-[oklch(1_0_0/0.25)]"
onClick={() => setShareTab("download")}
>
<Icon name="expand_more" />
</Button>
</ButtonGroup>
);
}
const failed = (error: unknown) =>
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Something went wrong. Try again.` }),
});
/** The name opens the letter's menu: rename, duplicate, lock and Move to Trash. The save state sits under it. */
function LetterMenu() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const prompt = usePrompt();
const id = useLetterEditorStore((state) => state.letter?.id ?? "");
const name = useLetterEditorStore((state) => state.letter?.name ?? "");
const isLocked = useLetterEditorStore((state) => state.letter?.isLocked ?? false);
const { edit, change, flush } = useLetterEditorStore.getState();
const ref = { type: "letter" as const, id };
const rename = async () => {
const next = await prompt(t`Rename letter`, { defaultValue: name });
const trimmed = next?.trim().slice(0, 100);
if (trimmed && trimmed !== name) edit({ name: trimmed });
};
const duplicate = async () => {
try {
await flush();
const copy = await client.coverLetters.duplicate({ id });
void queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
toast.add({ description: t`Duplicated` });
void navigate({ to: "/builder/letter/$coverLetterId", params: { coverLetterId: copy.id } });
} catch (error) {
failed(error);
}
};
// Locking moves the letter's revision on, so the letter is read again afterwards.
const setLocked = (locked: boolean) =>
change(async () => {
await client.documents.setLocked({ ...ref, isLocked: locked });
return client.coverLetters.getById({ id });
}).catch(failed);
// Undoable, so it doesn't ask first: the letter waits in Trash for 30 days.
const trash = async () => {
try {
await flush();
await client.documents.trash(ref);
void queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
void navigate({ to: "/dashboard" });
toast.add({
description: t`“${name}” moved to Trash`,
actionProps: {
children: t`Undo`,
onClick: () =>
void client.documents
.restore(ref)
.then(() => queryClient.invalidateQueries({ queryKey: orpc.documents.key() }))
.catch(failed),
},
});
} catch (error) {
failed(error);
}
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
type="button"
aria-label={t`Document menu: ${name}`}
className="-mx-1.5 flex min-w-0 max-w-[calc(100%+0.75rem)] flex-col items-start rounded-md px-1.5 py-0.5 text-start transition-colors duration-quick hover:bg-hover"
>
<span className="flex min-w-0 max-w-full items-center gap-1.5">
<span className="truncate font-semibold text-ink text-sm leading-[18px]">{name}</span>
{isLocked && <Icon name="lock" size={16} className="text-ink-3" />}
<Icon name="expand_more" size={16} className="text-ink-3" />
</span>
</button>
}
/>
<DropdownMenuContent align="start" className="w-60">
<DropdownMenuItem disabled={isLocked} onClick={() => void rename()}>
<Icon name="edit" />
<Trans>Rename…</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void duplicate()}>
<Icon name="content_copy" />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void setLocked(!isLocked)}>
<Icon name={isLocked ? "lock_open" : "lock"} />
{isLocked ? <Trans>Unlock editing</Trans> : <Trans>Lock editing</Trans>}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={isLocked} onClick={() => void trash()}>
<Icon name="delete" />
<Trans>Move to Trash</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<LetterSaveStatus />
</>
);
}
/** Saved, Saving…, Not saved · Retry, or Changed elsewhere · Reload (after which the letter reads as saved). */
function LetterSaveStatus() {
const status = useLetterEditorStore((state) => state.status);
const id = useLetterEditorStore((state) => state.letter?.id ?? "");
const reload = async () => {
try {
useLetterEditorStore.getState().load(await client.coverLetters.getById({ id }));
} catch (error) {
failed(error);
}
};
return (
<span role="status" aria-live="polite" className="flex min-w-0 items-center gap-1.5 text-ink-3 text-xs leading-4">
{status === "saving" && (
<>
<Spinner decorative className="size-3 border-[1.5px]" />
<Trans>Saving…</Trans>
</>
)}
{status === "saved" && (
<>
<Icon name="cloud_done" size={16} />
<Trans>Saved</Trans>
</>
)}
{status === "error" && (
<span className="flex min-w-0 items-center gap-1.5 text-danger-text">
<Icon name="sync_problem" size={16} />
<Trans>Not saved</Trans>
<span aria-hidden="true">·</span>
<button
type="button"
onClick={() => void useLetterEditorStore.getState().flush()}
aria-label={t`Retry saving`}
className="rounded-sm font-semibold underline underline-offset-2 hover:opacity-80"
>
<Trans>Retry</Trans>
</button>
</span>
)}
{status === "conflict" && (
<span className="flex min-w-0 items-center gap-1.5 text-warn-text">
<Icon name="sync_problem" size={16} />
<span className="truncate">
<Trans>Changed elsewhere</Trans>
</span>
<span aria-hidden="true">·</span>
<button
type="button"
onClick={() => void reload()}
className="rounded-sm font-semibold underline underline-offset-2 hover:opacity-80"
>
<Trans>Reload</Trans>
</button>
</span>
)}
</span>
);
}
@@ -0,0 +1,212 @@
import type { PageMap } from "@reactive-resume/pdf/page-map";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { coverLetterTextToHtml } from "@reactive-resume/resume/cover-letter";
import { Icon } from "@reactive-resume/ui/components/icon";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { templates } from "@/dialogs/resume/template/data";
import { letterPageData, useLetterWords } from "@/features/letters/compose";
import { useLetterEditorStore } from "@/features/letters/store";
import { useLetterMode } from "@/features/letters/use-letter-mode";
import { CANVAS_GUTTER, PAGE_WIDTH, useCanvasWidth, ZoomBar } from "@/features/resume/editor/chrome";
import { useEditorStore, ZOOM_MAX } from "@/features/resume/editor/store";
import { getScrollBehavior } from "@/features/resume/editor/write/reveal";
import { ResumePreview } from "@/features/resume/preview/preview";
import { formatVersionTime, getVersionTitle } from "@/features/resume/share/format";
import { orpc } from "@/libs/orpc/client";
/** The latest value, at most every `ms`: a streaming draft re-renders the page a few times a second, not per word. */
function useThrottled<T>(value: T, ms: number) {
const [shown, setShown] = useState(value);
const last = useRef(0);
useEffect(() => {
const timeout = window.setTimeout(
() => {
last.current = Date.now();
setShown(value);
},
Math.max(0, last.current + ms - Date.now()),
);
return () => window.clearTimeout(timeout);
}, [value, ms]);
return shown;
}
/**
* The letter on the desk, as it prints: sender header, recipient and date, greeting, body and sign-off. A draft
* shows in place on a green wash; History shows a version read-only; Design previews a hovered template.
*/
export function LetterPage() {
const letter = useLetterEditorStore((state) => state.letter);
const draft = useLetterEditorStore((state) => state.draft);
const words = useLetterWords();
const { i18n } = useLingui();
const reducedMotion = useReducedMotion();
const zoom = useEditorStore((state) => state.zoom);
const previewTemplate = useEditorStore((state) => state.previewTemplate);
const historyVersionId = useEditorStore((state) => state.historyVersionId);
const sheetOpen = useEditorStore((state) => state.shareTab !== null);
const rendered = useEditorStore((state) => state.rendered);
const setRendered = useEditorStore((state) => state.setRendered);
const breakpoint = useBreakpoint();
const [canvasRef, canvasWidth] = useCanvasWidth();
const { data: version } = useQuery({
...orpc.coverLetters.getVersion.queryOptions({
input: { id: letter?.id ?? "", versionId: historyVersionId ?? "" },
}),
enabled: Boolean(letter) && historyVersionId !== null,
});
const viewing = historyVersionId !== null && version?.id === historyVersionId ? version : null;
// Reduced motion puts the whole draft on the page at once.
const draftText =
draft.phase === "ready" || (draft.phase === "streaming" && !reducedMotion) ? draft.text || null : null;
const shownDraft = useThrottled(draftText, 400);
const data = useMemo(() => {
if (!letter) return undefined;
if (viewing) return letterPageData({ ...letter, ...viewing.data }, words);
const page = letterPageData(shownDraft ? { ...letter, content: coverLetterTextToHtml(shownDraft) } : letter, words);
return previewTemplate ? { ...page, metadata: { ...page.metadata, template: previewTemplate } } : page;
}, [letter, viewing, shownDraft, previewTemplate, words]);
if (!letter || !data) return null;
const isPhone = breakpoint === "mobile";
const format = data.metadata.page.format;
const gutter = isPhone ? CANVAS_GUTTER.narrow : CANVAS_GUTTER.wide;
const fitScale = canvasWidth > 0 ? Math.min(ZOOM_MAX, (canvasWidth - gutter) / PAGE_WIDTH[format]) : 1;
const pageScale = zoom === "fit" ? Math.max(0.25, fitScale) : zoom;
const formatLabel = { a4: "A4", letter: t`Letter`, "free-form": t`Free-form` }[format];
// Desktop: the page moves aside so it stays visible beside the Share & export sheet.
const shifted = sheetOpen && (breakpoint === "desktop" || breakpoint === "wide");
return (
<div className="relative h-full min-h-0 bg-sunken">
<div ref={canvasRef} className={cn("absolute inset-0 overflow-auto pt-7 pb-24", isPhone ? "px-4" : "px-10")}>
<ResumePreview
data={data}
includeCoverLetterHeader
pageLayout="vertical"
pageGap={24}
pageScale={pageScale}
className={cn(
"mx-auto w-fit transition-transform duration-emphasized ease-enter",
shifted && "-translate-x-[120px] rtl:translate-x-[120px]",
)}
pageClassName={cn("rounded-none shadow-page", viewing && "outline-2 outline-ink outline-offset-4")}
onRender={setRendered}
renderPageCaption={({ pageNumber }) =>
pageNumber === 1 ? (
<figcaption className="mb-2.5 flex min-h-8 flex-wrap items-center justify-center gap-2.5 text-center font-medium text-ink-3 text-xs">
{viewing ? (
<span
role="status"
className="flex h-8 items-center gap-2 rounded-lg bg-ink px-3 text-[13px] text-bg"
>
<Icon name="history" size={18} />
<Trans>
Viewing {formatVersionTime(viewing.createdAt, i18n.locale)} · {getVersionTitle(viewing)} ·
read-only
</Trans>
</span>
) : previewTemplate ? (
<span
role="status"
className="flex h-8 items-center gap-2 rounded-lg bg-ink px-3 text-[13px] text-bg"
>
<Icon name="visibility" size={18} />
<Trans>Previewing {templates[previewTemplate].name} · click to apply</Trans>
</span>
) : (
<Trans>Page 1 · {formatLabel}</Trans>
)}
</figcaption>
) : (
<figcaption className="mb-2.5 text-center font-medium text-ink-3 text-xs">
<Trans>Page {pageNumber}</Trans>
</figcaption>
)
}
renderPageOverlay={({ pageIndex, pageMap }) =>
viewing ? null : (
<LetterOverlay
pageIndex={pageIndex}
pageMap={pageMap}
itemId={letter.style.itemId}
washed={Boolean(shownDraft)}
/>
)
}
/>
</div>
<ZoomBar fitScale={fitScale} pageCount={Math.max(1, rendered.pageCount)} />
</div>
);
}
type LetterOverlayProps = { pageIndex: number; pageMap: PageMap | undefined; itemId: string; washed: boolean };
/**
* Pointer layer over the page: the sender's header leads to From, the letter to its body in the panel. A draft
* lies on a green wash until it's kept.
*/
function LetterOverlay({ pageIndex, pageMap, itemId, washed }: LetterOverlayProps) {
const [mode, setMode] = useLetterMode();
const page = pageMap?.pages[pageIndex];
if (!pageMap || !page || page.width <= 0 || page.height <= 0) return null;
const nodes = pageMap.nodes.filter(
(node) => node.page === pageIndex && (node.kind === "header" || (node.kind === "item" && node.itemId === itemId)),
);
const reveal = (target: "from" | "body") => {
if (mode !== "write") setMode("write");
useEditorStore.getState().setMobileView("write");
useEditorStore.getState().setDrawerOpen(true);
window.setTimeout(() => {
if (target === "body") {
const editor = document.getElementById("letter-body-editor");
if (editor) return editor.focus();
}
document
.getElementById(target === "from" ? "letter-from" : "letter-body")
?.scrollIntoView({ behavior: getScrollBehavior(), block: "start" });
}, 50);
};
return (
// A pointer shortcut, hidden from assistive tech: the panel reaches the same fields by keyboard.
<div aria-hidden="true" className="absolute inset-0">
{nodes.map((node) => (
// biome-ignore lint/a11y/noStaticElementInteractions: hidden from assistive tech, see above.
// biome-ignore lint/a11y/useKeyWithClickEvents: the panel offers the same by keyboard.
<div
key={`${node.key}:${node.y}`}
onClick={() => reveal(node.kind === "header" ? "from" : "body")}
className={cn(
"absolute cursor-pointer rounded-[4px] transition-[background-color,outline-color] duration-standard",
washed && node.kind === "item"
? "bg-[oklch(0.93_0.05_150/0.55)] mix-blend-multiply outline-[1.5px] outline-[oklch(0.5_0.1_150)] outline-solid"
: "hover:bg-[oklch(0.5_0.1_150/0.06)]",
)}
style={{
left: `calc(${(node.x / page.width) * 100}% - 4px)`,
top: `calc(${(node.y / page.height) * 100}% - 3px)`,
width: `calc(${(node.width / page.width) * 100}% + 8px)`,
height: `calc(${(node.height / page.height) * 100}% + 6px)`,
}}
/>
))}
</div>
);
}
@@ -0,0 +1,270 @@
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import type { IconName } from "@reactive-resume/ui/components/icon";
import type { LetterMode } from "@/features/letters/use-letter-mode";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useHotkey } from "@tanstack/react-hotkeys";
import { useEffect } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Tabs, TabsContent } from "@reactive-resume/ui/components/tabs";
import { toast } from "@reactive-resume/ui/components/toast";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { useLetterEditorStore } from "@/features/letters/store";
import { useLetterMode, useOpenLetterVersionFromUrl } from "@/features/letters/use-letter-mode";
import { usePreviewPausedStore } from "@/features/resume/builder/draft";
import { useIsLandscape } from "@/features/resume/editor/chrome";
import { useEditorStore } from "@/features/resume/editor/store";
import { LetterDesignPanel } from "./design-panel";
import { LetterBar, useDownloadLetter } from "./letter-bar";
import { LetterPage } from "./letter-page";
import { LetterShareSheet } from "./share-sheet";
import { LetterWritePanel } from "./write-panel";
/**
* The letter editor: the resume editor's shell with Write and Design (no Check). The panel holds who the letter is
* for, to and from, and its body; the page shows the letter as it prints.
*/
export function LetterShell() {
const [mode, setMode] = useLetterMode();
const breakpoint = useBreakpoint();
const layout = breakpoint === "mobile" ? "mobile" : breakpoint === "tablet" ? "tablet" : "desktop";
const landscape = useIsLandscape();
const pinnable = layout === "tablet" && landscape;
const pinned = useEditorStore((state) => state.drawerPinned) && pinnable;
const resetEditor = useEditorStore((state) => state.reset);
// Zoom, open sheets and the History version belong to one document.
useEffect(() => resetEditor, [resetEditor]);
useOpenLetterVersionFromUrl();
useLinkedUpdateNotice();
useUnsavedGuard();
return (
<Tabs value={mode} onValueChange={(value) => setMode(value as LetterMode)} className="contents">
<div className="grid h-svh grid-rows-[var(--editor-bar)_minmax(0,1fr)] overflow-hidden bg-bg">
<a
href="#main-content"
className="sr-only rounded-md bg-raised px-4 py-2 text-sm focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
>
<Trans>Skip to the page</Trans>
</a>
<LetterBar layout={layout} pinnable={pinnable} />
{(layout === "desktop" || pinned) && (
<div className="grid min-h-0 grid-cols-[var(--editor-panel)_minmax(0,1fr)] max-lg:grid-cols-[380px_minmax(0,1fr)]">
<TabsContent
value={mode}
aria-label={panelLabel(mode)}
className="relative min-h-0 overflow-y-auto border-line border-e bg-surface"
>
<ModePanel mode={mode} />
</TabsContent>
<main id="main-content" className="min-h-0 min-w-0">
<LetterPage />
</main>
</div>
)}
{layout === "tablet" && !pinned && <TabletBody mode={mode} />}
{layout === "mobile" && <MobileBody mode={mode} onModeChange={setMode} />}
<LetterShareSheet />
<LetterHotkeys onModeChange={setMode} />
</div>
</Tabs>
);
}
const panelLabel = (mode: LetterMode) => (mode === "design" ? t`Design` : t`Letter details`);
function ModePanel({ mode }: { mode: LetterMode }) {
return mode === "design" ? <LetterDesignPanel /> : <LetterWritePanel />;
}
function TabletBody({ mode }: { mode: LetterMode }) {
const drawerOpen = useEditorStore((state) => state.drawerOpen);
return (
<div className="relative min-h-0">
<main id="main-content" className="h-full min-w-0">
<LetterPage />
</main>
<TabsContent
value={mode}
aria-label={panelLabel(mode)}
inert={!drawerOpen}
className={cn(
"absolute inset-y-0 start-0 z-20 w-[380px] max-w-[calc(100%-3rem)] overflow-y-auto border-line border-e bg-surface shadow-e3 transition-transform duration-emphasized ease-enter",
!drawerOpen && "-translate-x-full rtl:translate-x-full",
)}
>
<ModePanel mode={mode} />
</TabsContent>
</div>
);
}
type MobileView = "write" | "page" | "design";
const MOBILE_TABS: { view: MobileView; icon: IconName }[] = [
{ view: "write", icon: "edit" },
{ view: "page", icon: "description" },
{ view: "design", icon: "palette" },
];
type MobileBodyProps = { mode: LetterMode; onModeChange: (mode: LetterMode) => void };
/** Phones: one view at a time, Write · Page · Design. The page stays mounted so zoom survives switching. */
function MobileBody({ mode, onModeChange }: MobileBodyProps) {
const stored = useEditorStore((state) => state.mobileView);
const view: MobileView = stored === "page" || stored === "design" || stored === "write" ? stored : mode;
const setView = useEditorStore((state) => state.setMobileView);
const setPreviewPaused = usePreviewPausedStore((state) => state.setPaused);
const labels: Record<MobileView, string> = { write: t`Write`, page: t`Page`, design: t`Design` };
useEffect(() => {
setPreviewPaused(view !== "page");
return () => setPreviewPaused(false);
}, [view, setPreviewPaused]);
return (
<div className="grid min-h-0 grid-rows-[minmax(0,1fr)_auto]">
<div className="relative min-h-0">
<main id="main-content" className={cn("h-full", view !== "page" && "invisible")}>
<LetterPage />
</main>
{view !== "page" && (
<TabsContent value={mode} className="absolute inset-0 overflow-y-auto bg-surface">
<ModePanel mode={mode} />
</TabsContent>
)}
</div>
<nav
aria-label={t`Editor views`}
className="flex border-line border-t bg-surface pb-[env(safe-area-inset-bottom)]"
>
{MOBILE_TABS.map(({ view: tab, icon }) => {
const active = tab === view;
return (
<button
key={tab}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => {
setView(tab);
if (tab !== "page") onModeChange(tab);
}}
className={cn(
"flex min-h-[52px] flex-1 flex-col items-center justify-center gap-0.5 text-[11px]",
active ? "font-semibold text-ink" : "text-ink-2",
)}
>
<span
className={cn(
"flex h-7 w-[52px] items-center justify-center rounded-full transition-colors duration-quick",
active && "bg-accent-soft text-accent-text",
)}
>
<Icon name={icon} size={24} filled={active} />
</span>
{labels[tab]}
</button>
);
})}
</nav>
</div>
);
}
/** 1 and 2 switch modes outside fields, ⌘P downloads the PDF, ⌘⇧E opens Download. Saving is automatic. */
function LetterHotkeys({ onModeChange }: { onModeChange: (mode: LetterMode) => void }) {
const download = useDownloadLetter();
const setShareTab = useEditorStore((state) => state.setShareTab);
useHotkey("1", () => onModeChange("write"));
useHotkey("2", () => onModeChange("design"));
useHotkey("Mod+P", () => void download.run());
useHotkey("Mod+Shift+E", () => setShareTab("download"));
useHotkey("Mod+S", () => {
void useLetterEditorStore.getState().flush();
toast.add({ type: "info", description: t`Your changes are saved automatically.`, id: "auto-save" });
});
return null;
}
/** Closing the tab with edits still on their way asks first. */
function useUnsavedGuard() {
useEffect(() => {
const onBeforeUnload = (event: BeforeUnloadEvent) => {
const { status, pending } = useLetterEditorStore.getState();
if (status === "saved" && Object.keys(pending).length === 0) return;
void useLetterEditorStore.getState().flush();
event.preventDefault();
};
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, []);
}
const SENDER_FIELDS = ["name", "headline", "email", "phone", "location"] as const;
type SenderField = (typeof SENDER_FIELDS)[number];
type SenderSnapshot = Partial<Record<SenderField, string>>;
const snapshotOf = (letter: CoverLetter): SenderSnapshot =>
Object.fromEntries(SENDER_FIELDS.map((field) => [field, letter.style.basics[field]]));
const readSnapshot = (key: string): SenderSnapshot | null => {
try {
return JSON.parse(window.localStorage.getItem(key) ?? "null") as SenderSnapshot | null;
} catch {
return null;
}
};
const changedMessage = (field: SenderField) =>
({
name: t`Your name changed on the resume. The letter updated too.`,
headline: t`Your headline changed on the resume. The letter updated too.`,
email: t`Your email address changed on the resume. The letter updated too.`,
phone: t`Your phone number changed on the resume. The letter updated too.`,
location: t`Your location changed on the resume. The letter updated too.`,
})[field];
/**
* C2: a linked letter says so, once, when details it takes from the resume changed since this device last showed
* it. The live link means there's nothing to refresh.
*/
function useLinkedUpdateNotice() {
const letterId = useLetterEditorStore((state) => state.letter?.id);
const basics = useLetterEditorStore((state) => state.letter?.style.basics);
useEffect(() => {
const letter = useLetterEditorStore.getState().letter;
if (!letterId || !letter?.senderLinked) return;
const before = readSnapshot(`letter-sender:${letter.id}`);
if (!before) return;
const now = snapshotOf(letter);
const changed = SENDER_FIELDS.filter((field) => before[field] !== undefined && before[field] !== now[field]);
const [only] = changed;
if (!only) return;
toast.add({
description:
changed.length === 1 ? changedMessage(only) : t`Your details changed on the resume. The letter updated too.`,
});
}, [letterId]);
// What this device has shown, for the next visit.
useEffect(() => {
const letter = useLetterEditorStore.getState().letter;
if (!letter || !basics) return;
try {
window.localStorage.setItem(`letter-sender:${letter.id}`, JSON.stringify(snapshotOf(letter)));
} catch {
// Storage can be unavailable; the notice is a nicety.
}
}, [basics]);
}
@@ -0,0 +1,214 @@
import type { ExportFormat } from "@/features/resume/export/use-resume-export";
import type { DownloadFormat, DownloadState } from "@/features/resume/share/download-tab";
import type { HistorySource } from "@/features/resume/share/history-tab";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@reactive-resume/ui/components/sheet";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { toast } from "@reactive-resume/ui/components/toast";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { downloadWithAnchor } from "@reactive-resume/utils/file";
import { cn } from "@reactive-resume/utils/style";
import { useLetterWords } from "@/features/letters/compose";
import { createLetterFile, letterFileName } from "@/features/letters/export";
import { useLetterEditorStore } from "@/features/letters/store";
import { useEditorStore } from "@/features/resume/editor/store";
import { createExportFile, getDefaultFileName, sanitizeFileName } from "@/features/resume/export/use-resume-export";
import {
DownloadActions,
FileNameField,
FormatRadioGroup,
getExportFormats,
} from "@/features/resume/share/download-tab";
import { HistoryTimeline } from "@/features/resume/share/history-tab";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
/**
* Share & export for letters: Download and History. Letters have no public link, so Share opens Download.
* 440px from the right; a full-height bottom sheet on phones.
*/
export function LetterShareSheet() {
const tab = useEditorStore((state) => state.shareTab);
const setTab = useEditorStore((state) => state.setShareTab);
const setHistoryVersion = useEditorStore((state) => state.setHistoryVersion);
const isPhone = useBreakpoint() === "mobile";
const history = useLetterHistory(tab === "history");
return (
<Sheet open={tab !== null} onOpenChange={(open) => !open && setTab(null)}>
<SheetContent
side={isPhone ? "bottom" : "right"}
closeLabel={t`Close`}
className={cn("gap-0", isPhone && "h-[calc(100svh-1.5rem)]")}
>
<SheetHeader className="px-5 pt-3.5 pb-2.5">
<SheetTitle>
<Trans>Share & export</Trans>
</SheetTitle>
</SheetHeader>
<Tabs
value={tab === "history" ? "history" : "download"}
onValueChange={(value) => {
// The page shows a version only while History is open.
if (value !== "history") setHistoryVersion(null);
setTab(value as "download" | "history");
}}
className="min-h-0 flex-1 gap-0"
>
<TabsList variant="line" aria-label={t`Share sections`} className="w-full justify-start gap-5 px-5">
<TabsTrigger value="download">
<Trans>Download</Trans>
</TabsTrigger>
<TabsTrigger value="history">
<Trans>History</Trans>
</TabsTrigger>
</TabsList>
<TabsContent value="download" className="overflow-y-auto p-5">
<LetterDownloadTab />
</TabsContent>
<TabsContent value="history" className="overflow-y-auto p-5">
<HistoryTimeline source={history} />
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
);
}
type LetterFormat = ExportFormat | "both";
const PAUSE_BETWEEN_FILES_MS = 400;
/**
* Download: the letter as PDF (or Word, Markdown, JSON), or, for a letter with a resume, both as two PDFs named to
* match: First-Last-Resume.pdf and First-Last-Cover-Letter.pdf.
*/
function LetterDownloadTab() {
const letter = useLetterEditorStore((state) => state.letter);
const words = useLetterWords();
const { data: resumes } = useQuery(orpc.resume.list.queryOptions({ input: {} }));
const [format, setFormat] = useState<LetterFormat>("pdf");
const [fileName, setFileName] = useState<string | null>(null);
const [state, setState] = useState<DownloadState>("idle");
if (!letter) return null;
const resumeId = letter.sourceResumeId;
const resumeName = resumes?.find((item) => item.id === resumeId)?.name ?? t`your resume`;
const [pdf, ...others] = getExportFormats();
const formats: DownloadFormat<LetterFormat>[] = [
...(pdf ? [pdf] : []),
...(resumeId
? [
{
id: "both" as const,
label: t`Resume + letter`,
extension: t`2 files`,
icon: "picture_as_pdf" as const,
description: t`This letter and “${resumeName}” as two PDFs, named to match.`,
},
]
: []),
...others.map((option) =>
option.id === "json"
? { ...option, description: t`A backup of this letter. Imports back into Reactive Resume as a letter.` }
: option,
),
];
const selected = formats.find((option) => option.id === format) ?? (formats[0] as DownloadFormat<LetterFormat>);
const name = fileName ?? letterFileName(letter, words);
const download = async (as: LetterFormat) => {
setState("busy");
try {
if (as === "both" && resumeId) {
const resume = await client.resume.getById({ id: resumeId });
downloadWithAnchor(await createExportFile(resume, "pdf"), `${getDefaultFileName(resume)}.pdf`);
// Two downloads in a row are more reliable a moment apart.
await new Promise((resolve) => window.setTimeout(resolve, PAUSE_BETWEEN_FILES_MS));
downloadWithAnchor(await createLetterFile(letter, words, "pdf"), `${letterFileName(letter, words)}.pdf`);
toast.add({ description: t`Downloaded your resume and this letter` });
} else if (as !== "both") {
const extension = formats.find((option) => option.id === as)?.extension ?? ".pdf";
const file = `${sanitizeFileName(name) || letterFileName(letter, words)}${extension}`;
downloadWithAnchor(await createLetterFile(letter, words, as), file);
toast.add({ description: t`Downloaded ${file}` });
}
setState("idle");
} catch {
setState("error");
}
};
return (
<div className="grid gap-4">
<FormatRadioGroup
formats={formats}
value={selected.id}
onChange={(value) => {
setFormat(value);
setState("idle");
}}
/>
{selected.id !== "both" && (
<FileNameField
value={name}
extension={selected.extension}
hint={<Trans>Recruiters see this name. Your name plus “Cover Letter” works well.</Trans>}
onChange={setFileName}
/>
)}
<DownloadActions
state={state}
label={selected.label}
onDownload={() => void download(selected.id)}
onDownloadPdf={selected.id === "pdf" ? undefined : () => void download("pdf")}
/>
</div>
);
}
/** History for the letter: sessions, named and sent versions, and restore points. */
function useLetterHistory(open: boolean): HistorySource {
const queryClient = useQueryClient();
const letter = useLetterEditorStore((state) => state.letter);
const locked = useLetterEditorStore((state) => state.letter?.isLocked ?? false);
const id = letter?.id ?? "";
const { data: versions, isPending: loading } = useQuery({
...orpc.coverLetters.listVersions.queryOptions({ input: { id } }),
enabled: Boolean(id) && open,
});
const refresh = () =>
queryClient.invalidateQueries({ queryKey: orpc.coverLetters.listVersions.queryKey({ input: { id } }) });
const { flush, change } = useLetterEditorStore.getState();
return {
versions,
loading,
locked,
nowDetail: t`The letter as it is`,
errorMessage: (error) => getOrpcErrorMessage(error, { fallback: t`Something went wrong. Try again.` }),
save: async (name) => {
await flush();
await client.coverLetters.createVersion({ id, name });
void refresh();
},
restore: async (versionId) => {
await change(() => client.coverLetters.restoreVersion({ id, versionId }));
void refresh();
},
rename: async (versionId, name) => {
await client.coverLetters.renameVersion({ id, versionId, name });
void refresh();
},
remove: async (versionId) => {
await client.coverLetters.deleteVersion({ id, versionId });
void refresh();
},
};
}
@@ -0,0 +1,648 @@
import type { CoverLetter } from "@reactive-resume/schema/cover-letter/data";
import type { ReactElement, ReactNode } from "react";
import type { Application } from "@/features/applications/types";
import type { LetterDraft } from "@/features/letters/store";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { ORPCError } from "@orpc/client";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useReducedMotion } from "motion/react";
import { useId, useRef, useState } from "react";
import { coverLetterTextToHtml, greetingName } from "@reactive-resume/resume/cover-letter";
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { NativeSelect } from "@reactive-resume/ui/components/native-select";
import { SwitchRow } from "@reactive-resume/ui/components/switch";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { templates } from "@/dialogs/resume/template/data";
import { applicationsListQueryOptions } from "@/features/applications/queries";
import { getStageColor, getStageLabel } from "@/features/applications/stages";
import { useLetterWords } from "@/features/letters/compose";
import { countWords, LETTER_LENGTH, letterLength } from "@/features/letters/length";
import { discardLetterDraft, startLetterDraft, useLetterEditorStore } from "@/features/letters/store";
import { useLetterMode } from "@/features/letters/use-letter-mode";
import { ACCENTS, FONT_PAIRINGS, matchFontPairing, rgbaToHex } from "@/features/resume/editor/design/presets";
import { RichTextEditor } from "@/features/resume/editor/write/rich-text-editor";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
const BODY_ID = "letter-body-editor";
const focusBody = () => window.setTimeout(() => document.getElementById(BODY_ID)?.focus(), 50);
const failed = (error: unknown) =>
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Couldn't save that change. Try again.` }),
});
/** Saves what's typed, then changes the letter on the server (links, the application, the resume). */
const updateLetter = (changes: Omit<Parameters<typeof client.coverLetters.update>[0], "id" | "expectedRevision">) =>
useLetterEditorStore.getState().change((letter) =>
client.coverLetters.update({
id: letter.id,
expectedRevision: letter.revision,
sessionId: useLetterEditorStore.getState().sessionId,
...changes,
}),
);
type SectionProps = { id: string; title: ReactNode; aside?: ReactNode; children: ReactNode };
function Section({ id, title, aside, children }: SectionProps) {
return (
<section aria-labelledby={id} className="grid gap-3 px-4 py-5">
<div className="flex items-center justify-between gap-3">
<h2 id={id} className="font-semibold text-ink-3 text-xs uppercase tracking-[0.04em]">
{title}
</h2>
{aside}
</div>
{children}
</section>
);
}
/**
* Write: who the letter is for (the application), to (recipient, company, date) and from (the resume, live or
* copied), then the body with its length guide and a note on the design. The page beside it updates as you type.
*/
export function LetterWritePanel() {
const letter = useLetterEditorStore((state) => state.letter);
const draft = useLetterEditorStore((state) => state.draft);
const { data: applications } = useQuery(applicationsListQueryOptions());
if (!letter) return null;
const application = applications?.find((item) => item.id === letter.sourceApplicationId) ?? null;
const disabled = letter.isLocked;
return (
<div className="divide-y divide-line">
{letter.isLocked && (
<p className="flex items-center gap-2 bg-sunken px-4 py-2.5 text-ink-2 text-sm">
<Icon name="lock" size={18} />
<Trans>This letter is locked. Unlock it from its menu to edit.</Trans>
</p>
)}
<ForSection letter={letter} application={application} applications={applications ?? []} disabled={disabled} />
<ToSection letter={letter} application={application} disabled={disabled} />
<FromSection letter={letter} disabled={disabled} />
<BodySection letter={letter} application={application} disabled={disabled} />
{/* While a draft shows, the length is the draft's. */}
<LengthSection content={draft.phase === "streaming" || draft.phase === "ready" ? draft.text : letter.content} />
<DesignNote letter={letter} />
</div>
);
}
type ForSectionProps = {
letter: CoverLetter;
application: Application | null;
applications: Application[];
disabled: boolean;
};
/** FOR: the application the letter is for. Linking one fills the recipient; without one, drafting uses the resume. */
function ForSection({ letter, application, applications, disabled }: ForSectionProps) {
const choices = applications.filter((item) => item.status !== "closed" || item.id === application?.id);
const choose = (next: Application | null) =>
void updateLetter({
applicationId: next?.id ?? null,
// The recipient comes from the application: its company, and its first contact when it has one.
...(next && letter.layout === "structured"
? { recipientCompany: next.company, recipientName: next.contacts[0]?.name ?? "" }
: {}),
}).catch(failed);
const menu = (trigger: ReactElement) => (
<DropdownMenu>
<DropdownMenuTrigger disabled={disabled} render={trigger} />
<DropdownMenuContent align="end" className="max-h-80 w-72 overflow-y-auto">
{choices.map((item) => (
<DropdownMenuItem key={item.id} onClick={() => choose(item)}>
<Icon name="work" />
<span className="grid min-w-0">
<span className="truncate">{item.role}</span>
<span className="truncate text-ink-3 text-xs">{item.company}</span>
</span>
</DropdownMenuItem>
))}
{choices.length === 0 && (
<p className="px-2 py-1.5 text-ink-2 text-sm">
<Trans>No applications yet. Add one in Applications first.</Trans>
</p>
)}
{application && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => choose(null)}>
<Icon name="link_off" />
<Trans>No application</Trans>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
return (
<Section id="letter-for" title={<Trans>For</Trans>}>
{application ? (
<div className="flex items-center gap-3 rounded-xl border border-line p-3">
<span
aria-hidden="true"
className="grid size-9 shrink-0 place-items-center rounded-lg bg-sunken font-semibold text-ink-2"
>
{application.company.charAt(0).toUpperCase()}
</span>
<span className="grid min-w-0 flex-1">
<span className="truncate font-medium text-sm">{application.role}</span>
<span className="flex min-w-0 items-center gap-1.5 text-ink-3 text-xs">
<span className="truncate">{application.company}</span>
<span aria-hidden="true">·</span>
<span
aria-hidden="true"
className="size-1.5 shrink-0 rounded-full"
style={{ background: getStageColor(application.status) }}
/>
{getStageLabel(application.status)}
</span>
</span>
{menu(
<Button size="sm" variant="secondary">
<Trans>Change</Trans>
</Button>,
)}
</div>
) : (
<>
{menu(
<Button variant="secondary" className="w-full justify-start gap-2">
<Icon name="work" />
<Trans>Link an application</Trans>
<Icon name="expand_more" className="ms-auto text-ink-3" />
</Button>,
)}
<p className="text-ink-3 text-xs">
<Trans>Without one, you fill in the recipient yourself and drafting uses only your resume.</Trans>
</p>
</>
)}
</Section>
);
}
type LetterSectionProps = { letter: CoverLetter; application: Application | null; disabled: boolean };
/** TO: name or team, company and date for structured letters; older (freeform) letters keep their recipient block. */
function ToSection({ letter, application, disabled }: LetterSectionProps) {
const id = useId();
const edit = useLetterEditorStore((state) => state.edit);
if (letter.layout === "freeform") {
return (
<Section id="letter-to" title={<Trans>To</Trans>}>
<RichTextEditor
label={t`Recipient`}
value={letter.recipient}
disabled={disabled}
heightClassName="min-h-[64px] max-h-[200px]"
onChange={(recipient) => edit({ recipient })}
/>
<p className="text-ink-3 text-xs">
<Trans>This letter keeps its recipient as written.</Trans>
</p>
</Section>
);
}
return (
<Section id="letter-to" title={<Trans>To</Trans>}>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 grid gap-1.5">
<Label htmlFor={`${id}-name`}>
<Trans>Name or team</Trans>
</Label>
<Input
id={`${id}-name`}
value={letter.recipientName}
maxLength={200}
placeholder={t`Hiring team`}
disabled={disabled}
onChange={(event) => edit({ recipientName: event.target.value })}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${id}-company`}>
<Trans>Company</Trans>
</Label>
<Input
id={`${id}-company`}
value={letter.recipientCompany}
maxLength={200}
disabled={disabled}
onChange={(event) => edit({ recipientCompany: event.target.value })}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor={`${id}-date`}>
<Trans>Date</Trans>
</Label>
<Input
id={`${id}-date`}
type="date"
value={letter.letterDate ?? ""}
disabled={disabled}
onChange={(event) => edit({ letterDate: event.target.value || null })}
/>
</div>
</div>
<p className="text-ink-3 text-xs">
{application ? (
<Trans>Filled from the application. The greeting follows the name.</Trans>
) : (
<Trans>The greeting follows the name.</Trans>
)}
</p>
</Section>
);
}
/** FROM: the resume the sender's details come from, and whether they stay live or are copied into the letter. */
function FromSection({ letter, disabled }: { letter: CoverLetter; disabled: boolean }) {
const id = useId();
const { data: resumes } = useQuery(orpc.resume.list.queryOptions({ input: {} }));
const resume = resumes?.find((item) => item.id === letter.sourceResumeId);
const setResume = (resumeId: string | null) =>
void updateLetter({
resumeId,
// A letter taking a resume for the first time takes its details and design with it.
...(resumeId && !letter.sourceResumeId ? { senderLinked: true, designLinked: true } : {}),
}).catch(failed);
const setLinked = (senderLinked: boolean) =>
void updateLetter({ senderLinked })
.then(() =>
toast.add({
description: senderLinked ? t`Linked to the resume again` : t`Sender details copied into the letter`,
}),
)
.catch(failed);
return (
<Section id="letter-from" title={<Trans>From</Trans>}>
<div className="grid gap-1.5">
<Label htmlFor={`${id}-resume`}>
<Trans>Resume</Trans>
</Label>
<NativeSelect
id={`${id}-resume`}
value={letter.sourceResumeId ?? ""}
disabled={disabled}
onChange={(event) => setResume(event.target.value || null)}
>
<option value="">{t`No resume`}</option>
{resumes?.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</NativeSelect>
</div>
{letter.sourceResumeId ? (
<SwitchRow
label={t`Use details from “${resume?.name ?? t`the resume`}”`}
description={
letter.senderLinked
? t`Linked. Name, email, phone and city update when the resume does.`
: t`Off. The letter keeps its own copy of your details.`
}
checked={letter.senderLinked}
disabled={disabled}
onCheckedChange={setLinked}
/>
) : (
<p className="text-ink-3 text-xs">
<Trans>Your name and contact details come from a resume.</Trans>
</p>
)}
</Section>
);
}
/**
* The body, edited here while the page updates. Empty, it offers a draft from the posting and the resume. A draft
* shows on a green wash until it's kept: it never replaces typed text by itself.
*/
function BodySection({ letter, application, disabled }: LetterSectionProps) {
const words = useLetterWords();
const edit = useLetterEditorStore((state) => state.edit);
const draft = useLetterEditorStore((state) => state.draft);
const [writing, setWriting] = useState(false);
const structured = letter.layout === "structured";
const name = greetingName(letter.recipientName);
const showEditor = writing || countWords(letter.content) > 0;
return (
<Section id="letter-body" title={<Trans>Letter</Trans>}>
{structured && <p className="text-ink-2 text-sm">{name ? words.greeting(name) : words.teamGreeting}</p>}
{draft.phase !== "idle" ? (
<DraftBox
draft={draft}
letter={letter}
application={application}
onKeep={(text) => {
edit({ content: coverLetterTextToHtml(text) });
discardLetterDraft();
setWriting(true);
toast.add({ description: t`Draft kept. Edit anything you like.` });
focusBody();
}}
/>
) : showEditor ? (
<RichTextEditor
id={BODY_ID}
label={t`Letter body`}
value={letter.content}
disabled={disabled}
heightClassName="min-h-[240px]"
onChange={(content) => edit({ content })}
/>
) : (
<EmptyBody
letter={letter}
application={application}
disabled={disabled}
onWrite={() => {
setWriting(true);
focusBody();
}}
/>
)}
{structured && (
<p className="text-ink-2 text-sm">
{words.signOff}
{letter.style.basics.name && (
<>
<br />
{letter.style.basics.name}
</>
)}
</p>
)}
</Section>
);
}
type EmptyBodyProps = { letter: CoverLetter; application: Application | null; disabled: boolean; onWrite: () => void };
function EmptyBody({ letter, application, disabled, onWrite }: EmptyBodyProps) {
const { hasUsableProvider } = useHasUsableAiProvider();
const hasResume = Boolean(letter.sourceResumeId);
const company = application?.company;
const canDraft = hasResume || Boolean(application);
return (
<div className="grid justify-items-start gap-3 rounded-xl border border-line-2 border-dashed p-4">
<p className="text-ink-2 text-sm">
{company && hasResume ? (
<Trans>Start typing, or draft from what we know: the {company} posting and your resume.</Trans>
) : company ? (
<Trans>Start typing, or draft from what we know: the {company} posting.</Trans>
) : hasResume ? (
<Trans>Start typing, or draft from what we know: your resume.</Trans>
) : (
<Trans>Start typing. To draft one, link a resume or an application first.</Trans>
)}
</p>
<div className="flex flex-wrap gap-1.5">
{canDraft && hasUsableProvider && (
<Button
size="sm"
disabled={disabled}
className="bg-accent-soft text-accent-text hover:bg-accent-soft hover:brightness-95"
onClick={() => void startLetterDraft("draft")}
>
<Icon name="auto_awesome" size={16} />
{application ? <Trans>Draft from the posting</Trans> : <Trans>Draft from your resume</Trans>}
</Button>
)}
<Button size="sm" variant="ghost" disabled={disabled} onClick={onWrite}>
<Trans>Write it myself</Trans>
</Button>
</div>
{canDraft && !hasUsableProvider && (
<p className="text-ink-3 text-xs">
<Trans>Drafting needs an AI provider.</Trans>{" "}
<Link to="/dashboard/settings/integrations" className={buttonVariants({ variant: "link", size: "sm" })}>
<Trans>Open AI settings</Trans>
</Link>
</p>
)}
</div>
);
}
type DraftBoxProps = {
draft: Exclude<LetterDraft, { phase: "idle" }>;
letter: CoverLetter;
application: Application | null;
onKeep: (text: string) => void;
};
/** The draft on its green wash, then the dark chip: Keep, Shorter, More personal, Discard. */
function DraftBox({ draft, letter, application, onKeep }: DraftBoxProps) {
const reducedMotion = useReducedMotion();
// Retrying repeats the last request.
const last = useRef<{ variant: "draft" | "shorter" | "personal"; previous?: string }>({ variant: "draft" });
const run = (variant: "draft" | "shorter" | "personal", previous?: string) => {
last.current = previous ? { variant, previous } : { variant };
void startLetterDraft(variant, previous);
};
if (draft.phase === "failed") {
const gateway = draft.error instanceof ORPCError && draft.error.code === "BAD_GATEWAY";
return (
<div role="alert" className="grid gap-2.5 rounded-xl bg-danger-soft p-3 text-[13px] text-danger-text">
<span className="flex gap-2">
<Icon name="error" size={18} className="shrink-0" />
{gateway && draft.provider ? (
<Trans>Drafting stopped: {draft.provider} didn't respond. Nothing on the page changed.</Trans>
) : (
<Trans>Drafting stopped. Nothing on the page changed.</Trans>
)}
</span>
<div className="flex gap-1.5">
<Button size="sm" variant="secondary" onClick={() => run(last.current.variant, last.current.previous)}>
<Trans>Try again</Trans>
</Button>
<Button size="sm" variant="ghost" onClick={discardLetterDraft}>
<Trans>Dismiss</Trans>
</Button>
</div>
</div>
);
}
const streaming = draft.phase === "streaming";
// Reduced motion shows the whole draft at once.
const text = streaming && reducedMotion ? "" : draft.text;
const sources =
letter.sourceResumeId && application
? t`Draft · uses only your resume and the posting`
: application
? t`Draft · uses only the posting`
: t`Draft · uses only your resume`;
return (
<div className="grid gap-2">
{draft.phase === "ready" && (
<div className="flex flex-wrap items-center gap-1 rounded-xl bg-ink p-1 ps-3 text-bg shadow-e3">
<span className="me-auto flex items-center gap-1.5 py-1 font-medium text-[13px]">
<Icon name="auto_awesome" size={16} />
{sources}
</span>
<button
type="button"
onClick={() => onKeep(draft.text)}
className="h-8 rounded-lg bg-accent px-3 font-semibold text-[13px] text-on-accent hover:bg-accent-hover"
>
<Trans>Keep</Trans>
</button>
{(
[
["shorter", t`Shorter`],
["personal", t`More personal`],
] as const
).map(([variant, label]) => (
<button
key={variant}
type="button"
onClick={() => run(variant, draft.text)}
className="h-8 rounded-lg px-2.5 text-[13px] hover:bg-[oklch(1_0_0/0.12)]"
>
{label}
</button>
))}
<button
type="button"
onClick={discardLetterDraft}
className="h-8 rounded-lg px-2.5 text-[13px] hover:bg-[oklch(1_0_0/0.12)]"
>
<Trans>Discard</Trans>
</button>
</div>
)}
<div
aria-live="polite"
aria-busy={streaming}
className={cn(
"min-h-[120px] whitespace-pre-wrap rounded-lg bg-accent-soft px-3 py-2.5 text-sm leading-relaxed transition-[outline-color] duration-standard",
draft.phase === "ready" ? "outline-[1.5px] outline-accent outline-solid" : "outline-transparent",
)}
>
{text}
{streaming && (
<span className="text-ink-3">
{reducedMotion ? <Trans>Drafting…</Trans> : <span aria-hidden="true">▍</span>}
</span>
)}
</div>
{streaming && (
<Button size="sm" variant="ghost" className="w-fit" onClick={discardLetterDraft}>
<Trans>Stop</Trans>
</Button>
)}
</div>
);
}
/** Length: the word count against the 180 to 320 band most recruiters read, as a hint rather than a rule. */
function LengthSection({ content }: { content: string }) {
const words = countWords(content);
const length = letterLength(words);
const color = length === "empty" ? "text-ink-3" : length === "comfortable" ? "text-accent-text" : "text-warn-text";
const fill = length === "empty" ? "bg-line-2" : length === "comfortable" ? "bg-accent" : "bg-warn";
const percent = (value: number) => `${(value / LETTER_LENGTH.scale) * 100}%`;
return (
<Section
id="letter-length"
title={<Trans>Length</Trans>}
aside={
<span className={cn("font-medium font-mono text-xs", color)}>
<Plural value={words} one="# word" other="# words" />
</span>
}
>
<div aria-hidden="true" className="relative h-1.5 overflow-hidden rounded-full bg-sunken">
<span
className="absolute inset-y-0 bg-line-2"
style={{
insetInlineStart: percent(LETTER_LENGTH.min),
width: percent(LETTER_LENGTH.max - LETTER_LENGTH.min),
}}
/>
<span
className={cn("absolute inset-y-0 start-0 rounded-full transition-[width] duration-standard", fill)}
style={{ width: percent(Math.min(words, LETTER_LENGTH.scale)) }}
/>
</div>
<p className="text-ink-3 text-xs">
{length === "empty" ? (
<Trans>Most recruiters read 180–320 words. The shaded band shows the range.</Trans>
) : length === "short" ? (
<Trans>Short and direct. Fine if the posting asks for brevity.</Trans>
) : length === "comfortable" ? (
<Trans>A comfortable length, and it fits on one page.</Trans>
) : (
<Trans>Getting long. Try “Shorter”, or cut the least specific paragraph.</Trans>
)}
</p>
</Section>
);
}
/** DESIGN: what the letter looks like, and where to change it. */
function DesignNote({ letter }: { letter: CoverLetter }) {
const [, setMode] = useLetterMode();
const { metadata } = letter.style;
const pairing = FONT_PAIRINGS.find((option) => option.id === matchFontPairing(metadata))?.label;
const accent = ACCENTS.find((option) => option.hex === rgbaToHex(metadata.design.colors.primary))?.label;
const summary = [templates[metadata.template].name, pairing && t`${pairing} type`, accent].filter(Boolean).join(", ");
return (
<Section id="letter-design" title={<Trans>Design</Trans>}>
<p className="text-ink-2 text-sm">
{letter.designLinked ? (
<Trans>Matches the resume ({summary}).</Trans>
) : (
<Trans>Its own design ({summary}).</Trans>
)}{" "}
<button
type="button"
onClick={() => setMode("design")}
className="font-medium text-accent-text underline underline-offset-2"
>
<Trans>Change it in Design.</Trans>
</button>
</p>
</Section>
);
}
+7 -1
View File
@@ -1,5 +1,5 @@
import type { DocumentsSearch } from "@/features/documents/documents-page";
import { createFileRoute, stripSearchParams } from "@tanstack/react-router";
import { createFileRoute, redirect, stripSearchParams } from "@tanstack/react-router";
import z from "zod";
import { DocumentsPage } from "@/features/documents/documents-page";
@@ -13,9 +13,15 @@ export const Route = createFileRoute("/dashboard/")({
sort: z.enum(["edited", "name", "created"]).default("edited").catch("edited"),
// Without one, the page uses the last view picked on this device.
view: z.enum(["grid", "list"]).optional().catch(undefined),
// Older links opened letters here; they open in the letter editor now.
letter: z.string().optional().catch(undefined),
}),
search: { middlewares: [stripSearchParams(defaults)] },
beforeLoad: ({ search }) => {
if (search.letter) {
throw redirect({ to: "/builder/letter/$coverLetterId", params: { coverLetterId: search.letter }, replace: true });
}
},
component: RouteComponent,
});