v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205)

This commit is contained in:
Amruth Pillai
2026-07-04 14:57:25 +02:00
committed by GitHub
parent 09bc6ec521
commit 57e9c8c487
181 changed files with 46794 additions and 11348 deletions
+7
View File
@@ -19,6 +19,13 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Check for merge conflict markers
run: |
if git grep -nEI '<{7} |>{7} |^={7}$' -- ':(exclude)*.md' ':(exclude)*.mdx'; then
echo "::error::Merge conflict markers found in tracked files"
exit 1
fi
- name: Install pnpm
uses: pnpm/action-setup@v6
+39
View File
@@ -19,7 +19,46 @@
<title>Reactive Resume</title>
</head>
<body>
<!-- Keep #app empty: main.tsx only mounts React when rootElement has no children. -->
<div id="app"></div>
<!-- Branded first paint; hidden once React populates #app (higher-specificity rule below). -->
<div id="initial-loader">
<svg width="48" height="48" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="Reactive Resume">
<path fill-rule="evenodd" clip-rule="evenodd" d="M173.611 166.311L132.877 219.804H173.524L193.973 191.813L213.183 219.804H256L215.673 165.707L215.15 165.046L207.461 155.332L195.329 140.004L195.258 139.915L193.813 138.089L193.923 138.001L176.286 112.861H134.061L173.611 166.311ZM199.89 133.554L214.959 112.861H254.619L219.874 158.8L199.89 133.554Z" fill="#FAFAFA"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 36.1959V174.314H39.0678V137.614H60.3938L60.4323 137.671C60.8436 137.653 61.2517 137.634 61.6567 137.614C75.0665 136.968 85.1471 135.549 96.385 131.385C96.7596 131.246 97.1355 131.104 97.5128 130.959L97.4591 130.881C105.816 126.86 112.331 121.344 117.006 114.331C122.005 106.702 124.504 97.6915 124.504 87.2997C124.504 76.7764 122.005 67.7 117.006 60.0706C112.007 52.3097 104.904 46.3903 95.6964 42.3125C86.62 38.2347 75.7679 36.1959 63.1399 36.1959H0ZM102.156 137.725L64.8705 144.175L85.4361 174.314H127.266L102.156 137.725ZM39.0678 107.426H60.7721C68.9277 107.426 74.9786 105.65 78.9248 102.098C83.0026 98.5465 85.0415 93.6137 85.0415 87.2997C85.0415 80.8542 83.0026 75.8556 78.9248 72.304C74.9786 68.7523 68.9277 66.9765 60.7721 66.9765H39.0678V107.426Z" fill="#FAFAFA"/>
</svg>
<div class="initial-loader__spinner"></div>
<span class="initial-loader__sr-only">Loading</span>
</div>
<style>
@keyframes app-spin { to { transform: rotate(360deg) } }
#initial-loader {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
background: #09090b;
}
#app:not(:empty) ~ #initial-loader { display: none; }
.initial-loader__spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(250, 250, 250, 0.2);
border-top-color: #fafafa;
border-radius: 9999px;
animation: app-spin 0.7s linear infinite;
}
.initial-loader__sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
</style>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+479 -170
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+479 -170
View File
File diff suppressed because it is too large Load Diff
+479 -169
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+479 -170
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+479 -170
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+479 -170
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+479 -170
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+480 -171
View File
File diff suppressed because it is too large Load Diff
+472 -162
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -49,7 +49,6 @@
"@tanstack/react-router": "^1.170.17",
"@tiptap/extension-color": "^3.27.1",
"@tiptap/extension-highlight": "^3.27.1",
"@tiptap/extension-table": "^3.27.1",
"@tiptap/extension-text-align": "^3.27.1",
"@tiptap/extension-text-style": "^3.27.1",
"@tiptap/pm": "^3.27.1",
@@ -72,6 +71,7 @@
"qrcode.react": "^4.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-easy-crop": "^6.1.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.0",
"react-window": "^2.2.7",
@@ -99,8 +99,8 @@
"@vitejs/plugin-react": "^6.0.3",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-react-compiler": "^1.0.0",
"typescript": "^6.0.3",
"rimraf": "^6.1.3",
"typescript": "^6.0.3",
"vite": "^8.1.3"
}
}
@@ -60,7 +60,9 @@ function IconCellComponent({ columnIndex, rowIndex, style, icons, onChange }: Ic
title={icon}
style={style}
tabIndex={-1}
onClick={() => onChange(icon)}
onClick={() => {
if (icon) onChange(icon);
}}
className="flex size-full items-center justify-center hover:bg-accent"
>
{icon ? <i className={cn("ph text-base", `ph-${icon}`)} /> : <ProhibitIcon />}
@@ -100,7 +102,7 @@ export function IconPicker({ value, onChange, popoverProps, ...props }: IconPick
<Popover {...popoverProps}>
<PopoverTrigger
render={
<Button size="icon" variant="outline" {...props}>
<Button size="icon" variant="outline" aria-label={t`Pick an icon`} {...props}>
<i className={cn("ph size-4 text-base", `ph-${value}`)} />
</Button>
}
+25 -105
View File
@@ -5,10 +5,7 @@ import { Trans } from "@lingui/react/macro";
import {
ArrowsInSimpleIcon,
ArrowsOutSimpleIcon,
CodeBlockIcon,
CodeSimpleIcon,
ColumnsPlusLeftIcon,
ColumnsPlusRightIcon,
HighlighterCircleIcon,
KeyReturnIcon,
LinkBreakIcon,
@@ -17,10 +14,6 @@ import {
ListNumbersIcon,
MinusIcon,
ParagraphIcon,
PlusIcon,
RowsPlusBottomIcon,
RowsPlusTopIcon,
TableIcon,
TextAlignCenterIcon,
TextAlignJustifyIcon,
TextAlignLeftIcon,
@@ -37,11 +30,9 @@ import {
TextOutdentIcon,
TextStrikethroughIcon,
TextUnderlineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import Color from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import { TableKit } from "@tiptap/extension-table";
import TextAlign from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style";
import { EditorContent, EditorContext, useEditor, useEditorState } from "@tiptap/react";
@@ -56,7 +47,6 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
@@ -76,9 +66,7 @@ const extensions = [
heading: {
levels: [1, 2, 3, 4, 5, 6],
},
codeBlock: {
enableTabIndentation: true,
},
codeBlock: false,
link: {
openOnClick: false,
enableClickSelection: true,
@@ -98,7 +86,6 @@ const extensions = [
},
}),
TextAlign.configure({ types: ["heading", "paragraph", "listItem"] }),
TableKit.configure(),
];
type Props = UseEditorOptions & {
@@ -347,28 +334,8 @@ function useEditorToolbarState(editor: Editor) {
canInlineCode: ctx.editor.can().chain().toggleCode().run() ?? false,
toggleInlineCode: () => ctx.editor.chain().focus().toggleCode().run(),
// Code Block
isCodeBlock: ctx.editor.isActive("codeBlock") ?? false,
canCodeBlock: ctx.editor.can().chain().toggleCodeBlock().run() ?? false,
toggleCodeBlock: () => ctx.editor.chain().focus().toggleCodeBlock().run(),
// Table
insertTable: () => ctx.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
canInsertTable: ctx.editor.can().chain().insertTable().run() ?? false,
addColumnBefore: () => ctx.editor.chain().focus().addColumnBefore().run(),
canAddColumnBefore: ctx.editor.can().chain().addColumnBefore().run() ?? false,
addColumnAfter: () => ctx.editor.chain().focus().addColumnAfter().run(),
canAddColumnAfter: ctx.editor.can().chain().addColumnAfter().run() ?? false,
addRowBefore: () => ctx.editor.chain().focus().addRowBefore().run(),
canAddRowBefore: ctx.editor.can().chain().addRowBefore().run() ?? false,
addRowAfter: () => ctx.editor.chain().focus().addRowAfter().run(),
canAddRowAfter: ctx.editor.can().chain().addRowAfter().run() ?? false,
deleteColumn: () => ctx.editor.chain().focus().deleteColumn().run(),
canDeleteColumn: ctx.editor.can().chain().deleteColumn().run() ?? false,
deleteRow: () => ctx.editor.chain().focus().deleteRow().run(),
canDeleteRow: ctx.editor.can().chain().deleteRow().run() ?? false,
deleteTable: () => ctx.editor.chain().focus().deleteTable().run(),
canDeleteTable: ctx.editor.can().chain().deleteTable().run() ?? false,
// Character Count
characterCount: ctx.editor.getText().length,
// Hard Break
setHardBreak: () => ctx.editor.chain().focus().setHardBreak().run(),
@@ -582,7 +549,13 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
aria-label={t`Paragraph and heading style`}
className="rounded-none"
>
{match(state)
.with({ isParagraph: true }, () => <ParagraphIcon className="size-3.5" />)
.with({ isHeading1: true }, () => <TextHOneIcon className="size-3.5" />)
@@ -655,7 +628,13 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
aria-label={t`Text alignment`}
className="rounded-none"
>
{match(state)
.with({ isLeftAlign: true }, () => <TextAlignLeftIcon className="size-3.5" />)
.with({ isCenterAlign: true }, () => <TextAlignCenterIcon className="size-3.5" />)
@@ -731,6 +710,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Decrease indent`}
disabled={!state.canLiftListItem}
onClick={state.liftListItem}
>
@@ -742,6 +722,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Increase indent`}
disabled={!state.canSinkListItem}
onClick={state.sinkListItem}
>
@@ -756,6 +737,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Remove link`}
onClick={state.unsetLink}
>
<LinkBreakIcon className="size-3.5" />
@@ -766,6 +748,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Add link`}
onClick={state.setLink}
>
<LinkIcon className="size-3.5" />
@@ -784,73 +767,6 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
<CodeSimpleIcon className="size-3.5" />
</Toggle>
<Toggle
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
className="rounded-none"
title={t`Code Block`}
pressed={state.isCodeBlock}
disabled={!state.canCodeBlock}
onPressedChange={state.toggleCodeBlock}
>
<CodeBlockIcon className="size-3.5" />
</Toggle>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Table`}
>
<TableIcon className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuItem disabled={!state.canInsertTable} onClick={state.insertTable}>
<PlusIcon />
<Trans>Insert Table</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canAddColumnBefore} onClick={state.addColumnBefore}>
<ColumnsPlusLeftIcon />
<Trans>Add Column Before</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canAddColumnAfter} onClick={state.addColumnAfter}>
<ColumnsPlusRightIcon />
<Trans>Add Column After</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canAddRowBefore} onClick={state.addRowBefore}>
<RowsPlusTopIcon />
<Trans>Add Row Before</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canAddRowAfter} onClick={state.addRowAfter}>
<RowsPlusBottomIcon />
<Trans>Add Row After</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canDeleteColumn} onClick={state.deleteColumn}>
<TrashSimpleIcon />
<Trans>Delete Column</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canDeleteRow} onClick={state.deleteRow}>
<TrashSimpleIcon />
<Trans>Delete Row</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={!state.canDeleteTable} onClick={state.deleteTable}>
<TrashSimpleIcon />
<Trans>Delete Table</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
@@ -872,6 +788,10 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
>
<MinusIcon className="size-3.5" />
</Button>
<span className="ml-auto px-2 text-muted-foreground text-xs tabular-nums" aria-live="polite">
<Trans comment="Character count readout for the rich-text editor">{state.characterCount} characters</Trans>
</span>
</div>
);
}
@@ -5,7 +5,18 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { ErrorScreen } from "./error-screen";
// `Link` from tanstack/react-router requires a Router context. Stub it out with
// a plain anchor so we can render the screen in isolation.
vi.mock("@tanstack/react-router", () => ({
Link: ({ children, to, ...rest }: React.PropsWithChildren<{ to: string }>) => (
<a href={typeof to === "string" ? to : "#"} {...rest}>
{children}
</a>
),
}));
const { ErrorScreen } = await import("./error-screen");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
@@ -25,21 +36,27 @@ const renderError = (overrides: { error?: Error; reset?: () => void } = {}) => {
};
describe("ErrorScreen", () => {
it("shows the error message provided", () => {
renderError({ error: new Error("Network is down") });
expect(screen.getByText("Network is down")).toBeInTheDocument();
});
it("shows the user-facing error heading", () => {
it("shows a distinct, non-blaming error heading", () => {
renderError();
expect(screen.getByText("An error occurred while loading the page.")).toBeInTheDocument();
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("calls reset when the Refresh button is clicked", () => {
it("does not surface the raw error message to the user", () => {
renderError({ error: new Error("Network is down") });
expect(screen.queryByText("Network is down")).not.toBeInTheDocument();
});
it("calls reset when the Try again button is clicked", () => {
const reset = vi.fn();
renderError({ reset });
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
fireEvent.click(screen.getByRole("button", { name: /try again/i }));
expect(reset).toHaveBeenCalledTimes(1);
});
it("offers an absolute dashboard escape", () => {
renderError();
const link = screen.getByRole("link", { name: /go to dashboard/i });
expect(link.getAttribute("href")).toBe("/dashboard");
});
});
@@ -1,11 +1,12 @@
import type { ErrorComponentProps } from "@tanstack/react-router";
import { Trans } from "@lingui/react/macro";
import { ArrowClockwiseIcon, WarningIcon } from "@phosphor-icons/react";
import { ArrowClockwiseIcon, HouseIcon, WarningIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Button } from "@reactive-resume/ui/components/button";
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
export function ErrorScreen({ error, reset }: ErrorComponentProps) {
export function ErrorScreen({ reset }: ErrorComponentProps) {
return (
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
<BrandIcon variant="logo" className="size-12" />
@@ -13,15 +14,24 @@ export function ErrorScreen({ error, reset }: ErrorComponentProps) {
<Alert>
<WarningIcon />
<AlertTitle>
<Trans>An error occurred while loading the page.</Trans>
<Trans>Something went wrong</Trans>
</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
<AlertDescription>
<Trans>An unexpected error stopped this page from loading. You can try again or head back.</Trans>
</AlertDescription>
</Alert>
<Button onClick={reset}>
<ArrowClockwiseIcon />
<Trans>Refresh</Trans>
</Button>
<div className="flex items-center gap-x-2">
<Button onClick={reset}>
<ArrowClockwiseIcon />
<Trans>Try again</Trans>
</Button>
<Link to="/dashboard" className={buttonVariants({ variant: "secondary" })}>
<HouseIcon />
<Trans>Go to dashboard</Trans>
</Link>
</div>
</div>
);
}
@@ -21,27 +21,33 @@ beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderScreen = (routeId = "/missing") =>
const renderScreen = () =>
render(
<I18nProvider i18n={i18n}>
<NotFoundScreen isNotFound routeId={routeId as never} />
<NotFoundScreen />
</I18nProvider>,
);
describe("NotFoundScreen", () => {
it("renders the documented error heading", () => {
it("renders a distinct not-found heading", () => {
renderScreen();
expect(screen.getByText("An error occurred while loading the page.")).toBeInTheDocument();
expect(screen.getByText("We couldn't find that page")).toBeInTheDocument();
});
it("displays the routeId that triggered the not-found", () => {
renderScreen("/dashboard/missing-page");
expect(screen.getByText("/dashboard/missing-page")).toBeInTheDocument();
it("does not surface the raw routeId", () => {
renderScreen();
expect(screen.queryByText(/\/dashboard\/missing-page/)).not.toBeInTheDocument();
});
it("renders a Go Back link", () => {
it("offers an absolute dashboard escape", () => {
renderScreen();
const link = screen.getByRole("link", { name: /go back/i });
expect(link.getAttribute("href")).toBe("..");
const link = screen.getByRole("link", { name: /go to dashboard/i });
expect(link.getAttribute("href")).toBe("/dashboard");
});
it("offers an absolute home escape", () => {
renderScreen();
const link = screen.getByRole("link", { name: /go home/i });
expect(link.getAttribute("href")).toBe("/");
});
});
@@ -1,12 +1,11 @@
import type { NotFoundRouteProps } from "@tanstack/react-router";
import { Trans } from "@lingui/react/macro";
import { ArrowLeftIcon, WarningIcon } from "@phosphor-icons/react";
import { HouseIcon, MagnifyingGlassIcon, WarningIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { buttonVariants } from "@reactive-resume/ui/components/button";
export function NotFoundScreen({ routeId }: NotFoundRouteProps) {
export function NotFoundScreen() {
return (
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
<BrandIcon variant="logo" className="size-12" />
@@ -14,15 +13,24 @@ export function NotFoundScreen({ routeId }: NotFoundRouteProps) {
<Alert>
<WarningIcon />
<AlertTitle>
<Trans>An error occurred while loading the page.</Trans>
<Trans>We couldn't find that page</Trans>
</AlertTitle>
<AlertDescription>{routeId}</AlertDescription>
<AlertDescription>
<Trans>The page you're looking for may have been moved or no longer exists.</Trans>
</AlertDescription>
</Alert>
<Link to=".." className={buttonVariants()}>
<ArrowLeftIcon />
<Trans>Go Back</Trans>
</Link>
<div className="flex items-center gap-x-2">
<Link to="/dashboard" className={buttonVariants()}>
<MagnifyingGlassIcon />
<Trans>Go to dashboard</Trans>
</Link>
<Link to="/" className={buttonVariants({ variant: "secondary" })}>
<HouseIcon />
<Trans>Go home</Trans>
</Link>
</div>
</div>
);
}
+6 -2
View File
@@ -25,6 +25,9 @@ import { useControlledState } from "@/hooks/use-controlled-state";
type ComboboxOption<TValue extends string | number = string> = {
value: TValue;
label: React.ReactNode;
// Plain-text label used for the collapsed trigger and filtering when `label` is a ReactNode.
// Without it, a JSX label falls back to String(value) (e.g. a raw enum or locale code) in the trigger.
textValue?: string;
group?: string | ComboboxOptionGroup;
keywords?: string[];
disabled?: boolean;
@@ -177,7 +180,8 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
});
const itemToStringLabel = React.useCallback(
(item: ComboboxOption<TValue>) => (typeof item.label === "string" ? item.label : String(item.value)),
(item: ComboboxOption<TValue>) =>
item.textValue ?? (typeof item.label === "string" ? item.label : String(item.value)),
[],
);
@@ -188,7 +192,7 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
const filter = React.useCallback(
(item: ComboboxOption<TValue>, query: string) => {
const labelStr = typeof item.label === "string" ? item.label : String(item.value);
const labelStr = typeof item.label === "string" ? item.label : (item.textValue ?? String(item.value));
if (contains(labelStr, query)) return true;
return item.keywords?.some((kw) => contains(kw, query)) ?? false;
},
@@ -36,7 +36,8 @@ describe("Copyright", () => {
it("includes the app version string", () => {
renderCopyright();
expect(screen.getByText(/v9\.9\.9/)).toBeInTheDocument();
// The version is wrapped in <bdi> for RTL isolation, so it is its own text node.
expect(screen.getByText("9.9.9")).toBeInTheDocument();
});
it("merges custom className into the wrapper", () => {
+1 -1
View File
@@ -42,7 +42,7 @@ export function Copyright({ className, ...props }: Props) {
<p className="mt-4">
<Trans comment="App version label in footer; includes semantic version variable">
Reactive Resume v{__APP_VERSION__}
Reactive Resume v<bdi>{__APP_VERSION__}</bdi>
</Trans>
</p>
</div>
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { detectJsonImportType } from "./import";
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("returns an empty string for unrecognized shapes", () => {
expect(detectJsonImportType({ foo: "bar" })).toBe("");
expect(detectJsonImportType(null)).toBe("");
expect(detectJsonImportType("nope")).toBe("");
});
});
+161 -88
View File
@@ -4,8 +4,8 @@ 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, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useMutation } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { useRef, useState } from "react";
import { toast } from "sonner";
import z from "zod";
@@ -24,8 +24,8 @@ import {
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 { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
@@ -88,6 +88,56 @@ function fileToBase64(file: File): Promise<string> {
});
}
// #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";
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 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`; the current schema's metadata has a `page` key,
// the legacy v4 schema does not. Best-effort guess — the user can override the type below.
if ("sections" in data || "metadata" in data) {
const metadata = data.metadata as Record<string, unknown> | undefined;
if (metadata && !("page" in metadata)) return "reactive-resume-v4-json";
return "reactive-resume-json";
}
return "";
}
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const navigate = useNavigate();
const closeDialog = useDialogStore((state) => state.closeDialog);
@@ -96,8 +146,7 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const [isImporting, setIsImporting] = useState<boolean>(false);
const { mutateAsync: importResume } = useMutation(orpc.resume.import.mutationOptions());
const { data: aiProviders, isLoading: isLoadingAiProviders } = useQuery(orpc.aiProviders.list.queryOptions());
const hasAIProvider = aiProviders?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
const { hasUsableProvider, isLoading: isLoadingAiProviders } = useHasUsableAiProvider();
const form = useAppForm({
defaultValues: {
@@ -137,8 +186,8 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
if (value.type === "pdf") {
if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`);
if (!hasAIProvider)
throw new Error(t`This feature requires a tested AI provider. Please add one in the settings.`);
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);
@@ -149,8 +198,8 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
if (value.type === "docx") {
if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`);
if (!hasAIProvider)
throw new Error(t`This feature requires a tested AI provider. Please add one in the settings.`);
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);
@@ -205,19 +254,24 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
});
const type = useStore(form.store, (s) => s.values.type);
const file = useStore(form.store, (s) => s.values.file);
const aiRequired = type === "pdf" || type === "docx";
const onSelectFile = () => {
if (!inputRef.current) return;
inputRef.current.click();
};
const onUploadFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
form.setFieldValue("file", file);
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));
};
useFormBlocker(form);
// #6: only warn about unsaved changes once a file has actually been chosen — not on a bare type selection.
useFormBlocker(form, { shouldBlock: () => Boolean(file) });
return (
<DialogContent>
@@ -242,83 +296,12 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
void form.handleSubmit();
}}
>
<form.Field name="type">
<form.Field name="file">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Type</Trans>
<Trans>File</Trans>
</FormLabel>
<FormControl
render={
<Combobox
showClear={false}
value={field.state.value}
onValueChange={(value) => {
const nextType = value as ImportType;
if (nextType !== field.state.value) form.setFieldValue("file", undefined);
field.handleChange(nextType);
}}
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: "pdf",
label: (
<div className="flex items-center gap-x-2">
{t({
comment: "File format label in import source selector",
message: "PDF",
})}{" "}
<Badge>{t`AI`}</Badge>
</div>
),
},
{
value: "docx",
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>
),
},
]}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field key={type} name="file">
{(field) => (
<FormItem
className={cn(!type && "hidden")}
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormControl>
<Input type="file" className="hidden" ref={inputRef} onChange={onUploadFile} />
@@ -345,8 +328,98 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
)}
</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: "pdf",
textValue: t({ comment: "File format label in import source selector", message: "PDF" }),
label: (
<div className="flex items-center gap-x-2">
{t({ comment: "File format label in import source selector", message: "PDF" })}{" "}
<Badge>{t`AI`}</Badge>
</div>
),
},
{
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 please 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 PDF or Word requires a connected AI provider.</Trans>
</span>
<Button
size="sm"
variant="secondary"
nativeButton={false}
render={<Link to="/dashboard/settings/integrations">{t`Set up a provider`}</Link>}
/>
</div>
)}
<DialogFooter>
<Button type="submit" disabled={!type || isImporting}>
<Button type="submit" disabled={!type || !file || isImporting || (aiRequired && !hasUsableProvider)}>
{isImporting ? <Spinner /> : null}
{isImporting ? t`Importing…` : t`Import`}
</Button>
+13 -4
View File
@@ -6,7 +6,7 @@ import { CaretDownIcon, MagicWandIcon, PencilSimpleLineIcon, PlusIcon, TestTubeI
import { useStore } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { useNavigate, useParams } from "@tanstack/react-router";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
@@ -59,7 +59,10 @@ const defaultValues: FormValues = {
};
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());
@@ -75,9 +78,11 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
const toastId = toast.loading(t`Creating your resume...`);
createResume(value, {
onSuccess: () => {
onSuccess: (id) => {
didCreateRef.current = true;
toast.success(t`Your resume has been created successfully.`, { id: toastId });
closeDialog();
void navigate({ to: "/builder/$resumeId", params: { resumeId: id } });
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
@@ -92,7 +97,9 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
form.setFieldValue("slug", slugify(name));
}, [form, name]);
useFormBlocker(form);
useFormBlocker(form, {
shouldBlock: () => !didCreateRef.current && form.state.isDirty && !form.state.isSubmitting,
});
const onCreateSampleResume = () => {
const values = form.state.values;
@@ -108,9 +115,11 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
const toastId = toast.loading(t`Creating your resume...`);
createResume(data, {
onSuccess: () => {
onSuccess: (id) => {
didCreateRef.current = true;
toast.success(t`Your resume has been created successfully.`, { id: toastId });
closeDialog();
void navigate({ to: "/builder/$resumeId", params: { resumeId: id } });
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
@@ -39,6 +39,8 @@ const defaultValues: FormValues = {
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [],
};
@@ -81,6 +83,8 @@ export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections
icon: data?.icon ?? "",
columns: data?.columns ?? 1,
hidden: data?.hidden ?? false,
keepTogether: data?.keepTogether ?? false,
startOnNewPage: data?.startOnNewPage ?? false,
items: data?.items ?? [],
},
validators: { onSubmit: formSchema },
@@ -1,9 +1,13 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { DialogProps } from "@/dialogs/store";
import type { TemplateMetadata } from "./data";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { SlideshowIcon } from "@phosphor-icons/react";
import { lazy, Suspense } from "react";
import { toast } from "sonner";
import { Badge } from "@reactive-resume/ui/components/badge";
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@reactive-resume/ui/components/dialog";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/ui/components/hover-card";
@@ -14,6 +18,15 @@ import { useDialogStore } from "@/dialogs/store";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { templates } from "./data";
// Lazy so the browser PDF pipeline (pdf.js) loads only when a preview card actually opens — mirrors the
// sidebar template card. The hover card mounts its content only when open, so at most one tile renders
// live at a time; `TemplateLivePreview` caches per (data, template) and cancels on close.
const TemplateLivePreview = lazy(() =>
import("@/features/resume/preview/template-live-preview").then((module) => ({
default: module.TemplateLivePreview,
})),
);
export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const resume = useCurrentResume();
@@ -21,11 +34,29 @@ export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">)
const updateResumeData = useUpdateResumeData();
function onSelectTemplate(template: Template) {
// Snapshot the only field this switch mutates so the undo action fully reverts it.
const previousTemplate = resume.data.metadata.template;
if (template === previousTemplate) {
closeDialog();
return;
}
updateResumeData((draft) => {
draft.metadata.template = template;
});
closeDialog();
toast(t`Switched to the ${templates[template].name} template.`, {
action: {
label: t`Undo`,
onClick: () => {
updateResumeData((draft) => {
draft.metadata.template = previousTemplate;
});
},
},
});
}
return (
@@ -49,6 +80,7 @@ export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">)
{Object.entries(templates).map(([template, metadata]) => (
<TemplateCard
key={template}
data={resume.data}
metadata={metadata}
id={template as Template}
isActive={template === selectedTemplate}
@@ -63,22 +95,23 @@ export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">)
type TemplateCardProps = {
id: Template;
data: ResumeData;
isActive?: boolean;
metadata: TemplateMetadata;
onSelect: (template: Template) => void;
};
function TemplateCard({ id, metadata, isActive, onSelect }: TemplateCardProps) {
function TemplateCard({ id, data, metadata, isActive, onSelect }: TemplateCardProps) {
const { i18n } = useLingui();
return (
<HoverCard>
<CometCard translateDepth={3} rotateDepth={6} glareOpacity={0}>
<HoverCardTrigger
delay={300}
render={
<button
type="button"
tabIndex={-1}
onClick={() => onSelect(id)}
className={cn(
"relative block aspect-page size-full cursor-pointer overflow-hidden rounded-md bg-popover outline-none",
@@ -101,6 +134,23 @@ function TemplateCard({ id, metadata, isActive, onSelect }: TemplateCardProps) {
alignOffset={32}
className="pointer-events-none! flex w-80 flex-col justify-between gap-y-6 rounded-md bg-background/80 p-4 pb-6"
>
{/* Live peek of the user's own data through this template. Static JPG tile stays the cheap default;
this renders on demand only while the card is open, one tile at a time. */}
<Suspense
fallback={
<div className="aspect-page w-full overflow-hidden rounded-md bg-white">
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-contain" />
</div>
}
>
<TemplateLivePreview
data={data}
template={id}
fallbackSrc={metadata.imageUrl}
alt={t`Live preview of your resume in the ${metadata.name} template`}
/>
</Suspense>
<div className="space-y-1">
<h3 className="font-semibold text-lg">{metadata.name}</h3>
<p className="text-muted-foreground">{i18n.t(metadata.description)}</p>
@@ -22,11 +22,15 @@ describe("getLocaleOptions", () => {
expect(values).toContain("de-DE");
});
it("populates label and keywords with the same translated string", () => {
it("makes each option searchable by translated name and ISO code", () => {
const options = getLocaleOptions();
const enUS = options.find((opt) => opt.value === "en-US");
expect(enUS?.label).toBeTruthy();
expect(enUS?.keywords).toEqual([enUS?.label]);
// Plain-text name for the collapsed trigger.
expect(typeof enUS?.textValue).toBe("string");
// Searchable by the ISO code and the translated name regardless of the active UI locale.
expect(enUS?.keywords).toContain("en-us");
expect(enUS?.keywords).toContain(enUS?.textValue);
});
it("uses unique values for every option", () => {
+18 -5
View File
@@ -8,11 +8,24 @@ import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale"
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
export const getLocaleOptions = () => {
return Object.entries(localeMap).map(([value, label]) => ({
value: value as Locale,
label: i18n.t(label),
keywords: [i18n.t(label)],
}));
return Object.entries(localeMap).map(([value, label]) => {
const name = i18n.t(label);
return {
value: value as Locale,
label: (
<span className="flex items-center gap-x-2">
<span className="font-mono text-muted-foreground text-xs">{value}</span>
{name}
</span>
),
// Shown in the collapsed trigger (a ReactNode label would otherwise fall back to the ISO code).
textValue: name,
// Match against the translated name, the ISO code, and the untranslated English name so
// the list stays searchable regardless of the active UI locale.
keywords: [name, value.toLowerCase(), label.message].filter((keyword): keyword is string => Boolean(keyword)),
};
});
};
export function LocaleCombobox(props: Props) {
@@ -251,6 +251,146 @@ describe("builder resume autosave", () => {
});
});
describe("builder resume undo/redo", () => {
beforeEach(() => {
vi.useFakeTimers();
orpcMocks.updateResume.mockReset();
// Echo the submitted data back so the autosave completion doesn't count as an external rebase.
orpcMocks.updateResume.mockImplementation((input: { id: string; data: ResumeData }) =>
Promise.resolve({ ...makeResume(input.id), data: input.data }),
);
routerParamsMock.value = {};
i18n.loadAndActivate({ locale: "en-US", messages: {} });
useResumeStore.getState().reset();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
useResumeStore.getState().reset();
});
it("coalesces rapid edits into a single undo step and restores the pre-burst state", () => {
const store = useResumeStore.getState;
store().initialize(makeResume("undo-coalesce"));
store().updateResumeData((draft) => {
draft.basics.name = "First";
});
store().updateResumeData((draft) => {
draft.basics.name = "Second";
});
expect(store().undoStack.length).toBe(1);
expect(store().canUndo).toBe(true);
expect(store().canRedo).toBe(false);
expect(store().resume?.data.basics.name).toBe("Second");
store().undo();
expect(store().resume?.data.basics.name).toBe(defaultResumeData.basics.name);
expect(store().canUndo).toBe(false);
expect(store().canRedo).toBe(true);
store().redo();
expect(store().resume?.data.basics.name).toBe("Second");
expect(store().canRedo).toBe(false);
});
it("separates edits outside the coalesce window into distinct undo steps", async () => {
const store = useResumeStore.getState;
store().initialize(makeResume("undo-boundary"));
store().updateResumeData((draft) => {
draft.basics.name = "A";
});
// Let the autosave flush (echoes the data back) and advance past the coalesce window.
vi.advanceTimersByTime(600);
await flushMicrotasks();
store().updateResumeData((draft) => {
draft.basics.name = "B";
});
expect(store().undoStack.length).toBe(2);
store().undo();
expect(store().resume?.data.basics.name).toBe("A");
store().undo();
expect(store().resume?.data.basics.name).toBe(defaultResumeData.basics.name);
});
it("clears the redo branch when a new edit follows an undo", () => {
const store = useResumeStore.getState;
store().initialize(makeResume("undo-redo-clear"));
store().updateResumeData((draft) => {
draft.basics.name = "One";
});
store().undo();
expect(store().canRedo).toBe(true);
store().updateResumeData((draft) => {
draft.basics.name = "Two";
});
expect(store().canRedo).toBe(false);
expect(store().redoStack.length).toBe(0);
});
it("does not undo when the resume is locked", () => {
const store = useResumeStore.getState;
store().initialize(makeResume("undo-locked"));
store().updateResumeData((draft) => {
draft.basics.name = "Editable";
});
store().patchResume((resume) => {
resume.isLocked = true;
});
store().undo();
expect(store().resume?.data.basics.name).toBe("Editable");
});
it("preserves the undo stack when the server echoes the current data (autosave)", () => {
const store = useResumeStore.getState;
store().initialize(makeResume("rebase-echo"));
store().updateResumeData((draft) => {
draft.basics.name = "Edited";
});
expect(store().undoStack.length).toBe(1);
const current = store().resume;
if (!current) throw new Error("expected a current resume");
// Autosave echo: the server returns data identical to what's already in the store.
store().replaceResumeFromServer({ ...current, data: cloneResumeData(current.data) });
expect(store().undoStack.length).toBe(1);
expect(store().canUndo).toBe(true);
});
it("clears the undo stack when the server sends different data (external rebase)", () => {
const store = useResumeStore.getState;
store().initialize(makeResume("rebase-external"));
store().updateResumeData((draft) => {
draft.basics.name = "Edited";
});
expect(store().undoStack.length).toBe(1);
const current = store().resume;
if (!current) throw new Error("expected a current resume");
// External / AI rebase: incoming data differs, so the local undo history no longer applies.
store().replaceResumeFromServer(withBasicsName(current, "External Name"));
expect(store().undoStack.length).toBe(0);
expect(store().canUndo).toBe(false);
});
});
describe("resume update stream subscription", () => {
beforeEach(() => {
vi.useFakeTimers();
+280 -14
View File
@@ -24,10 +24,22 @@ export type Resume = {
isPublic?: boolean;
};
// Mirrors the server-side ResumeUpdatedEvent discriminator (packages/api resume/events.ts).
type ResumeUpdateMutation = "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete";
type ResumeUpdateEvent = { mutation: ResumeUpdateMutation };
export type SaveStatus = "idle" | "saving" | "saved" | "error";
type ResumeStoreState = {
resume: Resume | null;
resumeId?: string;
isReady: boolean;
saveStatus: SaveStatus;
// Client-side undo/redo stacks holding whole-`ResumeData` snapshots (see recordHistory helpers below).
undoStack: ResumeData[];
redoStack: ResumeData[];
canUndo: boolean;
canRedo: boolean;
};
type ResumeStoreActions = {
@@ -38,6 +50,9 @@ type ResumeStoreActions = {
updateResumeData: (fn: (draft: WritableDraft<ResumeData>) => void) => void;
patchResume: (fn: (draft: WritableDraft<Resume>) => void) => void;
mergeResumeMetadata: (resume: Resume) => void;
setSaveStatus: (status: SaveStatus) => void;
undo: () => void;
redo: () => void;
};
type ResumeStore = ResumeStoreState & ResumeStoreActions;
@@ -51,17 +66,32 @@ type Runtime = {
syncErrorToastId?: string | number;
syncResume: ReturnType<typeof debounce<(resume: Resume) => void>>;
beforeUnloadHandler?: () => void;
deferredRemoteResume?: Resume;
deferredFocusHandler?: () => void;
};
type ResumeUpdateSubscriptionOptions = {
resumeId?: string;
onUpdate: () => Promise<void> | void;
onUpdate: (event: ResumeUpdateEvent) => Promise<void> | void;
onError?: (error: unknown) => void;
};
const SAVE_DEBOUNCE_MS = 500;
// Rapid edits within this window coalesce into a single undo step (e.g. typing a word / dragging).
const HISTORY_COALESCE_MS = 500;
// Bounded stacks: keep undo/redo memory (whole-resume snapshots) predictable during a long session.
const MAX_HISTORY_ENTRIES = 50;
const runtimes = new Map<string, Runtime>();
// Coalescing bookkeeping. Not reactive — only decides whether the next edit opens a new undo step.
let historyLastEditAt = 0;
let historyCanCoalesce = false;
function resetHistoryRuntime() {
historyLastEditAt = 0;
historyCanCoalesce = false;
}
let lockedToastId: string | number | undefined;
function getResumeQueryKey(id: string): QueryKey {
@@ -80,6 +110,66 @@ function createResumeUpdateEventIterator(resumeId: string) {
return streamClient.resume.updates.subscribe({ id: resumeId });
}
export function isEditableElementFocused(): boolean {
if (typeof document === "undefined") return false;
const element = document.activeElement as HTMLElement | null;
if (!element) return false;
return (
element.tagName === "INPUT" ||
element.tagName === "TEXTAREA" ||
element.tagName === "SELECT" ||
element.isContentEditable
);
}
function externalUpdateMessage(mutation: ResumeUpdateMutation): string {
if (mutation === "patch") return t`This resume was updated by an AI agent.`;
if (mutation === "lock" || mutation === "password") return t`This resume's sharing settings changed elsewhere.`;
return t`Synced changes made in another tab.`;
}
function notifyExternalUpdate(mutation: ResumeUpdateMutation) {
toast.info(externalUpdateMessage(mutation), { id: "resume-external-update" });
}
// #54: applies a remote update that was deferred because the user was typing.
function applyDeferredRemoteResume(id: string) {
const runtime = runtimes.get(id);
if (!runtime?.deferredRemoteResume) return;
const resume = runtime.deferredRemoteResume;
runtime.deferredRemoteResume = undefined;
if (runtime.deferredFocusHandler && typeof document !== "undefined") {
document.removeEventListener("focusout", runtime.deferredFocusHandler, true);
runtime.deferredFocusHandler = undefined;
}
// The user may have started editing again while the update was deferred; local edits win.
if (runtime.hasPendingLocalChanges) return;
useResumeStore.getState().replaceResumeFromServer(resume);
notifyExternalUpdate("update");
}
// #54: don't overwrite a focused field mid-keystroke; stash the remote resume and apply it on blur.
function deferRemoteResumeUntilBlur(id: string, resume: Resume) {
const runtime = getRuntime(id);
runtime.deferredRemoteResume = resume;
if (runtime.deferredFocusHandler || typeof document === "undefined") return;
const handler = () => {
// Let focus settle (e.g. tabbing between fields) before deciding editing has ended.
window.setTimeout(() => {
if (isEditableElementFocused()) return;
applyDeferredRemoteResume(id);
}, 0);
};
runtime.deferredFocusHandler = handler;
document.addEventListener("focusout", handler, true);
}
function setRuntimeBaseline(resume: Resume) {
const runtime = getRuntime(resume.id);
runtime.hasPendingLocalChanges = false;
@@ -110,6 +200,7 @@ async function flushResumeSave(id: string) {
if (currentDataStillMatchesSubmission && !runtime.pendingResume) {
runtime.hasPendingLocalChanges = false;
useResumeStore.getState().replaceResumeFromServer(updated);
useResumeStore.getState().setSaveStatus("saved");
} else {
runtime.hasPendingLocalChanges = true;
useResumeStore.getState().mergeResumeMetadata(updated);
@@ -129,6 +220,7 @@ async function flushResumeSave(id: string) {
runtime.pendingResume ??= submitted;
runtime.hasPendingLocalChanges = true;
useResumeStore.getState().setSaveStatus("error");
runtime.syncErrorToastId = toast.error(t`Your latest changes could not be saved.`, {
id: runtime.syncErrorToastId,
duration: Number.POSITIVE_INFINITY,
@@ -200,6 +292,10 @@ function cleanupRuntime(id: string) {
window.removeEventListener("beforeunload", runtime.beforeUnloadHandler);
}
if (runtime.deferredFocusHandler && typeof document !== "undefined") {
document.removeEventListener("focusout", runtime.deferredFocusHandler, true);
}
runtimes.delete(id);
}
@@ -215,40 +311,75 @@ export const useResumeStore = create<ResumeStore>()(
resume: null,
resumeId: undefined,
isReady: false,
saveStatus: "idle",
undoStack: [],
redoStack: [],
canUndo: false,
canRedo: false,
initialize: (resume) => {
if (resume) setRuntimeBaseline(resume);
resetHistoryRuntime();
set((state) => {
state.resume = resume;
state.resumeId = resume?.id;
state.isReady = resume !== null;
state.undoStack = [];
state.redoStack = [];
state.canUndo = false;
state.canRedo = false;
});
},
reset: () => {
resetHistoryRuntime();
set((state) => {
state.resume = null;
state.resumeId = undefined;
state.isReady = false;
state.undoStack = [];
state.redoStack = [];
state.canUndo = false;
state.canRedo = false;
});
},
replaceResumeDraft: (resume) => {
resetHistoryRuntime();
set((state) => {
state.resume = resume;
state.resumeId = resume.id;
state.isReady = true;
state.undoStack = [];
state.redoStack = [];
state.canUndo = false;
state.canRedo = false;
});
},
replaceResumeFromServer: (resume) => {
setRuntimeBaseline(resume);
// This runs both for the echo of our own autosave (identical data → keep history) and for
// external/cross-tab/AI rebases (different data → local undo history no longer applies).
const current = get().resume;
const isRebase = !current || !isEqual(current.data, resume.data);
if (isRebase) resetHistoryRuntime();
set((state) => {
state.resume = resume;
state.resumeId = resume.id;
state.isReady = true;
if (isRebase) {
state.undoStack = [];
state.redoStack = [];
state.canUndo = false;
state.canRedo = false;
}
});
},
@@ -259,6 +390,12 @@ export const useResumeStore = create<ResumeStore>()(
});
},
setSaveStatus: (status) => {
set((state) => {
state.saveStatus = status;
});
},
mergeResumeMetadata: (resume) => {
set((state) => {
if (!state.resume || state.resume.id !== resume.id) return;
@@ -284,17 +421,101 @@ export const useResumeStore = create<ResumeStore>()(
return;
}
// Coalesce bursts: only the first edit of a burst opens a new undo step by snapshotting the
// pre-edit state. Edits within HISTORY_COALESCE_MS of the previous one fold into that step.
const now = Date.now();
const coalesce = historyCanCoalesce && now - historyLastEditAt < HISTORY_COALESCE_MS;
const snapshotBefore = coalesce ? undefined : cloneResumeData(currentResume.data);
historyLastEditAt = now;
historyCanCoalesce = true;
set((state) => {
if (!state.resume) return;
if (snapshotBefore) {
state.undoStack.push(snapshotBefore);
if (state.undoStack.length > MAX_HISTORY_ENTRIES) state.undoStack.shift();
// A fresh edit invalidates the redo branch.
state.redoStack = [];
}
fn(state.resume.data as WritableDraft<ResumeData>);
state.saveStatus = "saving";
state.canUndo = state.undoStack.length > 0;
state.canRedo = state.redoStack.length > 0;
});
getRuntime(currentResume.id).hasPendingLocalChanges = true;
syncCurrentResume(currentResume.id);
},
undo: () => {
applyHistoryStep(get, set, "undo");
},
redo: () => {
applyHistoryStep(get, set, "redo");
},
})),
);
type ImmerSet = (fn: (state: WritableDraft<ResumeStore>) => void) => void;
type StoreGet = () => ResumeStore;
// Shared undo/redo: move the current data to the opposite stack and install the popped snapshot,
// then route the change through the normal autosave path so the preview and sync react as usual.
function applyHistoryStep(get: StoreGet, set: ImmerSet, direction: "undo" | "redo") {
const state = get();
const currentResume = state.resume;
if (!currentResume) return;
if (currentResume.isLocked) {
lockedToastId = toast.error(t`This resume is locked and cannot be updated.`, { id: lockedToastId });
return;
}
const source = direction === "undo" ? state.undoStack : state.redoStack;
if (source.length === 0) return;
// The next edit after an undo/redo must start a brand-new undo step.
resetHistoryRuntime();
const current = cloneResumeData(currentResume.data);
set((draft) => {
if (!draft.resume) return;
const from = direction === "undo" ? draft.undoStack : draft.redoStack;
const to = direction === "undo" ? draft.redoStack : draft.undoStack;
const snapshot = from.pop();
if (snapshot === undefined) return;
to.push(current as WritableDraft<ResumeData>);
if (to.length > MAX_HISTORY_ENTRIES) to.shift();
draft.resume.data = snapshot;
draft.saveStatus = "saving";
draft.canUndo = draft.undoStack.length > 0;
draft.canRedo = draft.redoStack.length > 0;
});
getRuntime(currentResume.id).hasPendingLocalChanges = true;
syncCurrentResume(currentResume.id);
}
// Mobile builder keeps the live preview mounted across tabs (to preserve zoom/pan), but pauses its PDF
// re-render while the Edit/Design overlay covers it — otherwise every keystroke re-renders a hidden PDF.
// Desktop never pauses. Lives here because it's the SSR-safe module both the shell and preview import.
type PreviewPausedStore = {
paused: boolean;
setPaused: (paused: boolean) => void;
};
export const usePreviewPausedStore = create<PreviewPausedStore>()((set) => ({
paused: false,
setPaused: (paused) => set({ paused }),
}));
export function useInitializeResumeStore() {
return useResumeStore((state) => state.initialize);
}
@@ -341,6 +562,30 @@ export function useResumeData(): ResumeData | undefined {
return useBuilderResumeSelector((resume) => resume.data);
}
export function useIsResumeLocked(): boolean {
return useBuilderResumeSelector((resume) => resume.isLocked) ?? false;
}
export function useSaveStatus(): SaveStatus {
return useResumeStore((state) => state.saveStatus);
}
export function useCanUndo(): boolean {
return useResumeStore((state) => state.canUndo);
}
export function useCanRedo(): boolean {
return useResumeStore((state) => state.canRedo);
}
export function useUndoResume() {
return useResumeStore((state) => state.undo);
}
export function useRedoResume() {
return useResumeStore((state) => state.redo);
}
export function useUpdateResumeData() {
const queryClient = useQueryClient();
const params = useParams({ strict: false }) as { resumeId?: string };
@@ -366,9 +611,9 @@ export function useResumeUpdateSubscription({ resumeId, onUpdate, onError }: Res
let didCancel = false;
let retryTimer: number | undefined;
const cancel = consumeEventIterator(createResumeUpdateEventIterator(resumeId), {
onEvent: async () => {
onEvent: async (event) => {
try {
await onUpdate();
await onUpdate((event ?? { mutation: "sync" }) as ResumeUpdateEvent);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
onError?.(error);
@@ -395,21 +640,42 @@ export function useBuilderResumeUpdateSubscription() {
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
const onUpdate = useCallback(async () => {
if (!resumeId) return;
const onUpdate = useCallback(
async (event: ResumeUpdateEvent) => {
if (!resumeId) return;
bindRuntimeQueryClient(resumeId, queryClient);
const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume;
bindRuntimeQueryClient(resumeId, queryClient);
const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume;
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
if (hasPendingLocalChanges(resumeId)) {
useResumeStore.getState().mergeResumeMetadata(resume);
return;
}
if (hasPendingLocalChanges(resumeId)) {
useResumeStore.getState().mergeResumeMetadata(resume);
return;
}
replaceResumeFromServer(resume);
}, [queryClient, replaceResumeFromServer, resumeId]);
const current = useResumeStore.getState().resume;
const isExternalChange =
event.mutation !== "sync" && current?.id === resume.id && !isEqual(current.data, resume.data);
if (!isExternalChange) {
replaceResumeFromServer(resume);
return;
}
// #54: never overwrite a field the user is editing; defer the swap until blur.
if (isEditableElementFocused()) {
useResumeStore.getState().mergeResumeMetadata(resume);
deferRemoteResumeUntilBlur(resumeId, resume);
return;
}
// #53: attribute cross-tab / AI-agent edits instead of silently swapping the document.
replaceResumeFromServer(resume);
notifyExternalUpdate(event.mutation);
},
[queryClient, replaceResumeFromServer, resumeId],
);
const onError = useCallback((error: unknown) => {
console.warn("Resume update stream failed, reconnecting:", error);
@@ -0,0 +1,81 @@
import type { Resume } from "@/features/resume/builder/draft";
import { t } from "@lingui/core/macro";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { buildDocx } from "@reactive-resume/docx";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { createResumePdfBlob } from "./pdf-document";
/**
* Single source of truth for resume export (PDF / DOCX / JSON / Print). Previously duplicated verbatim
* between the builder dock and the right-panel Export section (#17).
*/
export function useResumeExport(resume: Resume | undefined) {
const [isExporting, setIsExporting] = useState(false);
const onDownloadJSON = useCallback(() => {
if (!resume) return;
const blob = new Blob([JSON.stringify(resume.data, null, 2)], { type: "application/json" });
downloadWithAnchor(blob, generateFilename(resume.name, "json"));
}, [resume]);
const onDownloadDOCX = useCallback(async () => {
if (!resume) return;
try {
const blob = await buildDocx(resume.data);
downloadWithAnchor(blob, generateFilename(resume.name, "docx"));
} catch {
toast.error(t`There was a problem while generating the DOCX, please try again.`);
}
}, [resume]);
const onDownloadPDF = useCallback(async () => {
if (!resume) return;
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
setIsExporting(true);
try {
const blob = await createResumePdfBlob(resume.data);
downloadWithAnchor(blob, generateFilename(resume.name, "pdf"));
} catch {
toast.error(t`There was a problem while generating the PDF, please try again.`);
} finally {
setIsExporting(false);
toast.dismiss(toastId);
}
}, [resume]);
const onPrint = useCallback(async () => {
if (!resume) return;
const toastId = toast.loading(t`Preparing your resume for printing...`);
setIsExporting(true);
try {
const blob = await createResumePdfBlob(resume.data);
const url = URL.createObjectURL(blob);
// ponytail: print the generated PDF via a hidden iframe (reliable in Chromium). If the browser
// blocks iframe printing, fall back to opening the PDF in a new tab so the user can print manually.
const iframe = document.createElement("iframe");
iframe.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0";
iframe.src = url;
iframe.onload = () => {
try {
iframe.contentWindow?.focus();
iframe.contentWindow?.print();
} catch {
window.open(url, "_blank", "noopener");
}
setTimeout(() => {
iframe.remove();
URL.revokeObjectURL(url);
}, 60_000);
};
document.body.appendChild(iframe);
} catch {
toast.error(t`There was a problem while preparing your resume for printing, please try again.`);
} finally {
setIsExporting(false);
toast.dismiss(toastId);
}
}, [resume]);
return { onDownloadJSON, onDownloadDOCX, onDownloadPDF, onPrint, isExporting };
}
@@ -2,7 +2,8 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { ResumePreviewClient } from "./preview.browser";
@@ -40,6 +41,7 @@ vi.mock("@/features/resume/export/pdf-document", () => ({
vi.mock("../builder/draft", () => ({
useResumeData: () => previewMock.builderResumeData,
usePreviewPausedStore: (selector: (state: { paused: boolean }) => unknown) => selector({ paused: false }),
}));
vi.mock("./pdf-canvas", async () => {
@@ -70,6 +72,10 @@ vi.mock("./pdf-canvas", async () => {
});
describe("ResumePreviewClient", () => {
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
previewMock.builderResumeData = undefined;
previewMock.toBlob.mockReset();
@@ -2,11 +2,13 @@ import type { CSSProperties } from "react";
import type { PreviewPageSize, ResolvedResumePreviewProps } from "./preview.shared";
import { AnimatePresence, m } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { isRTL } from "@reactive-resume/utils/locale";
import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { useResumeData } from "../builder/draft";
import { usePreviewPausedStore, useResumeData } from "../builder/draft";
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
import { getResumePreviewGapValue, getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
import { ResumeAccessibleText } from "./resume-accessible-text";
type PreviewPdf = {
file: Blob;
@@ -92,6 +94,7 @@ export function ResumePreviewClient({
}: ResolvedResumePreviewProps) {
const builderResumeData = useResumeData();
const resumeData = data ?? builderResumeData;
const paused = usePreviewPausedStore((state) => state.paused);
const [previewLayers, setPreviewLayers] = useState<PreviewPdf[]>([]);
@@ -101,6 +104,8 @@ export function ResumePreviewClient({
useEffect(() => {
if (!resumeData) return;
// Mobile hides the preview behind the Edit/Design overlay; skip re-rendering and keep the last PDF shown.
if (paused) return;
let cancelled = false;
const requestId = ++requestIdRef.current;
@@ -128,7 +133,7 @@ export function ResumePreviewClient({
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [resumeData]);
}, [resumeData, paused]);
if (!resumeData) return null;
@@ -137,19 +142,23 @@ export function ResumePreviewClient({
if (!visiblePdf) {
return (
<ResumePreviewLoader
pageCount={getResumePreviewPageCount(resumeData)}
pageClassName={pageClassName}
pageGap={pageGap}
pageLayout={pageLayout}
pageScale={pageScale}
showPageNumbers={showPageNumbers}
/>
<>
<ResumeAccessibleText data={resumeData} />
<ResumePreviewLoader
pageCount={getResumePreviewPageCount(resumeData)}
pageClassName={pageClassName}
pageGap={pageGap}
pageLayout={pageLayout}
pageScale={pageScale}
showPageNumbers={showPageNumbers}
/>
</>
);
}
return (
<div className={cn("grid", className)}>
<ResumeAccessibleText data={resumeData} />
<AnimatePresence initial={false}>
{previewLayers.map((visiblePdf) => (
<m.div
@@ -174,6 +183,7 @@ export function ResumePreviewClient({
>
{(document) => (
<div
dir={isRTL(resumeData.metadata.page.locale) ? "rtl" : "ltr"}
className={cn(
"flex justify-start gap-(--resume-preview-page-gap)",
pageLayout === "horizontal" ? "flex-row items-start" : "flex-col items-center",
@@ -85,6 +85,8 @@ export function ResumePreviewLoader({
return (
<div
// Chrome-only placeholder: anchor pages left-to-right so page 1 stays on-screen regardless of UI direction.
dir="ltr"
style={{ "--resume-preview-page-gap": resolvedPageGap } as CSSProperties}
className={cn(
"flex justify-start gap-(--resume-preview-page-gap)",
@@ -0,0 +1,315 @@
import type {
AwardItem,
CertificationItem,
CoverLetterItem,
CustomSectionItem,
CustomSectionType,
EducationItem,
ExperienceItem,
InterestItem,
LanguageItem,
ProfileItem,
ProjectItem,
PublicationItem,
ReferenceItem,
ResumeData,
SectionType,
SkillItem,
SummaryItem,
VolunteerItem,
} from "@reactive-resume/schema/resume/data";
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { stripHtml } from "@reactive-resume/utils/string";
import { useResumeData } from "@/features/resume/builder/draft";
import { getSectionTitle } from "@/libs/resume/section";
// Fixed reading order for built-in sections. The visual PDF column order can differ,
// but for a screen-reader mirror a stable, complete order matters more than column fidelity.
const SECTION_ORDER: SectionType[] = [
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
];
const joinInline = (...parts: (string | undefined | null | false)[]): string =>
parts.filter((part): part is string => typeof part === "string" && part.trim().length > 0).join(" · ");
type ItemWebsite = { url?: string; label?: string };
type ItemBodyProps = {
primary?: string;
details?: string;
description?: string;
website?: ItemWebsite;
};
function ItemBody({ primary, details, description, website }: ItemBodyProps) {
const header = joinInline(primary, details);
const websiteUrl = website?.url?.trim();
return (
<>
{header ? <p>{header}</p> : null}
{description ? <p>{description}</p> : null}
{websiteUrl ? <a href={websiteUrl}>{website?.label?.trim() || websiteUrl}</a> : null}
</>
);
}
function renderItem(type: CustomSectionType, item: CustomSectionItem): ReactNode {
switch (type) {
case "experience": {
const it = item as ExperienceItem;
const roles = it.roles ?? [];
return (
<>
<ItemBody
primary={joinInline(it.position, it.company)}
details={joinInline(it.location, it.period)}
description={stripHtml(it.description)}
website={it.website}
/>
{roles.length > 0 ? (
<ul>
{roles.map((role) => (
<li key={role.id}>
<ItemBody
primary={joinInline(role.position, role.period)}
description={stripHtml(role.description)}
/>
</li>
))}
</ul>
) : null}
</>
);
}
case "education": {
const it = item as EducationItem;
return (
<ItemBody
primary={it.school}
details={joinInline(it.degree, it.area, it.grade, it.location, it.period)}
description={stripHtml(it.description)}
website={it.website}
/>
);
}
case "skills": {
const it = item as SkillItem;
return <ItemBody primary={it.name} details={joinInline(it.proficiency, (it.keywords ?? []).join(", "))} />;
}
case "interests": {
const it = item as InterestItem;
return <ItemBody primary={it.name} details={(it.keywords ?? []).join(", ")} />;
}
case "languages": {
const it = item as LanguageItem;
return <ItemBody primary={it.language} details={it.fluency} />;
}
case "profiles": {
const it = item as ProfileItem;
return <ItemBody primary={joinInline(it.network, it.username)} website={it.website} />;
}
case "projects": {
const it = item as ProjectItem;
return (
<ItemBody primary={it.name} details={it.period} description={stripHtml(it.description)} website={it.website} />
);
}
case "awards": {
const it = item as AwardItem;
return (
<ItemBody
primary={it.title}
details={joinInline(it.awarder, it.date)}
description={stripHtml(it.description)}
website={it.website}
/>
);
}
case "certifications": {
const it = item as CertificationItem;
return (
<ItemBody
primary={it.title}
details={joinInline(it.issuer, it.date)}
description={stripHtml(it.description)}
website={it.website}
/>
);
}
case "publications": {
const it = item as PublicationItem;
return (
<ItemBody
primary={it.title}
details={joinInline(it.publisher, it.date)}
description={stripHtml(it.description)}
website={it.website}
/>
);
}
case "volunteer": {
const it = item as VolunteerItem;
return (
<ItemBody
primary={it.organization}
details={joinInline(it.location, it.period)}
description={stripHtml(it.description)}
website={it.website}
/>
);
}
case "references": {
const it = item as ReferenceItem;
return (
<ItemBody
primary={it.name}
details={joinInline(it.position, it.phone)}
description={stripHtml(it.description)}
website={it.website}
/>
);
}
case "cover-letter": {
const it = item as CoverLetterItem;
return <ItemBody description={joinInline(stripHtml(it.recipient), stripHtml(it.content))} />;
}
case "summary": {
const it = item as SummaryItem;
return <ItemBody description={stripHtml(it.content)} />;
}
default:
return null;
}
}
type AccessibleSectionProps = {
type: CustomSectionType;
title: string;
hidden: boolean;
items: CustomSectionItem[];
};
function AccessibleSection({ type, title, hidden, items }: AccessibleSectionProps) {
if (hidden) return null;
const visibleItems = items.filter((item) => !item.hidden);
if (visibleItems.length === 0) return null;
return (
<section>
<h2>{title}</h2>
<ul>
{visibleItems.map((item) => (
<li key={item.id}>{renderItem(type, item)}</li>
))}
</ul>
</section>
);
}
type ResumeAccessibleTextProps = {
data?: ResumeData;
};
/**
* Visually-hidden, screen-reader-readable mirror of the resume content. The builder preview
* renders the resume as a rasterized PDF canvas that is opaque to assistive tech, so this
* structured HTML mirror is the permanent accessibility surface for the live editor.
*
* DOM-only: the PDF/export pipeline generates its own document and never reads this markup.
*/
export function ResumeAccessibleText({ data }: ResumeAccessibleTextProps) {
const builderResumeData = useResumeData();
const resumeData = data ?? builderResumeData;
if (!resumeData) return null;
const { basics, summary, sections, customSections } = resumeData;
const summaryText = summary && !summary.hidden ? stripHtml(summary.content) : "";
const website = basics.website;
const contact: ReactNode[] = [];
if (basics.email) contact.push(<a href={`mailto:${basics.email}`}>{basics.email}</a>);
if (basics.phone) contact.push(<a href={`tel:${basics.phone}`}>{basics.phone}</a>);
if (basics.location) contact.push(basics.location);
if (website?.url?.trim()) contact.push(<a href={website.url}>{website.label?.trim() || website.url}</a>);
for (const field of basics.customFields ?? []) {
if (!field.text?.trim()) continue;
contact.push(field.link?.trim() ? <a href={field.link}>{field.text}</a> : field.text);
}
return (
<section className="sr-only" aria-label={t`Resume content`}>
<header>
{basics.name ? <h1>{basics.name}</h1> : null}
{basics.headline ? <p>{basics.headline}</p> : null}
{contact.length > 0 ? (
<ul>
{contact.map((entry, index) => (
<li key={index}>{entry}</li>
))}
</ul>
) : null}
</header>
{summaryText ? (
<section>
<h2>{summary.title?.trim() || getSectionTitle("summary")}</h2>
<p>{summaryText}</p>
</section>
) : null}
{SECTION_ORDER.map((type) => {
const section = sections[type];
if (!section) return null;
return (
<AccessibleSection
key={type}
type={type}
title={section.title?.trim() || getSectionTitle(type)}
hidden={section.hidden}
items={section.items}
/>
);
})}
{(customSections ?? []).map((section) => (
<AccessibleSection
key={section.id}
type={section.type}
title={section.title?.trim() || getSectionTitle(section.type)}
hidden={section.hidden}
items={section.items}
/>
))}
</section>
);
}
@@ -0,0 +1,104 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { useEffect, useState } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { createPdfFirstPageImageUrl } from "./pdf-thumbnail";
// Bounded FIFO cache of generated object URLs keyed by (data, template) reference. Evicted entries are
// revoked so blobs don't leak; stale-data entries (a new `data` object after an edit) age out via the cap.
// ponytail: cap-8 linear scan — trivial for a hover preview; only one card renders at a time.
const PREVIEW_CACHE_LIMIT = 8;
type PreviewCacheEntry = { data: ResumeData; template: Template; url: string };
const previewCache: PreviewCacheEntry[] = [];
const getCachedPreview = (data: ResumeData, template: Template) =>
previewCache.find((entry) => entry.data === data && entry.template === template)?.url;
const setCachedPreview = (data: ResumeData, template: Template, url: string) => {
previewCache.push({ data, template, url });
while (previewCache.length > PREVIEW_CACHE_LIMIT) {
const evicted = previewCache.shift();
if (evicted) URL.revokeObjectURL(evicted.url);
}
};
// Single-flight render pipeline shared across every mounted instance: renders run one-at-a-time on the
// main thread (serialized through `renderQueue`), and only the latest requested render commits its result
// (`latestRenderRequestId`), so rapid hovers between templates discard superseded work instead of stacking.
let latestRenderRequestId = 0;
let renderQueue: Promise<void> = Promise.resolve();
type TemplateLivePreviewProps = {
alt: string;
className?: string;
data: ResumeData;
fallbackSrc: string;
template: Template;
};
/**
* Renders the first page of the user's actual resume data through a given template, lazily.
* Reuses the browser PDF pipeline (`createResumePdfBlob` + pdf.js first-page render). Falls back to the
* static template image while generating or if generation fails. Intended to be mounted on demand (e.g.
* inside a hover/preview card) so the render stays off the hover critical path.
*/
export function TemplateLivePreview({ alt, className, data, fallbackSrc, template }: TemplateLivePreviewProps) {
const [imageUrl, setImageUrl] = useState<string | null>(() => getCachedPreview(data, template) ?? null);
const [hasError, setHasError] = useState(false);
useEffect(() => {
const cached = getCachedPreview(data, template);
if (cached) {
setImageUrl(cached);
return;
}
let cancelled = false;
const requestId = ++latestRenderRequestId;
renderQueue = renderQueue.then(async () => {
if (cancelled || requestId !== latestRenderRequestId) return;
// Another instance may have cached this exact preview while we were queued.
const existing = getCachedPreview(data, template);
if (existing) {
setImageUrl(existing);
return;
}
try {
const blob = await createResumePdfBlob(data, template);
const url = await createPdfFirstPageImageUrl(blob);
if (cancelled || requestId !== latestRenderRequestId) {
URL.revokeObjectURL(url);
return;
}
setCachedPreview(data, template, url);
setImageUrl(url);
} catch {
if (!cancelled) setHasError(true);
}
});
return () => {
cancelled = true;
};
}, [data, template]);
const isLoading = !imageUrl && !hasError;
return (
<div className={cn("relative aspect-page w-full overflow-hidden rounded-md bg-white", className)}>
<img src={imageUrl ?? fallbackSrc} alt={alt} className="size-full object-contain" />
{isLoading ? (
<div className="absolute inset-0 flex items-center justify-center bg-white/40">
<Spinner className="size-8" />
</div>
) : null}
</div>
);
}
@@ -1,4 +1,5 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CircleNotchIcon, DownloadSimpleIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { getRouteApi } from "@tanstack/react-router";
@@ -41,15 +42,41 @@ export function PublicResumeRoute() {
if (!resume) return <LoadingScreen />;
const { basics, picture } = resume.data;
return (
<>
<div className="mx-auto flex w-full flex-col items-center gap-6 px-4 py-6 print:m-0 print:block print:max-w-full print:p-0">
<div className="w-full max-w-5xl bg-white print:max-w-full">
<header className="flex w-full max-w-5xl flex-col items-center gap-4 text-center print:hidden">
{picture.url && !picture.hidden && (
<img src={picture.url} alt={basics.name} className="size-20 rounded-full object-cover shadow-sm" />
)}
<div className="space-y-1">
{basics.name && <h1 className="font-semibold text-2xl tracking-tight">{basics.name}</h1>}
{basics.headline && <p className="text-muted-foreground">{basics.headline}</p>}
</div>
<Button onClick={onDownloadPDF} disabled={isPrinting}>
{isPrinting ? (
<CircleNotchIcon className="size-4 animate-spin" />
) : (
<DownloadSimpleIcon className="size-4" />
)}
<Trans>Download PDF</Trans>
</Button>
</header>
<main className="w-full max-w-5xl bg-white print:max-w-full">
<PdfViewer data={resume.data} className="block w-full" />
</div>
</main>
<footer className="flex justify-center print:hidden">
<BrandIcon variant="icon" className="size-8 opacity-60" />
<a
href="/"
className="flex items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground"
>
<BrandIcon variant="icon" className="size-5" />
<Trans>Build your own resume</Trans>
</a>
</footer>
</div>
@@ -114,7 +114,7 @@ export function PasskeysSection() {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.3, ease: "easeOut" }}
className="will-change-[transform,opacity]"
@@ -25,7 +25,7 @@ export function PasswordSection() {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.1, ease: "easeOut" }}
className="flex items-center justify-between gap-x-4 will-change-[transform,opacity]"
@@ -36,7 +36,7 @@ export function SocialProviderSection({ provider, name, animationDelay = 0 }: So
return (
<m.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: animationDelay, ease: "easeOut" }}
>
@@ -30,7 +30,7 @@ export function TwoFactorSection() {
return (
<m.div
className="will-change-[transform,opacity]"
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.2, ease: "easeOut" }}
>
@@ -10,7 +10,7 @@ export function AuthenticationSettingsPage() {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-4 will-change-[transform,opacity]"
@@ -21,14 +21,14 @@ export function AuthenticationSettingsPage() {
<PasskeysSection />
{"google" in enabledProviders && <SocialProviderSection provider="google" animationDelay={0.4} />}
{"google" in enabledProviders && <SocialProviderSection provider="google" animationDelay={0.04} />}
{"github" in enabledProviders && <SocialProviderSection provider="github" animationDelay={0.5} />}
{"github" in enabledProviders && <SocialProviderSection provider="github" animationDelay={0.08} />}
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" animationDelay={0.6} />}
{"linkedin" in enabledProviders && <SocialProviderSection provider="linkedin" animationDelay={0.12} />}
{"custom" in enabledProviders && (
<SocialProviderSection provider="custom" animationDelay={0.7} name={enabledProviders.custom} />
<SocialProviderSection provider="custom" animationDelay={0.16} name={enabledProviders.custom} />
)}
</m.div>
);
@@ -17,11 +17,12 @@ import { Spinner } from "@reactive-resume/ui/components/spinner";
import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
type SavedProvider = RouterOutput["aiProviders"]["list"][number];
type AIProviderOption = ComboboxOption<AIProvider> & { defaultBaseURL: string };
type AIProviderOption = ComboboxOption<AIProvider> & { defaultBaseURL: string; defaultModel: string };
type ProviderRowProps = {
provider: SavedProvider;
@@ -33,71 +34,86 @@ const providerOptions: AIProviderOption[] = [
label: t`OpenAI`,
keywords: ["openai", "gpt", "chatgpt"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openai,
defaultModel: "gpt-4.1",
},
{
value: "anthropic",
label: t`Anthropic Claude`,
keywords: ["anthropic", "claude", "ai"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.anthropic,
defaultModel: "claude-3-5-sonnet-latest",
},
{
value: "gemini",
label: t`Google Gemini`,
keywords: ["gemini", "google"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.gemini,
defaultModel: "gemini-2.0-flash",
},
{
value: "vercel-ai-gateway",
label: t`Vercel AI Gateway`,
keywords: ["vercel", "gateway", "ai"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS["vercel-ai-gateway"],
defaultModel: "openai/gpt-4.1",
},
{
value: "openrouter",
label: t`OpenRouter`,
keywords: ["openrouter", "router"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openrouter,
defaultModel: "openai/gpt-4.1",
},
{
value: "ollama",
label: t`Ollama`,
keywords: ["ollama", "local"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.ollama,
defaultModel: "llama3.1",
},
{
value: "openai-compatible",
label: t`OpenAI-compatible`,
keywords: ["compatible", "custom", "gateway"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS["openai-compatible"],
defaultModel: "",
},
];
// Prefill Base URL + Model from the provider's known defaults when the provider changes.
function providerDefaults(provider: AIProvider) {
const option = providerOptions.find((entry) => entry.value === provider);
return {
baseURL: option?.defaultBaseURL ?? AI_PROVIDER_DEFAULT_BASE_URLS[provider] ?? "",
model: option?.defaultModel ?? "",
};
}
const emptyForm = {
label: "",
provider: "openai" as AIProvider,
model: "",
baseURL: "",
apiKey: "",
...providerDefaults("openai"),
};
function statusBadge(provider: SavedProvider) {
if (provider.testStatus === "success") {
return (
<Badge className="bg-emerald-600 text-white">
<Trans>Tested</Trans>
<Trans>Connected</Trans>
</Badge>
);
}
if (provider.testStatus === "failure") {
return (
<Badge variant="destructive">
<Trans>Failed</Trans>
<Trans>Connection failed</Trans>
</Badge>
);
}
return (
<Badge variant="secondary">
<Trans>Untested</Trans>
<Trans>Not connected</Trans>
</Badge>
);
}
@@ -220,15 +236,67 @@ function ProviderRow({ provider }: ProviderRowProps) {
);
}
type SaveResult = { ok: boolean; message: string };
function CreateProviderForm() {
const queryClient = useQueryClient();
const [form, setForm] = useState(emptyForm);
const [result, setResult] = useState<SaveResult | null>(null);
const selectedOption = useMemo(
() => providerOptions.find((option) => option.value === form.provider),
[form.provider],
);
const canCreate = form.label.trim() && form.model.trim() && form.apiKey.trim();
const { mutate: createProvider, isPending } = useMutation(orpc.aiProviders.create.mutationOptions());
const invalidate = () => queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() });
const { mutateAsync: createProvider, isPending: isCreating } = useMutation(orpc.aiProviders.create.mutationOptions());
const { mutateAsync: testProvider, isPending: isTesting } = useMutation(orpc.aiProviders.test.mutationOptions());
const { mutateAsync: enableProvider, isPending: isEnabling } = useMutation(orpc.aiProviders.update.mutationOptions());
const isSaving = isCreating || isTesting || isEnabling;
// Model/label are prefilled from provider defaults, so step 1 (Provider + API Key) is enough to save.
const model = form.model.trim();
const label = form.label.trim() || String(selectedOption?.label ?? form.provider);
const canSave = Boolean(form.apiKey.trim() && model);
const save = async () => {
setResult(null);
try {
const created = await createProvider({
label,
provider: form.provider,
model,
baseURL: form.baseURL.trim(),
apiKey: form.apiKey.trim(),
});
// Test on save: verify the connection immediately instead of leaving it to a manual step.
const tested = await testProvider({ id: created.id });
if (tested.testStatus === "success") {
await enableProvider({ id: created.id, enabled: true });
setForm(emptyForm);
setResult({ ok: true, message: t`Connection verified — provider is ready to use.` });
} else {
// ponytail: provider stays persisted on failure so it shows in the list; a re-save creates a new row.
setResult({
ok: false,
message: tested.testError ?? t`Could not verify the connection. Check the API key, model, and base URL.`,
});
}
} catch (error) {
setResult({
ok: false,
message: getOrpcErrorMessage(error, {
byCode: {
PRECONDITION_FAILED: t`AI providers require REDIS_URL and ENCRYPTION_SECRET to be configured.`,
BAD_REQUEST: t`Invalid AI provider configuration.`,
},
fallback: t`Failed to save AI provider.`,
}),
});
} finally {
void invalidate();
}
};
return (
<div className="rounded-md border bg-card p-4">
@@ -241,19 +309,7 @@ function CreateProviderForm() {
</h3>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ai-label">
<Trans>Label</Trans>
</Label>
<Input
id="ai-label"
value={form.label}
onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))}
placeholder={t`Work OpenAI`}
/>
</div>
<div className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="ai-provider">
<Trans>Provider</Trans>
@@ -265,43 +321,12 @@ function CreateProviderForm() {
options={providerOptions}
onValueChange={(provider) => {
if (!provider) return;
setForm((current) => ({ ...current, provider }));
setForm((current) => ({ ...current, provider, ...providerDefaults(provider) }));
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-model">
<Trans>Model</Trans>
</Label>
<Input
id="ai-model"
value={form.model}
onChange={(event) => setForm((current) => ({ ...current, model: event.target.value }))}
placeholder={t`gpt-4.1`}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-base-url">
<Trans>Base URL</Trans>
</Label>
<Input
id="ai-base-url"
type="url"
value={form.baseURL}
onChange={(event) => setForm((current) => ({ ...current, baseURL: event.target.value }))}
placeholder={selectedOption?.defaultBaseURL || t`https://gateway.example.com/v1`}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="ai-api-key">
<Trans>API Key</Trans>
</Label>
@@ -318,42 +343,81 @@ function CreateProviderForm() {
data-1p-ignore="true"
/>
</div>
<details className="rounded-md border bg-background/50 px-3 py-2 [&_summary]:cursor-pointer">
<summary className="font-medium text-muted-foreground text-sm">
<Trans>Advanced</Trans>
</summary>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ai-label">
<Trans>Label</Trans>
</Label>
<Input
id="ai-label"
value={form.label}
onChange={(event) => setForm((current) => ({ ...current, label: event.target.value }))}
placeholder={selectedOption?.label ? String(selectedOption.label) : t`Work OpenAI`}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-model">
<Trans>Model</Trans>
</Label>
<Input
id="ai-model"
value={form.model}
onChange={(event) => setForm((current) => ({ ...current, model: event.target.value }))}
placeholder={t`gpt-4.1`}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="ai-base-url">
<Trans>Base URL</Trans>
</Label>
<Input
id="ai-base-url"
type="url"
value={form.baseURL}
onChange={(event) => setForm((current) => ({ ...current, baseURL: event.target.value }))}
placeholder={selectedOption?.defaultBaseURL || t`https://gateway.example.com/v1`}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
</div>
</details>
</div>
<div className="mt-4 flex justify-end">
<Button
disabled={!canCreate || isPending}
onClick={() =>
createProvider(
{
label: form.label.trim(),
provider: form.provider,
model: form.model.trim(),
baseURL: form.baseURL.trim(),
apiKey: form.apiKey.trim(),
},
{
onSuccess: () => {
setForm(emptyForm);
toast.success(t`AI provider saved. Test it before use.`);
void queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.queryKey() });
},
onError: (error) =>
toast.error(
getOrpcErrorMessage(error, {
byCode: {
PRECONDITION_FAILED: t`AI providers require REDIS_URL and ENCRYPTION_SECRET to be configured.`,
BAD_REQUEST: t`Invalid AI provider configuration.`,
},
fallback: t`Failed to save AI provider.`,
}),
),
},
)
}
{result ? (
<div
className={cn(
"mt-4 flex items-start gap-2 rounded-md border p-3 text-sm",
result.ok
? "border-emerald-300 bg-emerald-50 text-emerald-950 dark:bg-emerald-950/20 dark:text-emerald-200"
: "border-rose-300 bg-rose-50 text-rose-950 dark:bg-rose-950/20 dark:text-rose-200",
)}
>
{isPending ? <Spinner /> : <KeyIcon />}
<Trans>Save Provider</Trans>
{result.ok ? (
<CheckCircleIcon className="mt-0.5 shrink-0 text-emerald-600" />
) : (
<WarningCircleIcon className="mt-0.5 shrink-0 text-rose-600" />
)}
<span>{result.message}</span>
</div>
) : null}
<div className="mt-4 flex justify-end">
<Button disabled={!canSave || isSaving} onClick={() => void save()}>
{isSaving ? <Spinner /> : <KeyIcon />}
{isTesting ? <Trans>Testing</Trans> : <Trans>Save & Test Provider</Trans>}
</Button>
</div>
</div>
@@ -362,7 +426,7 @@ function CreateProviderForm() {
export function AISettingsSection() {
const { data: providers, isLoading, error } = useQuery(orpc.aiProviders.list.queryOptions());
const hasUsableProvider = providers?.some((provider) => provider.enabled && provider.testStatus === "success");
const { hasUsableProvider } = useHasUsableAiProvider();
const isConfigError = isAiProviderConfigError(error);
return (
@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/libs/orpc/client";
/**
* Single source of truth for "is an AI provider ready to use" (enabled AND its connection test succeeded).
* Replaces the predicate that was duplicated across the import dialog, agent setup, and AI settings.
*/
export function useHasUsableAiProvider() {
const { data: providers, isLoading } = useQuery(orpc.aiProviders.list.queryOptions());
const usableProviders = (providers ?? []).filter((provider) => provider.enabled && provider.testStatus === "success");
return {
hasUsableProvider: usableProviders.length > 0,
usableProviders,
isLoading,
};
}
@@ -9,7 +9,7 @@ export function IntegrationsSettingsPage() {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-4xl gap-8 will-change-[transform,opacity]"
@@ -67,7 +67,7 @@ export function ApiKeysSettingsPage() {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
@@ -1,6 +1,6 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { TrashSimpleIcon } from "@phosphor-icons/react";
import { DownloadSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { m } from "motion/react";
@@ -8,6 +8,7 @@ import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { useConfirm } from "@/hooks/use-confirm";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
@@ -23,6 +24,27 @@ export function DangerZoneSettingsPage() {
const { mutate: deleteAccount } = useMutation(orpc.auth.deleteAccount.mutationOptions());
const { mutate: exportData, isPending: isExporting } = useMutation(
orpc.auth.exportData.mutationOptions({
onSuccess: (data) => {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
downloadWithAnchor(blob, generateFilename("reactive-resume-export", "json"));
toast.success(t`Your data has been exported successfully.`);
},
onError: (error) => {
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when data export fails",
message: "Failed to export your data. Please try again.",
}),
),
);
},
}),
);
const handleDeleteAccount = async () => {
const confirmed = await confirm(t`Are you sure you want to delete your account?`, {
description: t`This action cannot be undone. All your data will be permanently deleted.`,
@@ -63,11 +85,31 @@ export function DangerZoneSettingsPage() {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
>
<div className="grid gap-3">
<p className="leading-relaxed">
<Trans>Download a copy of all your data, including your profile and every resume, as a JSON file.</Trans>
</p>
<m.div
className="justify-self-start will-change-transform"
whileHover={{ y: -1, scale: 1.01 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.14, ease: "easeOut" }}
>
<Button variant="outline" onClick={() => exportData(undefined)} disabled={isExporting}>
<DownloadSimpleIcon />
<Trans>Export my data</Trans>
</Button>
</m.div>
</div>
<hr className="border-border" />
<p className="leading-relaxed">
<Trans>To delete your account, you need to enter the confirmation text and click the button below.</Trans>
</p>
@@ -9,7 +9,7 @@ import { ThemeCombobox } from "@/features/theme/combobox";
export function PreferencesSettingsPage() {
return (
<m.div
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
@@ -3,7 +3,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CheckIcon, WarningIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { useRouter } from "@tanstack/react-router";
import { useRouteContext, useRouter } from "@tanstack/react-router";
import { AnimatePresence, m } from "motion/react";
import { toast } from "sonner";
import z from "zod";
@@ -33,6 +33,8 @@ type Props = {
export function ProfileSettingsPage({ session }: Props) {
const router = useRouter();
const context = useRouteContext({ strict: false });
const smtpEnabled = context.flags?.smtpEnabled ?? false;
const form = useAppForm({
defaultValues: {
@@ -130,7 +132,7 @@ export function ProfileSettingsPage({ session }: Props) {
return (
<m.form
initial={{ opacity: 0, y: -20 }}
initial={{ y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className="grid max-w-xl gap-6 will-change-[transform,opacity]"
@@ -226,7 +228,7 @@ export function ProfileSettingsPage({ session }: Props) {
<CheckIcon />
<Trans>Verified</Trans>
</p>
) : (
) : smtpEnabled ? (
<p className="flex items-center gap-x-1.5 text-amber-600 text-xs">
<WarningIcon className="size-3.5" />
<Trans>Unverified</Trans>
@@ -239,6 +241,10 @@ export function ProfileSettingsPage({ session }: Props) {
<Trans>Resend verification email</Trans>
</Button>
</p>
) : (
<p className="text-muted-foreground text-xs">
<Trans>Email delivery isn't configured on this instance, so verification is disabled.</Trans>
</p>
)}
</FormItem>
)}
@@ -26,6 +26,8 @@ describe("getSourceSectionTitle", () => {
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [],
});
});
@@ -58,6 +60,8 @@ describe("getCompatibleMoveTargets", () => {
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [],
});
draft.metadata.layout.pages[0].main.push("ext-1");
@@ -125,6 +129,8 @@ describe("removeItemFromSource", () => {
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "i1" } as never],
});
});
@@ -177,6 +183,8 @@ describe("addItemToSection", () => {
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [],
});
});
+4
View File
@@ -231,6 +231,8 @@ export function createCustomSectionWithItem(
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [item as never],
};
@@ -269,6 +271,8 @@ export function createPageWithSection(
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [item as never],
};
@@ -21,6 +21,8 @@ describe("createSectionItem", () => {
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [],
});
});
@@ -79,6 +81,8 @@ describe("updateSectionItem", () => {
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "x", value: "old" } as never],
});
});
+2 -2
View File
@@ -80,13 +80,13 @@ export const leftSidebarSections: LeftSidebarSection[] = [
export const rightSidebarSections: RightSidebarSection[] = [
"template",
"layout",
"sharing",
"statistics",
"typography",
"design",
"styles",
"page",
"notes",
"sharing",
"statistics",
"analysis",
"export",
"information",
+19
View File
@@ -17,6 +17,25 @@ export const getCanonicalRootUrl = (origin?: string): string => {
export const createNoindexFollowMeta = () => ({ name: "robots", content: "noindex, follow" });
type ResumeSocialMetaOptions = {
canonicalUrl: string;
title: string;
description: string;
imageUrl: string;
};
export const createResumeSocialMeta = ({ canonicalUrl, title, description, imageUrl }: ResumeSocialMetaOptions) => [
{ property: "og:type", content: "profile" },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:url", content: canonicalUrl },
{ property: "og:image", content: imageUrl },
{ property: "twitter:card", content: "summary_large_image" },
{ property: "twitter:title", content: title },
{ property: "twitter:description", content: description },
{ property: "twitter:image", content: imageUrl },
];
const serializeJsonLdForScript = (data: JsonLd) =>
JSON.stringify(data).replace(/[<>&\u2028\u2029]/g, (character) => {
switch (character) {

Some files were not shown because too many files have changed in this diff Show More