From daa7d331f3d4c775090815c25c58b766b6d753a8 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 28 Sep 2026 20:38:54 +0200 Subject: [PATCH] feat(web): documents home, trash and a new-document dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard becomes Documents: resumes and saved letters in one library, in a new app shell (a 240px sidebar, an icon rail on tablets and bottom tabs on phones) with Documents, Applications, Trash (when it has items), New (N) and the account row. - Documents: All / Resumes / Letters with counts, search (/) across titles, tags and linked applications, sort, grid or list (remembered on the device), tag chips once tags exist, cards with the real first page, "Resume · Edited 2h ago", the linked application, lock and "New" badges, and the first-run screen. A file dropped anywhere on the page imports. - One menu on cards, rows and right-click/long-press: Open, Rename (inline), Duplicate, Copy for a job… (resumes) or Link to application… (letters), Tags…, Lock editing, and Move to Trash with Undo. - Trash lists days left, with Restore and Delete now (asks once). - New: import a file in three labelled steps with the result and flagged fields (resumes, and saved letters' JSON), copy a resume for a job, start blank (named after its headline until renamed), a new letter, or a sample resume. Also: - /dashboard/resumes and /dashboard/cover-letters redirect to Documents. - Settings pages sit behind a tab strip until they're regrouped, and the account menu gains Settings. - A letter written inside a resume can be copied to Documents from its entry menu (Q3k), replacing the library's copy action. - The builder's document menu and the letter editor move documents to Trash instead of deleting them. - Dialogs reopened right after closing start fresh. - Removed: the resume and letter libraries and the old create and import dialogs. --- apps/web/src/dialogs/document/registry.tsx | 6 + apps/web/src/dialogs/manager.tsx | 5 +- apps/web/src/dialogs/renderers.tsx | 3 +- .../src/dialogs/resume/import.dialog.test.tsx | 262 -------- apps/web/src/dialogs/resume/import.test.ts | 40 -- apps/web/src/dialogs/resume/import.tsx | 507 --------------- apps/web/src/dialogs/resume/import.utils.ts | 27 - apps/web/src/dialogs/resume/index.tsx | 131 +--- .../web/src/dialogs/resume/parse-json.test.ts | 8 - apps/web/src/dialogs/resume/parse-json.ts | 12 - apps/web/src/dialogs/resume/registry.tsx | 5 +- apps/web/src/dialogs/schemas.ts | 14 +- apps/web/src/dialogs/store.test.ts | 6 +- apps/web/src/dialogs/store.ts | 8 +- .../command-palette/pages/navigation.tsx | 43 +- .../command-palette/pages/resumes.tsx | 5 +- .../features/cover-letters/editor-dialog.tsx | 43 +- .../src/features/cover-letters/library.tsx | 312 --------- .../features/documents/document-actions.tsx | 318 ++++++++++ .../src/features/documents/document-card.tsx | 299 +++++++++ .../src/features/documents/documents-page.tsx | 378 +++++++++++ .../web/src/features/documents/filter.test.ts | 83 +++ apps/web/src/features/documents/filter.ts | 38 ++ .../documents/new-document-dialog.tsx | 600 ++++++++++++++++++ .../src/features/documents/new-documents.ts | 35 + .../documents}/resume-thumbnail.test.tsx | 11 +- .../documents}/resume-thumbnail.tsx | 30 +- .../web/src/features/documents/trash-page.tsx | 81 +++ .../resume/editor/write/entry-card.tsx | 19 + .../resume/editor/write/write-panel.test.tsx | 1 + .../resume/editor/write/write-panel.tsx | 2 +- .../features/resume/import/read-file.test.ts | 90 +++ .../src/features/resume/import/read-file.ts | 173 +++++ apps/web/src/features/user/dropdown-menu.tsx | 6 + apps/web/src/libs/orpc/client.ts | 4 +- apps/web/src/routeTree.gen.ts | 152 +++-- .../agent/-components/thread-sidebar.tsx | 2 +- .../$resumeId/-components/document-menu.tsx | 39 +- .../$resumeId/-components/editor-bar.tsx | 3 +- .../dashboard/-components/app-shell.tsx | 337 ++++++++++ .../dashboard/-components/header.test.tsx | 14 +- .../routes/dashboard/-components/header.tsx | 4 +- .../routes/dashboard/-components/sidebar.tsx | 249 -------- .../routes/dashboard/applications/index.tsx | 2 +- .../src/routes/dashboard/cover-letters.tsx | 24 +- apps/web/src/routes/dashboard/index.tsx | 35 +- .../resumes/-components/cards/base-card.tsx | 40 -- .../-components/cards/create-card.test.tsx | 54 -- .../resumes/-components/cards/create-card.tsx | 20 - .../resumes/-components/cards/import-card.tsx | 20 - .../resumes/-components/cards/resume-card.tsx | 60 -- .../resumes/-components/grid-view.tsx | 71 --- .../resumes/-components/list-view.tsx | 138 ---- .../-components/menus/context-menu.tsx | 73 --- .../-components/menus/dropdown-menu.tsx | 71 --- .../menus/use-resume-menu-actions.ts | 54 -- .../resumes/-components/view-mode.test.tsx | 51 -- .../resumes/-components/view-mode.ts | 18 - .../src/routes/dashboard/resumes/index.tsx | 214 +------ apps/web/src/routes/dashboard/route.tsx | 28 +- .../src/routes/dashboard/settings/route.tsx | 34 + apps/web/src/routes/dashboard/trash.tsx | 4 + .../src/icons/material-symbols-rounded.json | 1 + .../src/icons/material-symbols-rounded.woff2 | Bin 37892 -> 38256 bytes packages/ui/src/icons/names.ts | 1 + 65 files changed, 2794 insertions(+), 2624 deletions(-) create mode 100644 apps/web/src/dialogs/document/registry.tsx delete mode 100644 apps/web/src/dialogs/resume/import.dialog.test.tsx delete mode 100644 apps/web/src/dialogs/resume/import.test.ts delete mode 100644 apps/web/src/dialogs/resume/import.tsx delete mode 100644 apps/web/src/dialogs/resume/import.utils.ts delete mode 100644 apps/web/src/dialogs/resume/parse-json.test.ts delete mode 100644 apps/web/src/dialogs/resume/parse-json.ts delete mode 100644 apps/web/src/features/cover-letters/library.tsx create mode 100644 apps/web/src/features/documents/document-actions.tsx create mode 100644 apps/web/src/features/documents/document-card.tsx create mode 100644 apps/web/src/features/documents/documents-page.tsx create mode 100644 apps/web/src/features/documents/filter.test.ts create mode 100644 apps/web/src/features/documents/filter.ts create mode 100644 apps/web/src/features/documents/new-document-dialog.tsx create mode 100644 apps/web/src/features/documents/new-documents.ts rename apps/web/src/{routes/dashboard/resumes/-components/cards => features/documents}/resume-thumbnail.test.tsx (97%) rename apps/web/src/{routes/dashboard/resumes/-components/cards => features/documents}/resume-thumbnail.tsx (88%) create mode 100644 apps/web/src/features/documents/trash-page.tsx create mode 100644 apps/web/src/features/resume/import/read-file.test.ts create mode 100644 apps/web/src/features/resume/import/read-file.ts create mode 100644 apps/web/src/routes/dashboard/-components/app-shell.tsx delete mode 100644 apps/web/src/routes/dashboard/-components/sidebar.tsx delete mode 100644 apps/web/src/routes/dashboard/resumes/-components/cards/base-card.tsx delete mode 100644 apps/web/src/routes/dashboard/resumes/-components/cards/create-card.test.tsx delete mode 100644 apps/web/src/routes/dashboard/resumes/-components/cards/create-card.tsx delete mode 100644 apps/web/src/routes/dashboard/resumes/-components/cards/import-card.tsx delete mode 100644 apps/web/src/routes/dashboard/resumes/-components/cards/resume-card.tsx delete mode 100644 apps/web/src/routes/dashboard/resumes/-components/grid-view.tsx delete mode 100644 apps/web/src/routes/dashboard/resumes/-components/list-view.tsx delete mode 100644 apps/web/src/routes/dashboard/resumes/-components/menus/context-menu.tsx delete mode 100644 apps/web/src/routes/dashboard/resumes/-components/menus/dropdown-menu.tsx delete mode 100644 apps/web/src/routes/dashboard/resumes/-components/menus/use-resume-menu-actions.ts delete mode 100644 apps/web/src/routes/dashboard/resumes/-components/view-mode.test.tsx delete mode 100644 apps/web/src/routes/dashboard/resumes/-components/view-mode.ts create mode 100644 apps/web/src/routes/dashboard/settings/route.tsx create mode 100644 apps/web/src/routes/dashboard/trash.tsx 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 07edaf7ef299d1c056ebddd858f955bbf9351f6d..59b5a36997dfb29f2b1f6bb35903425dc0436e49 100644 GIT binary patch literal 38256 zcmV)OK(@bkPew8T0RR910F`h65C8xG0attg0F@2^0RR9100000000000000000000 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 E01NTs3jhEB literal 37892 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 E0JUFZx&QzG 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",