mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 09:54:43 +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:
@@ -0,0 +1,143 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// @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("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
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>;
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// @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("@/features/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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// @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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CommandItem } from "@reactive-resume/ui/components/command";
|
||||
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
|
||||
import { BaseCommandGroup } from "../base";
|
||||
|
||||
export function LanguageCommandPage() {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const handleLocaleChange = async (value: string) => {
|
||||
if (!value || !isLocale(value)) return;
|
||||
setLocaleCookie(value);
|
||||
await loadLocale(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// @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("@/features/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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { MoonIcon, SunIcon } from "@phosphor-icons/react";
|
||||
import { CommandItem } from "@reactive-resume/ui/components/command";
|
||||
import { useTheme } from "@/features/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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
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: [] };
|
||||
});
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user