mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-27 10:24:48 +10:00
initial commit of v5
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user