mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 06:47:00 +10:00
- 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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)">
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -165,7 +165,6 @@
|
||||
}
|
||||
|
||||
.resume-preview-container {
|
||||
.page-section,
|
||||
.page-section .section-item {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
+1
-3
@@ -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)?/ }),
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user