mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-26 08:12:20 +10:00
v5.1.0 (#2970)
* chore(release): v5.1.0 * feat: implement resume thumbnails * fix: remove unused mcp tools * docs: fix formatting of docs
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { colorDesignSchema, levelDesignSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { LevelTypeCombobox } from "@/components/level/combobox";
|
||||
import { LevelDisplay } from "@/components/level/display";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function DesignSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="design" className="space-y-6">
|
||||
<ColorSectionForm />
|
||||
<Separator />
|
||||
<LevelSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
type ColorValues = z.infer<typeof colorDesignSchema>;
|
||||
|
||||
function ColorSectionForm() {
|
||||
const resume = useCurrentResume();
|
||||
const colors = resume.data.metadata.design.colors;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: ColorValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.design.colors = data;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: colors,
|
||||
validators: { onChange: colorDesignSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="primary">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="flex flex-wrap gap-2.5 p-1"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
{quickColorOptions.map((color) => (
|
||||
<QuickColorCircle
|
||||
key={color}
|
||||
color={color}
|
||||
active={color === field.state.value}
|
||||
onSelect={(color) => {
|
||||
field.handleChange(color as string);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="primary">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Primary Color</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorPicker
|
||||
value={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="text">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Text Color</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="background">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Background Color</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const quickColorOptions = [
|
||||
"rgba(231, 0, 11, 1)", // red-600
|
||||
"rgba(245, 73, 0, 1)", // orange-600
|
||||
"rgba(225, 113, 0, 1)", // amber-600
|
||||
"rgba(208, 135, 0, 1)", // yellow-600
|
||||
"rgba(94, 165, 0, 1)", // lime-600
|
||||
"rgba(0, 166, 62, 1)", // green-600
|
||||
"rgba(0, 153, 102, 1)", // emerald-600
|
||||
"rgba(0, 150, 137, 1)", // teal-600
|
||||
"rgba(0, 146, 184, 1)", // cyan-600
|
||||
"rgba(0, 132, 209, 1)", // sky-600
|
||||
"rgba(21, 93, 252, 1)", // blue-600
|
||||
"rgba(79, 57, 246, 1)", // indigo-600
|
||||
"rgba(127, 34, 254, 1)", // violet-600
|
||||
"rgba(152, 16, 250, 1)", // purple-600
|
||||
"rgba(200, 0, 222, 1)", // fuchsia-600
|
||||
"rgba(230, 0, 118, 1)", // pink-600
|
||||
"rgba(236, 0, 63, 1)", // rose-600
|
||||
"rgba(69, 85, 108, 1)", // slate-600
|
||||
"rgba(74, 85, 101, 1)", // gray-600
|
||||
"rgba(82, 82, 92, 1)", // zinc-600
|
||||
"rgba(82, 82, 82, 1)", // neutral-600
|
||||
"rgba(87, 83, 77, 1)", // stone-600
|
||||
];
|
||||
|
||||
type QuickColorCircleProps = React.ComponentProps<"button"> & {
|
||||
color: string;
|
||||
active: boolean;
|
||||
onSelect: (color: string) => void;
|
||||
};
|
||||
|
||||
function QuickColorCircle({ color, active, onSelect, className, ...props }: QuickColorCircleProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(color)}
|
||||
className={cn(
|
||||
"relative flex size-8 items-center justify-center rounded-md bg-transparent",
|
||||
"scale-100 transition-transform hover:scale-120 hover:bg-secondary/80 active:scale-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div style={{ backgroundColor: color }} className="size-6 shrink-0 rounded-md" />
|
||||
|
||||
<AnimatePresence>
|
||||
{active && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0 }}
|
||||
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||
className="absolute inset-0 flex size-8 items-center justify-center will-change-transform"
|
||||
>
|
||||
<div className="size-4 rounded-md bg-foreground" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type LevelValues = z.infer<typeof levelDesignSchema>;
|
||||
type LevelType = LevelValues["type"];
|
||||
|
||||
function LevelSectionForm() {
|
||||
const resume = useCurrentResume();
|
||||
const colors = resume.data.metadata.design.colors;
|
||||
const levelDesign = resume.data.metadata.design.level;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: LevelValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.design.level = data;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: levelDesign,
|
||||
validators: { onChange: levelDesignSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
const previewType = useStore(form.store, (s) => s.values.type);
|
||||
const previewIcon = useStore(form.store, (s) => s.values.icon);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<h4 className="font-semibold text-lg leading-none tracking-tight">
|
||||
<Trans>Level</Trans>
|
||||
</h4>
|
||||
|
||||
<div
|
||||
style={{ "--page-primary-color": colors.primary, backgroundColor: colors.background } as React.CSSProperties}
|
||||
className="flex items-center justify-center rounded-md p-6"
|
||||
>
|
||||
<LevelDisplay level={3} type={previewType} icon={previewIcon} className="w-full max-w-[220px] justify-center" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<form.Field name="icon">
|
||||
{(field) => (
|
||||
<FormItem className="shrink-0" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Icon</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<IconPicker
|
||||
size="icon"
|
||||
value={field.state.value}
|
||||
onChange={(value) => {
|
||||
field.handleChange(value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="type">
|
||||
{(field) => (
|
||||
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Type</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<LevelTypeCombobox
|
||||
value={field.state.value}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
field.handleChange(value as LevelType);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CircleNotchIcon, FileDocIcon, FileJsIcon, FilePdfIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { buildDocx } from "@reactive-resume/utils/resume/docx";
|
||||
import { useResume } from "@/components/resume/use-resume";
|
||||
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function ExportSectionBuilder() {
|
||||
const resumeData = useResume();
|
||||
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
const resume = resumeData;
|
||||
|
||||
const onDownloadJSON = useCallback(() => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "json");
|
||||
const jsonString = JSON.stringify(resume.data, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadDOCX = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "docx");
|
||||
|
||||
try {
|
||||
const blob = await buildDocx(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the DOCX, please try again.`);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "pdf");
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
|
||||
|
||||
setIsPrinting(true);
|
||||
try {
|
||||
const blob = await createResumePdfBlob(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the PDF, please try again.`);
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
if (!resume) return null;
|
||||
|
||||
return (
|
||||
<SectionBase type="export" className="space-y-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownloadJSON}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
|
||||
>
|
||||
<FileJsIcon className="size-6 shrink-0" />
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">JSON</h6>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
Download a copy of your resume in JSON format. Use this file for backup or to import your resume into
|
||||
other applications, including AI assistants.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDownloadDOCX}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
|
||||
>
|
||||
<FileDocIcon className="size-6 shrink-0" />
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">DOCX</h6>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
Download a copy of your resume as a Word document. Use this file to further customize your resume in
|
||||
Microsoft Word or Google Docs.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isPrinting}
|
||||
onClick={onDownloadPDF}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
|
||||
>
|
||||
{isPrinting ? (
|
||||
<CircleNotchIcon className="size-6 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<FilePdfIcon className="size-6 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-y-1">
|
||||
<h6 className="font-medium">PDF</h6>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>
|
||||
Download a copy of your resume in PDF format. Use this file for printing or to easily share your resume
|
||||
with recruiters.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { HandHeartIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function InformationSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="information" className="space-y-4">
|
||||
<div className="space-y-2 rounded-md border bg-sky-600 p-5 text-white dark:bg-sky-700">
|
||||
<h4 className="font-medium tracking-tight">
|
||||
<Trans>Support the app by doing what you can!</Trans>
|
||||
</h4>
|
||||
|
||||
<div className="space-y-2 text-xs leading-normal">
|
||||
<Trans>
|
||||
<p>
|
||||
Thank you for using Reactive Resume! This app is a labor of love, created mostly in my spare time, with
|
||||
wonderful support from open-source contributors around the world.
|
||||
</p>
|
||||
<p>
|
||||
If Reactive Resume has been helpful to you, and you'd like to help keep it free and open for everyone,
|
||||
please consider making a donation. Every little bit is appreciated!
|
||||
</p>
|
||||
</Trans>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
nativeButton={false}
|
||||
className="mt-2 whitespace-normal px-4! text-xs"
|
||||
render={
|
||||
<a href="http://opencollective.com/reactive-resume" target="_blank" rel="noopener">
|
||||
<HandHeartIcon />
|
||||
<span className="truncate">
|
||||
<Trans>Donate to Reactive Resume</Trans>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://docs.rxresu.me" target="_blank" rel="noopener">
|
||||
<Trans>Documentation</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://github.com/amruthpillai/reactive-resume" target="_blank" rel="noopener">
|
||||
<Trans>Source Code</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://github.com/amruthpillai/reactive-resume/issues" target="_blank" rel="noopener">
|
||||
<Trans>Report a Bug</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://crowdin.com/project/reactive-resume" target="_blank" rel="noopener">
|
||||
<Trans>Translations</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
className="text-xs"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<a href="https://opencollective.com/reactive-resume" target="_blank" rel="noopener">
|
||||
<Trans>Sponsors</Trans>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { metadataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Slider } from "@reactive-resume/ui/components/slider";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../../shared/section-base";
|
||||
import { LayoutPages } from "./pages";
|
||||
|
||||
export function LayoutSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="layout" className="space-y-4">
|
||||
<LayoutPages />
|
||||
<LayoutSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = metadataSchema.shape.layout.omit({ pages: true });
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function LayoutSectionForm() {
|
||||
const resume = useCurrentResume();
|
||||
const layout = resume.data.metadata.layout;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.layout.sidebarWidth = data.sidebarWidth;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: { sidebarWidth: layout.sidebarWidth },
|
||||
validators: { onChange: formSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="sidebarWidth">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Sidebar Width</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-4">
|
||||
<FormControl
|
||||
render={
|
||||
<Slider
|
||||
min={10}
|
||||
max={50}
|
||||
step={0.01}
|
||||
value={[field.state.value]}
|
||||
onValueChange={(value) => {
|
||||
field.handleChange(Array.isArray(value) ? value[0] : value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroup className="w-auto shrink-0">
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={10}
|
||||
max={50}
|
||||
step={0.1}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>%</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import type { SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import type { CSSProperties, HTMLAttributes } from "react";
|
||||
import {
|
||||
closestCorners,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DotsSixVerticalIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import { forwardRef, useCallback, useId, useState } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { getSectionTitle } from "@/libs/resume/section";
|
||||
|
||||
type ColumnId = "main" | "sidebar";
|
||||
|
||||
const getColumnLabel = (columnId: ColumnId): string => {
|
||||
return match(columnId)
|
||||
.with("main", () =>
|
||||
t({
|
||||
comment: "Layout editor column label for the primary content area",
|
||||
message: "Main",
|
||||
}),
|
||||
)
|
||||
.with("sidebar", () =>
|
||||
t({
|
||||
comment: "Layout editor column label for the secondary sidebar area",
|
||||
message: "Sidebar",
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
type PageLocation = {
|
||||
pageIndex: number;
|
||||
columnId: ColumnId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the page index and column that contains the given section id.
|
||||
* Format: "page-{index}-{columnId}" or "{sectionId}"
|
||||
*/
|
||||
const parseDroppableId = (id: string): PageLocation | null => {
|
||||
if (id.startsWith("page-")) {
|
||||
const parts = id.split("-");
|
||||
if (parts.length >= 3) {
|
||||
const pageIndex = Number.parseInt(parts[1] ?? "0", 10);
|
||||
const columnId = parts[2] as ColumnId;
|
||||
if (!Number.isNaN(pageIndex) && (columnId === "main" || columnId === "sidebar")) {
|
||||
return { pageIndex, columnId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const createDroppableId = (pageIndex: number, columnId: ColumnId): string => {
|
||||
return `page-${pageIndex}-${columnId}`;
|
||||
};
|
||||
|
||||
export function LayoutPages() {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const resume = useCurrentResume();
|
||||
const template = resume.data.metadata.template;
|
||||
const templateSidebarPosition = templates[template].sidebarPosition;
|
||||
|
||||
const layout = resume.data.metadata.layout;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
/**
|
||||
* Returns the page index and column that contains the given section id.
|
||||
*/
|
||||
const findContainer = useCallback(
|
||||
(id: string): PageLocation | null => {
|
||||
// Check if it's a droppable ID
|
||||
const location = parseDroppableId(id);
|
||||
if (location) return location;
|
||||
|
||||
// Search through all pages
|
||||
for (let pageIndex = 0; pageIndex < layout.pages.length; pageIndex++) {
|
||||
const page = layout.pages[pageIndex];
|
||||
if (page.main.includes(id)) return { pageIndex, columnId: "main" };
|
||||
if (page.sidebar.includes(id)) return { pageIndex, columnId: "sidebar" };
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[layout.pages],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => setActiveId(String(event.active.id)), []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
({ active, over }: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
if (!over) return;
|
||||
|
||||
const activeIdStr = String(active.id);
|
||||
const overIdStr = String(over.id);
|
||||
|
||||
if (activeIdStr === overIdStr) return;
|
||||
|
||||
const activeLocation = findContainer(activeIdStr);
|
||||
const overLocation = parseDroppableId(overIdStr) ?? findContainer(overIdStr);
|
||||
|
||||
if (!activeLocation || !overLocation) return;
|
||||
|
||||
// Same location, reorder within column
|
||||
if (activeLocation.pageIndex === overLocation.pageIndex && activeLocation.columnId === overLocation.columnId) {
|
||||
const page = layout.pages[activeLocation.pageIndex];
|
||||
const items = page[activeLocation.columnId];
|
||||
const oldIdx = items.indexOf(activeIdStr);
|
||||
let newIdx = items.indexOf(overIdStr);
|
||||
if (oldIdx === -1 || oldIdx === newIdx) return;
|
||||
if (newIdx === -1) newIdx = items.length - 1;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
const colOrder = draft.metadata.layout.pages[activeLocation.pageIndex][activeLocation.columnId];
|
||||
draft.metadata.layout.pages[activeLocation.pageIndex][activeLocation.columnId] = arrayMove(
|
||||
colOrder,
|
||||
oldIdx,
|
||||
newIdx,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Different location, move between columns/pages
|
||||
const fromPage = layout.pages[activeLocation.pageIndex];
|
||||
const toPage = layout.pages[overLocation.pageIndex];
|
||||
const fromItems = fromPage[activeLocation.columnId];
|
||||
const toItems = toPage[overLocation.columnId];
|
||||
const fromIdx = fromItems.indexOf(activeIdStr);
|
||||
if (fromIdx === -1) return;
|
||||
|
||||
let toIdx = toItems.indexOf(overIdStr);
|
||||
if (toIdx === -1) toIdx = toItems.length;
|
||||
|
||||
updateResumeData((draft) => {
|
||||
const fromPageDraft = draft.metadata.layout.pages[activeLocation.pageIndex];
|
||||
const toPageDraft = draft.metadata.layout.pages[overLocation.pageIndex];
|
||||
const from = fromPageDraft[activeLocation.columnId];
|
||||
const to = toPageDraft[overLocation.columnId];
|
||||
|
||||
from.splice(fromIdx, 1);
|
||||
to.splice(Math.min(toIdx, to.length), 0, activeIdStr);
|
||||
});
|
||||
},
|
||||
[findContainer, layout.pages, updateResumeData],
|
||||
);
|
||||
|
||||
const handleAddPage = useCallback(() => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.layout.pages.push({
|
||||
fullWidth: false,
|
||||
main: [],
|
||||
sidebar: [],
|
||||
});
|
||||
});
|
||||
}, [updateResumeData]);
|
||||
|
||||
const handleDeletePage = useCallback(
|
||||
(pageIndex: number) => {
|
||||
if (layout.pages.length <= 1) return; // Don't allow deleting the last page
|
||||
|
||||
updateResumeData((draft) => {
|
||||
const pageToDelete = draft.metadata.layout.pages[pageIndex];
|
||||
// Find the first available page that isn't being deleted
|
||||
const targetPageIndex = pageIndex === 0 ? 1 : 0;
|
||||
const targetPage = draft.metadata.layout.pages[targetPageIndex];
|
||||
|
||||
// Move all sections from deleted page to target page
|
||||
targetPage.main.push(...pageToDelete.main);
|
||||
targetPage.sidebar.push(...pageToDelete.sidebar);
|
||||
|
||||
draft.metadata.layout.pages.splice(pageIndex, 1);
|
||||
});
|
||||
},
|
||||
[layout.pages.length, updateResumeData],
|
||||
);
|
||||
|
||||
const handleToggleFullWidth = useCallback(
|
||||
(pageIndex: number, fullWidth: boolean) => {
|
||||
updateResumeData((draft) => {
|
||||
const page = draft.metadata.layout.pages[pageIndex];
|
||||
page.fullWidth = fullWidth;
|
||||
|
||||
if (fullWidth) {
|
||||
// Move all sidebar sections to main
|
||||
page.main.push(...page.sidebar);
|
||||
page.sidebar = [];
|
||||
}
|
||||
});
|
||||
},
|
||||
[updateResumeData],
|
||||
);
|
||||
|
||||
// Don't render until pages are initialized
|
||||
if (layout.pages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id="builder-layout"
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => setActiveId(null)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{layout.pages.map((page, pageIndex) => (
|
||||
<PageContainer
|
||||
key={`page-${pageIndex}`}
|
||||
pageIndex={pageIndex}
|
||||
page={page}
|
||||
canDelete={layout.pages.length > 1}
|
||||
sidebarPosition={templateSidebarPosition}
|
||||
onDelete={handleDeletePage}
|
||||
onToggleFullWidth={handleToggleFullWidth}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button variant="outline" className="self-end" onClick={handleAddPage}>
|
||||
<PlusIcon />
|
||||
<Trans>Add Page</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DragOverlay>{activeId ? <LayoutItemContent id={activeId} isDragging isOverlay /> : null}</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
type PageContainerProps = {
|
||||
pageIndex: number;
|
||||
page: { fullWidth: boolean; main: string[]; sidebar: string[] };
|
||||
canDelete: boolean;
|
||||
sidebarPosition: "left" | "right" | "none";
|
||||
onDelete: (pageIndex: number) => void;
|
||||
onToggleFullWidth: (pageIndex: number, fullWidth: boolean) => void;
|
||||
};
|
||||
|
||||
function PageContainer({
|
||||
pageIndex,
|
||||
page,
|
||||
canDelete,
|
||||
sidebarPosition,
|
||||
onDelete,
|
||||
onToggleFullWidth,
|
||||
}: PageContainerProps) {
|
||||
const isFullWidth = page.fullWidth;
|
||||
const fullWidthSwitchId = useId();
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-dashed bg-background/40">
|
||||
<div className="flex items-center justify-between bg-secondary/50 px-4 py-3">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<span className="font-medium text-xs">
|
||||
<Trans comment="Layout editor page label with 1-based page number">Page {pageIndex + 1}</Trans>
|
||||
</span>
|
||||
|
||||
<label htmlFor={fullWidthSwitchId} className="flex cursor-pointer items-center gap-2">
|
||||
<Switch
|
||||
id={fullWidthSwitchId}
|
||||
checked={page.fullWidth}
|
||||
onCheckedChange={(checked) => onToggleFullWidth(pageIndex, checked)}
|
||||
/>
|
||||
|
||||
<span className="font-medium text-muted-foreground text-xs">
|
||||
<Trans comment="Layout editor toggle label that makes a page single-column">Full Width</Trans>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{canDelete && (
|
||||
<Button variant="ghost" onClick={() => onDelete(pageIndex)} className="h-5 w-auto gap-x-2.5 px-0!">
|
||||
<TrashIcon />
|
||||
<Trans>Delete Page</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full @md:grid-cols-2 gap-x-4 gap-y-2 p-4 pt-0 font-medium",
|
||||
sidebarPosition === "none" && "@md:grid-cols-1",
|
||||
)}
|
||||
>
|
||||
<LayoutColumn
|
||||
pageIndex={pageIndex}
|
||||
columnId="main"
|
||||
items={page.main}
|
||||
disabled={false}
|
||||
className={cn(sidebarPosition === "left" ? "order-2" : "order-1")}
|
||||
/>
|
||||
|
||||
{!isFullWidth && (
|
||||
<LayoutColumn
|
||||
pageIndex={pageIndex}
|
||||
columnId="sidebar"
|
||||
items={page.sidebar}
|
||||
hideLabel={sidebarPosition === "none"}
|
||||
className={cn(sidebarPosition === "left" ? "order-1" : "order-2")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type LayoutColumnProps = {
|
||||
pageIndex: number;
|
||||
columnId: ColumnId;
|
||||
items: string[];
|
||||
hideLabel?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function LayoutColumn({
|
||||
pageIndex,
|
||||
columnId,
|
||||
items,
|
||||
hideLabel = false,
|
||||
disabled = false,
|
||||
className,
|
||||
}: LayoutColumnProps) {
|
||||
const droppableId = createDroppableId(pageIndex, columnId);
|
||||
const { setNodeRef, isOver } = useDroppable({ id: droppableId, disabled });
|
||||
|
||||
return (
|
||||
<SortableContext id={droppableId} items={items} strategy={verticalListSortingStrategy}>
|
||||
<div className={cn("space-y-1.5", disabled && "opacity-50", className)}>
|
||||
{!hideLabel && <div className="@md:row-start-1 ps-4 font-medium text-xs">{getColumnLabel(columnId)}</div>}
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"space-y-2.5 rounded-md border border-dashed p-3 pb-8 transition-colors",
|
||||
isOver && !disabled ? "border-primary/60 bg-primary/5" : "bg-background/40",
|
||||
)}
|
||||
>
|
||||
{items.map((id) => (
|
||||
<SortableLayoutItem key={id} id={id} pageIndex={pageIndex} columnId={columnId} />
|
||||
))}
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="rounded-md border border-dashed p-4 font-medium text-muted-foreground text-xs">
|
||||
<Trans>Drag and drop sections here to move them between columns</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
);
|
||||
}
|
||||
|
||||
type SortableLayoutItemProps = {
|
||||
id: string;
|
||||
pageIndex: number;
|
||||
columnId: ColumnId;
|
||||
};
|
||||
|
||||
function SortableLayoutItem({ id }: SortableLayoutItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
|
||||
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
return (
|
||||
<LayoutItemContent ref={setNodeRef} id={id} style={style} isDragging={isDragging} {...attributes} {...listeners} />
|
||||
);
|
||||
}
|
||||
|
||||
type LayoutItemContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
id: string;
|
||||
isDragging?: boolean;
|
||||
isOverlay?: boolean;
|
||||
};
|
||||
|
||||
const LayoutItemContent = forwardRef<HTMLDivElement, LayoutItemContentProps>(
|
||||
({ id, isDragging, isOverlay, className, style, ...rest }, ref) => {
|
||||
const resume = useCurrentResume();
|
||||
const title = (() => {
|
||||
if (!resume) return id;
|
||||
if (id === "summary") return resume.data.summary.title || getSectionTitle("summary");
|
||||
if (id in resume.data.sections)
|
||||
return resume.data.sections[id as SectionType].title || getSectionTitle(id as SectionType);
|
||||
const customSection = resume.data.customSections.find((section) => section.id === id);
|
||||
if (customSection) return customSection.title;
|
||||
return id;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
data-overlay={isOverlay ? "true" : undefined}
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
className={cn(
|
||||
"group/item flex cursor-grab touch-none select-none items-center gap-x-2 rounded-md border border-border bg-background px-2 py-1.5 font-medium text-sm transition-all duration-200 ease-out",
|
||||
"hover:bg-secondary/40 active:cursor-grabbing active:border-primary/60 active:bg-secondary/40",
|
||||
"data-[overlay=true]:cursor-grabbing data-[overlay=true]:border-primary/60 data-[overlay=true]:bg-background",
|
||||
"data-[dragging=true]:cursor-grabbing data-[dragging=true]:border-primary/60 data-[dragging=true]:bg-background",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<DotsSixVerticalIcon className="opacity-40 transition-opacity group-hover/item:opacity-100" />
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
LayoutItemContent.displayName = "LayoutItemContent";
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function NotesSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="notes">
|
||||
<NotesSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesSectionForm() {
|
||||
const resume = useCurrentResume();
|
||||
const notes = resume.data.metadata.notes;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const onChange = (value: string) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.notes = value;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
<Trans>
|
||||
This section is reserved for your personal notes specific to this resume. The content here remains private and
|
||||
is not shared with anyone else.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<RichInput value={notes} onChange={onChange} />
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
<Trans>
|
||||
For example, information regarding which companies you sent this resume to or the links to the job
|
||||
descriptions can be noted down here.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import type z from "zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { pageSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { getLocaleOptions } from "@/components/locale/combobox";
|
||||
import { useResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function PageSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="page">
|
||||
<PageSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = pageSchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function PageSectionForm() {
|
||||
const resume = useResume();
|
||||
const page = resume?.data.metadata.page;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.page = data;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: page,
|
||||
validators: { onChange: formSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = <K extends keyof FormValues>(name: K, value: FormValues[K]) => {
|
||||
persist({ ...form.state.values, [name]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid @md:grid-cols-2 grid-cols-1 gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.Field name="locale">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Language</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
options={getLocaleOptions()}
|
||||
value={field.state.value}
|
||||
onValueChange={(locale) => {
|
||||
const value = (locale ?? "") as string;
|
||||
field.handleChange(value);
|
||||
handleAutoSave("locale", value);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="format">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans context="Page Format (A4, Letter)">Format</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: "a4", label: t`A4` },
|
||||
{ value: "letter", label: t`Letter` },
|
||||
]}
|
||||
value={field.state.value}
|
||||
onValueChange={(value) => {
|
||||
const format = value as FormValues["format"];
|
||||
field.handleChange(format);
|
||||
handleAutoSave("format", format);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="marginX">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Margin (Horizontal)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const marginX = value === "" ? ("" as unknown as number) : Number(value);
|
||||
field.handleChange(marginX);
|
||||
handleAutoSave("marginX", marginX);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="marginY">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Margin (Vertical)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const marginY = value === "" ? ("" as unknown as number) : Number(value);
|
||||
field.handleChange(marginY);
|
||||
handleAutoSave("marginY", marginY);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="gapX">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Spacing (Horizontal)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0}
|
||||
step={1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const gapX = value === "" ? ("" as unknown as number) : Number(value);
|
||||
field.handleChange(gapX);
|
||||
handleAutoSave("gapX", gapX);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="gapY">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Spacing (Vertical)</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0}
|
||||
step={1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const gapY = value === "" ? ("" as unknown as number) : Number(value);
|
||||
field.handleChange(gapY);
|
||||
handleAutoSave("gapY", gapY);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="hideIcons">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full flex items-center gap-x-3 py-2"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<Switch
|
||||
checked={field.state.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.handleChange(checked);
|
||||
handleAutoSave("hideIcons", checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormLabel>
|
||||
<Trans>Hide all icons on the resume</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, InfoIcon, LightningIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
import { useAIStore } from "@reactive-resume/ai/store";
|
||||
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { useResume } from "@/components/resume/use-resume";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
function impactCircleClass(impact: "high" | "medium" | "low") {
|
||||
return match(impact)
|
||||
.with("high", () => "bg-rose-600")
|
||||
.with("medium", () => "bg-amber-600")
|
||||
.with("low", () => "bg-emerald-600")
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
function impactLabel(impact: "high" | "medium" | "low") {
|
||||
return match(impact)
|
||||
.with("high", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "High",
|
||||
}),
|
||||
)
|
||||
.with("medium", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "Medium",
|
||||
}),
|
||||
)
|
||||
.with("low", () =>
|
||||
t({
|
||||
comment: "Impact severity label in resume analysis suggestion card",
|
||||
message: "Low",
|
||||
}),
|
||||
)
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export function ResumeAnalysisSectionBuilder() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const resume = useResume();
|
||||
const aiEnabled = useAIStore((state) => state.enabled);
|
||||
const aiProvider = useAIStore((state) => state.provider);
|
||||
const aiModel = useAIStore((state) => state.model);
|
||||
const aiApiKey = useAIStore((state) => state.apiKey);
|
||||
const aiBaseURL = useAIStore((state) => state.baseURL);
|
||||
|
||||
const resumeId = resume?.id ?? "";
|
||||
|
||||
const analysisQuery = useQuery({
|
||||
...orpc.resume.analysis.getById.queryOptions({ input: { id: resumeId } }),
|
||||
enabled: !!resume,
|
||||
});
|
||||
|
||||
const { mutate: analyzeResume, isPending } = useMutation({
|
||||
...orpc.ai.analyzeResume.mutationOptions(),
|
||||
onSuccess: (analysis) => {
|
||||
queryClient.setQueryData(orpc.resume.analysis.getById.queryKey({ input: { id: resumeId } }), analysis);
|
||||
toast.success(t`Resume analysis complete.`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(t`Failed to analyze resume.`, {
|
||||
description: getOrpcErrorMessage(error, {
|
||||
byCode: {
|
||||
BAD_REQUEST: t({
|
||||
comment: "Error description when AI returns invalid resume analysis format",
|
||||
message: "The AI returned an invalid analysis format. Please try again.",
|
||||
}),
|
||||
BAD_GATEWAY: t({
|
||||
comment: "Error description when AI provider cannot be reached during resume analysis",
|
||||
message: "Could not reach the AI provider. Please try again.",
|
||||
}),
|
||||
},
|
||||
fallback: t({
|
||||
comment: "Fallback error description when resume analysis request fails",
|
||||
message: "Something went wrong while analyzing your resume.",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const analysis = analysisQuery.data;
|
||||
const score = analysis?.overallScore ?? null;
|
||||
const analyzeLabel = isPending ? t`Analyzing...` : t`Analyze Resume`;
|
||||
|
||||
const scoreTone = useMemo(() => {
|
||||
if (score == null) return "bg-muted";
|
||||
if (score >= 80) return "bg-emerald-600";
|
||||
if (score >= 60) return "bg-amber-600";
|
||||
return "bg-rose-600";
|
||||
}, [score]);
|
||||
|
||||
const onAnalyze = () => {
|
||||
if (!resume) return;
|
||||
|
||||
analyzeResume({
|
||||
provider: aiProvider,
|
||||
model: aiModel,
|
||||
apiKey: aiApiKey,
|
||||
baseURL: aiBaseURL,
|
||||
resumeId: resume.id,
|
||||
resumeData: resume.data,
|
||||
});
|
||||
};
|
||||
|
||||
if (!resume) return null;
|
||||
|
||||
return (
|
||||
<SectionBase type="analysis" className="space-y-4">
|
||||
{!aiEnabled && <DisabledState />}
|
||||
|
||||
{aiEnabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4 rounded-md border bg-card p-3">
|
||||
<div className="grid grid-cols-2 items-center gap-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>
|
||||
Get a review of your resume with an overall score, strengths, and actionable suggestions.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button disabled={isPending} onClick={onAnalyze} className="ml-auto w-fit">
|
||||
<SparkleIcon />
|
||||
{analyzeLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<div
|
||||
className={`grid size-18 place-items-center rounded-full border-3 border-background font-bold text-lg text-white ${scoreTone}`}
|
||||
>
|
||||
{score ?? "--"}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="font-medium text-sm leading-none">
|
||||
<Trans>Overall Score</Trans>
|
||||
</p>
|
||||
<div className="grid grid-cols-10 gap-1">
|
||||
{Array.from({ length: 10 }).map((_, index) => {
|
||||
const active = score != null && index < Math.round(score / 10);
|
||||
return (
|
||||
<div
|
||||
key={`scorebar-${index}`}
|
||||
className={`h-1.5 rounded-full transition-colors ${active ? "bg-primary" : "bg-muted"}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{analysis?.updatedAt && (
|
||||
<p className="text-muted-foreground text-xs leading-none">
|
||||
<Trans>Last analyzed on {new Date(analysis.updatedAt).toLocaleString()}</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysisQuery.isFetched && !analysis && !isPending && (
|
||||
<div className="rounded-md border border-dashed p-3">
|
||||
<p className="max-w-xs text-muted-foreground text-sm">
|
||||
<Trans>Run your first analysis to get a scorecard, strengths, and prioritized suggestions.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<h5 className="flex items-center gap-2 font-semibold text-sm">
|
||||
<LightningIcon className="text-primary" />
|
||||
<Trans>Scorecard</Trans>
|
||||
</h5>
|
||||
|
||||
<div className="space-y-3">
|
||||
{analysis.scorecard.map((item) => (
|
||||
<div key={item.dimension} className="space-y-3 rounded-md border bg-card p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-medium text-sm">{item.dimension}</div>
|
||||
<Badge variant="secondary">{item.score}/100</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">{item.rationale}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysis.strengths.length > 0 && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<h5 className="font-semibold text-sm">
|
||||
<Trans>Strengths</Trans>
|
||||
</h5>
|
||||
|
||||
<ul className="list-outside list-disc pl-5 text-muted-foreground text-sm">
|
||||
{analysis.strengths.map((strength) => (
|
||||
<li key={strength} className="py-1.5">
|
||||
{strength}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis.suggestions.length > 0 && (
|
||||
<div className="space-y-4 rounded-md border p-3">
|
||||
<h5 className="font-semibold text-sm">
|
||||
<Trans>Suggestions</Trans>
|
||||
</h5>
|
||||
|
||||
<div className="space-y-3">
|
||||
{analysis.suggestions.map((suggestion) => (
|
||||
<div key={suggestion.title} className="space-y-3 rounded-md border bg-card p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
role="img"
|
||||
className={`size-2.5 shrink-0 rounded-full ring-1 ring-border ${impactCircleClass(suggestion.impact)}`}
|
||||
title={impactLabel(suggestion.impact)}
|
||||
aria-label={impactLabel(suggestion.impact)}
|
||||
/>
|
||||
<div className="font-semibold text-sm tracking-tight">{suggestion.title}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-xs">{suggestion.why}</div>
|
||||
|
||||
{suggestion.exampleRewrite && (
|
||||
<div className="rounded bg-muted p-2 text-muted-foreground text-xs">
|
||||
{suggestion.exampleRewrite}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function DisabledState() {
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon />
|
||||
<AlertDescription className="space-y-3">
|
||||
<p>
|
||||
<Trans>
|
||||
Get an in-depth AI-powered review of your resume with an overall score, key strengths, and practical
|
||||
suggestions. To activate this feature, please update your AI settings.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard/settings/integrations">
|
||||
<Trans>Open Integrations Settings</Trans>
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { ClipboardIcon, LockSimpleIcon, LockSimpleOpenIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { useCurrentResume, usePatchResume } from "@/components/resume/use-resume";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function SharingSectionBuilder() {
|
||||
const prompt = usePrompt();
|
||||
const confirm = useConfirm();
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const { data: session } = authClient.useSession();
|
||||
const resume = useCurrentResume();
|
||||
const patchResume = usePatchResume();
|
||||
|
||||
const { mutateAsync: updateResume } = useMutation(orpc.resume.update.mutationOptions());
|
||||
const { mutateAsync: setPassword } = useMutation(orpc.resume.setPassword.mutationOptions());
|
||||
const { mutateAsync: removePassword } = useMutation(orpc.resume.removePassword.mutationOptions());
|
||||
|
||||
const publicUrl = useMemo(() => {
|
||||
if (!session) return "";
|
||||
return `${window.location.origin}/${session.user.username}/${resume.slug}`;
|
||||
}, [session, resume]);
|
||||
|
||||
const onCopyUrl = useCallback(async () => {
|
||||
await copyToClipboard(publicUrl);
|
||||
toast.success(t`A link to your resume has been copied to clipboard.`);
|
||||
}, [publicUrl, copyToClipboard]);
|
||||
|
||||
const onTogglePublic = useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
const updated = await updateResume({ id: resume.id, isPublic: checked });
|
||||
patchResume((draft) => {
|
||||
draft.isPublic = updated.isPublic;
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
[patchResume, resume.id, updateResume],
|
||||
);
|
||||
|
||||
const onSetPassword = useCallback(async () => {
|
||||
const value = await prompt(t`Protect your resume from unauthorized access with a password`, {
|
||||
description: t`Anyone visiting the resume's public URL must enter this password to access it.`,
|
||||
confirmText: t`Set Password`,
|
||||
inputProps: {
|
||||
type: "password",
|
||||
minLength: 6,
|
||||
maxLength: 64,
|
||||
},
|
||||
});
|
||||
if (!value) return;
|
||||
|
||||
const password = value.trim();
|
||||
if (!password) return toast.error(t`Password cannot be empty.`);
|
||||
|
||||
const toastId = toast.loading(t`Enabling password protection...`);
|
||||
|
||||
try {
|
||||
await setPassword({ id: resume.id, password });
|
||||
patchResume((draft) => {
|
||||
draft.hasPassword = true;
|
||||
});
|
||||
toast.success(t`Password protection has been enabled.`, { id: toastId });
|
||||
} catch (error) {
|
||||
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
|
||||
toast.error(message, { id: toastId });
|
||||
}
|
||||
}, [patchResume, prompt, resume.id, setPassword]);
|
||||
|
||||
const onRemovePassword = useCallback(async () => {
|
||||
if (!resume.hasPassword) return;
|
||||
|
||||
const confirmation = await confirm(t`Are you sure you want to remove password protection?`, {
|
||||
description: t`Anyone who has the resume's public URL will be able to view and download your resume without entering a password.`,
|
||||
confirmText: t`Confirm`,
|
||||
cancelText: t`Cancel`,
|
||||
});
|
||||
if (!confirmation) return;
|
||||
|
||||
const toastId = toast.loading(t`Removing password protection...`);
|
||||
|
||||
try {
|
||||
await removePassword({ id: resume.id });
|
||||
patchResume((draft) => {
|
||||
draft.hasPassword = false;
|
||||
});
|
||||
toast.success(t`Password protection has been disabled.`, { id: toastId });
|
||||
} catch (error) {
|
||||
const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`;
|
||||
toast.error(message, { id: toastId });
|
||||
}
|
||||
}, [confirm, patchResume, removePassword, resume.hasPassword, resume.id]);
|
||||
|
||||
const isPasswordProtected = resume.hasPassword;
|
||||
|
||||
return (
|
||||
<SectionBase type="sharing" className="space-y-4">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Switch
|
||||
id="sharing-switch"
|
||||
checked={resume.isPublic}
|
||||
onCheckedChange={(checked) => void onTogglePublic(checked)}
|
||||
/>
|
||||
|
||||
<Label htmlFor="sharing-switch" className="my-2 flex flex-col items-start gap-y-1 font-normal">
|
||||
<span className="font-medium">
|
||||
<Trans>Allow Public Access</Trans>
|
||||
</span>
|
||||
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<Trans>Anyone with the link can view and download the resume.</Trans>
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{resume.isPublic && (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="sharing-url">
|
||||
<Trans comment="Form field label for the generated public resume link in sharing settings">URL</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input readOnly id="sharing-url" value={publicUrl} />
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={onCopyUrl}>
|
||||
<ClipboardIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
{isPasswordProtected ? (
|
||||
<Trans>
|
||||
Your resume's public link is currently protected by a password. Share the password only with people you
|
||||
trust.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Optionally, set a password so that only people with the password can view your resume through the link.
|
||||
</Trans>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{isPasswordProtected ? (
|
||||
<Button variant="outline" onClick={onRemovePassword}>
|
||||
<LockSimpleOpenIcon />
|
||||
<Trans>Remove Password</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={onSetPassword}>
|
||||
<LockSimpleIcon />
|
||||
<Trans>Set Password</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { InfoIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import { Accordion, AccordionContent, AccordionItem } from "@reactive-resume/ui/components/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function StatisticsSectionBuilder() {
|
||||
const params = useParams({ from: "/builder/$resumeId" });
|
||||
const { data: statistics } = useQuery(
|
||||
orpc.resume.statistics.getById.queryOptions({ input: { id: params.resumeId } }),
|
||||
);
|
||||
|
||||
if (!statistics) return null;
|
||||
|
||||
return (
|
||||
<SectionBase type="statistics">
|
||||
<Accordion value={statistics.isPublic ? ["isPublic"] : ["isPrivate"]}>
|
||||
<AccordionItem value="isPrivate">
|
||||
<AccordionContent className="pb-0">
|
||||
<Alert>
|
||||
<InfoIcon />
|
||||
<AlertTitle>
|
||||
<Trans>Track your resume's views and downloads</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Turn on public sharing to track how many times your resume has been viewed or downloaded. Only you can
|
||||
see your resume's statistics.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="isPublic">
|
||||
<AccordionContent className="grid @md:grid-cols-2 grid-cols-1 gap-4 pb-0">
|
||||
<StatisticsItem
|
||||
label={t`Views`}
|
||||
value={statistics.views}
|
||||
timestamp={statistics.lastViewedAt ? t`Last viewed on ${statistics.lastViewedAt.toDateString()}` : null}
|
||||
/>
|
||||
|
||||
<StatisticsItem
|
||||
label={t`Downloads`}
|
||||
value={statistics.downloads}
|
||||
timestamp={
|
||||
statistics.lastDownloadedAt ? t`Last downloaded on ${statistics.lastDownloadedAt.toDateString()}` : null
|
||||
}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
type StatisticsItemProps = {
|
||||
label: string;
|
||||
value: number;
|
||||
timestamp: string | null;
|
||||
};
|
||||
|
||||
function StatisticsItem({ label, value, timestamp }: StatisticsItemProps) {
|
||||
return (
|
||||
<div>
|
||||
<h4 className="mb-1 font-bold font-mono text-4xl">{value}</h4>
|
||||
<p className="font-medium text-muted-foreground leading-none">{label}</p>
|
||||
{timestamp && <span className="text-muted-foreground text-xs">{timestamp}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { SwapIcon } from "@phosphor-icons/react";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { useCurrentResume } from "@/components/resume/use-resume";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function TemplateSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="template">
|
||||
<TemplateSectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateSectionForm() {
|
||||
const { i18n } = useLingui();
|
||||
const openDialog = useDialogStore((state) => state.openDialog);
|
||||
const resume = useCurrentResume();
|
||||
const template = resume.data.metadata.template;
|
||||
|
||||
const metadata = templates[template];
|
||||
|
||||
const onOpenTemplateGallery = () => {
|
||||
openDialog("resume.template.gallery", undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex @md:flex-row flex-col items-stretch gap-x-4 gap-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onOpenTemplateGallery}
|
||||
className="group/preview relative h-auto w-40 shrink-0 cursor-pointer p-0"
|
||||
>
|
||||
<div className="relative z-10 aspect-page size-full overflow-hidden rounded-md opacity-100 transition-opacity group-hover/preview:opacity-50">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<SwapIcon size={48} weight="thin" className="size-12" />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-1 flex-col space-y-4 @md:pt-1 @md:pb-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-2xl capitalize tracking-tight">{metadata.name}</h3>
|
||||
<p className="text-muted-foreground text-sm">{i18n.t(metadata.description)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{metadata.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { typographySchema } from "@reactive-resume/schema/resume/data";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { useResume, useUpdateResumeData } from "@/components/resume/use-resume";
|
||||
import { FontFamilyCombobox, FontWeightCombobox, getNextWeights } from "@/components/typography/combobox";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function TypographySectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="typography">
|
||||
<TypographySectionForm />
|
||||
</SectionBase>
|
||||
);
|
||||
}
|
||||
|
||||
const formSchema = typographySchema;
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
type FontWeight = FormValues["body"]["fontWeights"][number];
|
||||
|
||||
function TypographySectionForm() {
|
||||
const resume = useResume();
|
||||
const typography = resume?.data.metadata.typography;
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const persist = (data: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.typography.body = data.body;
|
||||
draft.metadata.typography.heading = data.heading;
|
||||
});
|
||||
};
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: typography,
|
||||
validators: { onChange: formSchema },
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
const bodyFontFamily = useStore(form.store, (s) => s.values.body.fontFamily);
|
||||
const headingFontFamily = useStore(form.store, (s) => s.values.heading.fontFamily);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid @md:grid-cols-2 grid-cols-1 gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="col-span-full flex items-center gap-x-2">
|
||||
<Separator className="basis-[16px]" />
|
||||
<div className="shrink-0 font-medium text-base leading-none">
|
||||
<Trans context="Body Text (paragraphs, lists, etc.)">Body</Trans>
|
||||
</div>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
<form.Field name="body.fontFamily">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Font Family</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<FontFamilyCombobox
|
||||
value={field.state.value}
|
||||
className="text-base"
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
field.handleChange(value);
|
||||
const nextWeights = getNextWeights(value);
|
||||
if (nextWeights) form.setFieldValue("body.fontWeights", nextWeights);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="body.fontWeights">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Font Weights</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<FontWeightCombobox
|
||||
value={field.state.value}
|
||||
fontFamily={bodyFontFamily}
|
||||
onValueChange={(value) => {
|
||||
if (value?.length === 0) return;
|
||||
field.handleChange(value as FontWeight[]);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="body.fontSize">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Font Size</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={6}
|
||||
max={24}
|
||||
step={0.1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="body.lineHeight">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Line Height</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0.5}
|
||||
max={4}
|
||||
step={0.05}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>x</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<div className="col-span-full flex items-center gap-x-2">
|
||||
<Separator className="basis-[16px]" />
|
||||
<div className="shrink-0 font-medium text-base leading-none">
|
||||
<Trans context="Headings or Titles (H1, H2, H3, H4, H5, H6)">Heading</Trans>
|
||||
</div>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
|
||||
<form.Field name="heading.fontFamily">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Font Family</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<FontFamilyCombobox
|
||||
value={field.state.value}
|
||||
className="text-base"
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
field.handleChange(value);
|
||||
const nextWeights = getNextWeights(value);
|
||||
if (nextWeights) form.setFieldValue("heading.fontWeights", nextWeights);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="heading.fontWeights">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="col-span-full"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Font Weight</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<FontWeightCombobox
|
||||
value={field.state.value}
|
||||
fontFamily={headingFontFamily}
|
||||
onValueChange={(value) => {
|
||||
if (value?.length === 0) return;
|
||||
field.handleChange(value as FontWeight[]);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="heading.fontSize">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Font Size</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={6}
|
||||
max={24}
|
||||
step={0.1}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="heading.lineHeight">
|
||||
{(field) => (
|
||||
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Line Height</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
min={0.5}
|
||||
max={4}
|
||||
step={0.05}
|
||||
type="number"
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>x</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user