-
- Importing from Word requires a connected AI provider.
-
-
- )}
-
- {pdfWithoutAi && (
-
-
- 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.
-
-
- )}
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/src/dialogs/resume/import.utils.ts b/apps/web/src/dialogs/resume/import.utils.ts
deleted file mode 100644
index 99aaa4bc4..000000000
--- a/apps/web/src/dialogs/resume/import.utils.ts
+++ /dev/null
@@ -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;
-
- // 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 | 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 "";
-}
diff --git a/apps/web/src/dialogs/resume/index.tsx b/apps/web/src/dialogs/resume/index.tsx
index 283d412c8..9ee014d19 100644
--- a/apps/web/src/dialogs/resume/index.tsx
+++ b/apps/web/src/dialogs/resume/index.tsx
@@ -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 (
-
-
-
-
- Create a new resume
-
-
- Start building your resume by giving it a name.
-
-
-
-
-
- );
-}
-
export function UpdateResumeDialog({ data }: DialogProps<"resume.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const patchResume = usePatchResume();
diff --git a/apps/web/src/dialogs/resume/parse-json.test.ts b/apps/web/src/dialogs/resume/parse-json.test.ts
deleted file mode 100644
index c28933ead..000000000
--- a/apps/web/src/dialogs/resume/parse-json.test.ts
+++ /dev/null
@@ -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);
- });
-});
diff --git a/apps/web/src/dialogs/resume/parse-json.ts b/apps/web/src/dialogs/resume/parse-json.ts
deleted file mode 100644
index ec03b429f..000000000
--- a/apps/web/src/dialogs/resume/parse-json.ts
+++ /dev/null
@@ -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);
-}
diff --git a/apps/web/src/dialogs/resume/registry.tsx b/apps/web/src/dialogs/resume/registry.tsx
index 2ebdbdbaa..82e111da1 100644
--- a/apps/web/src/dialogs/resume/registry.tsx
+++ b/apps/web/src/dialogs/resume/registry.tsx
@@ -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: () => },
{ type: "resume.update", render: ({ data }) => },
{ type: "resume.duplicate", render: ({ data }) => },
- { type: "resume.import", render: () => },
];
diff --git a/apps/web/src/dialogs/schemas.ts b/apps/web/src/dialogs/schemas.ts
index e5009aff0..2158b7b6f 100644
--- a/apps/web/src/dialogs/schemas.ts
+++ b/apps/web/src/dialogs/schemas.ts
@@ -1,16 +1,14 @@
import type { ReactNode } from "react";
+import type { NewDocumentDialogData } from "@/features/documents/new-document-dialog";
type EmptyDialog = { [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[] };
diff --git a/apps/web/src/dialogs/store.test.ts b/apps/web/src/dialogs/store.test.ts
index bb63d6b83..1c6e787b3 100644
--- a/apps/web/src/dialogs/store.test.ts
+++ b/apps/web/src/dialogs/store.test.ts
@@ -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,
});
diff --git a/apps/web/src/dialogs/store.ts b/apps/web/src/dialogs/store.ts
index 618bc9f42..d5205a359 100644
--- a/apps/web/src/dialogs/store.ts
+++ b/apps/web/src/dialogs/store.ts
@@ -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) | null;
}
@@ -21,6 +23,7 @@ type DialogStore = DialogStoreState & DialogStoreActions;
export const useDialogStore = create((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((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 });
diff --git a/apps/web/src/features/command-palette/pages/navigation.tsx b/apps/web/src/features/command-palette/pages/navigation.tsx
index 84fb91fef..c23d705be 100644
--- a/apps/web/src/features/command-palette/pages/navigation.tsx
+++ b/apps/web/src/features/command-palette/pages/navigation.tsx
@@ -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() {
onNavigate("/dashboard/resumes")}
+ keywords={[t`Documents`, t`Resumes`, t`Cover letters`]}
+ value="navigation.documents"
+ onSelect={() => onNavigate("/dashboard")}
>
- Resumes
+ Documents
+
+
+ {
+ reset();
+ useDialogStore.getState().openDialog("document.new", undefined);
+ }}
+ >
+
+ New document
+
+
+ onNavigate("/dashboard/trash")}
+ >
+
+ Trash
+
+
+ onNavigate("/ats-checker")}
+ >
+
+ ATS Checker {
- await navigate({ to: "/dashboard/resumes" });
- openDialog("resume.create", undefined);
+ const onCreate = () => {
reset();
+ openDialog("document.new", undefined);
};
const onNavigate = async (path: string) => {
diff --git a/apps/web/src/features/cover-letters/editor-dialog.tsx b/apps/web/src/features/cover-letters/editor-dialog.tsx
index 4ba95361a..39de76a94 100644
--- a/apps/web/src/features/cover-letters/editor-dialog.tsx
+++ b/apps/web/src/features/cover-letters/editor-dialog.tsx
@@ -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) => {
if (running.current) return;
@@ -121,7 +127,7 @@ export function CoverLetterEditorDialog({
-