initial commit of v5

This commit is contained in:
Amruth Pillai
2026-01-19 23:31:54 +01:00
parent 55bdfd0067
commit cad390fa13
1132 changed files with 200807 additions and 165288 deletions
+297
View File
@@ -0,0 +1,297 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { useForm, useWatch } from "react-hook-form";
import { toast } from "sonner";
import z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { Badge } from "@/components/ui/badge";
import { Combobox } from "@/components/ui/combobox";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { useAIStore } from "@/integrations/ai/store";
import { JSONResumeImporter } from "@/integrations/import/json-resume";
import { ReactiveResumeJSONImporter } from "@/integrations/import/reactive-resume-json";
import { ReactiveResumeV4JSONImporter } from "@/integrations/import/reactive-resume-v4-json";
import { client, orpc } from "@/integrations/orpc/client";
import type { ResumeData } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { type DialogProps, useDialogStore } from "../store";
const formSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("") }),
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>;
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const { enabled: isAIEnabled, provider, model, apiKey, baseURL } = useAIStore();
const closeDialog = useDialogStore((state) => state.closeDialog);
const inputRef = useRef<HTMLInputElement>(null);
const prevTypeRef = useRef<string>("");
const { mutateAsync: importResume, isPending } = useMutation(orpc.resume.import.mutationOptions());
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
type: "",
},
});
const type = useWatch({ control: form.control, name: "type" });
useEffect(() => {
if (prevTypeRef.current === type) return;
prevTypeRef.current = type;
form.resetField("file");
}, [form.resetField, 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.setValue("file", file, { shouldDirty: true });
};
const onSubmit = async (values: FormValues) => {
if (values.type === "") return;
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 (values.type === "json-resume-json") {
const json = await values.file.text();
const importer = new JSONResumeImporter();
data = importer.parse(json);
}
if (values.type === "reactive-resume-json") {
const json = await values.file.text();
const importer = new ReactiveResumeJSONImporter();
data = importer.parse(json);
}
if (values.type === "reactive-resume-v4-json") {
const json = await values.file.text();
const importer = new ReactiveResumeV4JSONImporter();
data = importer.parse(json);
}
if (values.type === "pdf") {
if (!isAIEnabled) {
toast.error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`, {
id: toastId,
description: null,
});
return;
}
const arrayBuffer = await values.file.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
data = await client.ai.parsePdf({
provider,
model,
apiKey,
baseURL,
file: { name: values.file.name, data: base64 },
});
}
if (values.type === "docx") {
if (!isAIEnabled) {
toast.error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`, {
id: toastId,
description: null,
});
return;
}
const arrayBuffer = await values.file.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
const mediaType =
values.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: values.file.name, data: base64 },
});
}
if (!data) return;
await importResume({ data });
toast.success(t`Your resume has been imported successfully.`, { id: toastId, description: null });
closeDialog();
} catch (error: unknown) {
if (error instanceof Error) {
toast.error(error.message, { id: toastId, description: null });
} else {
toast.error(t`An unknown error occurred while importing your resume.`, { id: toastId, description: null });
}
}
};
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 {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Type</Trans>
</FormLabel>
<FormControl>
<Combobox
clearable={false}
value={field.value}
onValueChange={field.onChange}
options={[
{ value: "reactive-resume-json", label: "Reactive Resume (JSON)" },
{ value: "reactive-resume-v4-json", label: "Reactive Resume v4 (JSON)" },
{ value: "json-resume-json", label: "JSON Resume" },
{
value: "pdf",
label: (
<div className="flex items-center gap-x-2">
PDF <Badge>{t`AI`}</Badge>
</div>
),
},
{
value: "docx",
label: (
<div className="flex items-center gap-x-2">
Microsoft Word <Badge>{t`AI`}</Badge>
</div>
),
},
]}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
key={type}
control={form.control}
name="file"
render={({ field }) => (
<FormItem className={cn(!type && "hidden")}>
<FormControl>
<div>
<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.value ? (
<>
<FileIcon weight="thin" size={32} />
<p>{field.value.name}</p>
</>
) : (
<>
<UploadSimpleIcon weight="thin" size={32} />
<Trans>Click here to select a file to import</Trans>
</>
)}
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit" disabled={!type || isPending}>
{isPending ? <Spinner /> : null}
{isPending ? t`Importing...` : t`Import`}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
+361
View File
@@ -0,0 +1,361 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { CaretDownIcon, MagicWandIcon, PencilSimpleLineIcon, PlusIcon, TestTubeIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useEffect, useMemo } from "react";
import { useForm, useFormContext, useWatch } from "react-hook-form";
import { toast } from "sonner";
import z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/animate-ui/components/radix/dropdown-menu";
import { ChipInput } from "@/components/input/chip-input";
import { ButtonGroup } from "@/components/ui/button-group";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { authClient } from "@/integrations/auth/client";
import { orpc, type RouterInput } from "@/integrations/orpc/client";
import { generateId, generateRandomName, slugify } from "@/utils/string";
import { type DialogProps, 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>;
export function CreateResumeDialog(_: DialogProps<"resume.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const { mutate: createResume, isPending } = useMutation(orpc.resume.create.mutationOptions());
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
name: "",
slug: "",
tags: [],
},
});
const name = useWatch({ control: form.control, name: "name" });
useEffect(() => {
form.setValue("slug", slugify(name), { shouldDirty: true });
}, [form, name]);
const onSubmit = (data: FormValues) => {
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(error.message, { id: toastId });
},
});
};
const onCreateSampleResume = () => {
const values = form.getValues();
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(error.message, { 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 {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<ResumeForm />
<DialogFooter>
<ButtonGroup aria-label="Create Resume with Options" className="gap-x-px">
<Button type="submit" disabled={isPending}>
<Trans>Create</Trans>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" disabled={isPending}>
<CaretDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={onCreateSampleResume}>
<TestTubeIcon />
<Trans>Create a Sample Resume</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdateResumeDialog({ data }: DialogProps<"resume.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const { mutate: updateResume, isPending } = useMutation(orpc.resume.update.mutationOptions());
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
name: data.name,
slug: data.slug,
tags: data.tags,
},
});
const name = useWatch({ control: form.control, name: "name" });
useEffect(() => {
if (!name) return;
form.setValue("slug", slugify(name), { shouldDirty: true });
}, [form, name]);
const onSubmit = (data: FormValues) => {
const toastId = toast.loading(t`Updating your resume...`);
updateResume(data, {
onSuccess: () => {
toast.success(t`Your resume has been updated successfully.`, { id: toastId });
closeDialog();
},
onError: (error) => {
toast.error(error.message, { id: toastId });
},
});
};
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 {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<ResumeForm />
<DialogFooter>
<Button type="submit" disabled={isPending}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</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 = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
name: `${data.name} (Copy)`,
slug: `${data.slug}-copy`,
tags: data.tags,
},
});
const name = useWatch({ control: form.control, name: "name" });
useEffect(() => {
if (!name) return;
form.setValue("slug", slugify(name), { shouldDirty: true });
}, [form, name]);
const onSubmit = (values: FormValues) => {
const toastId = toast.loading(t`Duplicating your resume...`);
duplicateResume(values, {
onSuccess: async (id) => {
toast.success(t`Your resume has been duplicated successfully.`, { id: toastId });
closeDialog();
if (data.shouldRedirect) {
navigate({ to: `/builder/$resumeId`, params: { resumeId: id } });
}
},
onError: (error) => {
toast.error(error.message, { id: toastId });
},
});
};
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 {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<ResumeForm />
<DialogFooter>
<Button type="submit" disabled={isPending}>
<Trans>Duplicate</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function ResumeForm() {
const form = useFormContext<FormValues>();
const { data: session } = authClient.useSession();
const slugPrefix = useMemo(() => {
return `${window.location.origin}/${session?.user.username ?? ""}/`;
}, [session]);
const onGenerateName = () => {
form.setValue("name", generateRandomName(), { shouldDirty: true });
};
return (
<>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<div className="flex items-center gap-x-2">
<FormControl>
<Input min={1} max={64} {...field} />
</FormControl>
<Button size="icon" variant="outline" title={t`Generate a random name`} onClick={onGenerateName}>
<MagicWandIcon />
</Button>
</div>
<FormMessage />
<FormDescription>
<Trans>Tip: You can name the resume referring to the position you are applying for.</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Slug</Trans>
</FormLabel>
<FormControl>
<InputGroup>
<InputGroupAddon align="inline-start" className="hidden sm:flex">
<InputGroupText>{slugPrefix}</InputGroupText>
</InputGroupAddon>
<InputGroupInput min={1} max={64} className="pl-0!" {...field} />
</InputGroup>
</FormControl>
<FormMessage />
<FormDescription>
<Trans>This is a URL-friendly name for your resume.</Trans>
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Tags</Trans>
</FormLabel>
<FormControl>
<ChipInput {...field} />
</FormControl>
<FormMessage />
<FormDescription>
<Trans>Tags can be used to categorize your resume by keywords.</Trans>
</FormDescription>
</FormItem>
)}
/>
</>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useForm, useFormContext } from "react-hook-form";
import type z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { awardItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
const formSchema = awardItemSchema;
type FormValues = z.infer<typeof formSchema>;
export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
title: data?.title ?? "",
awarder: data?.awarder ?? "",
date: data?.date ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
},
});
const onSubmit = (data: FormValues) => {
updateResumeData((draft) => {
draft.sections.awards.items.push(data);
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new award</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<AwardForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
title: data.title,
awarder: data.awarder,
date: data.date,
website: data.website,
description: data.description,
},
});
const onSubmit = (data: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.awards.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.awards.items[index] = data;
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing award</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<AwardForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function AwardForm() {
const form = useFormContext<FormValues>();
return (
<>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Title</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="awarder"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans context="(noun) person, organization, or entity that gives an award">Awarder</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="date"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
@@ -0,0 +1,222 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useForm, useFormContext } from "react-hook-form";
import type z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { certificationItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
const formSchema = certificationItemSchema;
type FormValues = z.infer<typeof formSchema>;
export function CreateCertificationDialog({ data }: DialogProps<"resume.sections.certifications.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
title: data?.title ?? "",
issuer: data?.issuer ?? "",
date: data?.date ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
updateResumeData((draft) => {
draft.sections.certifications.items.push(values);
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new certification</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<CertificationForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections.certifications.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
title: data.title,
issuer: data.issuer,
date: data.date,
website: data.website,
description: data.description,
},
});
const onSubmit = (values: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.certifications.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.certifications.items[index] = values;
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing certification</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<CertificationForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function CertificationForm() {
const form = useFormContext<FormValues>();
return (
<>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Title</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="issuer"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Issuer</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="date"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
+173
View File
@@ -0,0 +1,173 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useForm, useFormContext } from "react-hook-form";
import type z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { RichInput } from "@/components/input/rich-input";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { customSectionSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
const formSchema = customSectionSchema;
type FormValues = z.infer<typeof formSchema>;
export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections.custom.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
title: data?.title ?? "",
columns: data?.columns ?? 1,
hidden: data?.hidden ?? false,
content: data?.content ?? "",
},
});
const onSubmit = (data: FormValues) => {
updateResumeData((draft) => {
draft.customSections.push(data);
const lastPageIndex = draft.metadata.layout.pages.length - 1;
if (lastPageIndex < 0) return;
draft.metadata.layout.pages[lastPageIndex].main.push(data.id);
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new custom section</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<CustomSectionForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections.custom.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
title: data.title,
columns: data.columns,
hidden: data.hidden,
content: data.content,
},
});
const onSubmit = (data: FormValues) => {
updateResumeData((draft) => {
const index = draft.customSections.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.customSections[index] = data;
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing custom section</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<CustomSectionForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function CustomSectionForm() {
const form = useFormContext<FormValues>();
return (
<>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Title</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Content</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
+276
View File
@@ -0,0 +1,276 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useForm, useFormContext } from "react-hook-form";
import type z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { educationItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
const formSchema = educationItemSchema;
type FormValues = z.infer<typeof formSchema>;
export function CreateEducationDialog({ data }: DialogProps<"resume.sections.education.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
school: data?.school ?? "",
degree: data?.degree ?? "",
area: data?.area ?? "",
grade: data?.grade ?? "",
location: data?.location ?? "",
period: data?.period ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
},
});
const onSubmit = (data: FormValues) => {
updateResumeData((draft) => {
draft.sections.education.items.push(data);
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new education</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<EducationForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.education.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
school: data.school,
degree: data.degree,
area: data.area,
grade: data.grade,
location: data.location,
period: data.period,
website: data.website,
description: data.description,
},
});
const onSubmit = (data: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.education.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.education.items[index] = data;
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing education</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<EducationForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function EducationForm() {
const form = useFormContext<FormValues>();
return (
<>
<FormField
control={form.control}
name="school"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>School</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="degree"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Degree</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="area"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Area of Study</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="grade"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Grade</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Location</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="period"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Period</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
+240
View File
@@ -0,0 +1,240 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useForm, useFormContext } from "react-hook-form";
import type z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { experienceItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
const formSchema = experienceItemSchema;
type FormValues = z.infer<typeof formSchema>;
export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.experience.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
company: data?.company ?? "",
position: data?.position ?? "",
location: data?.location ?? "",
period: data?.period ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
},
});
const onSubmit = (data: FormValues) => {
updateResumeData((draft) => {
draft.sections.experience.items.push(data);
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new experience</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<ExperienceForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.experience.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
company: data.company,
position: data.position,
location: data.location,
period: data.period,
website: data.website,
description: data.description,
},
});
const onSubmit = (data: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.experience.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.experience.items[index] = data;
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing experience</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<ExperienceForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function ExperienceForm() {
const form = useFormContext<FormValues>();
return (
<>
<FormField
control={form.control}
name="company"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Company</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="position"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Position</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Location</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="period"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Period</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
+191
View File
@@ -0,0 +1,191 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { useForm, useFormContext, useFormState } from "react-hook-form";
import type z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { ChipInput } from "@/components/input/chip-input";
import { IconPicker } from "@/components/input/icon-picker";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { interestItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
import { cn } from "@/utils/style";
const formSchema = interestItemSchema;
type FormValues = z.infer<typeof formSchema>;
export function CreateInterestDialog({ data }: DialogProps<"resume.sections.interests.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
icon: data?.icon ?? "acorn",
name: data?.name ?? "",
keywords: data?.keywords ?? [],
},
});
const onSubmit = (values: FormValues) => {
updateResumeData((draft) => {
draft.sections.interests.items.push(values);
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new interest</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<InterestForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.interests.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
icon: data.icon,
name: data.name,
keywords: data.keywords,
},
});
const onSubmit = (values: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.interests.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.interests.items[index] = values;
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing interest</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<InterestForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function InterestForm() {
const form = useFormContext<FormValues>();
const nameState = useFormState({ control: form.control, name: "name" });
const isNameInvalid = useMemo(() => {
return nameState.errors && Object.keys(nameState.errors).length > 0;
}, [nameState]);
return (
<>
<div className={cn("col-span-full flex items-end", isNameInvalid && "items-center")}>
<FormField
control={form.control}
name={"icon"}
render={({ field }) => (
<FormItem className="shrink-0">
<FormControl>
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-r-0!" />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input className="rounded-l-none!" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="keywords"
render={({ field }) => (
<FormItem className="col-span-full">
<FormLabel>
<Trans>Keywords</Trans>
</FormLabel>
<FormControl>
<ChipInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
+193
View File
@@ -0,0 +1,193 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useForm, useFormContext } from "react-hook-form";
import type z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Slider } from "@/components/ui/slider";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { languageItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
const formSchema = languageItemSchema;
type FormValues = z.infer<typeof formSchema>;
export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.languages.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
language: data?.language ?? "",
fluency: data?.fluency ?? "",
level: data?.level ?? 0,
},
});
const onSubmit = (data: FormValues) => {
updateResumeData((draft) => {
draft.sections.languages.items.push(data);
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new language</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<LanguageForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.languages.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
language: data.language,
fluency: data.fluency,
level: data.level,
},
});
const onSubmit = (data: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.languages.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.languages.items[index] = data;
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing language</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<LanguageForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function LanguageForm() {
const form = useFormContext<FormValues>();
return (
<>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Language</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="fluency"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Fluency</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="level"
render={({ field }) => (
<FormItem className="gap-4 sm:col-span-full">
<FormLabel>
<Trans>Level</Trans>
</FormLabel>
<FormControl>
<Slider
min={0}
max={5}
step={1}
value={[field.value]}
onValueChange={(value) => field.onChange(value[0])}
/>
</FormControl>
<FormMessage />
<FormDescription>{Number(field.value) === 0 ? t`Hidden` : `${field.value} / 5`}</FormDescription>
</FormItem>
)}
/>
</>
);
}
+217
View File
@@ -0,0 +1,217 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Trans } from "@lingui/react/macro";
import { AtIcon, PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { useForm, useFormContext, useFormState } from "react-hook-form";
import type z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { IconPicker } from "@/components/input/icon-picker";
import { URLInput } from "@/components/input/url-input";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { type DialogProps, useDialogStore } from "@/dialogs/store";
import { profileItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
import { cn } from "@/utils/style";
const formSchema = profileItemSchema;
type FormValues = z.infer<typeof formSchema>;
export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profiles.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
icon: data?.icon ?? "acorn",
network: data?.network ?? "",
username: data?.username ?? "",
website: data?.website ?? { url: "", label: "" },
},
});
const onSubmit = (data: FormValues) => {
updateResumeData((draft) => {
draft.sections.profiles.items.push(data);
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new profile</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<ProfileForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profiles.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
icon: data.icon,
network: data.network,
username: data.username,
website: data.website,
},
});
const onSubmit = (data: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.profiles.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.profiles.items[index] = data;
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing profile</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<ProfileForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function ProfileForm() {
const form = useFormContext<FormValues>();
const networkState = useFormState({ control: form.control, name: "network" });
const isNetworkInvalid = useMemo(() => {
return networkState.errors && Object.keys(networkState.errors).length > 0;
}, [networkState]);
return (
<>
<div className={cn("flex items-end", isNetworkInvalid && "items-center")}>
<FormField
control={form.control}
name={"icon"}
render={({ field }) => (
<FormItem className="shrink-0">
<FormControl>
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-r-0!" />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="network"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Network</Trans>
</FormLabel>
<FormControl>
<Input className="rounded-l-none!" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Username</Trans>
</FormLabel>
<InputGroup>
<InputGroupAddon align="inline-start">
<InputGroupText>
<AtIcon />
</InputGroupText>
</InputGroupAddon>
<FormControl>
<InputGroupInput {...field} />
</FormControl>
</InputGroup>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
+204
View File
@@ -0,0 +1,204 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useForm, useFormContext } from "react-hook-form";
import type z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { projectItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
const formSchema = projectItemSchema;
type FormValues = z.infer<typeof formSchema>;
export function CreateProjectDialog({ data }: DialogProps<"resume.sections.projects.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
name: data?.name ?? "",
period: data?.period ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
updateResumeData((draft) => {
draft.sections.projects.items.push(values);
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new project</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<ProjectForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdateProjectDialog({ data }: DialogProps<"resume.sections.projects.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
name: data.name,
period: data.period,
website: data.website,
description: data.description,
},
});
const onSubmit = (values: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.projects.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.projects.items[index] = values;
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing project</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<ProjectForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function ProjectForm() {
const form = useFormContext<FormValues>();
return (
<>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="period"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Period</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useForm, useFormContext } from "react-hook-form";
import type z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { publicationItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
const formSchema = publicationItemSchema;
type FormValues = z.infer<typeof formSchema>;
export function CreatePublicationDialog({ data }: DialogProps<"resume.sections.publications.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
title: data?.title ?? "",
publisher: data?.publisher ?? "",
date: data?.date ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
updateResumeData((draft) => {
draft.sections.publications.items.push(values);
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new publication</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<PublicationForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdatePublicationDialog({ data }: DialogProps<"resume.sections.publications.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
title: data.title,
publisher: data.publisher,
date: data.date,
website: data.website,
description: data.description,
},
});
const onSubmit = (values: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.publications.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.publications.items[index] = values;
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing publication</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<PublicationForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function PublicationForm() {
const form = useFormContext<FormValues>();
return (
<>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Title</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="publisher"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Publisher</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="date"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Date</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
+167
View File
@@ -0,0 +1,167 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useForm, useFormContext } from "react-hook-form";
import type z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { RichInput } from "@/components/input/rich-input";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { referenceItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
const formSchema = referenceItemSchema;
type FormValues = z.infer<typeof formSchema>;
export function CreateReferenceDialog({ data }: DialogProps<"resume.sections.references.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
name: data?.name ?? "",
description: data?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
updateResumeData((draft) => {
draft.sections.references.items.push(values);
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new reference</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<ReferenceForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdateReferenceDialog({ data }: DialogProps<"resume.sections.references.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
name: data.name,
description: data.description,
},
});
const onSubmit = (values: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.references.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.references.items[index] = values;
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing reference</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<ReferenceForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function ReferenceForm() {
const form = useFormContext<FormValues>();
return (
<>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
+236
View File
@@ -0,0 +1,236 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { useForm, useFormContext, useFormState } from "react-hook-form";
import type z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { ChipInput } from "@/components/input/chip-input";
import { IconPicker } from "@/components/input/icon-picker";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Slider } from "@/components/ui/slider";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { skillItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
import { cn } from "@/utils/style";
const formSchema = skillItemSchema;
type FormValues = z.infer<typeof formSchema>;
export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
icon: data?.icon ?? "acorn",
name: data?.name ?? "",
proficiency: data?.proficiency ?? "",
level: data?.level ?? 0,
keywords: data?.keywords ?? [],
},
});
const onSubmit = (data: FormValues) => {
updateResumeData((draft) => {
draft.sections.skills.items.push(data);
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new skill</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<SkillForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
icon: data.icon,
name: data.name,
proficiency: data.proficiency,
level: data.level,
keywords: data.keywords,
},
});
const onSubmit = (data: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.skills.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.skills.items[index] = data;
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing skill</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<SkillForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function SkillForm() {
const form = useFormContext<FormValues>();
const nameState = useFormState({ control: form.control, name: "name" });
const isNameInvalid = useMemo(() => {
return nameState.errors && Object.keys(nameState.errors).length > 0;
}, [nameState]);
return (
<>
<div className={cn("flex items-end", isNameInvalid && "items-center")}>
<FormField
control={form.control}
name={"icon"}
render={({ field }) => (
<FormItem className="shrink-0">
<FormControl>
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-r-0!" />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input className="rounded-l-none!" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="proficiency"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Proficiency</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="level"
render={({ field }) => (
<FormItem className="gap-4 sm:col-span-full">
<FormLabel>
<Trans>Level</Trans>
</FormLabel>
<FormControl>
<Slider
min={0}
max={5}
step={1}
value={[field.value]}
onValueChange={(value) => field.onChange(value[0])}
/>
</FormControl>
<FormMessage />
<FormDescription>{Number(field.value) === 0 ? t`Hidden` : `${field.value} / 5`}</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keywords"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Keywords</Trans>
</FormLabel>
<FormControl>
<ChipInput {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useForm, useFormContext } from "react-hook-form";
import type z from "zod";
import { Button } from "@/components/animate-ui/components/buttons/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { RichInput } from "@/components/input/rich-input";
import { URLInput } from "@/components/input/url-input";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { volunteerItemSchema } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
const formSchema = volunteerItemSchema;
type FormValues = z.infer<typeof formSchema>;
export function CreateVolunteerDialog({ data }: DialogProps<"resume.sections.volunteer.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
organization: data?.organization ?? "",
location: data?.location ?? "",
period: data?.period ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
updateResumeData((draft) => {
draft.sections.volunteer.items.push(values);
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new volunteer experience</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<VolunteerForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export function UpdateVolunteerDialog({ data }: DialogProps<"resume.sections.volunteer.update">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
organization: data.organization,
location: data.location,
period: data.period,
website: data.website,
description: data.description,
},
});
const onSubmit = (values: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.volunteer.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.volunteer.items[index] = values;
});
closeDialog();
};
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing volunteer experience</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<VolunteerForm />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
function VolunteerForm() {
const form = useFormContext<FormValues>();
return (
<>
<FormField
control={form.control}
name="organization"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Organization</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Location</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="period"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Period</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Website</Trans>
</FormLabel>
<FormControl>
<URLInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Description</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
);
}
+91
View File
@@ -0,0 +1,91 @@
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
import type { Template } from "@/schema/templates";
export type TemplateMetadata = {
name: string;
description: MessageDescriptor;
imageUrl: string;
tags: string[];
};
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"],
},
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"],
},
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"],
},
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"],
},
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"],
},
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"],
},
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"],
},
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"],
},
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"],
},
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"],
},
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"],
},
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"],
},
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"],
},
} as const satisfies Record<Template, TemplateMetadata>;
+129
View File
@@ -0,0 +1,129 @@
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { SlideshowIcon } from "@phosphor-icons/react";
import { type RefObject, useRef } from "react";
import {
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { CometCard } from "@/components/animation/comet-card";
import { useResumeStore } from "@/components/resume/store/resume";
import { Badge } from "@/components/ui/badge";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { type DialogProps, useDialogStore } from "@/dialogs/store";
import type { Template } from "@/schema/templates";
import { cn } from "@/utils/style";
import { type TemplateMetadata, templates } from "./data";
export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">) {
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const closeDialog = useDialogStore((state) => state.closeDialog);
const selectedTemplate = useResumeStore((state) => state.resume.data.metadata.template);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
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 ref={scrollAreaRef} 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}
collisionBoundary={scrollAreaRef}
isActive={template === selectedTemplate}
onSelect={onSelectTemplate}
/>
))}
</div>
</ScrollArea>
</DialogContent>
);
}
type TemplateCardProps = {
id: Template;
isActive?: boolean;
metadata: TemplateMetadata;
collisionBoundary: RefObject<HTMLDivElement | null>;
onSelect: (template: Template) => void;
};
function TemplateCard({ id, metadata, isActive, collisionBoundary, onSelect }: TemplateCardProps) {
const { i18n } = useLingui();
return (
<HoverCard openDelay={0} closeDelay={0}>
<CometCard translateDepth={3} rotateDepth={6} glareOpacity={0}>
<HoverCardTrigger asChild>
<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>
</HoverCardTrigger>
<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}
collisionBoundary={collisionBoundary.current}
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>
);
}