- fixes #2562, add better error messages for duplicate resume slugs

- improvements made to ditgar template
- general improvements to all templates with backgrounds
- update dependencies and translations
- improved print function that handles single page and multi page resumes
This commit is contained in:
Amruth Pillai
2026-01-23 23:31:24 +01:00
parent ed74fb67f2
commit 4e73a81d4b
96 changed files with 3996 additions and 3418 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ export function CommandPalette() {
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogHeader className="sr-only">
<DialogHeader className="sr-only print:hidden">
<DialogTitle>
<Trans>Builder Command Palette</Trans>
</DialogTitle>
+2 -9
View File
@@ -106,7 +106,7 @@ export const ResumePreview = ({ showPageNumbers, pageClassName, className, ...pr
const pageNumber = pageIndex + 1;
return (
<div key={pageIndex} className="relative">
<div key={pageIndex} data-page-index={pageIndex} className="relative">
{showPageNumbers && totalNumberOfPages > 1 && (
<div className="absolute -top-6 left-0">
<span className="font-medium text-foreground text-xs">
@@ -117,14 +117,7 @@ export const ResumePreview = ({ showPageNumbers, pageClassName, className, ...pr
</div>
)}
<div
className={cn(
`page page-${pageIndex}`,
pageIndex > 0 && "print:break-before-page",
styles.page,
pageClassName,
)}
>
<div className={cn(`page page-${pageIndex}`, styles.page, pageClassName)}>
<TemplateComponent pageIndex={pageIndex} pageLayout={pageLayout} />
</div>
</div>
@@ -1,5 +1,5 @@
import { match } from "ts-pattern";
import type { CustomSectionItem, SectionType } from "@/schema/resume/data";
import type { CustomSectionItem, SectionItem, SectionType } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { useResumeStore } from "../store/resume";
import { AwardsItem } from "./items/awards-item";
@@ -25,38 +25,22 @@ type SectionComponentProps = {
// 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("profiles", () => <ProfilesItem {...(item as SectionItem<"profiles">)} className={itemClassName} />)
.with("experience", () => <ExperienceItem {...(item as SectionItem<"experience">)} className={itemClassName} />)
.with("education", () => <EducationItem {...(item as SectionItem<"education">)} className={itemClassName} />)
.with("projects", () => <ProjectsItem {...(item as SectionItem<"projects">)} className={itemClassName} />)
.with("skills", () => <SkillsItem {...(item as SectionItem<"skills">)} className={itemClassName} />)
.with("languages", () => <LanguagesItem {...(item as SectionItem<"languages">)} className={itemClassName} />)
.with("interests", () => <InterestsItem {...(item as SectionItem<"interests">)} className={itemClassName} />)
.with("awards", () => <AwardsItem {...(item as SectionItem<"awards">)} className={itemClassName} />)
.with("certifications", () => (
<CertificationsItem {...(item as Parameters<typeof CertificationsItem>[0])} className={itemClassName} />
<CertificationsItem {...(item as SectionItem<"certifications">)} 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} />
<PublicationsItem {...(item as SectionItem<"publications">)} className={itemClassName} />
))
.with("volunteer", () => <VolunteerItem {...(item as SectionItem<"volunteer">)} className={itemClassName} />)
.with("references", () => <ReferencesItem {...(item as SectionItem<"references">)} className={itemClassName} />)
.exhaustive();
}
@@ -186,7 +170,10 @@ export function getSectionComponent(
style={{ gridTemplateColumns: `repeat(${customSection.columns}, 1fr)` }}
>
{visibleItems.map((item) => (
<div key={item.id} className={cn(`section-item section-item-${customSection.type}`)}>
<div
key={item.id}
className={cn(`section-item section-item-${customSection.type} print:break-inside-avoid`)}
>
{renderItemByType(customSection.type, item, itemClassName)}
</div>
))}
@@ -194,6 +181,7 @@ export function getSectionComponent(
</section>
);
};
return CustomSectionComponent;
});
}
@@ -10,21 +10,27 @@ type AwardsItemProps = SectionItem<"awards"> & {
export function AwardsItem({ className, ...item }: AwardsItemProps) {
return (
<div className={cn("awards-item", className)}>
{/* Header */}
<div className="section-item-header awards-item-header">
{/* Row 1 */}
<div className="flex items-center justify-between">
<p className="section-item-title awards-item-title">
<strong>{item.title}</strong>
</p>
<p className="section-item-metadata awards-item-date text-right">{item.date}</p>
<strong className="section-item-title awards-item-title">{item.title}</strong>
<span className="section-item-metadata awards-item-date text-right">{item.date}</span>
</div>
{/* Row 2 */}
<div className="flex items-center justify-between">
<p className="section-item-metadata awards-item-awarder">{item.awarder}</p>
<span className="section-item-metadata awards-item-awarder">{item.awarder}</span>
</div>
</div>
{/* Description */}
<div className="section-item-description awards-item-description">
<TiptapContent content={item.description} />
</div>
<div className="section-item-link awards-item-link">
{/* Website */}
<div className="section-item-website awards-item-website">
<PageLink {...item.website} label={item.website.label} />
</div>
</div>
@@ -10,21 +10,27 @@ type CertificationsItemProps = SectionItem<"certifications"> & {
export function CertificationsItem({ className, ...item }: CertificationsItemProps) {
return (
<div className={cn("certifications-item", className)}>
{/* Header */}
<div className="section-item-header certifications-item-header">
{/* Row 1 */}
<div className="flex items-center justify-between">
<p className="section-item-title certifications-item-title">
<strong>{item.title}</strong>
</p>
<p className="section-item-metadata certifications-item-date text-right">{item.date}</p>
<strong className="section-item-title certifications-item-title">{item.title}</strong>
<span className="section-item-metadata certifications-item-date text-right">{item.date}</span>
</div>
{/* Row 2 */}
<div className="flex items-center justify-between">
<p className="section-item-metadata certifications-item-issuer">{item.issuer}</p>
<span className="section-item-metadata certifications-item-issuer">{item.issuer}</span>
</div>
</div>
{/* Description */}
<div className="section-item-description certifications-item-description">
<TiptapContent content={item.description} />
</div>
<div className="section-item-link certifications-item-link">
{/* Website */}
<div className="section-item-website certifications-item-website">
<PageLink {...item.website} label={item.website.label} />
</div>
</div>
@@ -10,26 +10,32 @@ type EducationItemProps = SectionItem<"education"> & {
export function EducationItem({ className, ...item }: EducationItemProps) {
return (
<div className={cn("education-item", className)}>
{/* Header */}
<div className="section-item-header education-item-header mb-2">
{/* Row 1 */}
<div className="flex items-center justify-between">
<p className="section-item-title education-item-title">
<strong>{item.school}</strong>
</p>
<p className="section-item-metadata education-item-degree-grade text-right">
<strong className="section-item-title education-item-title">{item.school}</strong>
<span className="section-item-metadata education-item-degree-grade text-right">
{[item.degree, item.grade].filter(Boolean).join(" • ")}
</p>
</span>
</div>
{/* Row 2 */}
<div className="flex items-center justify-between">
<p className="section-item-metadata education-item-area">{item.area}</p>
<p className="section-item-metadata education-item-location-period text-right">
<span className="section-item-metadata education-item-area">{item.area}</span>
<span className="section-item-metadata education-item-location-period text-right">
{[item.location, item.period].filter(Boolean).join(" • ")}
</p>
</span>
</div>
</div>
{/* Description */}
<div className="section-item-description education-item-description">
<TiptapContent content={item.description} />
</div>
<div className="section-item-link education-item-link">
{/* Website */}
<div className="section-item-website education-item-website">
<PageLink {...item.website} label={item.website.label} />
</div>
</div>
@@ -10,22 +10,28 @@ type ExperienceItemProps = SectionItem<"experience"> & {
export function ExperienceItem({ className, ...item }: ExperienceItemProps) {
return (
<div className={cn("experience-item", className)}>
{/* Header */}
<div className="section-item-header experience-item-header">
{/* Row 1 */}
<div className="flex items-center justify-between">
<p className="section-item-title experience-item-title">
<strong>{item.company}</strong>
</p>
<p className="section-item-metadata experience-item-location text-right">{item.location}</p>
<strong className="section-item-title experience-item-title">{item.company}</strong>
<span className="section-item-metadata experience-item-location text-right">{item.location}</span>
</div>
{/* Row 2 */}
<div className="flex items-center justify-between">
<p className="section-item-metadata experience-item-position">{item.position}</p>
<p className="section-item-metadata experience-item-period text-right">{item.period}</p>
<span className="section-item-metadata experience-item-position">{item.position}</span>
<span className="section-item-metadata experience-item-period text-right">{item.period}</span>
</div>
</div>
{/* Description */}
<div className="section-item-description experience-item-description">
<TiptapContent content={item.description} />
</div>
<div className="section-item-link experience-item-link">
{/* Website */}
<div className="section-item-website experience-item-website">
<PageLink {...item.website} label={item.website.label} />
</div>
</div>
@@ -9,14 +9,14 @@ type InterestsItemProps = SectionItem<"interests"> & {
export function InterestsItem({ className, ...item }: InterestsItemProps) {
return (
<div className={cn("interests-item", className)}>
{/* Header */}
<div className="section-item-header flex items-center gap-x-1.5">
<PageIcon icon={item.icon} className="section-item-icon interests-item-icon shrink-0" />
<p className="section-item-title interests-item-name">
<strong>{item.name}</strong>
</p>
<PageIcon icon={item.icon} className="section-item-icon interests-item-icon" />
<strong className="section-item-title interests-item-name">{item.name}</strong>
</div>
<p className="section-item-keywords interests-item-keywords opacity-80">{item.keywords.join(", ")}</p>
{/* Keywords */}
<span className="section-item-keywords interests-item-keywords opacity-80">{item.keywords.join(", ")}</span>
</div>
);
}
@@ -9,13 +9,16 @@ type LanguagesItemProps = SectionItem<"languages"> & {
export function LanguagesItem({ className, ...item }: LanguagesItemProps) {
return (
<div className={cn("languages-item", className)}>
<div className="section-item-header">
<p className="section-item-title languages-item-name">
<strong>{item.language}</strong>
</p>
<p className="section-item-metadata languages-item-fluency opacity-80">{item.fluency}</p>
{/* Header */}
<div className="section-item-header flex flex-col">
{/* Row 1 */}
<strong className="section-item-title languages-item-name">{item.language}</strong>
{/* Row 2 */}
<span className="section-item-metadata languages-item-fluency opacity-80">{item.fluency}</span>
</div>
{/* Level */}
<PageLevel level={item.level} className="section-item-level languages-item-level" />
</div>
);
@@ -10,17 +10,17 @@ type ProfilesItemProps = SectionItem<"profiles"> & {
export function ProfilesItem({ className, ...item }: ProfilesItemProps) {
return (
<div className={cn("profiles-item", className)}>
{/* Header */}
<div className="section-item-header flex items-center gap-x-1.5">
<PageIcon icon={item.icon} className="section-item-icon profiles-item-icon shrink-0" />
<p className="section-item-title profiles-item-network">
<strong>{item.network}</strong>
</p>
<PageIcon icon={item.icon} className="section-item-icon profiles-item-icon" />
<strong className="section-item-title profiles-item-network">{item.network}</strong>
</div>
{/* Website */}
<PageLink
{...item.website}
label={item.website.label || item.username}
className="section-item-link profiles-item-link"
className="section-item-website profiles-item-website"
/>
</div>
);
@@ -10,18 +10,22 @@ type ProjectsItemProps = SectionItem<"projects"> & {
export function ProjectsItem({ className, ...item }: ProjectsItemProps) {
return (
<div className={cn("projects-item", className)}>
{/* Header */}
<div className="section-item-header projects-item-header">
{/* Row 1 */}
<div className="flex items-center justify-between">
<p className="section-item-title projects-item-title">
<strong>{item.name}</strong>
</p>
<p className="section-item-metadata projects-item-period text-right">{item.period}</p>
<strong className="section-item-title projects-item-title">{item.name}</strong>
<span className="section-item-metadata projects-item-period text-right">{item.period}</span>
</div>
</div>
{/* Description */}
<div className="section-item-description projects-item-description">
<TiptapContent content={item.description} />
</div>
<div className="section-item-link projects-item-link">
{/* Website */}
<div className="section-item-website projects-item-website">
<PageLink {...item.website} label={item.website.label} />
</div>
</div>
@@ -10,21 +10,27 @@ type PublicationsItemProps = SectionItem<"publications"> & {
export function PublicationsItem({ className, ...item }: PublicationsItemProps) {
return (
<div className={cn("publications-item", className)}>
{/* Header */}
<div className="section-item-header publications-item-header">
{/* Row 1 */}
<div className="flex items-center justify-between">
<p className="section-item-title publications-item-title">
<strong>{item.title}</strong>
</p>
<p className="section-item-metadata publications-item-date text-right">{item.date}</p>
<strong className="section-item-title publications-item-title">{item.title}</strong>
<span className="section-item-metadata publications-item-date text-right">{item.date}</span>
</div>
{/* Row 2 */}
<div className="flex items-center justify-between">
<p className="section-item-metadata publications-item-publisher">{item.publisher}</p>
<span className="section-item-metadata publications-item-publisher">{item.publisher}</span>
</div>
</div>
{/* Description */}
<div className="section-item-description publications-item-description">
<TiptapContent content={item.description} />
</div>
<div className="section-item-link publications-item-link">
{/* Website */}
<div className="section-item-website publications-item-website">
<PageLink {...item.website} label={item.website.label} />
</div>
</div>
@@ -10,20 +10,37 @@ type ReferencesItemProps = SectionItem<"references"> & {
export function ReferencesItem({ className, ...item }: ReferencesItemProps) {
return (
<div className={cn("references-item", className)}>
{/* Header */}
<div className="section-item-header references-item-header">
{/* Row 1 */}
<div className="flex items-center justify-between">
<p className="section-item-title references-item-name">
<strong>{item.name}</strong>
</p>
<strong className="section-item-title references-item-name">{item.name}</strong>
</div>
{/* Row 2 */}
<div className="flex items-center justify-between">
<span className="section-item-metadata references-item-position">{item.position}</span>
</div>
<p className="section-item-metadata references-item-position">{item.position}</p>
</div>
{/* Description */}
<div className="section-item-description references-item-description">
<TiptapContent content={item.description} />
</div>
{/* Footer */}
<div className="section-item-footer references-item-footer flex flex-col">
<p className="section-item-metadata references-item-phone">{item.phone}</p>
<PageLink {...item.website} label={item.website.label} className="section-item-link references-item-link" />
{/* Row 1 */}
<div className="flex items-center justify-between">
<span className="section-item-metadata references-item-phone">{item.phone}</span>
</div>
{/* Row 2 */}
<PageLink
{...item.website}
label={item.website.label}
className="section-item-website references-item-website"
/>
</div>
</div>
);
@@ -10,18 +10,23 @@ type SkillsItemProps = SectionItem<"skills"> & {
export function SkillsItem({ className, ...item }: SkillsItemProps) {
return (
<div className={cn("skills-item", className)}>
{/* Header */}
<div className="section-item-header flex items-center gap-x-1.5">
<PageIcon icon={item.icon} className="section-item-icon skills-item-icon shrink-0" />
<p className="section-item-title skills-item-name">
<strong>{item.name}</strong>
</p>
<PageIcon icon={item.icon} className="section-item-icon skills-item-icon" />
<strong className="section-item-title skills-item-name">{item.name}</strong>
</div>
<div>
<p className="section-item-metadata skills-item-proficiency opacity-80">{item.proficiency}</p>
<small className="section-item-keywords skills-item-keywords">{item.keywords.join(", ")}</small>
{/* Proficiency */}
<div className="flex items-center justify-between">
<span className="section-item-metadata skills-item-proficiency">{item.proficiency}</span>
</div>
{/* Keywords */}
<div className="flex items-center justify-between">
<span className="section-item-keywords skills-item-keywords opacity-80">{item.keywords.join(", ")}</span>
</div>
{/* Level */}
<PageLevel level={item.level} className="section-item-level skills-item-level" />
</div>
);
@@ -10,21 +10,27 @@ type VolunteerItemProps = SectionItem<"volunteer"> & {
export function VolunteerItem({ className, ...item }: VolunteerItemProps) {
return (
<div className={cn("volunteer-item", className)}>
{/* Header */}
<div className="section-item-header volunteer-item-header">
{/* Row 1 */}
<div className="flex items-center justify-between">
<p className="section-item-title volunteer-item-title">
<strong>{item.organization}</strong>
</p>
<p className="section-item-metadata volunteer-item-period text-right">{item.period}</p>
<strong className="section-item-title volunteer-item-title">{item.organization}</strong>
<span className="section-item-metadata volunteer-item-period text-right">{item.period}</span>
</div>
{/* Row 2 */}
<div className="flex items-center justify-between">
<p className="section-item-metadata volunteer-item-location">{item.location}</p>
<span className="section-item-metadata volunteer-item-location">{item.location}</span>
</div>
</div>
{/* Description */}
<div className="section-item-description volunteer-item-description">
<TiptapContent content={item.description} />
</div>
<div className="section-item-link volunteer-item-link">
{/* Website */}
<div className="section-item-website volunteer-item-website">
<PageLink {...item.website} label={item.website.label} />
</div>
</div>
+1 -1
View File
@@ -10,7 +10,7 @@ export function PageIcon({ icon, className }: { icon: string; className?: string
return (
<i
className={cn("ph", `ph-${icon}`, className)}
className={cn("ph shrink-0", `ph-${icon}`, className)}
style={{ fontSize: `${iconContext.size}px`, color: iconContext.color }}
/>
);
+1 -1
View File
@@ -9,5 +9,5 @@ type Props = React.ComponentProps<"div"> & {
export function PageLevel({ level, className, ...props }: Props) {
const { icon, type } = useResumeStore((state) => state.resume.data.metadata.design.level);
return <LevelDisplay icon={icon} type={type} level={level} className={cn("h-4", className)} {...props} />;
return <LevelDisplay icon={icon} type={type} level={level} className={cn("h-6", className)} {...props} />;
}
+1 -1
View File
@@ -10,7 +10,7 @@ export function PageLink({ url, label, className }: Props) {
if (!url) return null;
return (
<a href={url} target="_blank" rel="noopener" className={cn("inline-block text-wrap", className)}>
<a href={url} target="_blank" rel="noopener" className={cn("inline-block text-wrap break-all", className)}>
{label || url}
</a>
);
@@ -19,14 +19,14 @@ export function PageSection<T extends SectionType>({ type, className, children }
return (
<section className={cn(`page-section page-section-${type}`, className)}>
<h6 className="mb-1 text-(--page-primary-color)">{section.title || getSectionTitle(type)}</h6>
<h6 className="mb-2 text-(--page-primary-color)">{section.title || getSectionTitle(type)}</h6>
<div
className="section-content grid gap-x-(--page-gap-x) gap-y-(--page-gap-y)"
style={{ gridTemplateColumns: `repeat(${section.columns}, 1fr)` }}
>
{items.map((item) => (
<div key={item.id} className={cn(`section-item section-item-${type} *:space-y-1`)}>
<div key={item.id} className={cn(`section-item section-item-${type} print:break-inside-avoid`)}>
{children(item)}
</div>
))}
+4 -4
View File
@@ -25,9 +25,8 @@ type ResumeStore = ResumeStoreState & ResumeStoreActions;
const controller = new AbortController();
const signal = controller.signal;
const _syncResume = async (resume: Resume | null) => {
if (!resume) return;
await orpc.resume.update.call({ id: resume.id, data: resume.data }, { signal });
const _syncResume = (resume: Resume) => {
orpc.resume.update.call({ id: resume.id, data: resume.data }, { signal });
};
const syncResume = debounce(_syncResume, 500, { signal });
@@ -49,12 +48,13 @@ export const useResumeStore = create<ResumeStore>()(
updateResumeData: (fn) => {
set((state) => {
if (!state.resume) return state;
if (state.resume.isLocked) {
errorToastId = toast.error(t`This resume is locked and cannot be updated.`, { id: errorToastId });
return state;
}
fn(state.resume.data as WritableDraft<ResumeData>);
fn(state.resume.data);
syncResume(current(state.resume));
});
},
@@ -8,6 +8,26 @@ import { useResumeStore } from "../store/resume";
import type { TemplateProps } from "./types";
const sectionClassName = cn(
// Heading Decoration in Sidebar Layout
"group-data-[layout=sidebar]:[&>h6]:px-4",
"group-data-[layout=sidebar]:[&>h6]:relative",
"group-data-[layout=sidebar]:[&>h6]:inline-flex",
"group-data-[layout=sidebar]:[&>h6]:items-center",
"group-data-[layout=sidebar]:[&>h6]:before:content-['']",
"group-data-[layout=sidebar]:[&>h6]:before:absolute",
"group-data-[layout=sidebar]:[&>h6]:before:left-0",
"group-data-[layout=sidebar]:[&>h6]:before:rounded-full",
"group-data-[layout=sidebar]:[&>h6]:before:size-2",
"group-data-[layout=sidebar]:[&>h6]:before:border",
"group-data-[layout=sidebar]:[&>h6]:before:border-(--page-primary-color)",
"group-data-[layout=sidebar]:[&>h6]:after:content-['']",
"group-data-[layout=sidebar]:[&>h6]:after:absolute",
"group-data-[layout=sidebar]:[&>h6]:after:right-0",
"group-data-[layout=sidebar]:[&>h6]:after:rounded-full",
"group-data-[layout=sidebar]:[&>h6]:after:size-2",
"group-data-[layout=sidebar]:[&>h6]:after:border",
"group-data-[layout=sidebar]:[&>h6]:after:border-(--page-primary-color)",
// Section in Sidebar Layout
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
+15 -3
View File
@@ -18,6 +18,10 @@ const sectionClassName = cn(
// Icon Colors in Sidebar Layout
"group-data-[layout=sidebar]:[&_.section-item_i]:text-(--page-background-color)!",
// Level Display in Sidebar Layout
"group-data-[layout=sidebar]:[&_.section-item-level>div]:bg-(--page-background-color)",
"group-data-[layout=sidebar]:[&_.section-item-level>div]:text-(--page-background-color)",
// Section Item Header in Sidebar Layout
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
@@ -32,10 +36,18 @@ export function ChikoritaTemplate({ pageIndex, pageLayout }: TemplateProps) {
return (
<div className="template-chikorita page-content">
{/* Sidebar Background */}
{!fullWidth && (
<div className="page-sidebar-background absolute inset-y-0 right-0 z-0 w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color)" />
)}
{isFirstPage && <Header />}
<div className="flex">
<main data-layout="main" className="group page-main flex-1 space-y-4 px-(--page-margin-x) py-(--page-margin-y)">
<main
data-layout="main"
className="group page-main z-10 flex-1 space-y-4 px-(--page-margin-x) py-(--page-margin-y)"
>
{main.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
@@ -45,7 +57,7 @@ export function ChikoritaTemplate({ pageIndex, pageLayout }: TemplateProps) {
{!fullWidth && (
<aside
data-layout="sidebar"
className="group page-sidebar w-(--page-sidebar-width) shrink-0 space-y-4 overflow-x-hidden bg-(--page-primary-color) px-(--page-margin-x) pb-(--page-margin-y) text-(--page-background-color)"
className="group page-sidebar z-10 w-(--page-sidebar-width) shrink-0 space-y-4 overflow-x-hidden px-(--page-margin-x) py-(--page-margin-y) text-(--page-background-color)"
>
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
@@ -111,7 +123,7 @@ function Header() {
</div>
</div>
<div className="w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color)" />
<div className="w-(--page-sidebar-width) shrink-0" />
</div>
);
}
+32 -21
View File
@@ -13,6 +13,13 @@ const sectionClassName = cn(
// Section Item Header in Sidebar Layout
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
// Decoration Line in Section Item Header
"group-data-[layout=main]:[&_.section-item-header]:pl-2",
"group-data-[layout=main]:[&_.section-item-header]:py-0.5",
"group-data-[layout=main]:[&_.section-item-header]:-ml-2.5",
"group-data-[layout=main]:[&_.section-item-header]:border-l-2",
"group-data-[layout=main]:[&_.section-item-header]:border-(--page-primary-color)",
);
/**
@@ -27,32 +34,36 @@ export function DitgarTemplate({ pageIndex, pageLayout }: TemplateProps) {
});
return (
<div className="template-ditgar page-content grid min-h-[inherit] grid-cols-3">
<div
data-layout="sidebar"
className={cn("sidebar group flex flex-col", !(isFirstPage || !fullWidth) && "hidden")}
>
{isFirstPage && <Header />}
<div className="template-ditgar page-content">
{/* Sidebar Background */}
{!fullWidth && (
<div className="page-sidebar-background absolute inset-y-0 left-0 z-0 w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color)/20" />
)}
<div className="flex-1 space-y-4 bg-(--page-primary-color)/20 px-(--page-margin-x) py-(--page-margin-y)">
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</div>
</div>
<div className="flex">
<aside data-layout="sidebar" className="sidebar group z-10 flex w-(--page-sidebar-width) shrink-0 flex-col">
{isFirstPage && <Header />}
<div data-layout="main" className={cn("main group", !fullWidth ? "col-span-2" : "col-span-3")}>
{isFirstPage && <SummaryComponent id="summary" />}
<div className="space-y-4 px-(--page-margin-x) py-(--page-margin-y)">
{main
.filter((section) => section !== "summary")
.map((section) => {
<div className="flex-1 space-y-4 px-(--page-margin-x) py-(--page-margin-y)">
{sidebar.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</div>
</div>
</aside>
<main data-layout="main" className={cn("main group z-10", !fullWidth ? "col-span-2" : "col-span-3")}>
{isFirstPage && <SummaryComponent id="summary" />}
<div className="space-y-4 px-(--page-margin-x) py-(--page-margin-y)">
{main
.filter((section) => section !== "summary")
.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</div>
</main>
</div>
</div>
);
+24 -14
View File
@@ -25,23 +25,33 @@ export function GengarTemplate({ pageIndex, pageLayout }: TemplateProps) {
return (
<div className="template-gengar page-content">
{/* Sidebar Background */}
{(!fullWidth || isFirstPage) && (
<div className="page-sidebar-background absolute inset-y-0 left-0 z-0 w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color)/20" />
)}
<div className="flex">
<div data-layout="sidebar" className="group page-sidebar flex w-(--page-sidebar-width) shrink-0 flex-col">
{isFirstPage && <Header />}
{(!fullWidth || isFirstPage) && (
<aside
data-layout="sidebar"
className="group page-sidebar z-10 flex w-(--page-sidebar-width) shrink-0 flex-col"
>
{isFirstPage && <Header />}
{!fullWidth && (
<aside className="shrink-0 space-y-4 overflow-x-hidden bg-(--page-primary-color)/20 px-(--page-margin-x) pt-4 pb-(--page-margin-y)">
{sidebar
.filter((section) => section !== "summary")
.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</aside>
)}
</div>
{!fullWidth && (
<div className="shrink-0 space-y-4 overflow-x-hidden px-(--page-margin-x) pt-4 pb-(--page-margin-y)">
{sidebar
.filter((section) => section !== "summary")
.map((section) => {
const Component = getSectionComponent(section, { sectionClassName });
return <Component key={section} id={section} />;
})}
</div>
)}
</aside>
)}
<main data-layout="main" className="group page-main">
<main data-layout="main" className="group page-main z-10">
{isFirstPage && (
<PageSummary
className={cn(
+7 -2
View File
@@ -25,10 +25,15 @@ export function GlalieTemplate({ pageIndex, pageLayout }: TemplateProps) {
return (
<div className="template-glalie page-content">
{/* Sidebar Background */}
{(!fullWidth || isFirstPage) && (
<div className="page-sidebar-background absolute inset-y-0 left-0 z-0 w-(--page-sidebar-width) shrink-0 bg-(--page-primary-color)/20" />
)}
<div className="flex">
<aside
data-layout="sidebar"
className="group page-sidebar flex w-(--page-sidebar-width) shrink-0 flex-col space-y-4 bg-(--page-primary-color)/20 px-(--page-margin-x) py-(--page-margin-y)"
className="group page-sidebar flex w-(--page-sidebar-width) shrink-0 flex-col space-y-4 px-(--page-margin-x) py-(--page-margin-y)"
>
{isFirstPage && <Header />}
@@ -60,7 +65,7 @@ function Header() {
return (
<div className="page-header relative flex">
<div className="flex w-full shrink-0 flex-col items-center justify-center gap-y-2">
<div className="flex w-full shrink-0 flex-col items-center justify-center gap-y-3">
<PagePicture />
<div className="text-center">
+2 -2
View File
@@ -9,7 +9,7 @@ import type { TemplateProps } from "./types";
const sectionClassName = cn(
// Container
"rounded-(--container-border-radius) border border-(--page-text-color)/10 bg-(--page-background-color) p-4 shadow-md",
"rounded-(--container-border-radius) border border-(--page-text-color)/10 bg-(--page-background-color) p-4",
// Section Heading
"[&>h6]:-mt-(--heading-negative-margin) [&>h6]:max-w-fit [&>h6]:bg-(--page-background-color) [&>h6]:px-4 [&>h6]:pb-0.5",
@@ -63,7 +63,7 @@ function Header() {
<div
className={cn(
"page-header flex items-center gap-x-4",
"rounded-(--picture-border-radius) border border-(--page-text-color)/10 bg-(--page-background-color) p-4 shadow-md",
"rounded-(--picture-border-radius) border border-(--page-text-color)/10 bg-(--page-background-color) p-4",
)}
>
<PagePicture />
+1 -1
View File
@@ -11,7 +11,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-9 w-full min-w-0 items-center rounded-md border border-input shadow-xs outline-none transition-[color,box-shadow] in-data-[slot=combobox-content]:focus-within:border-ring has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-start]]:h-auto has-[>textarea]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:flex-col has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
"group/input-group relative flex h-9 w-full min-w-0 items-center rounded-md border border-input shadow-xs outline-none transition-[color,box-shadow] in-data-[slot=combobox-content]:focus-within:border-ring has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-start]]:h-auto has-[>textarea]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:flex-col has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-[3px] has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
+1 -1
View File
@@ -8,7 +8,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
className="toaster group print:hidden"
icons={{
success: <CheckCircleIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
+10
View File
@@ -65,6 +65,11 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
closeDialog();
},
onError: (error) => {
if (error.message === "RESUME_SLUG_ALREADY_EXISTS") {
toast.error(t`A resume with this slug already exists.`, { id: toastId });
return;
}
toast.error(error.message, { id: toastId });
},
});
@@ -169,6 +174,11 @@ export function UpdateResumeDialog({ data }: DialogProps<"resume.update">) {
closeDialog();
},
onError: (error) => {
if (error.message === "RESUME_SLUG_ALREADY_EXISTS") {
toast.error(t`A resume with this slug already exists.`, { id: toastId });
return;
}
toast.error(error.message, { id: toastId });
},
});
+18
View File
@@ -166,6 +166,12 @@ export const resumeRouter = {
}),
)
.output(z.string().describe("The ID of the created resume."))
.errors({
RESUME_SLUG_ALREADY_EXISTS: {
message: "A resume with this slug already exists.",
status: 400,
},
})
.handler(async ({ context, input }) => {
return await resumeService.create({
name: input.name,
@@ -187,6 +193,12 @@ export const resumeRouter = {
})
.input(z.object({ data: resumeDataSchema }))
.output(z.string().describe("The ID of the imported resume."))
.errors({
RESUME_SLUG_ALREADY_EXISTS: {
message: "A resume with this slug already exists.",
status: 400,
},
})
.handler(async ({ context, input }) => {
const name = generateRandomName();
const slug = slugify(name);
@@ -220,6 +232,12 @@ export const resumeRouter = {
}),
)
.output(z.void())
.errors({
RESUME_SLUG_ALREADY_EXISTS: {
message: "A resume with this slug already exists.",
status: 400,
},
})
.handler(async ({ context, input }) => {
return await resumeService.update({
id: input.id,
+72 -11
View File
@@ -8,12 +8,12 @@ import { getStorageService, uploadFile } from "./storage";
const pageDimensions = {
a4: {
width: "210mm",
height: "297mm",
width: 794,
height: 1123,
},
letter: {
width: "8.5in",
height: "11in",
width: 816,
height: 1056,
},
} as const;
@@ -27,7 +27,7 @@ async function getBrowser(): Promise<Browser> {
const connectOptions: ConnectOptions = {
acceptInsecureCerts: true,
defaultViewport: { width: 794, height: 1123 },
defaultViewport: pageDimensions.a4,
};
if (isWebSocket) connectOptions.browserWSEndpoint = env.PRINTER_ENDPOINT;
@@ -64,16 +64,30 @@ export const printerService = {
await browser.disconnect();
},
/**
* Generates a PDF from a resume and uploads it to storage.
*
* The process:
* 1. Clean up any existing PDF for this resume
* 2. Navigate to the printer route which renders the resume
* 3. Calculate PDF margins (some templates require margins to be applied via PDF)
* 4. Adjust CSS variables so content fits within printable area (accounting for margins)
* 5. Add page break CSS to ensure each visual resume page becomes a PDF page
* 6. Generate the PDF with proper dimensions and margins
* 7. Upload to storage and return the URL
*/
printResumeAsPDF: async (
input: Pick<InferSelectModel<typeof schema.resume>, "userId" | "id" | "data">,
): Promise<string> => {
const { id, userId, data } = input;
// Step 1: Delete any existing PDF for this resume to ensure fresh generation
const storageService = getStorageService();
const pdfPrefix = `uploads/${userId}/pdfs/${id}`;
await storageService.delete(pdfPrefix);
// Step 2: Prepare the URL and authentication for the printer route
// The printer route renders the resume in a format optimized for PDF generation
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
const domain = new URL(baseUrl).hostname;
@@ -81,9 +95,13 @@ export const printerService = {
const locale = data.metadata.page.locale;
const template = data.metadata.template;
// Generate a secure token to authenticate the printer request
const token = generatePrinterToken(id);
const url = `${baseUrl}/printer/${id}?token=${token}`;
// Step 3: Calculate PDF margins
// Some templates require margins to be applied via PDF (they use print:p-0 to remove CSS padding)
// Convert from CSS pixels to PDF points (divide by 0.75 since 1pt = 0.75px at 72dpi)
let marginX = 0;
let marginY = 0;
@@ -92,21 +110,63 @@ export const printerService = {
marginY = Math.round(data.metadata.page.marginY / 0.75);
}
// Step 4: Connect to the browser and navigate to the printer route
const browser = await getBrowser();
// Set locale cookie so the resume renders in the correct language
await browser.setCookie({ name: "locale", value: locale, domain });
const page = await browser.newPage();
// Wait for the page to fully load (network idle + custom loaded attribute)
await page.goto(url, { waitUntil: "networkidle0" });
await page.waitForFunction(() => document.body.getAttribute("data-wf-loaded") === "true", { timeout: 5_000 });
// Step 5: Adjust the DOM for proper PDF pagination
// This runs in the browser context to modify CSS before PDF generation
await page.evaluate((marginY: number) => {
const root = document.documentElement;
const container = document.querySelector(".resume-preview-container") as HTMLElement | null;
// The --page-height CSS variable controls the height of each resume page.
// We need to reduce it by the PDF margins so content fits within the printable area.
// Without this, content would overflow and create empty pages.
const containerHeight = container ? getComputedStyle(container).getPropertyValue("--page-height").trim() : null;
const rootHeight = getComputedStyle(root).getPropertyValue("--page-height").trim();
const currentHeight = containerHeight || rootHeight;
const heightValue = Number.parseFloat(currentHeight);
if (!Number.isNaN(heightValue)) {
// Subtract top + bottom margins from page height
const newHeight = `${heightValue - marginY * 2}px`;
if (container) container.style.setProperty("--page-height", newHeight);
root.style.setProperty("--page-height", newHeight);
}
// Add page break CSS to each resume page element (identified by data-page-index attribute)
// This ensures each visual resume page starts a new PDF page
const pageElements = document.querySelectorAll("[data-page-index]");
for (const el of pageElements) {
const element = el as HTMLElement;
const index = Number.parseInt(element.getAttribute("data-page-index") ?? "0", 10);
// Force a page break before each page except the first
if (index > 0) element.style.breakBefore = "page";
// Allow content within a page to break naturally if it overflows
// (e.g., if a single page has more content than fits on one PDF page)
element.style.breakInside = "auto";
}
}, marginY);
// Step 6: Generate the PDF with the specified dimensions and margins
const pdfBuffer = await page.pdf({
width: pageDimensions[format].width,
height: pageDimensions[format].height,
tagged: true,
waitForFonts: true,
printBackground: true,
width: `${pageDimensions[format].width}px`,
height: `${pageDimensions[format].height}px`,
tagged: true, // Adds accessibility tags to the PDF
waitForFonts: true, // Ensures all fonts are loaded before rendering
printBackground: true, // Includes background colors and images
margin: {
top: marginY,
right: marginX,
@@ -117,6 +177,7 @@ export const printerService = {
await page.close();
// Step 7: Upload the generated PDF to storage
const result = await uploadFile({
userId,
resumeId: id,
+38 -15
View File
@@ -1,5 +1,6 @@
import { ORPCError } from "@orpc/client";
import { and, arrayContains, asc, desc, eq, sql } from "drizzle-orm";
import { get } from "es-toolkit/compat";
import { match } from "ts-pattern";
import { schema } from "@/integrations/drizzle";
import { db } from "@/integrations/drizzle/client";
@@ -238,16 +239,26 @@ export const resumeService = {
input.data = input.data ?? defaultResumeData;
input.data.metadata.page.locale = input.locale;
await db.insert(schema.resume).values({
id,
name: input.name,
slug: input.slug,
tags: input.tags,
userId: input.userId,
data: input.data,
});
try {
await db.insert(schema.resume).values({
id,
name: input.name,
slug: input.slug,
tags: input.tags,
userId: input.userId,
data: input.data,
});
return id;
return id;
} catch (error) {
const constraint = get(error, "cause.constraint") as string | undefined;
if (constraint === "resume_slug_user_id_unique") {
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
}
throw error;
}
},
update: async (input: {
@@ -274,12 +285,24 @@ export const resumeService = {
isPublic: input.isPublic,
};
await db
.update(schema.resume)
.set(updateData)
.where(
and(eq(schema.resume.id, input.id), eq(schema.resume.isLocked, false), eq(schema.resume.userId, input.userId)),
);
try {
await db
.update(schema.resume)
.set(updateData)
.where(
and(
eq(schema.resume.id, input.id),
eq(schema.resume.isLocked, false),
eq(schema.resume.userId, input.userId),
),
);
} catch (error) {
if (get(error, "cause.constraint") === "resume_slug_user_id_unique") {
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
}
throw error;
}
},
setLocked: async (input: { id: string; userId: string; isLocked: boolean }): Promise<void> => {
+2 -1
View File
@@ -27,7 +27,8 @@ export const getQueryClient = () => {
},
},
mutationCache: new MutationCache({
onSettled: () => {
onSettled: (_1, _2, _3, _4, _5, context) => {
if (context?.meta?.noInvalidate) return;
queryClient.invalidateQueries();
},
}),
@@ -24,9 +24,10 @@ import { downloadFromUrl, downloadWithAnchor, generateFilename } from "@/utils/f
import { cn } from "@/utils/style";
export function BuilderDock() {
const [_, copyToClipboard] = useCopyToClipboard();
const { data: session } = authClient.useSession();
const params = useParams({ from: "/builder/$resumeId" });
const [_, copyToClipboard] = useCopyToClipboard();
const { zoomIn, zoomOut, centerView } = useControls();
const { data: resume } = useQuery(orpc.resume.getById.queryOptions({ input: { id: params.resumeId } }));
@@ -32,8 +32,8 @@ function PictureSectionForm() {
const picture = useResumeStore((state) => state.resume.data.picture);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const { mutate: uploadFile } = useMutation(orpc.storage.uploadFile.mutationOptions());
const { mutate: deleteFile } = useMutation(orpc.storage.deleteFile.mutationOptions());
const { mutate: uploadFile } = useMutation(orpc.storage.uploadFile.mutationOptions({ meta: { noInvalidate: true } }));
const { mutate: deleteFile } = useMutation(orpc.storage.deleteFile.mutationOptions({ meta: { noInvalidate: true } }));
const form = useForm({
resolver: zodResolver(pictureSchema),
@@ -53,22 +53,16 @@ function PictureSectionForm() {
};
const onDeletePicture = () => {
if (!picture.url) return;
const appOrigin = window.location.origin;
const pictureOrigin = new URL(picture.url).origin;
const filename = picture.url.split("/").pop();
if (!filename) return;
const toastId = toast.loading(t`Deleting picture...`);
deleteFile(
{ filename },
{
onSuccess: () => {
toast.dismiss(toastId);
},
onError: (error) => {
toast.error(error.message, { id: toastId });
},
},
);
// If the picture is from the same origin, attempt to delete it
if (pictureOrigin === appOrigin) deleteFile({ filename });
form.setValue("url", "", { shouldDirty: true });
form.handleSubmit(onSubmit)();
@@ -85,6 +79,7 @@ function PictureSectionForm() {
form.setValue("url", url, { shouldDirty: true });
form.handleSubmit(onSubmit)();
toast.dismiss(toastId);
if (fileInputRef.current) fileInputRef.current.value = "";
},
onError: (error) => {
toast.error(error.message, { id: toastId });
@@ -33,7 +33,7 @@ const CSS_SELECTORS = [
".section-item-name",
".section-item-description",
".section-item-metadata",
".section-item-link",
".section-item-website",
".section-item-icon",
".section-item-level",
".section-item-keywords",
@@ -48,7 +48,7 @@ const CSS_SELECTORS = [
".profiles-item-title",
".profiles-item-name",
".profiles-item-description",
".profiles-item-link",
".profiles-item-website",
".profiles-item-icon",
".profiles-item-network",
".experience-item",
@@ -59,20 +59,20 @@ const CSS_SELECTORS = [
".experience-item-position",
".experience-item-location",
".experience-item-period",
".experience-item-link",
".experience-item-website",
".experience-item-description",
".education-item",
".education-item-header",
".education-item-title",
".education-item-name",
".education-item-description",
".education-item-link",
".education-item-website",
".projects-item",
".projects-item-header",
".projects-item-title",
".projects-item-name",
".projects-item-description",
".projects-item-link",
".projects-item-website",
".skills-item",
".skills-item-header",
".skills-item-title",
@@ -99,28 +99,28 @@ const CSS_SELECTORS = [
".awards-item-title",
".awards-item-name",
".awards-item-description",
".awards-item-link",
".awards-item-website",
".awards-item-awarder",
".certifications-item",
".certifications-item-header",
".certifications-item-title",
".certifications-item-name",
".certifications-item-description",
".certifications-item-link",
".certifications-item-website",
".certifications-item-issuer",
".publications-item",
".publications-item-header",
".publications-item-title",
".publications-item-name",
".publications-item-description",
".publications-item-link",
".publications-item-website",
".publications-item-publisher",
".volunteer-item",
".volunteer-item-header",
".volunteer-item-title",
".volunteer-item-name",
".volunteer-item-description",
".volunteer-item-link",
".volunteer-item-website",
".volunteer-item-location",
".references-item",
".references-item-header",
@@ -166,10 +166,13 @@ export function LayoutPages() {
updateResumeData((draft) => {
const pageToDelete = draft.metadata.layout.pages[pageIndex];
// Move all sections from deleted page to first page
const firstPage = draft.metadata.layout.pages[0];
firstPage.main.push(...pageToDelete.main);
firstPage.sidebar.push(...pageToDelete.sidebar);
// Find the first available page that isn't being deleted
const targetPageIndex = pageIndex === 0 ? 1 : 0;
const targetPage = draft.metadata.layout.pages[targetPageIndex];
// Move all sections from deleted page to target page
targetPage.main.push(...pageToDelete.main);
targetPage.sidebar.push(...pageToDelete.sidebar);
draft.metadata.layout.pages.splice(pageIndex, 1);
});
+15
View File
@@ -1,5 +1,9 @@
import { t } from "@lingui/core/macro";
import { FloppyDiskIcon } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
import { useHotkeys } from "react-hotkeys-hook";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import { toast } from "sonner";
import { ResumePreview } from "@/components/resume/preview";
import { BuilderDock } from "./-components/dock";
@@ -8,6 +12,17 @@ export const Route = createFileRoute("/builder/$resumeId/")({
});
function RouteComponent() {
useHotkeys(
["ctrl+s", "meta+s"],
() => {
toast.info(t`Your changes are saved automatically.`, {
id: "auto-save",
icon: <FloppyDiskIcon />,
});
},
{ preventDefault: true, enableOnFormTags: true },
);
return (
<div className="fixed inset-0">
<TransformWrapper centerOnInit limitToBounds={false} minScale={0.3} initialScale={0.6} maxScale={6}>
+1 -1
View File
@@ -621,7 +621,7 @@ export const defaultResumeData: ResumeData = {
export const sampleResumeData: ResumeData = {
picture: {
hidden: false,
url: `https://rxresu.me/photos/sample-picture.jpg`,
url: `https://i.imgur.com/o4Jpt1p.jpeg`,
size: 100,
rotation: 0,
aspectRatio: 1,
-6
View File
@@ -163,9 +163,3 @@
scroll-behavior: auto !important;
}
}
.resume-preview-container {
.page-section .section-item {
break-inside: avoid;
}
}