mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 02:33:47 +10:00
feat(web): letters on the editor shell, with drafting, History and both files to download
This commit is contained in:
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
/** 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>
|
||||
|
||||
@@ -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) });
|
||||
@@ -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> </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");
|
||||
});
|
||||
});
|
||||
@@ -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(/ | /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";
|
||||
}
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user