diff --git a/apps/web/src/dialogs/document/registry.tsx b/apps/web/src/dialogs/document/registry.tsx new file mode 100644 index 000000000..3c39e77e3 --- /dev/null +++ b/apps/web/src/dialogs/document/registry.tsx @@ -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 }) => }, +]; diff --git a/apps/web/src/dialogs/manager.tsx b/apps/web/src/dialogs/manager.tsx index 9576bdf68..6d9da3e81 100644 --- a/apps/web/src/dialogs/manager.tsx +++ b/apps/web/src/dialogs/manager.tsx @@ -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 ( - {DialogContent} + {DialogContent} ); } diff --git a/apps/web/src/dialogs/renderers.tsx b/apps/web/src/dialogs/renderers.tsx index 7caadec32..b7c859115 100644 --- a/apps/web/src/dialogs/renderers.tsx +++ b/apps/web/src/dialogs/renderers.tsx @@ -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, ), ); diff --git a/apps/web/src/dialogs/resume/import.dialog.test.tsx b/apps/web/src/dialogs/resume/import.dialog.test.tsx deleted file mode 100644 index e53a8af46..000000000 --- a/apps/web/src/dialogs/resume/import.dialog.test.tsx +++ /dev/null @@ -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 . 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 & { - to: string; - children: ReactNode; -}; - -vi.mock("@tanstack/react-router", () => ({ - useNavigate: () => navigate, - // Mirrors the part of 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) => ( - { - onClick?.(event); - - if (event.defaultPrevented) return; - if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - - routerNavigate({ to }); - }} - {...props} - > - {children} - - ), -})); - -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 ( - - - - ); -} - -const renderDialog = () => { - useDialogStore.setState({ open: true, activeDialog: null, onBeforeClose: null }); - - return render( - - - - - - - , - ); -}; - -// 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('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('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('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('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('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(); - }); -}); diff --git a/apps/web/src/dialogs/resume/import.test.ts b/apps/web/src/dialogs/resume/import.test.ts deleted file mode 100644 index d791297c1..000000000 --- a/apps/web/src/dialogs/resume/import.test.ts +++ /dev/null @@ -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(""); - }); -}); diff --git a/apps/web/src/dialogs/resume/import.tsx b/apps/web/src/dialogs/resume/import.tsx deleted file mode 100644 index 556b9588e..000000000 --- a/apps/web/src/dialogs/resume/import.tsx +++ /dev/null @@ -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 { - 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 { - 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(null); - const [isImporting, setIsImporting] = useState(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) => { - 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) => { - // 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 ( - - - - - Import an existing resume - - - - 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. - - - - -
{ - event.preventDefault(); - event.stopPropagation(); - void form.handleSubmit(); - }} - > - - {(field) => ( - 0}> - - File - - - - - - - - - )} - - - {file && ( - - {(field) => ( - 0}> - - Type - - 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: ( -
- {t({ - comment: "File format label in import source selector", - message: "Microsoft Word", - })}{" "} - {t`AI`} -
- ), - }, - ]} - /> - } - /> - {!field.state.value && ( -

- We couldn't detect the format automatically. Choose it above. -

- )} - -
- )} -
- )} - - {aiRequired && !isLoadingAiProviders && !hasUsableProvider && ( -
- - 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. - - - -
{ - event.preventDefault(); - event.stopPropagation(); - void form.handleSubmit(); - }} - > - - - - - - - - - - - } - /> - - - - - Create a Sample Resume - - - - - - -
- ); -} - 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({ - @@ -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({
diff --git a/apps/web/src/features/cover-letters/library.tsx b/apps/web/src/features/cover-letters/library.tsx deleted file mode 100644 index e8f81705f..000000000 --- a/apps/web/src/features/cover-letters/library.tsx +++ /dev/null @@ -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(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(initialResumeId ?? null); - const [template, setTemplate] = useState(null); - const [embeddedKey, setEmbeddedKey] = useState(null); - const [selectedId, setSelectedId] = useState(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) => { - 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 ( -
-
- { - setSearch(event.target.value); - setOffset(0); - }} - /> - - - { - 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); - }); - }} - /> -
- {creating && ( -
-
{ - 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); - }); - }} - > -
-
- - setName(event.target.value)} - /> -
-
- - -
- -
-
-
- -
- ({ value: resume.id, label: resume.name }))} - value={resumeId} - onValueChange={(id) => { - setResumeId(id); - setEmbeddedKey(null); - }} - showClear - placeholder={t`Choose a resume`} - /> -
- - -
-

- The original letter stays in your resume. Each copy can be edited independently. -

- {!sourceReady && ( -

- Save resume changes before copying its content or styling. -

- )} - {source.error && ( -

{getReadableErrorMessage(source.error, t`Could not load the selected resume.`)}

- )} -
-
-
- )} - {query.isPending ? ( - - ) : query.error ? ( -
-

{getReadableErrorMessage(query.error, t`Could not load cover letters.`)}

- -
- ) : query.data.items.length === 0 ? ( -

- No cover letters found. Create one or import an existing letter. -

- ) : ( -
    - {query.data.items.map((letter) => ( -
  • - -
  • - ))} -
- )} - {(offset > 0 || (query.data?.total ?? 0) > 20) && ( -
- - - Page {Math.floor(offset / 20) + 1} - - -
- )} - {selectedId && ( - setSelectedId(null)} - /> - )} -
- ); -} diff --git a/apps/web/src/features/documents/document-actions.tsx b/apps/web/src/features/documents/document-actions.tsx new file mode 100644 index 000000000..cf9e15994 --- /dev/null +++ b/apps/web/src/features/documents/document-actions.tsx @@ -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 ( + + {entries.map((entry) => ( +
+ {entry.danger && } + + + {entry.label} + +
+ ))} +
+ ); +} + +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(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 ( + { + if (open) return; + setTags(null); + onClose(); + }} + > + + + + Tags + + + Tags group documents in the library; pick one above the grid to filter by it. + + + + + + + + + ); +} + +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 ( + !open && onClose()}> + + + + Link to application + + + The application this letter is for. + + +
+ {jobs.map((application) => ( + + ))} + {jobs.length === 0 && ( +

+ No applications yet. Add one in Applications first. +

+ )} +
+ {document?.application && ( + + + + )} +
+
+ ); +} diff --git a/apps/web/src/features/documents/document-card.tsx b/apps/web/src/features/documents/document-card.tsx new file mode 100644 index 000000000..3d34cec1d --- /dev/null +++ b/apps/web/src/features/documents/document-card.tsx @@ -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 & { + className?: string; + children: React.ReactNode; + label?: string; +}) { + const markOpened = useNewDocumentsStore((state) => state.markOpened); + + if (document.type === "resume") { + return ( + markOpened(document.id)} + > + {children} + + ); + } + + return ( + + ); +} + +/** 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 ( + 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 ( + + ); +} + +/** 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 ( + + } + > + + {document.type === "resume" ? ( + + ) : ( + + )} + + {isNew && ( + + New + + )} + {document.isLocked && ( + + + + Locked + + + )} + + + +
+
+ {renaming ? ( + setRenaming(false)} /> + ) : ( +

{document.name}

+ )} + {meta} + {document.application && ( + + + {document.application.company} + + )} +
+ + + } + /> + + +
+
+ +
+ ); +} + +/** 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 ( + + + } + > + + + + {renaming ? ( + setRenaming(false)} /> + ) : ( + + {document.name} + + )} + {isNew && ( + + New + + )} + {document.isLocked && } + + + + {document.type === "resume" ? Resume : Letter} + + {document.application?.company ?? "—"} + + {document.trashedAt ? ( + {daysLeftInTrash(document.trashedAt)} days left + ) : ( + formatRelativeTime(document.updatedAt, formatter) + )} + + + + + } + /> + + + + + + + ); +} + +/** 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 } }); + }; +} diff --git a/apps/web/src/features/documents/documents-page.tsx b/apps/web/src/features/documents/documents-page.tsx new file mode 100644 index 000000000..cf52783be --- /dev/null +++ b/apps/web/src/features/documents/documents-page.tsx @@ -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) => 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(null); + const [tagsFor, setTagsFor] = useState(null); + const [linkFor, setLinkFor] = useState(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 ( +
+

+ Documents +

+ + {!isPending && all.length === 0 ? ( + openDialog("document.new", undefined)} /> + ) : ( + <> +
+ onSearchChange({ type: type as DocumentTypeFilter })}> + + + All + {counts.all} + + + Resumes + {counts.resume} + + + Letters + {counts.letter} + + + + +
+
+ + onSearchChange({ q: event.target.value })} + className="h-full min-w-0 flex-1 bg-transparent text-sm outline-none" + /> + / +
+
+ onSearchChange({ sort: event.target.value as DocumentSort })} + > + + + + +
+ setView(next as "grid" | "list")} + > + + + + + + + +
+
+ + {tags.length > 0 && ( +
+ + Filter by tag + + {tags.map((tag) => { + const active = search.tags.includes(tag); + return ( + + ); + })} +
+ )} + + {isPending ? ( +
+ {Array.from({ length: 6 }, (_, index) => ( +
+
+
+
+ ))} +
+ ) : shown.length === 0 ? ( +
+ +

+ {search.q.trim() ? ( + Nothing matches “{search.q.trim()}” + ) : ( + Nothing matches these filters + )} +

+

+ Search covers titles, tags and linked applications. +

+ {filtered && ( + + )} +
+ ) : view === "list" ? ( + + + + + + + + + + + + {shown.map((document) => ( + + ))} + +
+ Name + + Type + + Application + + Edited + + + Options + +
+ ) : ( +
+ {shown.map((document) => ( + + ))} +
+ )} + + )} + + + setTagsFor(null)} /> + setLinkFor(null)} /> + {search.letter && ( + onSearchChange({ letter: undefined })} /> + )} +
+ ); +} + +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 ( +
+

+ Let's start with what you have +

+

+ + 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. + +

+
+ + + Drop a PDF, Word or JSON file + + +
+
+ + +
+
+ ); +} + +/** 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 ( +
+
+ +

+ Drop to import +

+

+ PDF, Word or JSON. We'll build a resume from it. +

+
+
+ ); +} diff --git a/apps/web/src/features/documents/filter.test.ts b/apps/web/src/features/documents/filter.test.ts new file mode 100644 index 000000000..206ecdbf9 --- /dev/null +++ b/apps/web/src/features/documents/filter.test.ts @@ -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 => ({ + 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); + }); +}); diff --git a/apps/web/src/features/documents/filter.ts b/apps/web/src/features/documents/filter.ts new file mode 100644 index 000000000..9360a7682 --- /dev/null +++ b/apps/web/src/features/documents/filter.ts @@ -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))); +} diff --git a/apps/web/src/features/documents/new-document-dialog.tsx b/apps/web/src/features/documents/new-document-dialog.tsx new file mode 100644 index 000000000..31dcef4ea --- /dev/null +++ b/apps/web/src/features/documents/new-document-dialog.tsx @@ -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({ name: data?.step ?? "choose" } as Step); + const run = useRef(0); + const inputRef = useRef(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 = ( + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) void importFile(file); + }} + /> + ); + + if (step.name === "copy") { + return ( + + setStep({ name: "choose" })} + onCreated={(resumeId) => { + markNew(resumeId); + void refreshDocuments(); + openResume(resumeId); + }} + /> + + ); + } + + if (step.name !== "choose") { + return ( + + + + {step.name === "failed" ? Couldn't import : Importing} + + + {fileInput} +
+ + + {step.file.name} + {formatSize(step.file.size)} + + {step.name === "importing" && ( + + )} +
+ + {step.name === "failed" ? ( + <> +
+ + {step.message} +
+
+ + +
+ + ) : step.name === "importing" ? ( + + ) : ( + <> + +

+ + + + {step.sections} sections and {step.entries} entries found. + {" "} + {step.flagged > 0 && {step.flagged} fields are flagged for a quick look in the editor.} + +

+
+ + +
+ + )} +
+ ); + } + + return ( + + + + New document + + + Import a resume, copy one for a job, or start blank. + + + {fileInput} + + + +
+ setStep({ name: "copy" })} + /> + void startBlank()} + /> +
+ +
+ + +
+
+ ); +} + +/** 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 ( + + ); +} + +/** 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 ( +
+
    + {steps.map((label, index) => { + const done = stage > index; + const current = stage === index; + return ( +
  1. + {done ? ( + + ) : current ? ( + + ) : ( + + )} + {label} + {done && notes[index] && {notes[index]}} +
  2. + ); + })} +
+
+
+
+
+ ); +} + +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(null); + const [name, setName] = useState(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 ( + <> + + + Copy a resume for a job + + + +
+ + Start from + +
+ {resumes.map((resume) => ( + + ))} +
+
+ +
+ + For which job? + +
+ {[ + ...jobs.map((application) => ({ id: application.id, label: application.company })), + { id: null, label: t`No job yet` }, + ].map((option) => ( + + ))} +
+ {job && ( + + The copy is linked to the application, so Check and the assistant use its posting. + + )} +
+ +
+ + setName(event.target.value)} + /> + + Suggested from the source and the job. Change it anytime. + +
+ +
+ {onBack && ( + + )} + +
+ + ); +} diff --git a/apps/web/src/features/documents/new-documents.ts b/apps/web/src/features/documents/new-documents.ts new file mode 100644 index 000000000..a142868e2 --- /dev/null +++ b/apps/web/src/features/documents/new-documents.ts @@ -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()( + 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 }; + } + }), + }, + ), +); diff --git a/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.test.tsx b/apps/web/src/features/documents/resume-thumbnail.test.tsx similarity index 97% rename from apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.test.tsx rename to apps/web/src/features/documents/resume-thumbnail.test.tsx index 863f8961d..db874b7c1 100644 --- a/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.test.tsx +++ b/apps/web/src/features/documents/resume-thumbnail.test.tsx @@ -34,13 +34,6 @@ vi.mock("@/libs/orpc/client", () => ({ const resume: ComponentProps["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 = () => ( - + ); const result = render(ui()); @@ -136,7 +129,7 @@ it("reuses rendered image after card hide and remount", async () => { const remounted = render( - + , ); await waitFor(() => diff --git a/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.tsx b/apps/web/src/features/documents/resume-thumbnail.tsx similarity index 88% rename from apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.tsx rename to apps/web/src/features/documents/resume-thumbnail.tsx index c798c0f92..a2687c77d 100644 --- a/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.tsx +++ b/apps/web/src/features/documents/resume-thumbnail.tsx @@ -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(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 ( -
+
{thumbnail.status === "ready" ? (
) : hasFailed ? ( -
- +
+
) : ( -
- -
+ // Loading: a sunken placeholder at the page's real size, so nothing jumps. +
)}
); diff --git a/apps/web/src/features/documents/trash-page.tsx b/apps/web/src/features/documents/trash-page.tsx new file mode 100644 index 000000000..a9699eed4 --- /dev/null +++ b/apps/web/src/features/documents/trash-page.tsx @@ -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 ( +
+ + + Back to documents + +
+

+ Trash +

+

+ Items are deleted permanently after 30 days. +

+
+ + {!isPending && documents?.length === 0 ? ( +
+ +

+ Trash is empty +

+

+ Items you move here stay for 30 days. +

+
+ ) : ( + + + + + + + + + + + + {documents?.map((document) => ( + + ))} + +
+ Name + + Type + + Application + + Deleted in + + + Options + +
+ )} +
+ ); +} diff --git a/apps/web/src/features/resume/editor/write/entry-card.tsx b/apps/web/src/features/resume/editor/write/entry-card.tsx index 08d7ad320..db82c1409 100644 --- a/apps/web/src/features/resume/editor/write/entry-card.tsx +++ b/apps/web/src/features/resume/editor/write/entry-card.tsx @@ -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) { Duplicate + {section.type === "cover-letter" && section.kind === "custom" && ( + + + Copy to Documents + + )} diff --git a/apps/web/src/features/resume/editor/write/write-panel.test.tsx b/apps/web/src/features/resume/editor/write/write-panel.test.tsx index 3d1d00c60..757a0ff68 100644 --- a/apps/web/src/features/resume/editor/write/write-panel.test.tsx +++ b/apps/web/src/features/resume/editor/write/write-panel.test.tsx @@ -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() } } }, })); diff --git a/apps/web/src/features/resume/editor/write/write-panel.tsx b/apps/web/src/features/resume/editor/write/write-panel.tsx index 451a11abe..0387e6cb9 100644 --- a/apps/web/src/features/resume/editor/write/write-panel.tsx +++ b/apps/web/src/features/resume/editor/write/write-panel.tsx @@ -52,7 +52,7 @@ export function WritePanel() {

{isEmpty && !locked ? ( - openDialog("resume.import", undefined)} /> + openDialog("document.new", undefined)} /> ) : ( )} diff --git a/apps/web/src/features/resume/import/read-file.test.ts b/apps/web/src/features/resume/import/read-file.test.ts new file mode 100644 index 000000000..573ab87e6 --- /dev/null +++ b/apps/web/src/features/resume/import/read-file.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/features/resume/import/read-file.ts b/apps/web/src/features/resume/import/read-file.ts new file mode 100644 index 000000000..7e24aca95 --- /dev/null +++ b/apps/web/src/features/resume/import/read-file.ts @@ -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; + + // 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 | 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 { + 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 { + 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, + options: { aiAvailable: boolean; onRead?: (note: string) => void }, +): Promise { + 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, + }; +} diff --git a/apps/web/src/features/user/dropdown-menu.tsx b/apps/web/src/features/user/dropdown-menu.tsx index 4730faee9..f11eedffa 100644 --- a/apps/web/src/features/user/dropdown-menu.tsx +++ b/apps/web/src/features/user/dropdown-menu.tsx @@ -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) { + void router.navigate({ to: "/dashboard/settings/profile" })}> + + Settings + + diff --git a/apps/web/src/libs/orpc/client.ts b/apps/web/src/libs/orpc/client.ts index cfe76df00..6354346e4 100644 --- a/apps/web/src/libs/orpc/client.ts +++ b/apps/web/src/libs/orpc/client.ts @@ -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 = createORPCClient( export const orpc = createTanstackQueryUtils(client); -export type RouterInput = InferRouterInputs; - export type RouterOutput = InferRouterOutputs; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f55afeba2..08237b28b 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -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( diff --git a/apps/web/src/routes/agent/-components/thread-sidebar.tsx b/apps/web/src/routes/agent/-components/thread-sidebar.tsx index dd79936f1..cf9f33a6d 100644 --- a/apps/web/src/routes/agent/-components/thread-sidebar.tsx +++ b/apps/web/src/routes/agent/-components/thread-sidebar.tsx @@ -171,7 +171,7 @@ export function AgentThreadSidebar({ activeThreadId = null, className }: AgentTh Threads
- diff --git a/apps/web/src/routes/builder/$resumeId/-components/document-menu.tsx b/apps/web/src/routes/builder/$resumeId/-components/document-menu.tsx index 35cfb1f40..05403f1dc 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/document-menu.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/document-menu.tsx @@ -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) { Print - + - Delete… + Move to Trash ); diff --git a/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx b/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx index 79850da49..6b09df435 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx @@ -69,8 +69,7 @@ function BackLink() { diff --git a/apps/web/src/routes/dashboard/-components/app-shell.tsx b/apps/web/src/routes/dashboard/-components/app-shell.tsx new file mode 100644 index 000000000..26db555e8 --- /dev/null +++ b/apps/web/src/routes/dashboard/-components/app-shell.tsx @@ -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 ( +
+
+ {children} +
+ +
+ ); + } + + return ( +
+ + Skip to main content + + {breakpoint === "tablet" ? : } +
+ {children} +
+
+ ); +} + +function Sidebar() { + const { items, trash } = useNavItems(); + const isCurrent = useIsCurrent(); + const openPalette = useCommandPaletteStore((state) => state.setOpen); + const openDialog = useDialogStore((state) => state.openDialog); + + return ( + + ); +} + +function NavLink({ item, current }: { item: NavItem; current: boolean }) { + return ( + + + {item.label} + {item.count !== undefined && {item.count}} + + ); +} + +/** 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 ( +
+ + ); +} + +/** 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 ( + + + + + {label} + + ); +} + +/** 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) => ( + <> + + {label} + + ); + + return ( + + ); +} diff --git a/apps/web/src/routes/dashboard/-components/header.test.tsx b/apps/web/src/routes/dashboard/-components/header.test.tsx index 6da45189b..34ca37e27 100644 --- a/apps/web/src/routes/dashboard/-components/header.test.tsx +++ b/apps/web/src/routes/dashboard/-components/header.test.tsx @@ -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> = {}) => - render( - - - , - ); + render(); 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"); - }); }); diff --git a/apps/web/src/routes/dashboard/-components/header.tsx b/apps/web/src/routes/dashboard/-components/header.tsx index 5f0cbce65..bf62ecd55 100644 --- a/apps/web/src/routes/dashboard/-components/header.tsx +++ b/apps/web/src/routes/dashboard/-components/header.tsx @@ -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 (
- -
+

{title}

diff --git a/apps/web/src/routes/dashboard/-components/sidebar.tsx b/apps/web/src/routes/dashboard/-components/sidebar.tsx deleted file mode 100644 index 05676fe7b..000000000 --- a/apps/web/src/routes/dashboard/-components/sidebar.tsx +++ /dev/null @@ -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["to"]; -}; - -const appSidebarItems = [ - { - icon: , - label: msg`Resumes`, - href: "/dashboard/resumes", - }, - { - icon: , - label: msg`Applications`, - href: "/dashboard/applications", - }, - { - icon: , - label: msg`Cover Letters`, - href: "/dashboard/cover-letters", - }, - { - icon: , - label: msg`Agents`, - href: "/agent", - }, - { - icon: , - label: msg`ATS Checker`, - href: "/ats-checker", - }, -] as const satisfies SidebarItem[]; - -const settingsSidebarItems = [ - { - icon: , - label: msg`Profile`, - href: "/dashboard/settings/profile", - }, - { - icon: , - label: msg`Preferences`, - href: "/dashboard/settings/preferences", - }, - { - icon: , - label: msg`Authentication`, - href: "/dashboard/settings/authentication", - }, - { - icon: , - label: msg`API Keys`, - href: "/dashboard/settings/api-keys", - }, - { - icon: , - label: msg`Integrations`, - href: "/dashboard/settings/integrations", - }, - { - icon: , - label: msg`Account`, - href: "/dashboard/settings/account", - }, -] as const satisfies SidebarItem[]; - -type SidebarItemListProps = { - items: readonly SidebarItem[]; -}; - -function SidebarItemList({ items }: SidebarItemListProps) { - const { i18n } = useLingui(); - - return ( - - {items.map((item) => ( - - - {item.icon} - - {i18n.t(item.label)} - - - } - /> - - ))} - - ); -} - -function SidebarSearchButton() { - const { i18n } = useLingui(); - const setOpen = useCommandPaletteStore((state) => state.setOpen); - - const label = i18n.t(msg`Search`); - - return ( - - setOpen(true)}> - - - {label} - - - ⌘K - - - - ); -} - -export function DashboardSidebar() { - const { i18n } = useLingui(); - const { state } = useSidebarState(); - - return ( - - - - - - -

Reactive Resume

- - } - /> -
- - -
-
- - - - - - - App - - - - - - - - - Settings - - - - - - - - - - - - - - {({ session }) => ( - - - - - {getInitials(session.user.name)} - - - -
-

{session.user.name}

-

{session.user.email}

-
-
- )} -
-
-
- - - {state === "expanded" && ( - - - - )} - -
- - -
- ); -} diff --git a/apps/web/src/routes/dashboard/applications/index.tsx b/apps/web/src/routes/dashboard/applications/index.tsx index 8214d5731..64e3f350a 100644 --- a/apps/web/src/routes/dashboard/applications/index.tsx +++ b/apps/web/src/routes/dashboard/applications/index.tsx @@ -124,7 +124,7 @@ function RouteComponent() { const setUrlSearch = (patch: Partial) => void navigate({ search: (prev: Search) => ({ ...prev, ...patch }) }); return ( -
+
- - - -
- ); -} +// 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 }); + }, +}); diff --git a/apps/web/src/routes/dashboard/index.tsx b/apps/web/src/routes/dashboard/index.tsx index 0db3c81da..99af51e7a 100644 --- a/apps/web/src/routes/dashboard/index.tsx +++ b/apps/web/src/routes/dashboard/index.tsx @@ -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 ( + + void navigate({ search: (previous: DocumentsSearch) => ({ ...previous, ...patch }), replace: true }) + } + /> + ); +} diff --git a/apps/web/src/routes/dashboard/resumes/-components/cards/base-card.tsx b/apps/web/src/routes/dashboard/resumes/-components/cards/base-card.tsx deleted file mode 100644 index 9100b8d7a..000000000 --- a/apps/web/src/routes/dashboard/resumes/-components/cards/base-card.tsx +++ /dev/null @@ -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 ( - -
- {children} - -
-

{title}

-

{description}

- - -
-
-
- ); -} diff --git a/apps/web/src/routes/dashboard/resumes/-components/cards/create-card.test.tsx b/apps/web/src/routes/dashboard/resumes/-components/cards/create-card.test.tsx deleted file mode 100644 index 623b6bbba..000000000 --- a/apps/web/src/routes/dashboard/resumes/-components/cards/create-card.test.tsx +++ /dev/null @@ -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(); - 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(); - - 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(); - 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(); - - 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"); - }); -}); diff --git a/apps/web/src/routes/dashboard/resumes/-components/cards/create-card.tsx b/apps/web/src/routes/dashboard/resumes/-components/cards/create-card.tsx deleted file mode 100644 index 48e21a50e..000000000 --- a/apps/web/src/routes/dashboard/resumes/-components/cards/create-card.tsx +++ /dev/null @@ -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 ( - openDialog("resume.create", undefined)} - > -
- -
-
- ); -} diff --git a/apps/web/src/routes/dashboard/resumes/-components/cards/import-card.tsx b/apps/web/src/routes/dashboard/resumes/-components/cards/import-card.tsx deleted file mode 100644 index 729113de8..000000000 --- a/apps/web/src/routes/dashboard/resumes/-components/cards/import-card.tsx +++ /dev/null @@ -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 ( - openDialog("resume.import", undefined)} - > -
- -
-
- ); -} diff --git a/apps/web/src/routes/dashboard/resumes/-components/cards/resume-card.tsx b/apps/web/src/routes/dashboard/resumes/-components/cards/resume-card.tsx deleted file mode 100644 index 978e3e792..000000000 --- a/apps/web/src/routes/dashboard/resumes/-components/cards/resume-card.tsx +++ /dev/null @@ -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 ( - - - - - - - - - - ); -} - -function ResumeLockOverlay({ isLocked }: ResumeLockOverlayProps) { - return ( - - {isLocked && ( - -
- -
-
- )} -
- ); -} diff --git a/apps/web/src/routes/dashboard/resumes/-components/grid-view.tsx b/apps/web/src/routes/dashboard/resumes/-components/grid-view.tsx deleted file mode 100644 index 03c786756..000000000 --- a/apps/web/src/routes/dashboard/resumes/-components/grid-view.tsx +++ /dev/null @@ -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 ( -
-
-
-
- ); - } - - if (resumes.length === 0 && hasResumes) { - return ( -

- No resumes match your search. -

- ); - } - - if (resumes.length === 0) { - return ( -
- - -
- ); - } - - return ( -
- - {resumes.map((resume) => ( - - - - ))} - -
- ); -} diff --git a/apps/web/src/routes/dashboard/resumes/-components/list-view.tsx b/apps/web/src/routes/dashboard/resumes/-components/list-view.tsx deleted file mode 100644 index 9b6fbaf75..000000000 --- a/apps/web/src/routes/dashboard/resumes/-components/list-view.tsx +++ /dev/null @@ -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 ( -

- No resumes match your search. -

- ); - } - - if (resumes.length === 0) { - const handleCreateResume = () => { - openDialog("resume.create", undefined); - }; - - const handleImportResume = () => { - openDialog("resume.import", undefined); - }; - - return ( -
- - - -
- ); - } - - return ( -
- - {resumes.map((resume) => ( - - - - ))} - -
- ); -} - -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 ( -
- - -
- ); -} diff --git a/apps/web/src/routes/dashboard/resumes/-components/menus/context-menu.tsx b/apps/web/src/routes/dashboard/resumes/-components/menus/context-menu.tsx deleted file mode 100644 index f3dec853e..000000000 --- a/apps/web/src/routes/dashboard/resumes/-components/menus/context-menu.tsx +++ /dev/null @@ -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["render"]; -}; - -export function ResumeContextMenu({ resume, children }: Props) { - const { handleDelete, handleDuplicate, handleToggleLock, handleUpdate } = useResumeMenuActions(resume); - - return ( - - - - - - - Open - - } - /> - - - - - - Edit details - - - - - Duplicate - - - - {resume.isLocked ? : } - {resume.isLocked ? ( - Unlock - ) : ( - Lock - )} - - - - - - - Delete - - - - ); -} diff --git a/apps/web/src/routes/dashboard/resumes/-components/menus/dropdown-menu.tsx b/apps/web/src/routes/dashboard/resumes/-components/menus/dropdown-menu.tsx deleted file mode 100644 index 3137e8d52..000000000 --- a/apps/web/src/routes/dashboard/resumes/-components/menus/dropdown-menu.tsx +++ /dev/null @@ -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, "children"> & { - resume: RouterOutput["resume"]["list"][number]; - children: React.ComponentProps["render"]; -}; - -export function ResumeDropdownMenu({ resume, children, ...props }: Props) { - const { handleDelete, handleDuplicate, handleToggleLock, handleUpdate } = useResumeMenuActions(resume); - - return ( - - - - - - - - Open - - - - - - - - Edit details - - - - - Duplicate - - - - {resume.isLocked ? : } - {resume.isLocked ? ( - Unlock - ) : ( - Lock - )} - - - - - - - Delete - - - - ); -} diff --git a/apps/web/src/routes/dashboard/resumes/-components/menus/use-resume-menu-actions.ts b/apps/web/src/routes/dashboard/resumes/-components/menus/use-resume-menu-actions.ts deleted file mode 100644 index cd9dd078a..000000000 --- a/apps/web/src/routes/dashboard/resumes/-components/menus/use-resume-menu-actions.ts +++ /dev/null @@ -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), - }; -} diff --git a/apps/web/src/routes/dashboard/resumes/-components/view-mode.test.tsx b/apps/web/src/routes/dashboard/resumes/-components/view-mode.test.tsx deleted file mode 100644 index 960d16e93..000000000 --- a/apps/web/src/routes/dashboard/resumes/-components/view-mode.test.tsx +++ /dev/null @@ -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"); - }); -}); diff --git a/apps/web/src/routes/dashboard/resumes/-components/view-mode.ts b/apps/web/src/routes/dashboard/resumes/-components/view-mode.ts deleted file mode 100644 index e9c4f0cbc..000000000 --- a/apps/web/src/routes/dashboard/resumes/-components/view-mode.ts +++ /dev/null @@ -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; - -export function useResumeView(view: ResumeView | undefined, userId: string): ResumeView { - const [storedView, setStoredView] = useSessionStorage(`resume-view:${userId}`, "grid", { - initializeWithValue: false, - }); - - useEffect(() => { - if (view !== undefined && storedView !== view) setStoredView(view); - }, [view, storedView, setStoredView]); - - return view ?? resumeViewSchema.catch("grid").parse(storedView); -} diff --git a/apps/web/src/routes/dashboard/resumes/index.tsx b/apps/web/src/routes/dashboard/resumes/index.tsx index d6da83b86..8dab86bb4 100644 --- a/apps/web/src/routes/dashboard/resumes/index.tsx +++ b/apps/web/src/routes/dashboard/resumes/index.tsx @@ -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; - -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 ( -
- 0 ? ( - <> - - - - ) : undefined - } - /> - - - -
-
- - { - if (!value) return; - void navigate({ search: (prev: Search) => ({ ...prev, sort: value as SortOption }) }); - }} - /> -
- -
- - { - void navigate({ search: (prev: Search) => ({ ...prev, tags: value ?? [] }) }); - }} - /> -
- - {(resumes?.length ?? 0) > 5 && ( - - - - - setSearchQuery(event.target.value)} - /> - - )} - - - - ({ ...prev, view: "grid" })} />} - > - - Grid - - - ({ ...prev, view: "compact" })} />} - > - - Compact - - - ({ ...prev, view: "list" })} />} - > - - List - - - -
- - {view === "list" ? ( - 0} /> - ) : ( - 0} compact={view === "compact"} /> - )} -
- ); -} diff --git a/apps/web/src/routes/dashboard/route.tsx b/apps/web/src/routes/dashboard/route.tsx index 505c33bd5..db7580203 100644 --- a/apps/web/src/routes/dashboard/route.tsx +++ b/apps/web/src/routes/dashboard/route.tsx @@ -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 ( - - - Skip to main content - - - - -
- -
-
+ + + ); } diff --git a/apps/web/src/routes/dashboard/settings/route.tsx b/apps/web/src/routes/dashboard/settings/route.tsx new file mode 100644 index 000000000..1c2a4ab2b --- /dev/null +++ b/apps/web/src/routes/dashboard/settings/route.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/apps/web/src/routes/dashboard/trash.tsx b/apps/web/src/routes/dashboard/trash.tsx new file mode 100644 index 000000000..22cf097bb --- /dev/null +++ b/apps/web/src/routes/dashboard/trash.tsx @@ -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 }); diff --git a/packages/ui/src/icons/material-symbols-rounded.json b/packages/ui/src/icons/material-symbols-rounded.json index 4ee249cc4..396993417 100644 --- a/packages/ui/src/icons/material-symbols-rounded.json +++ b/packages/ui/src/icons/material-symbols-rounded.json @@ -85,6 +85,7 @@ "school", "search", "search_off", + "sell", "settings", "short_text", "sticky_note_2", diff --git a/packages/ui/src/icons/material-symbols-rounded.woff2 b/packages/ui/src/icons/material-symbols-rounded.woff2 index 07edaf7ef..59b5a3699 100644 Binary files a/packages/ui/src/icons/material-symbols-rounded.woff2 and b/packages/ui/src/icons/material-symbols-rounded.woff2 differ diff --git a/packages/ui/src/icons/names.ts b/packages/ui/src/icons/names.ts index 3fa7b88b5..aa63afff8 100644 --- a/packages/ui/src/icons/names.ts +++ b/packages/ui/src/icons/names.ts @@ -90,6 +90,7 @@ export const iconNames = [ "school", "search", "search_off", + "sell", "settings", "short_text", "sticky_note_2",