feat: add section heading icons to PDF templates (#3127)

* feat: add section heading icons to PDF templates

Add customizable Phosphor icons before section titles in PDF output.
Users can toggle visibility globally via a new "Hide section heading icons"
switch (independent of item-level icons) and customize individual section
icons through the builder sidebar icon picker.

- Add `icon` field to `baseSectionSchema` and `summarySchema`
- Add `hideSectionIcons` to `pageSchema` (defaults to true for backward compat)
- Implement `SectionHeadingIcon` component with heading font-size scaling
- Support "none" sentinel for per-section icon hiding
- Fallback to sensible defaults (briefcase, graduation-cap, etc.) for legacy data
- Add icon picker to builder sidebar sections and custom section dialogs

Closes #2632

* test: add unit tests for section heading icons

- Add tests for getResumeSectionIcon() covering built-in sections,
  summary, custom sections, "none" sentinel, and default fallbacks
- Add schema tests for baseSectionSchema icon field, summarySchema icon,
  and pageSchema hideSectionIcons default behavior

* refactor: minor updates to icon display

* Update apps/web/locales/es-ES.po

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
JamesGoslings
2026-06-01 20:02:42 +08:00
committed by GitHub
parent 1522794733
commit b932711f08
81 changed files with 1153 additions and 306 deletions
@@ -1,10 +1,12 @@
import type { SectionType } from "@reactive-resume/schema/resume/data";
import type { LeftSidebarSection } from "@/libs/resume/section";
import { CaretDownIcon } from "@phosphor-icons/react";
import { getDefaultSectionIconName } from "@reactive-resume/schema/resume/section-icons";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
import { Button } from "@reactive-resume/ui/components/button";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume } from "@/features/resume/builder/draft";
import { IconPicker } from "@/components/input/icon-picker";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { getSectionIcon, getSectionTitle } from "@/libs/resume/section";
import { useSectionStore } from "../../../-store/section";
import { SectionDropdownMenu } from "./section-menu";
@@ -15,6 +17,7 @@ type Props = React.ComponentProps<typeof AccordionContent> & {
export function SectionBase({ type, className, ...props }: Props) {
const resume = useCurrentResume();
const updateResumeData = useUpdateResumeData();
const data = resume.data;
const section =
type === "basics"
@@ -28,9 +31,27 @@ export function SectionBase({ type, className, ...props }: Props) {
: data.sections[type];
const isHidden = "hidden" in section && section.hidden;
const hasSectionIcon = !["picture", "basics", "custom"].includes(type);
const rawIcon = "icon" in section && typeof section.icon === "string" ? section.icon : "";
const fallbackIcon = hasSectionIcon ? getDefaultSectionIconName(type as "summary" | SectionType) : "";
const sectionIcon = rawIcon === "none" ? "" : rawIcon || fallbackIcon;
const collapsed = useSectionStore((state) => state.sections[type]?.collapsed ?? false);
const toggleCollapsed = useSectionStore((state) => state.toggleCollapsed);
const onIconChange = (icon: string) => {
// Store "none" when user explicitly picks the empty/prohibit option
const valueToStore = icon === "" ? "none" : icon;
updateResumeData((draft) => {
if (type === "summary") {
draft.summary.icon = valueToStore;
} else if (type !== "basics" && type !== "picture" && type !== "custom") {
draft.sections[type as SectionType].icon = valueToStore;
}
});
};
return (
<Accordion
id={`sidebar-${type}`}
@@ -49,8 +70,12 @@ export function SectionBase({ type, className, ...props }: Props) {
}
/>
<div className="flex flex-1 items-center gap-x-4">
{getSectionIcon(type)}
<div className={cn("flex flex-1 items-center gap-x-4", hasSectionIcon && "gap-x-2")}>
{hasSectionIcon ? (
<IconPicker value={sectionIcon} onChange={onIconChange} size="icon" variant="ghost" />
) : (
getSectionIcon(type)
)}
<h2 className="line-clamp-1 font-semibold text-2xl tracking-tight">
{("title" in section && section.title) || getSectionTitle(type)}
</h2>
@@ -261,7 +261,7 @@ function PageSectionForm() {
<form.Field name="hideIcons">
{(field) => (
<FormItem
className="col-span-full flex items-center gap-x-3 py-2"
className="col-span-full flex items-center gap-x-3 py-1"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormControl
@@ -276,7 +276,31 @@ function PageSectionForm() {
}
/>
<FormLabel>
<Trans>Hide all icons on the resume</Trans>
<Trans>Hide Icons</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.Field name="hideSectionIcons">
{(field) => (
<FormItem
className="col-span-full flex items-center gap-x-3 py-1"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked) => {
field.handleChange(checked);
handleAutoSave("hideSectionIcons", checked);
}}
/>
}
/>
<FormLabel>
<Trans>Hide Section Icons</Trans>
</FormLabel>
</FormItem>
)}