* chore(release): v5.1.0

* feat: implement resume thumbnails

* fix: remove unused mcp tools

* docs: fix formatting of docs
This commit is contained in:
Amruth Pillai
2026-05-07 15:12:33 +02:00
committed by GitHub
parent 51c366310e
commit 50ba37a27f
1015 changed files with 106087 additions and 141872 deletions
+368
View File
@@ -0,0 +1,368 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { DialogProps } from "../store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import z from "zod";
import { useAIStore } from "@reactive-resume/ai/store";
import { JSONResumeImporter } from "@reactive-resume/import/json-resume";
import { ReactiveResumeJSONImporter } from "@reactive-resume/import/reactive-resume-json";
import { ReactiveResumeV4JSONImporter } from "@reactive-resume/import/reactive-resume-v4-json";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
import { useAppForm } from "@/libs/tanstack-form";
import { useDialogStore } from "../store";
const formSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal(""),
file: z.undefined(),
}),
z.object({
type: z.literal("pdf"),
file: z.instanceof(File).refine((file) => file.type === "application/pdf", { message: "File must be a PDF" }),
}),
z.object({
type: z.literal("docx"),
file: z
.instanceof(File)
.refine(
(file) =>
file.type === "application/msword" ||
file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
{ message: "File must be a Microsoft Word document" },
),
}),
z.object({
type: z.literal("reactive-resume-json"),
file: z
.instanceof(File)
.refine((file) => file.type === "application/json", { message: "File must be a JSON file" }),
}),
z.object({
type: z.literal("reactive-resume-v4-json"),
file: z
.instanceof(File)
.refine((file) => file.type === "application/json", { message: "File must be a JSON file" }),
}),
z.object({
type: z.literal("json-resume-json"),
file: z
.instanceof(File)
.refine((file) => file.type === "application/json", { message: "File must be a JSON file" }),
}),
]);
type FormValues = z.infer<typeof formSchema>;
type ImportType = FormValues["type"];
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// remove data URL prefix (e.g., "data:application/pdf;base64," or "data:application/vnd...;base64,")
resolve(result.split(",")[1]);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const navigate = useNavigate();
const { enabled: isAIEnabled, provider, model, apiKey, baseURL } = useAIStore();
const closeDialog = useDialogStore((state) => state.closeDialog);
const prevTypeRef = useRef<string>("");
const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const { mutateAsync: importResume } = useMutation(orpc.resume.import.mutationOptions());
const form = useAppForm({
defaultValues: {
type: "" as ImportType,
file: undefined as File | undefined,
},
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
if (value.type === "" || !value.file) return;
setIsLoading(true);
const toastId = toast.loading(t`Importing your resume...`, {
description: t`This may take a few minutes, depending on the response of the AI provider. Please do not close the window or refresh the page.`,
});
try {
let data: ResumeData | undefined;
if (value.type === "json-resume-json") {
const json = await value.file.text();
const importer = new JSONResumeImporter();
data = importer.parse(json);
}
if (value.type === "reactive-resume-json") {
const json = await value.file.text();
const importer = new ReactiveResumeJSONImporter();
data = importer.parse(json);
}
if (value.type === "reactive-resume-v4-json") {
const json = await value.file.text();
const importer = new ReactiveResumeV4JSONImporter();
data = importer.parse(json);
}
if (value.type === "pdf") {
if (!isAIEnabled)
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
const base64 = await fileToBase64(value.file);
data = await client.ai.parsePdf({
provider,
model,
apiKey,
baseURL,
file: { name: value.file.name, data: base64 },
});
}
if (value.type === "docx") {
if (!isAIEnabled)
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
const base64 = await fileToBase64(value.file);
const mediaType =
value.file.type === "application/msword"
? ("application/msword" as const)
: ("application/vnd.openxmlformats-officedocument.wordprocessingml.document" as const);
data = await client.ai.parseDocx({
provider,
model,
apiKey,
baseURL,
mediaType,
file: { name: value.file.name, data: base64 },
});
}
if (!data) {
throw new Error(
t({
comment: "Error shown when AI import endpoint returns no parsed resume data",
message: "No data was returned from the AI provider.",
}),
);
}
const id = await importResume({ data });
toast.success(t`Your resume has been imported successfully.`, { id: toastId, description: null });
closeDialog();
void navigate({ to: "/builder/$resumeId", params: { resumeId: id } });
} catch (error: unknown) {
toast.error(
getOrpcErrorMessage(error, {
byCode: {
BAD_REQUEST: t({
comment: "Error shown when AI parsing returns invalid resume structure during import",
message: "The imported file could not be parsed into a valid resume.",
}),
BAD_GATEWAY: t({
comment: "Error shown when AI provider is unreachable during PDF/DOCX resume import",
message: "Could not reach the AI provider. Please try again.",
}),
},
fallback: t({
comment: "Fallback toast when importing a resume fails for an unknown reason",
message: "An unknown error occurred while importing your resume.",
}),
}),
{ id: toastId, description: null },
);
} finally {
setIsLoading(false);
}
},
});
const type = useStore(form.store, (s) => s.values.type);
useEffect(() => {
if (prevTypeRef.current === type) return;
prevTypeRef.current = type;
form.setFieldValue("file", undefined);
}, [form, type]);
const onSelectFile = () => {
if (!inputRef.current) return;
inputRef.current.click();
};
const onUploadFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
form.setFieldValue("file", file);
};
useFormBlocker(form);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<DownloadSimpleIcon />
<Trans>Import an existing resume</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Continue where you left off by importing an existing resume you created using Reactive Resume or any another
resume builder. Supported formats include PDF, Microsoft Word, as well as JSON files from Reactive Resume.
</Trans>
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<form.Field name="type">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Type</Trans>
</FormLabel>
<FormControl
render={
<Combobox
showClear={false}
value={field.state.value}
onValueChange={(value) => {
field.handleChange(value as ImportType);
}}
options={[
{
value: "reactive-resume-json",
label: t({
comment: "Import source option for current Reactive Resume JSON format",
message: "Reactive Resume (JSON)",
}),
},
{
value: "reactive-resume-v4-json",
label: t({
comment: "Import source option for legacy Reactive Resume v4 JSON format",
message: "Reactive Resume v4 (JSON)",
}),
},
{
value: "json-resume-json",
label: t({
comment: "Import source option for standard JSON Resume format",
message: "JSON Resume",
}),
},
{
value: "pdf",
label: (
<div className="flex items-center gap-x-2">
{t({
comment: "File format label in import source selector",
message: "PDF",
})}{" "}
<Badge>{t`AI`}</Badge>
</div>
),
},
{
value: "docx",
label: (
<div className="flex items-center gap-x-2">
{t({
comment: "File format label in import source selector",
message: "Microsoft Word",
})}{" "}
<Badge>{t`AI`}</Badge>
</div>
),
},
]}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field key={type} name="file">
{(field) => (
<FormItem
className={cn(!type && "hidden")}
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormControl>
<Input type="file" className="hidden" ref={inputRef} onChange={onUploadFile} />
<Button
variant="outline"
className="h-auto w-full flex-col border-dashed py-8 font-normal"
onClick={onSelectFile}
>
{field.state.value ? (
<>
<FileIcon weight="thin" size={32} />
<p>{field.state.value.name}</p>
</>
) : (
<>
<UploadSimpleIcon weight="thin" size={32} />
<Trans>Click here to select a file to import</Trans>
</>
)}
</Button>
</FormControl>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<DialogFooter>
<Button type="submit" disabled={!type || isLoading}>
{isLoading ? <Spinner /> : null}
{isLoading ? t`Importing...` : t`Import`}
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
+438
View File
@@ -0,0 +1,438 @@
import type { RouterInput } from "@/libs/orpc/client";
import type { DialogProps } from "../store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CaretDownIcon, MagicWandIcon, PencilSimpleLineIcon, PlusIcon, TestTubeIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { useNavigate, useParams } from "@tanstack/react-router";
import { useEffect, useMemo } from "react";
import { toast } from "sonner";
import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
import { ButtonGroup } from "@reactive-resume/ui/components/button-group";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@reactive-resume/ui/components/input-group";
import { generateId, generateRandomName, slugify } from "@reactive-resume/utils/string";
import { ChipInput } from "@/components/input/chip-input";
import { usePatchResume } from "@/components/resume/use-resume";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { authClient } from "@/libs/auth/client";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { useDialogStore } from "../store";
const formSchema = z.object({
id: z.string(),
name: z.string().min(1).max(64),
slug: z.string().min(1).max(64).transform(slugify),
tags: z.array(z.string()),
});
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
name: "",
slug: "",
tags: [],
};
export function CreateResumeDialog(_: DialogProps<"resume.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const { mutate: createResume, isPending } = useMutation(orpc.resume.create.mutationOptions());
const form = useAppForm({
defaultValues: {
id: generateId(),
name: "",
slug: "",
tags: [] as string[],
},
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
const toastId = toast.loading(t`Creating your resume...`);
createResume(value, {
onSuccess: () => {
toast.success(t`Your resume has been created successfully.`, { id: toastId });
closeDialog();
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
},
});
},
});
const name = useStore(form.store, (s) => s.values.name);
useEffect(() => {
form.setFieldValue("slug", slugify(name));
}, [form, name]);
useFormBlocker(form);
const onCreateSampleResume = () => {
const values = form.state.values;
const randomName = generateRandomName();
const data = {
name: values.name || randomName,
slug: values.slug || slugify(randomName),
tags: values.tags,
withSampleData: true,
} satisfies RouterInput["resume"]["create"];
const toastId = toast.loading(t`Creating your resume...`);
createResume(data, {
onSuccess: () => {
toast.success(t`Your resume has been created successfully.`, { id: toastId });
closeDialog();
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
},
});
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new resume</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Start building your resume by giving it a name.</Trans>
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<ResumeForm form={form} />
<DialogFooter>
<ButtonGroup
aria-label={t({
comment: "Accessible label for create-resume split button group",
message: "Create resume with options",
})}
className="gap-x-px rtl:flex-row-reverse"
>
<Button type="submit" disabled={isPending}>
<Trans>Create</Trans>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size="icon" disabled={isPending}>
<CaretDownIcon />
</Button>
}
/>
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onClick={onCreateSampleResume}>
<TestTubeIcon />
<Trans>Create a Sample Resume</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateResumeDialog({ data }: DialogProps<"resume.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const patchResume = usePatchResume();
const params = useParams({ strict: false }) as { resumeId?: string };
const { mutate: updateResume, isPending } = useMutation(orpc.resume.update.mutationOptions());
const form = useAppForm({
defaultValues: {
id: data.id,
name: data.name,
slug: data.slug,
tags: data.tags,
},
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
const toastId = toast.loading(t`Updating your resume...`);
updateResume(value, {
onSuccess: (updated) => {
if (params.resumeId === updated.id) {
patchResume((draft) => {
draft.name = updated.name;
draft.slug = updated.slug;
draft.tags = updated.tags;
draft.isLocked = updated.isLocked;
draft.isPublic = updated.isPublic;
draft.hasPassword = updated.hasPassword;
});
}
toast.success(t`Your resume has been updated successfully.`, { id: toastId });
closeDialog();
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
},
});
},
});
const name = useStore(form.store, (s) => s.values.name);
useEffect(() => {
if (!name) return;
form.setFieldValue("slug", slugify(name));
}, [form, name]);
useFormBlocker(form);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update Resume</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Changed your mind? Rename your resume to something more descriptive.</Trans>
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<ResumeForm form={form} />
<DialogFooter>
<Button type="submit" disabled={isPending}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">) {
const navigate = useNavigate();
const closeDialog = useDialogStore((state) => state.closeDialog);
const { mutate: duplicateResume, isPending } = useMutation(orpc.resume.duplicate.mutationOptions());
const form = useAppForm({
defaultValues: {
id: data.id,
name: `${data.name} (Copy)`,
slug: `${data.slug}-copy`,
tags: data.tags,
},
validators: { onSubmit: formSchema },
onSubmit: ({ value }) => {
const toastId = toast.loading(t`Duplicating your resume...`);
duplicateResume(value, {
onSuccess: async (id) => {
toast.success(t`Your resume has been duplicated successfully.`, { id: toastId });
closeDialog();
if (!data.shouldRedirect) return;
void navigate({ to: "/builder/$resumeId", params: { resumeId: id } });
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
},
});
},
});
const name = useStore(form.store, (s) => s.values.name);
useEffect(() => {
if (!name) return;
form.setFieldValue("slug", slugify(name));
}, [form, name]);
useFormBlocker(form);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Duplicate Resume</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Duplicate your resume to create a new one, just like the original.</Trans>
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<ResumeForm form={form} />
<DialogFooter>
<Button type="submit" disabled={isPending}>
<Trans>Duplicate</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const ResumeForm = withForm({
defaultValues,
render: ({ form }) => {
const { data: session } = authClient.useSession();
const slugPrefix = useMemo(() => {
return `${window.location.origin}/${session?.user.username ?? ""}/`;
}, [session]);
const onGenerateName = () => {
form.setFieldValue("name", generateRandomName());
};
return (
<>
<form.Field name="name">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<div className="flex items-center gap-x-2">
<FormControl
render={
<Input
min={1}
max={64}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<Button size="icon" variant="outline" title={t`Generate a random name`} onClick={onGenerateName}>
<MagicWandIcon />
</Button>
</div>
<FormMessage errors={field.state.meta.errors} />
<FormDescription>
<Trans>Tip: You can name the resume referring to the position you are applying for.</Trans>
</FormDescription>
</FormItem>
)}
</form.Field>
<form.Field name="slug">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Slug</Trans>
</FormLabel>
<FormControl
render={
<InputGroup>
<InputGroupAddon align="inline-start" className="hidden sm:flex">
<InputGroupText>{slugPrefix}</InputGroupText>
</InputGroupAddon>
<InputGroupInput
min={1}
max={64}
className="ps-0!"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
</InputGroup>
}
/>
<FormMessage errors={field.state.meta.errors} />
<FormDescription>
<Trans>This is a URL-friendly name for your resume.</Trans>
</FormDescription>
</FormItem>
)}
</form.Field>
<form.Field name="tags">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Tags</Trans>
</FormLabel>
<FormControl
render={
<ChipInput
value={field.state.value}
onChange={(value) => {
field.handleChange(value);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
<FormDescription>
<Trans>Tags can be used to categorize your resume by keywords.</Trans>
</FormDescription>
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -0,0 +1,230 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { awardItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = awardItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
title: "",
awarder: "",
date: "",
website: { url: "", label: "", inlineLink: false },
description: "",
};
export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "awards", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new award</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<AwardForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "awards", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing award</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<AwardForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const AwardForm = withForm({
defaultValues,
render: ({ form }) => {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
return (
<>
<form.AppField name="title">{(field) => <field.TextField label={<Trans>Title</Trans>} />}</form.AppField>
<form.Field name="awarder">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans context="(noun) person, organization, or entity that gives an award">Awarder</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.AppField name="date">{(field) => <field.TextField label={<Trans>Date</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -0,0 +1,210 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { certificationItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = certificationItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
title: "",
issuer: "",
date: "",
website: { url: "", label: "", inlineLink: false },
description: "",
};
export function CreateCertificationDialog({ data }: DialogProps<"resume.sections.certifications.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "certifications", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new certification</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<CertificationForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections.certifications.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "certifications", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing certification</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<CertificationForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const CertificationForm = withForm({
defaultValues,
render: ({ form }) => {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
return (
<>
<form.AppField name="title">{(field) => <field.TextField label={<Trans>Title</Trans>} />}</form.AppField>
<form.AppField name="issuer">{(field) => <field.TextField label={<Trans>Issuer</Trans>} />}</form.AppField>
<form.AppField name="date">{(field) => <field.TextField label={<Trans>Date</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -0,0 +1,177 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { coverLetterItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { RichInput } from "@/components/input/rich-input";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = coverLetterItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
recipient: "",
content: "",
};
export function CreateCoverLetterDialog({ data }: DialogProps<"resume.sections.cover-letter.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(value);
}
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new cover letter</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<CoverLetterForm form={form} />
<DialogFooter>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateCoverLetterDialog({ data }: DialogProps<"resume.sections.cover-letter.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === value.id);
if (index !== -1) section.items[index] = value;
}
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing cover letter</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<CoverLetterForm form={form} />
<DialogFooter>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const CoverLetterForm = withForm({
defaultValues,
render: ({ form }) => {
return (
<>
<form.Field name="recipient">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Recipient</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="content">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -0,0 +1,306 @@
import type { MessageDescriptor } from "@lingui/core";
import type { CustomSectionType } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { customSectionSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { generateId } from "@reactive-resume/utils/string";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { Combobox } from "@/components/ui/combobox";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = customSectionSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
title: "",
type: "experience",
columns: 1,
hidden: false,
items: [],
};
const SECTION_TYPE_OPTIONS: { value: CustomSectionType; label: MessageDescriptor }[] = [
{ value: "summary", label: msg`Summary` },
{ value: "experience", label: msg`Experience` },
{ value: "education", label: msg`Education` },
{ value: "projects", label: msg`Projects` },
{ value: "profiles", label: msg`Profiles` },
{ value: "skills", label: msg`Skills` },
{ value: "languages", label: msg`Languages` },
{ value: "interests", label: msg`Interests` },
{ value: "awards", label: msg`Awards` },
{ value: "certifications", label: msg`Certifications` },
{ value: "publications", label: msg`Publications` },
{ value: "volunteer", label: msg`Volunteer` },
{ value: "references", label: msg`References` },
{ value: "cover-letter", label: msg`Cover Letter` },
];
function isCustomSectionType(value: string | null | undefined): value is CustomSectionType {
return SECTION_TYPE_OPTIONS.some((option) => option.value === value);
}
export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections.custom.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: {
id: generateId(),
title: data?.title ?? "",
type: (data?.type ?? "experience") as CustomSectionType,
columns: data?.columns ?? 1,
hidden: data?.hidden ?? false,
items: data?.items ?? [],
},
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
draft.customSections.push(value);
const lastPageIndex = draft.metadata.layout.pages.length - 1;
if (lastPageIndex < 0) return;
draft.metadata.layout.pages[lastPageIndex].main.push(value.id);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new custom section</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<CreateCustomSectionForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections.custom.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
const index = draft.customSections.findIndex((item) => item.id === value.id);
if (index === -1) return;
draft.customSections[index] = value;
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing custom section</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<UpdateCustomSectionForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const CreateCustomSectionForm = withForm({
defaultValues,
render: ({ form }) => {
const { i18n } = useLingui();
return (
<>
<form.Field name="title">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Title</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="type">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Section Type</Trans>
</FormLabel>
<FormControl
render={
<Combobox
name={field.name}
value={field.state.value}
disabled={false}
onValueChange={(v) => {
if (isCustomSectionType(v)) field.handleChange(v);
}}
options={SECTION_TYPE_OPTIONS.map((option) => ({
value: option.value,
label: i18n.t(option.label),
}))}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
const UpdateCustomSectionForm = withForm({
defaultValues,
render: ({ form }) => {
const { i18n } = useLingui();
return (
<>
<form.Field name="title">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Title</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="type">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Section Type</Trans>
</FormLabel>
<FormControl
render={
<Combobox
name={field.name}
value={field.state.value}
disabled
onValueChange={(v) => {
if (isCustomSectionType(v)) field.handleChange(v);
}}
options={SECTION_TYPE_OPTIONS.map((option) => ({
value: option.value,
label: i18n.t(option.label),
}))}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -0,0 +1,222 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { educationItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = educationItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
school: "",
degree: "",
area: "",
grade: "",
location: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
};
export function CreateEducationDialog({ data }: DialogProps<"resume.sections.education.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "education", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new education</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<EducationForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.education.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "education", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing education</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<EducationForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const EducationForm = withForm({
defaultValues,
render: ({ form }) => {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
return (
<>
<form.AppField name="school">{(field) => <field.TextField label={<Trans>School</Trans>} />}</form.AppField>
<form.AppField name="area">{(field) => <field.TextField label={<Trans>Area of Study</Trans>} />}</form.AppField>
<form.AppField name="degree">{(field) => <field.TextField label={<Trans>Degree</Trans>} />}</form.AppField>
<form.AppField name="grade">{(field) => <field.TextField label={<Trans>Grade</Trans>} />}</form.AppField>
<form.AppField name="location">{(field) => <field.TextField label={<Trans>Location</Trans>} />}</form.AppField>
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -0,0 +1,382 @@
import type { RoleItem } from "@reactive-resume/schema/resume/data";
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon, RowsIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { AnimatePresence, Reorder, useDragControls } from "motion/react";
import { experienceItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { Switch } from "@reactive-resume/ui/components/switch";
import { generateId } from "@reactive-resume/utils/string";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = experienceItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
company: "",
position: "",
location: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
roles: [] as RoleItem[],
};
export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.experience.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "experience", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new experience</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<ExperienceForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.experience.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "experience", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing experience</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<ExperienceForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const ExperienceForm = withForm({
defaultValues,
render: ({ form }) => {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
const roles = useStore(form.store, (s) => s.values.roles);
const hasRoles = roles.length > 0;
const handleReorderRoles = (newOrder: RoleItem[]) => {
form.setFieldValue("roles", newOrder);
};
const RoleFields = ({ role, index, onRemove }: { role: RoleItem; index: number; onRemove: () => void }) => {
const controls = useDragControls();
return (
<Reorder.Item
value={role}
dragListener={false}
dragControls={controls}
initial={{ opacity: 1, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="relative grid rounded-md border sm:col-span-full sm:grid-cols-2"
>
<div className="col-span-full flex items-center justify-between rounded-t bg-border/30 px-2 py-1.5">
<Button
size="sm"
variant="ghost"
className="cursor-grab touch-none"
onPointerDown={(e) => {
e.preventDefault();
controls.start(e);
}}
>
<RowsIcon />
<Trans>Reorder</Trans>
</Button>
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive" onClick={onRemove}>
<TrashSimpleIcon />
<Trans>Remove</Trans>
</Button>
</div>
<div className="grid gap-4 p-4 sm:col-span-full sm:grid-cols-2">
<form.Field name={`roles[${index}].position`}>
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Position</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name={`roles[${index}].period`}>
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Period</Trans>
</FormLabel>
<FormControl
render={
<Input
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name={`roles[${index}].description`}>
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl
render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</div>
</Reorder.Item>
);
};
return (
<>
<form.AppField name="company">{(field) => <field.TextField label={<Trans>Company</Trans>} />}</form.AppField>
<form.AppField name="location">{(field) => <field.TextField label={<Trans>Location</Trans>} />}</form.AppField>
<form.AppField name="position">{(field) => <field.TextField label={<Trans>Position</Trans>} />}</form.AppField>
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel>
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
{/* Role Progression */}
<div className="flex items-center justify-between sm:col-span-full">
<div className="space-y-1">
<p className="font-medium text-foreground">
<Trans>Role Progression</Trans>
</p>
<p className="text-muted-foreground text-xs">
<Trans>Add multiple roles to show career progression at the same company.</Trans>
</p>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0"
onClick={() => {
form.pushFieldValue("roles", {
id: generateId(),
position: "",
period: "",
description: "",
});
}}
>
<PlusIcon />
<Trans>Add Role</Trans>
</Button>
</div>
{hasRoles && (
<form.Field name="roles" mode="array">
{(rolesField) => (
<Reorder.Group
axis="y"
values={rolesField.state.value}
onReorder={handleReorderRoles}
className="flex flex-col gap-4 sm:col-span-full"
>
<AnimatePresence>
{rolesField.state.value.map((role: RoleItem, index: number) => (
<RoleFields
key={role.id}
role={role}
index={index}
onRemove={() => {
rolesField.removeValue(index);
}}
/>
))}
</AnimatePresence>
</Reorder.Group>
)}
</form.Field>
)}
{/* Single Role Description — only show when no roles are defined */}
{!hasRoles && (
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
)}
</>
);
},
});
@@ -0,0 +1,249 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { interestItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { cn } from "@reactive-resume/utils/style";
import { ChipInput } from "@/components/input/chip-input";
import { ColorPicker } from "@/components/input/color-picker";
import { IconPicker } from "@/components/input/icon-picker";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = interestItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
icon: "acorn",
iconColor: "",
name: "",
keywords: [],
};
export function CreateInterestDialog({ data }: DialogProps<"resume.sections.interests.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "interests", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new interest</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<InterestForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.interests.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "interests", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing interest</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<InterestForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const InterestForm = withForm({
defaultValues,
render: ({ form }) => {
const nameMeta = useStore(form.store, (s) => s.fieldMeta?.name);
const isNameInvalid = (nameMeta?.isTouched ?? false) && (nameMeta?.errors?.length ?? 0) > 0;
return (
<>
<div className={cn("col-span-full flex items-end", isNameInvalid && "items-center")}>
<form.Field name="icon">
{(field) => (
<FormItem className="shrink-0">
<FormControl
render={
<IconPicker
value={field.state.value}
onChange={(icon: string) => {
field.handleChange(icon);
}}
popoverProps={{ modal: true }}
className="rounded-r-none border-input border-e-0"
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name="name">
{(field) => (
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl
render={
<Input
className="rounded-s-none rounded-e-none"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="iconColor">
{(field) => (
<FormItem className="shrink-0">
<FormControl
render={
<ColorPicker
value={field.state.value}
onChange={(v: string) => {
field.handleChange(v);
}}
trigger={
<PopoverTrigger className="h-9 rounded-e border-input border-y border-e px-2">
<div
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
style={{ backgroundColor: field.state.value ?? "currentColor" }}
/>
</PopoverTrigger>
}
/>
}
/>
</FormItem>
)}
</form.Field>
</div>
<form.Field name="keywords">
{(field) => (
<FormItem
className="col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Keywords</Trans>
</FormLabel>
<FormControl
render={
<ChipInput
value={field.state.value}
onChange={(v: string[]) => {
field.handleChange(v);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -0,0 +1,182 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { languageItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Slider } from "@reactive-resume/ui/components/slider";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = languageItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
language: "",
fluency: "",
level: 0,
};
export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.languages.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "languages", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new language</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<LanguageForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.languages.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "languages", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing language</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<LanguageForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const LanguageForm = withForm({
defaultValues,
render: ({ form }) => {
return (
<>
<form.AppField name="language">{(field) => <field.TextField label={<Trans>Language</Trans>} />}</form.AppField>
<form.AppField name="fluency">{(field) => <field.TextField label={<Trans>Fluency</Trans>} />}</form.AppField>
<form.Field name="level">
{(field) => (
<FormItem
className="gap-4 sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Level</Trans>
</FormLabel>
<FormControl
render={
<Slider
min={0}
max={5}
step={1}
value={[field.state.value]}
onValueChange={(value) => {
field.handleChange(Array.isArray(value) ? value[0] : value);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
<FormDescription>
{Number(field.state.value) === 0 ? t`Hidden` : `${field.state.value} / 5`}
</FormDescription>
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -0,0 +1,302 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { AtIcon, PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { profileItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from "@reactive-resume/ui/components/input-group";
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Switch } from "@reactive-resume/ui/components/switch";
import { cn } from "@reactive-resume/utils/style";
import { ColorPicker } from "@/components/input/color-picker";
import { IconPicker } from "@/components/input/icon-picker";
import { URLInput } from "@/components/input/url-input";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = profileItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
icon: "acorn",
iconColor: "",
network: "",
username: "",
website: { url: "", label: "", inlineLink: false },
};
export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profiles.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "profiles", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new profile</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<ProfileForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profiles.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "profiles", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing profile</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<ProfileForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const ProfileForm = withForm({
defaultValues,
render: ({ form }) => {
const networkMeta = useStore(form.store, (s) => s.fieldMeta?.network);
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
const isNetworkInvalid = (networkMeta?.isTouched ?? false) && (networkMeta?.errors?.length ?? 0) > 0;
return (
<>
<div className={cn("flex items-end", isNetworkInvalid && "items-center")}>
<form.Field name="icon">
{(field) => (
<FormItem className="shrink-0">
<FormControl
render={
<IconPicker
value={field.state.value}
onChange={(icon: string) => {
field.handleChange(icon);
}}
popoverProps={{ modal: true }}
className="rounded-r-none border-input border-e-0"
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name="network">
{(field) => (
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Network</Trans>
</FormLabel>
<FormControl
render={
<Input
className="rounded-s-none rounded-e-none"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="iconColor">
{(field) => (
<FormItem className="shrink-0">
<FormControl
render={
<ColorPicker
value={field.state.value}
onChange={(v: string) => {
field.handleChange(v);
}}
trigger={
<PopoverTrigger className="h-9 rounded-e border-input border-y border-e px-2">
<div
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
style={{ backgroundColor: field.state.value ?? "currentColor" }}
/>
</PopoverTrigger>
}
/>
}
/>
</FormItem>
)}
</form.Field>
</div>
<form.Field name="username">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Username</Trans>
</FormLabel>
<InputGroup>
<InputGroupAddon align="inline-start">
<InputGroupText>
<AtIcon />
</InputGroupText>
</InputGroupAddon>
<FormControl
render={
<InputGroupInput
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
</InputGroup>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="website">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -0,0 +1,210 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { projectItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = projectItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
name: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
};
export function CreateProjectDialog({ data }: DialogProps<"resume.sections.projects.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "projects", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new project</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<ProjectForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateProjectDialog({ data }: DialogProps<"resume.sections.projects.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "projects", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing project</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<ProjectForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const ProjectForm = withForm({
defaultValues,
render: ({ form }) => {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
return (
<>
<form.AppField name="name">{(field) => <field.TextField label={<Trans>Name</Trans>} />}</form.AppField>
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -0,0 +1,212 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { publicationItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = publicationItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
title: "",
publisher: "",
date: "",
website: { url: "", label: "", inlineLink: false },
description: "",
};
export function CreatePublicationDialog({ data }: DialogProps<"resume.sections.publications.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "publications", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new publication</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<PublicationForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdatePublicationDialog({ data }: DialogProps<"resume.sections.publications.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "publications", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing publication</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<PublicationForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const PublicationForm = withForm({
defaultValues,
render: ({ form }) => {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
return (
<>
<form.AppField name="title">{(field) => <field.TextField label={<Trans>Title</Trans>} />}</form.AppField>
<form.AppField name="publisher">
{(field) => <field.TextField label={<Trans>Publisher</Trans>} />}
</form.AppField>
<form.AppField name="date">{(field) => <field.TextField label={<Trans>Date</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -0,0 +1,210 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { referenceItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = referenceItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
name: "",
position: "",
website: { url: "", label: "", inlineLink: false },
phone: "",
description: "",
};
export function CreateReferenceDialog({ data }: DialogProps<"resume.sections.references.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "references", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new reference</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<ReferenceForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateReferenceDialog({ data }: DialogProps<"resume.sections.references.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "references", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing reference</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<ReferenceForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const ReferenceForm = withForm({
defaultValues,
render: ({ form }) => {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
return (
<>
<form.AppField name="name">{(field) => <field.TextField label={<Trans>Name</Trans>} />}</form.AppField>
<form.AppField name="position">{(field) => <field.TextField label={<Trans>Position</Trans>} />}</form.AppField>
<form.AppField name="phone">{(field) => <field.TextField label={<Trans>Phone</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -0,0 +1,287 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { skillItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Slider } from "@reactive-resume/ui/components/slider";
import { cn } from "@reactive-resume/utils/style";
import { ChipInput } from "@/components/input/chip-input";
import { ColorPicker } from "@/components/input/color-picker";
import { IconPicker } from "@/components/input/icon-picker";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = skillItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
icon: "acorn",
iconColor: "",
name: "",
proficiency: "",
level: 0,
keywords: [],
};
export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "skills", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new skill</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<SkillForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "skills", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing skill</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<SkillForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const SkillForm = withForm({
defaultValues,
render: ({ form }) => {
const nameMeta = useStore(form.store, (s) => s.fieldMeta?.name);
const isNameInvalid = (nameMeta?.isTouched ?? false) && (nameMeta?.errors?.length ?? 0) > 0;
return (
<>
<div className={cn("flex items-end", isNameInvalid && "items-center")}>
<form.Field name="icon">
{(field) => (
<FormItem className="shrink-0">
<FormControl
render={
<IconPicker
value={field.state.value}
onChange={(icon: string) => {
field.handleChange(icon);
}}
popoverProps={{ modal: true }}
className="rounded-r-none border-input border-e-0"
/>
}
/>
</FormItem>
)}
</form.Field>
<form.Field name="name">
{(field) => (
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl
render={
<Input
className="rounded-s-none rounded-e-none"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="iconColor">
{(field) => (
<FormItem className="shrink-0">
<FormControl
render={
<ColorPicker
value={field.state.value}
onChange={(v: string) => {
field.handleChange(v);
}}
trigger={
<PopoverTrigger className="h-9 rounded-e border-input border-y border-e px-2">
<div
className="size-4 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
style={{ backgroundColor: field.state.value ?? "currentColor" }}
/>
</PopoverTrigger>
}
/>
}
/>
</FormItem>
)}
</form.Field>
</div>
<form.AppField name="proficiency">
{(field) => <field.TextField label={<Trans>Proficiency</Trans>} />}
</form.AppField>
<form.Field name="level">
{(field) => (
<FormItem
className="gap-4 sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Level</Trans>
</FormLabel>
<FormControl
render={
<Slider
min={0}
max={5}
step={1}
value={[field.state.value]}
onValueChange={(value) => {
field.handleChange(Array.isArray(value) ? value[0] : value);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
<FormDescription>
{Number(field.state.value) === 0 ? t`Hidden` : `${field.state.value} / 5`}
</FormDescription>
</FormItem>
)}
</form.Field>
<form.Field name="keywords">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Keywords</Trans>
</FormLabel>
<FormControl
render={
<ChipInput
value={field.state.value}
onChange={(v: string[]) => {
field.handleChange(v);
}}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -0,0 +1,162 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { summaryItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { RichInput } from "@/components/input/rich-input";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = summaryItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
content: "",
};
export function CreateSummaryItemDialog({ data }: DialogProps<"resume.sections.summary.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(value);
}
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new summary item</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<SummaryItemForm form={form} />
<DialogFooter>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateSummaryItemDialog({ data }: DialogProps<"resume.sections.summary.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeStore = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeStore((draft) => {
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === value.id);
if (index !== -1) section.items[index] = value;
}
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing summary item</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<SummaryItemForm form={form} />
<DialogFooter>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const SummaryItemForm = withForm({
defaultValues,
render: ({ form }) => {
return (
<form.Field name="content">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
);
},
});
@@ -0,0 +1,212 @@
import type z from "zod";
import type { DialogProps } from "@/dialogs/store";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { volunteerItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
const formSchema = volunteerItemSchema;
type FormValues = z.infer<typeof formSchema>;
const defaultValues: FormValues = {
id: "",
hidden: false,
organization: "",
location: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
};
export function CreateVolunteerDialog({ data }: DialogProps<"resume.sections.volunteer.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: makeSectionItem(defaultValues, data?.item),
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
createSectionItem(draft, "volunteer", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new volunteer experience</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<VolunteerForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
export function UpdateVolunteerDialog({ data }: DialogProps<"resume.sections.volunteer.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useUpdateResumeData();
const form = useAppForm({
defaultValues: data.item,
validators: { onSubmit: formSchema },
onSubmit: async ({ value }) => {
updateResumeData((draft) => {
updateSectionItem(draft, "volunteer", value, data?.customSectionId);
});
closeDialog();
},
});
const { requestClose } = useFormBlocker(form);
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing volunteer experience</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<VolunteerForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
);
}
const VolunteerForm = withForm({
defaultValues,
render: ({ form }) => {
const inlineLink = useStore(form.store, (s) => s.values.website.inlineLink);
return (
<>
<form.AppField name="organization">
{(field) => <field.TextField label={<Trans>Organization</Trans>} />}
</form.AppField>
<form.AppField name="location">{(field) => <field.TextField label={<Trans>Location</Trans>} />}</form.AppField>
<form.AppField name="period">{(field) => <field.TextField label={<Trans>Period</Trans>} />}</form.AppField>
<form.Field name="website">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<URLInput
value={field.state.value}
onChange={(v) => field.handleChange(v)}
hideLabelButton={inlineLink}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field name="website.inlineLink">
{(field) => (
<FormItem className="flex items-center gap-x-2">
<FormControl
render={
<Switch
checked={field.state.value}
onCheckedChange={(checked: boolean) => {
field.handleChange(checked);
}}
/>
}
/>
<FormLabel className="mt-0!">
<Trans>Show link in title</Trans>
</FormLabel>
</FormItem>
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormItem
className="sm:col-span-full"
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl render={<RichInput value={field.state.value} onChange={(v) => field.handleChange(v)} />} />
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
</>
);
},
});
@@ -0,0 +1,112 @@
import type { MessageDescriptor } from "@lingui/core";
import type { Template } from "@reactive-resume/schema/templates";
import { msg } from "@lingui/core/macro";
export type TemplateMetadata = {
name: string;
description: MessageDescriptor;
imageUrl: string;
tags: string[];
sidebarPosition: "left" | "right" | "none";
};
export const templates = {
azurill: {
name: "Azurill",
description: msg`Two-column with a bold colored sidebar and skill bars; great for creative or tech roles where visual flair is welcome.`,
imageUrl: "/templates/jpg/azurill.jpg",
tags: ["Two-column", "Creative", "Tech", "Visual flair"],
sidebarPosition: "left",
},
bronzor: {
name: "Bronzor",
description: msg`Two-column, clean and professional with subtle section dividers; suits corporate, finance, or consulting positions.`,
imageUrl: "/templates/jpg/bronzor.jpg",
tags: ["Two-column", "Clean", "Professional", "Corporate", "Finance", "Consulting"],
sidebarPosition: "none",
},
chikorita: {
name: "Chikorita",
description: msg`Two-column with a soft header accent and circular profile photo; ideal for marketing, HR, or client-facing roles.`,
imageUrl: "/templates/jpg/chikorita.jpg",
tags: ["Two-column", "Soft accent", "Marketing", "HR", "Client-facing"],
sidebarPosition: "right",
},
ditgar: {
name: "Ditgar",
description: msg`Two-column with a dark teal sidebar and skills grid; modern feel for developers, data scientists, or technical PMs.`,
imageUrl: "/templates/jpg/ditgar.jpg",
tags: ["Two-column", "Modern", "Developer", "Data science", "Technical PM", "Dark sidebar"],
sidebarPosition: "left",
},
ditto: {
name: "Ditto",
description: msg`Two-column, minimal and text-dense with no decorative elements; perfect for traditional industries or ATS-heavy applications.`,
imageUrl: "/templates/jpg/ditto.jpg",
tags: ["Two-column", "ATS friendly", "Minimal", "Text-dense", "Traditional", "No decoration"],
sidebarPosition: "left",
},
gengar: {
name: "Gengar",
description: msg`Two-column with accent colors and clean typography; balanced choice for business analysts or operations roles.`,
imageUrl: "/templates/jpg/gengar.jpg",
tags: ["Two-column", "Accent colors", "Clean typography", "Business analyst", "Operations"],
sidebarPosition: "left",
},
glalie: {
name: "Glalie",
description: msg`Two-column, minimal with light gray sidebar and subtle icons; professional and understated for legal, finance, or executive roles.`,
imageUrl: "/templates/jpg/glalie.jpg",
tags: ["Two-column", "Minimal", "Professional", "Legal", "Finance", "Executive", "Understated"],
sidebarPosition: "left",
},
kakuna: {
name: "Kakuna",
description: msg`Single-column with a magenta left border accent; compact and efficient for entry-level or internship applications.`,
imageUrl: "/templates/jpg/kakuna.jpg",
tags: ["Single-column", "ATS friendly", "Compact", "Efficient", "Entry level", "Internship", "Magenta accent"],
sidebarPosition: "none",
},
lapras: {
name: "Lapras",
description: msg`Single-column; polished and serious for senior or enterprise-level positions.`,
imageUrl: "/templates/jpg/lapras.jpg",
tags: ["Single-column", "ATS friendly", "Polished", "Senior", "Enterprise"],
sidebarPosition: "none",
},
leafish: {
name: "Leafish",
description: msg`Two-column with a muted color sidebar; earthy and calm, suits sustainability, healthcare, or nonprofit sectors.`,
imageUrl: "/templates/jpg/leafish.jpg",
tags: ["Two-column", "Muted sidebar", "Earthy", "Calm", "Sustainability", "Healthcare", "Nonprofit"],
sidebarPosition: "right",
},
meowth: {
name: "Meowth",
description: msg`Single-column with an inline three-column entry header (position · organization · period); compact and ATS-friendly, well-suited for Asian resume conventions (CN/JP/KR).`,
imageUrl: "/templates/jpg/meowth.jpg",
tags: ["Single-column", "ATS friendly", "Inline header", "Compact", "Asian style", "CN/JP/KR"],
sidebarPosition: "none",
},
onyx: {
name: "Onyx",
description: msg`Single-column with a sidebar and clean grid layout; versatile for any professional or technical role.`,
imageUrl: "/templates/jpg/onyx.jpg",
tags: ["Single-column", "ATS friendly", "Sidebar", "Grid layout", "Versatile", "Professional", "Technical"],
sidebarPosition: "none",
},
pikachu: {
name: "Pikachu",
description: msg`Two-column with a left margin color; simple and approachable for creative, editorial, or junior roles.`,
imageUrl: "/templates/jpg/pikachu.jpg",
tags: ["Two-column", "Simple", "Creative", "Editorial", "Junior", "Accent colors"],
sidebarPosition: "left",
},
rhyhorn: {
name: "Rhyhorn",
description: msg`Single-column with a minimal top header and lots of whitespace; clean and modern for designers or content creators.`,
imageUrl: "/templates/jpg/rhyhorn.jpg",
tags: ["Single-column", "ATS friendly", "Minimal", "Clean", "Modern", "Designer", "Content creator", "Whitespace"],
sidebarPosition: "none",
},
} as const satisfies Record<Template, TemplateMetadata>;
@@ -0,0 +1,124 @@
import type { Template } from "@reactive-resume/schema/templates";
import type { DialogProps } from "@/dialogs/store";
import type { TemplateMetadata } from "./data";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { SlideshowIcon } from "@phosphor-icons/react";
import { Badge } from "@reactive-resume/ui/components/badge";
import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@reactive-resume/ui/components/dialog";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/ui/components/hover-card";
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
import { cn } from "@reactive-resume/utils/style";
import { CometCard } from "@/components/animation/comet-card";
import { useResume, useUpdateResumeData } from "@/components/resume/use-resume";
import { useDialogStore } from "@/dialogs/store";
import { templates } from "./data";
export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const resume = useResume();
const selectedTemplate = resume?.data.metadata.template;
const updateResumeData = useUpdateResumeData();
function onSelectTemplate(template: Template) {
updateResumeData((draft) => {
draft.metadata.template = template;
});
closeDialog();
}
return (
<DialogContent className="lg:max-w-5xl">
<DialogHeader className="gap-2">
<DialogTitle className="flex items-center gap-3 text-xl">
<SlideshowIcon size={20} />
<Trans>Template Gallery</Trans>
</DialogTitle>
<DialogDescription className="leading-relaxed">
<Trans>
Here's a range of resume templates for different professions and personalities. Whether you prefer modern or
classic, bold or simple, there is a design to match you. Look through the options below and choose a
template that fits your style.
</Trans>
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[80svh] pb-8">
<div className="grid grid-cols-2 gap-6 p-4 md:grid-cols-3 lg:grid-cols-4">
{Object.entries(templates).map(([template, metadata]) => (
<TemplateCard
key={template}
metadata={metadata}
id={template as Template}
isActive={template === selectedTemplate}
onSelect={onSelectTemplate}
/>
))}
</div>
</ScrollArea>
</DialogContent>
);
}
type TemplateCardProps = {
id: Template;
isActive?: boolean;
metadata: TemplateMetadata;
onSelect: (template: Template) => void;
};
function TemplateCard({ id, metadata, isActive, onSelect }: TemplateCardProps) {
const { i18n } = useLingui();
return (
<HoverCard>
<CometCard translateDepth={3} rotateDepth={6} glareOpacity={0}>
<HoverCardTrigger
render={
<button
type="button"
tabIndex={-1}
onClick={() => onSelect(id)}
className={cn(
"relative block aspect-page size-full cursor-pointer overflow-hidden rounded-md bg-popover outline-none",
isActive && "ring-2 ring-ring ring-offset-4 ring-offset-background",
)}
>
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
</button>
}
/>
<div className="flex items-center justify-center">
<span className="font-bold leading-loose tracking-tight">{metadata.name}</span>
</div>
<HoverCardContent
side="right"
sideOffset={-32}
align="start"
alignOffset={32}
className="pointer-events-none! flex w-80 flex-col justify-between space-y-6 rounded-md bg-background/80 p-4 pb-6"
>
<div className="space-y-1">
<h3 className="font-semibold text-lg">{metadata.name}</h3>
<p className="text-muted-foreground">{i18n.t(metadata.description)}</p>
</div>
{metadata.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{metadata.tags
.sort((a, b) => a.localeCompare(b))
.map((tag) => (
<Badge key={tag} variant="default">
{tag}
</Badge>
))}
</div>
)}
</HoverCardContent>
</CometCard>
</HoverCard>
);
}