mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 14:52:18 +10:00
feat: add semantic CSS stylesheets (#3274)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor Agent
parent
4ac19f81b3
commit
d2ffbf9618
@@ -17,6 +17,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { useResumeStore } from "@/features/resume/builder/draft";
|
||||
import {
|
||||
lockStylesheetStoreForRestore,
|
||||
replaceStylesheetStoreAfterRestore,
|
||||
unlockStylesheetStoreAfterRestore,
|
||||
} from "@/features/resume/stylesheet/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
import { formatRelativeTime } from "@/libs/locale";
|
||||
@@ -39,7 +44,7 @@ export function BuilderVersionHistory({ resumeId }: BuilderVersionHistoryProps)
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { mutate: restoreVersion, isPending } = useMutation(orpc.resume.restoreVersion.mutationOptions());
|
||||
const { mutateAsync: restoreVersion, isPending } = useMutation(orpc.resume.restoreVersion.mutationOptions());
|
||||
|
||||
const handleRestore = async (versionId: string) => {
|
||||
const confirmed = await confirm(t`Restore this version?`, {
|
||||
@@ -48,18 +53,28 @@ export function BuilderVersionHistory({ resumeId }: BuilderVersionHistoryProps)
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
restoreVersion(
|
||||
{ resumeId, versionId },
|
||||
{
|
||||
onSuccess: (restored) => {
|
||||
replaceResumeFromServer(restored as Resume);
|
||||
queryClient.setQueryData(orpc.resume.getById.queryOptions({ input: { id: resumeId } }).queryKey, restored);
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.resume.listVersions.queryKey({ input: { resumeId } }) });
|
||||
toast.success(t`Your resume has been restored to the selected version.`);
|
||||
},
|
||||
onError: (error) => toast.error(getResumeErrorMessage(error)),
|
||||
},
|
||||
);
|
||||
const token = lockStylesheetStoreForRestore(resumeId);
|
||||
if (!token) return;
|
||||
try {
|
||||
const restored = await restoreVersion({ resumeId, versionId });
|
||||
const applied = replaceStylesheetStoreAfterRestore({
|
||||
resumeId,
|
||||
resumeData: restored.resume.data,
|
||||
initial: restored.stylesheetState,
|
||||
token,
|
||||
});
|
||||
if (!applied) {
|
||||
unlockStylesheetStoreAfterRestore(token);
|
||||
return;
|
||||
}
|
||||
replaceResumeFromServer(restored.resume as Resume);
|
||||
queryClient.setQueryData(orpc.resume.getById.queryOptions({ input: { id: resumeId } }).queryKey, restored.resume);
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.resume.listVersions.queryKey({ input: { resumeId } }) });
|
||||
toast.success(t`Your resume has been restored to the selected version.`);
|
||||
} catch (error) {
|
||||
unlockStylesheetStoreAfterRestore(token);
|
||||
toast.error(getResumeErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RightSidebarSection } from "@/libs/resume/section";
|
||||
import { useRouteContext } from "@tanstack/react-router";
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
@@ -22,13 +23,13 @@ import { StatisticsSectionBuilder } from "./sections/statistics";
|
||||
import { TemplateSectionBuilder } from "./sections/template";
|
||||
import { TypographySectionBuilder } from "./sections/typography";
|
||||
|
||||
function getSectionComponent(type: RightSidebarSection) {
|
||||
function getSectionComponent(type: RightSidebarSection, semanticCssAuthoring: boolean) {
|
||||
return match(type)
|
||||
.with("template", () => <TemplateSectionBuilder />)
|
||||
.with("layout", () => <LayoutSectionBuilder />)
|
||||
.with("typography", () => <TypographySectionBuilder />)
|
||||
.with("design", () => <DesignSectionBuilder />)
|
||||
.with("styles", () => <CustomStylesSectionBuilder />)
|
||||
.with("styles", () => <CustomStylesSectionBuilder authoringEnabled={semanticCssAuthoring} />)
|
||||
.with("page", () => <PageSectionBuilder />)
|
||||
.with("notes", () => <NotesSectionBuilder />)
|
||||
.with("sharing", () => <SharingSectionBuilder />)
|
||||
@@ -41,6 +42,8 @@ function getSectionComponent(type: RightSidebarSection) {
|
||||
|
||||
export function BuilderSidebarRight() {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const context = useRouteContext({ strict: false });
|
||||
const semanticCssAuthoring = context.flags?.semanticCssAuthoring ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -53,7 +56,7 @@ export function BuilderSidebarRight() {
|
||||
<div className="space-y-4 p-4">
|
||||
{rightSidebarSections.map((section) => (
|
||||
<Fragment key={section}>
|
||||
{getSectionComponent(section)}
|
||||
{getSectionComponent(section, semanticCssAuthoring)}
|
||||
<Separator />
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
+23
-2
@@ -40,8 +40,13 @@ vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useUpdateResumeData: () => updateResumeData,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/resume/stylesheet/editor", () => ({
|
||||
default: () => <div data-testid="semantic-css-editor-shell">Semantic CSS editor</div>,
|
||||
}));
|
||||
|
||||
const { CustomStylesSectionBuilder } = await import("./custom-styles");
|
||||
const { getSectionIcon, getSectionTitle } = await import("@/libs/resume/section");
|
||||
const { useStylesheetStore } = await import("@/features/resume/stylesheet/store");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
@@ -49,12 +54,13 @@ beforeAll(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
updateResumeData.mockClear();
|
||||
useStylesheetStore.setState({ mode: "legacy" });
|
||||
});
|
||||
|
||||
const renderCustomStyles = () =>
|
||||
const renderCustomStyles = (authoringEnabled = false) =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<CustomStylesSectionBuilder />
|
||||
<CustomStylesSectionBuilder authoringEnabled={authoringEnabled} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
@@ -75,6 +81,21 @@ describe("CustomStylesSectionBuilder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("loads the Semantic CSS shell only when authoring is enabled", async () => {
|
||||
renderCustomStyles(true);
|
||||
|
||||
expect(await screen.findByTestId("semantic-css-editor-shell")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Target Scope")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a read-only notice for active Semantic CSS when authoring is disabled", () => {
|
||||
useStylesheetStore.setState({ mode: "semantic" });
|
||||
renderCustomStyles();
|
||||
|
||||
expect(screen.getByText(/semantic styles remain active/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Target Scope")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders structured style rule controls", async () => {
|
||||
renderCustomStyles();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { ReactNode } from "react";
|
||||
import type { ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { EyeIcon, EyeSlashIcon, PencilSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { sectionTypeSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
@@ -20,9 +20,14 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SemanticStylesheetReadOnlyNotice } from "@/features/resume/stylesheet/legacy-banner";
|
||||
import { useStylesheetStore } from "@/features/resume/stylesheet/store";
|
||||
import { getSectionTitle } from "@/libs/resume/section";
|
||||
import { useSectionStore } from "../../../-store/section";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
const StylesheetEditorShell = lazy(() => import("@/features/resume/stylesheet/editor"));
|
||||
|
||||
type TargetScope = StyleRuleTarget["scope"];
|
||||
|
||||
type StyleSlotOption = {
|
||||
@@ -100,15 +105,36 @@ const exactFourControlGridClassName = "grid grid-cols-1 gap-3 @min-[20rem]:grid-
|
||||
const compactSpacingInputClassName =
|
||||
"h-8 w-18 max-w-18 min-w-0 px-1.5 text-center text-xs tabular-nums placeholder:text-[0.68rem] placeholder:uppercase placeholder:tracking-wide";
|
||||
|
||||
export function CustomStylesSectionBuilder() {
|
||||
export type CustomStylesSectionBuilderProps = {
|
||||
authoringEnabled?: boolean;
|
||||
};
|
||||
|
||||
export function CustomStylesSectionBuilder({ authoringEnabled = false }: CustomStylesSectionBuilderProps) {
|
||||
const mode = useStylesheetStore((state) => state.mode);
|
||||
const collapsed = useSectionStore((state) => state.sections.styles?.collapsed ?? false);
|
||||
|
||||
return (
|
||||
<SectionBase type="styles" className="space-y-4">
|
||||
<CustomStylesSectionForm />
|
||||
{authoringEnabled ? (
|
||||
collapsed ? null : (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div role="status" className="h-72 animate-pulse rounded-md bg-muted" aria-label="Loading editor" />
|
||||
}
|
||||
>
|
||||
<StylesheetEditorShell />
|
||||
</Suspense>
|
||||
)
|
||||
) : mode === "semantic" ? (
|
||||
<SemanticStylesheetReadOnlyNotice />
|
||||
) : (
|
||||
<LegacyCustomStylesSectionForm />
|
||||
)}
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomStylesSectionForm() {
|
||||
function LegacyCustomStylesSectionForm() {
|
||||
const resume = useCurrentResume();
|
||||
const data = resume.data;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
@@ -19,6 +19,14 @@ const resumeMock = vi.hoisted(() => ({
|
||||
slug: string;
|
||||
data: typeof defaultResumeData;
|
||||
},
|
||||
stylesheet: {
|
||||
resumeId: "r1" as string | undefined,
|
||||
mode: "semantic" as "legacy" | "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nname {" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
|
||||
revision: 42,
|
||||
renderDataVersion: 7,
|
||||
},
|
||||
}));
|
||||
|
||||
type SectionBaseProps = {
|
||||
@@ -42,6 +50,9 @@ vi.mock("@/libs/resume/section-title-locale", () => ({
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useResume: () => resumeMock.resume,
|
||||
}));
|
||||
vi.mock("@/features/resume/stylesheet/store", () => ({
|
||||
useStylesheetStore: (selector: (state: typeof resumeMock.stylesheet) => unknown) => selector(resumeMock.stylesheet),
|
||||
}));
|
||||
|
||||
const { ExportSectionBuilder } = await import("./export");
|
||||
|
||||
@@ -51,6 +62,14 @@ beforeAll(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data: defaultResumeData };
|
||||
resumeMock.stylesheet = {
|
||||
resumeId: "r1",
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nname {" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
|
||||
revision: 42,
|
||||
renderDataVersion: 7,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -96,7 +115,7 @@ describe("ExportSectionBuilder", () => {
|
||||
expect(filename).toBe("My Resume.md");
|
||||
});
|
||||
|
||||
it("downloads a JSON blob when the JSON button is clicked", () => {
|
||||
it("downloads canonical stylesheet content in JSON without concurrency metadata", async () => {
|
||||
renderExport();
|
||||
openDialog();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
|
||||
@@ -107,6 +126,14 @@ describe("ExportSectionBuilder", () => {
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect((blob as Blob).type).toBe("application/json");
|
||||
expect(filename).toBe("My Resume.json");
|
||||
const exported = JSON.parse(await (blob as Blob).text());
|
||||
expect(exported.metadata.stylesheet).toEqual({
|
||||
mode: "semantic",
|
||||
source: resumeMock.stylesheet.source,
|
||||
applied: resumeMock.stylesheet.applied,
|
||||
});
|
||||
expect(JSON.stringify(exported)).not.toContain("revision");
|
||||
expect(JSON.stringify(exported)).not.toContain("renderDataVersion");
|
||||
});
|
||||
|
||||
it("calls buildDocx and downloads the resulting blob when DOCX is clicked", async () => {
|
||||
@@ -128,6 +155,12 @@ describe("ExportSectionBuilder", () => {
|
||||
await Promise.resolve();
|
||||
|
||||
expect(createResumePdfBlob).toHaveBeenCalledTimes(1);
|
||||
expect(createResumePdfBlob).toHaveBeenCalledWith(defaultResumeData, undefined, undefined, {
|
||||
stylesheet: {
|
||||
mode: "semantic",
|
||||
applied: resumeMock.stylesheet.applied,
|
||||
},
|
||||
});
|
||||
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
|
||||
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.pdf");
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { BuilderLayout } from "./-store/sidebar";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { useBuilderResumeUpdateSubscription, useResumeCleanup, useResumeStore } from "@/features/resume/builder/draft";
|
||||
import { initializeStylesheetStore, useStylesheetStore } from "@/features/resume/stylesheet/store";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { createNoindexFollowMeta } from "@/libs/seo";
|
||||
import { DesktopBuilderShell } from "./-components/desktop-builder-shell";
|
||||
@@ -20,6 +21,9 @@ export const Route = createFileRoute("/builder/$resumeId")({
|
||||
const [layout, resume] = await Promise.all([
|
||||
getBuilderLayout(),
|
||||
context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions({ input: { id: params.resumeId } })),
|
||||
context.queryClient.ensureQueryData(
|
||||
orpc.resume.stylesheet.getState.queryOptions({ input: { id: params.resumeId } }),
|
||||
),
|
||||
]);
|
||||
|
||||
return { layout, name: resume.name };
|
||||
@@ -36,11 +40,17 @@ function RouteComponent() {
|
||||
|
||||
const { resumeId } = Route.useParams();
|
||||
const { data: resume } = useSuspenseQuery(orpc.resume.getById.queryOptions({ input: { id: resumeId } }));
|
||||
const { data: stylesheet } = useSuspenseQuery(
|
||||
orpc.resume.stylesheet.getState.queryOptions({ input: { id: resumeId } }),
|
||||
);
|
||||
const initializeResumeStore = useResumeStore((state) => state.initialize);
|
||||
const mergeResumeMetadata = useResumeStore((state) => state.mergeResumeMetadata);
|
||||
const isReady = useResumeStore((state) => state.isReady);
|
||||
const initializedResumeId = useResumeStore((state) => state.resumeId);
|
||||
const isInitialized = isReady && initializedResumeId === resumeId;
|
||||
const isStylesheetInitialized = useStylesheetStore((state) => state.resumeId === resumeId);
|
||||
const stylesheetInitialization = useRef({ resume, stylesheet });
|
||||
stylesheetInitialization.current = { resume, stylesheet };
|
||||
|
||||
useResumeCleanup();
|
||||
useBuilderResumeUpdateSubscription();
|
||||
@@ -50,6 +60,16 @@ function RouteComponent() {
|
||||
initializeResumeStore(resume);
|
||||
}, [initializeResumeStore, isInitialized, resume]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
const initial = stylesheetInitialization.current;
|
||||
return initializeStylesheetStore({
|
||||
resumeId,
|
||||
initial: initial.stylesheet,
|
||||
resumeData: initial.resume.data,
|
||||
});
|
||||
}, [isInitialized, resumeId]);
|
||||
|
||||
useEffect(() => {
|
||||
mergeResumeMetadata(resume);
|
||||
}, [
|
||||
@@ -65,7 +85,7 @@ function RouteComponent() {
|
||||
resume,
|
||||
]);
|
||||
|
||||
if (!isInitialized) return null;
|
||||
if (!isInitialized || !isStylesheetInitialized) return null;
|
||||
|
||||
return <BuilderLayoutShell initialLayout={initialLayout} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user