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