* 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,174 @@
import type { Icon } from "@phosphor-icons/react";
import { t } from "@lingui/core/macro";
import {
ArrowUUpLeftIcon,
ArrowUUpRightIcon,
CircleNotchIcon,
CubeFocusIcon,
FileDocIcon,
FileJsIcon,
FilePdfIcon,
LinkSimpleIcon,
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
} from "@phosphor-icons/react";
import { useHotkeys } from "@tanstack/react-hotkeys";
import { motion } from "motion/react";
import { useCallback, useMemo, useState } from "react";
import { useControls } from "react-zoom-pan-pinch";
import { toast } from "sonner";
import { useCopyToClipboard } from "usehooks-ts";
import { Button } from "@reactive-resume/ui/components/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { buildDocx } from "@reactive-resume/utils/resume/docx";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useResumeHistory } from "@/components/resume/use-resume";
import { authClient } from "@/libs/auth/client";
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
export function BuilderDock() {
const { data: session } = authClient.useSession();
const resume = useCurrentResume();
const [_, copyToClipboard] = useCopyToClipboard();
const { zoomIn, zoomOut, centerView } = useControls();
const [isPrinting, setIsPrinting] = useState(false);
const { undo, redo, canUndo, canRedo } = useResumeHistory();
useHotkeys([
{ hotkey: "Mod+Z", callback: () => undo() },
{ hotkey: "Mod+Y", callback: () => redo() },
]);
const publicUrl = useMemo(() => {
if (!session?.user.username || !resume?.slug) return "";
return `${window.location.origin}/${session.user.username}/${resume.slug}`;
}, [session?.user.username, resume?.slug]);
const onCopyUrl = useCallback(async () => {
await copyToClipboard(publicUrl);
toast.success(t`A link to your resume has been copied to clipboard.`);
}, [publicUrl, copyToClipboard]);
const onDownloadJSON = useCallback(async () => {
if (!resume) return;
const filename = generateFilename(resume.name, "json");
const jsonString = JSON.stringify(resume.data, null, 2);
const blob = new Blob([jsonString], { type: "application/json" });
downloadWithAnchor(blob, filename);
}, [resume]);
const onDownloadDOCX = useCallback(async () => {
if (!resume) return;
const filename = generateFilename(resume.name, "docx");
try {
const blob = await buildDocx(resume.data);
downloadWithAnchor(blob, filename);
} catch {
toast.error(t`There was a problem while generating the DOCX, please try again.`);
}
}, [resume]);
const onDownloadPDF = useCallback(async () => {
if (!resume) return;
const filename = generateFilename(resume.name, "pdf");
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
setIsPrinting(true);
try {
const blob = await createResumePdfBlob(resume.data);
downloadWithAnchor(blob, filename);
} catch {
toast.error(t`There was a problem while generating the PDF, please try again.`);
} finally {
setIsPrinting(false);
toast.dismiss(toastId);
}
}, [resume]);
return (
<div className="fixed inset-x-0 bottom-4 flex items-center justify-center">
<motion.div
initial={{ opacity: 0, y: -18 }}
animate={{ opacity: 0.6, y: 0 }}
whileHover={{ opacity: 1, y: -2, scale: 1.01 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex items-center rounded-r-full rounded-l-full bg-popover px-2 shadow-xl will-change-[transform,opacity]"
>
<DockIcon
disabled={!canUndo}
onClick={() => undo()}
icon={ArrowUUpLeftIcon}
title={t({
context: "'Ctrl' may be replaced with the locale-specific equivalent (e.g. 'Strg' for QWERTZ layouts).",
message: "Undo (Ctrl+Z)",
})}
/>
<DockIcon
disabled={!canRedo}
onClick={() => redo()}
icon={ArrowUUpRightIcon}
title={t({
context: "'Ctrl' may be replaced with the locale-specific equivalent (e.g. 'Strg' for QWERTZ layouts).",
message: "Redo (Ctrl+Y)",
})}
/>
<div className="mx-1 h-8 w-px bg-border" />
<DockIcon icon={MagnifyingGlassPlusIcon} title={t`Zoom in`} onClick={() => zoomIn(0.1)} />
<DockIcon icon={MagnifyingGlassMinusIcon} title={t`Zoom out`} onClick={() => zoomOut(0.1)} />
<DockIcon icon={CubeFocusIcon} title={t`Center view`} onClick={() => centerView()} />
<div className="mx-1 h-8 w-px bg-border" />
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
<DockIcon icon={FileJsIcon} title={t`Download JSON`} onClick={() => onDownloadJSON()} />
<DockIcon icon={FileDocIcon} title={t`Download DOCX`} onClick={() => onDownloadDOCX()} />
<DockIcon
title={t`Download PDF`}
disabled={isPrinting}
onClick={() => onDownloadPDF()}
icon={isPrinting ? CircleNotchIcon : FilePdfIcon}
iconClassName={cn(isPrinting && "animate-spin")}
/>
</motion.div>
</div>
);
}
type DockIconProps = {
title: string;
icon: Icon;
disabled?: boolean;
onClick: () => void;
iconClassName?: string;
};
function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName }: DockIconProps) {
return (
<Tooltip>
<TooltipTrigger
render={
<motion.div
className="will-change-transform"
whileHover={disabled ? undefined : { y: -1, scale: 1.04 }}
whileTap={disabled ? undefined : { scale: 0.97 }}
transition={{ duration: 0.15, ease: "easeOut" }}
>
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
<Icon className={cn("size-4", iconClassName)} />
</Button>
</motion.div>
}
/>
<TooltipContent side="top" align="center" className="font-medium">
{title}
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,19 @@
import { cn } from "@reactive-resume/utils/style";
type Props = {
side: "left" | "right";
children: React.ReactNode;
};
export function BuilderSidebarEdge({ side, children }: Props) {
return (
<div
className={cn(
"absolute inset-y-0 hidden min-h-0 w-12 flex-col items-center overflow-hidden bg-popover py-2.5 sm:flex",
side === "left" ? "inset-s-0 border-r" : "inset-e-0 border-l",
)}
>
{children}
</div>
);
}
@@ -0,0 +1,187 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
CaretDownIcon,
CopySimpleIcon,
HouseSimpleIcon,
LockSimpleIcon,
LockSimpleOpenIcon,
PencilSimpleLineIcon,
SidebarSimpleIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { useCurrentResume, usePatchResume } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { useBuilderSidebar } from "../-store/sidebar";
export function BuilderHeader() {
const resume = useCurrentResume();
const name = resume.name;
const isLocked = resume.isLocked;
const toggleSidebar = useBuilderSidebar((state) => state.toggleSidebar);
return (
<div className="absolute inset-x-0 top-0 z-50 flex h-14 items-center justify-between border-b bg-popover px-1.5">
<Button size="icon" variant="ghost" onClick={() => toggleSidebar("left")}>
<SidebarSimpleIcon />
<span className="sr-only">
<Trans comment="Screen-reader label for opening or closing the left sidebar in resume builder">
Toggle left sidebar
</Trans>
</span>
</Button>
<div className="flex items-center gap-x-1">
<Button
size="icon"
variant="ghost"
aria-label={t({
comment: "Accessible label for button navigating from builder to resumes dashboard",
message: "Go to resumes dashboard",
})}
nativeButton={false}
render={
<Link to="/dashboard/resumes" search={{ sort: "lastUpdatedAt", tags: [] }}>
<HouseSimpleIcon />
</Link>
}
/>
<span className="me-2.5 text-muted-foreground">/</span>
<h2 className="flex-1 truncate font-medium">{name}</h2>
{isLocked && <LockSimpleIcon className="ms-2 text-muted-foreground" />}
<BuilderHeaderDropdown />
</div>
<Button size="icon" variant="ghost" onClick={() => toggleSidebar("right")}>
<SidebarSimpleIcon className="-scale-x-100" />
<span className="sr-only">
<Trans comment="Screen-reader label for opening or closing the right sidebar in resume builder">
Toggle right sidebar
</Trans>
</span>
</Button>
</div>
);
}
function BuilderHeaderDropdown() {
const confirm = useConfirm();
const navigate = useNavigate();
const { openDialog } = useDialogStore();
const resume = useCurrentResume();
const patchResume = usePatchResume();
const id = resume.id;
const name = resume.name;
const slug = resume.slug;
const tags = resume.tags;
const isLocked = resume.isLocked;
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
const handleUpdate = () => {
openDialog("resume.update", { id, name, slug, tags });
};
const handleDuplicate = () => {
openDialog("resume.duplicate", { id, name, slug, tags, shouldRedirect: true });
};
const handleToggleLock = async () => {
if (!isLocked) {
const confirmation = await confirm(t`Are you sure you want to lock this resume?`, {
description: t`When locked, the resume cannot be updated or deleted.`,
});
if (!confirmation) return;
}
setLockedResume(
{ id, isLocked: !isLocked },
{
onSuccess: () => {
patchResume((draft) => {
draft.isLocked = !isLocked;
});
},
onError: (error) => {
toast.error(getResumeErrorMessage(error));
},
},
);
};
const handleDelete = async () => {
const confirmation = await confirm(t`Are you sure you want to delete this resume?`, {
description: t`This action cannot be undone.`,
});
if (!confirmation) return;
const toastId = toast.loading(t`Deleting your resume...`);
deleteResume(
{ id },
{
onSuccess: () => {
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
void navigate({ to: "/dashboard/resumes", search: { sort: "lastUpdatedAt", tags: [] } });
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
},
},
);
};
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size="icon" variant="ghost">
<CaretDownIcon />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuItem disabled={isLocked} onClick={handleUpdate}>
<PencilSimpleLineIcon className="me-2" />
<Trans>Update</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDuplicate}>
<CopySimpleIcon className="me-2" />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleToggleLock}>
{isLocked ? <LockSimpleOpenIcon className="me-2" /> : <LockSimpleIcon className="me-2" />}
{isLocked ? <Trans>Unlock</Trans> : <Trans>Lock</Trans>}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={isLocked} onClick={handleDelete}>
<TrashSimpleIcon className="me-2" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,36 @@
import { t } from "@lingui/core/macro";
import { FloppyDiskIcon } from "@phosphor-icons/react";
import { useHotkey } from "@tanstack/react-hotkeys";
import { Suspense } from "react";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import { toast } from "sonner";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { ResumePreview } from "@/components/resume/preview";
import { BuilderDock } from "./dock";
export function PreviewPage() {
useHotkey("Mod+S", () => {
toast.info(t`Your changes are saved automatically.`, { id: "auto-save", icon: <FloppyDiskIcon /> });
});
return (
<Suspense fallback={<LoadingScreen />}>
<div className="fixed inset-0">
<TransformWrapper
centerOnInit
maxScale={6}
minScale={0.3}
initialScale={0.6}
limitToBounds={false}
wheel={{ step: 0.001 }}
>
<TransformComponent wrapperClass="h-full! w-full!">
<ResumePreview pageGap="2rem" showPageNumbers />
</TransformComponent>
<BuilderDock />
</TransformWrapper>
</div>
</Suspense>
);
}
@@ -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>
);
}
@@ -0,0 +1,102 @@
import type { RightSidebarSection } from "@/libs/resume/section";
import { Fragment, useCallback, useRef } from "react";
import { match } from "ts-pattern";
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 { Copyright } from "@/components/ui/copyright";
import { getSectionIcon, getSectionTitle, rightSidebarSections } from "@/libs/resume/section";
import { BuilderSidebarEdge } from "../../-components/edge";
import { useBuilderSidebar } from "../../-store/sidebar";
import { DesignSectionBuilder } from "./sections/design";
import { ExportSectionBuilder } from "./sections/export";
import { InformationSectionBuilder } from "./sections/information";
import { LayoutSectionBuilder } from "./sections/layout";
import { NotesSectionBuilder } from "./sections/notes";
import { PageSectionBuilder } from "./sections/page";
import { ResumeAnalysisSectionBuilder } from "./sections/resume-analysis";
import { SharingSectionBuilder } from "./sections/sharing";
import { StatisticsSectionBuilder } from "./sections/statistics";
import { TemplateSectionBuilder } from "./sections/template";
import { TypographySectionBuilder } from "./sections/typography";
function getSectionComponent(type: RightSidebarSection) {
return match(type)
.with("template", () => <TemplateSectionBuilder />)
.with("layout", () => <LayoutSectionBuilder />)
.with("typography", () => <TypographySectionBuilder />)
.with("design", () => <DesignSectionBuilder />)
.with("page", () => <PageSectionBuilder />)
.with("notes", () => <NotesSectionBuilder />)
.with("sharing", () => <SharingSectionBuilder />)
.with("statistics", () => <StatisticsSectionBuilder />)
.with("analysis", () => <ResumeAnalysisSectionBuilder />)
.with("export", () => <ExportSectionBuilder />)
.with("information", () => <InformationSectionBuilder />)
.exhaustive();
}
export function BuilderSidebarRight() {
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
return (
<>
<SidebarEdge scrollAreaRef={scrollAreaRef} />
<ScrollArea
ref={scrollAreaRef}
className="@container h-[calc(100svh-3.5rem)] overflow-hidden bg-background sm:me-12"
>
<div className="space-y-4 p-4">
{rightSidebarSections.map((section) => (
<Fragment key={section}>
{getSectionComponent(section)}
<Separator />
</Fragment>
))}
<Copyright className="mx-auto py-2 text-center" />
</div>
</ScrollArea>
</>
);
}
type SidebarEdgeProps = {
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
};
function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
const toggleSidebar = useBuilderSidebar((state) => state.toggleSidebar);
const scrollToSection = useCallback(
(section: RightSidebarSection) => {
if (!scrollAreaRef.current) return;
toggleSidebar("right", true);
const sectionElement = scrollAreaRef.current.querySelector(`#sidebar-${section}`);
sectionElement?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
},
[toggleSidebar, scrollAreaRef],
);
return (
<BuilderSidebarEdge side="right">
<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">
{rightSidebarSections.map((section) => (
<Button
key={section}
size="icon"
variant="ghost"
title={getSectionTitle(section)}
onClick={() => scrollToSection(section)}
>
{getSectionIcon(section)}
</Button>
))}
</div>
</div>
</BuilderSidebarEdge>
);
}
@@ -0,0 +1,344 @@
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { useStore } from "@tanstack/react-form";
import { AnimatePresence, motion } from "motion/react";
import { colorDesignSchema, levelDesignSchema } 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 { Separator } from "@reactive-resume/ui/components/separator";
import { cn } from "@reactive-resume/utils/style";
import { ColorPicker } from "@/components/input/color-picker";
import { IconPicker } from "@/components/input/icon-picker";
import { LevelTypeCombobox } from "@/components/level/combobox";
import { LevelDisplay } from "@/components/level/display";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { useAppForm } from "@/libs/tanstack-form";
import { SectionBase } from "../shared/section-base";
export function DesignSectionBuilder() {
return (
<SectionBase type="design" className="space-y-6">
<ColorSectionForm />
<Separator />
<LevelSectionForm />
</SectionBase>
);
}
type ColorValues = z.infer<typeof colorDesignSchema>;
function ColorSectionForm() {
const resume = useCurrentResume();
const colors = resume.data.metadata.design.colors;
const updateResumeData = useUpdateResumeData();
const persist = (data: ColorValues) => {
updateResumeData((draft) => {
draft.metadata.design.colors = data;
});
};
const form = useAppForm({
defaultValues: colors,
validators: { onChange: colorDesignSchema },
onSubmit: ({ value }) => {
persist(value);
},
});
const handleAutoSave = () => {
persist(form.state.values);
};
return (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<form.Field name="primary">
{(field) => (
<FormItem
className="flex flex-wrap gap-2.5 p-1"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
{quickColorOptions.map((color) => (
<QuickColorCircle
key={color}
color={color}
active={color === field.state.value}
onSelect={(color) => {
field.handleChange(color as string);
handleAutoSave();
}}
/>
))}
</FormItem>
)}
</form.Field>
<form.Field name="primary">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Primary Color</Trans>
</FormLabel>
<div className="flex items-center gap-3">
<ColorPicker
value={field.state.value}
onChange={(color) => {
field.handleChange(color);
handleAutoSave();
}}
/>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
handleAutoSave();
}}
/>
}
/>
</div>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="text">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Text Color</Trans>
</FormLabel>
<div className="flex items-center gap-3">
<ColorPicker
defaultValue={field.state.value}
onChange={(color) => {
field.handleChange(color);
handleAutoSave();
}}
/>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
handleAutoSave();
}}
/>
}
/>
</div>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="background">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Background Color</Trans>
</FormLabel>
<div className="flex items-center gap-3">
<ColorPicker
defaultValue={field.state.value}
onChange={(color) => {
field.handleChange(color);
handleAutoSave();
}}
/>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => {
field.handleChange(e.target.value);
handleAutoSave();
}}
/>
}
/>
</div>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</form>
);
}
const quickColorOptions = [
"rgba(231, 0, 11, 1)", // red-600
"rgba(245, 73, 0, 1)", // orange-600
"rgba(225, 113, 0, 1)", // amber-600
"rgba(208, 135, 0, 1)", // yellow-600
"rgba(94, 165, 0, 1)", // lime-600
"rgba(0, 166, 62, 1)", // green-600
"rgba(0, 153, 102, 1)", // emerald-600
"rgba(0, 150, 137, 1)", // teal-600
"rgba(0, 146, 184, 1)", // cyan-600
"rgba(0, 132, 209, 1)", // sky-600
"rgba(21, 93, 252, 1)", // blue-600
"rgba(79, 57, 246, 1)", // indigo-600
"rgba(127, 34, 254, 1)", // violet-600
"rgba(152, 16, 250, 1)", // purple-600
"rgba(200, 0, 222, 1)", // fuchsia-600
"rgba(230, 0, 118, 1)", // pink-600
"rgba(236, 0, 63, 1)", // rose-600
"rgba(69, 85, 108, 1)", // slate-600
"rgba(74, 85, 101, 1)", // gray-600
"rgba(82, 82, 92, 1)", // zinc-600
"rgba(82, 82, 82, 1)", // neutral-600
"rgba(87, 83, 77, 1)", // stone-600
];
type QuickColorCircleProps = React.ComponentProps<"button"> & {
color: string;
active: boolean;
onSelect: (color: string) => void;
};
function QuickColorCircle({ color, active, onSelect, className, ...props }: QuickColorCircleProps) {
return (
<button
type="button"
onClick={() => onSelect(color)}
className={cn(
"relative flex size-8 items-center justify-center rounded-md bg-transparent",
"scale-100 transition-transform hover:scale-120 hover:bg-secondary/80 active:scale-95",
className,
)}
{...props}
>
<div style={{ backgroundColor: color }} className="size-6 shrink-0 rounded-md" />
<AnimatePresence>
{active && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className="absolute inset-0 flex size-8 items-center justify-center will-change-transform"
>
<div className="size-4 rounded-md bg-foreground" />
</motion.div>
)}
</AnimatePresence>
</button>
);
}
type LevelValues = z.infer<typeof levelDesignSchema>;
type LevelType = LevelValues["type"];
function LevelSectionForm() {
const resume = useCurrentResume();
const colors = resume.data.metadata.design.colors;
const levelDesign = resume.data.metadata.design.level;
const updateResumeData = useUpdateResumeData();
const persist = (data: LevelValues) => {
updateResumeData((draft) => {
draft.metadata.design.level = data;
});
};
const form = useAppForm({
defaultValues: levelDesign,
validators: { onChange: levelDesignSchema },
onSubmit: ({ value }) => {
persist(value);
},
});
const handleAutoSave = () => {
persist(form.state.values);
};
const previewType = useStore(form.store, (s) => s.values.type);
const previewIcon = useStore(form.store, (s) => s.values.icon);
return (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<h4 className="font-semibold text-lg leading-none tracking-tight">
<Trans>Level</Trans>
</h4>
<div
style={{ "--page-primary-color": colors.primary, backgroundColor: colors.background } as React.CSSProperties}
className="flex items-center justify-center rounded-md p-6"
>
<LevelDisplay level={3} type={previewType} icon={previewIcon} className="w-full max-w-[220px] justify-center" />
</div>
<div className="flex items-center gap-3">
<form.Field name="icon">
{(field) => (
<FormItem className="shrink-0" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Icon</Trans>
</FormLabel>
<FormControl
render={
<IconPicker
size="icon"
value={field.state.value}
onChange={(value) => {
field.handleChange(value);
handleAutoSave();
}}
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name="type">
{(field) => (
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Type</Trans>
</FormLabel>
<FormControl
render={
<LevelTypeCombobox
value={field.state.value}
onValueChange={(value) => {
if (!value) return;
field.handleChange(value as LevelType);
handleAutoSave();
}}
/>
}
/>
</FormItem>
)}
</form.Field>
</div>
</form>
);
}
@@ -0,0 +1,119 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CircleNotchIcon, FileDocIcon, FileJsIcon, FilePdfIcon } from "@phosphor-icons/react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { Button } from "@reactive-resume/ui/components/button";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { buildDocx } from "@reactive-resume/utils/resume/docx";
import { useResume } from "@/components/resume/use-resume";
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
import { SectionBase } from "../shared/section-base";
export function ExportSectionBuilder() {
const resumeData = useResume();
const [isPrinting, setIsPrinting] = useState(false);
const resume = resumeData;
const onDownloadJSON = useCallback(() => {
if (!resume) return;
const filename = generateFilename(resume.name, "json");
const jsonString = JSON.stringify(resume.data, null, 2);
const blob = new Blob([jsonString], { type: "application/json" });
downloadWithAnchor(blob, filename);
}, [resume]);
const onDownloadDOCX = useCallback(async () => {
if (!resume) return;
const filename = generateFilename(resume.name, "docx");
try {
const blob = await buildDocx(resume.data);
downloadWithAnchor(blob, filename);
} catch {
toast.error(t`There was a problem while generating the DOCX, please try again.`);
}
}, [resume]);
const onDownloadPDF = useCallback(async () => {
if (!resume) return;
const filename = generateFilename(resume.name, "pdf");
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
setIsPrinting(true);
try {
const blob = await createResumePdfBlob(resume.data);
downloadWithAnchor(blob, filename);
} catch {
toast.error(t`There was a problem while generating the PDF, please try again.`);
} finally {
setIsPrinting(false);
toast.dismiss(toastId);
}
}, [resume]);
if (!resume) return null;
return (
<SectionBase type="export" className="space-y-4">
<Button
variant="outline"
onClick={onDownloadJSON}
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
>
<FileJsIcon className="size-6 shrink-0" />
<div className="flex flex-1 flex-col gap-y-1">
<h6 className="font-medium">JSON</h6>
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
Download a copy of your resume in JSON format. Use this file for backup or to import your resume into
other applications, including AI assistants.
</Trans>
</p>
</div>
</Button>
<Button
variant="outline"
onClick={onDownloadDOCX}
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
>
<FileDocIcon className="size-6 shrink-0" />
<div className="flex flex-1 flex-col gap-y-1">
<h6 className="font-medium">DOCX</h6>
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
Download a copy of your resume as a Word document. Use this file to further customize your resume in
Microsoft Word or Google Docs.
</Trans>
</p>
</div>
</Button>
<Button
variant="outline"
disabled={isPrinting}
onClick={onDownloadPDF}
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
>
{isPrinting ? (
<CircleNotchIcon className="size-6 shrink-0 animate-spin" />
) : (
<FilePdfIcon className="size-6 shrink-0" />
)}
<div className="flex flex-1 flex-col gap-y-1">
<h6 className="font-medium">PDF</h6>
<p className="text-muted-foreground text-xs leading-normal">
<Trans>
Download a copy of your resume in PDF format. Use this file for printing or to easily share your resume
with recruiters.
</Trans>
</p>
</div>
</Button>
</SectionBase>
);
}
@@ -0,0 +1,106 @@
import { Trans } from "@lingui/react/macro";
import { HandHeartIcon } from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
import { SectionBase } from "../shared/section-base";
export function InformationSectionBuilder() {
return (
<SectionBase type="information" className="space-y-4">
<div className="space-y-2 rounded-md border bg-sky-600 p-5 text-white dark:bg-sky-700">
<h4 className="font-medium tracking-tight">
<Trans>Support the app by doing what you can!</Trans>
</h4>
<div className="space-y-2 text-xs leading-normal">
<Trans>
<p>
Thank you for using Reactive Resume! This app is a labor of love, created mostly in my spare time, with
wonderful support from open-source contributors around the world.
</p>
<p>
If Reactive Resume has been helpful to you, and you'd like to help keep it free and open for everyone,
please consider making a donation. Every little bit is appreciated!
</p>
</Trans>
</div>
<Button
size="sm"
variant="default"
nativeButton={false}
className="mt-2 whitespace-normal px-4! text-xs"
render={
<a href="http://opencollective.com/reactive-resume" target="_blank" rel="noopener">
<HandHeartIcon />
<span className="truncate">
<Trans>Donate to Reactive Resume</Trans>
</span>
</a>
}
/>
</div>
<div className="flex flex-wrap gap-0.5">
<Button
size="sm"
variant="link"
className="text-xs"
nativeButton={false}
render={
<a href="https://docs.rxresu.me" target="_blank" rel="noopener">
<Trans>Documentation</Trans>
</a>
}
/>
<Button
size="sm"
variant="link"
className="text-xs"
nativeButton={false}
render={
<a href="https://github.com/amruthpillai/reactive-resume" target="_blank" rel="noopener">
<Trans>Source Code</Trans>
</a>
}
/>
<Button
size="sm"
variant="link"
className="text-xs"
nativeButton={false}
render={
<a href="https://github.com/amruthpillai/reactive-resume/issues" target="_blank" rel="noopener">
<Trans>Report a Bug</Trans>
</a>
}
/>
<Button
size="sm"
variant="link"
className="text-xs"
nativeButton={false}
render={
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener">
<Trans>Translations</Trans>
</a>
}
/>
<Button
size="sm"
variant="link"
className="text-xs"
nativeButton={false}
render={
<a href="https://opencollective.com/reactive-resume" target="_blank" rel="noopener">
<Trans>Sponsors</Trans>
</a>
}
/>
</div>
</SectionBase>
);
}
@@ -0,0 +1,115 @@
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { metadataSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@reactive-resume/ui/components/input-group";
import { Slider } from "@reactive-resume/ui/components/slider";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { useAppForm } from "@/libs/tanstack-form";
import { SectionBase } from "../../shared/section-base";
import { LayoutPages } from "./pages";
export function LayoutSectionBuilder() {
return (
<SectionBase type="layout" className="space-y-4">
<LayoutPages />
<LayoutSectionForm />
</SectionBase>
);
}
const formSchema = metadataSchema.shape.layout.omit({ pages: true });
type FormValues = z.infer<typeof formSchema>;
function LayoutSectionForm() {
const resume = useCurrentResume();
const layout = resume.data.metadata.layout;
const updateResumeData = useUpdateResumeData();
const persist = (data: FormValues) => {
updateResumeData((draft) => {
draft.metadata.layout.sidebarWidth = data.sidebarWidth;
});
};
const form = useAppForm({
defaultValues: { sidebarWidth: layout.sidebarWidth },
validators: { onChange: formSchema },
onSubmit: ({ value }) => {
persist(value);
},
});
const handleAutoSave = () => {
persist(form.state.values);
};
return (
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<form.Field name="sidebarWidth">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Sidebar Width</Trans>
</FormLabel>
<div className="flex items-center gap-4">
<FormControl
render={
<Slider
min={10}
max={50}
step={0.01}
value={[field.state.value]}
onValueChange={(value) => {
field.handleChange(Array.isArray(value) ? value[0] : value);
handleAutoSave();
}}
/>
}
/>
<FormControl
render={
<InputGroup className="w-auto shrink-0">
<InputGroupInput
name={field.name}
value={field.state.value}
type="number"
min={10}
max={50}
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();
}}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>%</InputGroupText>
</InputGroupAddon>
</InputGroup>
}
/>
</div>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</form>
);
}
@@ -0,0 +1,433 @@
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import type { SectionType } from "@reactive-resume/schema/resume/data";
import type { CSSProperties, HTMLAttributes } from "react";
import {
closestCorners,
DndContext,
DragOverlay,
PointerSensor,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DotsSixVerticalIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
import { forwardRef, useCallback, useId, useState } from "react";
import { match } from "ts-pattern";
import { Button } from "@reactive-resume/ui/components/button";
import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { templates } from "@/dialogs/resume/template/data";
import { getSectionTitle } from "@/libs/resume/section";
type ColumnId = "main" | "sidebar";
const getColumnLabel = (columnId: ColumnId): string => {
return match(columnId)
.with("main", () =>
t({
comment: "Layout editor column label for the primary content area",
message: "Main",
}),
)
.with("sidebar", () =>
t({
comment: "Layout editor column label for the secondary sidebar area",
message: "Sidebar",
}),
)
.exhaustive();
};
type PageLocation = {
pageIndex: number;
columnId: ColumnId;
};
/**
* Returns the page index and column that contains the given section id.
* Format: "page-{index}-{columnId}" or "{sectionId}"
*/
const parseDroppableId = (id: string): PageLocation | null => {
if (id.startsWith("page-")) {
const parts = id.split("-");
if (parts.length >= 3) {
const pageIndex = Number.parseInt(parts[1] ?? "0", 10);
const columnId = parts[2] as ColumnId;
if (!Number.isNaN(pageIndex) && (columnId === "main" || columnId === "sidebar")) {
return { pageIndex, columnId };
}
}
}
return null;
};
const createDroppableId = (pageIndex: number, columnId: ColumnId): string => {
return `page-${pageIndex}-${columnId}`;
};
export function LayoutPages() {
const [activeId, setActiveId] = useState<string | null>(null);
const resume = useCurrentResume();
const template = resume.data.metadata.template;
const templateSidebarPosition = templates[template].sidebarPosition;
const layout = resume.data.metadata.layout;
const updateResumeData = useUpdateResumeData();
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
/**
* Returns the page index and column that contains the given section id.
*/
const findContainer = useCallback(
(id: string): PageLocation | null => {
// Check if it's a droppable ID
const location = parseDroppableId(id);
if (location) return location;
// Search through all pages
for (let pageIndex = 0; pageIndex < layout.pages.length; pageIndex++) {
const page = layout.pages[pageIndex];
if (page.main.includes(id)) return { pageIndex, columnId: "main" };
if (page.sidebar.includes(id)) return { pageIndex, columnId: "sidebar" };
}
return null;
},
[layout.pages],
);
const handleDragStart = useCallback((event: DragStartEvent) => setActiveId(String(event.active.id)), []);
const handleDragEnd = useCallback(
({ active, over }: DragEndEvent) => {
setActiveId(null);
if (!over) return;
const activeIdStr = String(active.id);
const overIdStr = String(over.id);
if (activeIdStr === overIdStr) return;
const activeLocation = findContainer(activeIdStr);
const overLocation = parseDroppableId(overIdStr) ?? findContainer(overIdStr);
if (!activeLocation || !overLocation) return;
// Same location, reorder within column
if (activeLocation.pageIndex === overLocation.pageIndex && activeLocation.columnId === overLocation.columnId) {
const page = layout.pages[activeLocation.pageIndex];
const items = page[activeLocation.columnId];
const oldIdx = items.indexOf(activeIdStr);
let newIdx = items.indexOf(overIdStr);
if (oldIdx === -1 || oldIdx === newIdx) return;
if (newIdx === -1) newIdx = items.length - 1;
updateResumeData((draft) => {
const colOrder = draft.metadata.layout.pages[activeLocation.pageIndex][activeLocation.columnId];
draft.metadata.layout.pages[activeLocation.pageIndex][activeLocation.columnId] = arrayMove(
colOrder,
oldIdx,
newIdx,
);
});
return;
}
// Different location, move between columns/pages
const fromPage = layout.pages[activeLocation.pageIndex];
const toPage = layout.pages[overLocation.pageIndex];
const fromItems = fromPage[activeLocation.columnId];
const toItems = toPage[overLocation.columnId];
const fromIdx = fromItems.indexOf(activeIdStr);
if (fromIdx === -1) return;
let toIdx = toItems.indexOf(overIdStr);
if (toIdx === -1) toIdx = toItems.length;
updateResumeData((draft) => {
const fromPageDraft = draft.metadata.layout.pages[activeLocation.pageIndex];
const toPageDraft = draft.metadata.layout.pages[overLocation.pageIndex];
const from = fromPageDraft[activeLocation.columnId];
const to = toPageDraft[overLocation.columnId];
from.splice(fromIdx, 1);
to.splice(Math.min(toIdx, to.length), 0, activeIdStr);
});
},
[findContainer, layout.pages, updateResumeData],
);
const handleAddPage = useCallback(() => {
updateResumeData((draft) => {
draft.metadata.layout.pages.push({
fullWidth: false,
main: [],
sidebar: [],
});
});
}, [updateResumeData]);
const handleDeletePage = useCallback(
(pageIndex: number) => {
if (layout.pages.length <= 1) return; // Don't allow deleting the last page
updateResumeData((draft) => {
const pageToDelete = draft.metadata.layout.pages[pageIndex];
// Find the first available page that isn't being deleted
const targetPageIndex = pageIndex === 0 ? 1 : 0;
const targetPage = draft.metadata.layout.pages[targetPageIndex];
// Move all sections from deleted page to target page
targetPage.main.push(...pageToDelete.main);
targetPage.sidebar.push(...pageToDelete.sidebar);
draft.metadata.layout.pages.splice(pageIndex, 1);
});
},
[layout.pages.length, updateResumeData],
);
const handleToggleFullWidth = useCallback(
(pageIndex: number, fullWidth: boolean) => {
updateResumeData((draft) => {
const page = draft.metadata.layout.pages[pageIndex];
page.fullWidth = fullWidth;
if (fullWidth) {
// Move all sidebar sections to main
page.main.push(...page.sidebar);
page.sidebar = [];
}
});
},
[updateResumeData],
);
// Don't render until pages are initialized
if (layout.pages.length === 0) {
return null;
}
return (
<DndContext
id="builder-layout"
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveId(null)}
>
<div className="flex flex-col gap-4">
{layout.pages.map((page, pageIndex) => (
<PageContainer
key={`page-${pageIndex}`}
pageIndex={pageIndex}
page={page}
canDelete={layout.pages.length > 1}
sidebarPosition={templateSidebarPosition}
onDelete={handleDeletePage}
onToggleFullWidth={handleToggleFullWidth}
/>
))}
<Button variant="outline" className="self-end" onClick={handleAddPage}>
<PlusIcon />
<Trans>Add Page</Trans>
</Button>
</div>
<DragOverlay>{activeId ? <LayoutItemContent id={activeId} isDragging isOverlay /> : null}</DragOverlay>
</DndContext>
);
}
type PageContainerProps = {
pageIndex: number;
page: { fullWidth: boolean; main: string[]; sidebar: string[] };
canDelete: boolean;
sidebarPosition: "left" | "right" | "none";
onDelete: (pageIndex: number) => void;
onToggleFullWidth: (pageIndex: number, fullWidth: boolean) => void;
};
function PageContainer({
pageIndex,
page,
canDelete,
sidebarPosition,
onDelete,
onToggleFullWidth,
}: PageContainerProps) {
const isFullWidth = page.fullWidth;
const fullWidthSwitchId = useId();
return (
<div className="space-y-3 rounded-md border border-dashed bg-background/40">
<div className="flex items-center justify-between bg-secondary/50 px-4 py-3">
<div className="flex w-full items-center gap-4">
<span className="font-medium text-xs">
<Trans comment="Layout editor page label with 1-based page number">Page {pageIndex + 1}</Trans>
</span>
<label htmlFor={fullWidthSwitchId} className="flex cursor-pointer items-center gap-2">
<Switch
id={fullWidthSwitchId}
checked={page.fullWidth}
onCheckedChange={(checked) => onToggleFullWidth(pageIndex, checked)}
/>
<span className="font-medium text-muted-foreground text-xs">
<Trans comment="Layout editor toggle label that makes a page single-column">Full Width</Trans>
</span>
</label>
</div>
{canDelete && (
<Button variant="ghost" onClick={() => onDelete(pageIndex)} className="h-5 w-auto gap-x-2.5 px-0!">
<TrashIcon />
<Trans>Delete Page</Trans>
</Button>
)}
</div>
<div
className={cn(
"grid w-full @md:grid-cols-2 gap-x-4 gap-y-2 p-4 pt-0 font-medium",
sidebarPosition === "none" && "@md:grid-cols-1",
)}
>
<LayoutColumn
pageIndex={pageIndex}
columnId="main"
items={page.main}
disabled={false}
className={cn(sidebarPosition === "left" ? "order-2" : "order-1")}
/>
{!isFullWidth && (
<LayoutColumn
pageIndex={pageIndex}
columnId="sidebar"
items={page.sidebar}
hideLabel={sidebarPosition === "none"}
className={cn(sidebarPosition === "left" ? "order-1" : "order-2")}
/>
)}
</div>
</div>
);
}
type LayoutColumnProps = {
pageIndex: number;
columnId: ColumnId;
items: string[];
hideLabel?: boolean;
disabled?: boolean;
className?: string;
};
function LayoutColumn({
pageIndex,
columnId,
items,
hideLabel = false,
disabled = false,
className,
}: LayoutColumnProps) {
const droppableId = createDroppableId(pageIndex, columnId);
const { setNodeRef, isOver } = useDroppable({ id: droppableId, disabled });
return (
<SortableContext id={droppableId} items={items} strategy={verticalListSortingStrategy}>
<div className={cn("space-y-1.5", disabled && "opacity-50", className)}>
{!hideLabel && <div className="@md:row-start-1 ps-4 font-medium text-xs">{getColumnLabel(columnId)}</div>}
<div
ref={setNodeRef}
className={cn(
"space-y-2.5 rounded-md border border-dashed p-3 pb-8 transition-colors",
isOver && !disabled ? "border-primary/60 bg-primary/5" : "bg-background/40",
)}
>
{items.map((id) => (
<SortableLayoutItem key={id} id={id} pageIndex={pageIndex} columnId={columnId} />
))}
{items.length === 0 && (
<div className="rounded-md border border-dashed p-4 font-medium text-muted-foreground text-xs">
<Trans>Drag and drop sections here to move them between columns</Trans>
</div>
)}
</div>
</div>
</SortableContext>
);
}
type SortableLayoutItemProps = {
id: string;
pageIndex: number;
columnId: ColumnId;
};
function SortableLayoutItem({ id }: SortableLayoutItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition };
return (
<LayoutItemContent ref={setNodeRef} id={id} style={style} isDragging={isDragging} {...attributes} {...listeners} />
);
}
type LayoutItemContentProps = HTMLAttributes<HTMLDivElement> & {
id: string;
isDragging?: boolean;
isOverlay?: boolean;
};
const LayoutItemContent = forwardRef<HTMLDivElement, LayoutItemContentProps>(
({ id, isDragging, isOverlay, className, style, ...rest }, ref) => {
const resume = useCurrentResume();
const title = (() => {
if (!resume) return id;
if (id === "summary") return resume.data.summary.title || getSectionTitle("summary");
if (id in resume.data.sections)
return resume.data.sections[id as SectionType].title || getSectionTitle(id as SectionType);
const customSection = resume.data.customSections.find((section) => section.id === id);
if (customSection) return customSection.title;
return id;
})();
return (
<div
ref={ref}
style={style}
data-overlay={isOverlay ? "true" : undefined}
data-dragging={isDragging ? "true" : undefined}
className={cn(
"group/item flex cursor-grab touch-none select-none items-center gap-x-2 rounded-md border border-border bg-background px-2 py-1.5 font-medium text-sm transition-all duration-200 ease-out",
"hover:bg-secondary/40 active:cursor-grabbing active:border-primary/60 active:bg-secondary/40",
"data-[overlay=true]:cursor-grabbing data-[overlay=true]:border-primary/60 data-[overlay=true]:bg-background",
"data-[dragging=true]:cursor-grabbing data-[dragging=true]:border-primary/60 data-[dragging=true]:bg-background",
className,
)}
{...rest}
>
<DotsSixVerticalIcon className="opacity-40 transition-opacity group-hover/item:opacity-100" />
<span className="truncate">{title}</span>
</div>
);
},
);
LayoutItemContent.displayName = "LayoutItemContent";
@@ -0,0 +1,44 @@
import { Trans } from "@lingui/react/macro";
import { RichInput } from "@/components/input/rich-input";
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { SectionBase } from "../shared/section-base";
export function NotesSectionBuilder() {
return (
<SectionBase type="notes">
<NotesSectionForm />
</SectionBase>
);
}
function NotesSectionForm() {
const resume = useCurrentResume();
const notes = resume.data.metadata.notes;
const updateResumeData = useUpdateResumeData();
const onChange = (value: string) => {
updateResumeData((draft) => {
draft.metadata.notes = value;
});
};
return (
<div className="space-y-4">
<p>
<Trans>
This section is reserved for your personal notes specific to this resume. The content here remains private and
is not shared with anyone else.
</Trans>
</p>
<RichInput value={notes} onChange={onChange} />
<p className="text-muted-foreground">
<Trans>
For example, information regarding which companies you sent this resume to or the links to the job
descriptions can be noted down here.
</Trans>
</p>
</div>
);
}
@@ -0,0 +1,283 @@
import type z from "zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { pageSchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@reactive-resume/ui/components/input-group";
import { Switch } from "@reactive-resume/ui/components/switch";
import { getLocaleOptions } from "@/components/locale/combobox";
import { useResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { Combobox } from "@/components/ui/combobox";
import { useAppForm } from "@/libs/tanstack-form";
import { SectionBase } from "../shared/section-base";
export function PageSectionBuilder() {
return (
<SectionBase type="page">
<PageSectionForm />
</SectionBase>
);
}
const formSchema = pageSchema;
type FormValues = z.infer<typeof formSchema>;
function PageSectionForm() {
const resume = useResume();
const page = resume?.data.metadata.page;
const updateResumeData = useUpdateResumeData();
const persist = (data: FormValues) => {
updateResumeData((draft) => {
draft.metadata.page = data;
});
};
const form = useAppForm({
defaultValues: page,
validators: { onChange: formSchema },
onSubmit: ({ value }) => {
persist(value);
},
});
const handleAutoSave = <K extends keyof FormValues>(name: K, value: FormValues[K]) => {
persist({ ...form.state.values, [name]: value });
};
return (
<form
className="grid @md:grid-cols-2 grid-cols-1 gap-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<form.Field name="locale">
{(field) => (
<FormItem
className="col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Language</Trans>
</FormLabel>
<FormControl
render={
<Combobox
options={getLocaleOptions()}
value={field.state.value}
onValueChange={(locale) => {
const value = (locale ?? "") as string;
field.handleChange(value);
handleAutoSave("locale", value);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="format">
{(field) => (
<FormItem
className="col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans context="Page Format (A4, Letter)">Format</Trans>
</FormLabel>
<FormControl
render={
<Combobox
options={[
{ value: "a4", label: t`A4` },
{ value: "letter", label: t`Letter` },
]}
value={field.state.value}
onValueChange={(value) => {
const format = value as FormValues["format"];
field.handleChange(format);
handleAutoSave("format", format);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="marginX">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Margin (Horizontal)</Trans>
</FormLabel>
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
min={0}
max={100}
step={1}
type="number"
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
const marginX = value === "" ? ("" as unknown as number) : Number(value);
field.handleChange(marginX);
handleAutoSave("marginX", marginX);
}}
/>
}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>pt</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="marginY">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Margin (Vertical)</Trans>
</FormLabel>
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
min={0}
max={100}
step={1}
type="number"
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
const marginY = value === "" ? ("" as unknown as number) : Number(value);
field.handleChange(marginY);
handleAutoSave("marginY", marginY);
}}
/>
}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>pt</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="gapX">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Spacing (Horizontal)</Trans>
</FormLabel>
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
min={0}
step={1}
type="number"
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
const gapX = value === "" ? ("" as unknown as number) : Number(value);
field.handleChange(gapX);
handleAutoSave("gapX", gapX);
}}
/>
}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>pt</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="gapY">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Spacing (Vertical)</Trans>
</FormLabel>
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
min={0}
step={1}
type="number"
onBlur={field.handleBlur}
onChange={(e) => {
const value = e.target.value;
const gapY = value === "" ? ("" as unknown as number) : Number(value);
field.handleChange(gapY);
handleAutoSave("gapY", gapY);
}}
/>
}
/>
<InputGroupAddon align="inline-end">
<InputGroupText>pt</InputGroupText>
</InputGroupAddon>
</InputGroup>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="hideIcons">
{(field) => (
<FormItem
className="col-span-full flex items-center gap-x-3 py-2"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked) => {
field.handleChange(checked);
handleAutoSave("hideIcons", checked);
}}
/>
}
/>
<FormLabel>
<Trans>Hide all icons on the resume</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
</form>
);
}
@@ -0,0 +1,283 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, InfoIcon, LightningIcon, SparkleIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useMemo } from "react";
import { toast } from "sonner";
import { match } from "ts-pattern";
import { useAIStore } from "@reactive-resume/ai/store";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { useResume } from "@/components/resume/use-resume";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { SectionBase } from "../shared/section-base";
function impactCircleClass(impact: "high" | "medium" | "low") {
return match(impact)
.with("high", () => "bg-rose-600")
.with("medium", () => "bg-amber-600")
.with("low", () => "bg-emerald-600")
.exhaustive();
}
function impactLabel(impact: "high" | "medium" | "low") {
return match(impact)
.with("high", () =>
t({
comment: "Impact severity label in resume analysis suggestion card",
message: "High",
}),
)
.with("medium", () =>
t({
comment: "Impact severity label in resume analysis suggestion card",
message: "Medium",
}),
)
.with("low", () =>
t({
comment: "Impact severity label in resume analysis suggestion card",
message: "Low",
}),
)
.exhaustive();
}
export function ResumeAnalysisSectionBuilder() {
const queryClient = useQueryClient();
const resume = useResume();
const aiEnabled = useAIStore((state) => state.enabled);
const aiProvider = useAIStore((state) => state.provider);
const aiModel = useAIStore((state) => state.model);
const aiApiKey = useAIStore((state) => state.apiKey);
const aiBaseURL = useAIStore((state) => state.baseURL);
const resumeId = resume?.id ?? "";
const analysisQuery = useQuery({
...orpc.resume.analysis.getById.queryOptions({ input: { id: resumeId } }),
enabled: !!resume,
});
const { mutate: analyzeResume, isPending } = useMutation({
...orpc.ai.analyzeResume.mutationOptions(),
onSuccess: (analysis) => {
queryClient.setQueryData(orpc.resume.analysis.getById.queryKey({ input: { id: resumeId } }), analysis);
toast.success(t`Resume analysis complete.`);
},
onError: (error) => {
toast.error(t`Failed to analyze resume.`, {
description: getOrpcErrorMessage(error, {
byCode: {
BAD_REQUEST: t({
comment: "Error description when AI returns invalid resume analysis format",
message: "The AI returned an invalid analysis format. Please try again.",
}),
BAD_GATEWAY: t({
comment: "Error description when AI provider cannot be reached during resume analysis",
message: "Could not reach the AI provider. Please try again.",
}),
},
fallback: t({
comment: "Fallback error description when resume analysis request fails",
message: "Something went wrong while analyzing your resume.",
}),
}),
});
},
});
const analysis = analysisQuery.data;
const score = analysis?.overallScore ?? null;
const analyzeLabel = isPending ? t`Analyzing...` : t`Analyze Resume`;
const scoreTone = useMemo(() => {
if (score == null) return "bg-muted";
if (score >= 80) return "bg-emerald-600";
if (score >= 60) return "bg-amber-600";
return "bg-rose-600";
}, [score]);
const onAnalyze = () => {
if (!resume) return;
analyzeResume({
provider: aiProvider,
model: aiModel,
apiKey: aiApiKey,
baseURL: aiBaseURL,
resumeId: resume.id,
resumeData: resume.data,
});
};
if (!resume) return null;
return (
<SectionBase type="analysis" className="space-y-4">
{!aiEnabled && <DisabledState />}
{aiEnabled && (
<div className="space-y-3">
<div className="space-y-4 rounded-md border bg-card p-3">
<div className="grid grid-cols-2 items-center gap-3">
<div>
<p className="text-muted-foreground text-xs">
<Trans>
Get a review of your resume with an overall score, strengths, and actionable suggestions.
</Trans>
</p>
</div>
<Button disabled={isPending} onClick={onAnalyze} className="ml-auto w-fit">
<SparkleIcon />
{analyzeLabel}
</Button>
</div>
<div className="grid grid-cols-[auto_1fr] items-center gap-3">
<div
className={`grid size-18 place-items-center rounded-full border-3 border-background font-bold text-lg text-white ${scoreTone}`}
>
{score ?? "--"}
</div>
<div className="space-y-3">
<p className="font-medium text-sm leading-none">
<Trans>Overall Score</Trans>
</p>
<div className="grid grid-cols-10 gap-1">
{Array.from({ length: 10 }).map((_, index) => {
const active = score != null && index < Math.round(score / 10);
return (
<div
key={`scorebar-${index}`}
className={`h-1.5 rounded-full transition-colors ${active ? "bg-primary" : "bg-muted"}`}
/>
);
})}
</div>
{analysis?.updatedAt && (
<p className="text-muted-foreground text-xs leading-none">
<Trans>Last analyzed on {new Date(analysis.updatedAt).toLocaleString()}</Trans>
</p>
)}
</div>
</div>
</div>
{analysisQuery.isFetched && !analysis && !isPending && (
<div className="rounded-md border border-dashed p-3">
<p className="max-w-xs text-muted-foreground text-sm">
<Trans>Run your first analysis to get a scorecard, strengths, and prioritized suggestions.</Trans>
</p>
</div>
)}
{analysis && (
<div className="space-y-4">
<div className="space-y-3 rounded-md border p-3">
<h5 className="flex items-center gap-2 font-semibold text-sm">
<LightningIcon className="text-primary" />
<Trans>Scorecard</Trans>
</h5>
<div className="space-y-3">
{analysis.scorecard.map((item) => (
<div key={item.dimension} className="space-y-3 rounded-md border bg-card p-3">
<div className="flex items-center justify-between gap-2">
<div className="font-medium text-sm">{item.dimension}</div>
<Badge variant="secondary">{item.score}/100</Badge>
</div>
<p className="text-muted-foreground text-xs">{item.rationale}</p>
</div>
))}
</div>
</div>
{analysis.strengths.length > 0 && (
<div className="space-y-3 rounded-md border p-3">
<h5 className="font-semibold text-sm">
<Trans>Strengths</Trans>
</h5>
<ul className="list-outside list-disc pl-5 text-muted-foreground text-sm">
{analysis.strengths.map((strength) => (
<li key={strength} className="py-1.5">
{strength}
</li>
))}
</ul>
</div>
)}
{analysis.suggestions.length > 0 && (
<div className="space-y-4 rounded-md border p-3">
<h5 className="font-semibold text-sm">
<Trans>Suggestions</Trans>
</h5>
<div className="space-y-3">
{analysis.suggestions.map((suggestion) => (
<div key={suggestion.title} className="space-y-3 rounded-md border bg-card p-3">
<div className="flex items-center gap-2">
<span
role="img"
className={`size-2.5 shrink-0 rounded-full ring-1 ring-border ${impactCircleClass(suggestion.impact)}`}
title={impactLabel(suggestion.impact)}
aria-label={impactLabel(suggestion.impact)}
/>
<div className="font-semibold text-sm tracking-tight">{suggestion.title}</div>
</div>
<div className="text-muted-foreground text-xs">{suggestion.why}</div>
{suggestion.exampleRewrite && (
<div className="rounded bg-muted p-2 text-muted-foreground text-xs">
{suggestion.exampleRewrite}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)}
</SectionBase>
);
}
function DisabledState() {
return (
<Alert>
<InfoIcon />
<AlertDescription className="space-y-3">
<p>
<Trans>
Get an in-depth AI-powered review of your resume with an overall score, key strengths, and practical
suggestions. To activate this feature, please update your AI settings.
</Trans>
</p>
<Button
size="sm"
variant="outline"
nativeButton={false}
render={
<Link to="/dashboard/settings/integrations">
<Trans>Open Integrations Settings</Trans>
<ArrowRightIcon />
</Link>
}
/>
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,176 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ORPCError } from "@orpc/client";
import { ClipboardIcon, LockSimpleIcon, LockSimpleOpenIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useCallback, useMemo } from "react";
import { toast } from "sonner";
import { useCopyToClipboard } from "usehooks-ts";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
import { Label } from "@reactive-resume/ui/components/label";
import { Switch } from "@reactive-resume/ui/components/switch";
import { useCurrentResume, usePatchResume } from "@/components/resume/use-resume";
import { useConfirm } from "@/hooks/use-confirm";
import { usePrompt } from "@/hooks/use-prompt";
import { authClient } from "@/libs/auth/client";
import { orpc } from "@/libs/orpc/client";
import { SectionBase } from "../shared/section-base";
export function SharingSectionBuilder() {
const prompt = usePrompt();
const confirm = useConfirm();
const [_, copyToClipboard] = useCopyToClipboard();
const { data: session } = authClient.useSession();
const resume = useCurrentResume();
const patchResume = usePatchResume();
const { mutateAsync: updateResume } = useMutation(orpc.resume.update.mutationOptions());
const { mutateAsync: setPassword } = useMutation(orpc.resume.setPassword.mutationOptions());
const { mutateAsync: removePassword } = useMutation(orpc.resume.removePassword.mutationOptions());
const publicUrl = useMemo(() => {
if (!session) return "";
return `${window.location.origin}/${session.user.username}/${resume.slug}`;
}, [session, resume]);
const onCopyUrl = useCallback(async () => {
await copyToClipboard(publicUrl);
toast.success(t`A link to your resume has been copied to clipboard.`);
}, [publicUrl, copyToClipboard]);
const onTogglePublic = useCallback(
async (checked: boolean) => {
try {
const updated = await updateResume({ id: resume.id, isPublic: checked });
patchResume((draft) => {
draft.isPublic = updated.isPublic;
});
} catch (error) {
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
toast.error(message);
}
},
[patchResume, resume.id, updateResume],
);
const onSetPassword = useCallback(async () => {
const value = await prompt(t`Protect your resume from unauthorized access with a password`, {
description: t`Anyone visiting the resume's public URL must enter this password to access it.`,
confirmText: t`Set Password`,
inputProps: {
type: "password",
minLength: 6,
maxLength: 64,
},
});
if (!value) return;
const password = value.trim();
if (!password) return toast.error(t`Password cannot be empty.`);
const toastId = toast.loading(t`Enabling password protection...`);
try {
await setPassword({ id: resume.id, password });
patchResume((draft) => {
draft.hasPassword = true;
});
toast.success(t`Password protection has been enabled.`, { id: toastId });
} catch (error) {
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
toast.error(message, { id: toastId });
}
}, [patchResume, prompt, resume.id, setPassword]);
const onRemovePassword = useCallback(async () => {
if (!resume.hasPassword) return;
const confirmation = await confirm(t`Are you sure you want to remove password protection?`, {
description: t`Anyone who has the resume's public URL will be able to view and download your resume without entering a password.`,
confirmText: t`Confirm`,
cancelText: t`Cancel`,
});
if (!confirmation) return;
const toastId = toast.loading(t`Removing password protection...`);
try {
await removePassword({ id: resume.id });
patchResume((draft) => {
draft.hasPassword = false;
});
toast.success(t`Password protection has been disabled.`, { id: toastId });
} catch (error) {
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
toast.error(message, { id: toastId });
}
}, [confirm, patchResume, removePassword, resume.hasPassword, resume.id]);
const isPasswordProtected = resume.hasPassword;
return (
<SectionBase type="sharing" className="space-y-4">
<div className="flex items-center gap-x-4">
<Switch
id="sharing-switch"
checked={resume.isPublic}
onCheckedChange={(checked) => void onTogglePublic(checked)}
/>
<Label htmlFor="sharing-switch" className="my-2 flex flex-col items-start gap-y-1 font-normal">
<span className="font-medium">
<Trans>Allow Public Access</Trans>
</span>
<span className="text-muted-foreground text-xs">
<Trans>Anyone with the link can view and download the resume.</Trans>
</span>
</Label>
</div>
{resume.isPublic && (
<div className="space-y-4 rounded-md border p-4">
<div className="grid gap-2">
<Label htmlFor="sharing-url">
<Trans comment="Form field label for the generated public resume link in sharing settings">URL</Trans>
</Label>
<div className="flex items-center gap-x-2">
<Input readOnly id="sharing-url" value={publicUrl} />
<Button size="icon" variant="ghost" onClick={onCopyUrl}>
<ClipboardIcon />
</Button>
</div>
</div>
<p className="text-muted-foreground">
{isPasswordProtected ? (
<Trans>
Your resume's public link is currently protected by a password. Share the password only with people you
trust.
</Trans>
) : (
<Trans>
Optionally, set a password so that only people with the password can view your resume through the link.
</Trans>
)}
</p>
{isPasswordProtected ? (
<Button variant="outline" onClick={onRemovePassword}>
<LockSimpleOpenIcon />
<Trans>Remove Password</Trans>
</Button>
) : (
<Button variant="outline" onClick={onSetPassword}>
<LockSimpleIcon />
<Trans>Set Password</Trans>
</Button>
)}
</div>
)}
</SectionBase>
);
}
@@ -0,0 +1,75 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { InfoIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "@tanstack/react-router";
import { Accordion, AccordionContent, AccordionItem } from "@reactive-resume/ui/components/accordion";
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
import { orpc } from "@/libs/orpc/client";
import { SectionBase } from "../shared/section-base";
export function StatisticsSectionBuilder() {
const params = useParams({ from: "/builder/$resumeId" });
const { data: statistics } = useQuery(
orpc.resume.statistics.getById.queryOptions({ input: { id: params.resumeId } }),
);
if (!statistics) return null;
return (
<SectionBase type="statistics">
<Accordion value={statistics.isPublic ? ["isPublic"] : ["isPrivate"]}>
<AccordionItem value="isPrivate">
<AccordionContent className="pb-0">
<Alert>
<InfoIcon />
<AlertTitle>
<Trans>Track your resume's views and downloads</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Turn on public sharing to track how many times your resume has been viewed or downloaded. Only you can
see your resume's statistics.
</Trans>
</AlertDescription>
</Alert>
</AccordionContent>
</AccordionItem>
<AccordionItem value="isPublic">
<AccordionContent className="grid @md:grid-cols-2 grid-cols-1 gap-4 pb-0">
<StatisticsItem
label={t`Views`}
value={statistics.views}
timestamp={statistics.lastViewedAt ? t`Last viewed on ${statistics.lastViewedAt.toDateString()}` : null}
/>
<StatisticsItem
label={t`Downloads`}
value={statistics.downloads}
timestamp={
statistics.lastDownloadedAt ? t`Last downloaded on ${statistics.lastDownloadedAt.toDateString()}` : null
}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</SectionBase>
);
}
type StatisticsItemProps = {
label: string;
value: number;
timestamp: string | null;
};
function StatisticsItem({ label, value, timestamp }: StatisticsItemProps) {
return (
<div>
<h4 className="mb-1 font-bold font-mono text-4xl">{value}</h4>
<p className="font-medium text-muted-foreground leading-none">{label}</p>
{timestamp && <span className="text-muted-foreground text-xs">{timestamp}</span>}
</div>
);
}
@@ -0,0 +1,62 @@
import { useLingui } from "@lingui/react";
import { SwapIcon } from "@phosphor-icons/react";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import { useCurrentResume } from "@/components/resume/use-resume";
import { templates } from "@/dialogs/resume/template/data";
import { useDialogStore } from "@/dialogs/store";
import { SectionBase } from "../shared/section-base";
export function TemplateSectionBuilder() {
return (
<SectionBase type="template">
<TemplateSectionForm />
</SectionBase>
);
}
function TemplateSectionForm() {
const { i18n } = useLingui();
const openDialog = useDialogStore((state) => state.openDialog);
const resume = useCurrentResume();
const template = resume.data.metadata.template;
const metadata = templates[template];
const onOpenTemplateGallery = () => {
openDialog("resume.template.gallery", undefined);
};
return (
<div className="flex @md:flex-row flex-col items-stretch gap-x-4 gap-y-2">
<Button
variant="ghost"
onClick={onOpenTemplateGallery}
className="group/preview relative h-auto w-40 shrink-0 cursor-pointer p-0"
>
<div className="relative z-10 aspect-page size-full overflow-hidden rounded-md opacity-100 transition-opacity group-hover/preview:opacity-50">
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
</div>
<div className="absolute inset-0 flex items-center justify-center">
<SwapIcon size={48} weight="thin" className="size-12" />
</div>
</Button>
<div className="flex flex-1 flex-col space-y-4 @md:pt-1 @md:pb-3">
<div className="space-y-1">
<h3 className="font-semibold text-2xl capitalize tracking-tight">{metadata.name}</h3>
<p className="text-muted-foreground text-sm">{i18n.t(metadata.description)}</p>
</div>
<div className="flex flex-wrap gap-2.5">
{metadata.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,332 @@
import type z from "zod";
import { Trans } from "@lingui/react/macro";
import { useStore } from "@tanstack/react-form";
import { typographySchema } from "@reactive-resume/schema/resume/data";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@reactive-resume/ui/components/input-group";
import { Separator } from "@reactive-resume/ui/components/separator";
import { useResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { FontFamilyCombobox, FontWeightCombobox, getNextWeights } from "@/components/typography/combobox";
import { useAppForm } from "@/libs/tanstack-form";
import { SectionBase } from "../shared/section-base";
export function TypographySectionBuilder() {
return (
<SectionBase type="typography">
<TypographySectionForm />
</SectionBase>
);
}
const formSchema = typographySchema;
type FormValues = z.infer<typeof formSchema>;
type FontWeight = FormValues["body"]["fontWeights"][number];
function TypographySectionForm() {
const resume = useResume();
const typography = resume?.data.metadata.typography;
const updateResumeData = useUpdateResumeData();
const persist = (data: FormValues) => {
updateResumeData((draft) => {
draft.metadata.typography.body = data.body;
draft.metadata.typography.heading = data.heading;
});
};
const form = useAppForm({
defaultValues: typography,
validators: { onChange: formSchema },
onSubmit: ({ value }) => {
persist(value);
},
});
const handleAutoSave = () => {
persist(form.state.values);
};
const bodyFontFamily = useStore(form.store, (s) => s.values.body.fontFamily);
const headingFontFamily = useStore(form.store, (s) => s.values.heading.fontFamily);
return (
<form
className="grid @md:grid-cols-2 grid-cols-1 gap-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<div className="col-span-full flex items-center gap-x-2">
<Separator className="basis-[16px]" />
<div className="shrink-0 font-medium text-base leading-none">
<Trans context="Body Text (paragraphs, lists, etc.)">Body</Trans>
</div>
<Separator className="flex-1" />
</div>
<form.Field name="body.fontFamily">
{(field) => (
<FormItem
className="col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Font Family</Trans>
</FormLabel>
<FormControl
render={
<FontFamilyCombobox
value={field.state.value}
className="text-base"
onValueChange={(value: string | null) => {
if (value === null) return;
field.handleChange(value);
const nextWeights = getNextWeights(value);
if (nextWeights) form.setFieldValue("body.fontWeights", nextWeights);
handleAutoSave();
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="body.fontWeights">
{(field) => (
<FormItem
className="col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Font Weights</Trans>
</FormLabel>
<FormControl
render={
<FontWeightCombobox
value={field.state.value}
fontFamily={bodyFontFamily}
onValueChange={(value) => {
if (value?.length === 0) return;
field.handleChange(value as FontWeight[]);
handleAutoSave();
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="body.fontSize">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Font Size</Trans>
</FormLabel>
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
min={6}
max={24}
step={0.1}
type="number"
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>
<form.Field name="body.lineHeight">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Line Height</Trans>
</FormLabel>
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
min={0.5}
max={4}
step={0.05}
type="number"
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>x</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormItem>
)}
</form.Field>
<div className="col-span-full flex items-center gap-x-2">
<Separator className="basis-[16px]" />
<div className="shrink-0 font-medium text-base leading-none">
<Trans context="Headings or Titles (H1, H2, H3, H4, H5, H6)">Heading</Trans>
</div>
<Separator className="flex-1" />
</div>
<form.Field name="heading.fontFamily">
{(field) => (
<FormItem
className="col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Font Family</Trans>
</FormLabel>
<FormControl
render={
<FontFamilyCombobox
value={field.state.value}
className="text-base"
onValueChange={(value: string | null) => {
if (value === null) return;
field.handleChange(value);
const nextWeights = getNextWeights(value);
if (nextWeights) form.setFieldValue("heading.fontWeights", nextWeights);
handleAutoSave();
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="heading.fontWeights">
{(field) => (
<FormItem
className="col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Font Weight</Trans>
</FormLabel>
<FormControl
render={
<FontWeightCombobox
value={field.state.value}
fontFamily={headingFontFamily}
onValueChange={(value) => {
if (value?.length === 0) return;
field.handleChange(value as FontWeight[]);
handleAutoSave();
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="heading.fontSize">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Font Size</Trans>
</FormLabel>
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
min={6}
max={24}
step={0.1}
type="number"
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>
<form.Field name="heading.lineHeight">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Line Height</Trans>
</FormLabel>
<InputGroup>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
min={0.5}
max={4}
step={0.05}
type="number"
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>x</InputGroupText>
</InputGroupAddon>
</InputGroup>
</FormItem>
)}
</form.Field>
</form>
);
}
@@ -0,0 +1,51 @@
import type { RightSidebarSection } 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 { getSectionIcon, getSectionTitle } from "@/libs/resume/section";
import { useSectionStore } from "../../../-store/section";
type Props = React.ComponentProps<typeof AccordionContent> & {
type: RightSidebarSection;
};
export function SectionBase({ type, className, ...props }: Props) {
const collapsed = useSectionStore((state) => state.sections[type]?.collapsed ?? false);
const toggleCollapsed = useSectionStore((state) => state.toggleCollapsed);
return (
<Accordion
className="space-y-4"
id={`sidebar-${type}`}
value={collapsed ? [] : [type]}
onValueChange={() => toggleCollapsed(type)}
>
<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">{getSectionTitle(type)}</h2>
</div>
</div>
<AccordionContent
className={cn(
"overflow-hidden pb-0 data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
className,
)}
{...props}
/>
</AccordionItem>
</Accordion>
);
}
@@ -0,0 +1,55 @@
import type { SidebarSection } from "@/libs/resume/section";
import { createJSONStorage, persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
import { create } from "zustand/react";
import { leftSidebarSections, rightSidebarSections } from "@/libs/resume/section";
type SectionCollapseState = {
[id in SidebarSection]?: { collapsed: boolean };
};
type SectionStoreState = {
sections: SectionCollapseState;
};
type SectionStoreActions = {
setCollapsed: (id: SidebarSection, collapsed: boolean) => void;
toggleCollapsed: (id: SidebarSection) => void;
toggleAll: () => void;
};
type SectionStore = SectionStoreState & SectionStoreActions;
export const useSectionStore = create<SectionStore>()(
persist(
immer((set) => ({
sections: {},
setCollapsed: (id, collapsed) => {
set((state) => {
state.sections[id] = { collapsed };
});
},
toggleCollapsed: (id) => {
set((state) => {
const current = state.sections[id]?.collapsed ?? false;
state.sections[id] = { collapsed: !current };
});
},
toggleAll: () => {
set((state) => {
[...leftSidebarSections, ...rightSidebarSections].forEach((id) => {
const current = state.sections[id]?.collapsed ?? false;
state.sections[id] = { collapsed: !current };
});
});
},
})),
{
name: "section-store",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
sections: state.sections,
}),
},
),
);
@@ -0,0 +1,139 @@
import type { Layout, usePanelRef } from "react-resizable-panels";
import { useCallback, useMemo } from "react";
import { useWindowSize } from "usehooks-ts";
import { create } from "zustand/react";
import { useIsMobile } from "@/hooks/use-mobile";
type PanelImperativeHandle = ReturnType<typeof usePanelRef>;
export const BUILDER_LAYOUT_COOKIE_NAME = "builder_layout";
export type BuilderLayout = {
left: number;
artboard: number;
right: number;
};
export const DEFAULT_BUILDER_LAYOUT: BuilderLayout = {
left: 22,
artboard: 56,
right: 22,
};
export const mapPanelLayoutToBuilderLayout = (layout: Layout): BuilderLayout => {
const left = layout.left;
const artboard = layout.artboard;
const right = layout.right;
if (typeof left !== "number" || typeof artboard !== "number" || typeof right !== "number")
return DEFAULT_BUILDER_LAYOUT;
return { left, artboard, right };
};
export const parseBuilderLayoutCookie = (value?: string | null): BuilderLayout => {
if (!value) return DEFAULT_BUILDER_LAYOUT;
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return DEFAULT_BUILDER_LAYOUT;
if (typeof parsed !== "object" || parsed === null) return DEFAULT_BUILDER_LAYOUT;
const left = (parsed as { left?: unknown }).left;
const artboard = (parsed as { artboard?: unknown }).artboard;
const right = (parsed as { right?: unknown }).right;
if (typeof left !== "number" || typeof artboard !== "number" || typeof right !== "number")
return DEFAULT_BUILDER_LAYOUT;
return { left, artboard, right };
} catch {
return DEFAULT_BUILDER_LAYOUT;
}
};
interface BuilderSidebarState {
layout: BuilderLayout;
leftSidebar: PanelImperativeHandle | null;
rightSidebar: PanelImperativeHandle | null;
}
interface BuilderSidebarActions {
setLayout: (layout: BuilderLayout) => void;
setLeftSidebar: (ref: PanelImperativeHandle | null) => void;
setRightSidebar: (ref: PanelImperativeHandle | null) => void;
}
type BuilderSidebar = BuilderSidebarState & BuilderSidebarActions;
export const useBuilderSidebarStore = create<BuilderSidebar>((set) => ({
layout: DEFAULT_BUILDER_LAYOUT,
leftSidebar: null,
rightSidebar: null,
setLayout: (layout) => set({ layout }),
setLeftSidebar: (ref) => set({ leftSidebar: ref }),
setRightSidebar: (ref) => set({ rightSidebar: ref }),
}));
type UseBuilderSidebarReturn = {
maxSidebarSize: string | number;
collapsedSidebarSize: number;
isCollapsed: (side: "left" | "right") => boolean;
toggleSidebar: (side: "left" | "right", forceState?: boolean) => void;
};
export function useBuilderSidebar<T = UseBuilderSidebarReturn>(selector?: (builder: UseBuilderSidebarReturn) => T): T {
const isMobile = useIsMobile();
const { width } = useWindowSize();
const maxSidebarSize = useMemo((): string | number => {
if (!width) return 0;
return isMobile ? "95%" : "45%";
}, [width, isMobile]);
const collapsedSidebarSize = useMemo((): number => {
if (!width) return 0;
return isMobile ? 0 : 48;
}, [width, isMobile]);
const expandSize = useMemo(() => (isMobile ? "95%" : "30%"), [isMobile]);
const isCollapsed = useCallback((side: "left" | "right") => {
const sidebar =
side === "left"
? useBuilderSidebarStore.getState().leftSidebar?.current
: useBuilderSidebarStore.getState().rightSidebar?.current;
if (!sidebar) return false;
return sidebar.isCollapsed();
}, []);
const toggleSidebar = useCallback(
(side: "left" | "right", forceState?: boolean) => {
const sidebar =
side === "left"
? useBuilderSidebarStore.getState().leftSidebar?.current
: useBuilderSidebarStore.getState().rightSidebar?.current;
if (!sidebar) return;
const shouldExpand = forceState === undefined ? sidebar.isCollapsed() : forceState;
if (shouldExpand) sidebar.resize(expandSize);
else sidebar.collapse();
},
[expandSize],
);
const state = useMemo(() => {
return {
maxSidebarSize,
collapsedSidebarSize,
isCollapsed,
toggleSidebar,
};
}, [maxSidebarSize, collapsedSidebarSize, isCollapsed, toggleSidebar]);
return selector ? selector(state) : (state as T);
}
@@ -0,0 +1,6 @@
import { createFileRoute, lazyRouteComponent } from "@tanstack/react-router";
export const Route = createFileRoute("/builder/$resumeId/")({
component: lazyRouteComponent(() => import("./-components/preview-page"), "PreviewPage"),
ssr: false,
});
@@ -0,0 +1,181 @@
import type React from "react";
import type { Layout } from "react-resizable-panels";
import type { BuilderLayout } from "./-store/sidebar";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import { useEffect, useRef } from "react";
import { usePanelRef } from "react-resizable-panels";
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
import {
useInitializeResumeStore,
useMergeResumeMetadata,
useResumeCleanup,
useResumeStore,
} from "@/components/resume/use-resume";
import { useIsMobile } from "@/hooks/use-mobile";
import { orpc } from "@/libs/orpc/client";
import { BuilderHeader } from "./-components/header";
import { BuilderSidebarLeft } from "./-sidebar/left";
import { BuilderSidebarRight } from "./-sidebar/right";
import {
BUILDER_LAYOUT_COOKIE_NAME,
DEFAULT_BUILDER_LAYOUT,
mapPanelLayoutToBuilderLayout,
parseBuilderLayoutCookie,
useBuilderSidebar,
useBuilderSidebarStore,
} from "./-store/sidebar";
export const Route = createFileRoute("/builder/$resumeId")({
component: RouteComponent,
beforeLoad: async ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
loader: async ({ params, context }) => {
const [layout, resume] = await Promise.all([
getBuilderLayoutServerFn(),
context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions({ input: { id: params.resumeId } })),
]);
return { layout, name: resume.name };
},
head: ({ loaderData }) => ({
meta: loaderData ? [{ title: `${loaderData.name} - Reactive Resume` }] : undefined,
}),
});
function RouteComponent() {
const { layout: initialLayout } = Route.useLoaderData();
const { resumeId } = Route.useParams();
const { data: resume } = useSuspenseQuery(orpc.resume.getById.queryOptions({ input: { id: resumeId } }));
const initializeResumeStore = useInitializeResumeStore();
const mergeResumeMetadata = useMergeResumeMetadata();
const isReady = useResumeStore((state) => state.isReady);
const initializedResumeId = useResumeStore((state) => state.resumeId);
const isInitialized = isReady && initializedResumeId === resumeId;
useResumeCleanup();
useEffect(() => {
if (isInitialized) return;
initializeResumeStore(resume);
}, [initializeResumeStore, isInitialized, resume]);
useEffect(() => {
mergeResumeMetadata(resume);
}, [
mergeResumeMetadata,
resume.id,
resume.name,
resume.slug,
resume.tags,
resume.isLocked,
resume.isPublic,
resume.hasPassword,
resume,
]);
if (!isInitialized) return null;
return <BuilderLayoutShell initialLayout={initialLayout} />;
}
type BuilderLayoutShellProps = React.ComponentProps<"div"> & {
initialLayout: BuilderLayout;
};
function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
const isMobile = useIsMobile();
const canPersistLayoutRef = useRef(false);
const leftSidebarRef = usePanelRef();
const rightSidebarRef = usePanelRef();
const setLeftSidebar = useBuilderSidebarStore((state) => state.setLeftSidebar);
const setRightSidebar = useBuilderSidebarStore((state) => state.setRightSidebar);
const setLayout = useBuilderSidebarStore((state) => state.setLayout);
const { maxSidebarSize, collapsedSidebarSize } = useBuilderSidebar((state) => ({
maxSidebarSize: state.maxSidebarSize,
collapsedSidebarSize: state.collapsedSidebarSize,
}));
useEffect(() => {
setLayout(initialLayout);
canPersistLayoutRef.current = true;
}, [initialLayout, setLayout]);
const onLayoutChanged = (layout: Layout) => {
const nextLayout = mapPanelLayoutToBuilderLayout(layout);
if (!canPersistLayoutRef.current) return;
setLayout(nextLayout);
void setBuilderLayoutServerFn({ data: nextLayout });
};
useEffect(() => {
if (!leftSidebarRef || !rightSidebarRef) return;
setLeftSidebar(leftSidebarRef);
setRightSidebar(rightSidebarRef);
}, [leftSidebarRef, rightSidebarRef, setLeftSidebar, setRightSidebar]);
const sidebarMinSize = isMobile ? "0%" : `${collapsedSidebarSize * 2}px`;
const sidebarCollapsedSize = isMobile ? "0%" : `${collapsedSidebarSize}px`;
const leftSidebarSize = isMobile ? "0%" : `${initialLayout.left}%`;
const rightSidebarSize = isMobile ? "0%" : `${initialLayout.right}%`;
const artboardSize = isMobile ? "100%" : `${initialLayout.artboard}%`;
return (
<div className="flex h-svh flex-col">
<BuilderHeader />
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChanged={onLayoutChanged}>
<ResizablePanel
collapsible
id="left"
panelRef={leftSidebarRef}
maxSize={maxSidebarSize}
minSize={sidebarMinSize}
collapsedSize={sidebarCollapsedSize}
defaultSize={leftSidebarSize}
className="z-20 h-[calc(100svh-3.5rem)]"
>
<BuilderSidebarLeft />
</ResizablePanel>
<ResizableSeparator withHandle className="z-50 border-s" />
<ResizablePanel id="artboard" defaultSize={artboardSize} className="h-[calc(100svh-3.5rem)]">
<Outlet />
</ResizablePanel>
<ResizableSeparator withHandle className="z-50 border-e" />
<ResizablePanel
collapsible
id="right"
panelRef={rightSidebarRef}
maxSize={maxSidebarSize}
minSize={sidebarMinSize}
collapsedSize={sidebarCollapsedSize}
defaultSize={rightSidebarSize}
className="z-20 h-[calc(100svh-3.5rem)]"
>
<BuilderSidebarRight />
</ResizablePanel>
</ResizableGroup>
</div>
);
}
const setBuilderLayoutServerFn = createServerFn({ method: "POST" })
.inputValidator((data): BuilderLayout => parseBuilderLayoutCookie(JSON.stringify(data)))
.handler(async ({ data }) => {
setCookie(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(data), { path: "/" });
});
const getBuilderLayoutServerFn = createServerFn({ method: "GET" }).handler(async (): Promise<BuilderLayout> => {
const layout = getCookie(BUILDER_LAYOUT_COOKIE_NAME);
if (!layout) return DEFAULT_BUILDER_LAYOUT;
return parseBuilderLayoutCookie(layout);
});