Squashed commit of the following:

commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 13:13:38 2026 +0200

    chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies

commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 13:08:04 2026 +0200

    fix: remove timestamp conflict guard

commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 12:11:51 2026 +0200

    chore(release): v5.1.5

commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:58:35 2026 +0200

    revert: compose.yml

commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:58:08 2026 +0200

    refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086

commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 11:10:41 2026 +0200

    refactor(pdf): simplify sidebar section filtering and update summary feature logic

commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:53:37 2026 +0200

    chore: update pnpm-lock.yaml and turbo.json

commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:38:30 2026 +0200

    fix(polyfill): add tested polyfill for Map Upsert methods

commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:23:29 2026 +0200

    Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration

    # Conflicts:
    #	packages/api/src/services/agent-url.ts
    #	packages/runtime-externals/package.json

commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Tue May 19 09:22:12 2026 +0200

    chore: preserve branch changes before main sync

commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Mon May 18 07:50:28 2026 +0200

    chore: lot of fixes for monorepo migration

commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 13:57:17 2026 +0200

    chore: update knip version and refine web app routing with new SEO endpoints

commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 12:10:06 2026 +0200

    refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint

commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:54:29 2026 +0200

    chore: update dependencies and enhance PWA metadata in web app

commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:12:35 2026 +0200

    docs: revise manifest-only pwa testing scope

commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:11:33 2026 +0200

    docs: add manifest-only pwa design

commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:05:04 2026 +0200

    chore(dev): simplify server proxy config

commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:04:50 2026 +0200

    docs: add unsafe oauth redirect plan

commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 11:04:34 2026 +0200

    feat(auth): add unsafe oauth redirect flag

commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:55:02 2026 +0200

    docs: design unsafe oauth redirect flag

commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:22:04 2026 +0200

    chore: update translation source paths

commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 10:09:25 2026 +0200

    refactor(arch): react spa + hono migration

commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Fri May 15 01:10:47 2026 +0200

    docs: add docker nightly tagging design

commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date:   Thu May 14 20:05:44 2026 +0200

    feat: migrate to hono spa server
This commit is contained in:
Amruth Pillai
2026-05-19 13:14:21 +02:00
parent 5b1297fa2b
commit 62f8270b3e
518 changed files with 29398 additions and 26871 deletions
@@ -1,143 +0,0 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useHotkeys } from "@tanstack/react-hotkeys";
import { useRef } from "react";
import { Command, CommandEmpty, CommandInput, CommandList } from "@reactive-resume/ui/components/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { NavigationCommandGroup } from "./pages/navigation";
import { PreferencesCommandGroup } from "./pages/preferences";
import { ResumesCommandGroup } from "./pages/resumes";
import { useCommandPaletteStore } from "./store";
export function CommandPalette() {
const inputRef = useRef<HTMLInputElement>(null);
const { open, search, pages, setOpen, setSearch, goBack } = useCommandPaletteStore();
const isFirstPage = pages.length === 0;
const currentPage = pages[pages.length - 1];
// Toggle command palette with Cmd+K / Ctrl+K
useHotkeys([
{
hotkey: "Mod+K",
callback: () => {
setOpen(!open);
},
},
{
hotkey: "Escape",
callback: () => {
if (!open) return;
setOpen(false);
},
},
{
hotkey: "Backspace",
callback: (event) => {
// Only handle if the command palette is open
if (!open) return;
const input = inputRef.current;
if (!input) return;
// Only handle if input is focused
if (document.activeElement !== input) return;
// If input has text, let the default behavior handle it (delete character)
if (search.length > 0) return;
// If input is empty, prevent default and go back
event.preventDefault();
goBack();
},
},
]);
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
};
const handleSearchChange = (value: string) => {
setSearch(value);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogHeader className="sr-only print:hidden">
<DialogTitle>
<Trans comment="Screen-reader dialog title for the command palette in the resume builder">
Builder Command Palette
</Trans>
</DialogTitle>
<DialogDescription>
<Trans comment="Screen-reader dialog description instructing users how to use the command palette">
Type a command or search...
</Trans>
</DialogDescription>
</DialogHeader>
<DialogContent
className="overflow-hidden p-0"
aria-label={
isFirstPage
? t({
comment: "Accessible label for the command palette dialog",
message: "Command Palette",
})
: t({
comment: "Accessible label for command palette dialog when browsing a nested command page",
message: `Command Palette - ${currentPage}`,
})
}
>
<Command
loop
aria-label={t({
comment: "Accessible label for command list region inside command palette",
message: "Command Palette",
})}
className="[&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3"
>
<CommandInput
ref={inputRef}
value={search}
onValueChange={handleSearchChange}
placeholder={
isFirstPage
? t({
comment: "Placeholder in command palette input on root page",
message: "Type a command or search...",
})
: t({
comment: "Placeholder in command palette input on nested pages",
message: "Search...",
})
}
aria-label={t({
comment: "Accessible label for command palette search input",
message: "Search commands",
})}
/>
<CommandList>
<CommandEmpty>
<Trans comment="Empty-state message when no command palette results match the search query">
The command you're looking for doesn't exist.
</Trans>
</CommandEmpty>
<ResumesCommandGroup />
<PreferencesCommandGroup />
<NavigationCommandGroup />
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
@@ -1,53 +0,0 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { Command } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../store";
import { BaseCommandGroup } from "./base";
const renderInCommand = (ui: React.ReactNode) => render(<Command>{ui}</Command>);
const resetStore = () => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
};
afterEach(resetStore);
describe("BaseCommandGroup", () => {
it("renders children at the root (no page prop) when the page stack is empty", () => {
renderInCommand(<BaseCommandGroup heading="Root">child-text</BaseCommandGroup>);
expect(screen.getByText("child-text")).toBeInTheDocument();
});
it("does NOT render when the page stack tops a different page than its `page` prop", () => {
useCommandPaletteStore.setState({ pages: ["other"] });
const { container } = renderInCommand(
<BaseCommandGroup page="settings" heading="Settings">
<span>x</span>
</BaseCommandGroup>,
);
// Nothing rendered besides the Command shell itself.
expect(container.textContent).toBe("");
});
it("renders when the top of the page stack matches its `page` prop", () => {
useCommandPaletteStore.setState({ pages: ["settings"] });
renderInCommand(
<BaseCommandGroup page="settings" heading="Settings">
settings-children
</BaseCommandGroup>,
);
expect(screen.getByText("settings-children")).toBeInTheDocument();
});
it("does NOT render the root group when there is a sub-page on top", () => {
useCommandPaletteStore.setState({ pages: ["settings"] });
const { container } = renderInCommand(
<BaseCommandGroup heading="Root">
<span>root-text</span>
</BaseCommandGroup>,
);
expect(container.textContent).toBe("");
});
});
@@ -1,22 +0,0 @@
import { useMemo } from "react";
import { CommandGroup } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../store";
type Props = {
page?: string;
heading: React.ReactNode;
children: React.ReactNode;
};
export const BaseCommandGroup = ({ page, heading, children }: Props) => {
const pages = useCommandPaletteStore((state) => state.pages);
const currentPage = pages[pages.length - 1];
const isEnabled = useMemo(() => {
return currentPage === page;
}, [currentPage, page]);
if (!isEnabled) return null;
return <CommandGroup heading={heading}>{children}</CommandGroup>;
};
@@ -1,126 +0,0 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ChatCircleDotsIcon,
GearIcon,
HouseSimpleIcon,
KeyIcon,
OpenAiLogoIcon,
ReadCvLogoIcon,
ShieldCheckIcon,
UserCircleIcon,
WarningIcon,
} from "@phosphor-icons/react";
import { useNavigate, useRouteContext } from "@tanstack/react-router";
import { CommandItem } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../store";
import { BaseCommandGroup } from "./base";
export function NavigationCommandGroup() {
const navigate = useNavigate();
const { session } = useRouteContext({ strict: false });
const reset = useCommandPaletteStore((state) => state.reset);
const pushPage = useCommandPaletteStore((state) => state.pushPage);
const onNavigate = async (path: string) => {
await navigate({ to: path });
reset();
};
return (
<>
<BaseCommandGroup heading={<Trans>Go to...</Trans>}>
<CommandItem keywords={[t`Home`]} value="navigation.home" onSelect={() => onNavigate("/")}>
<HouseSimpleIcon />
<Trans>Home</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`Resumes`]}
value="navigation.resumes"
onSelect={() => onNavigate("/dashboard/resumes")}
>
<ReadCvLogoIcon />
<Trans>Resumes</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`Agent`, t`Artificial Intelligence`]}
value="navigation.agent"
onSelect={() => onNavigate("/agent")}
>
<ChatCircleDotsIcon />
<Trans>Agent</Trans>
</CommandItem>
<CommandItem
disabled={!session}
keywords={[t`Settings`]}
value="navigation.settings"
onSelect={() => pushPage("settings")}
>
<GearIcon />
<Trans>Settings</Trans>
</CommandItem>
</BaseCommandGroup>
<BaseCommandGroup page="settings" heading={<Trans>Settings</Trans>}>
<CommandItem
keywords={[t`Profile`]}
value="navigation.settings.profile"
onSelect={() => onNavigate("/dashboard/settings/profile")}
>
<UserCircleIcon />
<Trans>Profile</Trans>
</CommandItem>
<CommandItem
keywords={[t`Preferences`]}
value="navigation.settings.preferences"
onSelect={() => onNavigate("/dashboard/settings/preferences")}
>
<GearIcon />
<Trans>Preferences</Trans>
</CommandItem>
<CommandItem
keywords={[t`Authentication`]}
value="navigation.settings.authentication"
onSelect={() => onNavigate("/dashboard/settings/authentication")}
>
<ShieldCheckIcon />
<Trans>Authentication</Trans>
</CommandItem>
<CommandItem
keywords={[t`API Keys`]}
value="navigation.settings.api-keys"
onSelect={() => onNavigate("/dashboard/settings/api-keys")}
>
<KeyIcon />
<Trans>API Keys</Trans>
</CommandItem>
<CommandItem
keywords={[t`Integrations`, t`Artificial Intelligence`]}
value="navigation.settings.integrations"
onSelect={() => onNavigate("/dashboard/settings/integrations")}
>
<OpenAiLogoIcon />
<Trans>Integrations</Trans>
</CommandItem>
<CommandItem
keywords={[t`Danger Zone`]}
value="navigation.settings.danger-zone"
onSelect={() => onNavigate("/dashboard/settings/danger-zone")}
>
<WarningIcon />
<Trans>Danger Zone</Trans>
</CommandItem>
</BaseCommandGroup>
</>
);
}
@@ -1,52 +0,0 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { Command } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../../store";
vi.mock("@/components/theme/provider", () => ({
useTheme: () => ({ setTheme: vi.fn(), theme: "light", toggleTheme: vi.fn() }),
}));
const { PreferencesCommandGroup } = await import("./index");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
});
const renderGroup = () =>
render(
<I18nProvider i18n={i18n}>
<Command>
<PreferencesCommandGroup />
</Command>
</I18nProvider>,
);
describe("PreferencesCommandGroup", () => {
it("renders 'Change theme to...' and 'Change language to...' at the root", () => {
renderGroup();
expect(screen.getByText("Change theme to...")).toBeInTheDocument();
expect(screen.getByText("Change language to...")).toBeInTheDocument();
});
it("pushes 'theme' onto the page stack when the theme item is selected", () => {
renderGroup();
const item = screen.getByText("Change theme to...");
fireEvent.click(item);
expect(useCommandPaletteStore.getState().pages).toContain("theme");
});
it("pushes 'language' onto the page stack when the language item is selected", () => {
renderGroup();
fireEvent.click(screen.getByText("Change language to..."));
expect(useCommandPaletteStore.getState().pages).toContain("language");
});
});
@@ -1,30 +0,0 @@
import { Trans } from "@lingui/react/macro";
import { PaletteIcon, TranslateIcon } from "@phosphor-icons/react";
import { CommandItem } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../../store";
import { BaseCommandGroup } from "../base";
import { LanguageCommandPage } from "./language";
import { ThemeCommandPage } from "./theme";
export function PreferencesCommandGroup() {
const pushPage = useCommandPaletteStore((state) => state.pushPage);
return (
<>
<BaseCommandGroup heading={<Trans>Preferences</Trans>}>
<CommandItem onSelect={() => pushPage("theme")}>
<PaletteIcon />
<Trans>Change theme to...</Trans>
</CommandItem>
<CommandItem onSelect={() => pushPage("language")}>
<TranslateIcon />
<Trans>Change language to...</Trans>
</CommandItem>
</BaseCommandGroup>
<ThemeCommandPage />
<LanguageCommandPage />
</>
);
}
@@ -1,59 +0,0 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { Command } from "@reactive-resume/ui/components/command";
import { localeMap } from "@/libs/locale";
import { useCommandPaletteStore } from "../../store";
import { LanguageCommandPage } from "./language";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
});
const renderPage = () =>
render(
<I18nProvider i18n={i18n}>
<Command>
<LanguageCommandPage />
</Command>
</I18nProvider>,
);
describe("LanguageCommandPage", () => {
it("does NOT render when the page stack does not have 'language' on top", () => {
renderPage();
// localeMap codes shouldn't appear because BaseCommandGroup gating is off.
expect(screen.queryByText("en-US")).toBeNull();
});
it("renders one CommandItem for every entry in localeMap when active", () => {
useCommandPaletteStore.setState({ pages: ["language"] });
renderPage();
const expectedCount = Object.keys(localeMap).length;
expect(expectedCount).toBeGreaterThan(0);
// Each locale value is rendered in the inline font-mono span.
for (const code of Object.keys(localeMap).slice(0, 5)) {
expect(screen.getByText(code)).toBeInTheDocument();
}
});
it("includes the documented set of locales (sample check)", () => {
useCommandPaletteStore.setState({ pages: ["language"] });
renderPage();
// Spot-check a couple of common locales.
for (const code of ["en-US", "de-DE", "ja-JP"]) {
if (code in localeMap) {
expect(screen.getByText(code)).toBeInTheDocument();
}
}
});
});
@@ -1,26 +0,0 @@
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { CommandItem } from "@reactive-resume/ui/components/command";
import { isLocale, loadLocale, localeMap, setLocaleServerFn } from "@/libs/locale";
import { BaseCommandGroup } from "../base";
export function LanguageCommandPage() {
const { i18n } = useLingui();
const handleLocaleChange = async (value: string) => {
if (!value || !isLocale(value)) return;
await Promise.all([loadLocale(value), setLocaleServerFn({ data: value })]);
window.location.reload();
};
return (
<BaseCommandGroup page="language" heading={<Trans>Language</Trans>}>
{Object.entries(localeMap).map(([value, label]) => (
<CommandItem key={value} onSelect={() => handleLocaleChange(value)}>
<span className="font-mono text-muted-foreground text-xs">{value}</span>
{i18n.t(label)}
</CommandItem>
))}
</BaseCommandGroup>
);
}
@@ -1,45 +0,0 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { Command } from "@reactive-resume/ui/components/command";
import { useCommandPaletteStore } from "../../store";
vi.mock("@/components/theme/provider", () => ({
useTheme: () => ({ setTheme: vi.fn(), theme: "light", toggleTheme: vi.fn() }),
}));
const { ThemeCommandPage } = await import("./theme");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
});
const renderPage = () =>
render(
<I18nProvider i18n={i18n}>
<Command>
<ThemeCommandPage />
</Command>
</I18nProvider>,
);
describe("ThemeCommandPage", () => {
it("is hidden when 'theme' is not on top of the page stack", () => {
renderPage();
expect(screen.queryByText("Light theme")).toBeNull();
});
it("renders Light + Dark options when active", () => {
useCommandPaletteStore.setState({ pages: ["theme"] });
renderPage();
expect(screen.getByText("Light theme")).toBeInTheDocument();
expect(screen.getByText("Dark theme")).toBeInTheDocument();
});
});
@@ -1,30 +0,0 @@
import { Trans } from "@lingui/react/macro";
import { MoonIcon, SunIcon } from "@phosphor-icons/react";
import { CommandItem } from "@reactive-resume/ui/components/command";
import { useTheme } from "@/components/theme/provider";
import { useCommandPaletteStore } from "../../store";
import { BaseCommandGroup } from "../base";
export function ThemeCommandPage() {
const { setTheme } = useTheme();
const setOpen = useCommandPaletteStore((state) => state.setOpen);
const handleThemeChange = (theme: "light" | "dark") => {
setTheme(theme, { playSound: false });
setOpen(false);
};
return (
<BaseCommandGroup page="theme" heading={<Trans>Theme</Trans>}>
<CommandItem value="light" onSelect={() => handleThemeChange("light")}>
<SunIcon />
<Trans>Light theme</Trans>
</CommandItem>
<CommandItem value="dark" onSelect={() => handleThemeChange("dark")}>
<MoonIcon />
<Trans>Dark theme</Trans>
</CommandItem>
</BaseCommandGroup>
);
}
@@ -1,84 +0,0 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PlusIcon, ReadCvLogoIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useRouteContext } from "@tanstack/react-router";
import { CommandLoading } from "cmdk";
import { CommandItem, CommandShortcut } from "@reactive-resume/ui/components/command";
import { Kbd } from "@reactive-resume/ui/components/kbd";
import { useDialogStore } from "@/dialogs/store";
import { orpc } from "@/libs/orpc/client";
import { useCommandPaletteStore } from "../store";
import { BaseCommandGroup } from "./base";
export function ResumesCommandGroup() {
const navigate = useNavigate();
const { openDialog } = useDialogStore();
const { session } = useRouteContext({ strict: false });
const reset = useCommandPaletteStore((state) => state.reset);
const peekPage = useCommandPaletteStore((state) => state.peekPage);
const pushPage = useCommandPaletteStore((state) => state.pushPage);
const isResumesPage = peekPage() === "resumes";
const { data: resumes, isLoading } = useQuery(
orpc.resume.list.queryOptions({
enabled: !!session && isResumesPage,
}),
);
const onCreate = async () => {
await navigate({ to: "/dashboard/resumes" });
openDialog("resume.create", undefined);
reset();
};
const onNavigate = async (path: string) => {
await navigate({ to: path });
reset();
};
if (!session) return null;
return (
<>
<BaseCommandGroup heading={<Trans>Search for...</Trans>}>
<CommandItem keywords={[t`Resumes`]} value="search.resumes" onSelect={() => pushPage("resumes")}>
<ReadCvLogoIcon />
<Trans>Resumes</Trans>
</CommandItem>
</BaseCommandGroup>
<BaseCommandGroup page="resumes" heading={<Trans>Resumes</Trans>}>
<CommandItem onSelect={onCreate}>
<PlusIcon />
<Trans>Create a new resume</Trans>
</CommandItem>
{isLoading ? (
<CommandLoading>
<Trans>Loading resumes...</Trans>
</CommandLoading>
) : (
resumes?.map((resume) => (
<CommandItem
key={resume.id}
value={resume.id}
keywords={[resume.name]}
onSelect={() => onNavigate(`/builder/${resume.id}`)}
>
<ReadCvLogoIcon />
{resume.name}
<CommandShortcut className="opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100">
<Trans comment="Command palette hint that pressing Enter opens the selected resume">
Press <Kbd>Enter</Kbd> to open
</Trans>
</CommandShortcut>
</CommandItem>
))
)}
</BaseCommandGroup>
</>
);
}
@@ -1,118 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { useCommandPaletteStore } from "./store";
const reset = () => {
useCommandPaletteStore.setState({ open: false, search: "", pages: [] });
};
afterEach(reset);
describe("useCommandPaletteStore", () => {
it("starts closed with empty search and no pages", () => {
const state = useCommandPaletteStore.getState();
expect(state.open).toBe(false);
expect(state.search).toBe("");
expect(state.pages).toEqual([]);
});
it("setOpen(true) opens the palette without touching other fields", () => {
useCommandPaletteStore.setState({ search: "foo", pages: ["page1"] });
useCommandPaletteStore.getState().setOpen(true);
const state = useCommandPaletteStore.getState();
expect(state.open).toBe(true);
expect(state.search).toBe("foo");
expect(state.pages).toEqual(["page1"]);
});
it("setOpen(false) resets the entire store back to initial state", () => {
useCommandPaletteStore.setState({ open: true, search: "foo", pages: ["page1"] });
useCommandPaletteStore.getState().setOpen(false);
const state = useCommandPaletteStore.getState();
expect(state.open).toBe(false);
expect(state.search).toBe("");
expect(state.pages).toEqual([]);
});
it("setSearch updates only the search field", () => {
useCommandPaletteStore.getState().setSearch("query");
expect(useCommandPaletteStore.getState().search).toBe("query");
});
it("pushPage appends a page and clears search", () => {
useCommandPaletteStore.setState({ search: "leftover" });
useCommandPaletteStore.getState().pushPage("settings");
const state = useCommandPaletteStore.getState();
expect(state.pages).toEqual(["settings"]);
expect(state.search).toBe("");
});
it("pushPage stacks multiple pages in order", () => {
const { pushPage } = useCommandPaletteStore.getState();
pushPage("a");
pushPage("b");
pushPage("c");
expect(useCommandPaletteStore.getState().pages).toEqual(["a", "b", "c"]);
});
it("peekPage returns the top page (or undefined when empty)", () => {
expect(useCommandPaletteStore.getState().peekPage()).toBeUndefined();
useCommandPaletteStore.setState({ pages: ["a", "b"] });
expect(useCommandPaletteStore.getState().peekPage()).toBe("b");
});
it("popPage removes the last page and clears search", () => {
useCommandPaletteStore.setState({ pages: ["a", "b"], search: "x" });
useCommandPaletteStore.getState().popPage();
const state = useCommandPaletteStore.getState();
expect(state.pages).toEqual(["a"]);
expect(state.search).toBe("");
});
it("reset clears every state field", () => {
useCommandPaletteStore.setState({ open: true, search: "x", pages: ["a"] });
useCommandPaletteStore.getState().reset();
expect(useCommandPaletteStore.getState()).toMatchObject({ open: false, search: "", pages: [] });
});
describe("goBack", () => {
it("clears search first if present, leaving pages and open untouched", () => {
useCommandPaletteStore.setState({ open: true, search: "text", pages: ["a"] });
useCommandPaletteStore.getState().goBack();
const state = useCommandPaletteStore.getState();
expect(state.search).toBe("");
expect(state.pages).toEqual(["a"]);
expect(state.open).toBe(true);
});
it("pops the top page when no search and pages exist", () => {
useCommandPaletteStore.setState({ open: true, search: "", pages: ["a", "b"] });
useCommandPaletteStore.getState().goBack();
const state = useCommandPaletteStore.getState();
expect(state.pages).toEqual(["a"]);
expect(state.open).toBe(true);
});
it("closes the palette when no search and no pages", () => {
useCommandPaletteStore.setState({ open: true, search: "", pages: [] });
useCommandPaletteStore.getState().goBack();
expect(useCommandPaletteStore.getState().open).toBe(false);
});
});
});
@@ -1,59 +0,0 @@
import { create } from "zustand/react";
type CommandPaletteState = {
open: boolean;
search: string;
pages: string[];
};
type CommandPaletteActions = {
setOpen: (open: boolean) => void;
setSearch: (search: string) => void;
pushPage: (page: string) => void;
peekPage: () => string | undefined;
popPage: () => void;
reset: () => void;
goBack: () => void;
};
type CommandPaletteStore = CommandPaletteState & CommandPaletteActions;
const initialState: CommandPaletteState = {
open: false,
search: "",
pages: [],
};
export const useCommandPaletteStore = create<CommandPaletteStore>((set, get) => ({
...initialState,
setOpen: (open) => {
set({ open });
if (!open) set(initialState);
},
setSearch: (search) => set({ search }),
peekPage: () => get().pages[get().pages.length - 1],
pushPage: (page) => set((state) => ({ pages: [...state.pages, page], search: "" })),
popPage: () => set((state) => ({ pages: state.pages.slice(0, -1), search: "" })),
reset: () => set(initialState),
goBack: () => {
set((state) => {
if (state.search.length > 0) {
// If there's text, just clear the search
return { search: "" };
}
if (state.pages.length > 0) {
// If on a sub-page, go back
return { pages: state.pages.slice(0, -1), search: "" };
}
// If on first page, close
return { open: false, search: "", pages: [] };
});
},
}));
@@ -1,36 +0,0 @@
// @vitest-environment happy-dom
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { localeMap } from "@/libs/locale";
import { getLocaleOptions } from "./combobox";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("getLocaleOptions", () => {
it("returns one option per entry in localeMap", () => {
const options = getLocaleOptions();
expect(options).toHaveLength(Object.keys(localeMap).length);
});
it("uses the locale code as the value", () => {
const options = getLocaleOptions();
const values = options.map((opt) => opt.value);
expect(values).toContain("en-US");
expect(values).toContain("de-DE");
});
it("populates label and keywords with the same translated string", () => {
const options = getLocaleOptions();
const enUS = options.find((opt) => opt.value === "en-US");
expect(enUS?.label).toBeTruthy();
expect(enUS?.keywords).toEqual([enUS?.label]);
});
it("uses unique values for every option", () => {
const values = getLocaleOptions().map((opt) => opt.value);
expect(new Set(values).size).toBe(values.length);
});
});
@@ -1,36 +0,0 @@
import type { Locale } from "@reactive-resume/utils/locale";
import type { SingleComboboxProps } from "@/components/ui/combobox";
import { i18n } from "@lingui/core";
import { useLingui } from "@lingui/react";
import { Combobox } from "@/components/ui/combobox";
import { isLocale, loadLocale, localeMap, setLocaleServerFn } 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)],
}));
};
export function LocaleCombobox(props: Props) {
const { i18n } = useLingui();
const onLocaleChange = async (value: string | null) => {
if (!value || !isLocale(value)) return;
await Promise.all([loadLocale(value), setLocaleServerFn({ data: value })]);
window.location.reload();
};
return (
<Combobox
showClear={false}
defaultValue={i18n.locale}
options={getLocaleOptions()}
onValueChange={onLocaleChange}
{...props}
/>
);
}
@@ -1,404 +0,0 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { WritableDraft } from "immer";
import { t } from "@lingui/core/macro";
import { consumeEventIterator } from "@orpc/client";
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "@tanstack/react-router";
import { debounce, isEqual } from "es-toolkit";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { immer } from "zustand/middleware/immer";
import { create } from "zustand/react";
import { applyResumePatches, createResumePatches } from "@reactive-resume/utils/resume/patch";
import { orpc, streamClient } from "@/libs/orpc/client";
export type Resume = {
id: string;
name: string;
slug: string;
tags: string[];
data: ResumeData;
isLocked: boolean;
updatedAt: Date;
hasPassword?: boolean;
isPublic?: boolean;
};
type ResumeStoreState = {
resume: Resume | null;
resumeId?: string;
isReady: boolean;
};
type ResumeStoreActions = {
initialize: (resume: Resume | null) => void;
reset: () => void;
replaceResumeDraft: (resume: Resume) => void;
replaceResumeFromServer: (resume: Resume) => void;
updateResumeData: (fn: (draft: WritableDraft<ResumeData>) => void) => void;
patchResume: (fn: (draft: WritableDraft<Resume>) => void) => void;
mergeResumeMetadata: (resume: Resume) => void;
};
type ResumeStore = ResumeStoreState & ResumeStoreActions;
type Runtime = {
abortController: AbortController;
queryClient?: QueryClient;
baselineData?: ResumeData;
hasPendingLocalChanges: boolean;
syncErrorToastId?: string | number;
syncResume: ReturnType<typeof debounce<(resume: Resume) => Promise<void>>>;
beforeUnloadHandler?: () => void;
};
const SAVE_DEBOUNCE_MS = 500;
const runtimes = new Map<string, Runtime>();
let lockedToastId: string | number | undefined;
function getResumeQueryKey(id: string): QueryKey {
return orpc.resume.getById.queryOptions({ input: { id } }).queryKey as QueryKey;
}
function cloneResumeData(data: ResumeData): ResumeData {
return structuredClone(data);
}
function setRuntimeBaseline(resume: Resume) {
const runtime = getRuntime(resume.id);
runtime.baselineData = cloneResumeData(resume.data);
runtime.hasPendingLocalChanges = false;
}
function createRuntime(): Runtime {
const abortController = new AbortController();
const syncResume = debounce(
async (resume: Resume) => {
const runtime = runtimes.get(resume.id);
if (!runtime) return;
const baselineData = runtime.baselineData ?? cloneResumeData(resume.data);
const operations = createResumePatches(baselineData, resume.data);
if (operations.length === 0) {
runtime.hasPendingLocalChanges = false;
return;
}
const submittedData = cloneResumeData(resume.data);
try {
const updated = (await orpc.resume.patch.call(
{ id: resume.id, operations },
{ signal: abortController.signal },
)) as Resume;
runtime.queryClient?.setQueryData(getResumeQueryKey(resume.id), updated);
runtime.baselineData = cloneResumeData(updated.data);
const currentResume = useResumeStore.getState().resume;
const currentDataStillMatchesSubmission =
currentResume?.id === resume.id && isEqual(currentResume.data, submittedData);
if (currentDataStillMatchesSubmission) {
runtime.hasPendingLocalChanges = false;
useResumeStore.getState().replaceResumeFromServer(updated);
} else {
runtime.hasPendingLocalChanges = true;
useResumeStore.getState().mergeResumeMetadata(updated);
syncCurrentResume(resume.id);
}
if (runtime.syncErrorToastId === undefined) return;
toast.dismiss(runtime.syncErrorToastId);
runtime.syncErrorToastId = undefined;
} catch (error: unknown) {
if (error instanceof DOMException && error.name === "AbortError") return;
runtime.syncErrorToastId = toast.error(t`Your latest changes could not be saved.`, {
id: runtime.syncErrorToastId,
duration: Number.POSITIVE_INFINITY,
});
}
},
SAVE_DEBOUNCE_MS,
{ signal: abortController.signal },
);
const runtime: Runtime = {
abortController,
hasPendingLocalChanges: false,
syncResume,
};
if (typeof window !== "undefined") {
runtime.beforeUnloadHandler = () => runtime.syncResume.flush();
window.addEventListener("beforeunload", runtime.beforeUnloadHandler);
}
return runtime;
}
function getRuntime(id: string): Runtime {
const existing = runtimes.get(id);
if (existing) return existing;
const runtime = createRuntime();
runtimes.set(id, runtime);
return runtime;
}
function bindRuntimeQueryClient(id: string, queryClient: QueryClient) {
getRuntime(id).queryClient = queryClient;
}
function hasPendingLocalChanges(id: string): boolean {
return getRuntime(id).hasPendingLocalChanges;
}
function cleanupRuntime(id: string) {
const runtime = runtimes.get(id);
if (!runtime) return;
runtime.syncResume.flush();
runtime.abortController.abort();
if (runtime.beforeUnloadHandler && typeof window !== "undefined") {
window.removeEventListener("beforeunload", runtime.beforeUnloadHandler);
}
runtimes.delete(id);
}
function syncCurrentResume(id: string) {
const resume = useResumeStore.getState().resume;
if (!resume || resume.id !== id) return;
getRuntime(id).syncResume(resume);
}
export const useResumeStore = create<ResumeStore>()(
immer((set, get) => ({
resume: null,
resumeId: undefined,
isReady: false,
initialize: (resume) => {
if (resume) setRuntimeBaseline(resume);
set((state) => {
state.resume = resume;
state.resumeId = resume?.id;
state.isReady = resume !== null;
});
},
reset: () => {
set((state) => {
state.resume = null;
state.resumeId = undefined;
state.isReady = false;
});
},
replaceResumeDraft: (resume) => {
set((state) => {
state.resume = resume;
state.resumeId = resume.id;
state.isReady = true;
});
},
replaceResumeFromServer: (resume) => {
setRuntimeBaseline(resume);
set((state) => {
state.resume = resume;
state.resumeId = resume.id;
state.isReady = true;
});
},
patchResume: (fn) => {
set((state) => {
if (!state.resume) return;
fn(state.resume as WritableDraft<Resume>);
});
},
mergeResumeMetadata: (resume) => {
set((state) => {
if (!state.resume || state.resume.id !== resume.id) return;
state.resume.name = resume.name;
state.resume.slug = resume.slug;
state.resume.tags = resume.tags;
state.resume.isLocked = resume.isLocked;
state.resume.updatedAt = resume.updatedAt;
state.resume.hasPassword = resume.hasPassword;
state.resume.isPublic = resume.isPublic;
});
},
updateResumeData: (fn) => {
const currentResume = get().resume;
if (!currentResume) return;
if (currentResume.isLocked) {
lockedToastId = toast.error(t`This resume is locked and cannot be updated.`, {
id: lockedToastId,
});
return;
}
set((state) => {
if (!state.resume) return;
fn(state.resume.data as WritableDraft<ResumeData>);
});
getRuntime(currentResume.id).hasPendingLocalChanges = true;
syncCurrentResume(currentResume.id);
},
})),
);
export function useInitializeResumeStore() {
return useResumeStore((state) => state.initialize);
}
function useResetResumeStore() {
return useResumeStore((state) => state.reset);
}
export function useMergeResumeMetadata() {
return useResumeStore((state) => state.mergeResumeMetadata);
}
export function usePatchResume() {
return useResumeStore((state) => state.patchResume);
}
function useBuilderResumeSelector<T>(selector: (resume: Resume) => T): T | undefined {
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
return useResumeStore((state) => {
if (!resumeId || !state.resume || state.resume.id !== resumeId) return undefined;
return selector(state.resume);
});
}
export function useCurrentBuilderResumeSelector<T>(selector: (resume: Resume) => T): T {
const selected = useBuilderResumeSelector(selector);
if (selected === undefined) throw new Error("Resume data is required before rendering this component.");
return selected;
}
export function useResume(): Resume | undefined {
return useBuilderResumeSelector((resume) => resume);
}
export function useCurrentResume(): Resume {
const resume = useResume();
if (!resume) throw new Error("Resume data is required before rendering this component.");
return resume;
}
export function useResumeData(): ResumeData | undefined {
return useBuilderResumeSelector((resume) => resume.data);
}
export function useUpdateResumeData() {
const queryClient = useQueryClient();
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
const updateResumeData = useResumeStore((state) => state.updateResumeData);
return useCallback(
(fn: (draft: WritableDraft<ResumeData>) => void) => {
if (!resumeId) return;
bindRuntimeQueryClient(resumeId, queryClient);
updateResumeData(fn);
},
[queryClient, resumeId, updateResumeData],
);
}
export function useResumeUpdateSubscription() {
const queryClient = useQueryClient();
const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer);
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
const [_retryNonce, setRetryNonce] = useState(0);
useEffect(() => {
if (!resumeId) return;
bindRuntimeQueryClient(resumeId, queryClient);
let didCancel = false;
let retryTimer: number | undefined;
const cancel = consumeEventIterator(streamClient.resume.updates.subscribe({ id: resumeId }), {
onEvent: async () => {
try {
const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume;
if (hasPendingLocalChanges(resumeId)) {
const runtime = getRuntime(resumeId);
const currentResume = useResumeStore.getState().resume;
const baselineData = runtime.baselineData ?? currentResume?.data;
if (currentResume && baselineData) {
const localOperations = createResumePatches(baselineData, currentResume.data);
const mergedData = applyResumePatches(resume.data, localOperations);
runtime.baselineData = cloneResumeData(resume.data);
runtime.hasPendingLocalChanges = localOperations.length > 0;
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
useResumeStore.getState().replaceResumeDraft({ ...resume, data: mergedData });
syncCurrentResume(resumeId);
} else {
runtime.baselineData = cloneResumeData(resume.data);
useResumeStore.getState().mergeResumeMetadata(resume);
}
return;
}
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
replaceResumeFromServer(resume);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
console.warn("Failed to refresh resume after update event:", error);
}
},
onError: (error) => {
if (didCancel) return;
console.warn("Resume update stream failed, reconnecting:", error);
retryTimer = window.setTimeout(() => setRetryNonce((value) => value + 1), 2500);
},
});
return () => {
didCancel = true;
if (retryTimer) window.clearTimeout(retryTimer);
void cancel().catch(() => {});
};
}, [queryClient, replaceResumeFromServer, resumeId]);
}
export function useResumeCleanup() {
const params = useParams({ strict: false }) as { resumeId?: string };
const resumeId = params.resumeId;
const reset = useResetResumeStore();
useEffect(() => {
if (!resumeId) return;
return () => {
cleanupRuntime(resumeId);
reset();
};
}, [resumeId, reset]);
}
@@ -1,198 +0,0 @@
import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from "pdfjs-dist";
import type { ReactNode } from "react";
import type { PreviewPageSize } from "./preview.shared";
import { AnnotationMode, GlobalWorkerOptions, getDocument, RenderingCancelledException } from "pdfjs-dist";
import { useEffect, useRef, useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import { DEFAULT_PDF_PAGE_SIZE, getPreviewCanvasScale, getScaledPreviewPageSize } from "./preview.shared";
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
type PdfCanvasDocumentProps = {
children: (document: PDFDocumentProxy) => ReactNode;
file: Blob;
onLoadSuccess: (document: PDFDocumentProxy) => void;
};
type PdfCanvasPageProps = {
className?: string;
document: PDFDocumentProxy;
onLoadSuccess: (pageNumber: number, pageSize: PreviewPageSize) => void;
onRenderSuccess?: () => void;
pageNumber: number;
pageScale: number;
pageSize?: PreviewPageSize;
showPageNumbers: boolean;
totalPages: number;
};
const isRenderingCancelledError = (error: unknown) =>
error instanceof RenderingCancelledException ||
(typeof error === "object" && error !== null && "name" in error && error.name === "RenderingCancelledException");
export function PdfCanvasDocument({ children, file, onLoadSuccess }: PdfCanvasDocumentProps) {
const [document, setDocument] = useState<PDFDocumentProxy | null>(null);
const onLoadSuccessRef = useRef(onLoadSuccess);
useEffect(() => {
onLoadSuccessRef.current = onLoadSuccess;
}, [onLoadSuccess]);
useEffect(() => {
let isCancelled = false;
let loadingTask: PDFDocumentLoadingTask | undefined;
let loadedDocument: PDFDocumentProxy | undefined;
const loadDocument = async () => {
setDocument(null);
const arrayBuffer = await file.arrayBuffer();
if (isCancelled) return;
loadingTask = getDocument({ data: new Uint8Array(arrayBuffer) });
const pdfDocument = await loadingTask.promise;
if (isCancelled) {
void pdfDocument.destroy();
return;
}
loadedDocument = pdfDocument;
setDocument(pdfDocument);
onLoadSuccessRef.current(pdfDocument);
};
void loadDocument().catch((error: unknown) => {
if (isCancelled) return;
console.error("Failed to load PDF document", error);
});
return () => {
isCancelled = true;
if (loadedDocument) {
void loadedDocument.destroy();
} else if (loadingTask) {
void loadingTask.destroy();
}
};
}, [file]);
if (!document) return null;
return children(document);
}
export function PdfCanvasPage({
className,
document,
onLoadSuccess,
onRenderSuccess,
pageNumber,
pageScale,
pageSize = DEFAULT_PDF_PAGE_SIZE,
showPageNumbers,
totalPages,
}: PdfCanvasPageProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const onLoadSuccessRef = useRef(onLoadSuccess);
const onRenderSuccessRef = useRef(onRenderSuccess);
const scaledPageSize = getScaledPreviewPageSize(pageSize, pageScale);
useEffect(() => {
onLoadSuccessRef.current = onLoadSuccess;
onRenderSuccessRef.current = onRenderSuccess;
}, [onLoadSuccess, onRenderSuccess]);
useEffect(() => {
let isCancelled = false;
let renderTask: RenderTask | undefined;
const renderPage = async () => {
const canvas = canvasRef.current;
if (!canvas) return;
const page = await document.getPage(pageNumber);
try {
if (isCancelled) {
page.cleanup();
return;
}
const baseViewport = page.getViewport({ scale: 1 });
const pageSize = { height: baseViewport.height, width: baseViewport.width };
onLoadSuccessRef.current(pageNumber, pageSize);
const width = baseViewport.width * pageScale;
const height = baseViewport.height * pageScale;
const renderScale = getPreviewCanvasScale(width, height);
const canvasContext = canvas.getContext("2d");
if (!canvasContext) return;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
canvas.width = Math.floor(width * renderScale);
canvas.height = Math.floor(height * renderScale);
canvasContext.setTransform(1, 0, 0, 1, 0, 0);
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
const viewport = page.getViewport({ scale: pageScale });
const transform = [renderScale, 0, 0, renderScale, 0, 0];
renderTask = page.render({
canvas,
canvasContext,
viewport,
transform,
annotationMode: AnnotationMode.DISABLE,
background: "white",
});
await renderTask.promise;
renderTask = undefined;
if (!isCancelled) onRenderSuccessRef.current?.();
} finally {
page.cleanup();
}
};
void renderPage().catch((error: unknown) => {
if (isRenderingCancelledError(error)) return;
console.error(`Failed to render PDF page ${pageNumber}`, error);
});
return () => {
isCancelled = true;
if (renderTask) {
renderTask.cancel();
}
};
}, [document, pageNumber, pageScale]);
return (
<figure className="shrink-0">
{showPageNumbers ? (
<figcaption className="mb-1 font-medium text-[0.625rem] text-muted-foreground">
Page {pageNumber} of {totalPages}
</figcaption>
) : null}
<div
role="img"
aria-label={`Resume page ${pageNumber} of ${totalPages}`}
style={scaledPageSize}
className={cn("aspect-page overflow-hidden rounded-md", className)}
>
<canvas ref={canvasRef} />
</div>
</figure>
);
}
@@ -1,114 +0,0 @@
// @vitest-environment happy-dom
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 { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { ResumePreviewClient } from "./preview.browser";
const previewMock = vi.hoisted(() => ({
builderResumeData: undefined as ResumeData | undefined,
localizedResumeData: [] as Array<ResumeData | undefined>,
toBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
}));
const resumeDataWithPageCount = (pageCount: number): ResumeData => ({
...sampleResumeData,
metadata: {
...sampleResumeData.metadata,
layout: {
...sampleResumeData.metadata.layout,
pages: sampleResumeData.metadata.layout.pages.slice(0, pageCount),
},
},
});
vi.mock("@react-pdf/renderer", () => ({
pdf: () => ({ toBlob: previewMock.toBlob }),
}));
vi.mock("@/libs/resume/pdf-document", () => ({
useLocalizedResumeDocument: (data?: ResumeData) => {
previewMock.localizedResumeData.push(data);
return data ? { type: "resume-document", data } : null;
},
}));
vi.mock("./builder-resume-draft", () => ({
useResumeData: () => previewMock.builderResumeData,
}));
vi.mock("./pdf-canvas", async () => {
const React = await import("react");
const pdfDocument = { numPages: 1 };
return {
PdfCanvasDocument: ({
children,
onLoadSuccess,
}: {
children: (document: typeof pdfDocument) => React.ReactNode;
onLoadSuccess: (document: typeof pdfDocument) => void;
}) => {
React.useEffect(() => {
onLoadSuccess(pdfDocument);
}, [onLoadSuccess]);
return React.createElement(React.Fragment, null, children(pdfDocument));
},
PdfCanvasPage: ({
onLoadSuccess,
onRenderSuccess,
pageNumber,
totalPages,
}: {
onLoadSuccess: (pageNumber: number, pageSize: { height: number; width: number }) => void;
onRenderSuccess?: () => void;
pageNumber: number;
totalPages: number;
}) => {
React.useEffect(() => {
onLoadSuccess(pageNumber, { height: 200, width: 100 });
onRenderSuccess?.();
}, [onLoadSuccess, onRenderSuccess, pageNumber]);
return React.createElement(
"div",
{ role: "img", "aria-label": `Resume page ${pageNumber} of ${totalPages}` },
"Rendered page",
);
},
};
});
describe("ResumePreviewClient", () => {
beforeEach(() => {
previewMock.builderResumeData = undefined;
previewMock.localizedResumeData = [];
previewMock.toBlob.mockReset();
previewMock.toBlob.mockImplementation(async () => new Blob(["%PDF"], { type: "application/pdf" }));
});
it("renders a loading placeholder for each builder layout page while the PDF is generated", () => {
previewMock.builderResumeData = resumeDataWithPageCount(3);
previewMock.toBlob.mockImplementation(() => new Promise<Blob>(() => {}));
render(<ResumePreviewClient pageGap={16} pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(3);
});
it("renders from explicit resume data when no builder resume is active", async () => {
render(
<ResumePreviewClient data={sampleResumeData} pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />,
);
expect(await screen.findByRole("img", { name: "Resume page 1 of 1" })).toBeTruthy();
await waitFor(() => {
expect(previewMock.toBlob).toHaveBeenCalledTimes(1);
});
expect(previewMock.localizedResumeData).toContain(sampleResumeData);
});
});
@@ -1,219 +0,0 @@
import type { CSSProperties } from "react";
import type { PreviewPageSize, ResolvedResumePreviewProps } from "./preview.shared";
import { pdf } from "@react-pdf/renderer";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import { useLocalizedResumeDocument } from "@/libs/resume/pdf-document";
import { useResumeData } from "./builder-resume-draft";
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
import { getResumePreviewGapValue, getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
type PreviewPdf = {
file: Blob;
id: number;
numPages: number;
pageSizes: Record<number, PreviewPageSize>;
phase: "active" | "exiting" | "staged";
renderedPages: number[];
};
const UPDATE_DEBOUNCE_MS = 100;
const CROSSFADE_DURATION_MS = 180;
const createPreviewPdf = (file: Blob, id: number, hasExistingPreview: boolean): PreviewPdf => ({
file,
id,
numPages: 0,
pageSizes: {},
phase: hasExistingPreview ? "staged" : "active",
renderedPages: [],
});
const addPreviewLayer = (layers: PreviewPdf[], nextPdf: PreviewPdf) => {
const activeLayers = layers.filter((layer) => layer.phase === "active");
return activeLayers.length === 0 ? [nextPdf] : [...activeLayers, nextPdf];
};
const getActivePreviewLayer = (layers: PreviewPdf[]) => layers.find((layer) => layer.phase === "active") ?? null;
const setPreviewPageCount = (layers: PreviewPdf[], layerId: number, numPages: number) =>
layers.map((layer) => (layer.id === layerId ? { ...layer, numPages } : layer));
const setPreviewPageSize = (layers: PreviewPdf[], layerId: number, pageNumber: number, pageSize: PreviewPageSize) =>
layers.map((layer) =>
layer.id === layerId
? {
...layer,
pageSizes: {
...layer.pageSizes,
[pageNumber]: pageSize,
},
}
: layer,
);
const markPreviewPageRendered = (layers: PreviewPdf[], layerId: number, pageNumber: number) => {
let shouldPromoteLayer = false;
const nextLayers = layers.map((layer) => {
if (layer.id !== layerId || layer.renderedPages.includes(pageNumber)) return layer;
const renderedPages = [...layer.renderedPages, pageNumber];
const nextLayer = { ...layer, renderedPages };
if (layer.phase === "staged" && renderedPages.length >= layer.numPages) {
shouldPromoteLayer = true;
return { ...nextLayer, phase: "active" as const };
}
return nextLayer;
});
if (!shouldPromoteLayer) return nextLayers;
return nextLayers.map((layer) => {
if (layer.id === layerId) return layer;
if (layer.phase === "active") return { ...layer, phase: "exiting" as const };
return layer;
});
};
const removePreviewLayer = (layers: PreviewPdf[], layerId: number) => layers.filter((layer) => layer.id !== layerId);
export function ResumePreviewClient({
className,
data,
pageGap = 16,
pageLayout,
pageScale,
pageClassName,
showPageNumbers,
}: ResolvedResumePreviewProps) {
const builderResumeData = useResumeData();
const resumeData = data ?? builderResumeData;
const resumeDocument = useLocalizedResumeDocument(resumeData);
const [previewLayers, setPreviewLayers] = useState<PreviewPdf[]>([]);
const pdfIdRef = useRef(0);
const requestIdRef = useRef(0);
const hasPreviewRef = useRef(false);
useEffect(() => {
if (!resumeDocument) return;
let cancelled = false;
const requestId = ++requestIdRef.current;
const delay = hasPreviewRef.current ? UPDATE_DEBOUNCE_MS : 0;
const generatePdfPreview = async () => {
try {
const blob = await pdf(resumeDocument).toBlob();
if (cancelled || requestId !== requestIdRef.current) return;
const nextPdf = createPreviewPdf(blob, pdfIdRef.current++, hasPreviewRef.current);
hasPreviewRef.current = true;
setPreviewLayers((current) => addPreviewLayer(current, nextPdf));
} catch {}
};
const timeoutId = window.setTimeout(() => {
void generatePdfPreview();
}, delay);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [resumeDocument]);
if (!resumeData) return null;
const visiblePdf = getActivePreviewLayer(previewLayers);
const resolvedPageGap = getResumePreviewGapValue(pageGap);
if (!visiblePdf) {
return (
<ResumePreviewLoader
pageCount={getResumePreviewPageCount(resumeData)}
pageClassName={pageClassName}
pageGap={pageGap}
pageLayout={pageLayout}
pageScale={pageScale}
showPageNumbers={showPageNumbers}
/>
);
}
return (
<div className={cn("grid", className)}>
<AnimatePresence initial={false}>
{previewLayers.map((visiblePdf) => (
<motion.div
key={visiblePdf.id}
aria-hidden={visiblePdf.phase !== "active"}
style={{ "--resume-preview-page-gap": resolvedPageGap } as CSSProperties}
className={cn("col-start-1 row-start-1", visiblePdf.phase !== "active" && "pointer-events-none")}
initial={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
animate={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
exit={{ opacity: 0 }}
transition={{ duration: CROSSFADE_DURATION_MS / 1000, ease: "easeOut" }}
onAnimationComplete={() => {
if (visiblePdf.phase !== "exiting") return;
setPreviewLayers((current) => removePreviewLayer(current, visiblePdf.id));
}}
>
<PdfCanvasDocument
file={visiblePdf.file}
onLoadSuccess={(document) => {
setPreviewLayers((current) => setPreviewPageCount(current, visiblePdf.id, document.numPages));
}}
>
{(document) => (
<div
className={cn(
"flex justify-start gap-(--resume-preview-page-gap)",
pageLayout === "horizontal" ? "flex-row items-start" : "flex-col items-center",
)}
>
{Array.from({ length: visiblePdf.numPages }, (_, index) => {
const pageNumber = index + 1;
const totalPages = visiblePdf.numPages;
const pageSize = visiblePdf.pageSizes[pageNumber];
return (
<PdfCanvasPage
key={`${visiblePdf.id}-${pageNumber}`}
document={document}
pageSize={pageSize}
pageNumber={pageNumber}
pageScale={pageScale}
totalPages={totalPages}
className={pageClassName}
showPageNumbers={showPageNumbers}
onLoadSuccess={(_, pageSize) => {
setPreviewLayers((current) =>
setPreviewPageSize(current, visiblePdf.id, pageNumber, pageSize),
);
}}
onRenderSuccess={() => {
if (visiblePdf.phase !== "staged") return;
setPreviewLayers((current) => markPreviewPageRendered(current, visiblePdf.id, pageNumber));
}}
/>
);
})}
</div>
)}
</PdfCanvasDocument>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
@@ -1,102 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import {
DEFAULT_PDF_PAGE_SIZE,
getPreviewCanvasScale,
getResumePreviewGapValue,
getScaledPreviewPageSize,
normalizeResumePreviewProps,
} from "./preview.shared";
describe("normalizeResumePreviewProps", () => {
it("applies the documented defaults when fields are omitted", () => {
const result = normalizeResumePreviewProps({});
expect(result).toMatchObject({
pageGap: 16,
pageLayout: "horizontal",
pageScale: 1,
showPageNumbers: false,
});
});
it("preserves supplied values and forwards extra props (className, data)", () => {
const result = normalizeResumePreviewProps({
className: "preview-class",
pageGap: 16,
pageLayout: "vertical",
pageScale: 1.5,
showPageNumbers: true,
});
expect(result.className).toBe("preview-class");
expect(result.pageGap).toBe(16);
expect(result.pageLayout).toBe("vertical");
expect(result.pageScale).toBe(1.5);
expect(result.showPageNumbers).toBe(true);
});
});
describe("getScaledPreviewPageSize", () => {
it("multiplies both dimensions by the scale", () => {
const result = getScaledPreviewPageSize({ width: 100, height: 200 }, 2);
expect(result).toEqual({ width: 200, height: 400 });
});
it("returns the default A4 page size unchanged when scaled by 1", () => {
expect(getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, 1)).toEqual(DEFAULT_PDF_PAGE_SIZE);
});
it("supports fractional scaling", () => {
const result = getScaledPreviewPageSize({ width: 100, height: 200 }, 0.5);
expect(result).toEqual({ width: 50, height: 100 });
});
});
describe("getResumePreviewGapValue", () => {
it("adds px units for numeric custom-property gap values", () => {
expect(getResumePreviewGapValue(96)).toBe("96px");
});
it("preserves explicit zero gap", () => {
expect(getResumePreviewGapValue(0)).toBe(0);
});
it("preserves string gap values", () => {
expect(getResumePreviewGapValue("1rem")).toBe("1rem");
});
});
const setDevicePixelRatio = (value: number) => {
Object.defineProperty(window, "devicePixelRatio", {
writable: true,
configurable: true,
value,
});
};
afterEach(() => {
setDevicePixelRatio(1);
});
describe("getPreviewCanvasScale", () => {
it("returns the desired render scale (4x) for small pages", () => {
setDevicePixelRatio(1);
// width * height * 4 * 4 = 100 * 100 * 16 = 160_000 ≪ 16_777_216 budget
expect(getPreviewCanvasScale(100, 100)).toBe(4);
});
it("uses devicePixelRatio when it exceeds the desired 4x scale", () => {
setDevicePixelRatio(8);
// 50*50*8*8 = 160_000 ≪ budget, so we keep the 8x devicePixelRatio
expect(getPreviewCanvasScale(50, 50)).toBe(8);
});
it("clamps the scale when the page would exceed the canvas pixel budget", () => {
setDevicePixelRatio(1);
const scale = getPreviewCanvasScale(2000, 3000);
// Should NOT exceed the 4x desired scale and must satisfy the pixel budget.
expect(scale).toBeLessThan(4);
expect(scale * scale * 2000 * 3000).toBeLessThanOrEqual(16_777_216 + 1);
});
});
@@ -1,50 +0,0 @@
// @vitest-environment happy-dom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import {
DEFAULT_PDF_PAGE_SIZE,
getResumePreviewPageCount,
getScaledPreviewPageSize,
ResumePreviewLoader,
} from "./preview.shared";
describe("ResumePreviewLoader", () => {
it("uses the same scaled page dimensions as the preview page", () => {
const pageScale = 1.25;
const expectedSize = getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, pageScale);
const { container } = render(<ResumePreviewLoader pageScale={pageScale} />);
const page = container.querySelector("figure > div") as HTMLElement | null;
expect(Number.parseFloat(page?.style.height ?? "0")).toBeCloseTo(expectedSize.height);
expect(Number.parseFloat(page?.style.width ?? "0")).toBeCloseTo(expectedSize.width);
});
it("renders one loading placeholder for each resume layout page", () => {
const pageCount = sampleResumeData.metadata.layout.pages.length;
render(<ResumePreviewLoader pageCount={pageCount} />);
expect(screen.getAllByRole("img", { name: /Loading resume page/ })).toHaveLength(pageCount);
});
it("writes numeric page gaps as valid CSS custom-property lengths", () => {
const { container } = render(<ResumePreviewLoader pageGap={96} />);
expect((container.firstElementChild as HTMLElement).style.getPropertyValue("--resume-preview-page-gap")).toBe(
"96px",
);
});
});
describe("getResumePreviewPageCount", () => {
it("uses the resume layout page count when resume data is available", () => {
expect(getResumePreviewPageCount(sampleResumeData)).toBe(sampleResumeData.metadata.layout.pages.length);
});
it("falls back to one page without resume data", () => {
expect(getResumePreviewPageCount()).toBe(1);
});
});
@@ -1,118 +0,0 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { CSSProperties } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
export type ResumePreviewProps = {
className?: string;
data?: ResumeData;
pageGap?: CSSProperties["gap"];
pageLayout?: "horizontal" | "vertical";
pageScale?: number;
pageClassName?: string;
showPageNumbers?: boolean;
};
export type ResolvedResumePreviewProps = ResumePreviewProps & {
pageLayout: "horizontal" | "vertical";
pageScale: number;
showPageNumbers: boolean;
};
export type PreviewPageSize = {
height: number;
width: number;
};
type ResumePreviewLoaderProps = Pick<ResumePreviewProps, "pageClassName" | "showPageNumbers"> & {
pageCount?: number;
pageGap?: CSSProperties["gap"];
pageLayout?: "horizontal" | "vertical";
pageScale?: number;
};
const PDF_PAGE_RENDER_SCALE = 4;
const MAX_PREVIEW_CANVAS_PIXELS = 16_777_216; // 4096 * 4096
export const DEFAULT_PDF_PAGE_SIZE: PreviewPageSize = {
height: 841.89,
width: 595.28,
};
export const normalizeResumePreviewProps = ({
pageGap = 16,
pageLayout = "horizontal",
pageScale = 1,
showPageNumbers = false,
...props
}: ResumePreviewProps): ResolvedResumePreviewProps => ({
...props,
pageGap,
pageLayout,
pageScale,
showPageNumbers,
});
export const getPreviewCanvasScale = (width: number, height: number) => {
const devicePixelRatio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
const desiredScale = Math.max(PDF_PAGE_RENDER_SCALE, devicePixelRatio);
const desiredPixels = width * height * desiredScale * desiredScale;
if (desiredPixels <= MAX_PREVIEW_CANVAS_PIXELS) return desiredScale;
return Math.sqrt(MAX_PREVIEW_CANVAS_PIXELS / (width * height));
};
export const getScaledPreviewPageSize = (pageSize: PreviewPageSize, pageScale: number): PreviewPageSize => ({
height: pageSize.height * pageScale,
width: pageSize.width * pageScale,
});
export const getResumePreviewGapValue = (pageGap: CSSProperties["gap"]) =>
typeof pageGap === "number" && pageGap !== 0 ? `${pageGap}px` : pageGap;
export const getResumePreviewPageCount = (data?: ResumeData) => Math.max(1, data?.metadata.layout.pages.length ?? 1);
export function ResumePreviewLoader({
pageCount = 1,
pageClassName,
pageGap = 16,
pageLayout = "horizontal",
pageScale = 1,
showPageNumbers = false,
}: ResumePreviewLoaderProps) {
const pageSize = getScaledPreviewPageSize(DEFAULT_PDF_PAGE_SIZE, pageScale);
const resolvedPageGap = getResumePreviewGapValue(pageGap);
return (
<div
style={{ "--resume-preview-page-gap": resolvedPageGap } as CSSProperties}
className={cn(
"flex justify-start gap-(--resume-preview-page-gap)",
pageLayout === "horizontal" ? "flex-row items-start" : "flex-col items-center",
)}
>
{Array.from({ length: pageCount }, (_, index) => {
const pageNumber = index + 1;
return (
<figure key={pageNumber} className="shrink-0">
{showPageNumbers ? (
<figcaption className="mb-1 font-medium text-[0.625rem] text-muted-foreground">
Page {pageNumber} of {pageCount}
</figcaption>
) : null}
<div
role="img"
aria-label={`Loading resume page ${pageNumber} of ${pageCount}`}
style={pageSize}
className={cn("aspect-page overflow-hidden rounded-md bg-white", pageClassName)}
>
<Spinner className="size-10" />
</div>
</figure>
);
})}
</div>
);
}
@@ -1,38 +0,0 @@
import type { ResumePreviewProps } from "./preview.shared";
import { lazy, Suspense } from "react";
import { useIsClient } from "usehooks-ts";
import { useResumeData } from "./builder-resume-draft";
import { getResumePreviewPageCount, normalizeResumePreviewProps, ResumePreviewLoader } from "./preview.shared";
const ResumePreviewClient = lazy(() =>
import("./preview.browser").then((module) => ({ default: module.ResumePreviewClient })),
);
export type { ResumePreviewProps };
export function ResumePreview(props: ResumePreviewProps) {
const isClient = useIsClient();
const resolvedProps = normalizeResumePreviewProps(props);
const builderResumeData = useResumeData();
const resumeData = resolvedProps.data ?? builderResumeData;
const pageCount = getResumePreviewPageCount(resumeData);
if (!isClient) return null;
return (
<Suspense
fallback={
<ResumePreviewLoader
pageCount={pageCount}
pageClassName={resolvedProps.pageClassName}
pageGap={resolvedProps.pageGap}
pageLayout={resolvedProps.pageLayout}
pageScale={resolvedProps.pageScale}
showPageNumbers={resolvedProps.showPageNumbers}
/>
}
>
<ResumePreviewClient {...resolvedProps} />
</Suspense>
);
}
@@ -1,29 +0,0 @@
import type { SingleComboboxProps } from "@/components/ui/combobox";
import { useLingui } from "@lingui/react";
import { useRouter } from "@tanstack/react-router";
import { Combobox } from "@/components/ui/combobox";
import { isTheme, setThemeServerFn, themeMap } from "@/libs/theme";
import { useTheme } from "./provider";
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
export function ThemeCombobox(props: Props) {
const router = useRouter();
const { i18n } = useLingui();
const { theme, setTheme } = useTheme();
const options = Object.entries(themeMap).map(([value, label]) => ({
value,
label: i18n.t(label),
keywords: [i18n.t(label)],
}));
const onThemeChange = async (value: string | null) => {
if (!value || !isTheme(value)) return;
await setThemeServerFn({ data: value });
setTheme(value);
void router.invalidate();
};
return <Combobox {...props} showClear={false} options={options} defaultValue={theme} onValueChange={onThemeChange} />;
}
@@ -1,32 +0,0 @@
// @vitest-environment happy-dom
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
// ThemeProvider depends on TanStack Start helpers + server fn — stub them.
vi.mock("@tanstack/react-router", () => ({
useRouter: () => ({ invalidate: vi.fn() }),
}));
vi.mock("@/libs/theme", () => ({
setThemeServerFn: vi.fn().mockResolvedValue(undefined),
}));
const { ThemeProvider, useTheme } = await import("./provider");
describe("useTheme", () => {
it("throws when used outside ThemeProvider", () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => renderHook(() => useTheme())).toThrow(/useTheme must be used within a ThemeProvider/);
consoleError.mockRestore();
});
it("returns the theme and helpers when wrapped in ThemeProvider", () => {
const { result } = renderHook(() => useTheme(), {
wrapper: ({ children }) => <ThemeProvider theme="dark">{children}</ThemeProvider>,
});
expect(result.current.theme).toBe("dark");
expect(typeof result.current.setTheme).toBe("function");
expect(typeof result.current.toggleTheme).toBe("function");
});
});
@@ -1,51 +0,0 @@
import type { PropsWithChildren } from "react";
import type { Theme } from "@/libs/theme";
import { useRouter } from "@tanstack/react-router";
import { createContext, use } from "react";
import { setThemeServerFn } from "@/libs/theme";
type ThemeContextValue = {
theme: Theme;
setTheme: (value: Theme, options?: { playSound?: boolean }) => void;
toggleTheme: (options?: { playSound?: boolean }) => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
type Props = PropsWithChildren<{ theme: Theme }>;
export function ThemeProvider({ children, theme }: Props) {
const router = useRouter();
async function setTheme(value: Theme, options: { playSound?: boolean } = {}) {
const { playSound = true } = options;
document.documentElement.classList.toggle("dark", value === "dark");
await setThemeServerFn({ data: value });
void router.invalidate();
if (!playSound) return;
try {
const soundClip = value === "dark" ? "/sounds/switch-off.mp3" : "/sounds/switch-on.mp3";
const audio = new Audio(soundClip);
await audio.play();
} catch {
// ignore errors
}
}
function toggleTheme(options: { playSound?: boolean } = {}) {
void setTheme(theme === "dark" ? "light" : "dark", options);
}
return <ThemeContext value={{ theme, setTheme, toggleTheme }}>{children}</ThemeContext>;
}
export function useTheme() {
const value = use(ThemeContext);
if (!value) throw new Error("useTheme must be used within a ThemeProvider");
return value;
}
@@ -1,77 +0,0 @@
// @vitest-environment happy-dom
import { fireEvent, render } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
const toggleTheme = vi.hoisted(() => vi.fn());
vi.mock("./provider", () => ({
useTheme: () => ({ theme: "light", setTheme: vi.fn(), toggleTheme }),
}));
const { ThemeToggleButton } = await import("./toggle-button");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
afterEach(() => {
toggleTheme.mockReset();
// Reset prefers-reduced-motion + startViewTransition stub between tests.
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})),
});
// biome-ignore lint/suspicious/noExplicitAny: removing a non-standard API stub
(document as any).startViewTransition = undefined;
});
describe("ThemeToggleButton", () => {
it("renders a button with an aria-label that flips with the theme", () => {
const { container } = render(<ThemeToggleButton />);
const button = container.querySelector("button");
expect(button?.getAttribute("aria-label")).toBe("Switch to dark theme");
});
it("calls toggleTheme directly when the view-transition API is unavailable", () => {
const { container } = render(<ThemeToggleButton />);
const button = container.querySelector("button") as HTMLButtonElement;
fireEvent.click(button);
expect(toggleTheme).toHaveBeenCalledTimes(1);
});
it("calls toggleTheme directly when prefers-reduced-motion is set", () => {
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query.includes("prefers-reduced-motion"),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})),
});
const startVT = vi.fn();
Object.defineProperty(document, "startViewTransition", {
writable: true,
configurable: true,
value: startVT,
});
const { container } = render(<ThemeToggleButton />);
fireEvent.click(container.querySelector("button") as HTMLButtonElement);
expect(toggleTheme).toHaveBeenCalledTimes(1);
expect(startVT).not.toHaveBeenCalled();
});
});
@@ -1,65 +0,0 @@
import { t } from "@lingui/core/macro";
import { MoonIcon, SunIcon } from "@phosphor-icons/react";
import { useCallback, useRef } from "react";
import { flushSync } from "react-dom";
import { Button } from "@reactive-resume/ui/components/button";
import { useTheme } from "./provider";
export function ThemeToggleButton(props: React.ComponentProps<typeof Button>) {
const { theme, toggleTheme } = useTheme();
const buttonRef = useRef<HTMLButtonElement>(null);
const onToggleTheme = useCallback(async () => {
if (
!buttonRef.current ||
!document.startViewTransition ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
toggleTheme();
return;
}
let timeout: NodeJS.Timeout;
const style = document.createElement("style");
style.textContent = `
::view-transition-old(root), ::view-transition-new(root) {
mix-blend-mode: normal !important;
animation: none !important;
}
`;
function transitionCallback() {
flushSync(() => {
toggleTheme();
timeout = setTimeout(() => {
clearTimeout(timeout);
document.head.removeChild(style);
}, 1000);
});
}
document.head.appendChild(style);
await document.startViewTransition(transitionCallback).ready;
const { top, left, width, height } = buttonRef.current.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
const right = window.innerWidth - left;
const bottom = window.innerHeight - top;
const maxRadius = Math.hypot(Math.max(left, right), Math.max(top, bottom));
document.documentElement.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${maxRadius}px at ${x}px ${y}px)`] },
{ duration: 500, easing: "ease-in-out", pseudoElement: "::view-transition-new(root)" },
);
}, [toggleTheme]);
const ariaLabel = theme === "dark" ? t`Switch to light theme` : t`Switch to dark theme`;
return (
<Button size="icon" variant="ghost" ref={buttonRef} onClick={onToggleTheme} aria-label={ariaLabel} {...props}>
{theme === "dark" ? <MoonIcon aria-hidden="true" /> : <SunIcon aria-hidden="true" />}
</Button>
);
}
@@ -1,127 +0,0 @@
import type { AuthSession } from "@reactive-resume/auth/types";
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { PaletteIcon, SignOutIcon, TranslateIcon } from "@phosphor-icons/react";
import { useRouter } from "@tanstack/react-router";
import { toast } from "sonner";
import { useIsClient } from "usehooks-ts";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { useTheme } from "@/components/theme/provider";
import { authClient } from "@/libs/auth/client";
import { getReadableErrorMessage } from "@/libs/error-message";
import { isLocale, loadLocale, localeMap, setLocaleServerFn } from "@/libs/locale";
import { isTheme } from "@/libs/theme";
type Props = {
children: ({ session }: { session: AuthSession }) => React.ComponentProps<typeof DropdownMenuTrigger>["render"];
};
export function UserDropdownMenu({ children }: Props) {
const isClient = useIsClient();
const router = useRouter();
const { i18n } = useLingui();
const { theme, setTheme } = useTheme();
const { data: session } = authClient.useSession();
const handleThemeChange = (value: string) => {
if (!isTheme(value)) return;
setTheme(value);
};
const handleLocaleChange = async (value: string) => {
if (!isLocale(value)) return;
await Promise.all([loadLocale(value), setLocaleServerFn({ data: value })]);
window.location.reload();
};
const handleLogout = async () => {
const toastId = toast.loading(t`Signing out...`);
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
toast.dismiss(toastId);
void router.invalidate();
},
onError: ({ error }) => {
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when signing out fails",
message: "Failed to sign out. Please try again.",
}),
),
{ id: toastId },
);
},
},
});
};
if (!isClient) return null;
if (!session?.user) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger render={children({ session: session as AuthSession })} />
<DropdownMenuContent align="start" side="top">
<DropdownMenuGroup>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<TranslateIcon />
<Trans comment="Menu item that opens language selection submenu">Language</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-[400px] overflow-y-auto">
<DropdownMenuRadioGroup value={i18n.locale} onValueChange={handleLocaleChange}>
{Object.entries(localeMap).map(([value, label]) => (
<DropdownMenuRadioItem key={value} value={value}>
{i18n.t(label)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PaletteIcon />
<Trans comment="Menu item that opens appearance theme selection submenu">Theme</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={theme} onValueChange={handleThemeChange}>
<DropdownMenuRadioItem value="light">
<Trans comment="Appearance theme option for light mode">Light</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="dark">
<Trans comment="Appearance theme option for dark mode">Dark</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<SignOutIcon />
<Trans comment="User menu action to sign out of current account">Logout</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}