feat(web): documents home, trash and a new-document dialog

The dashboard becomes Documents: resumes and saved letters in one
library, in a new app shell (a 240px sidebar, an icon rail on tablets and
bottom tabs on phones) with Documents, Applications, Trash (when it has
items), New (N) and the account row.

- Documents: All / Resumes / Letters with counts, search (/) across
  titles, tags and linked applications, sort, grid or list (remembered on
  the device), tag chips once tags exist, cards with the real first page,
  "Resume · Edited 2h ago", the linked application, lock and "New" badges,
  and the first-run screen. A file dropped anywhere on the page imports.
- One menu on cards, rows and right-click/long-press: Open, Rename
  (inline), Duplicate, Copy for a job… (resumes) or Link to application…
  (letters), Tags…, Lock editing, and Move to Trash with Undo.
- Trash lists days left, with Restore and Delete now (asks once).
- New: import a file in three labelled steps with the result and flagged
  fields (resumes, and saved letters' JSON), copy a resume for a job,
  start blank (named after its headline until renamed), a new letter, or
  a sample resume.

Also:
- /dashboard/resumes and /dashboard/cover-letters redirect to Documents.
- Settings pages sit behind a tab strip until they're regrouped, and the
  account menu gains Settings.
- A letter written inside a resume can be copied to Documents from its
  entry menu (Q3k), replacing the library's copy action.
- The builder's document menu and the letter editor move documents to
  Trash instead of deleting them.
- Dialogs reopened right after closing start fresh.
- Removed: the resume and letter libraries and the old create and import
  dialogs.
This commit is contained in:
Amruth Pillai
2026-09-28 20:38:54 +02:00
parent d4406c729b
commit daa7d331f3
65 changed files with 2794 additions and 2624 deletions
@@ -0,0 +1,6 @@
import type { AnyDialogRendererEntry } from "../schemas";
import { NewDocumentDialog } from "@/features/documents/new-document-dialog";
export const documentDialogRenderers: readonly AnyDialogRendererEntry[] = [
{ type: "document.new", render: ({ data }) => <NewDocumentDialog data={data} /> },
];
+3 -2
View File
@@ -1,15 +1,16 @@
import { Fragment } from "react";
import { Dialog } from "@reactive-resume/ui/components/dialog";
import { renderDialog } from "./renderers";
import { useDialogStore } from "./store";
export function DialogManager() {
const { open, activeDialog, onOpenChange } = useDialogStore();
const { open, activeDialog, openCount, onOpenChange } = useDialogStore();
const DialogContent = renderDialog(activeDialog);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{DialogContent}
<Fragment key={openCount}>{DialogContent}</Fragment>
</Dialog>
);
}
+2 -1
View File
@@ -1,10 +1,11 @@
import type { DialogSchema } from "./schemas";
import { apiKeyDialogRenderers } from "./api-key/registry";
import { authDialogRenderers } from "./auth/registry";
import { documentDialogRenderers } from "./document/registry";
import { resumeDialogRenderers } from "./resume/registry";
const dialogRendererByType = new Map(
[...authDialogRenderers, ...apiKeyDialogRenderers, ...resumeDialogRenderers].map(
[...authDialogRenderers, ...apiKeyDialogRenderers, ...documentDialogRenderers, ...resumeDialogRenderers].map(
(renderer) => [renderer.type, renderer] as const,
),
);
@@ -1,262 +0,0 @@
// @vitest-environment happy-dom
import type { AnchorHTMLAttributes, ReactNode } from "react";
import { fireEvent, render, 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { Dialog } from "@reactive-resume/ui/components/dialog";
import { useDialogStore } from "@/dialogs/store";
import { ConfirmDialogProvider } from "@/hooks/use-confirm";
const navigate = vi.hoisted(() => vi.fn());
const importResume = vi.hoisted(() => vi.fn());
// Stands in for the navigation TanStack Router performs from inside <Link>. Keeping it separate
// from `navigate` lets a test tell "the router took us away" apart from "the dialog took us away".
const routerNavigate = vi.hoisted(() => vi.fn());
type MockLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
to: string;
children: ReactNode;
};
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => navigate,
// Mirrors the part of <Link> this fix depends on: the router handles the click and navigates
// unless something already prevented the default. Without that, a missing preventDefault() in
// the dialog would go unnoticed here.
Link: ({ to, children, onClick, ...props }: MockLinkProps) => (
<a
href={to}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
routerNavigate({ to });
}}
{...props}
>
{children}
</a>
),
}));
vi.mock("@/features/settings/integrations/hooks/use-has-usable-ai-provider", () => ({
useHasUsableAiProvider: () => ({ hasUsableProvider: false, isLoading: false }),
}));
vi.mock("@/features/resume/import/pdf-text", () => ({
extractPdfLines: () => ["Empty MIME PDF Probe"],
}));
vi.mock("@reactive-resume/import/plain-text", () => ({
parseResumeText: () => structuredClone(sampleResumeData),
}));
vi.mock("@/libs/orpc/client", () => ({
client: {},
orpc: { resume: { import: { mutationOptions: () => ({ mutationFn: importResume }) } } },
}));
const { ImportResumeDialog } = await import("./import");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
navigate.mockReset();
importResume.mockReset();
routerNavigate.mockReset();
});
// Drive `open` from the store the way DialogManager does, so closing the dialog actually unmounts it.
function DialogHarness() {
const open = useDialogStore((state) => state.open);
return (
<Dialog open={open}>
<ImportResumeDialog />
</Dialog>
);
}
const renderDialog = () => {
useDialogStore.setState({ open: true, activeDialog: null, onBeforeClose: null });
return render(
<I18nProvider i18n={i18n}>
<QueryClientProvider client={new QueryClient()}>
<ConfirmDialogProvider>
<DialogHarness />
</ConfirmDialogProvider>
</QueryClientProvider>
</I18nProvider>,
);
};
// Word still needs a provider, so it is what surfaces the notice. PDF falls back to a local parse.
const createWordFile = () =>
new File([new Uint8Array([0x50, 0x4b, 0x03, 0x04])], "resume.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
// The dialog renders through a portal, so query the document rather than the render container.
async function selectWordFile() {
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
if (!input) throw new Error("File input not found");
fireEvent.change(input, { target: { files: [createWordFile()] } });
return await screen.findByText("Set up a provider");
}
const createPdfFile = () =>
new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], "resume.pdf", { type: "application/pdf" });
describe("ImportResumeDialog — PDF without a provider", () => {
it("offers a local parse instead of demanding an AI provider", async () => {
renderDialog();
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
if (!input) throw new Error("File input not found");
fireEvent.change(input, { target: { files: [createPdfFile()] } });
expect(await screen.findByText(/read the text out of the PDF here in your browser/)).toBeInTheDocument();
expect(screen.queryByText("Set up a provider")).not.toBeInTheDocument();
});
it("keeps the import button usable", async () => {
renderDialog();
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
if (!input) throw new Error("File input not found");
fireEvent.change(input, { target: { files: [createPdfFile()] } });
await screen.findByText(/read the text out of the PDF here in your browser/);
expect(screen.getByRole("button", { name: "Import" })).not.toBeDisabled();
});
});
describe("ImportResumeDialog — detected files without MIME metadata", () => {
it("imports a valid current JSON file detected from its extension and shape", async () => {
importResume.mockResolvedValue("imported-resume-id");
renderDialog();
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
if (!input) throw new Error("File input not found");
const data = structuredClone(sampleResumeData);
data.basics.name = "Empty MIME JSON Probe";
fireEvent.change(input, {
target: { files: [new File([JSON.stringify(data)], "resume.json", { type: "" })] },
});
await screen.findByText("Reactive Resume (JSON)");
fireEvent.click(screen.getByRole("button", { name: "Import" }));
await waitFor(() => {
expect(importResume).toHaveBeenCalledOnce();
});
expect(navigate).toHaveBeenCalledWith({
to: "/builder/$resumeId",
params: { resumeId: "imported-resume-id" },
});
});
it("imports a valid PDF detected from its magic bytes", async () => {
importResume.mockResolvedValue("imported-resume-id");
renderDialog();
const input = document.querySelector<HTMLInputElement>('input[type="file"]');
if (!input) throw new Error("File input not found");
fireEvent.change(input, {
target: {
files: [new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], "resume.bin", { type: "" })],
},
});
await screen.findByText(/read the text out of the PDF here in your browser/);
fireEvent.click(screen.getByRole("button", { name: "Import" }));
await waitFor(() => {
expect(importResume).toHaveBeenCalledOnce();
});
});
});
describe("ImportResumeDialog — Set up a provider", () => {
// https://github.com/reactive-resume/reactive-resume/issues/3307
it("confirms before leaving instead of navigating behind the dialog", async () => {
renderDialog();
const link = await selectWordFile();
fireEvent.click(link);
expect(await screen.findByText("Leave to set up an AI provider?")).toBeInTheDocument();
expect(routerNavigate).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
expect(screen.getByText("Import an existing resume")).toBeInTheDocument();
});
it("stays put and keeps the selected file when the user cancels", async () => {
renderDialog();
const link = await selectWordFile();
fireEvent.click(link);
fireEvent.click(await screen.findByText("Stay"));
await waitFor(() => {
expect(screen.queryByText("Leave to set up an AI provider?")).not.toBeInTheDocument();
});
expect(routerNavigate).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
expect(useDialogStore.getState().open).toBe(true);
expect(screen.getByText("resume.docx")).toBeInTheDocument();
});
it("closes the dialog and navigates once the user confirms", async () => {
renderDialog();
const link = await selectWordFile();
fireEvent.click(link);
fireEvent.click(await screen.findByText("Leave"));
await waitFor(() => {
expect(navigate).toHaveBeenCalledWith({ to: "/dashboard/settings/integrations" });
});
expect(useDialogStore.getState().open).toBe(false);
await waitFor(() => {
expect(screen.queryByText("Set up a provider")).not.toBeInTheDocument();
});
});
it("leaves modifier clicks to the browser so the link can open in a new tab", async () => {
renderDialog();
const link = await selectWordFile();
const event = new MouseEvent("click", { bubbles: true, cancelable: true, metaKey: true });
fireEvent(link, event);
expect(event.defaultPrevented).toBe(false);
expect(screen.queryByText("Leave to set up an AI provider?")).not.toBeInTheDocument();
expect(navigate).not.toHaveBeenCalled();
});
it("leaves middle clicks to the browser too", async () => {
renderDialog();
const link = await selectWordFile();
const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 1 });
fireEvent(link, event);
expect(event.defaultPrevented).toBe(false);
expect(screen.queryByText("Leave to set up an AI provider?")).not.toBeInTheDocument();
expect(navigate).not.toHaveBeenCalled();
});
});
@@ -1,40 +0,0 @@
import { describe, expect, it } from "vitest";
import { detectJsonImportType } from "./import.utils";
describe("detectJsonImportType", () => {
it("detects JSON Resume by a top-level basics without Reactive Resume sections/metadata", () => {
expect(detectJsonImportType({ basics: { name: "A" }, work: [] })).toBe("json-resume-json");
});
it("detects the current Reactive Resume schema by metadata.page", () => {
expect(detectJsonImportType({ basics: {}, sections: {}, metadata: { page: { locale: "en-US" } } })).toBe(
"reactive-resume-json",
);
});
it("detects the legacy v4 schema by metadata without a page key", () => {
expect(detectJsonImportType({ basics: {}, sections: {}, metadata: { template: "azurill" } })).toBe(
"reactive-resume-v4-json",
);
});
it("detects a real v4 layout even though v4 metadata also has a page key", () => {
expect(
detectJsonImportType({
basics: {},
sections: {},
metadata: {
layout: [[["experience"], ["skills"]]],
page: { margin: 14, format: "a4", options: { breakLine: false, pageNumbers: false } },
},
}),
).toBe("reactive-resume-v4-json");
});
it("returns an empty string for unrecognized shapes", () => {
expect(detectJsonImportType({})).toBe("");
expect(detectJsonImportType({ foo: "bar" })).toBe("");
expect(detectJsonImportType(null)).toBe("");
expect(detectJsonImportType("nope")).toBe("");
});
});
-507
View File
@@ -1,507 +0,0 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { DialogProps } from "../store";
import type { ImportType } from "./import.utils";
import type { ResumeJsonFormat } from "./parse-json";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { useRef, useState } from "react";
import z from "zod";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { toast } from "@reactive-resume/ui/components/toast";
import { Combobox } from "@/components/ui/combobox";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { useConfirm } from "@/hooks/use-confirm";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
import { useAppForm } from "@/libs/tanstack-form";
import { useDialogStore } from "../store";
import { detectJsonImportType } from "./import.utils";
import { parseResumeJson } from "./parse-json";
const formSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal(""),
file: z.undefined(),
}),
z.object({
type: z.literal("pdf"),
file: z
.instanceof(File)
.refine((file) => file.type === "" || file.type === "application/pdf", { message: "File must be a PDF" }),
}),
z.object({
type: z.literal("docx"),
file: z
.instanceof(File)
.refine(
(file) =>
file.type === "" ||
file.type === "application/msword" ||
file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
{ message: "File must be a Microsoft Word document" },
),
}),
z.object({
type: z.literal("linkedin"),
file: z
.instanceof(File)
.refine(
(file) => file.type === "" || file.type === "application/zip" || file.name.toLowerCase().endsWith(".zip"),
{
message: "File must be a ZIP archive",
},
),
}),
z.object({
type: z.literal("reactive-resume-json"),
file: z
.instanceof(File)
.refine((file) => file.type === "" || file.type === "application/json", { message: "File must be a JSON file" }),
}),
z.object({
type: z.literal("reactive-resume-v4-json"),
file: z
.instanceof(File)
.refine((file) => file.type === "" || file.type === "application/json", { message: "File must be a JSON file" }),
}),
z.object({
type: z.literal("json-resume-json"),
file: z
.instanceof(File)
.refine((file) => file.type === "" || file.type === "application/json", { message: "File must be a JSON file" }),
}),
]);
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// remove data URL prefix (e.g., "data:application/pdf;base64," or "data:application/vnd...;base64,")
resolve(result.split(",")[1]);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// #7: sniff the source format from magic bytes + JSON shape rather than trusting the extension/MIME
// (multiple resume interchange formats share the .json extension). Returns "" when unrecognized.
async function detectImportType(file: File): Promise<ImportType> {
const name = file.name.toLowerCase();
const mime = file.type;
const header = new Uint8Array(await file.slice(0, 4).arrayBuffer());
const isPdf = header[0] === 0x25 && header[1] === 0x50 && header[2] === 0x44 && header[3] === 0x46; // "%PDF"
const isZip = header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04; // "PK\x03\x04"
if (isPdf || mime === "application/pdf" || name.endsWith(".pdf")) return "pdf";
// Word documents are also ZIPs, so a bare "PK" header is ambiguous. LinkedIn's export is
// only ever named with a .zip extension, so check that first and let it win the tie.
if (name.endsWith(".zip") || mime === "application/zip") return "linkedin";
if (
isZip ||
mime === "application/msword" ||
mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
name.endsWith(".docx") ||
name.endsWith(".doc")
) {
return "docx";
}
if (mime === "application/json" || name.endsWith(".json")) {
try {
return detectJsonImportType(JSON.parse(await file.text()));
} catch {
return "";
}
}
return "";
}
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const confirm = useConfirm();
const navigate = useNavigate();
const closeDialog = useDialogStore((state) => state.closeDialog);
const inputRef = useRef<HTMLInputElement>(null);
const [isImporting, setIsImporting] = useState<boolean>(false);
const { mutateAsync: importResume } = useMutation(orpc.resume.import.mutationOptions());
const { hasUsableProvider, isLoading: isLoadingAiProviders } = useHasUsableAiProvider();
const form = useAppForm({
defaultValues: {
type: "" as ImportType,
file: undefined as File | undefined,
},
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
if (value.type === "" || !value.file) return;
setIsImporting(true);
// A LinkedIn export, or a PDF parsed in the browser, never touches a provider, so promising one would be a lie.
const isLocalParse = value.type === "linkedin" || (value.type === "pdf" && !hasUsableProvider);
const toastId = toast.add({
type: "loading",
title: t`Importing your resume...`,
description: isLocalParse
? t`This may take a moment. Please do not close the window or refresh the page.`
: t`This may take a few minutes, depending on the response of the AI provider. Please do not close the window or refresh the page.`,
});
try {
let data: ResumeData | undefined;
if (
value.type === "json-resume-json" ||
value.type === "reactive-resume-json" ||
value.type === "reactive-resume-v4-json"
) {
data = parseResumeJson(await value.file.text(), value.type as ResumeJsonFormat);
}
if (value.type === "pdf") {
if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`);
if (hasUsableProvider) {
const base64 = await fileToBase64(value.file);
data = await client.ai.parsePdf({
file: { name: value.file.name, data: base64 },
});
} else {
const [{ extractPdfLines }, { parseResumeText }] = await Promise.all([
import("@/features/resume/import/pdf-text"),
import("@reactive-resume/import/plain-text"),
]);
const lines = await extractPdfLines(value.file);
if (lines.length === 0) {
throw new Error(
t({
comment: "Error shown when a PDF has no extractable text layer during import",
message: "This PDF has no readable text. It is likely a scan, so there is nothing to import.",
}),
);
}
data = parseResumeText(lines.join("\n"));
}
}
if (value.type === "linkedin") {
const { parseLinkedInExport } = await import("@reactive-resume/import/linkedin");
const bytes = new Uint8Array(await value.file.arrayBuffer());
data = parseLinkedInExport(bytes);
}
if (value.type === "docx") {
if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`);
if (!hasUsableProvider)
throw new Error(t`This feature requires a connected AI provider. Please set one up in the settings.`);
const base64 = await fileToBase64(value.file);
const mediaType =
value.file.type === "application/msword"
? ("application/msword" as const)
: ("application/vnd.openxmlformats-officedocument.wordprocessingml.document" as const);
data = await client.ai.parseDocx({
mediaType,
file: { name: value.file.name, data: base64 },
});
}
if (!data) {
throw new Error(
t({
comment: "Error shown when AI import endpoint returns no parsed resume data",
message: "No data was returned from the AI provider.",
}),
);
}
const id = await importResume({ data });
toast.add({
type: "success",
title: null,
description: t`Your resume has been imported.`,
id: toastId,
});
closeDialog();
void navigate({ to: "/builder/$resumeId", params: { resumeId: id } });
} catch (error: unknown) {
toast.add({
type: "error",
title: null,
description: getOrpcErrorMessage(error, {
byCode: {
BAD_REQUEST: t({
comment: "Error shown when AI parsing returns invalid resume structure during import",
message: "The imported file could not be parsed into a valid resume.",
}),
BAD_GATEWAY: t({
comment: "Error shown when AI provider is unreachable during PDF/DOCX resume import",
message: "Could not reach the AI provider. Please try again.",
}),
},
fallback: t({
comment: "Fallback toast when importing a resume fails for an unknown reason",
message: "An unknown error occurred while importing your resume.",
}),
}),
id: toastId,
});
} finally {
setIsImporting(false);
}
},
});
const type = useStore(form.store, (s) => s.values.type);
const file = useStore(form.store, (s) => s.values.file);
const aiRequired = type === "docx";
const pdfWithoutAi = type === "pdf" && !isLoadingAiProviders && !hasUsableProvider;
const onSelectFile = () => {
if (!inputRef.current) return;
inputRef.current.click();
};
const onUploadFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0];
if (!selected) return;
form.setFieldValue("file", selected);
// #7: pre-select the source format from the file's content; the user can still override below.
form.setFieldValue("type", await detectImportType(selected));
};
// #6: only warn about unsaved changes once a file has actually been chosen — not on a bare type selection.
useFormBlocker(form, { shouldBlock: () => Boolean(file) });
// The provider link navigates away while this dialog stays mounted over the new page, so the
// unsaved-changes guard (which only runs on a close attempt) fires far too late. Confirm first,
// then close and navigate ourselves.
const onSetUpProvider = async (event: React.MouseEvent<HTMLAnchorElement>) => {
// Modifier and middle clicks open a new tab: the user is not leaving this page, so let the
// browser handle the link and keep the dialog exactly as it is.
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
if (file) {
const confirmed = await confirm(t`Leave to set up an AI provider?`, {
description: t`You'll be taken to the Integrations page. The file you selected won't be imported.`,
confirmText: t`Leave`,
cancelText: t`Stay`,
});
if (!confirmed) return;
}
closeDialog();
await navigate({ to: "/dashboard/settings/integrations" });
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<DownloadSimpleIcon />
<Trans>Import an existing resume</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Continue where you left off by importing a resume you built in Reactive Resume or another resume builder.
Supported formats are PDF, Microsoft Word, a LinkedIn data export, and JSON files from Reactive Resume or
JSON Resume.
</Trans>
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<form.Field name="file">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>File</Trans>
</FormLabel>
<FormControl className="group/upload relative">
<Input
type="file"
className="absolute inset-0 z-1 h-full w-full cursor-pointer opacity-0"
tabIndex={-1}
ref={inputRef}
onChange={onUploadFile}
/>
<Button
variant="secondary"
className="h-auto w-full flex-col border-dashed py-8 font-normal group-hover/upload:bg-muted group-hover/upload:text-foreground"
onClick={onSelectFile}
>
{field.state.value ? (
<>
<FileIcon weight="thin" size={32} />
<p>{field.state.value.name}</p>
</>
) : (
<>
<UploadSimpleIcon weight="thin" size={32} />
<Trans>Click here to select a file to import</Trans>
</>
)}
</Button>
</FormControl>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
{file && (
<form.Field name="type">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Type</Trans>
</FormLabel>
<FormControl
render={
<Combobox
showClear={false}
value={field.state.value}
onValueChange={(value) => field.handleChange(value as ImportType)}
options={[
{
value: "reactive-resume-json",
label: t({
comment: "Import source option for current Reactive Resume JSON format",
message: "Reactive Resume (JSON)",
}),
},
{
value: "reactive-resume-v4-json",
label: t({
comment: "Import source option for legacy Reactive Resume v4 JSON format",
message: "Reactive Resume v4 (JSON)",
}),
},
{
value: "json-resume-json",
label: t({
comment: "Import source option for standard JSON Resume format",
message: "JSON Resume",
}),
},
{
value: "linkedin",
textValue: "LinkedIn",
label: t({
comment: "Import source option for a LinkedIn data export ZIP",
message: "LinkedIn (Data Export)",
}),
},
{
value: "pdf",
textValue: "PDF",
label: "PDF",
},
{
value: "docx",
textValue: t({
comment: "File format label in import source selector",
message: "Microsoft Word",
}),
label: (
<div className="flex items-center gap-x-2">
{t({
comment: "File format label in import source selector",
message: "Microsoft Word",
})}{" "}
<Badge>{t`AI`}</Badge>
</div>
),
},
]}
/>
}
/>
{!field.state.value && (
<p className="text-muted-foreground text-xs">
<Trans>We couldn't detect the format automatically. Choose it above.</Trans>
</p>
)}
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
)}
{aiRequired && !isLoadingAiProviders && !hasUsableProvider && (
<div className="flex flex-col gap-3 rounded-md border border-dashed p-3 text-sm lg:flex-row lg:items-center lg:justify-between">
<span className="text-muted-foreground">
<Trans>Importing from Word requires a connected AI provider.</Trans>
</span>
<Button
size="sm"
variant="secondary"
nativeButton={false}
render={
<Link to="/dashboard/settings/integrations" onClick={onSetUpProvider}>
{t`Set up a provider`}
</Link>
}
/>
</div>
)}
{pdfWithoutAi && (
<div className="rounded-md border border-dashed p-3 text-muted-foreground text-sm">
<Trans>
No AI provider is connected, so we will read the text out of the PDF here in your browser and fill in what
we can recognize. Expect to tidy up the result.
</Trans>
</div>
)}
<DialogFooter>
<Button type="submit" disabled={!type || !file || isImporting || (aiRequired && !hasUsableProvider)}>
{isImporting ? <Spinner /> : null}
{isImporting ? t`Importing…` : t`Import`}
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
@@ -1,27 +0,0 @@
export type ImportType =
| ""
| "pdf"
| "docx"
| "linkedin"
| "reactive-resume-json"
| "reactive-resume-v4-json"
| "json-resume-json";
export function detectJsonImportType(parsed: unknown): ImportType {
if (!parsed || typeof parsed !== "object") return "";
const data = parsed as Record<string, unknown>;
// JSON Resume standard: top-level `basics`, without Reactive Resume's `sections`/`metadata`.
if ("basics" in data && !("sections" in data) && !("metadata" in data)) return "json-resume-json";
// Reactive Resume exports carry `sections` + `metadata`. V4 stores layout as nested arrays, while the current
// schema stores a layout object. Both versions can have `metadata.page`, so that key alone cannot distinguish them.
if ("sections" in data || "metadata" in data) {
const metadata = data.metadata as Record<string, unknown> | undefined;
if (metadata && Array.isArray(metadata.layout)) return "reactive-resume-v4-json";
if (metadata && !("page" in metadata)) return "reactive-resume-v4-json";
return "reactive-resume-json";
}
return "";
}
+2 -129
View File
@@ -1,14 +1,11 @@
import type { RouterInput } from "@/libs/orpc/client";
import type { DialogProps } from "../store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CaretDownIcon, MagicWandIcon, PencilSimpleLineIcon, PlusIcon, TestTubeIcon } from "@phosphor-icons/react";
import { MagicWandIcon, PencilSimpleLineIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate, useParams } from "@tanstack/react-router";
import { useRef } from "react";
import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
import { ButtonGroup } from "@reactive-resume/ui/components/button-group";
import {
DialogContent,
DialogDescription,
@@ -16,16 +13,10 @@ import {
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { toast } from "@reactive-resume/ui/components/toast";
import { generateId, generateRandomName } from "@reactive-resume/utils/string";
import { generateRandomName } from "@reactive-resume/utils/string";
import { ChipInput } from "@/components/input/chip-input";
import { usePatchResume } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
@@ -48,124 +39,6 @@ const defaultValues: FormValues = {
tags: [],
};
export function CreateResumeDialog(_: DialogProps<"resume.create">) {
const navigate = useNavigate();
const closeDialog = useDialogStore((state) => state.closeDialog);
// Skip the unsaved-changes guard when we close as a result of a successful create.
const didCreateRef = useRef(false);
const { mutate: createResume, isPending } = useMutation(orpc.resume.create.mutationOptions());
const form = useAppForm({
defaultValues: {
id: generateId(),
name: "",
tags: [] as string[],
},
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
const toastId = toast.add({ type: "loading", description: t`Creating your resume...` });
createResume(value, {
onSuccess: (id) => {
didCreateRef.current = true;
toast.add({ type: "success", description: t`Your resume has been created.`, id: toastId });
closeDialog();
void navigate({ to: "/builder/$resumeId", params: { resumeId: id } });
},
onError: (error) => {
toast.add({ type: "error", description: getResumeErrorMessage(error), id: toastId });
},
});
},
});
useFormBlocker(form, {
shouldBlock: () => !didCreateRef.current && form.state.isDirty && !form.state.isSubmitting,
});
const onCreateSampleResume = () => {
const values = form.state.values;
const randomName = generateRandomName();
const data = {
name: values.name || randomName,
tags: values.tags,
withSampleData: true,
} satisfies RouterInput["resume"]["create"];
const toastId = toast.add({ type: "loading", description: t`Creating your resume...` });
createResume(data, {
onSuccess: (id) => {
didCreateRef.current = true;
toast.add({ type: "success", description: t`Your resume has been created.`, id: toastId });
closeDialog();
void navigate({ to: "/builder/$resumeId", params: { resumeId: id } });
},
onError: (error) => {
toast.add({ type: "error", description: getResumeErrorMessage(error), id: toastId });
},
});
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new resume</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Start building your resume by giving it a name.</Trans>
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<ResumeForm form={form} />
<DialogFooter>
<ButtonGroup
aria-label={t({
comment: "Accessible label for create-resume split button group",
message: "Create resume with options",
})}
className="gap-x-px rtl:flex-row-reverse"
>
<Button type="submit" disabled={isPending}>
<Trans>Create</Trans>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size="icon" disabled={isPending}>
<CaretDownIcon />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onClick={onCreateSampleResume}>
<TestTubeIcon />
<Trans>Create a Sample Resume</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateResumeDialog({ data }: DialogProps<"resume.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const patchResume = usePatchResume();
@@ -1,8 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseResumeJson } from "./parse-json";
describe("parseResumeJson", () => {
it("keeps a selected v4 import from falling back to JSON Resume", () => {
expect(() => parseResumeJson("{}", "reactive-resume-v4-json")).toThrow(/v4/i);
});
});
-12
View File
@@ -1,12 +0,0 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { parseJSONResume } from "@reactive-resume/import/json-resume";
import { parseReactiveResumeJSON } from "@reactive-resume/import/reactive-resume-json";
import { parseReactiveResumeV4JSON } from "@reactive-resume/import/reactive-resume-v4-json";
export type ResumeJsonFormat = "reactive-resume-json" | "reactive-resume-v4-json" | "json-resume-json";
export function parseResumeJson(text: string, format: ResumeJsonFormat): ResumeData {
if (format === "reactive-resume-json") return parseReactiveResumeJSON(text);
if (format === "reactive-resume-v4-json") return parseReactiveResumeV4JSON(text);
return parseJSONResume(text);
}
+1 -4
View File
@@ -1,10 +1,7 @@
import type { AnyDialogRendererEntry } from "../schemas";
import { CreateResumeDialog, DuplicateResumeDialog, UpdateResumeDialog } from ".";
import { ImportResumeDialog } from "./import";
import { DuplicateResumeDialog, UpdateResumeDialog } from ".";
export const resumeDialogRenderers: readonly AnyDialogRendererEntry[] = [
{ type: "resume.create", render: () => <CreateResumeDialog /> },
{ type: "resume.update", render: ({ data }) => <UpdateResumeDialog data={data} /> },
{ type: "resume.duplicate", render: ({ data }) => <DuplicateResumeDialog data={data} /> },
{ type: "resume.import", render: () => <ImportResumeDialog /> },
];
+6 -8
View File
@@ -1,16 +1,14 @@
import type { ReactNode } from "react";
import type { NewDocumentDialogData } from "@/features/documents/new-document-dialog";
type EmptyDialog<T extends string> = { [K in T]: { type: K; data?: undefined } }[T];
export type DialogSchema =
| EmptyDialog<
| "auth.change-password"
| "auth.two-factor.enable"
| "auth.two-factor.disable"
| "api-key.create"
| "resume.create"
| "resume.import"
>
| EmptyDialog<"auth.change-password" | "auth.two-factor.enable" | "auth.two-factor.disable" | "api-key.create">
| {
type: "document.new";
data?: NewDocumentDialogData | undefined;
}
| {
type: "resume.update";
data: { id: string; name: string; slug: string; tags: string[] };
+3 -3
View File
@@ -23,7 +23,7 @@ describe("useDialogStore", () => {
it("clears any existing onBeforeClose handler", () => {
useDialogStore.setState({ onBeforeClose: () => true });
useDialogStore.getState().openDialog("resume.create", undefined);
useDialogStore.getState().openDialog("document.new", undefined);
expect(useDialogStore.getState().onBeforeClose).toBeNull();
});
@@ -69,7 +69,7 @@ describe("useDialogStore", () => {
useDialogStore.getState().closeDialog();
vi.advanceTimersByTime(100);
useDialogStore.getState().openDialog("resume.create", undefined);
useDialogStore.getState().openDialog("document.new", undefined);
const onBeforeClose = () => false;
useDialogStore.getState().setOnBeforeClose(onBeforeClose);
if (closing) useDialogStore.getState().closeDialog();
@@ -77,7 +77,7 @@ describe("useDialogStore", () => {
expect(useDialogStore.getState()).toMatchObject({
open: !closing,
activeDialog: { type: "resume.create" },
activeDialog: { type: "document.new" },
onBeforeClose,
});
+6 -2
View File
@@ -6,6 +6,8 @@ export type { DialogProps };
interface DialogStoreState {
open: boolean;
activeDialog: DialogSchema | null;
/** Changes on every open, so a dialog opened again right after closing starts fresh. */
openCount: number;
onBeforeClose: (() => boolean | Promise<boolean>) | null;
}
@@ -21,6 +23,7 @@ type DialogStore = DialogStoreState & DialogStoreActions;
export const useDialogStore = create<DialogStore>((set) => ({
open: false,
activeDialog: null,
openCount: 0,
onBeforeClose: null,
onOpenChange: (open, eventDetails) => {
if (open) return set({ open: true });
@@ -34,11 +37,12 @@ export const useDialogStore = create<DialogStore>((set) => ({
});
},
openDialog: (type, data) =>
set({
set((state) => ({
open: true,
activeDialog: { type, data } as DialogSchema,
openCount: state.openCount + 1,
onBeforeClose: null,
}),
})),
closeDialog: () => {
const closingDialog = useDialogStore.getState().activeDialog;
set({ open: false });
@@ -9,12 +9,15 @@ import {
OpenAiLogoIcon,
PlusIcon,
ReadCvLogoIcon,
SealCheckIcon,
ShieldCheckIcon,
TrashIcon,
UserCircleIcon,
UserGearIcon,
} from "@phosphor-icons/react";
import { useNavigate, useRouteContext } from "@tanstack/react-router";
import { CommandItem } from "@reactive-resume/ui/components/command";
import { useDialogStore } from "@/dialogs/store";
import { useCommandPaletteStore } from "../store";
import { BaseCommandGroup } from "./base";
@@ -39,12 +42,44 @@ export function NavigationCommandGroup() {
<CommandItem
disabled={!session}
keywords={[t`Resumes`]}
value="navigation.resumes"
onSelect={() => onNavigate("/dashboard/resumes")}
keywords={[t`Documents`, t`Resumes`, t`Cover letters`]}
value="navigation.documents"
onSelect={() => onNavigate("/dashboard")}
>
<ReadCvLogoIcon />
<Trans>Resumes</Trans>
<Trans>Documents</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`New document`, t`Create`, t`Import`]}
value="navigation.documents.new"
onSelect={() => {
reset();
useDialogStore.getState().openDialog("document.new", undefined);
}}
>
<PlusIcon />
<Trans>New document</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`Trash`, t`Deleted`]}
value="navigation.trash"
onSelect={() => onNavigate("/dashboard/trash")}
>
<TrashIcon />
<Trans>Trash</Trans>
</CommandItem>
<CommandItem
keywords={[t`ATS Checker`, t`Check a PDF`]}
value="navigation.ats-checker"
onSelect={() => onNavigate("/ats-checker")}
>
<SealCheckIcon />
<Trans>ATS Checker</Trans>
</CommandItem>
<CommandItem
@@ -62,10 +62,9 @@ export function ResumesCommandGroup() {
matchesSearch(search, [thread.title, thread.resumeName, thread.providerLabel]),
);
const onCreate = async () => {
await navigate({ to: "/dashboard/resumes" });
openDialog("resume.create", undefined);
const onCreate = () => {
reset();
openDialog("document.new", undefined);
};
const onNavigate = async (path: string) => {
@@ -61,17 +61,23 @@ export function CoverLetterEditorDialog({
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()) onClose();
if (await canClose()) close();
};
useBlocker({
shouldBlockFn: async () => !(await canClose()),
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.coverLetters.list.key() });
void queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
};
const run = async (action: () => Promise<void>) => {
if (running.current) return;
@@ -121,7 +127,7 @@ export function CoverLetterEditorDialog({
<Button onClick={() => void query.refetch()}>
<Trans>Retry</Trans>
</Button>
<Button variant="secondary" onClick={onClose}>
<Button variant="secondary" onClick={close}>
<Trans>Close</Trans>
</Button>
</div>
@@ -146,7 +152,7 @@ export function CoverLetterEditorDialog({
disabled={disabled || busy}
run={run}
onUpdated={remember}
onDeleted={onClose}
onDeleted={close}
/>
)}
/>
@@ -235,7 +241,7 @@ function CoverLetterActions({
onClick={() =>
void run(async () => {
await orpc.coverLetters.duplicate.call({ id: letter.id });
await queryClient.invalidateQueries({ queryKey: orpc.coverLetters.list.key() });
await queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
toast.add({ type: "success", description: t`Copy saved to your cover-letter library.` });
})
}
@@ -245,22 +251,19 @@ function CoverLetterActions({
<Button
type="button"
variant="danger"
onClick={() =>
void run(async () => {
if (
!(await confirm(t`Delete this cover letter?`, {
description: t`PDFs already attached to applications will remain available.`,
confirmText: t`Delete`,
}))
)
return;
// 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.coverLetters.list.key() });
onDeleted();
})
}
await queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
toast.add({ description: t`“${letter.name}” moved to Trash` });
trashed = true;
});
if (trashed) onDeleted();
}}
>
<Trans>Delete</Trans>
<Trans>Move to Trash</Trans>
</Button>
</div>
<div className="space-y-2">
@@ -1,312 +0,0 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { coverLetterDocumentSchema } from "@reactive-resume/schema/cover-letter/data";
import { templateSchema } from "@reactive-resume/schema/templates";
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 { Spinner } from "@reactive-resume/ui/components/spinner";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { templates } from "@/dialogs/resume/template/data";
import { getReadableErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { CoverLetterEditorDialog } from "./editor-dialog";
type CoverLetterLibraryProps = {
initialResumeId?: string;
resumeReady?: boolean;
onEditingChange?: (editing: boolean) => void;
};
export function CoverLetterLibrary({ initialResumeId, resumeReady = true, onEditingChange }: CoverLetterLibraryProps) {
const queryClient = useQueryClient();
const nameId = useId();
const resumeInputId = useId();
const templateInputId = useId();
const embeddedId = useId();
const importInput = useRef<HTMLInputElement>(null);
const running = useRef(false);
const [busy, setBusy] = useState(false);
const [search, setSearch] = useState("");
const [querySearch, setQuerySearch] = useState("");
const [offset, setOffset] = useState(0);
const [creating, setCreating] = useState(false);
const [name, setName] = useState("");
const [resumeId, setResumeId] = useState<string | null>(initialResumeId ?? null);
const [template, setTemplate] = useState<string | null>(null);
const [embeddedKey, setEmbeddedKey] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
useEffect(() => {
const timer = setTimeout(() => setQuerySearch(search), 250);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => {
onEditingChange?.(selectedId !== null);
}, [selectedId, onEditingChange]);
const query = useQuery({
...orpc.coverLetters.list.queryOptions({ input: { search: querySearch, offset, limit: 20 } }),
placeholderData: keepPreviousData,
});
const resumes = useQuery(orpc.resume.list.queryOptions({ input: {} }));
const source = useQuery(
orpc.resume.getById.queryOptions({ input: { id: resumeId ?? "" }, enabled: creating && !!resumeId }),
);
const embedded = (source.data?.data.customSections ?? []).flatMap((section) =>
section.type === "cover-letter"
? section.items.map((item, index) => ({
value: `${section.id}/${item.id}`,
label: `${section.title || t`Cover letter`} ${index + 1}`,
sectionId: section.id,
itemId: item.id,
}))
: [],
);
const selectedEmbedded = embedded.find((item) => item.value === embeddedKey);
const templateOptions = templateSchema.options.map((value) => ({ value, label: templates[value].name }));
const sourceReady = resumeId !== initialResumeId || resumeReady;
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 created = async (id: string) => {
await queryClient.invalidateQueries({ queryKey: orpc.coverLetters.list.key() });
setCreating(false);
setName("");
setSelectedId(id);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Input
className="min-w-48 flex-1"
aria-label={t`Search cover letters`}
placeholder={t`Search cover letters...`}
value={search}
onChange={(event) => {
setSearch(event.target.value);
setOffset(0);
}}
/>
<Button disabled={busy} onClick={() => setCreating(!creating)}>
<Trans>Create</Trans>
</Button>
<Button variant="secondary" disabled={busy} onClick={() => importInput.current?.click()}>
<Trans>Import JSON</Trans>
</Button>
<input
ref={importInput}
type="file"
accept="application/json,.json"
aria-label={t`Import cover letter JSON`}
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
void run(async () => {
const document = coverLetterDocumentSchema.parse(JSON.parse(await file.text()));
const letter = await orpc.coverLetters.import.call({ document });
await created(letter.id);
});
}}
/>
</div>
{creating && (
<div className="space-y-4 rounded-lg border p-4">
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!name.trim() || !sourceReady) return;
void run(async () => {
const letter = await orpc.coverLetters.create.call({
name: name.trim(),
...(templateSchema.safeParse(template).success ? { template: templateSchema.parse(template) } : {}),
});
await created(letter.id);
});
}}
>
<fieldset disabled={busy} className="min-w-0 space-y-3">
<div className="space-y-2">
<Label htmlFor={nameId}>
<Trans>Name</Trans>
</Label>
<Input
id={nameId}
value={name}
maxLength={100}
required
onChange={(event) => setName(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor={templateInputId}>
<Trans>Template</Trans>
</Label>
<Combobox
id={templateInputId}
disabled={busy}
className="w-full"
options={templateOptions}
value={template}
onValueChange={setTemplate}
showClear
placeholder={t`Default template`}
/>
</div>
<Button type="submit" disabled={busy || !name.trim() || !sourceReady}>
<Trans>Create cover letter</Trans>
</Button>
</fieldset>
</form>
<div className="space-y-2">
<Label htmlFor={resumeInputId}>
<Trans>Copy an existing letter from a resume</Trans>
</Label>
<div className="space-y-2">
<Combobox
id={resumeInputId}
className="w-full"
disabled={busy}
options={(resumes.data ?? []).map((resume) => ({ value: resume.id, label: resume.name }))}
value={resumeId}
onValueChange={(id) => {
setResumeId(id);
setEmbeddedKey(null);
}}
showClear
placeholder={t`Choose a resume`}
/>
<div className="flex flex-wrap gap-2">
<Combobox
id={embeddedId}
className="min-w-48 flex-1"
disabled={busy || !sourceReady || !resumeId}
options={embedded}
value={embeddedKey}
onValueChange={setEmbeddedKey}
placeholder={t`Choose a cover letter`}
emptyMessage={t`This resume has no embedded cover letters.`}
/>
<Button
variant="secondary"
disabled={busy || !sourceReady || !selectedEmbedded || !resumeId}
onClick={() =>
void run(async () => {
if (!selectedEmbedded || !resumeId) return;
const letter = await orpc.coverLetters.copyEmbedded.call({
resumeId,
sectionId: selectedEmbedded.sectionId,
itemId: selectedEmbedded.itemId,
...(name.trim() ? { name: name.trim() } : {}),
});
await created(letter.id);
})
}
>
<Trans>Save copy to library</Trans>
</Button>
</div>
<p className="text-muted-foreground text-xs">
<Trans>The original letter stays in your resume. Each copy can be edited independently.</Trans>
</p>
{!sourceReady && (
<p role="status" className="text-muted-foreground text-sm">
<Trans>Save resume changes before copying its content or styling.</Trans>
</p>
)}
{source.error && (
<p role="alert">{getReadableErrorMessage(source.error, t`Could not load the selected resume.`)}</p>
)}
</div>
</div>
</div>
)}
{query.isPending ? (
<Spinner />
) : query.error ? (
<div className="space-y-2">
<p role="alert">{getReadableErrorMessage(query.error, t`Could not load cover letters.`)}</p>
<Button variant="secondary" onClick={() => void query.refetch()}>
<Trans>Retry</Trans>
</Button>
</div>
) : query.data.items.length === 0 ? (
<p className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
<Trans>No cover letters found. Create one or import an existing letter.</Trans>
</p>
) : (
<ul
className={cn(
"divide-y rounded-lg border transition-opacity duration-150",
query.isPlaceholderData && "opacity-60",
)}
>
{query.data.items.map((letter) => (
<li key={letter.id}>
<button
type="button"
className="flex w-full items-center justify-between gap-4 p-4 text-start hover:bg-muted/50"
onClick={() => setSelectedId(letter.id)}
aria-label={t`Edit ${letter.name}`}
>
<span className="min-w-0 truncate font-medium">{letter.name}</span>
<time className="shrink-0 text-muted-foreground text-xs" dateTime={letter.updatedAt.toISOString()}>
{letter.updatedAt.toLocaleDateString()}
</time>
</button>
</li>
))}
</ul>
)}
{(offset > 0 || (query.data?.total ?? 0) > 20) && (
<div className="flex items-center justify-between gap-3">
<Button
variant="secondary"
disabled={offset === 0 || query.isFetching}
onClick={() => setOffset(Math.max(0, offset - 20))}
>
<Trans>Previous</Trans>
</Button>
<span className="text-muted-foreground text-sm">
<Trans>Page {Math.floor(offset / 20) + 1}</Trans>
</span>
<Button
variant="secondary"
disabled={offset + 20 >= (query.data?.total ?? 0) || query.isFetching}
onClick={() => setOffset(offset + 20)}
>
<Trans>Next</Trans>
</Button>
</div>
)}
{selectedId && (
<CoverLetterEditorDialog
key={selectedId}
letterId={selectedId}
activeResumeId={initialResumeId}
resumeReady={resumeReady}
onClose={() => setSelectedId(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,318 @@
import type { IconName } from "@reactive-resume/ui/components/icon";
import type { DocumentSummary } from "./filter";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from "@reactive-resume/ui/components/context-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Icon } from "@reactive-resume/ui/components/icon";
import { toast } from "@reactive-resume/ui/components/toast";
import { ChipInput } from "@/components/input/chip-input";
import { useDialogStore } from "@/dialogs/store";
import { applicationsListQueryOptions } from "@/features/applications/queries";
import { useConfirm } from "@/hooks/use-confirm";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { useNewDocumentsStore } from "./new-documents";
type Ref = { type: DocumentSummary["type"]; id: string };
const ref = (document: DocumentSummary): Ref => ({ type: document.type, id: document.id });
const failed = (error: unknown) =>
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Something went wrong. Try again.` }),
});
/** Everything the card menu does, shared by grid cards, list rows and Trash. */
export function useDocumentActions() {
const queryClient = useQueryClient();
const confirm = useConfirm();
const openDialog = useDialogStore((state) => state.openDialog);
const markNew = useNewDocumentsStore((state) => state.markNew);
const refresh = () =>
Promise.all([
queryClient.invalidateQueries({ queryKey: orpc.documents.key() }),
queryClient.invalidateQueries({ queryKey: orpc.resume.list.key() }),
]);
const rename = useMutation(orpc.documents.rename.mutationOptions({ onSettled: refresh, onError: failed }));
const setLocked = useMutation(orpc.documents.setLocked.mutationOptions({ onSettled: refresh, onError: failed }));
const trash = useMutation(orpc.documents.trash.mutationOptions({ onSettled: refresh }));
const restore = useMutation(orpc.documents.restore.mutationOptions({ onSettled: refresh }));
const purge = useMutation(orpc.documents.purge.mutationOptions({ onSettled: refresh }));
const duplicateResume = useMutation(orpc.resume.duplicate.mutationOptions({ onSettled: refresh }));
const duplicateLetter = useMutation(orpc.coverLetters.duplicate.mutationOptions({ onSettled: refresh }));
return {
rename: (document: DocumentSummary, name: string) => {
const trimmed = name.trim();
if (!trimmed || trimmed === document.name) return;
rename.mutate({ ...ref(document), name: trimmed });
},
duplicate: async (document: DocumentSummary) => {
try {
const id =
document.type === "resume"
? await duplicateResume.mutateAsync({
id: document.id,
name: t`${document.name} (copy)`,
tags: document.tags,
})
: (await duplicateLetter.mutateAsync({ id: document.id })).id;
markNew(id);
toast.add({ description: t`Duplicated` });
} catch (error) {
failed(error);
}
},
copyForJob: (document: DocumentSummary) =>
openDialog("document.new", { step: "copy", sourceResumeId: document.id }),
setLocked: (document: DocumentSummary, isLocked: boolean) => setLocked.mutate({ ...ref(document), isLocked }),
/** Undoable, so it doesn't ask first. */
trash: async (document: DocumentSummary) => {
try {
await trash.mutateAsync(ref(document));
toast.add({
description: t`“${document.name}” moved to Trash`,
actionProps: {
children: t`Undo`,
onClick: () => restore.mutate(ref(document), { onError: failed }),
},
});
} catch (error) {
failed(error);
}
},
restore: async (document: DocumentSummary) => {
try {
await restore.mutateAsync(ref(document));
toast.add({ description: t`Restored “${document.name}”` });
} catch (error) {
failed(error);
}
},
/** The only permanent action here; it asks once. */
purge: async (document: DocumentSummary) => {
const confirmed = await confirm(t`Delete “${document.name}” now?`, {
description: t`It will be deleted for good and can't be restored.`,
confirmText: t`Delete now`,
cancelText: t`Keep in Trash`,
});
if (!confirmed) return;
try {
await purge.mutateAsync(ref(document));
toast.add({ description: t`Deleted “${document.name}”` });
} catch (error) {
failed(error);
}
},
};
}
type MenuEntry = { icon: IconName; label: string; onSelect: () => void; disabled?: boolean; danger?: boolean };
type DocumentMenuProps = {
document: DocumentSummary;
onOpen: () => void;
onRename: () => void;
onTags: () => void;
onLink: () => void;
/** The same items as a context menu (right-click and long-press) instead of the ⋯ menu. */
variant?: "dropdown" | "context";
};
/** Open, Rename, Duplicate, Copy for a job (or Link to application), Tags, Lock, then Move to Trash. */
export function DocumentMenuContent({
document,
onOpen,
onRename,
onTags,
onLink,
variant = "dropdown",
}: DocumentMenuProps) {
const actions = useDocumentActions();
const trashEntries: MenuEntry[] = [
{ icon: "restore_from_trash", label: t`Restore`, onSelect: () => void actions.restore(document) },
{ icon: "delete_forever", label: t`Delete now…`, onSelect: () => void actions.purge(document), danger: true },
];
const liveEntries: MenuEntry[] = [
{ icon: "open_in_new", label: t`Open`, onSelect: onOpen },
{ icon: "edit", label: t`Rename`, onSelect: onRename, disabled: document.isLocked },
{ icon: "content_copy", label: t`Duplicate`, onSelect: () => void actions.duplicate(document) },
document.type === "resume"
? { icon: "work", label: t`Copy for a job…`, onSelect: () => actions.copyForJob(document) }
: { icon: "work", label: t`Link to application…`, onSelect: onLink, disabled: document.isLocked },
{ icon: "sell", label: t`Tags…`, onSelect: onTags, disabled: document.isLocked },
document.isLocked
? { icon: "lock_open", label: t`Unlock`, onSelect: () => actions.setLocked(document, false) }
: { icon: "lock", label: t`Lock editing`, onSelect: () => actions.setLocked(document, true) },
{
icon: "delete",
label: t`Move to Trash`,
onSelect: () => void actions.trash(document),
danger: true,
disabled: document.isLocked,
},
];
const entries = document.trashedAt ? trashEntries : liveEntries;
const Content = variant === "context" ? ContextMenuContent : DropdownMenuContent;
const Item = variant === "context" ? ContextMenuItem : DropdownMenuItem;
const Separator = variant === "context" ? ContextMenuSeparator : DropdownMenuSeparator;
return (
<Content align="end" className="w-60">
{entries.map((entry) => (
<div key={entry.label} className="contents">
{entry.danger && <Separator />}
<Item
variant={entry.danger ? "destructive" : "default"}
disabled={entry.disabled}
onClick={entry.onSelect}
title={entry.disabled ? t`Unlock it first` : undefined}
>
<Icon name={entry.icon} />
{entry.label}
</Item>
</div>
))}
</Content>
);
}
type TagsDialogProps = { document: DocumentSummary | null; onClose: () => void };
/** Tags… from the card menu: tags filter the library once any exist. */
export function TagsDialog({ document, onClose }: TagsDialogProps) {
const queryClient = useQueryClient();
const [tags, setTags] = useState<string[] | null>(null);
const setDocumentTags = useMutation(orpc.documents.setTags.mutationOptions());
const save = async () => {
if (!document) return;
try {
await setDocumentTags.mutateAsync({ type: document.type, id: document.id, tags: tags ?? document.tags });
await queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
setTags(null);
onClose();
} catch (error) {
failed(error);
}
};
return (
<Dialog
open={document !== null}
onOpenChange={(open) => {
if (open) return;
setTags(null);
onClose();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Tags</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Tags group documents in the library; pick one above the grid to filter by it.</Trans>
</DialogDescription>
</DialogHeader>
<ChipInput value={tags ?? document?.tags ?? []} onChange={setTags} />
<DialogFooter>
<Button onClick={() => void save()} disabled={setDocumentTags.isPending}>
<Trans>Save</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
type LinkApplicationDialogProps = { document: DocumentSummary | null; onClose: () => void };
/** Link to application… for letters: the job the letter is for. */
export function LinkApplicationDialog({ document, onClose }: LinkApplicationDialogProps) {
const queryClient = useQueryClient();
const { data: applications } = useQuery({ ...applicationsListQueryOptions(), enabled: document !== null });
const link = useMutation(orpc.documents.linkApplication.mutationOptions());
const choose = async (applicationId: string | null) => {
if (!document) return;
try {
await link.mutateAsync({ type: document.type, id: document.id, applicationId });
await queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
onClose();
} catch (error) {
failed(error);
}
};
const jobs = (applications ?? []).filter((application) => !application.archived);
return (
<Dialog open={document !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Link to application</Trans>
</DialogTitle>
<DialogDescription>
<Trans>The application this letter is for.</Trans>
</DialogDescription>
</DialogHeader>
<div className="grid max-h-72 gap-1 overflow-y-auto">
{jobs.map((application) => (
<Button
key={application.id}
variant={document?.application?.id === application.id ? "secondary" : "ghost"}
className="h-auto justify-start py-2 text-start"
disabled={link.isPending}
onClick={() => void choose(application.id)}
>
<Icon name="work" className="text-ink-2" />
<span className="grid min-w-0">
<span className="truncate font-medium">{application.role}</span>
<span className="truncate text-ink-3 text-xs">{application.company}</span>
</span>
</Button>
))}
{jobs.length === 0 && (
<p className="text-ink-2 text-sm">
<Trans>No applications yet. Add one in Applications first.</Trans>
</p>
)}
</div>
{document?.application && (
<DialogFooter>
<Button variant="ghost" disabled={link.isPending} onClick={() => void choose(null)}>
<Trans>Unlink</Trans>
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,299 @@
import type { DocumentSummary } from "./filter";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { Link, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { ContextMenu, ContextMenuTrigger } from "@reactive-resume/ui/components/context-menu";
import { DropdownMenu, 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 { cn } from "@reactive-resume/utils/style";
import { formatRelativeTime } from "@/libs/locale";
import { DocumentMenuContent, useDocumentActions } from "./document-actions";
import { daysLeftInTrash } from "./filter";
import { useNewDocumentsStore } from "./new-documents";
import { ResumeThumbnail } from "./resume-thumbnail";
export type DocumentItemProps = {
document: DocumentSummary;
onOpenLetter: (id: string) => void;
onTags: (document: DocumentSummary) => void;
onLink: (document: DocumentSummary) => void;
};
/** "Resume · Edited 2h ago", or the days left for a document in Trash. */
function useDocumentMeta(document: DocumentSummary) {
const { i18n } = useLingui();
const formatter = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
const type = document.type === "resume" ? t`Resume` : t`Letter`;
if (document.trashedAt) return t`${type} · ${daysLeftInTrash(document.trashedAt)} days left`;
return t`${type} · Edited ${formatRelativeTime(document.updatedAt, formatter)}`;
}
/** Opens the document: resumes in the editor, letters in the letter editor. */
function OpenLink({
document,
onOpenLetter,
className,
children,
label,
}: Pick<DocumentItemProps, "document" | "onOpenLetter"> & {
className?: string;
children: React.ReactNode;
label?: string;
}) {
const markOpened = useNewDocumentsStore((state) => state.markOpened);
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);
}}
>
{children}
</button>
);
}
/** Enter commits, Esc cancels, and leaving the field commits. */
function RenameInput({ document, onDone }: { document: DocumentSummary; onDone: () => void }) {
const actions = useDocumentActions();
const [value, setValue] = useState(document.name);
const commit = () => {
actions.rename(document, value);
onDone();
};
return (
<input
// biome-ignore lint/a11y/noAutofocus: renaming starts from the menu, so focus goes straight to the field.
autoFocus
value={value}
maxLength={100}
aria-label={t`Name`}
onChange={(event) => setValue(event.target.value)}
onFocus={(event) => event.target.select()}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") commit();
if (event.key === "Escape") {
event.stopPropagation();
onDone();
}
}}
className="h-7 w-full min-w-0 rounded-md border border-accent bg-raised px-1.5 font-semibold text-sm outline-none ring-3 ring-accent-soft"
/>
);
}
/** A letter's page, drawn from lines: letters have no thumbnail render. */
function LetterThumbnail({ name }: { name: string }) {
return (
<div aria-hidden="true" className="flex size-full flex-col gap-1.5 bg-white p-[14%] text-[0]">
<span className="h-1.5 w-2/5 rounded-full bg-[#c9c9c9]" />
<span className="mb-3 h-1 w-3/5 rounded-full bg-[#e2e2e2]" />
<span className="h-1 w-1/3 rounded-full bg-[#d4d4d4]" />
{Array.from({ length: 7 }, (_, index) => (
<span key={index} className={cn("h-1 rounded-full bg-[#e6e6e6]", index % 3 === 2 ? "w-4/5" : "w-full")} />
))}
<span className="mt-2 h-1 w-1/4 rounded-full bg-[#d4d4d4]" />
<span className="sr-only">{name}</span>
</div>
);
}
/** 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);
const [renaming, setRenaming] = useState(false);
const isNew = useNewDocumentsStore((state) => state.ids.includes(document.id)) && !document.trashedAt;
const meta = useDocumentMeta(document);
const menuProps = {
document,
onOpen: () => openDocument(document),
onRename: () => setRenaming(true),
onTags: () => onTags(document),
onLink: () => onLink(document),
};
return (
<ContextMenu>
<ContextMenuTrigger
render={<article className={cn("group/card grid gap-2", document.trashedAt && "opacity-70")} />}
>
<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)]",
isNew && "shadow-[0_0_0_2px_var(--accent),var(--shadow-1)]",
document.trashedAt && "pointer-events-none",
)}
>
{document.type === "resume" ? (
<ResumeThumbnail resume={document} />
) : (
<LetterThumbnail name={document.name} />
)}
<span className="absolute start-2 top-2 flex gap-1">
{isNew && (
<span className="rounded bg-accent px-1.5 font-semibold text-[11px] text-on-accent leading-[18px]">
<Trans>New</Trans>
</span>
)}
{document.isLocked && (
<span className="grid size-[22px] place-items-center rounded bg-ink/80 text-bg" title={t`Locked`}>
<Icon name="lock" size={14} />
<span className="sr-only">
<Trans>Locked</Trans>
</span>
</span>
)}
</span>
</OpenLink>
<div className="flex items-start gap-1">
<div className="grid min-w-0 flex-1 gap-0.5">
{renaming ? (
<RenameInput document={document} onDone={() => setRenaming(false)} />
) : (
<h3 className="truncate font-semibold text-sm leading-5">{document.name}</h3>
)}
<span className="truncate text-ink-3 text-xs">{meta}</span>
{document.application && (
<span className="flex min-w-0 items-center gap-1 text-ink-2 text-xs">
<Icon name="work" size={14} />
<span className="truncate">{document.application.company}</span>
</span>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={
<IconButton
icon="more_horiz"
size="icon-sm"
label={t`Options for ${document.name}`}
className="text-ink-2"
/>
}
/>
<DocumentMenuContent {...menuProps} />
</DropdownMenu>
</div>
</ContextMenuTrigger>
<DocumentMenuContent {...menuProps} variant="context" />
</ContextMenu>
);
}
/** The list view's row: Name, Type, Application, Edited, ⋯. */
export function DocumentRow({ document, onOpenLetter, onTags, onLink }: DocumentItemProps) {
const { i18n } = useLingui();
const openDocument = useOpenDocument(onOpenLetter);
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" });
const menuProps = {
document,
onOpen: () => openDocument(document),
onRename: () => setRenaming(true),
onTags: () => onTags(document),
onLink: () => onLink(document),
};
return (
<ContextMenu>
<ContextMenuTrigger
render={
<tr
className={cn(
"border-line border-b transition-colors duration-quick hover:bg-hover",
document.trashedAt && "opacity-70",
)}
/>
}
>
<td className="py-3 ps-3 pe-2">
<span className="flex min-w-0 items-center gap-2.5">
<Icon name={document.type === "resume" ? "description" : "mail"} className="shrink-0 text-ink-2" />
{renaming ? (
<RenameInput document={document} onDone={() => setRenaming(false)} />
) : (
<OpenLink
document={document}
onOpenLetter={onOpenLetter}
className="min-w-0 truncate text-start font-semibold text-sm hover:underline"
>
{document.name}
</OpenLink>
)}
{isNew && (
<span className="rounded bg-accent px-1.5 font-semibold text-[11px] text-on-accent leading-[18px]">
<Trans>New</Trans>
</span>
)}
{document.isLocked && <Icon name="lock" size={16} className="shrink-0 text-ink-3" />}
</span>
</td>
<td className="px-2 text-ink-2 text-sm max-sm:hidden">
{document.type === "resume" ? <Trans>Resume</Trans> : <Trans>Letter</Trans>}
</td>
<td className="px-2 text-ink-2 text-sm max-sm:hidden">{document.application?.company ?? "—"}</td>
<td className="whitespace-nowrap px-2 text-ink-3 text-sm">
{document.trashedAt ? (
<Trans>{daysLeftInTrash(document.trashedAt)} days left</Trans>
) : (
formatRelativeTime(document.updatedAt, formatter)
)}
</td>
<td className="w-10 pe-2 text-end">
<DropdownMenu>
<DropdownMenuTrigger
render={
<IconButton
icon="more_horiz"
size="icon-sm"
label={t`Options for ${document.name}`}
className="text-ink-2"
/>
}
/>
<DocumentMenuContent {...menuProps} />
</DropdownMenu>
</td>
</ContextMenuTrigger>
<DocumentMenuContent {...menuProps} variant="context" />
</ContextMenu>
);
}
/** Open from a menu: resumes in the editor, letters in the letter editor. */
function useOpenDocument(onOpenLetter: (id: string) => void) {
const navigate = useNavigate();
return (document: DocumentSummary) => {
useNewDocumentsStore.getState().markOpened(document.id);
if (document.type === "letter") return onOpenLetter(document.id);
void navigate({ to: "/builder/$resumeId", params: { resumeId: document.id } });
};
}
@@ -0,0 +1,378 @@
import type { DocumentSort, DocumentSummary, DocumentTypeFilter } from "./filter";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useHotkey } from "@tanstack/react-hotkeys";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Kbd } from "@reactive-resume/ui/components/kbd";
import { NativeSelect } from "@reactive-resume/ui/components/native-select";
import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/components/segmented-control";
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";
import { DocumentCard, DocumentRow } from "./document-card";
import { collectTags, filterDocuments } from "./filter";
import { useStartDocument } from "./new-document-dialog";
export type DocumentsSearch = {
type: DocumentTypeFilter;
q: string;
tags: string[];
sort: DocumentSort;
view?: "grid" | "list" | undefined;
/** A letter open in the letter editor. */
letter?: string | undefined;
};
type DocumentsPageProps = {
search: DocumentsSearch;
onSearchChange: (patch: Partial<DocumentsSearch>) => void;
};
/**
* Documents: every resume and letter in one library, with type tabs, search (/), sort, grid or list, and tag
* chips once tags exist. A file dropped anywhere on the page imports straight away.
*/
export function DocumentsPage({ search, onSearchChange }: DocumentsPageProps) {
const openDialog = useDialogStore((state) => state.openDialog);
const searchRef = useRef<HTMLInputElement>(null);
const [tagsFor, setTagsFor] = useState<DocumentSummary | null>(null);
const [linkFor, setLinkFor] = useState<DocumentSummary | null>(null);
const { data: documents, isPending } = useQuery(orpc.documents.list.queryOptions({ input: { trashed: false } }));
const view = search.view ?? readStoredView();
const setView = (next: "grid" | "list") => {
storeView(next);
onSearchChange({ view: next });
};
useHotkey("/", (event) => {
if (isEditableElementFocused()) return;
event.preventDefault();
searchRef.current?.focus();
});
const all = documents ?? [];
const shown = filterDocuments(all, search);
const tags = collectTags(all);
const counts = {
all: all.length,
resume: all.filter((document) => document.type === "resume").length,
letter: all.filter((document) => document.type === "letter").length,
};
const filtered = search.q.trim() !== "" || search.tags.length > 0;
const itemProps = {
onOpenLetter: (id: string) => onSearchChange({ letter: id }),
onTags: setTagsFor,
onLink: setLinkFor,
};
return (
<div className="mx-auto grid w-full max-w-[1180px] content-start gap-5 px-8 py-8 max-sm:px-4 max-sm:py-5">
<h1 className="font-display font-medium text-[30px] leading-9">
<Trans>Documents</Trans>
</h1>
{!isPending && all.length === 0 ? (
<FirstRun onChooseFile={() => openDialog("document.new", undefined)} />
) : (
<>
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<Tabs value={search.type} onValueChange={(type) => onSearchChange({ type: type as DocumentTypeFilter })}>
<TabsList variant="line" aria-label={t`Document type`}>
<TabsTrigger value="all">
<Trans>All</Trans>
<TabsCount>{counts.all}</TabsCount>
</TabsTrigger>
<TabsTrigger value="resume">
<Trans>Resumes</Trans>
<TabsCount>{counts.resume}</TabsCount>
</TabsTrigger>
<TabsTrigger value="letter">
<Trans>Letters</Trans>
<TabsCount>{counts.letter}</TabsCount>
</TabsTrigger>
</TabsList>
</Tabs>
<div className="ms-auto flex flex-wrap items-center gap-2 max-sm:ms-0 max-sm:w-full">
<div className="flex h-9 w-60 items-center gap-2 rounded-lg border border-line-2 bg-raised px-2.5 focus-within:border-accent focus-within:ring-3 focus-within:ring-accent-soft max-sm:w-full">
<Icon name="search" size={18} className="text-ink-3" />
<input
ref={searchRef}
type="search"
value={search.q}
aria-label={t`Search documents`}
placeholder={t`Search`}
onChange={(event) => onSearchChange({ q: event.target.value })}
className="h-full min-w-0 flex-1 bg-transparent text-sm outline-none"
/>
<Kbd className="max-sm:hidden">/</Kbd>
</div>
<div className="w-[150px]">
<NativeSelect
aria-label={t`Sort`}
value={search.sort}
onChange={(event) => onSearchChange({ sort: event.target.value as DocumentSort })}
>
<option value="edited">{t`Last edited`}</option>
<option value="name">{t`Name`}</option>
<option value="created">{t`Created`}</option>
</NativeSelect>
</div>
<SegmentedControl
aria-label={t`View`}
value={view}
onValueChange={(next) => setView(next as "grid" | "list")}
>
<SegmentedControlItem value="grid" aria-label={t`Grid`}>
<Icon name="grid_view" size={18} />
</SegmentedControlItem>
<SegmentedControlItem value="list" aria-label={t`List`}>
<Icon name="view_list" size={18} />
</SegmentedControlItem>
</SegmentedControl>
</div>
</div>
{tags.length > 0 && (
<fieldset className="m-0 flex flex-wrap gap-1.5 border-0 p-0">
<legend className="sr-only">
<Trans>Filter by tag</Trans>
</legend>
{tags.map((tag) => {
const active = search.tags.includes(tag);
return (
<button
key={tag}
type="button"
aria-pressed={active}
onClick={() =>
onSearchChange({
tags: active ? search.tags.filter((known) => known !== tag) : [...search.tags, tag],
})
}
className={cn(
"h-7 rounded-full border px-3 text-[13px] transition-colors duration-quick",
active
? "border-accent bg-accent-soft text-accent-text"
: "border-line-2 text-ink-2 hover:bg-hover",
)}
>
#{tag}
</button>
);
})}
</fieldset>
)}
{isPending ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,204px))] gap-x-7 gap-y-[22px] max-sm:grid-cols-2 max-sm:gap-4">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="grid gap-2">
<div className="aspect-page animate-pulse rounded-[6px] bg-sunken" />
<div className="h-4 w-3/4 animate-pulse rounded bg-sunken" />
</div>
))}
</div>
) : shown.length === 0 ? (
<div className="grid justify-items-center gap-2 py-16 text-center">
<Icon name="search_off" size={28} className="text-ink-3" />
<p className="font-semibold">
{search.q.trim() ? (
<Trans>Nothing matches “{search.q.trim()}”</Trans>
) : (
<Trans>Nothing matches these filters</Trans>
)}
</p>
<p className="text-ink-2 text-sm">
<Trans>Search covers titles, tags and linked applications.</Trans>
</p>
{filtered && (
<Button
variant="secondary"
className="mt-2"
onClick={() => onSearchChange({ q: "", tags: [], type: "all" })}
>
<Trans>Clear search and filters</Trans>
</Button>
)}
</div>
) : view === "list" ? (
<table className="w-full border-collapse">
<thead>
<tr className="border-line border-b text-start font-medium text-ink-3 text-xs">
<th className="h-10 ps-3 text-start font-medium">
<Trans>Name</Trans>
</th>
<th className="px-2 text-start font-medium max-sm:hidden">
<Trans>Type</Trans>
</th>
<th className="px-2 text-start font-medium max-sm:hidden">
<Trans>Application</Trans>
</th>
<th className="px-2 text-start font-medium">
<Trans>Edited</Trans>
</th>
<th className="w-10">
<span className="sr-only">
<Trans>Options</Trans>
</span>
</th>
</tr>
</thead>
<tbody>
{shown.map((document) => (
<DocumentRow key={`${document.type}:${document.id}`} document={document} {...itemProps} />
))}
</tbody>
</table>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,204px))] gap-x-7 gap-y-[22px] max-sm:grid-cols-2 max-sm:gap-4">
{shown.map((document) => (
<DocumentCard key={`${document.type}:${document.id}`} document={document} {...itemProps} />
))}
</div>
)}
</>
)}
<DropToImport />
<TagsDialog document={tagsFor} onClose={() => setTagsFor(null)} />
<LinkApplicationDialog document={linkFor} onClose={() => setLinkFor(null)} />
{search.letter && (
<CoverLetterEditorDialog letterId={search.letter} onClose={() => onSearchChange({ letter: undefined })} />
)}
</div>
);
}
const VIEW_KEY = "documents-view";
// The last view picked on this device; storage can be unavailable, and then it's the grid.
function readStoredView(): "grid" | "list" {
try {
return window.localStorage.getItem(VIEW_KEY) === "list" ? "list" : "grid";
} catch {
return "grid";
}
}
function storeView(view: "grid" | "list") {
try {
window.localStorage.setItem(VIEW_KEY, view);
} catch {
// A convenience only.
}
}
/** The first visit: import is the filled button here, and only here. */
function FirstRun({ onChooseFile }: { onChooseFile: () => void }) {
const { startBlank, trySample, creating } = useStartDocument();
return (
<section className="grid max-w-xl gap-4 pt-4">
<h2 className="font-display font-medium text-[26px] leading-8">
<Trans>Let's start with what you have</Trans>
</h2>
<p className="text-ink-2 leading-6">
<Trans>
Import your current resume and we'll lay out every section for you to refine. Or start fresh; it takes a
minute to get your name on the page.
</Trans>
</p>
<div className="flex flex-wrap items-center gap-3 rounded-xl border-[1.5px] border-line-2 border-dashed p-5">
<Icon name="upload_file" size={24} className="text-ink-2" />
<span className="flex-1 text-ink-2 text-sm">
<Trans>Drop a PDF, Word or JSON file</Trans>
</span>
<Button onClick={onChooseFile}>
<Trans>Choose a file</Trans>
</Button>
</div>
<div className="flex gap-4 text-sm">
<button
type="button"
disabled={creating}
className="text-ink-2 underline underline-offset-2 hover:text-ink"
onClick={() => void startBlank()}
>
<Trans>Start blank</Trans>
</button>
<button
type="button"
disabled={creating}
className="text-ink-2 underline underline-offset-2 hover:text-ink"
onClick={() => void trySample()}
>
<Trans>Try a sample</Trans>
</button>
</div>
</section>
);
}
/** A file dragged anywhere over the page shows the drop target, and dropping it starts the import. */
function DropToImport() {
const openDialog = useDialogStore((state) => state.openDialog);
const [dragging, setDragging] = useState(false);
useEffect(() => {
let depth = 0;
const hasFiles = (event: DragEvent) => Array.from(event.dataTransfer?.types ?? []).includes("Files");
const onEnter = (event: DragEvent) => {
if (!hasFiles(event)) return;
depth++;
setDragging(true);
};
const onLeave = () => {
depth = Math.max(0, depth - 1);
if (depth === 0) setDragging(false);
};
const onOver = (event: DragEvent) => {
if (hasFiles(event)) event.preventDefault();
};
const onDrop = (event: DragEvent) => {
depth = 0;
setDragging(false);
const file = event.dataTransfer?.files[0];
if (!file) return;
event.preventDefault();
// A drop inside an open dialog is that dialog's to handle.
if (useDialogStore.getState().open) return;
openDialog("document.new", { file });
};
window.addEventListener("dragenter", onEnter);
window.addEventListener("dragleave", onLeave);
window.addEventListener("dragover", onOver);
window.addEventListener("drop", onDrop);
return () => {
window.removeEventListener("dragenter", onEnter);
window.removeEventListener("dragleave", onLeave);
window.removeEventListener("dragover", onOver);
window.removeEventListener("drop", onDrop);
};
}, [openDialog]);
if (!dragging) return null;
return (
<div className="pointer-events-none fixed inset-3 z-40 grid place-items-center rounded-2xl border-2 border-accent border-dashed bg-accent-soft/80">
<div className="grid justify-items-center gap-2 text-center">
<Icon name="download" size={32} className="text-accent-text" />
<p className="font-semibold text-lg">
<Trans>Drop to import</Trans>
</p>
<p className="text-ink-2 text-sm">
<Trans>PDF, Word or JSON. We'll build a resume from it.</Trans>
</p>
</div>
</div>
);
}
@@ -0,0 +1,83 @@
import type { DocumentSummary } from "./filter";
import { describe, expect, it } from "vitest";
import { collectTags, daysLeftInTrash, filterDocuments } from "./filter";
const document = (patch: Partial<DocumentSummary>): DocumentSummary => ({
type: "resume",
id: patch.name ?? "id",
name: "Resume",
tags: [],
isLocked: false,
trashedAt: null,
createdAt: new Date(2026, 0, 1),
updatedAt: new Date(2026, 0, 1),
application: null,
...patch,
});
const documents = [
document({
name: "Product Designer",
tags: ["design"],
updatedAt: new Date(2026, 8, 3),
createdAt: new Date(2026, 1, 1),
}),
document({
name: "Letter to Lumen",
type: "letter",
updatedAt: new Date(2026, 8, 5),
application: { id: "a1", company: "Lumen", role: "Designer" },
}),
document({
name: "backend engineer",
tags: ["design", "tech"],
updatedAt: new Date(2026, 8, 1),
createdAt: new Date(2026, 5, 1),
}),
];
const all = { type: "all" as const, q: "", tags: [], sort: "edited" as const };
const names = (list: DocumentSummary[]) => list.map((item) => item.name);
describe("filterDocuments", () => {
it("shows the newest edit first by default", () => {
expect(names(filterDocuments(documents, all))).toEqual(["Letter to Lumen", "Product Designer", "backend engineer"]);
});
it("filters by type and by every chosen tag", () => {
expect(names(filterDocuments(documents, { ...all, type: "letter" }))).toEqual(["Letter to Lumen"]);
expect(names(filterDocuments(documents, { ...all, tags: ["design", "tech"] }))).toEqual(["backend engineer"]);
});
it("searches titles, tags and linked applications, ignoring case", () => {
expect(names(filterDocuments(documents, { ...all, q: "lumen" }))).toEqual(["Letter to Lumen"]);
expect(names(filterDocuments(documents, { ...all, q: "TECH" }))).toEqual(["backend engineer"]);
expect(names(filterDocuments(documents, { ...all, q: "designer" }))).toEqual([
"Letter to Lumen",
"Product Designer",
]);
});
it("sorts by name without regard to case, or by newest creation", () => {
expect(names(filterDocuments(documents, { ...all, sort: "name" }))).toEqual([
"backend engineer",
"Letter to Lumen",
"Product Designer",
]);
expect(names(filterDocuments(documents, { ...all, sort: "created" }))[0]).toBe("backend engineer");
});
});
describe("collectTags", () => {
it("lists each tag once, alphabetically", () => {
expect(collectTags(documents)).toEqual(["design", "tech"]);
});
});
describe("daysLeftInTrash", () => {
it("counts down from 30 days and stops at 0", () => {
const now = new Date(2026, 8, 28).getTime();
expect(daysLeftInTrash(new Date(2026, 8, 25), now)).toBe(27);
expect(daysLeftInTrash(new Date(2026, 6, 1), now)).toBe(0);
});
});
+38
View File
@@ -0,0 +1,38 @@
import type { RouterOutput } from "@/libs/orpc/client";
export type DocumentSummary = RouterOutput["documents"]["list"][number];
export type DocumentTypeFilter = "all" | "resume" | "letter";
export type DocumentSort = "edited" | "name" | "created";
type DocumentFilters = { type: DocumentTypeFilter; q: string; tags: readonly string[]; sort: DocumentSort };
const byName = new Intl.Collator(undefined, { sensitivity: "base", numeric: true });
/** Search covers titles, tags and linked applications; every chosen tag must be on the document. */
export function filterDocuments(documents: readonly DocumentSummary[], filters: DocumentFilters) {
const query = filters.q.trim().toLocaleLowerCase();
return documents
.filter((document) => filters.type === "all" || document.type === filters.type)
.filter((document) => filters.tags.every((tag) => document.tags.includes(tag)))
.filter((document) => {
if (!query) return true;
const haystack = [document.name, ...document.tags, document.application?.company, document.application?.role];
return haystack.some((text) => text?.toLocaleLowerCase().includes(query));
})
.sort((a, b) => {
if (filters.sort === "name") return byName.compare(a.name, b.name);
if (filters.sort === "created") return b.createdAt.getTime() - a.createdAt.getTime();
return b.updatedAt.getTime() - a.updatedAt.getTime();
});
}
/** Every tag in use, alphabetically; the tag chips appear only when there are some. */
export const collectTags = (documents: readonly DocumentSummary[]) =>
[...new Set(documents.flatMap((document) => document.tags))].sort(byName.compare);
/** Days until a document in Trash is deleted for good (at least 0). */
export function daysLeftInTrash(trashedAt: Date, now = Date.now()) {
const left = trashedAt.getTime() + 30 * 24 * 60 * 60 * 1000 - now;
return Math.max(0, Math.ceil(left / (24 * 60 * 60 * 1000)));
}
@@ -0,0 +1,600 @@
import type { ImportKind } from "@/features/resume/import/read-file";
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 { useNavigate } from "@tanstack/react-router";
import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@reactive-resume/ui/components/dialog";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Input } from "@reactive-resume/ui/components/input";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { toast } from "@reactive-resume/ui/components/toast";
import { generateRandomName } from "@reactive-resume/utils/string";
import { cn } from "@reactive-resume/utils/style";
import { useDialogStore } from "@/dialogs/store";
import { applicationsListQueryOptions } from "@/features/applications/queries";
import { detectImportKind, ImportError, readResumeFile, summarizeImport } from "@/features/resume/import/read-file";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { formatRelativeTime } from "@/libs/locale";
import { client, orpc } from "@/libs/orpc/client";
import { useNewDocumentsStore } from "./new-documents";
export type NewDocumentDialogData = {
/** Open on a step other than the three choices. */
step?: "copy";
/** Copy for a job: the resume to start from. */
sourceResumeId?: string;
/** A file dropped on the page, imported straight away. */
file?: File;
};
type Step =
| { name: "choose" }
| { name: "importing"; file: File; stage: number; notes: string[] }
| { name: "imported"; file: File; resumeId: string; sections: number; entries: number; flagged: number }
| { name: "failed"; file: File; message: string }
| { name: "copy" };
const ACCEPT = ".pdf,.doc,.docx,.json,.zip,application/pdf,application/json,application/zip";
const formatSize = (bytes: number) =>
bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
/**
* New: import a file, copy a resume for a job, or start blank; no name, slug or tags are asked for first.
* Importing shows three labelled steps rather than a spinner, so a slow parse still looks like progress.
*/
export function NewDocumentDialog({ data }: { data?: NewDocumentDialogData | undefined }) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const closeDialog = useDialogStore((state) => state.closeDialog);
const markNew = useNewDocumentsStore((state) => state.markNew);
const { hasUsableProvider } = useHasUsableAiProvider();
const [step, setStep] = useState<Step>({ name: data?.step ?? "choose" } as Step);
const run = useRef(0);
const inputRef = useRef<HTMLInputElement>(null);
const refreshDocuments = () => queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
const openResume = (resumeId: string) => {
closeDialog();
void navigate({ to: "/builder/$resumeId", params: { resumeId } });
};
const openLetter = (letterId: string) => {
closeDialog();
void navigate({ to: "/dashboard", search: { type: "letter", letter: letterId } });
};
const importFile = async (file: File) => {
const attempt = ++run.current;
const current = () => run.current === attempt;
const advance = (stage: number, note: string) =>
setStep((previous) =>
previous.name === "importing" && current()
? { ...previous, stage, notes: Object.assign([...previous.notes], { [stage - 1]: note }) }
: previous,
);
setStep({ name: "importing", file, stage: 0, notes: [] });
try {
const kind: ImportKind | null = await detectImportKind(file);
if (!kind) {
throw new ImportError(
t`This file type can't be imported. Use a PDF, Word, Reactive Resume or JSON Resume file, or a LinkedIn export (.zip).`,
);
}
if (kind === "cover-letter-json") {
const letter = await client.coverLetters.import({ document: JSON.parse(await file.text()) });
if (!current()) return;
markNew(letter.id);
void refreshDocuments();
toast.add({ description: t`Cover letter imported` });
openLetter(letter.id);
return;
}
const resume = await readResumeFile(file, kind, {
aiAvailable: hasUsableProvider,
onRead: (note) => advance(1, note),
});
if (!current()) return;
const summary = summarizeImport(resume);
advance(1, t`file read`);
advance(2, t`${summary.sections} sections`);
const resumeId = await client.resume.import({ data: resume });
if (!current()) return;
markNew(resumeId);
void refreshDocuments();
setStep({ name: "imported", file, resumeId, ...summary });
} catch (error) {
if (!current()) return;
const message =
error instanceof ImportError
? error.message
: getOrpcErrorMessage(error, {
byCode: {
BAD_REQUEST: t`The file couldn't be read as a resume.`,
BAD_GATEWAY: t`Couldn't reach the AI provider. Try again in a moment.`,
},
fallback: t`Something went wrong while importing. Try again, or start blank.`,
});
setStep({ name: "failed", file, message });
}
};
// A file dropped on the page starts importing as soon as the dialog opens.
const dropped = useRef(data?.file);
useEffect(() => {
if (!dropped.current) return;
const file = dropped.current;
dropped.current = undefined;
void importFile(file);
});
const { startBlank, trySample, newLetter, creating } = useStartDocument();
const chooseFile = () => inputRef.current?.click();
const fileInput = (
<input
ref={inputRef}
type="file"
accept={ACCEPT}
className="hidden"
aria-label={t`Choose a file to import`}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void importFile(file);
}}
/>
);
if (step.name === "copy") {
return (
<DialogContent className="sm:max-w-[640px]">
<CopyForJob
initialSourceId={data?.sourceResumeId}
onBack={data?.step === "copy" ? undefined : () => setStep({ name: "choose" })}
onCreated={(resumeId) => {
markNew(resumeId);
void refreshDocuments();
openResume(resumeId);
}}
/>
</DialogContent>
);
}
if (step.name !== "choose") {
return (
<DialogContent className="sm:max-w-[640px]">
<DialogHeader>
<DialogTitle className="font-display font-medium text-[22px]">
{step.name === "failed" ? <Trans>Couldn't import</Trans> : <Trans>Importing</Trans>}
</DialogTitle>
</DialogHeader>
{fileInput}
<div className="flex items-center gap-3 rounded-[10px] border border-line p-3">
<Icon name="picture_as_pdf" className="text-ink-2" />
<span className="grid min-w-0 flex-1">
<span className="truncate font-medium text-sm">{step.file.name}</span>
<span className="text-ink-3 text-xs">{formatSize(step.file.size)}</span>
</span>
{step.name === "importing" && (
<Button
variant="ghost"
size="sm"
onClick={() => {
run.current++;
setStep({ name: "choose" });
}}
>
<Trans>Cancel</Trans>
</Button>
)}
</div>
{step.name === "failed" ? (
<>
<div
role="alert"
className="flex gap-2.5 rounded-[10px] bg-danger-soft p-3 text-[13px] text-danger-text leading-[19px]"
>
<Icon name="error" size={20} />
<span>{step.message}</span>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="secondary" onClick={() => void startBlank()} disabled={creating}>
<Trans>Start blank</Trans>
</Button>
<Button onClick={chooseFile}>
<Trans>Choose another file</Trans>
</Button>
</div>
</>
) : step.name === "importing" ? (
<ImportProgress stage={step.stage} notes={step.notes} />
) : (
<>
<ImportProgress
stage={3}
notes={[t`file read`, t`${step.sections} sections`, t`${step.entries} entries`]}
/>
<p role="status" className="flex gap-2 text-[13px] leading-[19px]">
<Icon name="check_circle" size={20} className="text-accent-text" />
<span>
<Trans>
{step.sections} sections and {step.entries} entries found.
</Trans>{" "}
{step.flagged > 0 && <Trans>{step.flagged} fields are flagged for a quick look in the editor.</Trans>}
</span>
</p>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="secondary" onClick={closeDialog}>
<Trans>Stay here</Trans>
</Button>
<Button onClick={() => openResume(step.resumeId)}>
<Trans>Open in editor</Trans>
</Button>
</div>
</>
)}
</DialogContent>
);
}
return (
<DialogContent className="sm:max-w-[640px]">
<DialogHeader>
<DialogTitle className="font-display font-medium text-[22px]">
<Trans>New document</Trans>
</DialogTitle>
<DialogDescription className="sr-only">
<Trans>Import a resume, copy one for a job, or start blank.</Trans>
</DialogDescription>
</DialogHeader>
{fileInput}
<button
type="button"
onClick={chooseFile}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
const file = event.dataTransfer.files[0];
if (file) void importFile(file);
}}
className="flex items-start gap-4 rounded-xl border-[1.5px] border-line-2 border-dashed p-5 text-start transition-colors duration-quick hover:border-accent hover:bg-accent-soft"
>
<span className="grid size-11 shrink-0 place-items-center rounded-[10px] bg-sunken text-ink-2">
<Icon name="upload_file" size={24} />
</span>
<span className="grid gap-1">
<span className="font-semibold text-[15px]">
<Trans>Import a resume</Trans>
</span>
<span className="text-[13px] text-ink-2 leading-[19px]">
<Trans>
Drop a file here or browse. PDF, Word, Reactive Resume or JSON Resume. We fill in every section and flag
anything we're unsure of.
</Trans>
</span>
</span>
</button>
<div className="grid gap-3 sm:grid-cols-2">
<ChoiceTile
icon="content_copy"
title={t`Copy a resume for a job`}
description={t`Start from one you have and link the application.`}
onClick={() => setStep({ name: "copy" })}
/>
<ChoiceTile
icon="note_add"
title={t`Start blank`}
description={t`Opens the editor on your name. Nothing else to fill in first.`}
disabled={creating}
onClick={() => void startBlank()}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-line border-t pt-4 text-[13px]">
<button
type="button"
className="flex items-center gap-1.5 text-ink-2 hover:text-ink"
onClick={() => void newLetter()}
>
<Icon name="mail" size={18} />
<Trans>New cover letter instead</Trans>
</button>
<button
type="button"
className="text-ink-2 underline underline-offset-2 hover:text-ink"
disabled={creating}
onClick={() => void trySample()}
>
<Trans>Try with a sample resume</Trans>
</button>
</div>
</DialogContent>
);
}
/** Start blank, try a sample, or a new letter: each creates the document at once and opens it. */
export function useStartDocument() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const closeDialog = useDialogStore((state) => state.closeDialog);
const markNew = useNewDocumentsStore((state) => state.markNew);
const { mutateAsync: createResume, isPending: creating } = useMutation(orpc.resume.create.mutationOptions());
const { mutateAsync: createLetter } = useMutation(orpc.coverLetters.create.mutationOptions());
const created = (id: string) => {
markNew(id);
void queryClient.invalidateQueries({ queryKey: orpc.documents.key() });
closeDialog();
};
const failed = (error: unknown) =>
toast.add({
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Couldn't create the document.` }),
});
return {
creating,
startBlank: async () => {
try {
const resumeId = await createResume({
name: t`Untitled resume`,
tags: [],
autoName: true,
withSampleData: false,
});
created(resumeId);
void navigate({ to: "/builder/$resumeId", params: { resumeId } });
} catch (error) {
failed(error);
}
},
trySample: async () => {
try {
const resumeId = await createResume({ name: generateRandomName(), tags: [], withSampleData: true });
created(resumeId);
toast.add({ description: t`Sample resume added. Delete it anytime.` });
void navigate({ to: "/builder/$resumeId", params: { resumeId } });
} catch (error) {
failed(error);
}
},
newLetter: async () => {
try {
const letter = await createLetter({ name: t`Untitled letter`, recipient: "", content: "" });
created(letter.id);
void navigate({ to: "/dashboard", search: { type: "letter", letter: letter.id } });
} catch (error) {
failed(error);
}
},
};
}
type ChoiceTileProps = {
icon: "content_copy" | "note_add";
title: string;
description: string;
onClick: () => void;
disabled?: boolean;
};
function ChoiceTile({ icon, title, description, onClick, disabled }: ChoiceTileProps) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className="flex items-start gap-3 rounded-xl border border-line p-4 text-start transition-colors duration-quick hover:border-line-2 hover:bg-hover disabled:opacity-60"
>
<Icon name={icon} size={22} className="mt-0.5 text-ink-2" />
<span className="grid gap-0.5">
<span className="font-semibold text-sm">{title}</span>
<span className="text-[13px] text-ink-2 leading-[18px]">{description}</span>
</span>
</button>
);
}
/** Reading the file → Finding sections → Filling in entries, each with a note once done. */
function ImportProgress({ stage, notes }: { stage: number; notes: string[] }) {
const steps = [t`Reading the file`, t`Finding sections`, t`Filling in entries`];
return (
<div className="grid gap-3">
<ol className="grid gap-2.5" aria-label={t`Import steps`}>
{steps.map((label, index) => {
const done = stage > index;
const current = stage === index;
return (
<li
key={label}
className={cn("flex items-center gap-2.5 text-sm", done || current ? "text-ink" : "text-ink-3")}
>
{done ? (
<Icon name="check_circle" size={18} className="text-accent-text" />
) : current ? (
<Spinner decorative className="size-[18px]" />
) : (
<span className="size-[18px] rounded-full border-[1.5px] border-line-2" />
)}
<span className="font-medium">{label}</span>
{done && notes[index] && <span className="text-ink-3 text-xs">{notes[index]}</span>}
</li>
);
})}
</ol>
<div
role="progressbar"
aria-label={t`Import progress`}
aria-valuemin={0}
aria-valuemax={3}
aria-valuenow={stage}
className="h-1 overflow-hidden rounded-full bg-sunken"
>
<div
className="h-full bg-accent transition-[width] duration-standard"
style={{ width: `${(stage / 3) * 100}%` }}
/>
</div>
</div>
);
}
type CopyForJobProps = {
initialSourceId?: string | undefined;
onBack?: (() => void) | undefined;
onCreated: (resumeId: string) => void;
};
/** Pick a resume and a job; the copy is named from both and linked to the application. */
function CopyForJob({ initialSourceId, onBack, onCreated }: CopyForJobProps) {
const { i18n } = useLingui();
const nameId = useId();
const { data: documents } = useQuery(orpc.documents.list.queryOptions({ input: { trashed: false } }));
const { data: applications } = useQuery(applicationsListQueryOptions());
const resumes = (documents ?? []).filter((document) => document.type === "resume");
const jobs = (applications ?? []).filter((application) => !application.archived);
const [sourceId, setSourceId] = useState(initialSourceId);
const [jobId, setJobId] = useState<string | null>(null);
const [name, setName] = useState<string | null>(null);
const { mutateAsync: copyForJob, isPending } = useMutation(orpc.documents.copyForJob.mutationOptions());
const formatter = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
const source = resumes.find((resume) => resume.id === sourceId) ?? resumes[0];
const job = jobs.find((application) => application.id === jobId);
const base = (source?.name ?? "").split(" — ")[0] ?? "";
const suggested = job ? `${base} — ${job.company}` : t`${base} (copy)`;
const finalName = (name ?? suggested).trim();
const create = async () => {
if (!source) return;
try {
const resumeId = await copyForJob({
resumeId: source.id,
...(job ? { applicationId: job.id } : {}),
...(finalName ? { name: finalName } : {}),
});
toast.add({ description: job ? t`Created and linked to ${job.company}` : t`Created “${finalName}”` });
onCreated(resumeId);
} catch (error) {
toast.add({ type: "error", description: getOrpcErrorMessage(error, { fallback: t`Couldn't copy the resume.` }) });
}
};
return (
<>
<DialogHeader>
<DialogTitle className="font-display font-medium text-[22px]">
<Trans>Copy a resume for a job</Trans>
</DialogTitle>
</DialogHeader>
<fieldset className="grid gap-1.5">
<legend className="mb-1.5 font-medium text-ink-2 text-xs">
<Trans>Start from</Trans>
</legend>
<div className="grid max-h-56 gap-1 overflow-y-auto">
{resumes.map((resume) => (
<label
key={resume.id}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-[10px] border px-3 py-2.5 transition-colors duration-quick",
resume.id === source?.id ? "border-accent bg-accent-soft" : "border-line hover:bg-hover",
)}
>
<input
type="radio"
name="copy-source"
className="sr-only"
checked={resume.id === source?.id}
onChange={() => {
setSourceId(resume.id);
setName(null);
}}
/>
<Icon name="description" className="text-ink-2" />
<span className="grid min-w-0 flex-1">
<span className="truncate font-medium text-sm">{resume.name}</span>
<span className="text-ink-3 text-xs">{formatRelativeTime(resume.updatedAt, formatter)}</span>
</span>
</label>
))}
</div>
</fieldset>
<fieldset className="grid gap-1.5">
<legend className="mb-1.5 font-medium text-ink-2 text-xs">
<Trans>For which job?</Trans>
</legend>
<div className="flex flex-wrap gap-1.5">
{[
...jobs.map((application) => ({ id: application.id, label: application.company })),
{ id: null, label: t`No job yet` },
].map((option) => (
<button
key={option.id ?? "none"}
type="button"
aria-pressed={jobId === option.id}
onClick={() => {
setJobId(option.id);
setName(null);
}}
className={cn(
"flex h-8 items-center gap-1.5 rounded-full border px-3 text-[13px] transition-colors duration-quick",
jobId === option.id
? "border-accent bg-accent-soft text-accent-text"
: "border-line-2 text-ink-2 hover:bg-hover",
)}
>
{option.id && <Icon name="work" size={16} />}
{option.label}
</button>
))}
</div>
{job && (
<span className="text-ink-3 text-xs">
<Trans>The copy is linked to the application, so Check and the assistant use its posting.</Trans>
</span>
)}
</fieldset>
<div className="grid gap-1.5">
<label htmlFor={nameId} className="font-medium text-ink-2 text-xs">
<Trans>Name</Trans>
</label>
<Input
id={nameId}
value={name ?? suggested}
maxLength={100}
onChange={(event) => setName(event.target.value)}
/>
<span className="text-ink-3 text-xs">
<Trans>Suggested from the source and the job. Change it anytime.</Trans>
</span>
</div>
<div className="flex flex-wrap justify-end gap-2">
{onBack && (
<Button variant="ghost" onClick={onBack}>
<Trans>Back</Trans>
</Button>
)}
<Button disabled={!source || !finalName || isPending} onClick={() => void create()}>
<Trans>Create and open</Trans>
</Button>
</div>
</>
);
}
@@ -0,0 +1,35 @@
import { createJSONStorage, persist } from "zustand/middleware";
import { create } from "zustand/react";
type NewDocumentsStore = {
/** Documents made on this device that haven't been opened yet; their cards show "New". */
ids: readonly string[];
markNew: (id: string) => void;
markOpened: (id: string) => void;
};
// Only the latest few matter; older ones have long been opened or forgotten.
const MAX_REMEMBERED = 50;
export const useNewDocumentsStore = create<NewDocumentsStore>()(
persist(
(set) => ({
ids: [],
markNew: (id) =>
set((state) => ({ ids: [id, ...state.ids.filter((known) => known !== id)].slice(0, MAX_REMEMBERED) })),
markOpened: (id) =>
set((state) => (state.ids.includes(id) ? { ids: state.ids.filter((known) => known !== id) } : state)),
}),
{
name: "new-documents",
// Storage can be unavailable (private windows, blocked site data); the badge is cosmetic, so go without.
storage: createJSONStorage(() => {
try {
return window.localStorage;
} catch {
return { getItem: () => null, setItem: () => undefined, removeItem: () => undefined };
}
}),
},
),
);
@@ -34,13 +34,6 @@ vi.mock("@/libs/orpc/client", () => ({
const resume: ComponentProps<typeof ResumeThumbnail>["resume"] = {
id: "thumbnail-test",
name: "Resume",
slug: "resume",
tags: [],
isLocked: false,
isPublic: false,
showDownloadButtons: true,
createdAt: new Date(0),
updatedAt: new Date(0),
};
let resize: () => void;
@@ -87,7 +80,7 @@ function setup() {
});
const ui = () => (
<QueryClientProvider client={client}>
<ResumeThumbnail resume={currentResume} isLocked={false} />
<ResumeThumbnail resume={currentResume} />
</QueryClientProvider>
);
const result = render(ui());
@@ -136,7 +129,7 @@ it("reuses rendered image after card hide and remount", async () => {
const remounted = render(
<QueryClientProvider client={view.client}>
<ResumeThumbnail resume={resume} isLocked={false} />
<ResumeThumbnail resume={resume} />
</QueryClientProvider>,
);
await waitFor(() =>
@@ -1,25 +1,19 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { RefObject } from "react";
import type { ResumeThumbnailSize } from "@/features/resume/preview/resume-thumbnail.shared";
import type { RouterOutput } from "@/libs/orpc/client";
import { FileTextIcon } from "@phosphor-icons/react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useInView } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { Icon } from "@reactive-resume/ui/components/icon";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { createPdfFirstPageImageUrl, releaseThumbnailUrls } from "@/features/resume/preview/pdf-thumbnail";
import { getResumeThumbnailCacheKey, getResumeThumbnailSize } from "@/features/resume/preview/resume-thumbnail.shared";
import { orpc } from "@/libs/orpc/client";
type ResumeListItem = RouterOutput["resume"]["list"][number];
type ThumbnailState = { status: "error" | "idle" | "loading" } | { status: "ready"; url: string };
type ResumeThumbnailProps = {
isLocked: boolean;
resume: ResumeListItem;
resume: { id: string; updatedAt: Date };
};
const throwIfAborted = (signal: AbortSignal) => {
@@ -149,7 +143,8 @@ function useResumeThumbnail(
return { status: "loading" };
}
export function ResumeThumbnail({ isLocked, resume }: ResumeThumbnailProps) {
/** The resume's first page, rendered once it scrolls near view and cached until it changes. */
export function ResumeThumbnail({ resume }: ResumeThumbnailProps) {
const containerRef = useRef<HTMLDivElement>(null);
const isInView = useInView(containerRef, { amount: 0.1, margin: "240px" });
const size = useThumbnailSize(containerRef, isInView);
@@ -166,13 +161,7 @@ export function ResumeThumbnail({ isLocked, resume }: ResumeThumbnailProps) {
const hasFailed = resumeIsError || thumbnail.status === "error";
return (
<div
ref={containerRef}
className={cn(
"relative size-full overflow-hidden bg-muted/40 transition-[filter] duration-200 ease-out-strong",
isLocked && "blur-xs",
)}
>
<div ref={containerRef} className="relative size-full overflow-hidden bg-white">
{thumbnail.status === "ready" ? (
<div
aria-hidden
@@ -180,13 +169,12 @@ export function ResumeThumbnail({ isLocked, resume }: ResumeThumbnailProps) {
style={{ backgroundImage: `url(${thumbnail.url})` }}
/>
) : hasFailed ? (
<div className="absolute inset-0 flex items-center justify-center">
<FileTextIcon weight="thin" className="size-12 opacity-40" />
<div className="absolute inset-0 flex items-center justify-center text-ink-3">
<Icon name="description" size={24} />
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner className="size-8 text-muted-foreground" />
</div>
// Loading: a sunken placeholder at the page's real size, so nothing jumps.
<div className="absolute inset-0 animate-pulse bg-sunken" />
)}
</div>
);
@@ -0,0 +1,81 @@
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { buttonVariants } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { orpc } from "@/libs/orpc/client";
import { DocumentRow } from "./document-card";
const noop = () => undefined;
/** Trash: documents stay 30 days, then go for good. Restore, or Delete now after one confirmation. */
export function TrashPage() {
const { data: documents, isPending } = useQuery(orpc.documents.list.queryOptions({ input: { trashed: true } }));
return (
<div className="mx-auto grid w-full max-w-[1180px] content-start gap-5 px-8 py-8 max-sm:px-4 max-sm:py-5">
<Link
to="/dashboard"
className={buttonVariants({ variant: "ghost", size: "sm", className: "-ms-2.5 w-fit gap-1.5 text-ink-2" })}
>
<Icon name="arrow_back" size={18} />
<Trans>Back to documents</Trans>
</Link>
<div className="grid gap-1">
<h1 className="font-display font-medium text-[30px] leading-9">
<Trans>Trash</Trans>
</h1>
<p className="text-ink-2 text-sm">
<Trans>Items are deleted permanently after 30 days.</Trans>
</p>
</div>
{!isPending && documents?.length === 0 ? (
<div className="grid justify-items-center gap-2 py-16 text-center">
<Icon name="delete" size={28} className="text-ink-3" />
<p className="font-semibold">
<Trans>Trash is empty</Trans>
</p>
<p className="text-ink-2 text-sm">
<Trans>Items you move here stay for 30 days.</Trans>
</p>
</div>
) : (
<table className="w-full border-collapse">
<thead>
<tr className="border-line border-b font-medium text-ink-3 text-xs">
<th className="h-10 ps-3 text-start font-medium">
<Trans>Name</Trans>
</th>
<th className="px-2 text-start font-medium max-sm:hidden">
<Trans>Type</Trans>
</th>
<th className="px-2 text-start font-medium max-sm:hidden">
<Trans>Application</Trans>
</th>
<th className="px-2 text-start font-medium">
<Trans>Deleted in</Trans>
</th>
<th className="w-10">
<span className="sr-only">
<Trans>Options</Trans>
</span>
</th>
</tr>
</thead>
<tbody>
{documents?.map((document) => (
<DocumentRow
key={`${document.type}:${document.id}`}
document={document}
onOpenLetter={noop}
onTags={noop}
onLink={noop}
/>
))}
</tbody>
</table>
)}
</div>
);
}
@@ -7,6 +7,7 @@ import { CSS } from "@dnd-kit/utilities";
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 { useEffect, useMemo } from "react";
import { Badge } from "@reactive-resume/ui/components/badge";
import {
@@ -25,6 +26,7 @@ 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 { useCurrentResume, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
import { orpc } from "@/libs/orpc/client";
import { atsFindingItemElementId } from "@/libs/resume/ats";
import { getCompatibleMoveTargets, getSourceSectionTitle, moveItem } from "@/libs/resume/move-item";
import { useEditorStore } from "../store";
@@ -273,6 +275,17 @@ function EntryMenu({ section, entry }: EntryMenuProps) {
useEditorStore.getState().select({ kind: "item", sectionId: section.id, itemId: copy.id });
};
// Q3k: a letter written inside a resume can become a saved letter in Documents (a copy; this entry stays).
const { mutate: copyLetter, isPending: copying } = useMutation(orpc.coverLetters.copyEmbedded.mutationOptions());
const copyToDocuments = () =>
copyLetter(
{ resumeId: resume.id, sectionId: section.id, itemId: entry.id },
{
onSuccess: () => toast.add({ description: t`Copied to Documents` }),
onError: () => toast.add({ type: "error", description: t`Couldn't copy the letter. Save and try again.` }),
},
);
const toggleHidden = () =>
updateResumeData(
(draft) => {
@@ -296,6 +309,12 @@ function EntryMenu({ section, entry }: EntryMenuProps) {
<Icon name="content_copy" />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
{section.type === "cover-letter" && section.kind === "custom" && (
<DropdownMenuItem disabled={copying} onClick={copyToDocuments}>
<Icon name="mail" />
<Trans>Copy to Documents</Trans>
</DropdownMenuItem>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Icon name="arrow_forward" />
@@ -23,6 +23,7 @@ vi.mock("@/libs/orpc/client", () => ({
setLocked: { mutationOptions: () => ({}) },
update: { call: vi.fn(() => new Promise(() => undefined)) },
},
coverLetters: { copyEmbedded: { mutationOptions: () => ({}) } },
},
streamClient: { resume: { updates: { subscribe: vi.fn() } } },
}));
@@ -52,7 +52,7 @@ export function WritePanel() {
</p>
{isEmpty && !locked ? (
<StartSuggestions onImport={() => openDialog("resume.import", undefined)} />
<StartSuggestions onImport={() => openDialog("document.new", undefined)} />
) : (
<Outline locked={locked} page={page} />
)}
@@ -0,0 +1,90 @@
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { parseResumeData } from "@reactive-resume/schema/resume/data";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { detectImportKind, detectJsonImportKind, parseResumeJson, summarizeImport } from "./read-file";
beforeAll(() => i18n.loadAndActivate({ locale: "en-US", messages: {} }));
describe("detectJsonImportKind", () => {
it("detects JSON Resume by a top-level basics without Reactive Resume sections/metadata", () => {
expect(detectJsonImportKind({ basics: { name: "A" }, work: [] })).toBe("json-resume-json");
});
it("detects the current Reactive Resume schema by metadata.page", () => {
expect(detectJsonImportKind({ basics: {}, sections: {}, metadata: { page: { locale: "en-US" } } })).toBe(
"reactive-resume-json",
);
});
it("detects the legacy v4 schema by metadata without a page key, or by a v4 layout", () => {
expect(detectJsonImportKind({ basics: {}, sections: {}, metadata: { template: "azurill" } })).toBe(
"reactive-resume-v4-json",
);
expect(
detectJsonImportKind({
basics: {},
sections: {},
metadata: { layout: [[["experience"], ["skills"]]], page: { margin: 14, format: "a4" } },
}),
).toBe("reactive-resume-v4-json");
});
it("detects a saved cover letter", () => {
expect(detectJsonImportKind({ format: "reactive-resume-cover-letter", version: 1, name: "Letter" })).toBe(
"cover-letter-json",
);
});
it("returns null for unrecognized shapes", () => {
expect(detectJsonImportKind({})).toBeNull();
expect(detectJsonImportKind({ foo: "bar" })).toBeNull();
expect(detectJsonImportKind(null)).toBeNull();
expect(detectJsonImportKind("nope")).toBeNull();
});
});
describe("detectImportKind", () => {
const file = (bytes: BlobPart, name: string, type = "") => new File([bytes], name, { type });
it("reads PDFs by their magic bytes, whatever the name", async () => {
expect(await detectImportKind(file("%PDF-1.7", "resume"))).toBe("pdf");
});
it("prefers LinkedIn for .zip and Word for other ZIP containers", async () => {
const zip = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
expect(await detectImportKind(file(zip, "Basic_LinkedInDataExport.zip"))).toBe("linkedin");
expect(await detectImportKind(file(zip, "resume.docx"))).toBe("docx");
});
it("reads JSON by its shape", async () => {
expect(await detectImportKind(file(JSON.stringify({ basics: {}, work: [] }), "resume.json"))).toBe(
"json-resume-json",
);
expect(await detectImportKind(file("{nope", "broken.json"))).toBeNull();
// Downloads can arrive without an extension or a type; the first character gives JSON away.
expect(await detectImportKind(file(` {"format":"reactive-resume-cover-letter"}`, "33ae6e21"))).toBe(
"cover-letter-json",
);
});
});
describe("parseResumeJson", () => {
it("keeps a selected v4 import from falling back to JSON Resume", () => {
expect(() => parseResumeJson("{}", "reactive-resume-v4-json")).toThrow(/v4/i);
});
});
describe("summarizeImport", () => {
it("counts sections with content, their entries and dates flagged for a look", () => {
const data = parseResumeData(structuredClone(sampleResumeData));
const experience = data.sections.experience.items[0];
if (experience?.dates) experience.dates = { ...experience.dates, raw: "Summer 2016" };
const summary = summarizeImport(data);
expect(summary.sections).toBeGreaterThan(3);
expect(summary.entries).toBeGreaterThanOrEqual(summary.sections - 1);
expect(summary.flagged).toBe(1);
});
});
@@ -0,0 +1,173 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { t } from "@lingui/core/macro";
import { parseJSONResume } from "@reactive-resume/import/json-resume";
import { parseReactiveResumeJSON } from "@reactive-resume/import/reactive-resume-json";
import { parseReactiveResumeV4JSON } from "@reactive-resume/import/reactive-resume-v4-json";
import { forEachDatedEntry } from "@reactive-resume/schema/resume/dates";
import { client } from "@/libs/orpc/client";
/** What a file holds, read from its bytes and shape rather than its extension or type. */
export type ImportKind =
| "pdf"
| "docx"
| "linkedin"
| "reactive-resume-json"
| "reactive-resume-v4-json"
| "json-resume-json"
| "cover-letter-json";
type ResumeJsonKind = "reactive-resume-json" | "reactive-resume-v4-json" | "json-resume-json";
/** An import failure worded for the person importing. */
export class ImportError extends Error {}
export function detectJsonImportKind(parsed: unknown): ImportKind | null {
if (!parsed || typeof parsed !== "object") return null;
const data = parsed as Record<string, unknown>;
// A saved cover letter exported from Reactive Resume.
if (data.format === "reactive-resume-cover-letter") return "cover-letter-json";
// JSON Resume standard: top-level `basics`, without Reactive Resume's `sections`/`metadata`.
if ("basics" in data && !("sections" in data) && !("metadata" in data)) return "json-resume-json";
// Reactive Resume exports carry `sections` + `metadata`. V4 stores layout as nested arrays, while the current
// schema stores a layout object. Both versions can have `metadata.page`, so that key alone cannot distinguish them.
if ("sections" in data || "metadata" in data) {
const metadata = data.metadata as Record<string, unknown> | undefined;
if (metadata && Array.isArray(metadata.layout)) return "reactive-resume-v4-json";
if (metadata && !("page" in metadata)) return "reactive-resume-v4-json";
return "reactive-resume-json";
}
return null;
}
/** Sniffs the format from magic bytes and JSON shape: several resume formats share the .json extension. */
export async function detectImportKind(file: File): Promise<ImportKind | null> {
const name = file.name.toLowerCase();
const mime = file.type;
const header = new Uint8Array(await file.slice(0, 4).arrayBuffer());
const isPdf = header[0] === 0x25 && header[1] === 0x50 && header[2] === 0x44 && header[3] === 0x46; // "%PDF"
const isZip = header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04; // "PK\x03\x04"
if (isPdf || mime === "application/pdf" || name.endsWith(".pdf")) return "pdf";
// Word documents are also ZIPs, so a bare "PK" header is ambiguous. LinkedIn's export is
// only ever named with a .zip extension, so check that first and let it win the tie.
if (name.endsWith(".zip") || mime === "application/zip") return "linkedin";
if (
isZip ||
mime === "application/msword" ||
mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
name.endsWith(".docx") ||
name.endsWith(".doc")
) {
return "docx";
}
// JSON by type, extension or its first character: downloads and drag-outs can arrive without either.
const start = new TextDecoder().decode(await file.slice(0, 64).arrayBuffer()).trimStart();
if (mime === "application/json" || name.endsWith(".json") || start.startsWith("{")) {
try {
return detectJsonImportKind(JSON.parse(await file.text()));
} catch {
return null;
}
}
return null;
}
export function parseResumeJson(text: string, kind: ResumeJsonKind): ResumeData {
if (kind === "reactive-resume-json") return parseReactiveResumeJSON(text);
if (kind === "reactive-resume-v4-json") return parseReactiveResumeV4JSON(text);
return parseJSONResume(text);
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
// Drop the data URL prefix ("data:application/pdf;base64,").
reader.onload = () => resolve(String(reader.result).split(",")[1] ?? "");
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
* Reads a resume from a file. PDFs go to the AI provider when one is set up and are read in the browser
* otherwise; Word needs a provider; LinkedIn exports and JSON never leave the browser. `onRead` reports the
* end of the first step (the file's text is in hand) with a short note.
*/
export async function readResumeFile(
file: File,
kind: Exclude<ImportKind, "cover-letter-json">,
options: { aiAvailable: boolean; onRead?: (note: string) => void },
): Promise<ResumeData> {
if (kind === "reactive-resume-json" || kind === "reactive-resume-v4-json" || kind === "json-resume-json") {
const text = await file.text();
options.onRead?.(t`JSON read`);
return parseResumeJson(text, kind);
}
if (kind === "linkedin") {
const { parseLinkedInExport } = await import("@reactive-resume/import/linkedin");
const bytes = new Uint8Array(await file.arrayBuffer());
options.onRead?.(t`export opened`);
return parseLinkedInExport(bytes);
}
if (kind === "docx") {
if (!options.aiAvailable) {
throw new ImportError(
t`Reading Word files needs an AI provider. Set one up in Settings, or import a PDF or JSON file instead.`,
);
}
const base64 = await fileToBase64(file);
options.onRead?.(t`file read`);
const mediaType =
file.type === "application/msword"
? ("application/msword" as const)
: ("application/vnd.openxmlformats-officedocument.wordprocessingml.document" as const);
return client.ai.parseDocx({ mediaType, file: { name: file.name, data: base64 } });
}
if (options.aiAvailable) {
const base64 = await fileToBase64(file);
options.onRead?.(t`file read`);
return client.ai.parsePdf({ file: { name: file.name, data: base64 } });
}
const [{ extractPdfLines }, { parseResumeText }] = await Promise.all([
import("./pdf-text"),
import("@reactive-resume/import/plain-text"),
]);
const lines = await extractPdfLines(file);
if (lines.length === 0) {
throw new ImportError(
t`This PDF is a scanned image. There's no text to read. Try the Word version, or start blank and paste sections in.`,
);
}
options.onRead?.(t`text layer found`);
return parseResumeText(lines.join("\n"));
}
/** What an import found: sections with content, their entries, and dates flagged for a look. */
export function summarizeImport(data: ResumeData) {
const sections = [...Object.values(data.sections), ...data.customSections].filter(
(section) => section.items.length > 0,
);
let flagged = 0;
forEachDatedEntry(data, (entry) => {
if (entry.dates?.raw) flagged++;
});
return {
sections: sections.length + (data.summary.content.replace(/<[^>]*>/g, "").trim() ? 1 : 0),
entries: sections.reduce((total, section) => total + section.items.length, 0),
flagged,
};
}
@@ -17,6 +17,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Icon } from "@reactive-resume/ui/components/icon";
import { toast } from "@reactive-resume/ui/components/toast";
import { useTheme } from "@/features/theme/provider";
import { authClient } from "@/libs/auth/client";
@@ -72,6 +73,11 @@ export function UserDropdownMenu({ children }: Props) {
<DropdownMenuTrigger render={children({ session: session as AuthSession })} />
<DropdownMenuContent align="start" side="top">
<DropdownMenuItem onClick={() => void router.navigate({ to: "/dashboard/settings/profile" })}>
<Icon name="settings" />
<Trans>Settings</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
+1 -3
View File
@@ -1,4 +1,4 @@
import type { InferRouterInputs, InferRouterOutputs, RouterClient } from "@orpc/server";
import type { InferRouterOutputs, RouterClient } from "@orpc/server";
import type router from "@reactive-resume/api/routers";
import { createORPCClient, onError } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
@@ -45,6 +45,4 @@ export const streamClient: RouterClient<typeof router> = createORPCClient(
export const orpc = createTanstackQueryUtils(client);
export type RouterInput = InferRouterInputs<typeof router>;
export type RouterOutput = InferRouterOutputs<typeof router>;
+103 -49
View File
@@ -32,6 +32,8 @@ import { Route as AuthVerify2faBackupRouteImport } from "./routes/auth/verify-2f
import { Route as BuilderResumeIdRouteRouteImport } from "./routes/builder/$resumeId/route";
import { Route as DashboardIndexRouteImport } from "./routes/dashboard/index";
import { Route as DashboardCoverLettersRouteImport } from "./routes/dashboard/cover-letters";
import { Route as DashboardSettingsRouteRouteImport } from "./routes/dashboard/settings/route";
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 DashboardApplicationsIndexRouteImport } from "./routes/dashboard/applications/index";
@@ -158,6 +160,16 @@ const DashboardCoverLettersRoute = DashboardCoverLettersRouteImport.update({
path: "/cover-letters",
getParentRoute: () => DashboardRouteRoute,
} as any);
const DashboardSettingsRouteRoute = DashboardSettingsRouteRouteImport.update({
id: "/settings",
path: "/settings",
getParentRoute: () => DashboardRouteRoute,
} as any);
const DashboardTrashRoute = DashboardTrashRouteImport.update({
id: "/trash",
path: "/trash",
getParentRoute: () => DashboardRouteRoute,
} as any);
const TemplatesSplatRoute = TemplatesSplatRouteImport.update({
id: "/templates/$",
path: "/templates/$",
@@ -181,45 +193,45 @@ const DashboardResumesIndexRoute = DashboardResumesIndexRouteImport.update({
} as any);
const DashboardSettingsAccountRoute =
DashboardSettingsAccountRouteImport.update({
id: "/settings/account",
path: "/settings/account",
getParentRoute: () => DashboardRouteRoute,
id: "/account",
path: "/account",
getParentRoute: () => DashboardSettingsRouteRoute,
} as any);
const DashboardSettingsApiKeysRoute =
DashboardSettingsApiKeysRouteImport.update({
id: "/settings/api-keys",
path: "/settings/api-keys",
getParentRoute: () => DashboardRouteRoute,
id: "/api-keys",
path: "/api-keys",
getParentRoute: () => DashboardSettingsRouteRoute,
} as any);
const DashboardSettingsIntegrationsRouteRoute =
DashboardSettingsIntegrationsRouteRouteImport.update({
id: "/settings/integrations",
path: "/settings/integrations",
getParentRoute: () => DashboardRouteRoute,
id: "/integrations",
path: "/integrations",
getParentRoute: () => DashboardSettingsRouteRoute,
} as any);
const DashboardSettingsJobSearchRoute =
DashboardSettingsJobSearchRouteImport.update({
id: "/settings/job-search",
path: "/settings/job-search",
getParentRoute: () => DashboardRouteRoute,
id: "/job-search",
path: "/job-search",
getParentRoute: () => DashboardSettingsRouteRoute,
} as any);
const DashboardSettingsPreferencesRoute =
DashboardSettingsPreferencesRouteImport.update({
id: "/settings/preferences",
path: "/settings/preferences",
getParentRoute: () => DashboardRouteRoute,
id: "/preferences",
path: "/preferences",
getParentRoute: () => DashboardSettingsRouteRoute,
} as any);
const DashboardSettingsProfileRoute =
DashboardSettingsProfileRouteImport.update({
id: "/settings/profile",
path: "/settings/profile",
getParentRoute: () => DashboardRouteRoute,
id: "/profile",
path: "/profile",
getParentRoute: () => DashboardSettingsRouteRoute,
} as any);
const DashboardSettingsAuthenticationIndexRoute =
DashboardSettingsAuthenticationIndexRouteImport.update({
id: "/settings/authentication/",
path: "/settings/authentication/",
getParentRoute: () => DashboardRouteRoute,
id: "/authentication/",
path: "/authentication/",
getParentRoute: () => DashboardSettingsRouteRoute,
} as any);
export interface FileRoutesByFullPath {
@@ -228,6 +240,7 @@ export interface FileRoutesByFullPath {
"/auth": typeof AuthRouteRouteWithChildren;
"/dashboard": typeof DashboardRouteRouteWithChildren;
"/builder/$resumeId": typeof BuilderResumeIdRouteRouteWithChildren;
"/dashboard/settings": typeof DashboardSettingsRouteRouteWithChildren;
"/$username/$slug": typeof UsernameSlugRoute;
"/ats-checker": typeof HomeAtsCheckerRoute;
"/agent/$threadId": typeof AgentThreadIdRoute;
@@ -242,6 +255,7 @@ export interface FileRoutesByFullPath {
"/auth/verify-2fa": typeof AuthVerify2faRoute;
"/auth/verify-2fa-backup": typeof AuthVerify2faBackupRoute;
"/dashboard/cover-letters": typeof DashboardCoverLettersRoute;
"/dashboard/trash": typeof DashboardTrashRoute;
"/templates/$": typeof TemplatesSplatRoute;
"/agent/": typeof AgentIndexRoute;
"/auth/": typeof AuthIndexRoute;
@@ -258,6 +272,7 @@ export interface FileRoutesByFullPath {
"/dashboard/settings/authentication/": typeof DashboardSettingsAuthenticationIndexRoute;
}
export interface FileRoutesByTo {
"/dashboard/settings": typeof DashboardSettingsRouteRouteWithChildren;
"/$username/$slug": typeof UsernameSlugRoute;
"/ats-checker": typeof HomeAtsCheckerRoute;
"/agent/$threadId": typeof AgentThreadIdRoute;
@@ -272,6 +287,7 @@ export interface FileRoutesByTo {
"/auth/verify-2fa": typeof AuthVerify2faRoute;
"/auth/verify-2fa-backup": typeof AuthVerify2faBackupRoute;
"/dashboard/cover-letters": typeof DashboardCoverLettersRoute;
"/dashboard/trash": typeof DashboardTrashRoute;
"/templates/$": typeof TemplatesSplatRoute;
"/": typeof HomeIndexRoute;
"/agent": typeof AgentIndexRoute;
@@ -295,6 +311,7 @@ export interface FileRoutesById {
"/auth": typeof AuthRouteRouteWithChildren;
"/dashboard": typeof DashboardRouteRouteWithChildren;
"/builder/$resumeId": typeof BuilderResumeIdRouteRouteWithChildren;
"/dashboard/settings": typeof DashboardSettingsRouteRouteWithChildren;
"/$username/$slug": typeof UsernameSlugRoute;
"/_home/ats-checker": typeof HomeAtsCheckerRoute;
"/agent/$threadId": typeof AgentThreadIdRoute;
@@ -309,6 +326,7 @@ export interface FileRoutesById {
"/auth/verify-2fa": typeof AuthVerify2faRoute;
"/auth/verify-2fa-backup": typeof AuthVerify2faBackupRoute;
"/dashboard/cover-letters": typeof DashboardCoverLettersRoute;
"/dashboard/trash": typeof DashboardTrashRoute;
"/templates/$": typeof TemplatesSplatRoute;
"/_home/": typeof HomeIndexRoute;
"/agent/": typeof AgentIndexRoute;
@@ -333,6 +351,7 @@ export interface FileRouteTypes {
| "/auth"
| "/dashboard"
| "/builder/$resumeId"
| "/dashboard/settings"
| "/$username/$slug"
| "/ats-checker"
| "/agent/$threadId"
@@ -347,6 +366,7 @@ export interface FileRouteTypes {
| "/auth/verify-2fa"
| "/auth/verify-2fa-backup"
| "/dashboard/cover-letters"
| "/dashboard/trash"
| "/templates/$"
| "/agent/"
| "/auth/"
@@ -363,6 +383,7 @@ export interface FileRouteTypes {
| "/dashboard/settings/authentication/";
fileRoutesByTo: FileRoutesByTo;
to:
| "/dashboard/settings"
| "/$username/$slug"
| "/ats-checker"
| "/agent/$threadId"
@@ -377,6 +398,7 @@ export interface FileRouteTypes {
| "/auth/verify-2fa"
| "/auth/verify-2fa-backup"
| "/dashboard/cover-letters"
| "/dashboard/trash"
| "/templates/$"
| "/"
| "/agent"
@@ -399,6 +421,7 @@ export interface FileRouteTypes {
| "/auth"
| "/dashboard"
| "/builder/$resumeId"
| "/dashboard/settings"
| "/$username/$slug"
| "/_home/ats-checker"
| "/agent/$threadId"
@@ -413,6 +436,7 @@ export interface FileRouteTypes {
| "/auth/verify-2fa"
| "/auth/verify-2fa-backup"
| "/dashboard/cover-letters"
| "/dashboard/trash"
| "/templates/$"
| "/_home/"
| "/agent/"
@@ -603,6 +627,20 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof DashboardCoverLettersRouteImport;
parentRoute: typeof DashboardRouteRoute;
};
"/dashboard/settings": {
id: "/dashboard/settings";
path: "/settings";
fullPath: "/dashboard/settings";
preLoaderRoute: typeof DashboardSettingsRouteRouteImport;
parentRoute: typeof DashboardRouteRoute;
};
"/dashboard/trash": {
id: "/dashboard/trash";
path: "/trash";
fullPath: "/dashboard/trash";
preLoaderRoute: typeof DashboardTrashRouteImport;
parentRoute: typeof DashboardRouteRoute;
};
"/templates/$": {
id: "/templates/$";
path: "/templates/$";
@@ -633,52 +671,52 @@ declare module "@tanstack/react-router" {
};
"/dashboard/settings/account": {
id: "/dashboard/settings/account";
path: "/settings/account";
path: "/account";
fullPath: "/dashboard/settings/account";
preLoaderRoute: typeof DashboardSettingsAccountRouteImport;
parentRoute: typeof DashboardRouteRoute;
parentRoute: typeof DashboardSettingsRouteRoute;
};
"/dashboard/settings/api-keys": {
id: "/dashboard/settings/api-keys";
path: "/settings/api-keys";
path: "/api-keys";
fullPath: "/dashboard/settings/api-keys";
preLoaderRoute: typeof DashboardSettingsApiKeysRouteImport;
parentRoute: typeof DashboardRouteRoute;
parentRoute: typeof DashboardSettingsRouteRoute;
};
"/dashboard/settings/integrations": {
id: "/dashboard/settings/integrations";
path: "/settings/integrations";
path: "/integrations";
fullPath: "/dashboard/settings/integrations";
preLoaderRoute: typeof DashboardSettingsIntegrationsRouteRouteImport;
parentRoute: typeof DashboardRouteRoute;
parentRoute: typeof DashboardSettingsRouteRoute;
};
"/dashboard/settings/job-search": {
id: "/dashboard/settings/job-search";
path: "/settings/job-search";
path: "/job-search";
fullPath: "/dashboard/settings/job-search";
preLoaderRoute: typeof DashboardSettingsJobSearchRouteImport;
parentRoute: typeof DashboardRouteRoute;
parentRoute: typeof DashboardSettingsRouteRoute;
};
"/dashboard/settings/preferences": {
id: "/dashboard/settings/preferences";
path: "/settings/preferences";
path: "/preferences";
fullPath: "/dashboard/settings/preferences";
preLoaderRoute: typeof DashboardSettingsPreferencesRouteImport;
parentRoute: typeof DashboardRouteRoute;
parentRoute: typeof DashboardSettingsRouteRoute;
};
"/dashboard/settings/profile": {
id: "/dashboard/settings/profile";
path: "/settings/profile";
path: "/profile";
fullPath: "/dashboard/settings/profile";
preLoaderRoute: typeof DashboardSettingsProfileRouteImport;
parentRoute: typeof DashboardRouteRoute;
parentRoute: typeof DashboardSettingsRouteRoute;
};
"/dashboard/settings/authentication/": {
id: "/dashboard/settings/authentication/";
path: "/settings/authentication";
path: "/authentication";
fullPath: "/dashboard/settings/authentication/";
preLoaderRoute: typeof DashboardSettingsAuthenticationIndexRouteImport;
parentRoute: typeof DashboardRouteRoute;
parentRoute: typeof DashboardSettingsRouteRoute;
};
}
}
@@ -743,34 +781,50 @@ const AuthRouteRouteWithChildren = AuthRouteRoute._addFileChildren(
AuthRouteRouteChildren,
);
interface DashboardRouteRouteChildren {
DashboardCoverLettersRoute: typeof DashboardCoverLettersRoute;
DashboardIndexRoute: typeof DashboardIndexRoute;
interface DashboardSettingsRouteRouteChildren {
DashboardSettingsIntegrationsRouteRoute: typeof DashboardSettingsIntegrationsRouteRoute;
DashboardSettingsAccountRoute: typeof DashboardSettingsAccountRoute;
DashboardSettingsApiKeysRoute: typeof DashboardSettingsApiKeysRoute;
DashboardSettingsJobSearchRoute: typeof DashboardSettingsJobSearchRoute;
DashboardSettingsPreferencesRoute: typeof DashboardSettingsPreferencesRoute;
DashboardSettingsProfileRoute: typeof DashboardSettingsProfileRoute;
DashboardApplicationsIndexRoute: typeof DashboardApplicationsIndexRoute;
DashboardResumesIndexRoute: typeof DashboardResumesIndexRoute;
DashboardSettingsAuthenticationIndexRoute: typeof DashboardSettingsAuthenticationIndexRoute;
}
const DashboardSettingsRouteRouteChildren: DashboardSettingsRouteRouteChildren =
{
DashboardSettingsIntegrationsRouteRoute:
DashboardSettingsIntegrationsRouteRoute,
DashboardSettingsAccountRoute: DashboardSettingsAccountRoute,
DashboardSettingsApiKeysRoute: DashboardSettingsApiKeysRoute,
DashboardSettingsJobSearchRoute: DashboardSettingsJobSearchRoute,
DashboardSettingsPreferencesRoute: DashboardSettingsPreferencesRoute,
DashboardSettingsProfileRoute: DashboardSettingsProfileRoute,
DashboardSettingsAuthenticationIndexRoute:
DashboardSettingsAuthenticationIndexRoute,
};
const DashboardSettingsRouteRouteWithChildren =
DashboardSettingsRouteRoute._addFileChildren(
DashboardSettingsRouteRouteChildren,
);
interface DashboardRouteRouteChildren {
DashboardSettingsRouteRoute: typeof DashboardSettingsRouteRouteWithChildren;
DashboardCoverLettersRoute: typeof DashboardCoverLettersRoute;
DashboardTrashRoute: typeof DashboardTrashRoute;
DashboardIndexRoute: typeof DashboardIndexRoute;
DashboardApplicationsIndexRoute: typeof DashboardApplicationsIndexRoute;
DashboardResumesIndexRoute: typeof DashboardResumesIndexRoute;
}
const DashboardRouteRouteChildren: DashboardRouteRouteChildren = {
DashboardSettingsRouteRoute: DashboardSettingsRouteRouteWithChildren,
DashboardCoverLettersRoute: DashboardCoverLettersRoute,
DashboardTrashRoute: DashboardTrashRoute,
DashboardIndexRoute: DashboardIndexRoute,
DashboardSettingsIntegrationsRouteRoute:
DashboardSettingsIntegrationsRouteRoute,
DashboardSettingsAccountRoute: DashboardSettingsAccountRoute,
DashboardSettingsApiKeysRoute: DashboardSettingsApiKeysRoute,
DashboardSettingsJobSearchRoute: DashboardSettingsJobSearchRoute,
DashboardSettingsPreferencesRoute: DashboardSettingsPreferencesRoute,
DashboardSettingsProfileRoute: DashboardSettingsProfileRoute,
DashboardApplicationsIndexRoute: DashboardApplicationsIndexRoute,
DashboardResumesIndexRoute: DashboardResumesIndexRoute,
DashboardSettingsAuthenticationIndexRoute:
DashboardSettingsAuthenticationIndexRoute,
};
const DashboardRouteRouteWithChildren = DashboardRouteRoute._addFileChildren(
@@ -171,7 +171,7 @@ export function AgentThreadSidebar({ activeThreadId = null, className }: AgentTh
<Trans>Threads</Trans>
</div>
</div>
<Button size="sm" variant="ghost" nativeButton={false} render={<Link to="/dashboard/resumes" />}>
<Button size="sm" variant="ghost" nativeButton={false} render={<Link to="/dashboard" />}>
<ArrowLeftIcon />
<Trans>Back to resumes</Trans>
</Button>
@@ -17,7 +17,6 @@ import { useDialogStore } from "@/dialogs/store";
import { useCurrentBuilderResumeSelector, useCurrentResume, usePatchResume } from "@/features/resume/builder/draft";
import { SaveStatus } from "@/features/resume/editor/save-status";
import { useResumeExport } from "@/features/resume/export/use-resume-export";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { InformationSectionBuilder } from "../-sidebar/right/sections/information";
@@ -86,7 +85,6 @@ type DocumentMenuItemsProps = {
};
function DocumentMenuItems({ onOpenDialog }: DocumentMenuItemsProps) {
const confirm = useConfirm();
const navigate = useNavigate();
const { openDialog } = useDialogStore();
const resume = useCurrentResume();
@@ -94,7 +92,8 @@ function DocumentMenuItems({ onOpenDialog }: DocumentMenuItemsProps) {
const { onPrint } = useResumeExport(resume);
const { id, name, slug, tags, isLocked } = resume;
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
const { mutate: trashResume } = useMutation(orpc.documents.trash.mutationOptions());
const { mutate: restoreResume } = useMutation(orpc.documents.restore.mutationOptions());
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
// Locking is reversible, so it doesn't ask for confirmation.
@@ -114,23 +113,27 @@ function DocumentMenuItems({ onOpenDialog }: DocumentMenuItemsProps) {
);
};
// ponytail: permanent delete with a confirmation until Trash exists (plan M6), then this moves to Trash with undo.
const handleDelete = async () => {
const confirmation = await confirm(t`Are you sure you want to delete this resume?`, {
description: t`This action cannot be undone.`,
});
if (!confirmation) return;
const toastId = toast.add({ type: "loading", description: t`Deleting your resume...` });
deleteResume(
{ id },
// Undoable, so it doesn't ask first: the resume waits in Trash for 30 days.
const handleTrash = () => {
trashResume(
{ type: "resume", id },
{
onSuccess: () => {
toast.add({ type: "success", description: t`Your resume has been deleted.`, id: toastId });
void navigate({ to: "/dashboard/resumes", search: { sort: "lastUpdatedAt", tags: [] } });
void navigate({ to: "/dashboard" });
toast.add({
description: t`“${name}” moved to Trash`,
actionProps: {
children: t`Undo`,
onClick: () =>
restoreResume(
{ type: "resume", id },
{ onError: (error) => toast.add({ type: "error", description: getResumeErrorMessage(error) }) },
),
},
});
},
onError: (error) => {
toast.add({ type: "error", description: getResumeErrorMessage(error), id: toastId });
toast.add({ type: "error", description: getResumeErrorMessage(error) });
},
},
);
@@ -164,9 +167,9 @@ function DocumentMenuItems({ onOpenDialog }: DocumentMenuItemsProps) {
<Trans>Print</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={isLocked} onClick={handleDelete}>
<DropdownMenuItem variant="destructive" disabled={isLocked} onClick={handleTrash}>
<Icon name="delete" />
<Trans>Delete…</Trans>
<Trans>Move to Trash</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
);
@@ -69,8 +69,7 @@ function BackLink() {
<TooltipTrigger
render={
<Link
to="/dashboard/resumes"
search={{ sort: "lastUpdatedAt", tags: [] }}
to="/dashboard"
aria-label={label}
className={buttonVariants({ variant: "ghost", size: "icon", className: "text-ink-2" })}
/>
@@ -0,0 +1,337 @@
import type { IconName } from "@reactive-resume/ui/components/icon";
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useHotkey } from "@tanstack/react-hotkeys";
import { useQuery } from "@tanstack/react-query";
import { Link, useMatchRoute } from "@tanstack/react-router";
import { Avatar, AvatarFallback, AvatarImage } from "@reactive-resume/ui/components/avatar";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Kbd } from "@reactive-resume/ui/components/kbd";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { getInitials } from "@reactive-resume/utils/string";
import { cn } from "@reactive-resume/utils/style";
import { useDialogStore } from "@/dialogs/store";
import { applicationsListQueryOptions } from "@/features/applications/queries";
import { useCommandPaletteStore } from "@/features/command-palette/store";
import { isEditableElementFocused } from "@/features/resume/builder/draft";
import { UserDropdownMenu } from "@/features/user/dropdown-menu";
import { orpc } from "@/libs/orpc/client";
type NavItem = {
to: "/dashboard" | "/dashboard/applications" | "/dashboard/trash";
icon: IconName;
label: string;
count?: number;
};
function useNavItems() {
const { data: counts } = useQuery(orpc.documents.counts.queryOptions());
const { data: applications } = useQuery(applicationsListQueryOptions());
const items: NavItem[] = [
{
to: "/dashboard",
icon: "description",
label: t`Documents`,
...(counts ? { count: counts.resume + counts.letter } : {}),
},
{
to: "/dashboard/applications",
icon: "work",
label: t`Applications`,
...(applications ? { count: applications.filter((application) => !application.archived).length } : {}),
},
];
const trash: NavItem | null = counts?.trash
? { to: "/dashboard/trash", icon: "delete", label: t`Trash`, count: counts.trash }
: null;
return { items, trash };
}
/** Whether the item is the current page: Documents is exact, the others match their section. */
function useIsCurrent() {
const matchRoute = useMatchRoute();
return (to: NavItem["to"]) => Boolean(matchRoute({ to, fuzzy: to !== "/dashboard" }));
}
/**
* The app shell for Documents, Trash, Applications and Settings: a 240px sidebar at ≥1024, an icon rail at
* 640–1023 and a bottom tab bar below 640. N opens New anywhere outside a field.
*/
export function AppShell({ children }: { children: ReactNode }) {
const breakpoint = useBreakpoint();
const openDialog = useDialogStore((state) => state.openDialog);
useHotkey("N", () => {
if (isEditableElementFocused() || useDialogStore.getState().open) return;
openDialog("document.new", undefined);
});
if (breakpoint === "mobile") {
return (
<div className="grid min-h-svh grid-rows-[minmax(0,1fr)_auto] bg-bg">
<main id="main-content" className="min-w-0">
{children}
</main>
<MobileTabs />
</div>
);
}
return (
<div
className={cn(
"grid min-h-svh bg-bg",
breakpoint === "tablet" ? "grid-cols-[64px_minmax(0,1fr)]" : "grid-cols-[240px_minmax(0,1fr)]",
)}
>
<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 main content</Trans>
</a>
{breakpoint === "tablet" ? <Rail /> : <Sidebar />}
<main id="main-content" className="min-w-0">
{children}
</main>
</div>
);
}
function Sidebar() {
const { items, trash } = useNavItems();
const isCurrent = useIsCurrent();
const openPalette = useCommandPaletteStore((state) => state.setOpen);
const openDialog = useDialogStore((state) => state.openDialog);
return (
<aside className="sticky top-0 flex h-svh flex-col gap-3 border-line border-e bg-surface p-3">
<Link to="/" className="flex h-9 items-center gap-2.5 px-1.5">
<span className="grid size-6 place-items-center rounded-[5px] bg-ink font-display font-medium text-[13px] text-bg">
Rr
</span>
<span className="font-semibold text-sm">Reactive Resume</span>
</Link>
<button
type="button"
aria-label={t`Search or run…`}
aria-keyshortcuts="Meta+K"
onClick={() => openPalette(true)}
className="flex h-[34px] items-center gap-2 rounded-lg border border-line bg-bg px-2.5 text-ink-3 text-sm transition-colors duration-quick hover:text-ink-2"
>
<Icon name="search" size={18} />
<span className="flex-1 text-start">
<Trans>Search or run…</Trans>
</span>
<Kbd>⌘K</Kbd>
</button>
<nav aria-label={t`App`} className="grid gap-0.5">
{items.map((item) => (
<NavLink key={item.to} item={item} current={isCurrent(item.to)} />
))}
</nav>
<div className="mt-auto grid gap-2">
{trash && <NavLink item={trash} current={isCurrent(trash.to)} />}
<Button
aria-label={t`New`}
aria-keyshortcuts="N"
className="h-9 justify-between"
onClick={() => openDialog("document.new", undefined)}
>
<span className="flex items-center gap-1.5">
<Icon name="add" />
<Trans>New</Trans>
</span>
<Kbd className="bg-on-accent/15 text-on-accent">N</Kbd>
</Button>
<UserDropdownMenu>
{({ session }) => (
<button
type="button"
className="flex h-11 items-center gap-2.5 rounded-lg px-1.5 text-start transition-colors duration-quick hover:bg-hover"
>
<Avatar className="size-7">
<AvatarImage src={session.user.image ?? undefined} />
<AvatarFallback className="text-[11px]">{getInitials(session.user.name)}</AvatarFallback>
</Avatar>
<span className="grid min-w-0">
<span className="truncate font-medium text-sm">{session.user.name}</span>
<span className="text-ink-3 text-xs">
<Trans>Settings</Trans>
</span>
</span>
</button>
)}
</UserDropdownMenu>
</div>
</aside>
);
}
function NavLink({ item, current }: { item: NavItem; current: boolean }) {
return (
<Link
to={item.to}
aria-current={current ? "page" : undefined}
className={cn(
"flex h-[34px] items-center gap-2.5 rounded-lg px-2.5 text-sm transition-colors duration-quick",
current ? "bg-sunken font-medium text-ink" : "text-ink-2 hover:bg-hover hover:text-ink",
)}
>
<Icon name={item.icon} filled={current} />
<span className="flex-1">{item.label}</span>
{item.count !== undefined && <span className="font-mono text-ink-3 text-xs">{item.count}</span>}
</Link>
);
}
/** Tablets: the same destinations as icons, with their names in tooltips. */
function Rail() {
const { items, trash } = useNavItems();
const isCurrent = useIsCurrent();
const openPalette = useCommandPaletteStore((state) => state.setOpen);
const openDialog = useDialogStore((state) => state.openDialog);
return (
<aside className="sticky top-0 flex h-svh flex-col items-center gap-2 border-line border-e bg-surface py-3">
<Link
to="/"
aria-label="Reactive Resume"
className="mb-1 grid size-8 place-items-center rounded-md bg-ink font-display text-[13px] text-bg"
>
Rr
</Link>
<RailTip label={t`Search`} icon="search">
<button
type="button"
aria-label={t`Search or run…`}
onClick={() => openPalette(true)}
className="grid size-10 place-items-center rounded-lg text-ink-2 hover:bg-hover"
/>
</RailTip>
<nav aria-label={t`App`} className="grid gap-1">
{items.map((item) => (
<RailTip key={item.to} label={item.label} icon={item.icon} filled={isCurrent(item.to)}>
<Link
to={item.to}
aria-label={item.label}
aria-current={isCurrent(item.to) ? "page" : undefined}
className={cn(
"grid size-10 place-items-center rounded-lg transition-colors duration-quick",
isCurrent(item.to) ? "bg-sunken text-ink" : "text-ink-2 hover:bg-hover",
)}
/>
</RailTip>
))}
</nav>
<div className="mt-auto grid justify-items-center gap-2">
{trash && (
<RailTip label={trash.label} icon={trash.icon}>
<Link
to={trash.to}
aria-label={t`Trash`}
className="grid size-10 place-items-center rounded-lg text-ink-2 hover:bg-hover"
/>
</RailTip>
)}
<RailTip label={t`New`} icon="add">
<Button size="icon" aria-label={t`New`} onClick={() => openDialog("document.new", undefined)} />
</RailTip>
<UserDropdownMenu>
{({ session }) => (
<button type="button" aria-label={t`Settings`} className="rounded-full">
<Avatar className="size-8">
<AvatarImage src={session.user.image ?? undefined} />
<AvatarFallback className="text-[11px]">{getInitials(session.user.name)}</AvatarFallback>
</Avatar>
</button>
)}
</UserDropdownMenu>
</div>
</aside>
);
}
/** A rail button: its icon inside the given trigger, and its name in a tooltip. */
function RailTip({
label,
icon,
filled = false,
children,
}: {
label: string;
icon: IconName;
filled?: boolean;
children: React.ReactElement;
}) {
return (
<Tooltip>
<TooltipTrigger render={children}>
<Icon name={icon} filled={filled} />
</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
);
}
/** Phones: Documents · Applications · New · Account, with New as an accent pill in the middle. */
function MobileTabs() {
const isCurrent = useIsCurrent();
const openDialog = useDialogStore((state) => state.openDialog);
const tab = (icon: IconName, label: string, current: boolean) => (
<>
<Icon name={icon} size={24} filled={current} />
<span className={cn("text-[11px]", current ? "font-semibold text-ink" : "text-ink-2")}>{label}</span>
</>
);
return (
<nav
aria-label={t`App`}
className="sticky bottom-0 z-30 grid grid-cols-4 border-line border-t bg-surface pb-[env(safe-area-inset-bottom)]"
>
<Link
to="/dashboard"
aria-current={isCurrent("/dashboard") ? "page" : undefined}
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5"
>
{tab("description", t`Documents`, isCurrent("/dashboard"))}
</Link>
<Link
to="/dashboard/applications"
aria-current={isCurrent("/dashboard/applications") ? "page" : undefined}
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5"
>
{tab("work", t`Applications`, isCurrent("/dashboard/applications"))}
</Link>
<button
type="button"
onClick={() => openDialog("document.new", undefined)}
className="flex min-h-[52px] flex-col items-center justify-center gap-0.5"
>
<span className="grid h-[26px] w-[34px] place-items-center rounded-full bg-accent text-on-accent">
<Icon name="add" size={20} />
</span>
<span className="text-[11px] text-ink-2">
<Trans>New</Trans>
</span>
</button>
<UserDropdownMenu>
{() => (
<button type="button" className="flex min-h-[52px] flex-col items-center justify-center gap-0.5">
{tab("account_circle", t`Account`, false)}
</button>
)}
</UserDropdownMenu>
</nav>
);
}
@@ -3,15 +3,10 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ListIcon } from "@phosphor-icons/react";
import { SidebarProvider } from "@reactive-resume/ui/components/sidebar";
import { DashboardHeader } from "./header";
const renderHeader = (props: Partial<React.ComponentProps<typeof DashboardHeader>> = {}) =>
render(
<SidebarProvider>
<DashboardHeader title="Resumes" icon={ListIcon} {...props} />
</SidebarProvider>,
);
render(<DashboardHeader title="Resumes" icon={ListIcon} {...props} />);
describe("DashboardHeader", () => {
it("renders the title as an h1", () => {
@@ -30,11 +25,4 @@ describe("DashboardHeader", () => {
const wrapper = container.querySelector(".custom-class");
expect(wrapper).not.toBeNull();
});
it("includes the mobile sidebar trigger (hidden on md+)", () => {
const { container } = renderHeader();
const trigger = container.querySelector('[data-sidebar="trigger"]');
expect(trigger).not.toBeNull();
expect(trigger?.className).toContain("md:hidden");
});
});
@@ -1,6 +1,5 @@
import type { Icon as IconType } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { SidebarTrigger } from "@reactive-resume/ui/components/sidebar";
import { cn } from "@reactive-resume/utils/style";
type Props = {
@@ -13,8 +12,7 @@ type Props = {
export function DashboardHeader({ title, icon: IconComponent, className, actions }: Props) {
return (
<div className={cn("relative flex items-center gap-x-2.5", className)}>
<SidebarTrigger className="absolute inset-s-0 md:hidden" />
<div className="flex flex-1 items-center justify-center gap-x-2.5 md:justify-start">
<div className="flex flex-1 items-center gap-x-2.5">
<IconComponent weight="light" className="size-5" />
<h1 className="font-medium text-xl tracking-tight">{title}</h1>
</div>
@@ -1,249 +0,0 @@
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import {
BrainIcon,
BriefcaseIcon,
ChatCircleDotsIcon,
EnvelopeSimpleIcon,
GearSixIcon,
KeyIcon,
MagnifyingGlassIcon,
ReadCvLogoIcon,
SealCheckIcon,
ShieldCheckIcon,
UserCircleIcon,
UserGearIcon,
} from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { AnimatePresence, m } from "motion/react";
import { Avatar, AvatarFallback, AvatarImage } from "@reactive-resume/ui/components/avatar";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Kbd } from "@reactive-resume/ui/components/kbd";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
SidebarSeparator,
useSidebarState,
} from "@reactive-resume/ui/components/sidebar";
import { getInitials } from "@reactive-resume/utils/string";
import { Copyright } from "@/components/ui/copyright";
import { useCommandPaletteStore } from "@/features/command-palette/store";
import { UserDropdownMenu } from "@/features/user/dropdown-menu";
import { EASE_OUT_STRONG } from "@/libs/motion";
type SidebarItem = {
icon: React.ReactNode;
label: MessageDescriptor;
href: React.ComponentProps<typeof Link>["to"];
};
const appSidebarItems = [
{
icon: <ReadCvLogoIcon />,
label: msg`Resumes`,
href: "/dashboard/resumes",
},
{
icon: <BriefcaseIcon />,
label: msg`Applications`,
href: "/dashboard/applications",
},
{
icon: <EnvelopeSimpleIcon />,
label: msg`Cover Letters`,
href: "/dashboard/cover-letters",
},
{
icon: <ChatCircleDotsIcon />,
label: msg`Agents`,
href: "/agent",
},
{
icon: <SealCheckIcon />,
label: msg`ATS Checker`,
href: "/ats-checker",
},
] as const satisfies SidebarItem[];
const settingsSidebarItems = [
{
icon: <UserCircleIcon />,
label: msg`Profile`,
href: "/dashboard/settings/profile",
},
{
icon: <GearSixIcon />,
label: msg`Preferences`,
href: "/dashboard/settings/preferences",
},
{
icon: <ShieldCheckIcon />,
label: msg`Authentication`,
href: "/dashboard/settings/authentication",
},
{
icon: <KeyIcon />,
label: msg`API Keys`,
href: "/dashboard/settings/api-keys",
},
{
icon: <BrainIcon />,
label: msg`Integrations`,
href: "/dashboard/settings/integrations",
},
{
icon: <UserGearIcon />,
label: msg`Account`,
href: "/dashboard/settings/account",
},
] as const satisfies SidebarItem[];
type SidebarItemListProps = {
items: readonly SidebarItem[];
};
function SidebarItemList({ items }: SidebarItemListProps) {
const { i18n } = useLingui();
return (
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
tooltip={i18n.t(item.label)}
render={
<Link to={item.href} activeProps={{ className: "bg-sidebar-accent" }}>
{item.icon}
<span className="shrink-0 transition-opacity duration-150 ease-out-strong group-data-[collapsible=icon]:opacity-0 group-data-instant/sidebar-wrapper:transition-none">
{i18n.t(item.label)}
</span>
</Link>
}
/>
</SidebarMenuItem>
))}
</SidebarMenu>
);
}
function SidebarSearchButton() {
const { i18n } = useLingui();
const setOpen = useCommandPaletteStore((state) => state.setOpen);
const label = i18n.t(msg`Search`);
return (
<SidebarMenuItem>
<SidebarMenuButton tooltip={label} onClick={() => setOpen(true)}>
<MagnifyingGlassIcon />
<span className="flex-1 text-start transition-opacity duration-150 ease-out-strong group-data-[collapsible=icon]:opacity-0 group-data-instant/sidebar-wrapper:transition-none">
{label}
</span>
<Kbd className="transition-opacity duration-150 ease-out-strong group-data-[collapsible=icon]:opacity-0 group-data-instant/sidebar-wrapper:transition-none">
⌘K
</Kbd>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
export function DashboardSidebar() {
const { i18n } = useLingui();
const { state } = useSidebarState();
return (
<Sidebar variant="floating" collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
className="h-auto justify-center"
render={
<Link to="/">
<BrandIcon variant="icon" className="size-6" />
<h1 className="sr-only">Reactive Resume</h1>
</Link>
}
/>
</SidebarMenuItem>
<SidebarSearchButton />
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent aria-label={i18n.t(msg`Dashboard`)} role="navigation">
<SidebarGroup>
<SidebarGroupLabel>
<Trans>App</Trans>
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarItemList items={appSidebarItems} />
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>
<Trans>Settings</Trans>
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarItemList items={settingsSidebarItems} />
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarSeparator />
<SidebarFooter className="gap-y-0">
<SidebarMenu>
<SidebarMenuItem>
<UserDropdownMenu>
{({ session }) => (
<SidebarMenuButton className="h-auto gap-x-3 group-data-[collapsible=icon]:p-1!">
<Avatar className="size-8 shrink-0 group-data-[collapsible=icon]:size-6">
<AvatarImage src={session.user.image ?? undefined} />
<AvatarFallback className="group-data-[collapsible=icon]:text-[0.5rem]">
{getInitials(session.user.name)}
</AvatarFallback>
</Avatar>
<div className="transition-opacity duration-150 ease-out-strong group-data-[collapsible=icon]:opacity-0 group-data-instant/sidebar-wrapper:transition-none">
<p className="font-medium">{session.user.name}</p>
<p className="text-muted-foreground text-xs">{session.user.email}</p>
</div>
</SidebarMenuButton>
)}
</UserDropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<AnimatePresence>
{state === "expanded" && (
<m.div
key="copyright"
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { duration: 0.2, ease: EASE_OUT_STRONG } }}
exit={{ opacity: 0, transition: { duration: 0.08 } }}
>
<Copyright className="wrap-break-word shrink-0 whitespace-normal p-2" />
</m.div>
)}
</AnimatePresence>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}
@@ -124,7 +124,7 @@ function RouteComponent() {
const setUrlSearch = (patch: Partial<Search>) => void navigate({ search: (prev: Search) => ({ ...prev, ...patch }) });
return (
<div className="flex h-[calc(100dvh-2rem)] flex-col gap-4">
<div className="flex h-dvh flex-col gap-4 p-4 max-sm:h-[calc(100dvh-52px)]">
<DashboardHeader
className="max-sm:flex-col max-sm:gap-y-3"
icon={BriefcaseIcon}
@@ -1,18 +1,8 @@
import { t } from "@lingui/core/macro";
import { EnvelopeSimpleIcon } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
import { Separator } from "@reactive-resume/ui/components/separator";
import { CoverLetterLibrary } from "@/features/cover-letters/library";
import { DashboardHeader } from "./-components/header";
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard/cover-letters")({ component: RouteComponent });
function RouteComponent() {
return (
<div className="space-y-4">
<DashboardHeader icon={EnvelopeSimpleIcon} title={t`Cover Letters`} />
<Separator />
<CoverLetterLibrary />
</div>
);
}
// The cover-letter library is now the Letters tab in Documents (6.0). This stub keeps old links working (Q2).
export const Route = createFileRoute("/dashboard/cover-letters")({
beforeLoad: () => {
throw redirect({ to: "/dashboard", search: { type: "letter" }, replace: true });
},
});
+31 -4
View File
@@ -1,7 +1,34 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import type { DocumentsSearch } from "@/features/documents/documents-page";
import { createFileRoute, stripSearchParams } from "@tanstack/react-router";
import z from "zod";
import { DocumentsPage } from "@/features/documents/documents-page";
const defaults = { type: "all", q: "", tags: [], sort: "edited" } satisfies DocumentsSearch;
export const Route = createFileRoute("/dashboard/")({
beforeLoad: () => {
throw redirect({ to: "/dashboard/resumes", search: { sort: "lastUpdatedAt", tags: [] }, replace: true });
},
validateSearch: z.object({
type: z.enum(["all", "resume", "letter"]).default("all").catch("all"),
q: z.string().default("").catch(""),
tags: z.array(z.string()).default([]).catch([]),
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),
letter: z.string().optional().catch(undefined),
}),
search: { middlewares: [stripSearchParams(defaults)] },
component: RouteComponent,
});
function RouteComponent() {
const search = Route.useSearch();
const navigate = Route.useNavigate();
return (
<DocumentsPage
search={search}
onSearchChange={(patch) =>
void navigate({ search: (previous: DocumentsSearch) => ({ ...previous, ...patch }), replace: true })
}
/>
);
}
@@ -1,40 +0,0 @@
import { Badge } from "@reactive-resume/ui/components/badge";
import { cn } from "@reactive-resume/utils/style";
import { CometCard } from "@/components/animation/comet-card";
type BaseCardProps = React.ComponentProps<"div"> & {
title: string;
description: string;
tags?: string[];
className?: string;
children?: React.ReactNode;
};
export function BaseCard({ title, description, tags, className, children, ...props }: BaseCardProps) {
return (
<CometCard>
<div
{...props}
className={cn(
"relative flex aspect-page size-full overflow-hidden rounded-md bg-popover shadow transition-shadow hover:shadow-xl",
className,
)}
>
{children}
<div className="absolute inset-x-0 bottom-0 flex w-full flex-col justify-end gap-y-0.5 bg-background/40 px-4 py-3 backdrop-blur-xs">
<h3 className="truncate font-medium tracking-tight">{title}</h3>
<p className="truncate text-xs opacity-80">{description}</p>
<div className={cn("mt-2 hidden flex-wrap items-center gap-1", tags && tags.length > 0 && "flex")}>
{tags?.map((tag) => (
<Badge key={tag} variant="neutral">
{tag}
</Badge>
))}
</div>
</div>
</div>
</CometCard>
);
}
@@ -1,54 +0,0 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { useDialogStore } from "@/dialogs/store";
import { CreateResumeCard } from "./create-card";
import { ImportResumeCard } from "./import-card";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
useDialogStore.setState({ open: false, activeDialog: null, onBeforeClose: null });
});
describe("CreateResumeCard", () => {
it("renders the create-resume copy", () => {
render(<CreateResumeCard />);
expect(screen.getByText("Create a new resume")).toBeInTheDocument();
expect(screen.getByText("Start building your resume from scratch")).toBeInTheDocument();
});
it("opens the resume.create dialog when clicked", () => {
render(<CreateResumeCard />);
const card = screen.getByText("Create a new resume").closest("div[class*='aspect-page']") as HTMLElement;
fireEvent.click(card);
const state = useDialogStore.getState();
expect(state.open).toBe(true);
expect(state.activeDialog?.type).toBe("resume.create");
});
});
describe("ImportResumeCard", () => {
it("renders the import-resume copy", () => {
render(<ImportResumeCard />);
expect(screen.getByText("Import an existing resume")).toBeInTheDocument();
expect(screen.getByText("Continue where you left off")).toBeInTheDocument();
});
it("opens the resume.import dialog when clicked", () => {
render(<ImportResumeCard />);
const card = screen.getByText("Import an existing resume").closest("div[class*='aspect-page']") as HTMLElement;
fireEvent.click(card);
const state = useDialogStore.getState();
expect(state.open).toBe(true);
expect(state.activeDialog?.type).toBe("resume.import");
});
});
@@ -1,20 +0,0 @@
import { t } from "@lingui/core/macro";
import { PlusIcon } from "@phosphor-icons/react";
import { useDialogStore } from "@/dialogs/store";
import { BaseCard } from "./base-card";
export function CreateResumeCard() {
const { openDialog } = useDialogStore();
return (
<BaseCard
title={t`Create a new resume`}
description={t`Start building your resume from scratch`}
onClick={() => openDialog("resume.create", undefined)}
>
<div className="absolute inset-0 flex items-center justify-center">
<PlusIcon weight="thin" className="size-12" />
</div>
</BaseCard>
);
}
@@ -1,20 +0,0 @@
import { t } from "@lingui/core/macro";
import { DownloadSimpleIcon } from "@phosphor-icons/react";
import { useDialogStore } from "@/dialogs/store";
import { BaseCard } from "./base-card";
export function ImportResumeCard() {
const { openDialog } = useDialogStore();
return (
<BaseCard
title={t`Import an existing resume`}
description={t`Continue where you left off`}
onClick={() => openDialog("resume.import", undefined)}
>
<div className="absolute inset-0 flex items-center justify-center">
<DownloadSimpleIcon weight="thin" className="size-12" />
</div>
</BaseCard>
);
}
@@ -1,60 +0,0 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { LockSimpleIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { AnimatePresence, m } from "motion/react";
import { useMemo } from "react";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { ResumeContextMenu } from "../menus/context-menu";
import { BaseCard } from "./base-card";
import { ResumeThumbnail } from "./resume-thumbnail";
type ResumeCardProps = {
resume: RouterOutput["resume"]["list"][number];
};
type ResumeLockOverlayProps = {
isLocked: boolean;
};
export function ResumeCard({ resume }: ResumeCardProps) {
const { i18n } = useLingui();
const updatedAt = useMemo(() => {
return Intl.DateTimeFormat(i18n.locale, { dateStyle: "long", timeStyle: "short" }).format(resume.updatedAt);
}, [i18n.locale, resume.updatedAt]);
return (
<ResumeContextMenu resume={resume}>
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }} className="cursor-default">
<BaseCard title={resume.name} description={t`Last updated on ${updatedAt}`} tags={resume.tags}>
<ResumeThumbnail resume={resume} isLocked={resume.isLocked} />
<ResumeLockOverlay isLocked={resume.isLocked} />
</BaseCard>
</Link>
</ResumeContextMenu>
);
}
function ResumeLockOverlay({ isLocked }: ResumeLockOverlayProps) {
return (
<AnimatePresence>
{isLocked && (
<m.div
key="resume-lock-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 0.6 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: EASE_OUT_STRONG }}
className="absolute inset-0 flex items-center justify-center"
>
<div className="flex items-center justify-center rounded-full bg-popover p-6">
<LockSimpleIcon weight="thin" className="size-12 opacity-60" />
</div>
</m.div>
)}
</AnimatePresence>
);
}
@@ -1,71 +0,0 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, m } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { CreateResumeCard } from "./cards/create-card";
import { ImportResumeCard } from "./cards/import-card";
import { ResumeCard } from "./cards/resume-card";
type Resume = RouterOutput["resume"]["list"][number];
type Props = {
/** `undefined` while the first page of resumes is loading. */
resumes: Resume[] | undefined;
hasResumes: boolean;
compact?: boolean;
};
export function GridView({ resumes, hasResumes, compact = false }: Props) {
const gridClassName = cn(
"grid gap-4",
compact
? "3xl:grid-cols-8 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6"
: "3xl:grid-cols-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5",
);
if (!resumes) {
return (
<div className={gridClassName}>
<div className="aspect-page rounded-md bg-muted/40" />
<div className="aspect-page rounded-md bg-muted/40" />
</div>
);
}
if (resumes.length === 0 && hasResumes) {
return (
<p className="py-8 text-center text-muted-foreground text-sm">
<Trans>No resumes match your search.</Trans>
</p>
);
}
if (resumes.length === 0) {
return (
<div className={gridClassName}>
<CreateResumeCard />
<ImportResumeCard />
</div>
);
}
return (
<div className={gridClassName}>
<AnimatePresence initial={false} mode="popLayout">
{resumes.map((resume) => (
<m.div
layout
key={resume.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: EASE_OUT_STRONG }}
>
<ResumeCard resume={resume} />
</m.div>
))}
</AnimatePresence>
</div>
);
}
@@ -1,138 +0,0 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { DotsThreeIcon, DownloadSimpleIcon, PlusIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { AnimatePresence, m } from "motion/react";
import { useMemo } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { useDialogStore } from "@/dialogs/store";
import { EASE_OUT_STRONG } from "@/libs/motion";
import { ResumeDropdownMenu } from "./menus/dropdown-menu";
type Resume = RouterOutput["resume"]["list"][number];
type ListViewProps = {
/** `undefined` while the first page of resumes is loading. */
resumes: Resume[] | undefined;
hasResumes: boolean;
};
type ResumeListItemProps = {
resume: Resume;
};
export function ListView({ resumes, hasResumes }: ListViewProps) {
const { openDialog } = useDialogStore();
if (!resumes) return null;
if (resumes.length === 0 && hasResumes) {
return (
<p className="py-8 text-center text-muted-foreground text-sm">
<Trans>No resumes match your search.</Trans>
</p>
);
}
if (resumes.length === 0) {
const handleCreateResume = () => {
openDialog("resume.create", undefined);
};
const handleImportResume = () => {
openDialog("resume.import", undefined);
};
return (
<div className="flex flex-col gap-y-1">
<Button
size="lg"
variant="ghost"
className="h-12 w-full justify-start gap-x-4 text-start"
onClick={handleCreateResume}
>
<PlusIcon />
<div className="min-w-0 flex-1 truncate">
<Trans>Create a new resume</Trans>
</div>
<p className="hidden text-xs opacity-60 sm:block">
<Trans>Start building your resume from scratch</Trans>
</p>
</Button>
<Button
size="lg"
variant="ghost"
className="h-12 w-full justify-start gap-x-4 text-start"
onClick={handleImportResume}
>
<DownloadSimpleIcon />
<div className="min-w-0 flex-1 truncate">
<Trans>Import an existing resume</Trans>
</div>
<p className="hidden text-xs opacity-60 sm:block">
<Trans>Continue where you left off</Trans>
</p>
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-y-1">
<AnimatePresence initial={false} mode="popLayout">
{resumes.map((resume) => (
<m.div
layout
key={resume.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: EASE_OUT_STRONG }}
>
<ResumeListItem resume={resume} />
</m.div>
))}
</AnimatePresence>
</div>
);
}
function ResumeListItem({ resume }: ResumeListItemProps) {
const { i18n } = useLingui();
const updatedAt = useMemo(() => {
return Intl.DateTimeFormat(i18n.locale, { dateStyle: "long", timeStyle: "short" }).format(resume.updatedAt);
}, [i18n.locale, resume.updatedAt]);
return (
<div className="flex items-center gap-x-2">
<Button
size="lg"
variant="ghost"
nativeButton={false}
className="h-12 w-full flex-1 justify-start gap-x-4 text-start"
render={
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }}>
<div className="size-3" />
<div className="min-w-0 flex-1 truncate">{resume.name}</div>
<p className="hidden text-xs opacity-60 sm:block">
<Trans>Last updated on {updatedAt}</Trans>
</p>
</Link>
}
/>
<ResumeDropdownMenu resume={resume} align="end">
<Button size="icon" variant="ghost" className="size-12">
<DotsThreeIcon />
</Button>
</ResumeDropdownMenu>
</div>
);
}
@@ -1,73 +0,0 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { Trans } from "@lingui/react/macro";
import {
CopySimpleIcon,
FolderOpenIcon,
LockSimpleIcon,
LockSimpleOpenIcon,
PencilSimpleLineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@reactive-resume/ui/components/context-menu";
import { useResumeMenuActions } from "./use-resume-menu-actions";
type Props = {
resume: RouterOutput["resume"]["list"][number];
children: React.ComponentProps<typeof ContextMenuTrigger>["render"];
};
export function ResumeContextMenu({ resume, children }: Props) {
const { handleDelete, handleDuplicate, handleToggleLock, handleUpdate } = useResumeMenuActions(resume);
return (
<ContextMenu>
<ContextMenuTrigger render={children} />
<ContextMenuContent>
<ContextMenuItem
render={
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }}>
<FolderOpenIcon />
<Trans comment="Resume card context menu action to open the resume editor">Open</Trans>
</Link>
}
/>
<ContextMenuSeparator />
<ContextMenuItem disabled={resume.isLocked} onClick={handleUpdate}>
<PencilSimpleLineIcon />
<Trans comment="Resume card context menu action to edit resume metadata">Edit details</Trans>
</ContextMenuItem>
<ContextMenuItem onClick={handleDuplicate}>
<CopySimpleIcon />
<Trans comment="Resume card context menu action to create a copy">Duplicate</Trans>
</ContextMenuItem>
<ContextMenuItem onClick={handleToggleLock}>
{resume.isLocked ? <LockSimpleOpenIcon /> : <LockSimpleIcon />}
{resume.isLocked ? (
<Trans comment="Resume card context menu action to remove edit lock">Unlock</Trans>
) : (
<Trans comment="Resume card context menu action to prevent edits">Lock</Trans>
)}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" disabled={resume.isLocked} onClick={handleDelete}>
<TrashSimpleIcon />
<Trans comment="Resume card context menu destructive action to remove a resume">Delete</Trans>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
@@ -1,71 +0,0 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { Trans } from "@lingui/react/macro";
import {
CopySimpleIcon,
FolderOpenIcon,
LockSimpleIcon,
LockSimpleOpenIcon,
PencilSimpleLineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { useResumeMenuActions } from "./use-resume-menu-actions";
type Props = Omit<React.ComponentProps<typeof DropdownMenuContent>, "children"> & {
resume: RouterOutput["resume"]["list"][number];
children: React.ComponentProps<typeof DropdownMenuTrigger>["render"];
};
export function ResumeDropdownMenu({ resume, children, ...props }: Props) {
const { handleDelete, handleDuplicate, handleToggleLock, handleUpdate } = useResumeMenuActions(resume);
return (
<DropdownMenu>
<DropdownMenuTrigger render={children} />
<DropdownMenuContent {...props}>
<Link to="/builder/$resumeId" params={{ resumeId: resume.id }}>
<DropdownMenuItem>
<FolderOpenIcon />
<Trans comment="Resume card dropdown action to open the resume editor">Open</Trans>
</DropdownMenuItem>
</Link>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={resume.isLocked} onClick={handleUpdate}>
<PencilSimpleLineIcon />
<Trans comment="Resume card dropdown action to edit resume metadata">Edit details</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDuplicate}>
<CopySimpleIcon />
<Trans comment="Resume card dropdown action to create a copy">Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleToggleLock}>
{resume.isLocked ? <LockSimpleOpenIcon /> : <LockSimpleIcon />}
{resume.isLocked ? (
<Trans comment="Resume card dropdown action to remove edit lock">Unlock</Trans>
) : (
<Trans comment="Resume card dropdown action to prevent edits">Lock</Trans>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={resume.isLocked} onClick={handleDelete}>
<TrashSimpleIcon />
<Trans comment="Resume card dropdown destructive action to remove a resume">Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,54 +0,0 @@
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { useMutation } from "@tanstack/react-query";
import { toast } from "@reactive-resume/ui/components/toast";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
type Resume = RouterOutput["resume"]["list"][number];
export function useResumeMenuActions(resume: Resume) {
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
const handleToggleLock = async () => {
if (!resume.isLocked) {
const confirmed = await confirm(t`Are you sure you want to lock this resume?`, {
description: t`When locked, the resume cannot be updated or deleted.`,
});
if (!confirmed) return;
}
setLockedResume(
{ id: resume.id, isLocked: !resume.isLocked },
{ onError: (error) => toast.add({ type: "error", description: getResumeErrorMessage(error) }) },
);
};
const handleDelete = async () => {
const confirmed = await confirm(t`Are you sure you want to delete this resume?`, {
description: t`This action cannot be undone.`,
});
if (!confirmed) return;
const toastId = toast.add({ type: "loading", description: t`Deleting your resume...` });
deleteResume(
{ id: resume.id },
{
onSuccess: () => toast.add({ type: "success", description: t`Your resume has been deleted.`, id: toastId }),
onError: (error) => toast.add({ type: "error", description: getResumeErrorMessage(error), id: toastId }),
},
);
};
return {
handleDelete,
handleDuplicate: () => openDialog("resume.duplicate", resume),
handleToggleLock,
handleUpdate: () => openDialog("resume.update", resume),
};
}
@@ -1,51 +0,0 @@
// @vitest-environment happy-dom
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useResumeView } from "./view-mode";
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("resume view preference", () => {
it("restores the selected view after leaving and returning during the session", async () => {
const first = renderHook(() => useResumeView("compact", "owner"));
await waitFor(() => expect(sessionStorage.getItem("resume-view:owner")).toBe('"compact"'));
first.unmount();
const second = renderHook(() => useResumeView(undefined, "owner"));
await waitFor(() => expect(second.result.current).toBe("compact"));
});
it("honors explicit grid in the URL over a saved compact preference", async () => {
sessionStorage.setItem("resume-view:owner", '"compact"');
const { result } = renderHook(() => useResumeView("grid", "owner"));
expect(result.current).toBe("grid");
await waitFor(() => expect(sessionStorage.getItem("resume-view:owner")).toBe('"grid"'));
});
it("keeps the preference scoped to its account", async () => {
sessionStorage.setItem("resume-view:first", '"list"');
const { result, rerender } = renderHook(({ user }) => useResumeView(undefined, user), {
initialProps: { user: "first" },
});
await waitFor(() => expect(result.current).toBe("list"));
rerender({ user: "second" });
await waitFor(() => expect(result.current).toBe("grid"));
});
it("ignores unknown stored views", () => {
sessionStorage.setItem("resume-view:owner", '"unknown"');
const { result } = renderHook(() => useResumeView(undefined, "owner"));
expect(result.current).toBe("grid");
});
it("renders the default when session storage is unavailable", () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new DOMException("Blocked", "SecurityError");
});
vi.spyOn(console, "warn").mockImplementation(() => {});
const { result } = renderHook(() => useResumeView(undefined, "owner"));
expect(result.current).toBe("grid");
});
});
@@ -1,18 +0,0 @@
import { useEffect } from "react";
import { useSessionStorage } from "usehooks-ts";
import z from "zod";
export const resumeViewSchema = z.enum(["grid", "compact", "list"]);
type ResumeView = z.infer<typeof resumeViewSchema>;
export function useResumeView(view: ResumeView | undefined, userId: string): ResumeView {
const [storedView, setStoredView] = useSessionStorage<unknown>(`resume-view:${userId}`, "grid", {
initializeWithValue: false,
});
useEffect(() => {
if (view !== undefined && storedView !== view) setStoredView(view);
}, [view, storedView, setStoredView]);
return view ?? resumeViewSchema.catch("grid").parse(storedView);
}
+19 -195
View File
@@ -1,201 +1,25 @@
import { msg, t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import {
DownloadSimpleIcon,
GridFourIcon,
GridNineIcon,
ListIcon,
MagnifyingGlassIcon,
PlusIcon,
ReadCvLogoIcon,
} from "@phosphor-icons/react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { createFileRoute, Link, stripSearchParams, useNavigate } from "@tanstack/react-router";
import { useMemo, useState } from "react";
import { createFileRoute, redirect } from "@tanstack/react-router";
import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@reactive-resume/ui/components/input-group";
import { Label } from "@reactive-resume/ui/components/label";
import { Separator } from "@reactive-resume/ui/components/separator";
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { useDialogStore } from "@/dialogs/store";
import { orpc } from "@/libs/orpc/client";
import { DashboardHeader } from "../-components/header";
import { GridView } from "./-components/grid-view";
import { ListView } from "./-components/list-view";
import { resumeViewSchema, useResumeView } from "./-components/view-mode";
type SortOption = "lastUpdatedAt" | "createdAt" | "name";
const searchSchema = z.object({
tags: z.array(z.string()).default([]),
sort: z.enum(["lastUpdatedAt", "createdAt", "name"]).default("lastUpdatedAt"),
view: resumeViewSchema.optional().catch(undefined),
});
type Search = z.output<typeof searchSchema>;
const defaultSearch: Search = { tags: [], sort: "lastUpdatedAt" };
// The resume library is now Documents (6.0). This stub keeps old links working through 6.0.x (Q2).
const SORTS = { lastUpdatedAt: "edited", createdAt: "created", name: "name" } as const;
export const Route = createFileRoute("/dashboard/resumes/")({
component: RouteComponent,
validateSearch: searchSchema,
search: {
middlewares: [stripSearchParams(defaultSearch)],
validateSearch: z.object({
tags: z.array(z.string()).optional().catch(undefined),
sort: z.enum(["lastUpdatedAt", "createdAt", "name"]).optional().catch(undefined),
view: z.enum(["grid", "compact", "list"]).optional().catch(undefined),
}),
beforeLoad: ({ search }) => {
throw redirect({
to: "/dashboard",
search: {
type: "resume",
...(search.tags?.length ? { tags: search.tags } : {}),
...(search.sort ? { sort: SORTS[search.sort] } : {}),
...(search.view === "list" ? { view: "list" as const } : {}),
},
replace: true,
});
},
});
function RouteComponent() {
const { i18n } = useLingui();
const { tags, sort, view: searchView } = Route.useSearch();
const [searchQuery, setSearchQuery] = useState("");
const { session } = Route.useRouteContext();
const view = useResumeView(searchView, session.user.id);
const navigate = useNavigate({ from: Route.fullPath });
const { openDialog } = useDialogStore();
const { data: allTags } = useQuery(orpc.resume.tags.list.queryOptions());
const { data: resumes } = useQuery({
...orpc.resume.list.queryOptions({ input: { tags, sort } }),
placeholderData: keepPreviousData,
});
const filteredResumes = useMemo(() => {
if (!resumes) return undefined;
const query = searchQuery.trim().toLowerCase();
if (!query) return resumes;
return resumes.filter(
(resume) => resume.name.toLowerCase().includes(query) || resume.slug.toLowerCase().includes(query),
);
}, [resumes, searchQuery]);
const tagOptions = useMemo(() => {
if (!allTags) return [];
return allTags.map((tag) => ({ value: tag, label: tag }));
}, [allTags]);
const sortOptions = useMemo(() => {
return [
{ value: "lastUpdatedAt", label: i18n.t(msg`Last Updated`) },
{ value: "createdAt", label: i18n.t(msg`Created`) },
{ value: "name", label: i18n.t(msg`Name`) },
];
}, [i18n]);
return (
<div className="space-y-4">
<DashboardHeader
icon={ReadCvLogoIcon}
title={t`Resumes`}
actions={
(resumes?.length ?? 0) > 0 ? (
<>
<Button size="sm" variant="secondary" onClick={() => openDialog("resume.create", undefined)}>
<PlusIcon />
<Trans>Create</Trans>
</Button>
<Button size="sm" variant="secondary" onClick={() => openDialog("resume.import", undefined)}>
<DownloadSimpleIcon />
<Trans>Import</Trans>
</Button>
</>
) : undefined
}
/>
<Separator />
<div className="grid gap-3 sm:flex sm:flex-wrap sm:items-center">
<div className="grid min-w-0 gap-1.5 sm:flex sm:items-center sm:gap-2">
<Label className="text-muted-foreground text-xs sm:text-sm">
<Trans>Sort by</Trans>
</Label>
<Combobox
className="w-full sm:w-44"
value={sort}
options={sortOptions}
placeholder={t`Sort by`}
onValueChange={(value) => {
if (!value) return;
void navigate({ search: (prev: Search) => ({ ...prev, sort: value as SortOption }) });
}}
/>
</div>
<div
className={cn("grid min-w-0 gap-1.5 sm:flex sm:items-center sm:gap-2", { hidden: tagOptions.length === 0 })}
>
<Label className="text-muted-foreground text-xs sm:text-sm">
<Trans>Filter by</Trans>
</Label>
<Combobox
multiple
className="w-full sm:w-44"
value={tags}
options={tagOptions}
placeholder={t`Filter by`}
onValueChange={(value) => {
void navigate({ search: (prev: Search) => ({ ...prev, tags: value ?? [] }) });
}}
/>
</div>
{(resumes?.length ?? 0) > 5 && (
<InputGroup className="w-full sm:w-56 lg:w-64">
<InputGroupAddon align="inline-start">
<MagnifyingGlassIcon />
</InputGroupAddon>
<InputGroupInput
value={searchQuery}
placeholder={t`Search resumes...`}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</InputGroup>
)}
<Tabs className="w-full sm:w-auto ltr:sm:ms-auto rtl:sm:me-auto" value={view}>
<TabsList className="w-full max-w-full overflow-x-auto sm:w-fit">
<TabsTrigger
value="grid"
nativeButton={false}
className="rounded-r-none"
render={<Link to="." search={(prev: Search) => ({ ...prev, view: "grid" })} />}
>
<GridFourIcon />
<Trans>Grid</Trans>
</TabsTrigger>
<TabsTrigger
value="compact"
nativeButton={false}
className="rounded-none"
render={<Link to="." search={(prev: Search) => ({ ...prev, view: "compact" })} />}
>
<GridNineIcon />
<Trans>Compact</Trans>
</TabsTrigger>
<TabsTrigger
value="list"
nativeButton={false}
className="rounded-l-none"
render={<Link to="." search={(prev: Search) => ({ ...prev, view: "list" })} />}
>
<ListIcon />
<Trans>List</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{view === "list" ? (
<ListView resumes={filteredResumes} hasResumes={(resumes?.length ?? 0) > 0} />
) : (
<GridView resumes={filteredResumes} hasResumes={(resumes?.length ?? 0) > 0} compact={view === "compact"} />
)}
</div>
);
}
+4 -24
View File
@@ -1,9 +1,6 @@
import { Trans } from "@lingui/react/macro";
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import Cookies from "js-cookie";
import { SidebarProvider } from "@reactive-resume/ui/components/sidebar";
import { createNoindexFollowMeta } from "@/libs/seo";
import { DashboardSidebar } from "./-components/sidebar";
import { AppShell } from "./-components/app-shell";
export const Route = createFileRoute("/dashboard")({
component: RouteComponent,
@@ -11,32 +8,15 @@ export const Route = createFileRoute("/dashboard")({
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
loader: () => {
const sidebarState = Cookies.get("sidebar_state") !== "false";
return { sidebarState };
},
head: () => ({
meta: [createNoindexFollowMeta()],
}),
});
function RouteComponent() {
const { sidebarState } = Route.useLoaderData();
return (
<SidebarProvider defaultOpen={sidebarState}>
<a
href="#main-content"
className="sr-only rounded-md bg-popover px-4 py-2 text-sm ring-2 ring-ring focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
>
<Trans>Skip to main content</Trans>
</a>
<DashboardSidebar />
<main id="main-content" className="@container flex-1 p-4 md:ps-2">
<Outlet />
</main>
</SidebarProvider>
<AppShell>
<Outlet />
</AppShell>
);
}
@@ -0,0 +1,34 @@
import { t } from "@lingui/core/macro";
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
// Settings leave the app sidebar in 6.0; until they're regrouped (M11) the pages sit behind this tab strip.
const PAGES = [
{ to: "/dashboard/settings/profile", label: () => t`Profile` },
{ to: "/dashboard/settings/preferences", label: () => t`Preferences` },
{ to: "/dashboard/settings/authentication", label: () => t`Authentication` },
{ to: "/dashboard/settings/api-keys", label: () => t`API Keys` },
{ to: "/dashboard/settings/integrations", label: () => t`Integrations` },
{ to: "/dashboard/settings/account", label: () => t`Account` },
] as const;
export const Route = createFileRoute("/dashboard/settings")({ component: RouteComponent });
function RouteComponent() {
return (
<div className="mx-auto grid w-full max-w-[1180px] content-start gap-5 px-8 py-8 max-sm:px-4 max-sm:py-5">
<nav aria-label={t`Settings`} className="flex gap-5 overflow-x-auto border-line border-b">
{PAGES.map((page) => (
<Link
key={page.to}
to={page.to}
className="flex h-10 shrink-0 items-center font-medium text-ink-2 text-sm hover:text-ink"
activeProps={{ className: "text-ink shadow-[inset_0_-2px_0_var(--ink)]", "aria-current": "page" }}
>
{page.label()}
</Link>
))}
</nav>
<Outlet />
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { TrashPage } from "@/features/documents/trash-page";
export const Route = createFileRoute("/dashboard/trash")({ component: TrashPage });
@@ -85,6 +85,7 @@
"school",
"search",
"search_off",
"sell",
"settings",
"short_text",
"sticky_note_2",
Binary file not shown.
+1
View File
@@ -90,6 +90,7 @@ export const iconNames = [
"school",
"search",
"search_off",
"sell",
"settings",
"short_text",
"sticky_note_2",