mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 08:54:05 +10:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user