- Use browserless over gotenberg

- Implement functionality to move items between sections or pages
- Enhance custom sections to have a `type` property
- Update the v4 importer to account for custom sections
- Update healthcheck to be a simple curl command
- Update dependencies to latest
and a lot more changes
This commit is contained in:
Amruth Pillai
2026-01-21 18:49:54 +01:00
parent b3c342b029
commit 70064be7de
54 changed files with 2153 additions and 822 deletions
@@ -14,10 +14,9 @@ const buttonVariants = cva(
accent: "bg-accent text-accent-foreground shadow-xs hover:bg-accent/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
outline: "border bg-background shadow-xs hover:bg-secondary/40 hover:text-secondary-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
ghost: "hover:bg-secondary/40 hover:text-secondary-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
@@ -81,7 +81,7 @@ function AlertDialogContent({
onOpenAutoFocus,
onCloseAutoFocus,
onEscapeKeyDown,
transition = { type: "spring", stiffness: 150, damping: 25 },
transition = { type: "spring", stiffness: 250, damping: 25 },
...props
}: AlertDialogContentProps) {
const initialRotation = from === "bottom" || from === "left" ? "20deg" : "-20deg";
@@ -99,6 +99,7 @@ function AlertDialogContent({
<motion.div
key="alert-dialog-content"
data-slot="alert-dialog-content"
transition={transition}
initial={{
opacity: 0,
filter: "blur(4px)",
@@ -114,7 +115,6 @@ function AlertDialogContent({
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
transition={transition}
{...props}
/>
</AlertDialogPrimitive.Content>
@@ -77,7 +77,7 @@ function DialogContent({
onEscapeKeyDown,
onPointerDownOutside,
onInteractOutside,
transition = { type: "spring", stiffness: 150, damping: 25 },
transition = { type: "spring", stiffness: 250, damping: 25 },
...props
}: DialogContentProps) {
const initialRotation = from === "bottom" || from === "left" ? "20deg" : "-20deg";
@@ -97,6 +97,7 @@ function DialogContent({
<motion.div
key="dialog-content"
data-slot="dialog-content"
transition={transition}
initial={{
opacity: 0,
filter: "blur(4px)",
@@ -112,7 +113,6 @@ function DialogContent({
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
transition={transition}
{...props}
/>
</DialogPrimitive.Content>
@@ -1,5 +1,7 @@
import { match } from "ts-pattern";
import type { SectionType } from "@/schema/resume/data";
import type { CustomSectionItem, SectionType } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { useResumeStore } from "../store/resume";
import { AwardsItem } from "./items/awards-item";
import { CertificationsItem } from "./items/certifications-item";
import { EducationItem } from "./items/education-item";
@@ -12,7 +14,6 @@ import { PublicationsItem } from "./items/publications-item";
import { ReferencesItem } from "./items/references-item";
import { SkillsItem } from "./items/skills-item";
import { VolunteerItem } from "./items/volunteer-item";
import { PageCustomSection } from "./page-custom";
import { PageSection } from "./page-section";
import { PageSummary } from "./page-summary";
@@ -21,6 +22,44 @@ type SectionComponentProps = {
itemClassName?: string;
};
// Helper to render item component based on type
function renderItemByType(type: SectionType, item: CustomSectionItem, itemClassName?: string) {
return match(type)
.with("profiles", () => (
<ProfilesItem {...(item as Parameters<typeof ProfilesItem>[0])} className={itemClassName} />
))
.with("experience", () => (
<ExperienceItem {...(item as Parameters<typeof ExperienceItem>[0])} className={itemClassName} />
))
.with("education", () => (
<EducationItem {...(item as Parameters<typeof EducationItem>[0])} className={itemClassName} />
))
.with("projects", () => (
<ProjectsItem {...(item as Parameters<typeof ProjectsItem>[0])} className={itemClassName} />
))
.with("skills", () => <SkillsItem {...(item as Parameters<typeof SkillsItem>[0])} className={itemClassName} />)
.with("languages", () => (
<LanguagesItem {...(item as Parameters<typeof LanguagesItem>[0])} className={itemClassName} />
))
.with("interests", () => (
<InterestsItem {...(item as Parameters<typeof InterestsItem>[0])} className={itemClassName} />
))
.with("awards", () => <AwardsItem {...(item as Parameters<typeof AwardsItem>[0])} className={itemClassName} />)
.with("certifications", () => (
<CertificationsItem {...(item as Parameters<typeof CertificationsItem>[0])} className={itemClassName} />
))
.with("publications", () => (
<PublicationsItem {...(item as Parameters<typeof PublicationsItem>[0])} className={itemClassName} />
))
.with("volunteer", () => (
<VolunteerItem {...(item as Parameters<typeof VolunteerItem>[0])} className={itemClassName} />
))
.with("references", () => (
<ReferencesItem {...(item as Parameters<typeof ReferencesItem>[0])} className={itemClassName} />
))
.exhaustive();
}
export function getSectionComponent(
section: "summary" | SectionType | (string & {}),
{ sectionClassName, itemClassName }: SectionComponentProps = {},
@@ -127,9 +166,39 @@ export function getSectionComponent(
return ReferencesSection;
})
.otherwise(() => {
const CustomSection = ({ id }: { id: string }) => (
<PageCustomSection sectionId={id} className={sectionClassName} />
);
return CustomSection;
// Custom section - render based on its type
const CustomSectionComponent = ({ id }: { id: string }) => {
const customSection = useResumeStore((state) => state.resume.data.customSections.find((s) => s.id === id));
if (!customSection) return null;
if (customSection.hidden) return null;
if (customSection.items.length === 0) return null;
const visibleItems = customSection.items.filter((item) => !item.hidden);
if (visibleItems.length === 0) return null;
return (
<section
className={cn(`page-section page-section-custom page-section-${id} wrap-break-word`, sectionClassName)}
>
<h6 className="mb-1 text-(--page-primary-color)">{customSection.title}</h6>
<div
className="section-content grid gap-x-(--page-gap-x) gap-y-(--page-gap-y)"
style={{ gridTemplateColumns: `repeat(${customSection.columns}, 1fr)` }}
>
{visibleItems.map((item) => (
<div
key={item.id}
className={cn(`section-item section-item-${customSection.type} wrap-break-word *:space-y-1`)}
>
{renderItemByType(customSection.type, item, itemClassName)}
</div>
))}
</div>
</section>
);
};
return CustomSectionComponent;
});
}
@@ -1,34 +0,0 @@
import { TiptapContent } from "@/components/input/rich-input";
import { cn } from "@/utils/style";
import { useResumeStore } from "../store/resume";
type PageCustomSectionProps = {
sectionId: string;
className?: string;
};
export function PageCustomSection({ sectionId, className }: PageCustomSectionProps) {
const section = useResumeStore((state) =>
state.resume.data.customSections.find((section) => section.id === sectionId),
);
// biome-ignore lint/complexity/noUselessFragments: render empty fragment, instead of null
if (!section) return <></>;
return (
<section
className={cn(
`page-section page-section-custom page-section-${sectionId} wrap-break-word`,
section.hidden && "hidden",
section.content === "" && "hidden",
className,
)}
>
<h6 className="mb-2 text-(--page-primary-color)">{section.title}</h6>
<div className="section-content">
<TiptapContent style={{ columnCount: section.columns }} content={section.content} />
</div>
</section>
);
}
+1 -1
View File
@@ -41,7 +41,7 @@ export function AzurillTemplate({ pageIndex, pageLayout }: TemplateProps) {
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-azurill page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y)">
<div className="template-azurill page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<div className="flex gap-x-(--page-gap-x)">
+1 -1
View File
@@ -23,7 +23,7 @@ export function BronzorTemplate({ pageIndex, pageLayout }: TemplateProps) {
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-bronzor page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y)">
<div className="template-bronzor page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<div className="space-y-(--page-gap-y)">
+1 -1
View File
@@ -20,7 +20,7 @@ export function KakunaTemplate({ pageIndex, pageLayout }: TemplateProps) {
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-kakuna page-content space-y-4 px-(--page-margin-x) py-(--page-margin-y)">
<div className="template-kakuna page-content space-y-4 px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<main data-layout="main" className="group page-main space-y-4">
+1 -1
View File
@@ -17,7 +17,7 @@ export function OnyxTemplate({ pageIndex, pageLayout }: TemplateProps) {
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-onyx page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y)">
<div className="template-onyx page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
+1 -1
View File
@@ -24,7 +24,7 @@ export function PikachuTemplate({ pageIndex, pageLayout }: TemplateProps) {
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-pikachu page-content px-(--page-margin-x) py-(--page-margin-y)">
<div className="template-pikachu page-content px-(--page-margin-x) py-(--page-margin-y) print:p-0">
<div className="flex gap-x-(--page-margin-x)">
<aside
data-layout="sidebar"
+1 -1
View File
@@ -20,7 +20,7 @@ export function RhyhornTemplate({ pageIndex, pageLayout }: TemplateProps) {
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-rhyhorn page-content space-y-4 px-(--page-margin-x) py-(--page-margin-y)">
<div className="template-rhyhorn page-content space-y-4 px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<main data-layout="main" className="group page-main space-y-4">
+1 -1
View File
@@ -109,7 +109,7 @@ function InputGroupInput({ className, ...props }: React.ComponentProps<"input">)
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none aria-invalid:ring-0 dark:bg-transparent",
"flex-1 rounded-none border-0 bg-transparent shadow-none hover:bg-secondary/40 focus-visible:bg-secondary/40 aria-invalid:ring-0",
className,
)}
{...props}
+1 -1
View File
@@ -7,7 +7,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 shadow-xs outline-none transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 shadow-xs outline-none transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground placeholder:text-muted-foreground hover:bg-secondary/40 focus-visible:border-ring focus-visible:bg-secondary/40 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
+30 -19
View File
@@ -33,18 +33,23 @@ export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
title: data?.title ?? "",
awarder: data?.awarder ?? "",
date: data?.date ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
title: data?.item?.title ?? "",
awarder: data?.item?.awarder ?? "",
date: data?.item?.date ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.awards.items.push(data);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.awards.items.push(formData);
}
});
closeDialog();
};
@@ -85,21 +90,27 @@ export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
title: data.title,
awarder: data.awarder,
date: data.date,
website: data.website,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
title: data.item.title,
awarder: data.item.awarder,
date: data.item.date,
website: data.item.website,
description: data.item.description,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.awards.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.awards.items[index] = data;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.awards.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.awards.items[index] = formData;
}
});
closeDialog();
};
+30 -19
View File
@@ -33,18 +33,23 @@ export function CreateCertificationDialog({ data }: DialogProps<"resume.sections
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
title: data?.title ?? "",
issuer: data?.issuer ?? "",
date: data?.date ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
title: data?.item?.title ?? "",
issuer: data?.item?.issuer ?? "",
date: data?.item?.date ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.certifications.items.push(values);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.certifications.items.push(formData);
}
});
closeDialog();
};
@@ -85,21 +90,27 @@ export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
title: data.title,
issuer: data.issuer,
date: data.date,
website: data.website,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
title: data.item.title,
issuer: data.item.issuer,
date: data.item.date,
website: data.item.website,
description: data.item.description,
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.certifications.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.certifications.items[index] = values;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.certifications.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.certifications.items[index] = formData;
}
});
closeDialog();
};
+41 -15
View File
@@ -11,19 +11,33 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { RichInput } from "@/components/input/rich-input";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { customSectionSchema } from "@/schema/resume/data";
import { customSectionSchema, type SectionType } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
const formSchema = customSectionSchema;
type FormValues = z.infer<typeof formSchema>;
const SECTION_TYPE_OPTIONS: { value: SectionType; label: string }[] = [
{ value: "experience", label: "Experience" },
{ value: "education", label: "Education" },
{ value: "projects", label: "Projects" },
{ value: "profiles", label: "Profiles" },
{ value: "skills", label: "Skills" },
{ value: "languages", label: "Languages" },
{ value: "interests", label: "Interests" },
{ value: "awards", label: "Awards" },
{ value: "certifications", label: "Certifications" },
{ value: "publications", label: "Publications" },
{ value: "volunteer", label: "Volunteer" },
{ value: "references", label: "References" },
];
export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections.custom.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
@@ -33,19 +47,20 @@ export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections
defaultValues: {
id: generateId(),
title: data?.title ?? "",
type: data?.type ?? "experience",
columns: data?.columns ?? 1,
hidden: data?.hidden ?? false,
content: data?.content ?? "",
items: data?.items ?? [],
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.customSections.push(data);
draft.customSections.push(formData);
const lastPageIndex = draft.metadata.layout.pages.length - 1;
if (lastPageIndex < 0) return;
draft.metadata.layout.pages[lastPageIndex].main.push(data.id);
draft.metadata.layout.pages[lastPageIndex].main.push(formData.id);
});
closeDialog();
};
@@ -88,17 +103,18 @@ export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections
defaultValues: {
id: data.id,
title: data.title,
type: data.type,
columns: data.columns,
hidden: data.hidden,
content: data.content,
items: data.items,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.customSections.findIndex((item) => item.id === data.id);
const index = draft.customSections.findIndex((item) => item.id === formData.id);
if (index === -1) return;
draft.customSections[index] = data;
draft.customSections[index] = formData;
});
closeDialog();
};
@@ -115,7 +131,7 @@ export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<CustomSectionForm />
<CustomSectionForm isUpdate />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
@@ -132,7 +148,7 @@ export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections
);
}
function CustomSectionForm() {
function CustomSectionForm({ isUpdate = false }: { isUpdate?: boolean }) {
const form = useFormContext<FormValues>();
return (
@@ -155,14 +171,24 @@ function CustomSectionForm() {
<FormField
control={form.control}
name="content"
name="type"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Content</Trans>
<Trans>Section Type</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
<select
{...field}
disabled={isUpdate}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{SECTION_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormControl>
<FormMessage />
</FormItem>
+36 -25
View File
@@ -33,21 +33,26 @@ export function CreateEducationDialog({ data }: DialogProps<"resume.sections.edu
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
school: data?.school ?? "",
degree: data?.degree ?? "",
area: data?.area ?? "",
grade: data?.grade ?? "",
location: data?.location ?? "",
period: data?.period ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
school: data?.item?.school ?? "",
degree: data?.item?.degree ?? "",
area: data?.item?.area ?? "",
grade: data?.item?.grade ?? "",
location: data?.item?.location ?? "",
period: data?.item?.period ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.education.items.push(data);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.education.items.push(formData);
}
});
closeDialog();
};
@@ -88,24 +93,30 @@ export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.edu
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
school: data.school,
degree: data.degree,
area: data.area,
grade: data.grade,
location: data.location,
period: data.period,
website: data.website,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
school: data.item.school,
degree: data.item.degree,
area: data.item.area,
grade: data.item.grade,
location: data.item.location,
period: data.item.period,
website: data.item.website,
description: data.item.description,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.education.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.education.items[index] = data;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.education.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.education.items[index] = formData;
}
});
closeDialog();
};
+32 -21
View File
@@ -33,19 +33,24 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
company: data?.company ?? "",
position: data?.position ?? "",
location: data?.location ?? "",
period: data?.period ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
company: data?.item?.company ?? "",
position: data?.item?.position ?? "",
location: data?.item?.location ?? "",
period: data?.item?.period ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.experience.items.push(data);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.experience.items.push(formData);
}
});
closeDialog();
};
@@ -86,22 +91,28 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
company: data.company,
position: data.position,
location: data.location,
period: data.period,
website: data.website,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
company: data.item.company,
position: data.item.position,
location: data.item.location,
period: data.item.period,
website: data.item.website,
description: data.item.description,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.experience.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.experience.items[index] = data;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.experience.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.experience.items[index] = formData;
}
});
closeDialog();
};
+26 -15
View File
@@ -35,16 +35,21 @@ export function CreateInterestDialog({ data }: DialogProps<"resume.sections.inte
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
icon: data?.icon ?? "acorn",
name: data?.name ?? "",
keywords: data?.keywords ?? [],
hidden: data?.item?.hidden ?? false,
icon: data?.item?.icon ?? "acorn",
name: data?.item?.name ?? "",
keywords: data?.item?.keywords ?? [],
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.interests.items.push(values);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.interests.items.push(formData);
}
});
closeDialog();
};
@@ -85,19 +90,25 @@ export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.inte
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
icon: data.icon,
name: data.name,
keywords: data.keywords,
id: data.item.id,
hidden: data.item.hidden,
icon: data.item.icon,
name: data.item.name,
keywords: data.item.keywords,
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.interests.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.interests.items[index] = values;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.interests.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.interests.items[index] = formData;
}
});
closeDialog();
};
+26 -15
View File
@@ -33,16 +33,21 @@ export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.lang
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
language: data?.language ?? "",
fluency: data?.fluency ?? "",
level: data?.level ?? 0,
hidden: data?.item?.hidden ?? false,
language: data?.item?.language ?? "",
fluency: data?.item?.fluency ?? "",
level: data?.item?.level ?? 0,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.languages.items.push(data);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.languages.items.push(formData);
}
});
closeDialog();
};
@@ -83,19 +88,25 @@ export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.lang
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
language: data.language,
fluency: data.fluency,
level: data.level,
id: data.item.id,
hidden: data.item.hidden,
language: data.item.language,
fluency: data.item.fluency,
level: data.item.level,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.languages.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.languages.items[index] = data;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.languages.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.languages.items[index] = formData;
}
});
closeDialog();
};
+28 -17
View File
@@ -35,17 +35,22 @@ export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profi
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
icon: data?.icon ?? "acorn",
network: data?.network ?? "",
username: data?.username ?? "",
website: data?.website ?? { url: "", label: "" },
hidden: data?.item?.hidden ?? false,
icon: data?.item?.icon ?? "acorn",
network: data?.item?.network ?? "",
username: data?.item?.username ?? "",
website: data?.item?.website ?? { url: "", label: "" },
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.profiles.items.push(data);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.profiles.items.push(formData);
}
});
closeDialog();
};
@@ -86,20 +91,26 @@ export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profi
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
icon: data.icon,
network: data.network,
username: data.username,
website: data.website,
id: data.item.id,
hidden: data.item.hidden,
icon: data.item.icon,
network: data.item.network,
username: data.item.username,
website: data.item.website,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.profiles.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.profiles.items[index] = data;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.profiles.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.profiles.items[index] = formData;
}
});
closeDialog();
};
+28 -17
View File
@@ -33,17 +33,22 @@ export function CreateProjectDialog({ data }: DialogProps<"resume.sections.proje
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
name: data?.name ?? "",
period: data?.period ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
name: data?.item?.name ?? "",
period: data?.item?.period ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.projects.items.push(values);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.projects.items.push(formData);
}
});
closeDialog();
};
@@ -84,20 +89,26 @@ export function UpdateProjectDialog({ data }: DialogProps<"resume.sections.proje
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
name: data.name,
period: data.period,
website: data.website,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
name: data.item.name,
period: data.item.period,
website: data.item.website,
description: data.item.description,
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.projects.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.projects.items[index] = values;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.projects.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.projects.items[index] = formData;
}
});
closeDialog();
};
+30 -19
View File
@@ -33,18 +33,23 @@ export function CreatePublicationDialog({ data }: DialogProps<"resume.sections.p
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
title: data?.title ?? "",
publisher: data?.publisher ?? "",
date: data?.date ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
title: data?.item?.title ?? "",
publisher: data?.item?.publisher ?? "",
date: data?.item?.date ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.publications.items.push(values);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.publications.items.push(formData);
}
});
closeDialog();
};
@@ -85,21 +90,27 @@ export function UpdatePublicationDialog({ data }: DialogProps<"resume.sections.p
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
title: data.title,
publisher: data.publisher,
date: data.date,
website: data.website,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
title: data.item.title,
publisher: data.item.publisher,
date: data.item.date,
website: data.item.website,
description: data.item.description,
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.publications.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.publications.items[index] = values;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.publications.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.publications.items[index] = formData;
}
});
closeDialog();
};
+24 -13
View File
@@ -32,15 +32,20 @@ export function CreateReferenceDialog({ data }: DialogProps<"resume.sections.ref
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
name: data?.name ?? "",
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
name: data?.item?.name ?? "",
description: data?.item?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.references.items.push(values);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.references.items.push(formData);
}
});
closeDialog();
};
@@ -81,18 +86,24 @@ export function UpdateReferenceDialog({ data }: DialogProps<"resume.sections.ref
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
name: data.name,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
name: data.item.name,
description: data.item.description,
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.references.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.references.items[index] = values;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.references.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.references.items[index] = formData;
}
});
closeDialog();
};
+30 -19
View File
@@ -37,18 +37,23 @@ export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
icon: data?.icon ?? "acorn",
name: data?.name ?? "",
proficiency: data?.proficiency ?? "",
level: data?.level ?? 0,
keywords: data?.keywords ?? [],
hidden: data?.item?.hidden ?? false,
icon: data?.item?.icon ?? "acorn",
name: data?.item?.name ?? "",
proficiency: data?.item?.proficiency ?? "",
level: data?.item?.level ?? 0,
keywords: data?.item?.keywords ?? [],
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.skills.items.push(data);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.skills.items.push(formData);
}
});
closeDialog();
};
@@ -89,21 +94,27 @@ export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
icon: data.icon,
name: data.name,
proficiency: data.proficiency,
level: data.level,
keywords: data.keywords,
id: data.item.id,
hidden: data.item.hidden,
icon: data.item.icon,
name: data.item.name,
proficiency: data.item.proficiency,
level: data.item.level,
keywords: data.item.keywords,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.skills.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.skills.items[index] = data;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.skills.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.skills.items[index] = formData;
}
});
closeDialog();
};
+30 -19
View File
@@ -33,18 +33,23 @@ export function CreateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
organization: data?.organization ?? "",
location: data?.location ?? "",
period: data?.period ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
organization: data?.item?.organization ?? "",
location: data?.item?.location ?? "",
period: data?.item?.period ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.volunteer.items.push(values);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.volunteer.items.push(formData);
}
});
closeDialog();
};
@@ -85,21 +90,27 @@ export function UpdateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
organization: data.organization,
location: data.location,
period: data.period,
website: data.website,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
organization: data.item.organization,
location: data.item.location,
period: data.item.period,
website: data.item.website,
description: data.item.description,
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.volunteer.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.volunteer.items[index] = values;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.volunteer.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.volunteer.items[index] = formData;
}
});
closeDialog();
};
+96 -24
View File
@@ -38,30 +38,102 @@ const dialogTypeSchema = z.discriminatedUnion("type", [
}),
}),
z.object({ type: z.literal("resume.template.gallery"), data: z.undefined() }),
z.object({ type: z.literal("resume.sections.profiles.create"), data: profileItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.profiles.update"), data: profileItemSchema }),
z.object({ type: z.literal("resume.sections.experience.create"), data: experienceItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.experience.update"), data: experienceItemSchema }),
z.object({ type: z.literal("resume.sections.education.create"), data: educationItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.education.update"), data: educationItemSchema }),
z.object({ type: z.literal("resume.sections.projects.create"), data: projectItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.projects.update"), data: projectItemSchema }),
z.object({ type: z.literal("resume.sections.skills.create"), data: skillItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.skills.update"), data: skillItemSchema }),
z.object({ type: z.literal("resume.sections.languages.create"), data: languageItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.languages.update"), data: languageItemSchema }),
z.object({ type: z.literal("resume.sections.awards.create"), data: awardItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.awards.update"), data: awardItemSchema }),
z.object({ type: z.literal("resume.sections.certifications.create"), data: certificationItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.certifications.update"), data: certificationItemSchema }),
z.object({ type: z.literal("resume.sections.publications.create"), data: publicationItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.publications.update"), data: publicationItemSchema }),
z.object({ type: z.literal("resume.sections.interests.create"), data: interestItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.interests.update"), data: interestItemSchema }),
z.object({ type: z.literal("resume.sections.volunteer.create"), data: volunteerItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.volunteer.update"), data: volunteerItemSchema }),
z.object({ type: z.literal("resume.sections.references.create"), data: referenceItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.references.update"), data: referenceItemSchema }),
z.object({
type: z.literal("resume.sections.profiles.create"),
data: z.object({ item: profileItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.profiles.update"),
data: z.object({ item: profileItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.experience.create"),
data: z.object({ item: experienceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.experience.update"),
data: z.object({ item: experienceItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.education.create"),
data: z.object({ item: educationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.education.update"),
data: z.object({ item: educationItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.projects.create"),
data: z.object({ item: projectItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.projects.update"),
data: z.object({ item: projectItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.skills.create"),
data: z.object({ item: skillItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.skills.update"),
data: z.object({ item: skillItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.languages.create"),
data: z.object({ item: languageItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.languages.update"),
data: z.object({ item: languageItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.awards.create"),
data: z.object({ item: awardItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.awards.update"),
data: z.object({ item: awardItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.certifications.create"),
data: z.object({ item: certificationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.certifications.update"),
data: z.object({ item: certificationItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.publications.create"),
data: z.object({ item: publicationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.publications.update"),
data: z.object({ item: publicationItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.interests.create"),
data: z.object({ item: interestItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.interests.update"),
data: z.object({ item: interestItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.volunteer.create"),
data: z.object({ item: volunteerItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.volunteer.update"),
data: z.object({ item: volunteerItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.references.create"),
data: z.object({ item: referenceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.references.update"),
data: z.object({ item: referenceItemSchema, customSectionId: z.string().optional() }),
}),
z.object({ type: z.literal("resume.sections.custom.create"), data: customSectionSchema.optional() }),
z.object({ type: z.literal("resume.sections.custom.update"), data: customSectionSchema }),
]);
@@ -644,51 +644,28 @@ export class ReactiveResumeV4JSONImporter {
})),
},
},
customSections: Object.entries(v4Data.sections.custom ?? {}).map(([sectionId, section]) => {
const itemsHtml = section.items
customSections: Object.entries(v4Data.sections.custom ?? {}).map(([sectionId, section]) => ({
id: section.id || sectionId,
title: section.name ?? "",
type: "experience" as const, // Default to experience type as it has the most compatible fields
columns: section.columns ?? 1,
hidden: !(section.visible ?? true),
items: section.items
.filter((item) => item.visible !== false)
.map((item) => {
const parts: string[] = [];
if (item.name) {
parts.push(`<h3>${item.name}</h3>`);
}
if (item.description) {
parts.push(`<p>${item.description}</p>`);
}
if (item.date || item.location) {
const details = [item.date, item.location].filter(Boolean).join(" • ");
if (details) parts.push(`<p><em>${details}</em></p>`);
}
if (item.summary) {
parts.push(`<div>${item.summary}</div>`);
}
if (item.keywords && item.keywords.length > 0) {
parts.push(`<p><strong>Keywords:</strong> ${item.keywords.join(", ")}</p>`);
}
if (item.url?.href) {
const label = item.url.label || item.url.href;
parts.push(`<p><a href="${item.url.href}">${label}</a></p>`);
}
return parts.length > 0 ? `<div style="margin-bottom: 1em;">${parts.join("")}</div>` : "";
})
.filter(Boolean)
.join("");
return {
id: section.id || sectionId,
title: section.name ?? "",
columns: section.columns ?? 1,
hidden: !(section.visible ?? true),
content: itemsHtml || "",
};
}),
.map((item) => ({
id: item.id || generateId(),
hidden: !item.visible,
company: item.name?.trim() || "",
position: item.description ?? "",
location: item.location ?? "",
period: item.date ?? "",
website: {
url: item.url?.href ?? "",
label: item.url?.label ?? "",
},
description: item.summary ?? "",
})),
})),
metadata: {
template: (templateSchema.safeParse(v4Data.metadata.template).success
? v4Data.metadata.template
+4 -11
View File
@@ -15,13 +15,8 @@ export const printerRouter = {
.input(z.object({ id: z.string() }))
.output(z.object({ url: z.string() }))
.handler(async ({ input, context }) => {
// Get resume to find the owner's userId for storage key
const resume = await resumeService.getByIdForPrinter({ id: input.id });
const url = await printerService.printResumeAsPDF({
id: input.id,
userId: resume.userId,
});
const url = await printerService.printResumeAsPDF(resume);
if (!context.user) {
await resumeService.statistics.increment({ id: input.id, downloads: true });
@@ -40,11 +35,9 @@ export const printerRouter = {
})
.input(z.object({ id: z.string() }))
.output(z.object({ url: z.string() }))
.handler(async ({ input, context }) => {
const url = await printerService.getResumeScreenshot({
id: input.id,
userId: context.user.id,
});
.handler(async ({ input }) => {
const resume = await resumeService.getByIdForPrinter({ id: input.id });
const url = await printerService.getResumeScreenshot(resume);
return { url };
}),
+126 -84
View File
@@ -1,9 +1,25 @@
import { ORPCError } from "@orpc/server";
import type { InferSelectModel } from "drizzle-orm";
import puppeteer, { type Browser, type Page } from "puppeteer-core";
import type { schema } from "@/integrations/drizzle";
import { printMarginTemplates } from "@/schema/templates";
import { env } from "@/utils/env";
import { generatePrinterToken } from "@/utils/printer-token";
import { resumeService } from "./resume";
import { getStorageService, uploadFile } from "./storage";
type PressureResponse = {
cpu: number;
date: number;
isAvailable: boolean;
maxConcurrent: number;
maxQueued: number;
memory: number;
message: string;
queued: number;
reason: string;
recentlyRejected: number;
running: number;
};
const pageDimensions = {
a4: {
width: "210mm",
@@ -17,65 +33,102 @@ const pageDimensions = {
const SCREENSHOT_TTL = 1000 * 60 * 60; // 1 hour
let pdfBrowser: Browser | null = null;
let screenshotBrowser: Browser | null = null;
async function interceptLocalhostRequests(page: Page) {
await page.setRequestInterception(true);
page.on("request", (request) => {
const url = request.url();
if (url.includes(env.APP_URL) && env.PRINTER_APP_URL) {
const newUrl = url.replace(env.APP_URL, env.PRINTER_APP_URL);
request.continue({ url: newUrl });
return;
}
request.continue();
});
}
export const printerService = {
printResumeAsPDF: async (input: { id: string; userId: string }): Promise<string> => {
healthcheck: async (): Promise<PressureResponse> => {
const printerEndpoint = env.PRINTER_ENDPOINT;
const headers = new Headers({ Accept: "application/json" });
const endpoint = new URL(printerEndpoint);
endpoint.protocol = endpoint.protocol === "wss:" ? "https:" : "http:";
endpoint.pathname = "/pressure";
const response = await fetch(endpoint, { headers });
const data = (await response.json()) as { pressure: PressureResponse };
return data.pressure;
},
printResumeAsPDF: async (
input: Pick<InferSelectModel<typeof schema.resume>, "userId" | "id" | "data">,
): Promise<string> => {
const { id, userId, data } = input;
const storageService = getStorageService();
const pdfPrefix = `uploads/${userId}/pdfs/${id}`;
// Delete any existing PDFs for this resume
const pdfPrefix = `uploads/${input.userId}/pdfs/${input.id}`;
await storageService.delete(pdfPrefix);
const resume = await resumeService.getByIdForPrinter({ id: input.id });
const format = resume.data.metadata.page.format;
const locale = resume.data.metadata.page.locale;
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
const domain = new URL(baseUrl).hostname;
const token = generatePrinterToken(input.id);
const url = `${baseUrl}/printer/${input.id}?token=${token}`;
const format = data.metadata.page.format;
const locale = data.metadata.page.locale;
const template = data.metadata.template;
const formData = new FormData();
const cookies = [{ name: "locale", value: locale, domain }];
const token = generatePrinterToken(id);
const url = `${baseUrl}/printer/${id}?token=${token}`;
formData.append("url", url);
formData.append("marginTop", "0");
formData.append("marginLeft", "0");
formData.append("marginRight", "0");
formData.append("marginBottom", "0");
formData.append("printBackground", "true");
formData.append("skipNetworkIdleEvent", "false");
formData.append("cookies", JSON.stringify(cookies));
formData.append("paperWidth", pageDimensions[format].width);
formData.append("paperHeight", pageDimensions[format].height);
let marginX = 0;
let marginY = 0;
const headers = new Headers();
if (env.GOTENBERG_USERNAME && env.GOTENBERG_PASSWORD) {
const credentials = `${env.GOTENBERG_USERNAME}:${env.GOTENBERG_PASSWORD}`;
const encodedCredentials = btoa(credentials);
headers.set("Authorization", `Basic ${encodedCredentials}`);
if (printMarginTemplates.includes(template)) {
marginX = Math.round(data.metadata.page.marginX / 0.75);
marginY = Math.round(data.metadata.page.marginY / 0.75);
}
const response = await fetch(`${env.GOTENBERG_ENDPOINT}/forms/chromium/convert/url`, {
headers,
method: "POST",
body: formData,
});
if (!response.ok) {
throw new ORPCError("UNAUTHORIZED", {
status: response.status,
message: response.statusText,
if (!pdfBrowser || !pdfBrowser.connected) {
pdfBrowser = await puppeteer.connect({
acceptInsecureCerts: true,
browserWSEndpoint: env.PRINTER_ENDPOINT,
});
}
const pdfBuffer = await response.arrayBuffer();
await pdfBrowser.setCookie({ name: "locale", value: locale, domain });
const page = await pdfBrowser.newPage();
if (env.APP_URL.includes("localhost")) await interceptLocalhostRequests(page);
await page.goto(url, { waitUntil: "networkidle0" });
const pdfBuffer = await page.pdf({
width: pageDimensions[format].width,
height: pageDimensions[format].height,
tagged: true,
waitForFonts: true,
printBackground: true,
margin: {
top: marginY,
right: marginX,
bottom: marginY,
left: marginX,
},
});
await page.close();
// Store PDF and return URL
const result = await uploadFile({
userId: input.userId,
resumeId: input.id,
userId,
resumeId: id,
data: new Uint8Array(pdfBuffer),
contentType: "application/pdf",
type: "pdf",
@@ -84,9 +137,13 @@ export const printerService = {
return result.url;
},
getResumeScreenshot: async (input: { id: string; userId: string }): Promise<string> => {
getResumeScreenshot: async (
input: Pick<InferSelectModel<typeof schema.resume>, "userId" | "id" | "data">,
): Promise<string> => {
const { id, userId, data } = input;
const storageService = getStorageService();
const screenshotPrefix = `uploads/${input.userId}/screenshots/${input.id}`;
const screenshotPrefix = `uploads/${userId}/screenshots/${id}`;
const existingScreenshots = await storageService.list(screenshotPrefix);
const now = Date.now();
@@ -105,59 +162,44 @@ export const printerService = {
const latest = sortedFiles[0];
const age = now - latest.timestamp;
if (age < SCREENSHOT_TTL) {
// Return URL of cached screenshot
return new URL(latest.path, env.APP_URL).toString();
}
if (age < SCREENSHOT_TTL) return new URL(latest.path, env.APP_URL).toString();
// Delete old screenshots
await Promise.all(sortedFiles.map((file) => storageService.delete(file.path)));
}
}
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
const domain = new URL(baseUrl).hostname;
const token = generatePrinterToken(input.id);
const url = `${baseUrl}/printer/${input.id}?token=${token}`;
const locale = data.metadata.page.locale;
const formData = new FormData();
const token = generatePrinterToken(id);
const url = `${baseUrl}/printer/${id}?token=${token}`;
formData.append("url", url);
formData.append("clip", "true");
formData.append("width", "794");
formData.append("height", "1123");
formData.append("format", "webp");
formData.append("optimizeForSpeed", "true");
formData.append("skipNetworkIdleEvent", "false");
const headers = new Headers();
if (env.GOTENBERG_USERNAME && env.GOTENBERG_PASSWORD) {
const credentials = `${env.GOTENBERG_USERNAME}:${env.GOTENBERG_PASSWORD}`;
const encodedCredentials = btoa(credentials);
headers.set("Authorization", `Basic ${encodedCredentials}`);
}
const response = await fetch(`${env.GOTENBERG_ENDPOINT}/forms/chromium/screenshot/url`, {
headers,
method: "POST",
body: formData,
});
if (!response.ok) {
throw new ORPCError("UNAUTHORIZED", {
status: response.status,
message: response.statusText,
if (!screenshotBrowser || !screenshotBrowser.connected) {
screenshotBrowser = await puppeteer.connect({
acceptInsecureCerts: true,
defaultViewport: { width: 794, height: 1123 },
browserWSEndpoint: env.PRINTER_ENDPOINT,
});
}
const imageBuffer = await response.arrayBuffer();
await screenshotBrowser.setCookie({ name: "locale", value: locale, domain });
const page = await screenshotBrowser.newPage();
if (env.APP_URL.includes("localhost")) await interceptLocalhostRequests(page);
await page.goto(url, { waitUntil: "networkidle0" });
const screenshotBuffer = await page.screenshot({ type: "webp", quality: 80 });
await page.close();
// Store screenshot and return URL
const result = await uploadFile({
userId: input.userId,
resumeId: input.id,
data: new Uint8Array(imageBuffer),
userId,
resumeId: id,
data: new Uint8Array(screenshotBuffer),
contentType: "image/webp",
type: "screenshot",
});
+15
View File
@@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { sql } from "drizzle-orm";
import { db } from "@/integrations/drizzle/client";
import { printerService } from "@/integrations/orpc/services/printer";
import { getStorageService } from "@/integrations/orpc/services/storage";
function isUnhealthy(check: unknown): boolean {
@@ -20,6 +21,7 @@ async function handler() {
timestamp: new Date().toISOString(),
uptime: `${process.uptime().toFixed(2)}s`,
database: await checkDatabase(),
printer: await checkPrinter(),
storage: await checkStorage(),
};
@@ -50,6 +52,19 @@ async function checkDatabase() {
}
}
async function checkPrinter() {
try {
const result = await printerService.healthcheck();
return { status: "healthy", ...result };
} catch (error) {
return {
status: "unhealthy",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
async function checkStorage() {
try {
const storageService = getStorageService();
@@ -3,6 +3,7 @@ import {
ArrowsClockwiseIcon,
CircleNotchIcon,
CubeFocusIcon,
FileJsIcon,
FilePdfIcon,
type Icon,
LinkSimpleIcon,
@@ -20,7 +21,7 @@ import { Button } from "@/components/animate-ui/components/buttons/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { authClient } from "@/integrations/auth/client";
import { orpc } from "@/integrations/orpc/client";
import { downloadFromUrl, generateFilename } from "@/utils/file";
import { downloadFromUrl, downloadWithAnchor, generateFilename } from "@/utils/file";
import { cn } from "@/utils/style";
export function BuilderDock() {
@@ -49,6 +50,15 @@ export function BuilderDock() {
toast.success(t`A link to your resume has been copied to clipboard.`);
}, [publicUrl, copyToClipboard]);
const onDownloadJSON = useCallback(async () => {
if (!resume) return;
const jsonString = JSON.stringify(resume, null, 2);
const blob = new Blob([jsonString], { type: "application/json" });
const filename = generateFilename(resume.data.basics.name, "json");
downloadWithAnchor(blob, filename);
}, [resume]);
const onDownloadPDF = useCallback(async () => {
if (!resume) return;
@@ -73,6 +83,7 @@ export function BuilderDock() {
<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
title={t`Download PDF`}
disabled={isPrinting}
@@ -8,8 +8,8 @@ import {
PencilSimpleLineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react";
import { useMemo } from "react";
import { AnimatePresence, Reorder } from "motion/react";
import { match } from "ts-pattern";
import {
DropdownMenu,
DropdownMenuContent,
@@ -24,47 +24,144 @@ import {
DropdownMenuTrigger,
} from "@/components/animate-ui/components/radix/dropdown-menu";
import { useResumeStore } from "@/components/resume/store/resume";
import { Badge } from "@/components/ui/badge";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import type { CustomSection } from "@/schema/resume/data";
import { sanitizeHtml } from "@/utils/sanitize";
import type { CustomSection, CustomSectionItem as CustomSectionItemType, SectionType } from "@/schema/resume/data";
import { getSectionTitle } from "@/utils/resume/section";
import { cn } from "@/utils/style";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton } from "../shared/section-item";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
function getItemTitle(type: SectionType, item: CustomSectionItemType): string {
return match(type)
.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 : ""))
.exhaustive();
}
function getItemSubtitle(type: SectionType, item: CustomSectionItemType): string | undefined {
return match(type)
.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)
.exhaustive();
}
export function CustomSectionBuilder() {
const customSections = useResumeStore((state) => state.resume.data.customSections);
return (
<SectionBase type="custom" className={cn("rounded-md border", customSections.length === 0 && "border-dashed")}>
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
<AnimatePresence>
{customSections.map((section) => (
<CustomSectionItem key={section.id} section={section} />
<CustomSectionContainer key={section.id} section={section} />
))}
</AnimatePresence>
<SectionAddItemButton type="custom">
{/* Add Custom Section Button */}
<SectionAddItemButton type="custom" variant="outline" className="rounded-md">
<Trans>Add a new custom section</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
function CustomSectionItem({ section }: { section: CustomSection }) {
const confirm = useConfirm();
function CustomSectionContainer({ section }: { section: CustomSection }) {
const { openDialog } = useDialogStore();
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const sanitizedContent = useMemo(() => sanitizeHtml(section.content), [section.content]);
const onUpdate = () => {
const onUpdateSection = () => {
openDialog("resume.sections.custom.update", section);
};
const onDuplicate = () => {
openDialog("resume.sections.custom.create", 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;
});
};
const onToggleVisibility = () => {
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-left 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-sm">
{getSectionTitle(section.type)}
</Badge>
<span className="line-clamp-1 break-all 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 = useResumeStore((state) => state.updateResumeData);
const onToggleSectionVisibility = () => {
updateResumeData((draft) => {
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
if (sectionIndex === -1) return;
@@ -72,6 +169,14 @@ function CustomSectionItem({ section }: { section: CustomSection }) {
});
};
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);
@@ -80,7 +185,7 @@ function CustomSectionItem({ section }: { section: CustomSection }) {
});
};
const onDelete = async () => {
const onDeleteSection = async () => {
const confirmed = await confirm("Are you sure you want to delete this custom section?", {
confirmText: "Delete",
cancelText: "Cancel",
@@ -90,7 +195,6 @@ function CustomSectionItem({ section }: { section: CustomSection }) {
updateResumeData((draft) => {
draft.customSections = draft.customSections.filter((_section) => _section.id !== section.id);
// remove from layout
draft.metadata.layout.pages = draft.metadata.layout.pages.map((page) => ({
...page,
main: page.main.filter((id) => id !== section.id),
@@ -100,84 +204,60 @@ function CustomSectionItem({ section }: { section: CustomSection }) {
};
return (
<motion.div
key={section.id}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="group flex select-none border-b"
>
<button
type="button"
onClick={onUpdate}
className={cn(
"flex flex-1 flex-col items-start justify-center space-y-0.5 p-4 text-left transition-opacity hover:bg-secondary/20 focus:outline-none focus-visible:ring-1",
section.hidden && "opacity-50",
)}
>
<div className="line-clamp-1 font-medium text-base">{section.title}</div>
<div
className="line-clamp-2 text-muted-foreground text-xs"
// biome-ignore lint/security/noDangerouslySetInnerHtml: Content is sanitized with DOMPurify
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
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 />
</button>
</DropdownMenuTrigger>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/20 focus:outline-none focus-visible:ring-1 group-hover:opacity-100"
>
<DotsThreeVerticalIcon />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={onToggleSectionVisibility}>
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
</DropdownMenuItem>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={onToggleVisibility}>
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onUpdateSection}>
<PencilSimpleLineIcon />
<Trans>Update</Trans>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onUpdate}>
<PencilSimpleLineIcon />
<Trans>Update</Trans>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onDuplicateSection}>
<CopySimpleIcon />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onDuplicate}>
<CopySimpleIcon />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ColumnsIcon />
<Trans>Columns</Trans>
</DropdownMenuSubTrigger>
<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>
<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 />
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onSelect={onDelete}>
<TrashSimpleIcon />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</motion.div>
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onSelect={onDeleteSection}>
<TrashSimpleIcon />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,37 +1,167 @@
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, type ButtonProps } from "@/components/animate-ui/components/buttons/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/animate-ui/components/radix/dropdown-menu";
import { useResumeStore } from "@/components/resume/store/resume";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import type { SectionItem as SectionItemType, SectionType } from "@/schema/resume/data";
import {
addItemToSection,
createCustomSectionWithItem,
createPageWithSection,
getCompatibleMoveTargets,
getSourceSectionTitle,
removeItemFromSource,
} from "@/utils/resume/move-item";
import { cn } from "@/utils/style";
// ============================================================================
// MoveItemSubmenu Component
// ============================================================================
type MoveItemSubmenuProps = {
type: SectionType;
item: 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 = useResumeStore((state) => state.resume);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
/** Compute compatible move targets grouped by page */
const moveTargets = useMemo(
() => getCompatibleMoveTargets(resume.data, type, customSectionId),
[resume.data, type, customSectionId],
);
/** Get the current section's title (used when creating new sections) */
const currentSectionTitle = useMemo(
() => getSourceSectionTitle(resume.data, type, customSectionId),
[resume.data, 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} onSelect={() => handleMoveToSection(sectionId)}>
{sectionTitle}
</DropdownMenuItem>
))}
{/* Separator if there are existing sections */}
{sections.length > 0 && <DropdownMenuSeparator />}
{/* Option to create a new section on this page */}
<DropdownMenuItem onSelect={() => handleNewSectionOnPage(pageIndex)}>
<FolderPlusIcon />
<Trans>New Section</Trans>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
))}
<DropdownMenuSeparator />
{/* Option to create a new page with a new section */}
<DropdownMenuItem onSelect={handleNewPage}>
<PlusCircleIcon />
<Trans>New Page</Trans>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
// ============================================================================
// SectionItem Component
// ============================================================================
type Props<T extends SectionItemType> = {
type: SectionType;
item: T;
title: string;
subtitle?: string;
customSectionId?: string;
};
export function SectionItem<T extends SectionItemType>({ type, item, title, subtitle }: Props<T>) {
export function SectionItem<T extends SectionItemType>({ type, item, title, subtitle, customSectionId }: Props<T>) {
const confirm = useConfirm();
const controls = useDragControls();
const { openDialog } = useDialogStore();
@@ -39,20 +169,30 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
const onToggleVisibility = () => {
updateResumeData((draft) => {
const section = draft.sections[type];
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;
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 {
const section = draft.sections[type];
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 = () => {
openDialog(`resume.sections.${type}.update`, item);
// 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 = () => {
openDialog(`resume.sections.${type}.create`, item);
// 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 () => {
@@ -64,11 +204,19 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
if (!confirmed) return;
updateResumeData((draft) => {
const section = draft.sections[type];
if (!("items" in section)) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items.splice(index, 1);
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 {
const section = draft.sections[type];
if (!("items" in section)) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items.splice(index, 1);
}
});
};
@@ -84,7 +232,7 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
className="group relative flex h-18 select-none border-b"
>
<div
className="flex cursor-ns-resize touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/20 group-hover:opacity-100"
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);
@@ -96,7 +244,7 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
<button
onClick={onUpdate}
className={cn(
"flex flex-1 flex-col items-start justify-center space-y-0.5 pl-1.5 text-left opacity-100 transition-opacity hover:bg-secondary/20 focus:outline-none focus-visible:ring-1",
"flex flex-1 flex-col items-start justify-center space-y-0.5 pl-1.5 text-left opacity-100 transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1",
item.hidden && "opacity-50",
)}
>
@@ -106,7 +254,7 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/20 focus:outline-none focus-visible:ring-1 group-hover:opacity-100">
<button 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 />
</button>
</DropdownMenuTrigger>
@@ -131,6 +279,8 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
<CopySimpleIcon />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<MoveItemSubmenu type={type} item={item} customSectionId={customSectionId} />
</DropdownMenuGroup>
<DropdownMenuSeparator />
@@ -147,26 +297,32 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
);
}
type AddButtonProps = {
type AddButtonProps = Omit<ButtonProps, "type"> & {
type: SectionType | "custom";
children: React.ReactNode;
customSectionId?: string;
};
export function SectionAddItemButton({ type, children }: AddButtonProps) {
export function SectionAddItemButton({ type, customSectionId, className, children, ...props }: AddButtonProps) {
const { openDialog } = useDialogStore();
const handleAdd = () => {
openDialog(`resume.sections.${type}.create`, undefined);
if (type === "custom") {
openDialog("resume.sections.custom.create", undefined);
} else {
openDialog(`resume.sections.${type}.create`, customSectionId ? { customSectionId } : undefined);
}
};
return (
<button
type="button"
<Button
tapScale={1}
variant="ghost"
onClick={handleAdd}
className="flex w-full items-center gap-x-2 px-3 py-4 font-medium hover:bg-secondary/20 focus:outline-none focus-visible:ring-1"
className={cn("h-12 w-full justify-start rounded-t-none", className)}
{...props}
>
<PlusIcon />
{children}
</button>
</Button>
);
}
@@ -289,7 +289,7 @@ function LayoutColumn({ pageIndex, columnId, items, disabled = false }: LayoutCo
return (
<SortableContext id={droppableId} items={items} strategy={verticalListSortingStrategy}>
<div className={cn(disabled && "opacity-50")}>
<div className={cn("space-y-1.5", disabled && "opacity-50")}>
<div className="@md:row-start-1 pl-4 font-medium text-xs">{getColumnLabel(columnId)}</div>
<div
@@ -355,7 +355,7 @@ const LayoutItemContent = forwardRef<HTMLDivElement, LayoutItemContentProps>(
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/20 active:cursor-grabbing active:border-primary/60 active:bg-secondary/20",
"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,
+38 -3
View File
@@ -308,11 +308,46 @@ export type SectionType = keyof z.infer<typeof sectionsSchema>;
export type SectionData<T extends SectionType = SectionType> = z.infer<typeof sectionsSchema>[T];
export type SectionItem<T extends SectionType = SectionType> = SectionData<T>["items"][number];
export const sectionTypeSchema = z.enum([
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
]);
export const customSectionItemSchema = z.union([
profileItemSchema,
experienceItemSchema,
educationItemSchema,
projectItemSchema,
skillItemSchema,
languageItemSchema,
interestItemSchema,
awardItemSchema,
certificationItemSchema,
publicationItemSchema,
volunteerItemSchema,
referenceItemSchema,
]);
export type CustomSectionItem = z.infer<typeof customSectionItemSchema>;
export const customSectionSchema = baseSectionSchema.extend({
id: z.string().describe("The unique identifier for the custom section. Usually generated as a UUID."),
content: z
.string()
.describe("The content of the custom section. This should be a HTML-formatted string. Leave blank to hide."),
type: sectionTypeSchema.describe(
"The type of items this custom section contains. Determines which item schema and form fields to use.",
),
items: z
.array(customSectionItemSchema)
.describe("The items to display in the custom section. Items follow the schema of the section type."),
});
export type CustomSection = z.infer<typeof customSectionSchema>;
-1
View File
@@ -165,7 +165,6 @@
}
.resume-preview-container {
.page-section,
.page-section .section-item {
break-inside: avoid;
}
+1 -3
View File
@@ -15,9 +15,7 @@ export const env = createEnv({
PRINTER_APP_URL: z.url({ protocol: /https?/ }).optional(),
// Printer
GOTENBERG_ENDPOINT: z.url({ protocol: /https?/ }),
GOTENBERG_USERNAME: z.string().min(1).optional(),
GOTENBERG_PASSWORD: z.string().min(1).optional(),
PRINTER_ENDPOINT: z.url({ protocol: /wss?/ }),
// Database
DATABASE_URL: z.url({ protocol: /postgres(ql)?/ }),
+269
View File
@@ -0,0 +1,269 @@
import type { WritableDraft } from "immer";
import type { CustomSection, ResumeData, SectionItem, SectionType } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
import { getSectionTitle as getDefaultSectionTitle } from "./section";
// ============================================================================
// Types
// ============================================================================
/** Target section that an item can be moved to */
export type MoveTargetSection = {
sectionId: string;
sectionTitle: string;
/** Whether this is a standard section (true) or custom section (false) */
isStandard: boolean;
};
/** Page with its compatible sections for the move menu */
export type MoveTargetPage = {
pageIndex: number;
sections: MoveTargetSection[];
};
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Checks if a section ID belongs to a standard section.
* Standard sections have predefined keys like "experience", "education", etc.
*/
function isStandardSectionId(sectionId: string): sectionId is SectionType {
const standardSections: SectionType[] = [
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
];
return standardSections.includes(sectionId as SectionType);
}
// ============================================================================
// Public API
// ============================================================================
/**
* Gets the title of a section.
* For standard sections, returns the localized default title.
* For custom sections, returns the user-defined title.
*
* @param resumeData - The resume data object
* @param type - The section type
* @param customSectionId - The custom section ID (if applicable)
* @returns The section title
*/
export function getSourceSectionTitle(resumeData: ResumeData, type: SectionType, customSectionId?: string): string {
if (customSectionId) {
const customSection = resumeData.customSections.find((s) => s.id === customSectionId);
return customSection?.title ?? getDefaultSectionTitle(type);
}
return getDefaultSectionTitle(type);
}
/**
* Finds all compatible sections an item can be moved to.
* A section is compatible if it has the same type as the source section.
*
* @param resumeData - The resume data object
* @param sourceType - The type of the source section
* @param sourceSectionId - The ID of the source section (custom section ID or undefined for standard)
* @returns Array of pages with their compatible sections
*/
export function getCompatibleMoveTargets(
resumeData: ResumeData,
sourceType: SectionType,
sourceSectionId: string | undefined,
): MoveTargetPage[] {
const { pages } = resumeData.metadata.layout;
const result: MoveTargetPage[] = [];
for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
const page = pages[pageIndex];
const allSectionIds = [...page.main, ...page.sidebar];
const compatibleSections: MoveTargetSection[] = [];
for (const sectionId of allSectionIds) {
// Skip the source section itself
if (sectionId === sourceSectionId || (sourceSectionId === undefined && sectionId === sourceType)) {
continue;
}
// Check if it's a standard section with matching type
if (isStandardSectionId(sectionId) && sectionId === sourceType) {
compatibleSections.push({
sectionId,
sectionTitle: getDefaultSectionTitle(sectionId),
isStandard: true,
});
continue;
}
// Check if it's a custom section with matching type
const customSection = resumeData.customSections.find((s) => s.id === sectionId);
if (customSection && customSection.type === sourceType) {
compatibleSections.push({
sectionId: customSection.id,
sectionTitle: customSection.title,
isStandard: false,
});
}
}
result.push({ pageIndex, sections: compatibleSections });
}
return result;
}
/**
* Removes an item from its source section (standard or custom).
*
* @param draft - The immer draft of resume data
* @param itemId - The ID of the item to remove
* @param type - The section type
* @param customSectionId - The custom section ID (if applicable)
* @returns The removed item, or null if not found
*/
export function removeItemFromSource(
draft: WritableDraft<ResumeData>,
itemId: string,
type: SectionType,
customSectionId?: string,
): SectionItem | null {
if (customSectionId) {
const section = draft.customSections.find((s) => s.id === customSectionId);
if (!section) return null;
const index = section.items.findIndex((item) => item.id === itemId);
if (index === -1) return null;
const [removed] = section.items.splice(index, 1);
return removed as SectionItem;
}
const section = draft.sections[type];
if (!("items" in section)) return null;
const index = section.items.findIndex((item) => item.id === itemId);
if (index === -1) return null;
const [removed] = section.items.splice(index, 1);
return removed as SectionItem;
}
/**
* Adds an item to a target section.
*
* @param draft - The immer draft of resume data
* @param item - The item to add
* @param targetSectionId - The target section ID
* @param type - The section type
*/
export function addItemToSection(
draft: WritableDraft<ResumeData>,
item: SectionItem,
targetSectionId: string,
type: SectionType,
): void {
// Check if target is a standard section
if (isStandardSectionId(targetSectionId) && targetSectionId === type) {
const section = draft.sections[type];
if ("items" in section) {
section.items.push(item as never);
}
return;
}
// Otherwise, it's a custom section
const customSection = draft.customSections.find((s) => s.id === targetSectionId);
if (customSection) {
customSection.items.push(item as never);
}
}
/**
* Creates a new custom section with the given item and adds it to the specified page.
*
* @param draft - The immer draft of resume data
* @param item - The item to add to the new section
* @param type - The section type for the new custom section
* @param sectionTitle - The title for the new custom section
* @param targetPageIndex - The page index to add the section to
* @returns The ID of the newly created custom section
*/
export function createCustomSectionWithItem(
draft: WritableDraft<ResumeData>,
item: SectionItem,
type: SectionType,
sectionTitle: string,
targetPageIndex: number,
): string {
const newSectionId = generateId();
// Create the new custom section
const newSection: CustomSection = {
id: newSectionId,
type,
title: sectionTitle,
columns: 1,
hidden: false,
items: [item as never],
};
draft.customSections.push(newSection as WritableDraft<CustomSection>);
// Add the section to the target page's main column
const page = draft.metadata.layout.pages[targetPageIndex];
if (page) {
page.main.push(newSectionId);
}
return newSectionId;
}
/**
* Creates a new page with a custom section containing the given item.
*
* @param draft - The immer draft of resume data
* @param item - The item to add to the new section
* @param type - The section type for the new custom section
* @param sectionTitle - The title for the new custom section
*/
export function createPageWithSection(
draft: WritableDraft<ResumeData>,
item: SectionItem,
type: SectionType,
sectionTitle: string,
): void {
const newSectionId = generateId();
// Create the new custom section
const newSection: CustomSection = {
id: newSectionId,
type,
title: sectionTitle,
columns: 1,
hidden: false,
items: [item as never],
};
draft.customSections.push(newSection as WritableDraft<CustomSection>);
// Create the new page with the section in the main column
draft.metadata.layout.pages.push({
fullWidth: false,
main: [newSectionId],
sidebar: [],
});
}