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