mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
feat: improvements to custom styles
This commit is contained in:
@@ -17,11 +17,11 @@
|
||||
"#react-pdf-renderer": "@react-pdf/renderer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.79",
|
||||
"@ai-sdk/google": "^3.0.79",
|
||||
"@ai-sdk/anthropic": "^3.0.80",
|
||||
"@ai-sdk/google": "^3.0.80",
|
||||
"@ai-sdk/openai": "^3.0.65",
|
||||
"@ai-sdk/openai-compatible": "^2.0.48",
|
||||
"@aws-sdk/client-s3": "^3.1054.0",
|
||||
"@aws-sdk/client-s3": "^3.1055.0",
|
||||
"@better-auth/api-key": "^1.6.11",
|
||||
"@better-auth/drizzle-adapter": "^1.6.11",
|
||||
"@better-auth/infra": "^0.2.10",
|
||||
@@ -51,7 +51,7 @@
|
||||
"better-auth": "1.6.11",
|
||||
"cjk-regex": "^3.4.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"dompurify": "^3.4.6",
|
||||
"dompurify": "^3.4.7",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "1.0.0-rc.3",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
@@ -65,7 +65,7 @@
|
||||
"pg": "^8.21.0",
|
||||
"phosphor-icons-react-pdf": "^0.1.3",
|
||||
"react": "^19.2.6",
|
||||
"react-email": "^6.4.0",
|
||||
"react-email": "^6.5.0",
|
||||
"react-pdf-html": "^2.1.5",
|
||||
"resumable-stream": "^2.2.12",
|
||||
"sharp": "^0.34.5",
|
||||
|
||||
@@ -32,6 +32,14 @@ describe("Combobox", () => {
|
||||
expect(screen.getAllByText("Beta").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("places the clear action inside the trigger footprint", () => {
|
||||
const { container } = wrap(<Combobox options={[...options]} value="beta" showClear />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Clear selection" })).toHaveClass("absolute", "end-7");
|
||||
expect(screen.getByRole("combobox")).not.toHaveClass("pe-14");
|
||||
expect(container.querySelector("[data-slot='combobox-trigger'] > span")).toHaveClass("pe-7");
|
||||
});
|
||||
|
||||
it("renders all option labels for the multi-select default values", () => {
|
||||
wrap(<Combobox multiple options={[...options]} defaultValue={["alpha", "gamma"]} />);
|
||||
expect(screen.getAllByText(/Alpha/).length).toBeGreaterThan(0);
|
||||
|
||||
@@ -147,6 +147,10 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
[contains],
|
||||
);
|
||||
|
||||
const hasSelectedValue = multiple
|
||||
? Array.isArray(selectedValue) && selectedValue.length > 0
|
||||
: selectedValue !== null && selectedValue !== undefined;
|
||||
|
||||
const listContent = (item: ComboboxOption<TValue>) => (
|
||||
<ComboboxItem key={String(item.value)} value={item} disabled={item.disabled}>
|
||||
{item.label}
|
||||
@@ -166,7 +170,7 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
<span className={cn("min-w-0 flex-1 truncate text-left", showClear && hasSelectedValue && "pe-7")}>
|
||||
<ComboboxValue placeholder={placeholder ?? t`Select...`} />
|
||||
</span>
|
||||
</ComboboxTrigger>
|
||||
@@ -185,9 +189,15 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
{...(multiple ? { multiple: true } : {})}
|
||||
>
|
||||
{showClear ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="relative w-full min-w-0">
|
||||
{triggerNode}
|
||||
<ComboboxClear disabled={disabled} />
|
||||
{hasSelectedValue && (
|
||||
<ComboboxClear
|
||||
aria-label={t`Clear selection`}
|
||||
disabled={disabled}
|
||||
className="absolute end-7 top-1/2 z-10 -translate-y-1/2 text-muted-foreground opacity-70 hover:opacity-100 focus-visible:opacity-100"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
triggerNode
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Copyright } from "@/components/ui/copyright";
|
||||
import { getSectionIcon, getSectionTitle, rightSidebarSections } from "@/libs/resume/section";
|
||||
import { BuilderSidebarEdge } from "../../-components/edge";
|
||||
import { useBuilderSidebar } from "../../-store/sidebar";
|
||||
import { CustomStylesSectionBuilder } from "./sections/custom-styles";
|
||||
import { DesignSectionBuilder } from "./sections/design";
|
||||
import { ExportSectionBuilder } from "./sections/export";
|
||||
import { InformationSectionBuilder } from "./sections/information";
|
||||
@@ -17,7 +18,6 @@ import { PageSectionBuilder } from "./sections/page";
|
||||
import { ResumeAnalysisSectionBuilder } from "./sections/resume-analysis";
|
||||
import { SharingSectionBuilder } from "./sections/sharing";
|
||||
import { StatisticsSectionBuilder } from "./sections/statistics";
|
||||
import { StylesSectionBuilder } from "./sections/styles";
|
||||
import { TemplateSectionBuilder } from "./sections/template";
|
||||
import { TypographySectionBuilder } from "./sections/typography";
|
||||
|
||||
@@ -27,7 +27,7 @@ function getSectionComponent(type: RightSidebarSection) {
|
||||
.with("layout", () => <LayoutSectionBuilder />)
|
||||
.with("typography", () => <TypographySectionBuilder />)
|
||||
.with("design", () => <DesignSectionBuilder />)
|
||||
.with("styles", () => <StylesSectionBuilder />)
|
||||
.with("styles", () => <CustomStylesSectionBuilder />)
|
||||
.with("page", () => <PageSectionBuilder />)
|
||||
.with("notes", () => <NotesSectionBuilder />)
|
||||
.with("sharing", () => <SharingSectionBuilder />)
|
||||
|
||||
+62
-37
@@ -1,13 +1,14 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { StyleRule } from "@reactive-resume/schema/resume/data";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { isValidElement } from "react";
|
||||
|
||||
const updateResumeData = vi.hoisted(() => vi.fn());
|
||||
const styleRules = vi.hoisted(() => [
|
||||
const styleRules = vi.hoisted<StyleRule[]>(() => [
|
||||
{
|
||||
id: "style-global-heading",
|
||||
label: "All sections: Section heading",
|
||||
@@ -35,7 +36,7 @@ vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useUpdateResumeData: () => updateResumeData,
|
||||
}));
|
||||
|
||||
const { StylesSectionBuilder } = await import("./styles");
|
||||
const { CustomStylesSectionBuilder } = await import("./custom-styles");
|
||||
const { getSectionIcon, getSectionTitle } = await import("@/libs/resume/section");
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -46,14 +47,19 @@ beforeEach(() => {
|
||||
updateResumeData.mockClear();
|
||||
});
|
||||
|
||||
const renderStyles = () =>
|
||||
const renderCustomStyles = () =>
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<StylesSectionBuilder />
|
||||
<CustomStylesSectionBuilder />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
describe("StylesSectionBuilder", () => {
|
||||
const chooseComboboxOption = async (label: string, option: string) => {
|
||||
fireEvent.click(screen.getByLabelText(label));
|
||||
fireEvent.click(await screen.findByRole("option", { name: option }));
|
||||
};
|
||||
|
||||
describe("CustomStylesSectionBuilder", () => {
|
||||
beforeEach(() => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
styleRules.push({
|
||||
@@ -65,8 +71,8 @@ describe("StylesSectionBuilder", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders structured style rule controls", () => {
|
||||
renderStyles();
|
||||
it("renders structured style rule controls", async () => {
|
||||
renderCustomStyles();
|
||||
|
||||
expect(screen.getByLabelText("Target Scope")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Style Slot")).toBeInTheDocument();
|
||||
@@ -91,18 +97,18 @@ describe("StylesSectionBuilder", () => {
|
||||
expect(screen.getByLabelText("Row Gap")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Column Gap")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Border Style")).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Section heading" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText("Style Slot"));
|
||||
expect(await screen.findByRole("option", { name: "Section heading" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "List" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "List item content" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: "Bullet or number" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels the empty font weight option as default", () => {
|
||||
renderStyles();
|
||||
renderCustomStyles();
|
||||
|
||||
const fontWeightSelect = screen.getByLabelText("Font Weight");
|
||||
expect(within(fontWeightSelect).getByRole("option", { name: "Default" })).toBeInTheDocument();
|
||||
expect(within(fontWeightSelect).queryByRole("option", { name: "Template default" })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Font Weight")).toHaveTextContent("Default");
|
||||
expect(screen.queryByText("Template default")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renames the sidebar entry and uses a distinct icon from design", () => {
|
||||
@@ -117,7 +123,7 @@ describe("StylesSectionBuilder", () => {
|
||||
|
||||
it("upserts one style rule for the selected target and slot", () => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
renderStyles();
|
||||
renderCustomStyles();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Text Color"), { target: { value: "rgba(220, 38, 38, 1)" } });
|
||||
|
||||
@@ -139,13 +145,16 @@ describe("StylesSectionBuilder", () => {
|
||||
|
||||
it("stores padding as per-side values", () => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
renderStyles();
|
||||
renderCustomStyles();
|
||||
|
||||
expect(screen.queryByLabelText("Padding")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Top")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Right")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Bottom")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Left")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Padding Top").closest("div")?.parentElement).toHaveClass(
|
||||
"grid-cols-[repeat(auto-fit,minmax(7rem,1fr))]",
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Padding Top"), { target: { value: "12" } });
|
||||
|
||||
@@ -165,11 +174,11 @@ describe("StylesSectionBuilder", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("stores text decoration intent", () => {
|
||||
it("stores text decoration intent", async () => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
renderStyles();
|
||||
renderCustomStyles();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Text Decoration"), { target: { value: "underline" } });
|
||||
await chooseComboboxOption("Text Decoration", "Underline");
|
||||
|
||||
expect(updateResumeData).toHaveBeenCalledTimes(1);
|
||||
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: { metadata: { styleRules: unknown[] } }) => void;
|
||||
@@ -189,7 +198,7 @@ describe("StylesSectionBuilder", () => {
|
||||
|
||||
it("stores margin and gap intent", () => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
renderStyles();
|
||||
renderCustomStyles();
|
||||
|
||||
expect(screen.getByLabelText("Margin Bottom")).toHaveAttribute("min", "-72");
|
||||
expect(screen.getByLabelText("Row Gap")).toHaveAttribute("min", "-72");
|
||||
@@ -230,11 +239,11 @@ describe("StylesSectionBuilder", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("stores list slot rules for rich text lists", () => {
|
||||
it("stores list slot rules for rich text lists", async () => {
|
||||
styleRules.splice(0, styleRules.length);
|
||||
renderStyles();
|
||||
renderCustomStyles();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Style Slot"), { target: { value: "richList" } });
|
||||
await chooseComboboxOption("Style Slot", "List");
|
||||
fireEvent.change(screen.getByLabelText("Row Gap"), { target: { value: "8" } });
|
||||
|
||||
expect(updateResumeData).toHaveBeenCalledTimes(1);
|
||||
@@ -254,7 +263,7 @@ describe("StylesSectionBuilder", () => {
|
||||
});
|
||||
|
||||
it("can reset the selected style rule", () => {
|
||||
renderStyles();
|
||||
renderCustomStyles();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset Style" }));
|
||||
|
||||
@@ -288,12 +297,25 @@ describe("StylesSectionBuilder", () => {
|
||||
});
|
||||
|
||||
it("lists applied style rules and toggles individual rules", () => {
|
||||
renderStyles();
|
||||
styleRules.push({
|
||||
id: "style-global-section",
|
||||
label: "All sections: Section container",
|
||||
enabled: false,
|
||||
target: { scope: "global" },
|
||||
slots: { section: { paddingTop: 4 } },
|
||||
});
|
||||
renderCustomStyles();
|
||||
|
||||
expect(screen.getByText("Applied Rules")).toBeInTheDocument();
|
||||
expect(screen.getByText("All sections: Section heading")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Manage Rules" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("All sections: Section heading")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Off")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("All sections").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Section heading").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Enable All sections: Section container" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Toggle All sections: Section heading"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Disable All sections: Section heading" }));
|
||||
|
||||
expect(updateResumeData).toHaveBeenCalledTimes(1);
|
||||
const recipe = updateResumeData.mock.calls[0]?.[0] as (draft: {
|
||||
@@ -305,18 +327,21 @@ describe("StylesSectionBuilder", () => {
|
||||
expect(draft.metadata.styleRules[0]?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("opens a manage rules dialog with editable rule fields", () => {
|
||||
renderStyles();
|
||||
it("loads a selected applied rule into the editor form", () => {
|
||||
styleRules.push({
|
||||
id: "style-section-type-experience-richListItemContent",
|
||||
label: "Experience: List item content",
|
||||
enabled: true,
|
||||
target: { scope: "sectionType", sectionType: "experience" },
|
||||
slots: { richListItemContent: { lineHeight: 1.4 } },
|
||||
});
|
||||
renderCustomStyles();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Manage Rules" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit Experience: List item content" }));
|
||||
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("Manage Style Rules")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Rule Label")).toHaveValue("All sections: Section heading");
|
||||
expect(screen.getByLabelText("Dialog Text Color")).toHaveValue("rgba(220, 38, 38, 1)");
|
||||
expect(screen.getByLabelText("Dialog Padding Top")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Dialog Padding Right")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Dialog Padding Bottom")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Dialog Padding Left")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Target Scope")).toHaveTextContent("Section type");
|
||||
expect(screen.getByLabelText("Section Type")).toHaveTextContent("Experience");
|
||||
expect(screen.getByLabelText("Style Slot")).toHaveTextContent("List item content");
|
||||
expect(screen.getByLabelText("Line Height")).toHaveValue(1.4);
|
||||
});
|
||||
});
|
||||
+155
-293
@@ -5,26 +5,19 @@ import type {
|
||||
StyleRuleTarget,
|
||||
StyleSlot,
|
||||
} from "@reactive-resume/schema/resume/data";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { SlidersHorizontalIcon, TrashSimpleIcon } from "@phosphor-icons/react";
|
||||
import { EyeIcon, EyeSlashIcon, PencilSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { sectionTypeSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
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 { getSectionTitle } from "@/libs/resume/section";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
@@ -37,6 +30,12 @@ type StyleSlotOption = {
|
||||
group: "Section" | "Rich text";
|
||||
};
|
||||
|
||||
const targetScopeOptions: ComboboxOption<TargetScope>[] = [
|
||||
{ value: "global", label: "All sections" },
|
||||
{ value: "sectionType", label: "Section type" },
|
||||
{ value: "sectionId", label: "Specific section" },
|
||||
];
|
||||
|
||||
const styleSlotOptions: StyleSlotOption[] = [
|
||||
{ value: "section", label: "Section container", group: "Section" },
|
||||
{ value: "heading", label: "Section heading", group: "Section" },
|
||||
@@ -55,10 +54,11 @@ const styleSlotOptions: StyleSlotOption[] = [
|
||||
{ value: "richMark", label: "Highlight", group: "Rich text" },
|
||||
];
|
||||
|
||||
const groupedStyleSlotOptions: [StyleSlotOption["group"], StyleSlotOption[]][] = [
|
||||
["Section", styleSlotOptions.filter((option) => option.group === "Section")],
|
||||
["Rich text", styleSlotOptions.filter((option) => option.group === "Rich text")],
|
||||
];
|
||||
const styleSlotComboboxOptions: ComboboxOption<StyleSlot>[] = styleSlotOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
keywords: [option.group],
|
||||
}));
|
||||
|
||||
const fontWeightOptions = ["100", "200", "300", "400", "500", "600", "700", "800", "900"] as const;
|
||||
const fontStyleOptions = [
|
||||
@@ -93,26 +93,32 @@ const borderStyleOptions = [
|
||||
{ value: "dotted", label: "Dotted" },
|
||||
] as const satisfies readonly { value: NonNullable<StyleIntent["borderStyle"]>; label: string }[];
|
||||
|
||||
export function StylesSectionBuilder() {
|
||||
const controlGridClassName = "grid grid-cols-[repeat(auto-fit,minmax(8rem,1fr))] gap-3";
|
||||
const compactControlGridClassName = "grid grid-cols-[repeat(auto-fit,minmax(7rem,1fr))] gap-3";
|
||||
|
||||
export function CustomStylesSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="styles" className="space-y-4">
|
||||
<StylesSectionForm />
|
||||
<CustomStylesSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function StylesSectionForm() {
|
||||
function CustomStylesSectionForm() {
|
||||
const resume = useCurrentResume();
|
||||
const data = resume.data;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
const sectionOptions = useMemo(() => getSectionIdOptions(data), [data]);
|
||||
const sectionOptions = useMemo<ComboboxOption<string>[]>(() => getSectionIdOptions(data), [data]);
|
||||
const sectionTypeOptions = useMemo<ComboboxOption<string>[]>(
|
||||
() => sectionTypeSchema.options.map((type) => ({ value: type, label: getSectionTitle(type) })),
|
||||
[],
|
||||
);
|
||||
const styleRules = data.metadata.styleRules ?? [];
|
||||
|
||||
const [targetScope, setTargetScope] = useState<TargetScope>("global");
|
||||
const [sectionType, setSectionType] = useState("summary");
|
||||
const [sectionId, setSectionId] = useState("summary");
|
||||
const [slot, setSlot] = useState<StyleSlot>("heading");
|
||||
const [isManageDialogOpen, setManageDialogOpen] = useState(false);
|
||||
|
||||
const target = createTarget({ targetScope, sectionType, sectionId });
|
||||
const ruleId = getStyleRuleId(target, slot);
|
||||
@@ -161,26 +167,14 @@ function StylesSectionForm() {
|
||||
});
|
||||
};
|
||||
|
||||
const updateRuleLabel = (ruleId: string, label: string) => {
|
||||
updateResumeData((draft) => {
|
||||
const rule = (draft.metadata.styleRules ?? []).find((rule) => rule.id === ruleId);
|
||||
if (rule) rule.label = label;
|
||||
});
|
||||
};
|
||||
const editRule = (rule: StyleRule) => {
|
||||
const nextSlot = getConfiguredSlots(rule)[0];
|
||||
if (!nextSlot) return;
|
||||
|
||||
const updateRuleIntent = (ruleId: string, slot: StyleSlot, patch: Partial<StyleIntent>) => {
|
||||
updateResumeData((draft) => {
|
||||
const rules = draft.metadata.styleRules ?? [];
|
||||
const ruleIndex = rules.findIndex((rule) => rule.id === ruleId);
|
||||
const rule = rules[ruleIndex];
|
||||
if (!rule) return;
|
||||
|
||||
const nextIntent = compactIntent({ ...(rule.slots[slot] ?? {}), ...patch });
|
||||
if (Object.keys(nextIntent).length === 0) delete rule.slots[slot];
|
||||
else rule.slots[slot] = nextIntent;
|
||||
|
||||
if (getConfiguredSlots(rule).length === 0) rules.splice(ruleIndex, 1);
|
||||
});
|
||||
setTargetScope(rule.target.scope);
|
||||
if (rule.target.scope === "sectionType") setSectionType(rule.target.sectionType);
|
||||
if (rule.target.scope === "sectionId") setSectionId(rule.target.sectionId);
|
||||
setSlot(nextSlot);
|
||||
};
|
||||
|
||||
const deleteRule = (ruleId: string) => {
|
||||
@@ -191,59 +185,65 @@ function StylesSectionForm() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid @md:grid-cols-2 grid-cols-1 gap-3">
|
||||
<div className={controlGridClassName}>
|
||||
<Field label="Target Scope" id="style-target-scope">
|
||||
<Select
|
||||
<Combobox
|
||||
id="style-target-scope"
|
||||
options={targetScopeOptions}
|
||||
value={targetScope}
|
||||
onChange={(event) => setTargetScope(event.target.value as TargetScope)}
|
||||
>
|
||||
<option value="global">All sections</option>
|
||||
<option value="sectionType">Section type</option>
|
||||
<option value="sectionId">Specific section</option>
|
||||
</Select>
|
||||
onValueChange={(value) => {
|
||||
if (value) setTargetScope(value);
|
||||
}}
|
||||
className="w-full"
|
||||
placeholder="Target scope"
|
||||
searchPlaceholder="Search scopes..."
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{targetScope === "sectionType" && (
|
||||
<Field label="Section Type" id="style-section-type">
|
||||
<Select
|
||||
<Combobox
|
||||
id="style-section-type"
|
||||
options={sectionTypeOptions}
|
||||
value={sectionType}
|
||||
onChange={(event) => setSectionType(event.target.value)}
|
||||
>
|
||||
{sectionTypeSchema.options.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{getSectionTitle(type)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
onValueChange={(value) => {
|
||||
if (value) setSectionType(value);
|
||||
}}
|
||||
className="w-full"
|
||||
placeholder="Section type"
|
||||
searchPlaceholder="Search section types..."
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{targetScope === "sectionId" && (
|
||||
<Field label="Section" id="style-section-id">
|
||||
<Select id="style-section-id" value={sectionId} onChange={(event) => setSectionId(event.target.value)}>
|
||||
{sectionOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Combobox
|
||||
id="style-section-id"
|
||||
options={sectionOptions}
|
||||
value={sectionId}
|
||||
onValueChange={(value) => {
|
||||
if (value) setSectionId(value);
|
||||
}}
|
||||
className="w-full"
|
||||
placeholder="Section"
|
||||
searchPlaceholder="Search sections..."
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Style Slot" id="style-slot">
|
||||
<Select id="style-slot" value={slot} onChange={(event) => setSlot(event.target.value as StyleSlot)}>
|
||||
{groupedStyleSlotOptions.map(([group, options]) => (
|
||||
<optgroup key={group} label={group}>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</Select>
|
||||
<Combobox
|
||||
id="style-slot"
|
||||
options={styleSlotComboboxOptions}
|
||||
value={slot}
|
||||
onValueChange={(value) => {
|
||||
if (value) setSlot(value);
|
||||
}}
|
||||
className="w-full"
|
||||
placeholder="Style slot"
|
||||
searchPlaceholder="Search style slots..."
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -263,18 +263,7 @@ function StylesSectionForm() {
|
||||
data={data}
|
||||
rules={styleRules}
|
||||
onToggleRule={updateRuleEnabled}
|
||||
onDeleteRule={deleteRule}
|
||||
onManageRules={() => setManageDialogOpen(true)}
|
||||
/>
|
||||
|
||||
<ManageStyleRulesDialog
|
||||
data={data}
|
||||
rules={styleRules}
|
||||
open={isManageDialogOpen}
|
||||
onOpenChange={setManageDialogOpen}
|
||||
onToggleRule={updateRuleEnabled}
|
||||
onUpdateRuleLabel={updateRuleLabel}
|
||||
onUpdateRuleIntent={updateRuleIntent}
|
||||
onEditRule={editRule}
|
||||
onDeleteRule={deleteRule}
|
||||
/>
|
||||
</div>
|
||||
@@ -283,26 +272,15 @@ function StylesSectionForm() {
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Label htmlFor={id} className="block min-w-0 text-pretty leading-snug">
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Select({ className, ...props }: ComponentProps<"select">) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none transition-[color,box-shadow]",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorField({
|
||||
label,
|
||||
id,
|
||||
@@ -320,10 +298,11 @@ function ColorField({
|
||||
}) {
|
||||
return (
|
||||
<Field label={label} id={id}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ColorPicker value={value ?? fallback} defaultValue={fallback} onChange={(color) => onChange(color)} />
|
||||
<Input
|
||||
id={id}
|
||||
className="min-w-0"
|
||||
value={value ?? ""}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value.trim() || undefined)}
|
||||
@@ -356,6 +335,7 @@ function NumberInput({
|
||||
<Field label={label} id={inputId}>
|
||||
<Input
|
||||
id={inputId}
|
||||
className="tabular-nums"
|
||||
value={value ?? ""}
|
||||
type="number"
|
||||
min={min}
|
||||
@@ -374,31 +354,24 @@ function AppliedRulesList({
|
||||
data,
|
||||
rules,
|
||||
onToggleRule,
|
||||
onEditRule,
|
||||
onDeleteRule,
|
||||
onManageRules,
|
||||
}: {
|
||||
data: ResumeData;
|
||||
rules: StyleRule[];
|
||||
onToggleRule: (ruleId: string, enabled: boolean) => void;
|
||||
onEditRule: (rule: StyleRule) => void;
|
||||
onDeleteRule: (ruleId: string) => void;
|
||||
onManageRules: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="font-medium text-sm">
|
||||
<Trans>Applied Rules</Trans>
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-xs tabular-nums">
|
||||
{rules.length} {rules.length === 1 ? <Trans>rule</Trans> : <Trans>rules</Trans>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onManageRules}>
|
||||
<SlidersHorizontalIcon data-icon="inline-start" />
|
||||
<Trans>Manage Rules</Trans>
|
||||
</Button>
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="font-medium text-sm">
|
||||
<Trans>Applied Rules</Trans>
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-xs tabular-nums">
|
||||
{rules.length} {rules.length === 1 ? <Trans>rule</Trans> : <Trans>rules</Trans>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
@@ -413,6 +386,7 @@ function AppliedRulesList({
|
||||
data={data}
|
||||
rule={rule}
|
||||
onToggleRule={onToggleRule}
|
||||
onEditRule={onEditRule}
|
||||
onDeleteRule={onDeleteRule}
|
||||
/>
|
||||
))}
|
||||
@@ -426,54 +400,58 @@ function AppliedRuleCard({
|
||||
data,
|
||||
rule,
|
||||
onToggleRule,
|
||||
onEditRule,
|
||||
onDeleteRule,
|
||||
}: {
|
||||
data: ResumeData;
|
||||
rule: StyleRule;
|
||||
onToggleRule: (ruleId: string, enabled: boolean) => void;
|
||||
onEditRule: (rule: StyleRule) => void;
|
||||
onDeleteRule: (ruleId: string) => void;
|
||||
}) {
|
||||
const slots = getConfiguredSlots(rule);
|
||||
const primaryIntent = slots[0] ? rule.slots[slots[0]] : undefined;
|
||||
const fallbackLabel = getRuleFallbackLabel(data, rule);
|
||||
const ruleLabel = rule.label || fallbackLabel;
|
||||
const targetLabel = getTargetLabel(data, rule.target);
|
||||
const slotLabel = slots.length > 0 ? slots.map(getSlotLabel).join(", ") : "No slot";
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-background/70 p-3 transition-opacity", !rule.enabled && "opacity-60")}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate font-medium text-sm">{rule.label || fallbackLabel}</span>
|
||||
{!rule.enabled && (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
<Trans>Off</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary">{getTargetLabel(data, rule.target)}</Badge>
|
||||
{slots.map((slot) => (
|
||||
<Badge key={slot} variant="outline">
|
||||
{getSlotLabel(slot)}
|
||||
</Badge>
|
||||
))}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<RuleScopePill target={targetLabel} slot={slotLabel} />
|
||||
</div>
|
||||
|
||||
{primaryIntent && <RulePropertySummary intent={primaryIntent} />}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={rule.enabled}
|
||||
aria-label={`Toggle ${rule.label || fallbackLabel}`}
|
||||
onCheckedChange={(checked) => onToggleRule(rule.id, checked)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={`Delete ${rule.label || fallbackLabel}`}
|
||||
aria-label={`${rule.enabled ? "Disable" : "Enable"} ${ruleLabel}`}
|
||||
aria-pressed={rule.enabled}
|
||||
onClick={() => onToggleRule(rule.id, !rule.enabled)}
|
||||
>
|
||||
{rule.enabled ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={`Edit ${ruleLabel}`}
|
||||
onClick={() => onEditRule(rule)}
|
||||
>
|
||||
<PencilSimpleIcon />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={`Delete ${ruleLabel}`}
|
||||
onClick={() => onDeleteRule(rule.id)}
|
||||
>
|
||||
<TrashSimpleIcon />
|
||||
@@ -484,130 +462,13 @@ function AppliedRuleCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ManageStyleRulesDialog({
|
||||
data,
|
||||
rules,
|
||||
open,
|
||||
onOpenChange,
|
||||
onToggleRule,
|
||||
onUpdateRuleLabel,
|
||||
onUpdateRuleIntent,
|
||||
onDeleteRule,
|
||||
}: {
|
||||
data: ResumeData;
|
||||
rules: StyleRule[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onToggleRule: (ruleId: string, enabled: boolean) => void;
|
||||
onUpdateRuleLabel: (ruleId: string, label: string) => void;
|
||||
onUpdateRuleIntent: (ruleId: string, slot: StyleSlot, patch: Partial<StyleIntent>) => void;
|
||||
onDeleteRule: (ruleId: string) => void;
|
||||
}) {
|
||||
function RuleScopePill({ target, slot }: { target: string; slot: string }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[min(760px,calc(100svh-2rem))] gap-4 sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Manage Style Rules</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Review and edit the style rules saved on this resume.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 px-3 py-5 text-muted-foreground text-sm">
|
||||
<Trans>No style rules yet.</Trans>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rules.map((rule) => (
|
||||
<ManagedRuleCard
|
||||
key={rule.id}
|
||||
data={data}
|
||||
rule={rule}
|
||||
onToggleRule={onToggleRule}
|
||||
onUpdateRuleLabel={onUpdateRuleLabel}
|
||||
onUpdateRuleIntent={onUpdateRuleIntent}
|
||||
onDeleteRule={onDeleteRule}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagedRuleCard({
|
||||
data,
|
||||
rule,
|
||||
onToggleRule,
|
||||
onUpdateRuleLabel,
|
||||
onUpdateRuleIntent,
|
||||
onDeleteRule,
|
||||
}: {
|
||||
data: ResumeData;
|
||||
rule: StyleRule;
|
||||
onToggleRule: (ruleId: string, enabled: boolean) => void;
|
||||
onUpdateRuleLabel: (ruleId: string, label: string) => void;
|
||||
onUpdateRuleIntent: (ruleId: string, slot: StyleSlot, patch: Partial<StyleIntent>) => void;
|
||||
onDeleteRule: (ruleId: string) => void;
|
||||
}) {
|
||||
const slots = getConfiguredSlots(rule);
|
||||
const fallbackLabel = getRuleFallbackLabel(data, rule);
|
||||
const labelId = `style-dialog-${slugify(rule.id)}-label`;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-lg border bg-background/70 p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Field label="Rule Label" id={labelId}>
|
||||
<Input
|
||||
id={labelId}
|
||||
value={rule.label}
|
||||
onChange={(event) => onUpdateRuleLabel(rule.id, event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary">{getTargetLabel(data, rule.target)}</Badge>
|
||||
{slots.map((slot) => (
|
||||
<Badge key={slot} variant="outline">
|
||||
{getSlotLabel(slot)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 pt-7">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={rule.enabled}
|
||||
aria-label={`Toggle ${rule.label || fallbackLabel}`}
|
||||
onCheckedChange={(checked) => onToggleRule(rule.id, checked)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${rule.label || fallbackLabel}`}
|
||||
onClick={() => onDeleteRule(rule.id)}
|
||||
>
|
||||
<TrashSimpleIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{slots.map((slot) => (
|
||||
<RuleIntentEditor
|
||||
key={slot}
|
||||
idPrefix={`style-dialog-${slugify(rule.id)}-${slot}`}
|
||||
intent={rule.slots[slot] ?? {}}
|
||||
labelPrefix="Dialog"
|
||||
onChange={(patch) => onUpdateRuleIntent(rule.id, slot, patch)}
|
||||
/>
|
||||
))}
|
||||
<div className="inline-flex max-w-full overflow-hidden rounded-sm border border-border/80 bg-background text-xs shadow-xs">
|
||||
<span className="min-w-0 max-w-32 truncate bg-secondary px-2.5 py-1 font-medium text-secondary-foreground">
|
||||
{target}
|
||||
</span>
|
||||
<span className="min-w-0 max-w-40 truncate px-2.5 py-1 font-medium text-foreground">{slot}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -628,7 +489,7 @@ function RuleIntentEditor({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ControlPanel title="Color">
|
||||
<div className="grid @md:grid-cols-2 grid-cols-1 gap-3">
|
||||
<div className={controlGridClassName}>
|
||||
<ColorField
|
||||
label={`${labelStart}Text Color`}
|
||||
id={`${idPrefix}-color`}
|
||||
@@ -666,7 +527,7 @@ function RuleIntentEditor({
|
||||
</ControlPanel>
|
||||
|
||||
<ControlPanel title="Text">
|
||||
<div className="grid @md:grid-cols-2 grid-cols-1 gap-3">
|
||||
<div className={controlGridClassName}>
|
||||
<NumberInput
|
||||
label={`${labelStart}Font Size`}
|
||||
id={`${idPrefix}-font-size`}
|
||||
@@ -746,7 +607,7 @@ function RuleIntentEditor({
|
||||
<MarginSideInputs idPrefix={idPrefix} intent={intent} labelPrefix={labelPrefix} onChange={onChange} />
|
||||
</ControlSubsection>
|
||||
<ControlSubsection title="Gap">
|
||||
<div className="grid @md:grid-cols-2 grid-cols-1 gap-3">
|
||||
<div className={controlGridClassName}>
|
||||
<NumberInput
|
||||
label={`${labelStart}Row Gap`}
|
||||
id={`${idPrefix}-row-gap`}
|
||||
@@ -769,7 +630,7 @@ function RuleIntentEditor({
|
||||
</ControlPanel>
|
||||
|
||||
<ControlPanel title="Border">
|
||||
<div className="grid @md:grid-cols-3 grid-cols-1 gap-3">
|
||||
<div className={controlGridClassName}>
|
||||
<IntentSelectField
|
||||
label={`${labelStart}Border Style`}
|
||||
id={`${idPrefix}-border-style`}
|
||||
@@ -835,23 +696,21 @@ function IntentSelectField<TValue extends string>({
|
||||
label: string;
|
||||
id: string;
|
||||
value: TValue | undefined;
|
||||
options: readonly { value: TValue; label: string }[];
|
||||
options: readonly ComboboxOption<TValue>[];
|
||||
onChange: (value: TValue | undefined) => void;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label} id={id}>
|
||||
<Select
|
||||
<Combobox
|
||||
id={id}
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange((event.target.value || undefined) as TValue | undefined)}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
options={[...options]}
|
||||
value={value ?? null}
|
||||
onValueChange={(nextValue) => onChange(nextValue ?? undefined)}
|
||||
className="w-full"
|
||||
showClear
|
||||
placeholder="Default"
|
||||
searchPlaceholder={`Search ${label.toLowerCase()}...`}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -867,20 +726,23 @@ function FontWeightField({
|
||||
value: StyleIntent["fontWeight"] | undefined;
|
||||
onChange: (value: StyleIntent["fontWeight"] | undefined) => void;
|
||||
}) {
|
||||
const options: ComboboxOption<NonNullable<StyleIntent["fontWeight"]>>[] = fontWeightOptions.map((weight) => ({
|
||||
value: weight,
|
||||
label: weight,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Field label={label} id={id}>
|
||||
<Select
|
||||
<Combobox
|
||||
id={id}
|
||||
value={value ?? ""}
|
||||
onChange={(event) => onChange((event.target.value || undefined) as StyleIntent["fontWeight"] | undefined)}
|
||||
>
|
||||
<option value="">Default</option>
|
||||
{fontWeightOptions.map((weight) => (
|
||||
<option key={weight} value={weight}>
|
||||
{weight}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
options={options}
|
||||
value={value ?? null}
|
||||
onValueChange={(nextValue) => onChange(nextValue ?? undefined)}
|
||||
className="w-full"
|
||||
showClear
|
||||
placeholder="Default"
|
||||
searchPlaceholder="Search font weights..."
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -917,7 +779,7 @@ function PaddingSideInputs({
|
||||
const labelStart = labelPrefix ? `${labelPrefix} ` : "";
|
||||
|
||||
return (
|
||||
<div className="grid @lg:grid-cols-4 grid-cols-2 gap-3">
|
||||
<div className={compactControlGridClassName}>
|
||||
{paddingSideOptions.map((side) => (
|
||||
<NumberInput
|
||||
key={side.property}
|
||||
@@ -947,7 +809,7 @@ function MarginSideInputs({
|
||||
const labelStart = labelPrefix ? `${labelPrefix} ` : "";
|
||||
|
||||
return (
|
||||
<div className="grid @lg:grid-cols-4 grid-cols-2 gap-3">
|
||||
<div className={compactControlGridClassName}>
|
||||
{marginSideOptions.map((side) => (
|
||||
<NumberInput
|
||||
key={side.property}
|
||||
+2
-2
@@ -13,7 +13,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/amruthpillai/reactive-resume.git"
|
||||
},
|
||||
"packageManager": "pnpm@11.3.0+sha512.2c403d6594527287672b1f7056343a1f7c3634036a67ffabfcc2b3d7595d843768f8787148d1b57cf7956c90606bbd192857c363af19e96d2d0ec9ec5741d215",
|
||||
"packageManager": "pnpm@11.4.0",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*",
|
||||
@@ -38,7 +38,7 @@
|
||||
"test:agent": "turbo run test:agent"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@biomejs/biome": "^2.4.16",
|
||||
"@commitlint/cli": "^21.0.1",
|
||||
"@commitlint/config-conventional": "^21.0.1",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.79",
|
||||
"@ai-sdk/google": "^3.0.79",
|
||||
"@ai-sdk/anthropic": "^3.0.80",
|
||||
"@ai-sdk/google": "^3.0.80",
|
||||
"@ai-sdk/openai": "^3.0.65",
|
||||
"@ai-sdk/openai-compatible": "^2.0.48",
|
||||
"@aws-sdk/client-s3": "^3.1054.0",
|
||||
"@aws-sdk/client-s3": "^3.1055.0",
|
||||
"@orpc/client": "^1.14.3",
|
||||
"@orpc/experimental-ratelimit": "^1.14.3",
|
||||
"@orpc/server": "^1.14.3",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"dependencies": {
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"docx": "^9.7.0"
|
||||
"docx": "^9.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"nodemailer": "^8.0.9",
|
||||
"react": "^19.2.6",
|
||||
"react-email": "^6.4.0"
|
||||
"react-email": "^6.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-email/ui": "^6.4.0",
|
||||
"@react-email/ui": "^6.5.0",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/react": "^19.2.15",
|
||||
|
||||
@@ -210,7 +210,6 @@ const useAzurillTemplate = (): AzurillTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -206,7 +206,6 @@ const useBronzorTemplate = (): BronzorTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -220,7 +220,6 @@ const useChikoritaTemplate = (): ChikoritaTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -248,7 +248,6 @@ const useDitgarTemplate = (): DitgarTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -216,7 +216,6 @@ const useDittoTemplate = (): DittoTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -245,7 +245,6 @@ const useGengarTemplate = (): GengarTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -225,7 +225,6 @@ const useGlalieTemplate = (): GlalieTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -188,7 +188,6 @@ const useKakunaTemplate = (): KakunaTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -190,7 +190,6 @@ const useLaprasTemplate = (): LaprasTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -216,7 +216,6 @@ const useLeafishTemplate = (): LeafishTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -191,7 +191,6 @@ const useMeowthTemplate = (): MeowthTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -186,7 +186,6 @@ const useOnyxTemplate = (): OnyxTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -223,7 +223,6 @@ const usePikachuTemplate = (): PikachuTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -236,7 +236,6 @@ const useRhyhornTemplate = (): RhyhornTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -183,7 +183,6 @@ const useScizorTemplate = (): ScizorTemplate => {
|
||||
richListItemContent: {
|
||||
...bodyText,
|
||||
flex: 1,
|
||||
lineHeight: metadata.typography.body.lineHeight * 0.5,
|
||||
},
|
||||
splitRow: {
|
||||
flexDirection: r.row,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const templatesDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
|
||||
const templatePageFiles = readdirSync(templatesDir, { withFileTypes: true }).flatMap((entry) => {
|
||||
if (!entry.isDirectory() || entry.name === "shared") return [];
|
||||
|
||||
const templateDir = join(templatesDir, entry.name);
|
||||
const pageFile = readdirSync(templateDir).find((file) => file.endsWith("Page.tsx"));
|
||||
|
||||
return pageFile ? [join(templateDir, pageFile)] : [];
|
||||
});
|
||||
|
||||
describe("rich text template styles", () => {
|
||||
it.each(
|
||||
templatePageFiles.map((file) => [basename(file), file]),
|
||||
)("%s keeps list item rich text on the global body line height", (_name, file) => {
|
||||
const source = readFileSync(file, "utf8");
|
||||
const richListItemContentBlock = source.match(/richListItemContent:\s*{(?<body>[\s\S]*?)^\s*},/m);
|
||||
|
||||
expect(richListItemContentBlock?.groups?.body).toBeDefined();
|
||||
expect(richListItemContentBlock?.groups?.body).toContain("...bodyText");
|
||||
expect(richListItemContentBlock?.groups?.body).not.toMatch(/\blineHeight:/);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"shadcn": "^4.8.1",
|
||||
"shadcn": "^4.8.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ const buttonVariants = cva(
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
"border-border bg-transparent hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost: "hover:bg-muted hover:text-foreground",
|
||||
|
||||
@@ -194,7 +194,7 @@ function ComboboxChips({
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"flex min-h-9 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1 has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
|
||||
"flex min-h-9 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-data-[slot=combobox-chip]:px-1 has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.4.6",
|
||||
"dompurify": "^3.4.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"uuid": "^14.0.0",
|
||||
|
||||
Generated
+337
-155
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user