* chore(release): v5.1.0

* feat: implement resume thumbnails

* fix: remove unused mcp tools

* docs: fix formatting of docs
This commit is contained in:
Amruth Pillai
2026-05-07 15:12:33 +02:00
committed by GitHub
parent 51c366310e
commit 50ba37a27f
1015 changed files with 106087 additions and 141872 deletions
@@ -0,0 +1,122 @@
import type { LeftSidebarSection } from "@/libs/resume/section";
import { Fragment, useCallback, useRef } from "react";
import { match } from "ts-pattern";
import { Avatar, AvatarFallback, AvatarImage } from "@reactive-resume/ui/components/avatar";
import { Button } from "@reactive-resume/ui/components/button";
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
import { Separator } from "@reactive-resume/ui/components/separator";
import { getInitials } from "@reactive-resume/utils/string";
import { UserDropdownMenu } from "@/components/user/dropdown-menu";
import { getSectionIcon, getSectionTitle, leftSidebarSections } from "@/libs/resume/section";
import { BuilderSidebarEdge } from "../../-components/edge";
import { useBuilderSidebar } from "../../-store/sidebar";
import { AwardsSectionBuilder } from "./sections/awards";
import { BasicsSectionBuilder } from "./sections/basics";
import { CertificationsSectionBuilder } from "./sections/certifications";
import { CustomSectionBuilder } from "./sections/custom";
import { EducationSectionBuilder } from "./sections/education";
import { ExperienceSectionBuilder } from "./sections/experience";
import { InterestsSectionBuilder } from "./sections/interests";
import { LanguagesSectionBuilder } from "./sections/languages";
import { PictureSectionBuilder } from "./sections/picture";
import { ProfilesSectionBuilder } from "./sections/profiles";
import { ProjectsSectionBuilder } from "./sections/projects";
import { PublicationsSectionBuilder } from "./sections/publications";
import { ReferencesSectionBuilder } from "./sections/references";
import { SkillsSectionBuilder } from "./sections/skills";
import { SummarySectionBuilder } from "./sections/summary";
import { VolunteerSectionBuilder } from "./sections/volunteer";
function getSectionComponent(type: LeftSidebarSection) {
return match(type)
.with("picture", () => <PictureSectionBuilder />)
.with("basics", () => <BasicsSectionBuilder />)
.with("summary", () => <SummarySectionBuilder />)
.with("profiles", () => <ProfilesSectionBuilder />)
.with("experience", () => <ExperienceSectionBuilder />)
.with("education", () => <EducationSectionBuilder />)
.with("projects", () => <ProjectsSectionBuilder />)
.with("skills", () => <SkillsSectionBuilder />)
.with("languages", () => <LanguagesSectionBuilder />)
.with("interests", () => <InterestsSectionBuilder />)
.with("awards", () => <AwardsSectionBuilder />)
.with("certifications", () => <CertificationsSectionBuilder />)
.with("publications", () => <PublicationsSectionBuilder />)
.with("volunteer", () => <VolunteerSectionBuilder />)
.with("references", () => <ReferencesSectionBuilder />)
.with("custom", () => <CustomSectionBuilder />)
.exhaustive();
}
export function BuilderSidebarLeft() {
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
return (
<>
<SidebarEdge scrollAreaRef={scrollAreaRef} />
<ScrollArea ref={scrollAreaRef} className="@container h-[calc(100svh-3.5rem)] bg-background sm:ms-12">
<div className="space-y-4 p-4">
{leftSidebarSections.map((section) => (
<Fragment key={section}>
{getSectionComponent(section)}
<Separator />
</Fragment>
))}
</div>
</ScrollArea>
</>
);
}
type SidebarEdgeProps = {
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
};
function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
const toggleSidebar = useBuilderSidebar((state) => state.toggleSidebar);
const scrollToSection = useCallback(
(section: LeftSidebarSection) => {
if (!scrollAreaRef.current) return;
toggleSidebar("left", true);
const sectionElement = scrollAreaRef.current.querySelector(`#sidebar-${section}`);
sectionElement?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
},
[toggleSidebar, scrollAreaRef],
);
return (
<BuilderSidebarEdge side="left">
<div className="flex min-h-0 w-full flex-1 flex-col items-center gap-y-2 overflow-hidden">
<div className="no-scrollbar min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden">
<div className="flex min-h-full flex-col items-center justify-center gap-y-2">
{leftSidebarSections.map((section) => (
<Button
key={section}
size="icon"
variant="ghost"
title={getSectionTitle(section)}
onClick={() => scrollToSection(section)}
>
{getSectionIcon(section)}
</Button>
))}
</div>
</div>
<UserDropdownMenu>
{({ session }) => (
<Button size="icon" variant="ghost">
<Avatar className="size-6">
<AvatarImage src={session.user.image ?? undefined} />
<AvatarFallback className="text-[0.5rem]">{getInitials(session.user.name)}</AvatarFallback>
</Avatar>
</Button>
)}
</UserDropdownMenu>
</div>
</BuilderSidebarEdge>
);
}
@@ -0,0 +1,36 @@
import type { awardItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function AwardsSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.awards;
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof awardItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.awards.items = items;
});
};
return (
<SectionBase type="awards" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="awards" item={item} title={item.title} subtitle={item.awarder} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="awards">
<Trans>Add a new award</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -0,0 +1,194 @@
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { basicsSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { URLInput } from "@/components/input/url-input";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/components/resume/use-resume";
import { useAppForm } from "@/libs/tanstack-form";
import { SectionBase } from "../shared/section-base";
import { CustomFieldsSection } from "./custom-fields";
export function BasicsSectionBuilder() {
return (
<SectionBase type="basics">
<BasicsSectionForm />
</SectionBase>
);
}
const formSchema = basicsSchema;
type FormValues = z.infer<typeof formSchema>;
function BasicsSectionForm() {
const basics = useCurrentBuilderResumeSelector((resume) => resume.data.basics);
const updateResumeData = useUpdateResumeData();
const persist = (data: FormValues) => {
updateResumeData((draft) => {
draft.basics = data;
});
};
const form = useAppForm({
defaultValues: basics,
validators: { onChange: formSchema },
onSubmit: ({ value }) => {
persist(value);
},
});
return (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<form.Field name="name">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
void form.handleSubmit();
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="headline">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Headline</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
void form.handleSubmit();
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="email">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
<FormControl
render={
<Input
type="email"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
void form.handleSubmit();
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="phone">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Phone</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
void form.handleSubmit();
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="location">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Location</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
void form.handleSubmit();
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
name={field.name}
value={field.state.value}
onChange={(value) => {
field.handleChange(value);
void form.handleSubmit();
}}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<CustomFieldsSection form={form} />
</form>
);
}
@@ -0,0 +1,45 @@
import type { certificationItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function CertificationsSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.certifications;
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof certificationItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.certifications.items = items;
});
};
return (
<SectionBase
type="certifications"
className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}
>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem
key={item.id}
type="certifications"
item={item}
title={item.title}
subtitle={[item.issuer, item.date].filter(Boolean).join(" • ") || undefined}
/>
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="certifications">
<Trans>Add a new certification</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -0,0 +1,184 @@
import type { basicsSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DotsSixVerticalIcon, LinkIcon, ListPlusIcon, XIcon } from "@phosphor-icons/react";
import { Reorder, useDragControls } from "motion/react";
import { Button } from "@reactive-resume/ui/components/button";
import { FormControl, FormItem } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { generateId } from "@reactive-resume/utils/string";
import { IconPicker } from "@/components/input/icon-picker";
import { withForm } from "@/libs/tanstack-form";
type FormValues = z.infer<typeof basicsSchema>;
type CustomField = FormValues["customFields"][number];
const defaultValues: FormValues = {
name: "",
headline: "",
email: "",
phone: "",
location: "",
website: { url: "", label: "" },
customFields: [],
};
export const CustomFieldsSection = withForm({
defaultValues,
render: ({ form }) => {
return (
<form.Field name="customFields" mode="array">
{(customFieldsField) => (
<Reorder.Group
className="touch-none space-y-4"
values={customFieldsField.state.value}
onReorder={(fields) => {
customFieldsField.setValue(fields);
void form.handleSubmit();
}}
>
{customFieldsField.state.value.map((field: CustomField, index: number) => (
<CustomFieldItem key={field.id} field={field}>
<form.Field name={`customFields[${index}].icon`}>
{(iconField) => (
<FormItem className="shrink-0">
<FormControl
render={
<IconPicker
name={iconField.name}
value={iconField.state.value}
className="rounded-r-none! border-e-0!"
onChange={(icon) => {
iconField.handleChange(icon);
void form.handleSubmit();
}}
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name={`customFields[${index}].text`}>
{(textField) => (
<FormItem className="flex-1">
<FormControl
render={
<Input
name={textField.name}
value={textField.state.value}
className="rounded-l-none!"
onChange={(e) => {
textField.handleChange(e.target.value);
void form.handleSubmit();
}}
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name={`customFields[${index}].link`}>
{(linkField) => (
<Popover>
<PopoverTrigger
render={
<Button size="icon" variant="ghost" className="ms-1">
<LinkIcon />
</Button>
}
/>
<PopoverContent align="center">
<div className="flex flex-col gap-y-1.5">
<Label htmlFor={linkField.name} className="text-muted-foreground text-xs">
<Trans>Enter the URL to link to</Trans>
</Label>
<Input
type="url"
value={linkField.state.value}
id={linkField.name}
placeholder={t({
comment: "Placeholder text for custom link URL field in resume builder",
message: "Must start with https://",
})}
onChange={(e) => {
linkField.handleChange(e.target.value);
void form.handleSubmit();
}}
/>
</div>
</PopoverContent>
</Popover>
)}
</form.Field>
<Button
size="icon"
variant="ghost"
onClick={() => {
customFieldsField.removeValue(index);
void form.handleSubmit();
}}
>
<XIcon />
</Button>
</CustomFieldItem>
))}
<Button
variant="ghost"
onClick={() => {
customFieldsField.pushValue({ id: generateId(), icon: "acorn", text: "", link: "" });
void form.handleSubmit();
}}
>
<ListPlusIcon />
<Trans>Add a custom field</Trans>
</Button>
</Reorder.Group>
)}
</form.Field>
);
},
});
type CustomFieldItemProps = {
field: CustomField;
children: React.ReactNode;
};
function CustomFieldItem({ field, children }: CustomFieldItemProps) {
const controls = useDragControls();
return (
<Reorder.Item
key={field.id}
value={field}
dragListener={false}
dragControls={controls}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex touch-none items-center"
>
<Button
size="icon"
variant="ghost"
className="me-2 touch-none"
onPointerDown={(e) => {
e.preventDefault();
controls.start(e);
}}
>
<DotsSixVerticalIcon />
</Button>
{children}
</Reorder.Item>
);
}
@@ -0,0 +1,311 @@
import type {
CustomSection,
CustomSectionItem as CustomSectionItemType,
CustomSectionType,
} from "@reactive-resume/schema/resume/data";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import {
ColumnsIcon,
CopySimpleIcon,
DotsThreeVerticalIcon,
EyeClosedIcon,
EyeIcon,
PencilSimpleLineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { AnimatePresence, Reorder } from "motion/react";
import { match } from "ts-pattern";
import { Badge } from "@reactive-resume/ui/components/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { stripHtml } from "@reactive-resume/utils/string";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import { getSectionTitle } from "@/libs/resume/section";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
function getItemTitle(type: CustomSectionType, item: CustomSectionItemType): string {
return match(type)
.with("summary", () => {
if ("content" in item) {
const stripped = stripHtml(item.content);
return stripped.length > 50
? `${stripped.slice(0, 50)}...`
: stripped ||
t({
comment: "Fallback title for a custom summary item in resume builder when content is empty",
message: "Summary",
});
}
return t({
comment: "Fallback title for a custom summary item in resume builder when content is unavailable",
message: "Summary",
});
})
.with("profiles", () => ("network" in item ? item.network : ""))
.with("experience", () => ("company" in item ? item.company : ""))
.with("education", () => ("school" in item ? item.school : ""))
.with("projects", () => ("name" in item ? item.name : ""))
.with("skills", () => ("name" in item ? item.name : ""))
.with("languages", () => ("language" in item ? item.language : ""))
.with("interests", () => ("name" in item ? item.name : ""))
.with("awards", () => ("title" in item ? item.title : ""))
.with("certifications", () => ("title" in item ? item.title : ""))
.with("publications", () => ("title" in item ? item.title : ""))
.with("volunteer", () => ("organization" in item ? item.organization : ""))
.with("references", () => ("name" in item ? item.name : ""))
.with("cover-letter", () => {
if ("recipient" in item) {
const stripped = stripHtml(item.recipient);
return stripped.length > 50
? `${stripped.slice(0, 50)}...`
: stripped ||
t({
comment: "Fallback title for a custom cover letter item in resume builder when recipient is empty",
message: "Cover Letter",
});
}
return t({
comment: "Fallback title for a custom cover letter item in resume builder when recipient is unavailable",
message: "Cover Letter",
});
})
.exhaustive();
}
function getItemSubtitle(type: CustomSectionType, item: CustomSectionItemType): string | undefined {
return match(type)
.with("summary", () => undefined)
.with("profiles", () => ("username" in item ? item.username : undefined))
.with("experience", () => ("position" in item ? item.position : undefined))
.with("education", () => ("degree" in item ? item.degree : undefined))
.with("projects", () => ("period" in item ? item.period : undefined))
.with("skills", () => ("proficiency" in item ? item.proficiency : undefined))
.with("languages", () => ("fluency" in item ? item.fluency : undefined))
.with("interests", () => undefined)
.with("awards", () => ("awarder" in item ? item.awarder : undefined))
.with("certifications", () => ("issuer" in item ? item.issuer : undefined))
.with("publications", () => ("publisher" in item ? item.publisher : undefined))
.with("volunteer", () => ("period" in item ? item.period : undefined))
.with("references", () => undefined)
.with("cover-letter", () => {
if ("content" in item) {
const stripped = stripHtml(item.content);
return stripped.length > 50 ? `${stripped.slice(0, 50)}...` : stripped || undefined;
}
return undefined;
})
.exhaustive();
}
export function CustomSectionBuilder() {
const resume = useCurrentResume();
const customSections = resume.data.customSections;
return (
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
<AnimatePresence>
{customSections.map((section) => (
<CustomSectionContainer key={section.id} section={section} />
))}
</AnimatePresence>
{/* Add Custom Section Button */}
<SectionAddItemButton type="custom" variant="outline" className="rounded-md">
<Trans>Add a new custom section</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
function CustomSectionContainer({ section }: { section: CustomSection }) {
const { openDialog } = useDialogStore();
const updateResumeData = useUpdateResumeData();
const onUpdateSection = () => {
openDialog("resume.sections.custom.update", section);
};
const handleReorder = (items: CustomSectionItemType[]) => {
updateResumeData((draft) => {
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
if (sectionIndex === -1) return;
draft.customSections[sectionIndex].items = items;
});
};
return (
<div className="rounded-md border">
{/* Section Header */}
<div className="group flex select-none">
<button
type="button"
onClick={onUpdateSection}
className={cn(
"flex flex-1 flex-col items-start justify-center space-y-0.5 p-4 text-start transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1",
section.hidden && "opacity-50",
)}
>
<Badge variant="secondary" className="mb-1.5 rounded-md">
{getSectionTitle(section.type)}
</Badge>
<span className="line-clamp-1 text-wrap font-medium text-base">{section.title}</span>
<span className="text-muted-foreground text-xs">
<Plural value={section.items.length} one="# item" other="# items" />
</span>
</button>
<CustomSectionDropdownMenu section={section} />
</div>
{/* Section Items */}
{section.items.length > 0 && (
<div className={cn("border-t", section.hidden && "opacity-50")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem
key={item.id}
type={section.type}
item={item}
customSectionId={section.id}
title={getItemTitle(section.type, item)}
subtitle={getItemSubtitle(section.type, item)}
/>
))}
</AnimatePresence>
</Reorder.Group>
</div>
)}
{/* Add Item Button */}
<div className="border-t">
<SectionAddItemButton type={section.type} customSectionId={section.id}>
<Trans>Add a new item</Trans>
</SectionAddItemButton>
</div>
</div>
);
}
function CustomSectionDropdownMenu({ section }: { section: CustomSection }) {
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const updateResumeData = useUpdateResumeData();
const onToggleSectionVisibility = () => {
updateResumeData((draft) => {
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
if (sectionIndex === -1) return;
draft.customSections[sectionIndex].hidden = !draft.customSections[sectionIndex].hidden;
});
};
const onUpdateSection = () => {
openDialog("resume.sections.custom.update", section);
};
const onDuplicateSection = () => {
openDialog("resume.sections.custom.create", section);
};
const onSetColumns = (value: string) => {
updateResumeData((draft) => {
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
if (sectionIndex === -1) return;
draft.customSections[sectionIndex].columns = Number.parseInt(value, 10);
});
};
const onDeleteSection = async () => {
const confirmed = await confirm(t`Are you sure you want to delete this custom section?`, {
confirmText: t({
comment: "Destructive confirmation button label when deleting a custom section in resume builder",
message: "Delete",
}),
cancelText: t({
comment: "Confirmation dialog button label to abort deleting a custom section in resume builder",
message: "Cancel",
}),
});
if (!confirmed) return;
updateResumeData((draft) => {
draft.customSections = draft.customSections.filter((_section) => _section.id !== section.id);
draft.metadata.layout.pages = draft.metadata.layout.pages.map((page) => ({
...page,
main: page.main.filter((id) => id !== section.id),
sidebar: page.sidebar.filter((id) => id !== section.id),
}));
});
};
return (
<DropdownMenu>
<DropdownMenuTrigger>
<DotsThreeVerticalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem onClick={onToggleSectionVisibility}>
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onClick={onUpdateSection}>
<PencilSimpleLineIcon />
<Trans>Update</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={onDuplicateSection}>
<CopySimpleIcon />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ColumnsIcon />
<Trans>Columns</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={section.columns.toString()} onValueChange={onSetColumns}>
{[1, 2, 3, 4, 5, 6].map((column) => (
<DropdownMenuRadioItem key={column} value={column.toString()}>
<Plural value={column} one="# Column" other="# Columns" />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={onDeleteSection}>
<TrashSimpleIcon />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,36 @@
import type { educationItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function EducationSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.education;
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof educationItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.education.items = items;
});
};
return (
<SectionBase type="education" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="education" item={item} title={item.school} subtitle={item.degree} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="education">
<Trans>Add a new education</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -0,0 +1,45 @@
import type { experienceItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { plural } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function ExperienceSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.experience;
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof experienceItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.experience.items = items;
});
};
return (
<SectionBase type="experience" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence initial={false} mode="popLayout">
{section.items.map((item) => {
return (
<SectionItem
key={item.id}
type="experience"
item={item}
title={item.company}
subtitle={item.position || plural(item.roles.length, { one: "# role", other: "# roles" })}
/>
);
})}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="experience">
<Trans>Add a new experience</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -0,0 +1,36 @@
import type { interestItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function InterestsSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.interests;
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof interestItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.interests.items = items;
});
};
return (
<SectionBase type="interests" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="interests" item={item} title={item.name} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="interests">
<Trans>Add a new interest</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -0,0 +1,36 @@
import type { languageItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function LanguagesSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.languages;
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof languageItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.languages.items = items;
});
};
return (
<SectionBase type="languages" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="languages" item={item} title={item.language} subtitle={item.fluency} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="languages">
<Trans>Add a new language</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -0,0 +1,554 @@
import type z from "zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { EyeIcon, EyeSlashIcon, TrashSimpleIcon, UploadSimpleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { pictureSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import { ButtonGroup } from "@reactive-resume/ui/components/button-group";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@reactive-resume/ui/components/input-group";
import { ColorPicker } from "@/components/input/color-picker";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { getReadableErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { useAppForm } from "@/libs/tanstack-form";
import { SectionBase } from "../shared/section-base";
export function PictureSectionBuilder() {
return (
<SectionBase type="picture">
<PictureSectionForm />
</SectionBase>
);
}
type PictureValues = z.infer<typeof pictureSchema>;
function normalizePictureUrl(url: string, origin: string): string {
if (!url) return url;
if (url.startsWith("/uploads/")) return `/api${url}`;
try {
const parsed = new URL(url, origin);
if (parsed.origin !== origin) return url;
if (!parsed.pathname.startsWith("/uploads/")) return url;
return `/api${parsed.pathname}${parsed.search}${parsed.hash}`;
} catch {
return url;
}
}
function PictureSectionForm() {
const fileInputRef = useRef<HTMLInputElement>(null);
const appOrigin = typeof window === "undefined" ? "" : window.location.origin;
const resume = useCurrentResume();
const picture = resume.data.picture;
const normalizedPictureUrl = normalizePictureUrl(picture.url, appOrigin);
const [pictureSrc, setPictureSrc] = useState("");
const updateResumeData = useUpdateResumeData();
const { mutate: uploadFile } = useMutation(orpc.storage.uploadFile.mutationOptions({ meta: { noInvalidate: true } }));
const { mutate: deleteFile } = useMutation(orpc.storage.deleteFile.mutationOptions({ meta: { noInvalidate: true } }));
const persist = (data: PictureValues) => {
updateResumeData((draft) => {
draft.picture = data;
});
};
const form = useAppForm({
defaultValues: picture,
validators: { onChange: pictureSchema },
onSubmit: ({ value }) => {
persist(value);
},
});
const handleAutoSave = () => {
persist(form.state.values);
};
const onSelectPicture = () => {
if (!fileInputRef.current) return;
fileInputRef.current?.click();
};
const onDeletePicture = () => {
if (!picture.url) return;
const appOrigin = window.location.origin;
const pictureUrl = new URL(picture.url, appOrigin);
const pictureOrigin = pictureUrl.origin;
const filename = pictureUrl.pathname.split("/").pop();
if (!filename) return;
// If the picture is from the same origin, attempt to delete it
if (pictureOrigin === appOrigin) deleteFile({ filename });
form.setFieldValue("url", "");
handleAutoSave();
};
const onUploadPicture = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const toastId = toast.loading(t`Uploading picture...`);
uploadFile(file, {
onSuccess: ({ url }) => {
form.setFieldValue("url", url);
handleAutoSave();
toast.dismiss(toastId);
if (fileInputRef.current) fileInputRef.current.value = "";
},
onError: (error) => {
toast.error(
getReadableErrorMessage(
error,
t({
comment: "Fallback toast when uploading profile picture for resume fails",
message: "Failed to upload picture. Please try again.",
}),
),
{ id: toastId },
);
},
});
};
useEffect(() => {
if (!normalizedPictureUrl) {
setPictureSrc("");
return;
}
const controller = new AbortController();
let objectUrl = "";
void fetch(normalizedPictureUrl, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error(`Failed to fetch image: ${response.status}`);
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
setPictureSrc(objectUrl);
})
.catch(() => {
if (controller.signal.aborted) return;
setPictureSrc(normalizedPictureUrl);
});
return () => {
controller.abort();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [normalizedPictureUrl]);
return (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<div className="flex items-center gap-x-4">
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onUploadPicture} />
<button
type="button"
onClick={picture.url ? onDeletePicture : onSelectPicture}
aria-label={picture.url ? t`Delete picture` : t`Upload picture`}
className="group/picture relative size-18 cursor-pointer overflow-hidden rounded-md bg-secondary transition-colors hover:bg-secondary/50"
>
{(pictureSrc || normalizedPictureUrl) && (
<img
alt=""
src={pictureSrc || normalizedPictureUrl}
className="fade-in relative z-10 size-full animate-in rounded-md object-cover transition-opacity group-hover/picture:opacity-20"
/>
)}
<div className="absolute inset-0 z-0 flex size-full items-center justify-center">
{picture.url ? <TrashSimpleIcon className="size-6" /> : <UploadSimpleIcon className="size-6" />}
</div>
</button>
<form.Field name="url">
{(field) => (
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>URL</Trans>
</FormLabel>
<div className="flex items-center gap-x-2">
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => {
field.handleChange(event.target.value);
handleAutoSave();
}}
/>
}
/>
<Button
size="icon"
variant="ghost"
onClick={() => {
form.setFieldValue("hidden", !picture.hidden);
handleAutoSave();
}}
>
{picture.hidden ? <EyeSlashIcon /> : <EyeIcon />}
</Button>
</div>
</FormItem>
)}
</form.Field>
</div>
<div className="grid @md:grid-cols-2 grid-cols-1 gap-4">
<form.Field name="size">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Size</Trans>
</FormLabel>
<InputGroup>
<InputGroupInput
name={field.name}
value={field.state.value}
type="number"
min={32}
max={512}
step={1}
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
if (value === "") field.handleChange("" as unknown as number);
else field.handleChange(Number(value));
handleAutoSave();
}}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>pt</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="rotation">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Rotation</Trans>
</FormLabel>
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
type="number"
min={0}
max={360}
step={5}
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
if (value === "") field.handleChange("" as unknown as number);
else field.handleChange(Number(value));
handleAutoSave();
}}
/>
}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>°</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormItem>
)}
</form.Field>
<form.Field name="aspectRatio">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Aspect Ratio</Trans>
</FormLabel>
<div className="flex items-center gap-x-2">
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
type="number"
min={0.5}
max={2.5}
step={0.1}
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
if (value === "") field.handleChange("" as unknown as number);
else field.handleChange(Number(value));
handleAutoSave();
}}
/>
}
/>
<ButtonGroup className="shrink-0">
<Button
size="icon"
variant="outline"
title={t({
comment: "Preset button for setting picture aspect ratio to square",
message: "Square",
})}
onClick={() => {
field.handleChange(1);
handleAutoSave();
}}
>
<div className="aspect-square min-h-3 min-w-3 border border-primary" />
</Button>
<Button
size="icon"
variant="outline"
title={t({
comment: "Preset button for setting picture aspect ratio to landscape orientation",
message: "Landscape",
})}
onClick={() => {
field.handleChange(1.5);
handleAutoSave();
}}
>
<div className="aspect-1.5/1 min-h-3 min-w-3 border border-primary" />
</Button>
<Button
size="icon"
variant="outline"
title={t({
comment: "Preset button for setting picture aspect ratio to portrait orientation",
message: "Portrait",
})}
onClick={() => {
field.handleChange(0.5);
handleAutoSave();
}}
>
<div className="aspect-1/1.5 min-h-3 min-w-3 border border-primary" />
</Button>
</ButtonGroup>
</div>
</FormItem>
)}
</form.Field>
<form.Field name="borderRadius">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Border Radius</Trans>
</FormLabel>
<div className="flex items-center gap-x-2">
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
type="number"
min={0}
max={100}
step={1}
onBlur={field.handleBlur}
onChange={(e) => {
const value = Number(e.target.value);
field.handleChange(value);
handleAutoSave();
}}
/>
}
/>
<InputGroupAddon align="inline-end">pt</InputGroupAddon>
</InputGroup>
<ButtonGroup className="shrink-0">
<Button
size="icon"
variant="outline"
title="0pt"
onClick={() => {
field.handleChange(0);
handleAutoSave();
}}
>
<div className="size-3 rounded-none border border-primary" />
</Button>
<Button
size="icon"
variant="outline"
title="10pt"
onClick={() => {
field.handleChange(10);
handleAutoSave();
}}
>
<div className="size-3 rounded-[10%] border border-primary" />
</Button>
<Button
size="icon"
variant="outline"
title="100pt"
onClick={() => {
field.handleChange(100);
handleAutoSave();
}}
>
<div className="size-3 rounded-full border border-primary" />
</Button>
</ButtonGroup>
</div>
</FormItem>
)}
</form.Field>
<div className="flex items-end gap-x-3">
<form.Field name="borderColor">
{(field) => (
<FormItem
className="mb-1.5 shrink-0"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormControl
render={
<ColorPicker
defaultValue={field.state.value}
onChange={(color) => {
field.handleChange(color);
handleAutoSave();
}}
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name="borderWidth">
{(field) => (
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Border Width</Trans>
</FormLabel>
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
type="number"
min={0}
step={1}
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
if (value === "") field.handleChange("" as unknown as number);
else field.handleChange(Number(value));
handleAutoSave();
}}
/>
}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>pt</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormItem>
)}
</form.Field>
</div>
<div className="flex items-end gap-x-3">
<form.Field name="shadowColor">
{(field) => (
<FormItem
className="mb-1.5 shrink-0"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormControl
render={
<ColorPicker
defaultValue={field.state.value}
onChange={(color) => {
field.handleChange(color);
handleAutoSave();
}}
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name="shadowWidth">
{(field) => (
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Shadow Width</Trans>
</FormLabel>
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
type="number"
min={0}
step={0.5}
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
if (value === "") field.handleChange("" as unknown as number);
else field.handleChange(Number(value));
handleAutoSave();
}}
/>
}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>pt</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormItem>
)}
</form.Field>
</div>
</div>
</form>
);
}
@@ -0,0 +1,36 @@
import type { profileItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function ProfilesSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.profiles;
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof profileItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.profiles.items = items;
});
};
return (
<SectionBase type="profiles" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="profiles" item={item} title={item.network} subtitle={item.username} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="profiles">
<Trans>Add a new profile</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -0,0 +1,41 @@
import type { projectItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function ProjectsSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.projects;
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof projectItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.projects.items = items;
});
};
const buildSubtitle = (item: z.infer<typeof projectItemSchema>) => {
const parts = [item.period, item.website.label].filter((part) => part && part.trim().length > 0);
return parts.length > 0 ? parts.join(" • ") : undefined;
};
return (
<SectionBase type="projects" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="projects" item={item} title={item.name} subtitle={buildSubtitle(item)} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="projects">
<Trans>Add a new project</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -0,0 +1,36 @@
import type { publicationItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function PublicationsSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.publications;
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof publicationItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.publications.items = items;
});
};
return (
<SectionBase type="publications" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="publications" item={item} title={item.title} subtitle={item.publisher} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="publications">
<Trans>Add a new publication</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -0,0 +1,36 @@
import type { referenceItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function ReferencesSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.references;
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof referenceItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.references.items = items;
});
};
return (
<SectionBase type="references" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem key={item.id} type="references" item={item} title={item.name} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="references">
<Trans>Add a new reference</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -0,0 +1,36 @@
import type { skillItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function SkillsSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.skills;
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof skillItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.skills.items = items;
});
};
return (
<SectionBase type="skills" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence initial={false} mode="popLayout">
{section.items.map((item) => (
<SectionItem key={item.id} type="skills" item={item} title={item.name} subtitle={item.proficiency} />
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="skills">
<Trans>Add a new skill</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -0,0 +1,21 @@
import { RichInput } from "@/components/input/rich-input";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { SectionBase } from "../shared/section-base";
export function SummarySectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.summary;
const updateResumeData = useUpdateResumeData();
const onChange = (value: string) => {
updateResumeData((draft) => {
draft.summary.content = value;
});
};
return (
<SectionBase type="summary">
<RichInput value={section.content} onChange={onChange} />
</SectionBase>
);
}
@@ -0,0 +1,42 @@
import type { volunteerItemSchema } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { AnimatePresence, Reorder } from "motion/react";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
export function VolunteerSectionBuilder() {
const resume = useCurrentResume();
const section = resume.data.sections.volunteer;
const updateResumeData = useUpdateResumeData();
const handleReorder = (items: z.infer<typeof volunteerItemSchema>[]) => {
updateResumeData((draft) => {
draft.sections.volunteer.items = items;
});
};
return (
<SectionBase type="volunteer" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem
key={item.id}
type="volunteer"
item={item}
title={item.organization}
subtitle={item.location}
/>
))}
</AnimatePresence>
</Reorder.Group>
<SectionAddItemButton type="volunteer">
<Trans>Add a new volunteer experience</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
@@ -0,0 +1,74 @@
import type { SectionType } from "@reactive-resume/schema/resume/data";
import type { LeftSidebarSection } from "@/libs/resume/section";
import { CaretDownIcon } from "@phosphor-icons/react";
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 "@/components/resume/use-resume";
import { getSectionIcon, getSectionTitle } from "@/libs/resume/section";
import { useSectionStore } from "../../../-store/section";
import { SectionDropdownMenu } from "./section-menu";
type Props = React.ComponentProps<typeof AccordionContent> & {
type: LeftSidebarSection;
};
export function SectionBase({ type, className, ...props }: Props) {
const resume = useCurrentResume();
const data = resume.data;
const section =
type === "basics"
? data.basics
: type === "summary"
? data.summary
: type === "picture"
? data.picture
: type === "custom"
? data.customSections
: data.sections[type];
const isHidden = "hidden" in section && section.hidden;
const collapsed = useSectionStore((state) => state.sections[type]?.collapsed ?? false);
const toggleCollapsed = useSectionStore((state) => state.toggleCollapsed);
return (
<Accordion
id={`sidebar-${type}`}
value={collapsed ? [] : [type]}
onValueChange={() => toggleCollapsed(type)}
className={cn("space-y-4", isHidden && "opacity-50")}
>
<AccordionItem value={type} className="group/accordion-item space-y-4">
<div className="flex items-center">
<AccordionTrigger
className="me-2 items-center justify-center"
render={
<Button size="icon" variant="ghost">
<CaretDownIcon className="transition-transform duration-200 group-data-closed/accordion-item:-rotate-90" />
</Button>
}
/>
<div className="flex flex-1 items-center gap-x-4">
{getSectionIcon(type)}
<h2 className="line-clamp-1 font-bold text-2xl tracking-tight">
{("title" in section && section.title) || getSectionTitle(type)}
</h2>
</div>
{!["picture", "basics", "custom"].includes(type) && (
<SectionDropdownMenu type={type as "summary" | SectionType} />
)}
</div>
<AccordionContent
className={cn(
"p-0 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
className,
)}
{...props}
/>
</AccordionItem>
</Accordion>
);
}
@@ -0,0 +1,348 @@
import type {
CustomSectionItem,
CustomSectionType,
SectionItem as SectionItemType,
SectionType,
} from "@reactive-resume/schema/resume/data";
import type { ButtonProps } from "@reactive-resume/ui/components/button";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ArrowBendUpRightIcon,
CopySimpleIcon,
DotsSixVerticalIcon,
DotsThreeVerticalIcon,
EyeClosedIcon,
EyeIcon,
FileIcon,
FolderPlusIcon,
PencilSimpleLineIcon,
PlusCircleIcon,
PlusIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { Reorder, useDragControls } from "motion/react";
import { useMemo } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import {
addItemToSection,
createCustomSectionWithItem,
createPageWithSection,
getCompatibleMoveTargets,
getSourceSectionTitle,
removeItemFromSource,
} from "@/libs/resume/move-item";
// ============================================================================
// MoveItemSubmenu Component
// ============================================================================
type MoveItemSubmenuProps = {
type: CustomSectionType;
item: CustomSectionItem | SectionItemType;
customSectionId?: string;
};
/**
* Submenu component for moving items between sections/pages.
* Displays compatible targets grouped by page with options to:
* - Move to existing compatible section
* - Create new section on existing page
* - Create new page with new section
*/
function MoveItemSubmenu({ type, item, customSectionId }: MoveItemSubmenuProps) {
const resume = useCurrentResume();
const updateResumeData = useUpdateResumeData();
/** Compute compatible move targets grouped by page */
const moveTargets = useMemo(
() => getCompatibleMoveTargets(resume.data, type, customSectionId),
[resume, type, customSectionId],
);
/** Get the current section's title (used when creating new sections) */
const currentSectionTitle = useMemo(
() => getSourceSectionTitle(resume.data, type, customSectionId),
[resume, type, customSectionId],
);
/** Handler: Move item to an existing section */
const handleMoveToSection = (targetSectionId: string) => {
updateResumeData((draft) => {
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
if (!removedItem) return;
addItemToSection(draft, removedItem, targetSectionId, type);
});
};
/** Handler: Create a new custom section on an existing page and move the item there */
const handleNewSectionOnPage = (pageIndex: number) => {
updateResumeData((draft) => {
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
if (!removedItem) return;
createCustomSectionWithItem(draft, removedItem, type, currentSectionTitle, pageIndex);
});
};
/** Handler: Create a new page with a new custom section and move the item there */
const handleNewPage = () => {
updateResumeData((draft) => {
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
if (!removedItem) return;
createPageWithSection(draft, removedItem, type, currentSectionTitle);
});
};
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ArrowBendUpRightIcon />
<Trans>Move to</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{/* Render each page as a submenu */}
{moveTargets.map(({ pageIndex, sections }) => (
<DropdownMenuSub key={pageIndex}>
<DropdownMenuSubTrigger>
<FileIcon />
<Trans>Page {pageIndex + 1}</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{/* Existing compatible sections on this page */}
{sections.map(({ sectionId, sectionTitle }) => (
<DropdownMenuItem key={sectionId} onClick={() => handleMoveToSection(sectionId)}>
{sectionTitle}
</DropdownMenuItem>
))}
{/* Separator if there are existing sections */}
{sections.length > 0 && <DropdownMenuSeparator />}
{/* Option to create a new section on this page */}
<DropdownMenuItem onClick={() => handleNewSectionOnPage(pageIndex)}>
<FolderPlusIcon />
<Trans>New Section</Trans>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
))}
<DropdownMenuSeparator />
{/* Option to create a new page with a new section */}
<DropdownMenuItem onClick={handleNewPage}>
<PlusCircleIcon />
<Trans>New Page</Trans>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
// ============================================================================
// SectionItem Component
// ============================================================================
type Props<T extends CustomSectionItem | SectionItemType> = {
type: CustomSectionType;
item: T;
title: string;
subtitle?: string;
customSectionId?: string;
};
export function SectionItem<T extends CustomSectionItem | SectionItemType>({
type,
item,
title,
subtitle,
customSectionId,
}: Props<T>) {
const confirm = useConfirm();
const controls = useDragControls();
const { openDialog } = useDialogStore();
const updateResumeData = useUpdateResumeData();
const onToggleVisibility = () => {
updateResumeData((draft) => {
if (customSectionId) {
const section = draft.customSections.find((s) => s.id === customSectionId);
if (!section) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items[index].hidden = !section.items[index].hidden;
} else {
// Type assertion: when customSectionId is not provided, type is always a built-in SectionType
const section = draft.sections[type as SectionType];
if (!("items" in section)) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items[index].hidden = !section.items[index].hidden;
}
});
};
const onUpdate = () => {
// Type assertion needed because TypeScript can't narrow the union type through template literals
openDialog(`resume.sections.${type}.update`, { item, customSectionId } as never);
};
const onDuplicate = () => {
// Type assertion needed because TypeScript can't narrow the union type through template literals
openDialog(`resume.sections.${type}.create`, { item, customSectionId } as never);
};
const onDelete = async () => {
const confirmed = await confirm(t`Are you sure you want to delete this item?`, {
confirmText: t({
comment: "Destructive confirmation button label when deleting a section item in resume builder",
message: "Delete",
}),
cancelText: t({
comment: "Confirmation dialog button label to abort deleting a section item in resume builder",
message: "Cancel",
}),
});
if (!confirmed) return;
updateResumeData((draft) => {
if (customSectionId) {
const section = draft.customSections.find((s) => s.id === customSectionId);
if (!section) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items.splice(index, 1);
} else {
// Type assertion: when customSectionId is not provided, type is always a built-in SectionType
const section = draft.sections[type as SectionType];
if (!("items" in section)) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items.splice(index, 1);
}
});
};
return (
<Reorder.Item
key={item.id}
value={item}
dragListener={false}
dragControls={controls}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className="group relative flex h-18 select-none border-b will-change-[transform,opacity]"
>
<div
className="flex cursor-ns-resize touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 group-hover:opacity-100"
onPointerDown={(e) => {
e.preventDefault();
controls.start(e);
}}
>
<DotsSixVerticalIcon />
</div>
<button
type="button"
onClick={onUpdate}
className={cn(
"flex flex-1 flex-col items-start justify-center space-y-0.5 ps-1.5 text-start opacity-100 transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1",
item.hidden && "opacity-50",
)}
>
<div className="line-clamp-1 font-medium">{title}</div>
{subtitle && <div className="line-clamp-1 text-muted-foreground text-xs">{subtitle}</div>}
</button>
<DropdownMenu>
<DropdownMenuTrigger className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 focus:outline-none focus-visible:ring-1 group-hover:opacity-100">
<DotsThreeVerticalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem onClick={onToggleVisibility}>
{item.hidden ? <EyeIcon /> : <EyeClosedIcon />}
{item.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={onUpdate}>
<PencilSimpleLineIcon />
<Trans>Update</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={onDuplicate}>
<CopySimpleIcon />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<MoveItemSubmenu type={type} item={item} customSectionId={customSectionId} />
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<TrashSimpleIcon />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</Reorder.Item>
);
}
type AddButtonProps = Omit<ButtonProps, "type"> & {
type: CustomSectionType | "custom";
customSectionId?: string;
};
export function SectionAddItemButton({ type, customSectionId, className, children, ...props }: AddButtonProps) {
const { openDialog } = useDialogStore();
const handleAdd = () => {
if (type === "custom") {
openDialog("resume.sections.custom.create", undefined);
} else {
openDialog(`resume.sections.${type}.create`, customSectionId ? { customSectionId } : undefined);
}
};
return (
<Button
variant="ghost"
onClick={handleAdd}
className={cn("h-12 w-full justify-start rounded-t-none", className)}
{...props}
>
<PlusIcon />
{children}
</Button>
);
}
@@ -0,0 +1,175 @@
import type { SectionType } from "@reactive-resume/schema/resume/data";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import {
BroomIcon,
ColumnsIcon,
EyeClosedIcon,
EyeIcon,
ListIcon,
PencilSimpleLineIcon,
PlusIcon,
} from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import { usePrompt } from "@/hooks/use-prompt";
type Props = {
type: "summary" | SectionType;
};
export function SectionDropdownMenu({ type }: Props) {
const prompt = usePrompt();
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const updateResumeData = useUpdateResumeData();
const resume = useCurrentResume();
const section = type === "summary" ? resume.data.summary : resume.data.sections[type];
const onAddItem = () => {
if (type === "summary") return;
openDialog(`resume.sections.${type}.create`, undefined);
};
const onToggleVisibility = () => {
updateResumeData((draft) => {
if (type === "summary") {
draft.summary.hidden = !draft.summary.hidden;
} else {
draft.sections[type].hidden = !draft.sections[type].hidden;
}
});
};
const onRenameSection = async () => {
const newTitle = await prompt(t`What do you want to rename this section to?`, {
description: t`Leave empty to reset the title to the original.`,
defaultValue: section.title,
});
if (newTitle === null || newTitle === section.title) return;
updateResumeData((draft) => {
if (type === "summary") {
draft.summary.title = newTitle ?? "";
} else {
draft.sections[type].title = newTitle ?? "";
}
});
};
const onSetColumns = (value: string) => {
updateResumeData((draft) => {
if (type === "summary") {
draft.summary.columns = Number.parseInt(value, 10);
} else {
draft.sections[type].columns = Number.parseInt(value, 10);
}
});
};
const onReset = async () => {
const confirmed = await confirm(t`Are you sure you want to reset this section?`, {
description: t`This will remove all items from this section.`,
confirmText: t({
comment: "Destructive confirmation button label when resetting a resume section",
message: "Reset",
}),
cancelText: t({
comment: "Confirmation dialog button label to abort resetting a resume section",
message: "Cancel",
}),
});
if (!confirmed) return;
updateResumeData((draft) => {
if (type === "summary") {
draft.summary.content = "";
} else {
draft.sections[type].items = [];
}
});
};
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size="icon" variant="ghost">
<ListIcon />
</Button>
}
/>
<DropdownMenuContent align="end">
{type !== "summary" && (
<>
<DropdownMenuGroup>
<DropdownMenuItem onClick={onAddItem}>
<PlusIcon />
<Trans>Add a new item</Trans>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuGroup>
<DropdownMenuItem onClick={onToggleVisibility}>
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onClick={onRenameSection}>
<PencilSimpleLineIcon />
<Trans>Rename</Trans>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ColumnsIcon />
<Trans>Columns</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={section.columns.toString()} onValueChange={onSetColumns}>
{[1, 2, 3, 4, 5, 6].map((column) => (
<DropdownMenuRadioItem key={column} value={column.toString()}>
<Plural value={column} one="# Column" other="# Columns" />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={onReset}>
<BroomIcon />
<Trans>Reset</Trans>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}