chore: integrate improve-integration

This commit is contained in:
Amruth Pillai
2026-07-08 19:07:05 +02:00
parent 73daf22b2f
commit 90105cb148
139 changed files with 6054 additions and 7793 deletions
+16 -16
View File
@@ -5,25 +5,25 @@ import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { FontDisplay } from "./font-display";
// Options depend only on the static font list, so compute them once per process
// instead of per component instance (the body + heading pickers rendered identical output twice).
const FONT_FAMILY_OPTIONS = fontList.map((font) => ({
value: font.family,
keywords: getFontSearchKeywords(font.family),
label: (
<FontDisplay
family={font.family}
label={getFontDisplayName(font.family)}
type={font.type}
url={"preview" in font ? font.preview : undefined}
/>
),
}));
type FontFamilyComboboxProps = Omit<SingleComboboxProps, "options">;
export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxProps) {
const options = useMemo(() => {
return fontList.map((font) => ({
value: font.family,
keywords: getFontSearchKeywords(font.family),
label: (
<FontDisplay
family={font.family}
label={getFontDisplayName(font.family)}
type={font.type}
url={"preview" in font ? font.preview : undefined}
/>
),
}));
}, []);
return <Combobox {...props} options={options} className={cn("w-full", className)} />;
return <Combobox {...props} options={FONT_FAMILY_OPTIONS} className={cn("w-full", className)} />;
}
type FontWeightComboboxProps = Omit<MultiComboboxProps, "options" | "multiple"> & { fontFamily: string };
@@ -1,7 +1,7 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useHotkeys } from "@tanstack/react-hotkeys";
import { useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { Command, CommandEmpty, CommandInput, CommandList } from "@reactive-resume/ui/components/command";
import {
Dialog,
@@ -17,10 +17,13 @@ import { useCommandPaletteStore } from "./store";
export function CommandPalette() {
const inputRef = useRef<HTMLInputElement>(null);
const commandRef = useRef<HTMLDivElement>(null);
const [selectedValue, setSelectedValue] = useState<string>();
const { open, search, pages, setOpen, setSearch, goBack } = useCommandPaletteStore();
const isFirstPage = pages.length === 0;
const currentPage = pages[pages.length - 1];
const commandListPage = currentPage ?? "root";
// Toggle command palette with Cmd+K / Ctrl+K
useHotkeys([
@@ -67,6 +70,18 @@ export function CommandPalette() {
setSearch(value);
};
useEffect(() => {
if (!open) return;
const firstItem = commandRef.current?.querySelector<HTMLElement>(
`[data-command-page="${commandListPage}"] [cmdk-item]:not([aria-disabled="true"])`,
);
const value = firstItem?.getAttribute("data-value");
if (value) setSelectedValue(value);
inputRef.current?.focus();
}, [open, commandListPage]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogHeader className="sr-only print:hidden">
@@ -97,7 +112,10 @@ export function CommandPalette() {
}
>
<Command
ref={commandRef}
loop
value={selectedValue}
onValueChange={setSelectedValue}
aria-label={t({
comment: "Accessible label for command list region inside command palette",
message: "Command Palette",
@@ -125,7 +143,7 @@ export function CommandPalette() {
})}
/>
<CommandList>
<CommandList key={commandListPage} data-command-page={commandListPage}>
<CommandEmpty>
<Trans comment="Empty-state message when no command palette results match the search query">
The command you're looking for doesn't exist.
@@ -1,6 +1,7 @@
// @vitest-environment happy-dom
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
@@ -21,6 +22,10 @@ vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn(),
}));
vi.mock("@tanstack/react-hotkeys", () => ({
useHotkeys: vi.fn(),
}));
vi.mock("@tanstack/react-router", () => ({
useNavigate: () => mocks.navigate,
useRouteContext: () => ({ session: { user: { id: "user-1" } } }),
@@ -36,6 +41,10 @@ vi.mock("@/features/applications/queries", () => ({
applicationsListQueryOptions: mocks.applicationsListQueryOptions,
}));
vi.mock("@/features/theme/provider", () => ({
useTheme: () => ({ setTheme: vi.fn(), theme: "light", toggleTheme: vi.fn() }),
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
@@ -53,6 +62,7 @@ vi.mock("@/libs/orpc/client", () => ({
},
}));
const { CommandPalette } = await import("../index");
const { ResumesCommandGroup } = await import("./resumes");
const { NavigationCommandGroup } = await import("./navigation");
@@ -83,8 +93,67 @@ const renderGroup = () =>
</I18nProvider>,
);
function TestCommandPalette() {
return (
<I18nProvider i18n={i18n}>
<CommandPalette />
</I18nProvider>
);
}
describe("ResumesCommandGroup", () => {
it("loads resumes with the default list input on the resumes page", () => {
it.each([
["resumes", "", "Create a new resume"],
["applications", "{ArrowDown}", "New Application"],
["threads", "{ArrowDown}{ArrowDown}", "New Thread"],
])("opens the %s list page from the root palette with Enter", async (page, keys, createLabel) => {
mockUseQueryData((entity) => {
if (entity === "resumes") return [{ id: "resume-1", name: "Evil Apricot Pike", slug: "apricot" }];
if (entity === "applications")
return [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }];
if (entity === "threads")
return [{ id: "thread-1", title: "Cover letter rewrite", resumeName: "Product Resume" }];
return [];
});
useCommandPaletteStore.setState({ open: true });
render(<TestCommandPalette />);
await userEvent.click(screen.getByRole("combobox"));
await userEvent.keyboard(`${keys}{Enter}`);
expect(useCommandPaletteStore.getState().pages).toEqual([page]);
expect(await screen.findByText(createLabel)).toBeInTheDocument();
expect(screen.queryByText("The command you're looking for doesn't exist.")).not.toBeInTheDocument();
});
it.each([
["resumes", "", "Create a new resume", "Evil Apricot Pike"],
["applications", "{ArrowDown}", "New Application", "Umbrella"],
["threads", "{ArrowDown}{ArrowDown}", "New Thread", "Cover letter rewrite"],
])("keeps arrow-key navigation active on the %s list page", async (_page, keys, createLabel, itemLabel) => {
mockUseQueryData((entity) => {
if (entity === "resumes") return [{ id: "resume-1", name: "Evil Apricot Pike", slug: "apricot" }];
if (entity === "applications")
return [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }];
if (entity === "threads")
return [{ id: "thread-1", title: "Cover letter rewrite", resumeName: "Product Resume" }];
return [];
});
useCommandPaletteStore.setState({ open: true });
render(<TestCommandPalette />);
await userEvent.click(screen.getByRole("combobox"));
await userEvent.keyboard(`${keys}{Enter}`);
const createItem = await screen.findByText(createLabel);
await userEvent.keyboard("{ArrowDown}");
expect((await screen.findByText(itemLabel)).closest("[cmdk-item]")).toHaveAttribute("aria-selected", "true");
await userEvent.keyboard("{ArrowUp}");
expect(createItem.closest("[cmdk-item]")).toHaveAttribute("aria-selected", "true");
});
it("does not show resume results beside the root categories on the resumes route", () => {
mocks.pathname = "/dashboard/resumes";
mockUseQueryData((entity) =>
entity === "resumes" ? [{ id: "resume-1", name: "Evil Apricot Pike", slug: "apricot" }] : [],
@@ -92,6 +161,19 @@ describe("ResumesCommandGroup", () => {
renderGroup();
expect(screen.getAllByText("Resumes")).toHaveLength(1);
expect(screen.queryByText("Create a new resume")).not.toBeInTheDocument();
expect(screen.queryByText("Evil Apricot Pike")).not.toBeInTheDocument();
});
it("loads resumes with the default list input after opening the resumes command page", () => {
useCommandPaletteStore.setState({ pages: ["resumes"] });
mockUseQueryData((entity) =>
entity === "resumes" ? [{ id: "resume-1", name: "Evil Apricot Pike", slug: "apricot" }] : [],
);
renderGroup();
expect(mocks.resumeQueryOptions).toHaveBeenCalledWith({
enabled: true,
input: { sort: "lastUpdatedAt", tags: [] },
@@ -100,8 +182,7 @@ describe("ResumesCommandGroup", () => {
});
it("filters resume results from the command palette search", () => {
mocks.pathname = "/dashboard/resumes";
useCommandPaletteStore.setState({ search: "apri" });
useCommandPaletteStore.setState({ pages: ["resumes"], search: "apri" });
mockUseQueryData((entity) =>
entity === "resumes"
? [
@@ -118,7 +199,7 @@ describe("ResumesCommandGroup", () => {
});
it("loads applications on the applications page", () => {
mocks.pathname = "/dashboard/applications";
useCommandPaletteStore.setState({ pages: ["applications"] });
mockUseQueryData((entity) =>
entity === "applications"
? [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }]
@@ -132,8 +213,7 @@ describe("ResumesCommandGroup", () => {
});
it("filters application results from the command palette search", () => {
mocks.pathname = "/dashboard/applications";
useCommandPaletteStore.setState({ search: "umbrella" });
useCommandPaletteStore.setState({ pages: ["applications"], search: "umbrella" });
mockUseQueryData((entity) =>
entity === "applications"
? [
@@ -150,7 +230,7 @@ describe("ResumesCommandGroup", () => {
});
it("opens the selected application by id", () => {
mocks.pathname = "/dashboard/applications";
useCommandPaletteStore.setState({ pages: ["applications"] });
mockUseQueryData((entity) =>
entity === "applications"
? [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }]
@@ -167,7 +247,7 @@ describe("ResumesCommandGroup", () => {
});
it("loads threads on agent pages", () => {
mocks.pathname = "/agent";
useCommandPaletteStore.setState({ pages: ["threads"] });
mockUseQueryData((entity) =>
entity === "threads" ? [{ id: "thread-1", title: "Cover letter rewrite", resumeName: "Product Resume" }] : [],
);
@@ -179,8 +259,7 @@ describe("ResumesCommandGroup", () => {
});
it("filters thread results from the command palette search", () => {
mocks.pathname = "/agent";
useCommandPaletteStore.setState({ search: "cover" });
useCommandPaletteStore.setState({ pages: ["threads"], search: "cover" });
mockUseQueryData((entity) =>
entity === "threads"
? [
@@ -3,7 +3,7 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { BriefcaseIcon, ChatCircleDotsIcon, PlusIcon, ReadCvLogoIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useRouteContext, useRouterState } from "@tanstack/react-router";
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";
@@ -20,12 +20,6 @@ type SearchPage = "resumes" | "applications" | "threads";
const isSearchPage = (page: string | undefined): page is SearchPage =>
page === "resumes" || page === "applications" || page === "threads";
const getRouteSearchPage = (pathname: string): SearchPage | undefined => {
if (pathname.startsWith("/dashboard/resumes")) return "resumes";
if (pathname.startsWith("/dashboard/applications")) return "applications";
if (pathname.startsWith("/agent")) return "threads";
};
const matchesSearch = (search: string, values: Array<string | null | undefined>) => {
const query = search.trim().toLowerCase();
return !query || values.some((value) => value?.toLowerCase().includes(query));
@@ -35,7 +29,6 @@ export function ResumesCommandGroup() {
const navigate = useNavigate();
const { openDialog } = useDialogStore();
const { session } = useRouteContext({ strict: false });
const pathname = useRouterState({ select: (state) => state.location.pathname });
const reset = useCommandPaletteStore((state) => state.reset);
const peekPage = useCommandPaletteStore((state) => state.peekPage);
const pushPage = useCommandPaletteStore((state) => state.pushPage);
@@ -43,7 +36,7 @@ export function ResumesCommandGroup() {
const commandPage = peekPage();
const commandSearchPage = isSearchPage(commandPage) ? commandPage : undefined;
const searchPage = commandSearchPage ?? (!commandPage ? getRouteSearchPage(pathname) : undefined);
const searchPage = commandSearchPage;
const { data: resumes, isLoading } = useQuery(
orpc.resume.list.queryOptions({
@@ -11,7 +11,7 @@ import {
FileTextIcon,
MarkdownLogoIcon,
} from "@phosphor-icons/react";
import { useState } from "react";
import { useId, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import {
Dialog,
@@ -21,6 +21,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@reactive-resume/ui/components/dialog";
import { Switch } from "@reactive-resume/ui/components/switch";
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { cn } from "@reactive-resume/utils/style";
import { useResumeExport } from "./use-resume-export";
@@ -63,6 +64,8 @@ function FormatRow({ action, description, disabled, icon, title }: FormatRowProp
export function ResumeDownloadDialog({ resume, trigger }: ResumeDownloadDialogProps) {
const [open, setOpen] = useState(false);
const [scope, setScope] = useState<ResumeExportTarget>("resume");
const [includeCoverLetterHeader, setIncludeCoverLetterHeader] = useState(false);
const includeHeaderSwitchId = useId();
const { hasCoverLetter, isExporting, onDownloadDOCX, onDownloadJSON, onDownloadMarkdown, onDownloadPDF } =
useResumeExport(resume);
const disabled = !resume || isExporting;
@@ -102,6 +105,28 @@ export function ResumeDownloadDialog({ resume, trigger }: ResumeDownloadDialogPr
</TabsList>
</Tabs>
{activeScope === "cover-letter" && (
<label
htmlFor={includeHeaderSwitchId}
className="flex cursor-pointer items-center gap-3 rounded-lg border bg-background p-3"
>
<Switch
id={includeHeaderSwitchId}
checked={includeCoverLetterHeader}
onCheckedChange={setIncludeCoverLetterHeader}
/>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="font-medium text-sm">
<Trans>Include resume header</Trans>
</span>
<span className="text-muted-foreground text-xs leading-normal">
<Trans>Show the same first-page header on the cover letter.</Trans>
</span>
</span>
</label>
)}
<div className="grid gap-2">
<FormatRow
icon={
@@ -114,7 +139,7 @@ export function ResumeDownloadDialog({ resume, trigger }: ResumeDownloadDialogPr
size="sm"
aria-label="Download PDF"
disabled={isExporting}
onClick={() => run(() => onDownloadPDF(activeScope))}
onClick={() => run(() => onDownloadPDF(activeScope, { includeCoverLetterHeader }))}
>
<DownloadSimpleIcon />
<Trans>Download</Trans>
@@ -5,6 +5,10 @@ import { createResumePdfBlob as createPdfBlob } from "@reactive-resume/pdf/brows
import { ResumeDocument } from "@reactive-resume/pdf/document";
import { createSectionTitleResolverForLocale, useSectionTitleResolver } from "@/libs/resume/section-title-locale";
type ResumePdfRenderOptions = {
includeCoverLetterHeader?: boolean;
};
export const useLocalizedResumeDocument = (data?: ResumeData, template?: Template) => {
const sectionTitleResolver = useSectionTitleResolver(data?.metadata.page.locale);
@@ -21,12 +25,17 @@ export const useLocalizedResumeDocument = (data?: ResumeData, template?: Templat
}, [data, template, sectionTitleResolver]);
};
export const createResumePdfBlob = async (data: ResumeData, template?: Template) => {
export const createResumePdfBlob = async (
data: ResumeData,
template?: Template,
renderOptions?: ResumePdfRenderOptions,
) => {
const sectionTitleResolver = await createSectionTitleResolverForLocale(data.metadata.page.locale);
return createPdfBlob({
data,
template,
...(renderOptions ? { renderOptions } : {}),
resolveSectionTitle: sectionTitleResolver,
});
};
@@ -33,6 +33,10 @@ const getExportName = (resume: ExportableResume) => resume.name || resume.data.b
const getTargetExportName = (resume: ExportableResume, target: ResumeExportTarget) =>
target === "cover-letter" ? `${getExportName(resume)} Cover Letter` : getExportName(resume);
type DownloadPdfOptions = {
includeCoverLetterHeader?: boolean;
};
/**
* Single source of truth for resume export (PDF / DOCX / JSON / Print). Previously duplicated verbatim
* between the builder dock and the right-panel Export section (#17).
@@ -76,14 +80,18 @@ export function useResumeExport(resume: ExportableResume | undefined) {
);
const onDownloadPDF = useCallback(
async (target: ResumeExportTarget = "resume") => {
async (target: ResumeExportTarget = "resume", options?: DownloadPdfOptions) => {
if (!resume) return;
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return;
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
setIsExporting(true);
try {
const data = getResumeExportData(resume.data, target);
const blob = await createResumePdfBlob(data);
const blob = await createResumePdfBlob(
data,
undefined,
target === "cover-letter" ? { includeCoverLetterHeader: options?.includeCoverLetterHeader } : undefined,
);
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "pdf"));
} catch {
toast.error(t`There was a problem while generating the PDF, please try again.`);
@@ -142,6 +142,27 @@ describe("AISettingsSection", () => {
mutations.delete.mockReset();
});
it("offers popular AI SDK providers and labels Ollama as cloud-hosted", () => {
renderSection();
for (const label of [
"Mistral AI",
"Cohere",
"xAI Grok",
"Groq",
"DeepSeek",
"Together.ai",
"Fireworks",
"Cerebras",
"Perplexity",
"Ollama Cloud",
]) {
expect(screen.getByRole("option", { name: label })).toBeInTheDocument();
}
expect(screen.queryByRole("option", { name: "Ollama" })).not.toBeInTheDocument();
});
it("uses the save-and-test result as the connected provider row", async () => {
const created = provider({});
const tested = provider({ enabled: true, testStatus: "success", lastTestedAt: new Date("2026-07-04T01:00:00Z") });
@@ -64,10 +64,73 @@ const providerOptions: AIProviderOption[] = [
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.openrouter,
defaultModel: "openai/gpt-4.1",
},
{
value: "mistral",
label: t`Mistral AI`,
keywords: ["mistral", "magistral"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.mistral,
defaultModel: "mistral-large-latest",
},
{
value: "cohere",
label: t`Cohere`,
keywords: ["cohere", "command"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.cohere,
defaultModel: "command-a-03-2025",
},
{
value: "xai",
label: t`xAI Grok`,
keywords: ["xai", "grok"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.xai,
defaultModel: "grok-4",
},
{
value: "groq",
label: t`Groq`,
keywords: ["groq", "llama"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.groq,
defaultModel: "llama-3.3-70b-versatile",
},
{
value: "deepseek",
label: t`DeepSeek`,
keywords: ["deepseek"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.deepseek,
defaultModel: "deepseek-chat",
},
{
value: "togetherai",
label: t`Together.ai`,
keywords: ["together", "togetherai", "llama"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.togetherai,
defaultModel: "meta-llama/Meta-Llama-3.3-70B-Instruct-Turbo",
},
{
value: "fireworks",
label: t`Fireworks`,
keywords: ["fireworks", "llama", "deepseek"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.fireworks,
defaultModel: "accounts/fireworks/models/llama-v3p3-70b-instruct",
},
{
value: "cerebras",
label: t`Cerebras`,
keywords: ["cerebras", "llama"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.cerebras,
defaultModel: "llama3.3-70b",
},
{
value: "perplexity",
label: t`Perplexity`,
keywords: ["perplexity", "sonar"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.perplexity,
defaultModel: "sonar-pro",
},
{
value: "ollama",
label: t`Ollama`,
keywords: ["ollama", "local"],
label: t`Ollama Cloud`,
keywords: ["ollama", "cloud"],
defaultBaseURL: AI_PROVIDER_DEFAULT_BASE_URLS.ollama,
defaultModel: "llama3.1",
},
@@ -92,7 +92,8 @@ describe("updateSectionItem", () => {
});
const customSection = result.customSections.find((s) => s.id === "custom-1");
expect((customSection?.items[0] as { value?: string }).value).toBe("new");
if (!customSection) throw new Error("Custom section not found");
expect((customSection.items[0] as { value?: string }).value).toBe("new");
});
it("does nothing when custom section is not found", () => {
@@ -0,0 +1,13 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync("src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx", "utf8");
describe("layout page header", () => {
it("uses container queries to prevent narrow sidebar control collisions", () => {
expect(source).toContain("@container bg-secondary/50");
expect(source).toContain("grid-cols-[minmax(0,1fr)_auto]");
expect(source).toContain("@max-[22rem]:grid-cols-1");
expect(source).toContain("flex min-w-0 flex-wrap");
});
});
@@ -297,31 +297,37 @@ function PageContainer({
return (
<div className="space-y-3 rounded-md border border-dashed bg-background/40">
<div className="flex items-center justify-between bg-secondary/50 px-4 py-3">
<div className="flex w-full items-center gap-4">
<span className="font-medium text-xs">
<Trans comment="Layout editor page label with 1-based page number">Page {pageIndex + 1}</Trans>
</span>
<label htmlFor={fullWidthSwitchId} className="flex cursor-pointer items-center gap-2">
<Switch
id={fullWidthSwitchId}
checked={page.fullWidth}
onCheckedChange={(checked) => onToggleFullWidth(pageIndex, checked)}
/>
<span className="font-medium text-muted-foreground text-xs">
<Trans comment="Layout editor toggle label that makes a page single-column">Full Width</Trans>
<div className="@container bg-secondary/50 px-4 py-3">
<div className="grid @max-[22rem]:grid-cols-1 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2">
<span className="font-medium text-xs">
<Trans comment="Layout editor page label with 1-based page number">Page {pageIndex + 1}</Trans>
</span>
</label>
</div>
{canDelete && (
<Button variant="ghost" onClick={() => onDelete(pageIndex)} className="h-5 w-auto gap-x-2.5 px-0!">
<TrashIcon />
<Trans>Delete Page</Trans>
</Button>
)}
<label htmlFor={fullWidthSwitchId} className="flex min-w-0 cursor-pointer items-center gap-2">
<Switch
id={fullWidthSwitchId}
checked={page.fullWidth}
onCheckedChange={(checked) => onToggleFullWidth(pageIndex, checked)}
/>
<span className="font-medium text-muted-foreground text-xs">
<Trans comment="Layout editor toggle label that makes a page single-column">Full Width</Trans>
</span>
</label>
</div>
{canDelete && (
<Button
variant="ghost"
onClick={() => onDelete(pageIndex)}
className="size-auto gap-x-2.5 justify-self-end p-0!"
>
<TrashIcon />
<Trans>Delete Page</Trans>
</Button>
)}
</div>
</div>
<div
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
@@ -64,4 +65,11 @@ describe("TemplateSectionBuilder", () => {
const img = screen.getByAltText("Ditto") as HTMLImageElement;
expect(img.src).toContain("/templates/jpg/ditto.jpg");
});
it("keeps the template preview inline instead of mounting a hover card", () => {
const source = readFileSync("src/routes/builder/$resumeId/-sidebar/right/sections/template.tsx", "utf8");
expect(source).not.toContain("@reactive-resume/ui/components/hover-card");
expect(source).not.toContain("HoverCardContent");
});
});
@@ -1,23 +1,12 @@
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { SwapIcon } from "@phosphor-icons/react";
import { lazy, Suspense } from "react";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/ui/components/hover-card";
import { templates } from "@/dialogs/resume/template/data";
import { useDialogStore } from "@/dialogs/store";
import { useCurrentResume } from "@/features/resume/builder/draft";
import { SectionBase } from "../shared/section-base";
// Lazy so the browser PDF pipeline (pdf.js) loads only when a preview card actually opens, keeping it out
// of the SSR/module graph — mirrors the `ResumePreview` entry convention.
const TemplateLivePreview = lazy(() =>
import("@/features/resume/preview/template-live-preview").then((module) => ({
default: module.TemplateLivePreview,
})),
);
export function TemplateSectionBuilder() {
return (
<SectionBase type="template">
@@ -40,43 +29,19 @@ function TemplateSectionForm() {
return (
<div className="flex @md:flex-row flex-col items-stretch gap-x-4 gap-y-2">
<HoverCard>
<HoverCardTrigger
delay={300}
render={
<Button
variant="ghost"
onClick={onOpenTemplateGallery}
className="group/preview relative h-auto w-40 shrink-0 cursor-pointer p-0"
>
<div className="relative z-10 aspect-page size-full overflow-hidden rounded-md opacity-100 transition-opacity group-hover/preview:opacity-50">
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
</div>
<Button
variant="ghost"
onClick={onOpenTemplateGallery}
className="group/preview relative h-auto w-40 shrink-0 cursor-pointer p-0"
>
<div className="relative z-10 aspect-page size-full overflow-hidden rounded-md opacity-100 transition-opacity group-hover/preview:opacity-50">
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
</div>
<div className="absolute inset-0 flex items-center justify-center">
<SwapIcon size={48} weight="thin" className="size-12" />
</div>
</Button>
}
/>
<HoverCardContent side="right" align="start" className="w-64 p-1.5">
<Suspense
fallback={
<div className="aspect-page w-full overflow-hidden rounded-md bg-white">
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-contain" />
</div>
}
>
<TemplateLivePreview
data={resume.data}
template={template}
fallbackSrc={metadata.imageUrl}
alt={t`Live preview of your resume in the ${metadata.name} template`}
/>
</Suspense>
</HoverCardContent>
</HoverCard>
<div className="absolute inset-0 flex items-center justify-center">
<SwapIcon size={48} weight="thin" className="size-12" />
</div>
</Button>
<div className="flex flex-1 flex-col gap-y-4 @md:pt-1 @md:pb-3">
<div className="space-y-1">