* 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,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>
);
}