mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-25 17:34:52 +10:00
use vite+
This commit is contained in:
+156
-156
@@ -21,194 +21,194 @@ import { authClient } from "@/integrations/auth/client";
|
||||
import { type DialogProps, useDialogStore } from "../store";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1).max(64),
|
||||
expiresIn: z.number().min(1),
|
||||
name: z.string().min(1).max(64),
|
||||
expiresIn: z.number().min(1),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateApiKeyDialog(_: DialogProps<"api-key.create">) {
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
|
||||
if (apiKey) return <CopyApiKeyForm apiKey={apiKey} />;
|
||||
if (apiKey) return <CopyApiKeyForm apiKey={apiKey} />;
|
||||
|
||||
return <CreateApiKeyForm setApiKey={setApiKey} />;
|
||||
return <CreateApiKeyForm setApiKey={setApiKey} />;
|
||||
}
|
||||
|
||||
type CreateApiKeyFormProps = {
|
||||
setApiKey: (apiKey: string) => void;
|
||||
setApiKey: (apiKey: string) => void;
|
||||
};
|
||||
|
||||
const CreateApiKeyForm = ({ setApiKey }: CreateApiKeyFormProps) => {
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
expiresIn: 3600 * 24 * 30,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
expiresIn: 3600 * 24 * 30,
|
||||
},
|
||||
});
|
||||
|
||||
const { blockEvents } = useFormBlocker(form);
|
||||
const { blockEvents } = useFormBlocker(form);
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
const toastId = toast.loading(t`Creating your API key...`);
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
const toastId = toast.loading(t`Creating your API key...`);
|
||||
|
||||
const { data, error } = await authClient.apiKey.create({
|
||||
name: values.name,
|
||||
expiresIn: values.expiresIn,
|
||||
});
|
||||
const { data, error } = await authClient.apiKey.create({
|
||||
name: values.name,
|
||||
expiresIn: values.expiresIn,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
setApiKey(data.key);
|
||||
toast.dismiss(toastId);
|
||||
};
|
||||
setApiKey(data.key);
|
||||
toast.dismiss(toastId);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new API key</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
This will generate a new API key to access the Reactive Resume API to allow machines to interact with your
|
||||
resume data.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new API key</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
This will generate a new API key to access the Reactive Resume API to allow machines to interact with your
|
||||
resume data.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input min={1} max={64} {...field} />} />
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Tip: Give your API key a name, corresponding to the purpose of the key, to help you identify it
|
||||
later.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input min={1} max={64} {...field} />} />
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Tip: Give your API key a name, corresponding to the purpose of the key, to help you identify it
|
||||
later.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiresIn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Expires in</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
value={field.value}
|
||||
onValueChange={(value) => value && field.onChange(Number(value))}
|
||||
options={[
|
||||
{
|
||||
// 1 month = 30 days
|
||||
value: 3600 * 24 * 30,
|
||||
label: t`1 month`,
|
||||
},
|
||||
{
|
||||
// 3 months = 90 days
|
||||
value: 3600 * 24 * 90,
|
||||
label: t`3 months`,
|
||||
},
|
||||
{
|
||||
// 6 months = 180 days
|
||||
value: 3600 * 24 * 180,
|
||||
label: t`6 months`,
|
||||
},
|
||||
{
|
||||
// 1 year = 365 days
|
||||
value: 3600 * 24 * 365,
|
||||
label: t`1 year`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expiresIn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Expires in</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
value={field.value}
|
||||
onValueChange={(value) => value && field.onChange(Number(value))}
|
||||
options={[
|
||||
{
|
||||
// 1 month = 30 days
|
||||
value: 3600 * 24 * 30,
|
||||
label: t`1 month`,
|
||||
},
|
||||
{
|
||||
// 3 months = 90 days
|
||||
value: 3600 * 24 * 90,
|
||||
label: t`3 months`,
|
||||
},
|
||||
{
|
||||
// 6 months = 180 days
|
||||
value: 3600 * 24 * 180,
|
||||
label: t`6 months`,
|
||||
},
|
||||
{
|
||||
// 1 year = 365 days
|
||||
value: 3600 * 24 * 365,
|
||||
label: t`1 year`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
};
|
||||
|
||||
type CopyApiKeyFormProps = {
|
||||
apiKey: string;
|
||||
apiKey: string;
|
||||
};
|
||||
|
||||
const CopyApiKeyForm = ({ apiKey }: CopyApiKeyFormProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const queryClient = useQueryClient();
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
|
||||
const onCopy = async () => {
|
||||
await copyToClipboard(apiKey);
|
||||
toast.success(t`Your API key has been copied to the clipboard.`);
|
||||
};
|
||||
const onCopy = async () => {
|
||||
await copyToClipboard(apiKey);
|
||||
toast.success(t`Your API key has been copied to the clipboard.`);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
closeDialog();
|
||||
void queryClient.invalidateQueries({ queryKey: ["auth", "api-keys"] });
|
||||
};
|
||||
const onConfirm = () => {
|
||||
closeDialog();
|
||||
void queryClient.invalidateQueries({ queryKey: ["auth", "api-keys"] });
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<CopyIcon />
|
||||
<Trans>Here's your new API key</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Copy this secret key and use it in your applications to access your data.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<CopyIcon />
|
||||
<Trans>Here's your new API key</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Copy this secret key and use it in your applications to access your data.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 py-2">
|
||||
<InputGroup>
|
||||
<InputGroupInput value={apiKey} readOnly />
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-sm" onClick={onCopy}>
|
||||
<CopyIcon />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<div className="space-y-2 py-2">
|
||||
<InputGroup>
|
||||
<InputGroupInput value={apiKey} readOnly />
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-sm" onClick={onCopy}>
|
||||
<CopyIcon />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
<Trans>For security reasons, this key will only be displayed once.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
<Trans>For security reasons, this key will only be displayed once.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onConfirm}>
|
||||
<Trans>Confirm</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
<DialogFooter>
|
||||
<Button onClick={onConfirm}>
|
||||
<Trans>Confirm</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,133 +18,133 @@ import { authClient } from "@/integrations/auth/client";
|
||||
import { type DialogProps, useDialogStore } from "../store";
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(6).max(64),
|
||||
newPassword: z.string().min(6).max(64),
|
||||
})
|
||||
.refine((data) => data.newPassword !== data.currentPassword, {
|
||||
message: "New password cannot be the same as the current password.",
|
||||
path: ["newPassword"],
|
||||
});
|
||||
.object({
|
||||
currentPassword: z.string().min(6).max(64),
|
||||
newPassword: z.string().min(6).max(64),
|
||||
})
|
||||
.refine((data) => data.newPassword !== data.currentPassword, {
|
||||
message: "New password cannot be the same as the current password.",
|
||||
path: ["newPassword"],
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function ChangePasswordDialog(_: DialogProps<"auth.change-password">) {
|
||||
const queryClient = useQueryClient();
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const queryClient = useQueryClient();
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
|
||||
const [showCurrentPassword, toggleShowCurrentPassword] = useToggle(false);
|
||||
const [showNewPassword, toggleShowNewPassword] = useToggle(false);
|
||||
const [showCurrentPassword, toggleShowCurrentPassword] = useToggle(false);
|
||||
const [showNewPassword, toggleShowNewPassword] = useToggle(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { blockEvents } = useFormBlocker(form);
|
||||
const { blockEvents } = useFormBlocker(form);
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Updating your password...`);
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Updating your password...`);
|
||||
|
||||
const { error } = await authClient.changePassword({
|
||||
currentPassword: data.currentPassword,
|
||||
newPassword: data.newPassword,
|
||||
});
|
||||
const { error } = await authClient.changePassword({
|
||||
currentPassword: data.currentPassword,
|
||||
newPassword: data.newPassword,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`Your password has been updated successfully.`, { id: toastId });
|
||||
void queryClient.invalidateQueries({ queryKey: ["auth", "accounts"] });
|
||||
closeDialog();
|
||||
};
|
||||
toast.success(t`Your password has been updated successfully.`, { id: toastId });
|
||||
void queryClient.invalidateQueries({ queryKey: ["auth", "accounts"] });
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PasswordIcon />
|
||||
<Trans>Update your password</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Enter your current password and a new password to update your account.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PasswordIcon />
|
||||
<Trans>Update your password</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Enter your current password and a new password to update your account.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Current Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showCurrentPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
{...field}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Current Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showCurrentPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
{...field}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowCurrentPassword}>
|
||||
{showCurrentPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowCurrentPassword}>
|
||||
{showCurrentPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>New Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
{...field}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>New Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
autoComplete="new-password"
|
||||
{...field}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowNewPassword}>
|
||||
{showNewPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowNewPassword}>
|
||||
{showNewPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
<Trans>Update Password</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
<Trans>Update Password</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,95 +18,95 @@ import { authClient } from "@/integrations/auth/client";
|
||||
import { type DialogProps, useDialogStore } from "../store";
|
||||
|
||||
const formSchema = z.object({
|
||||
password: z.string().min(6).max(64),
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function DisableTwoFactorDialog(_: DialogProps<"auth.two-factor.disable">) {
|
||||
const router = useRouter();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const router = useRouter();
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { blockEvents } = useFormBlocker(form);
|
||||
const { blockEvents } = useFormBlocker(form);
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Disabling two-factor authentication...`);
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Disabling two-factor authentication...`);
|
||||
|
||||
const { error } = await authClient.twoFactor.disable({ password: data.password });
|
||||
const { error } = await authClient.twoFactor.disable({ password: data.password });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t`Two-factor authentication has been disabled successfully.`, { id: toastId });
|
||||
void router.invalidate();
|
||||
closeDialog();
|
||||
form.reset();
|
||||
};
|
||||
toast.success(t`Two-factor authentication has been disabled successfully.`, { id: toastId });
|
||||
void router.invalidate();
|
||||
closeDialog();
|
||||
form.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<LockOpenIcon />
|
||||
<Trans>Disable Two-Factor Authentication</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Enter your password to disable two-factor authentication. Your account will be less secure without 2FA
|
||||
enabled.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<LockOpenIcon />
|
||||
<Trans>Disable Two-Factor Authentication</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Enter your password to disable two-factor authentication. Your account will be less secure without 2FA
|
||||
enabled.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
{...field}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
{...field}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowPassword}>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowPassword}>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" variant="destructive">
|
||||
<Trans>Disable 2FA</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<DialogFooter>
|
||||
<Button type="submit" variant="destructive">
|
||||
<Trans>Disable 2FA</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,324 +23,324 @@ import { authClient } from "@/integrations/auth/client";
|
||||
import { type DialogProps, useDialogStore } from "../store";
|
||||
|
||||
const enableFormSchema = z.object({
|
||||
password: z.string().min(6).max(64),
|
||||
password: z.string().min(6).max(64),
|
||||
});
|
||||
|
||||
type EnableFormValues = z.infer<typeof enableFormSchema>;
|
||||
|
||||
const verifyFormSchema = z.object({
|
||||
code: z.string().length(6, "Code must be 6 digits"),
|
||||
code: z.string().length(6, "Code must be 6 digits"),
|
||||
});
|
||||
|
||||
type VerifyFormValues = z.infer<typeof verifyFormSchema>;
|
||||
|
||||
export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">) {
|
||||
const router = useRouter();
|
||||
const router = useRouter();
|
||||
|
||||
const [totpUri, setTotpUri] = useState<string | null>(null);
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||
const [step, setStep] = useState<"enable" | "verify" | "backup">("enable");
|
||||
const [totpUri, setTotpUri] = useState<string | null>(null);
|
||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||
const [step, setStep] = useState<"enable" | "verify" | "backup">("enable");
|
||||
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const [showPassword, toggleShowPassword] = useToggle(false);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
|
||||
const enableForm = useForm<EnableFormValues>({
|
||||
resolver: zodResolver(enableFormSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
const enableForm = useForm<EnableFormValues>({
|
||||
resolver: zodResolver(enableFormSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const verifyForm = useForm<VerifyFormValues>({
|
||||
resolver: zodResolver(verifyFormSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
},
|
||||
});
|
||||
const verifyForm = useForm<VerifyFormValues>({
|
||||
resolver: zodResolver(verifyFormSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
},
|
||||
});
|
||||
|
||||
const enableFormState = enableForm.formState;
|
||||
const verifyFormState = verifyForm.formState;
|
||||
const enableFormState = enableForm.formState;
|
||||
const verifyFormState = verifyForm.formState;
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(enableForm, {
|
||||
shouldBlock: () => {
|
||||
if (step === "enable") return enableFormState.isDirty && !enableFormState.isSubmitting;
|
||||
if (step === "verify") return verifyFormState.isDirty && !verifyFormState.isSubmitting;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const { blockEvents, requestClose } = useFormBlocker(enableForm, {
|
||||
shouldBlock: () => {
|
||||
if (step === "enable") return enableFormState.isDirty && !enableFormState.isSubmitting;
|
||||
if (step === "verify") return verifyFormState.isDirty && !verifyFormState.isSubmitting;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const onEnableSubmit = async (values: EnableFormValues) => {
|
||||
const toastId = toast.loading(t`Enabling two-factor authentication...`);
|
||||
const onEnableSubmit = async (values: EnableFormValues) => {
|
||||
const toastId = toast.loading(t`Enabling two-factor authentication...`);
|
||||
|
||||
const { data, error } = await authClient.twoFactor.enable({
|
||||
password: values.password,
|
||||
issuer: "Reactive Resume",
|
||||
});
|
||||
const { data, error } = await authClient.twoFactor.enable({
|
||||
password: values.password,
|
||||
issuer: "Reactive Resume",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.totpURI && data.backupCodes) {
|
||||
setTotpUri(data.totpURI);
|
||||
setBackupCodes(data.backupCodes);
|
||||
setStep("verify");
|
||||
toast.dismiss(toastId);
|
||||
} else {
|
||||
toast.error(t`Failed to setup two-factor authentication.`, { id: toastId });
|
||||
}
|
||||
};
|
||||
if (data.totpURI && data.backupCodes) {
|
||||
setTotpUri(data.totpURI);
|
||||
setBackupCodes(data.backupCodes);
|
||||
setStep("verify");
|
||||
toast.dismiss(toastId);
|
||||
} else {
|
||||
toast.error(t`Failed to setup two-factor authentication.`, { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
const onVerifySubmit = async (data: VerifyFormValues) => {
|
||||
const toastId = toast.loading(t`Verifying code...`);
|
||||
const onVerifySubmit = async (data: VerifyFormValues) => {
|
||||
const toastId = toast.loading(t`Verifying code...`);
|
||||
|
||||
const { error } = await authClient.twoFactor.verifyTotp({ code: data.code });
|
||||
const { error } = await authClient.twoFactor.verifyTotp({ code: data.code });
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
toast.error(error.message, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(toastId);
|
||||
setStep("backup");
|
||||
};
|
||||
toast.dismiss(toastId);
|
||||
setStep("backup");
|
||||
};
|
||||
|
||||
const onConfirmBackup = () => {
|
||||
toast.success(t`Two-factor authentication has been setup successfully.`);
|
||||
void router.invalidate();
|
||||
closeDialog();
|
||||
onReset();
|
||||
};
|
||||
const onConfirmBackup = () => {
|
||||
toast.success(t`Two-factor authentication has been setup successfully.`);
|
||||
void router.invalidate();
|
||||
closeDialog();
|
||||
onReset();
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
enableForm.reset();
|
||||
verifyForm.reset();
|
||||
setStep("enable");
|
||||
setTotpUri(null);
|
||||
setBackupCodes(null);
|
||||
};
|
||||
const onReset = () => {
|
||||
enableForm.reset();
|
||||
verifyForm.reset();
|
||||
setStep("enable");
|
||||
setTotpUri(null);
|
||||
setBackupCodes(null);
|
||||
};
|
||||
|
||||
const handleCopySecret = async () => {
|
||||
if (!totpUri) return;
|
||||
const secret = extractSecretFromTotpUri(totpUri);
|
||||
if (!secret) return;
|
||||
await navigator.clipboard.writeText(secret);
|
||||
toast.success(t`Secret copied to clipboard.`);
|
||||
};
|
||||
const handleCopySecret = async () => {
|
||||
if (!totpUri) return;
|
||||
const secret = extractSecretFromTotpUri(totpUri);
|
||||
if (!secret) return;
|
||||
await navigator.clipboard.writeText(secret);
|
||||
toast.success(t`Secret copied to clipboard.`);
|
||||
};
|
||||
|
||||
const handleCopyBackupCodes = async () => {
|
||||
if (!backupCodes) return;
|
||||
await navigator.clipboard.writeText(backupCodes.join("\n"));
|
||||
toast.success(t`Backup codes copied to clipboard.`);
|
||||
};
|
||||
const handleCopyBackupCodes = async () => {
|
||||
if (!backupCodes) return;
|
||||
await navigator.clipboard.writeText(backupCodes.join("\n"));
|
||||
toast.success(t`Backup codes copied to clipboard.`);
|
||||
};
|
||||
|
||||
const handleDownloadBackupCodes = () => {
|
||||
if (!backupCodes) return;
|
||||
const content = backupCodes.join("\n");
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "reactive-resume_backup-codes.txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
const handleDownloadBackupCodes = () => {
|
||||
if (!backupCodes) return;
|
||||
const content = backupCodes.join("\n");
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "reactive-resume_backup-codes.txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent className="max-w-md" {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{match(step)
|
||||
.with("enable", () => <Trans>Enable Two-Factor Authentication</Trans>)
|
||||
.with("verify", () => <Trans>Setup Authenticator App</Trans>)
|
||||
.with("backup", () => <Trans>Copy Backup Codes</Trans>)
|
||||
.exhaustive()}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{match(step)
|
||||
.with("enable", () => (
|
||||
<Trans>
|
||||
Enter your password to confirm setting up two-factor authentication. When enabled, you'll need to enter
|
||||
a code from your authenticator app every time you log in.
|
||||
</Trans>
|
||||
))
|
||||
.with("verify", () => (
|
||||
<Trans>
|
||||
Scan the QR code below with your preferred authenticator app. You can also copy the secret below and
|
||||
paste it into your app.
|
||||
</Trans>
|
||||
))
|
||||
.with("backup", () => <Trans>Copy and store these backup codes in case you lose your device.</Trans>)
|
||||
.exhaustive()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent className="max-w-md" {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{match(step)
|
||||
.with("enable", () => <Trans>Enable Two-Factor Authentication</Trans>)
|
||||
.with("verify", () => <Trans>Setup Authenticator App</Trans>)
|
||||
.with("backup", () => <Trans>Copy Backup Codes</Trans>)
|
||||
.exhaustive()}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{match(step)
|
||||
.with("enable", () => (
|
||||
<Trans>
|
||||
Enter your password to confirm setting up two-factor authentication. When enabled, you'll need to enter
|
||||
a code from your authenticator app every time you log in.
|
||||
</Trans>
|
||||
))
|
||||
.with("verify", () => (
|
||||
<Trans>
|
||||
Scan the QR code below with your preferred authenticator app. You can also copy the secret below and
|
||||
paste it into your app.
|
||||
</Trans>
|
||||
))
|
||||
.with("backup", () => <Trans>Copy and store these backup codes in case you lose your device.</Trans>)
|
||||
.exhaustive()}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{match(step)
|
||||
.with("enable", () => (
|
||||
<Form {...enableForm}>
|
||||
<form onSubmit={enableForm.handleSubmit(onEnableSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={enableForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
{...field}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{match(step)
|
||||
.with("enable", () => (
|
||||
<Form {...enableForm}>
|
||||
<form onSubmit={enableForm.handleSubmit(onEnableSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={enableForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Password</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-1.5">
|
||||
<FormControl
|
||||
render={
|
||||
<Input
|
||||
min={6}
|
||||
max={64}
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
{...field}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowPassword}>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button size="icon" variant="ghost" type="button" onClick={toggleShowPassword}>
|
||||
{showPassword ? <EyeIcon /> : <EyeSlashIcon />}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
))
|
||||
.with("verify", () => {
|
||||
const secret = totpUri ? extractSecretFromTotpUri(totpUri) : null;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{totpUri && secret && (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input readOnly value={secret} className="font-mono text-sm" />
|
||||
<Button size="icon" variant="ghost" type="button" onClick={handleCopySecret}>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
))
|
||||
.with("verify", () => {
|
||||
const secret = totpUri ? extractSecretFromTotpUri(totpUri) : null;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{totpUri && secret && (
|
||||
<>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input readOnly value={secret} className="font-mono text-sm" />
|
||||
<Button size="icon" variant="ghost" type="button" onClick={handleCopySecret}>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TwoFactorQRCode totpUri={totpUri} />
|
||||
</>
|
||||
)}
|
||||
<TwoFactorQRCode totpUri={totpUri} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<p>
|
||||
<Trans>Then, enter the 6 digit code that the app provides to continue.</Trans>
|
||||
</p>
|
||||
<p>
|
||||
<Trans>Then, enter the 6 digit code that the app provides to continue.</Trans>
|
||||
</p>
|
||||
|
||||
<Form {...verifyForm}>
|
||||
<form onSubmit={verifyForm.handleSubmit(onVerifySubmit)}>
|
||||
<FormField
|
||||
control={verifyForm.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl
|
||||
render={
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
onComplete={verifyForm.handleSubmit(onVerifySubmit)}
|
||||
pasteTransformer={(pasted) => pasted.replaceAll("-", "")}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} className="size-12" />
|
||||
<InputOTPSlot index={1} className="size-12" />
|
||||
<InputOTPSlot index={2} className="size-12" />
|
||||
<InputOTPSlot index={3} className="size-12" />
|
||||
<InputOTPSlot index={4} className="size-12" />
|
||||
<InputOTPSlot index={5} className="size-12" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Form {...verifyForm}>
|
||||
<form onSubmit={verifyForm.handleSubmit(onVerifySubmit)}>
|
||||
<FormField
|
||||
control={verifyForm.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl
|
||||
render={
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
onComplete={verifyForm.handleSubmit(onVerifySubmit)}
|
||||
pasteTransformer={(pasted) => pasted.replaceAll("-", "")}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} className="size-12" />
|
||||
<InputOTPSlot index={1} className="size-12" />
|
||||
<InputOTPSlot index={2} className="size-12" />
|
||||
<InputOTPSlot index={3} className="size-12" />
|
||||
<InputOTPSlot index={4} className="size-12" />
|
||||
<InputOTPSlot index={5} className="size-12" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="gap-x-2">
|
||||
<Button type="button" variant="outline" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
.with("backup", () => (
|
||||
<div className="space-y-4">
|
||||
{backupCodes && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{backupCodes.map((code, index) => (
|
||||
<div key={index} className="rounded-md border border-border p-2 text-center font-mono text-sm">
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter className="gap-x-2">
|
||||
<Button type="button" variant="outline" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
.with("backup", () => (
|
||||
<div className="space-y-4">
|
||||
{backupCodes && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{backupCodes.map((code, index) => (
|
||||
<div key={index} className="rounded-md border border-border p-2 text-center font-mono text-sm">
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button type="button" variant="outline" onClick={handleDownloadBackupCodes} className="flex-1">
|
||||
<ArrowDownIcon className="me-2 size-4" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={handleCopyBackupCodes} className="flex-1">
|
||||
<CopyIcon className="me-2 size-4" />
|
||||
<Trans>Copy</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button type="button" variant="outline" onClick={handleDownloadBackupCodes} className="flex-1">
|
||||
<ArrowDownIcon className="me-2 size-4" />
|
||||
<Trans>Download</Trans>
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={handleCopyBackupCodes} className="flex-1">
|
||||
<CopyIcon className="me-2 size-4" />
|
||||
<Trans>Copy</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={onConfirmBackup}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</DialogContent>
|
||||
);
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={onConfirmBackup}>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
))
|
||||
.exhaustive()}
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function extractSecretFromTotpUri(totpUri: string): string | null {
|
||||
try {
|
||||
const url = new URL(totpUri);
|
||||
return url.searchParams.get("secret");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const url = new URL(totpUri);
|
||||
return url.searchParams.get("secret");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function TwoFactorQRCode({ totpUri }: { totpUri: string }) {
|
||||
return (
|
||||
<QRCodeSVG
|
||||
value={totpUri}
|
||||
size={256}
|
||||
marginSize={2}
|
||||
className="rounded-md"
|
||||
title="Two-Factor Authentication QR Code"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<QRCodeSVG
|
||||
value={totpUri}
|
||||
size={256}
|
||||
marginSize={2}
|
||||
className="rounded-md"
|
||||
title="Two-Factor Authentication QR Code"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+47
-47
@@ -27,53 +27,53 @@ import { TemplateGalleryDialog } from "./resume/template/gallery";
|
||||
import { useDialogStore } from "./store";
|
||||
|
||||
export function DialogManager() {
|
||||
const { open, activeDialog, onOpenChange } = useDialogStore();
|
||||
const { open, activeDialog, onOpenChange } = useDialogStore();
|
||||
|
||||
const DialogContent = match(activeDialog)
|
||||
.with({ type: "auth.change-password" }, () => <ChangePasswordDialog />)
|
||||
.with({ type: "auth.two-factor.enable" }, () => <EnableTwoFactorDialog />)
|
||||
.with({ type: "auth.two-factor.disable" }, () => <DisableTwoFactorDialog />)
|
||||
.with({ type: "api-key.create" }, () => <CreateApiKeyDialog />)
|
||||
.with({ type: "resume.create" }, () => <CreateResumeDialog />)
|
||||
.with({ type: "resume.update" }, ({ data }) => <UpdateResumeDialog data={data} />)
|
||||
.with({ type: "resume.duplicate" }, ({ data }) => <DuplicateResumeDialog data={data} />)
|
||||
.with({ type: "resume.import" }, () => <ImportResumeDialog />)
|
||||
.with({ type: "resume.template.gallery" }, () => <TemplateGalleryDialog />)
|
||||
.with({ type: "resume.sections.profiles.create" }, ({ data }) => <CreateProfileDialog data={data} />)
|
||||
.with({ type: "resume.sections.profiles.update" }, ({ data }) => <UpdateProfileDialog data={data} />)
|
||||
.with({ type: "resume.sections.experience.create" }, ({ data }) => <CreateExperienceDialog data={data} />)
|
||||
.with({ type: "resume.sections.experience.update" }, ({ data }) => <UpdateExperienceDialog data={data} />)
|
||||
.with({ type: "resume.sections.education.create" }, ({ data }) => <CreateEducationDialog data={data} />)
|
||||
.with({ type: "resume.sections.education.update" }, ({ data }) => <UpdateEducationDialog data={data} />)
|
||||
.with({ type: "resume.sections.skills.create" }, ({ data }) => <CreateSkillDialog data={data} />)
|
||||
.with({ type: "resume.sections.skills.update" }, ({ data }) => <UpdateSkillDialog data={data} />)
|
||||
.with({ type: "resume.sections.projects.create" }, ({ data }) => <CreateProjectDialog data={data} />)
|
||||
.with({ type: "resume.sections.projects.update" }, ({ data }) => <UpdateProjectDialog data={data} />)
|
||||
.with({ type: "resume.sections.certifications.create" }, ({ data }) => <CreateCertificationDialog data={data} />)
|
||||
.with({ type: "resume.sections.certifications.update" }, ({ data }) => <UpdateCertificationDialog data={data} />)
|
||||
.with({ type: "resume.sections.languages.create" }, ({ data }) => <CreateLanguageDialog data={data} />)
|
||||
.with({ type: "resume.sections.languages.update" }, ({ data }) => <UpdateLanguageDialog data={data} />)
|
||||
.with({ type: "resume.sections.publications.create" }, ({ data }) => <CreatePublicationDialog data={data} />)
|
||||
.with({ type: "resume.sections.publications.update" }, ({ data }) => <UpdatePublicationDialog data={data} />)
|
||||
.with({ type: "resume.sections.awards.create" }, ({ data }) => <CreateAwardDialog data={data} />)
|
||||
.with({ type: "resume.sections.awards.update" }, ({ data }) => <UpdateAwardDialog data={data} />)
|
||||
.with({ type: "resume.sections.interests.create" }, ({ data }) => <CreateInterestDialog data={data} />)
|
||||
.with({ type: "resume.sections.interests.update" }, ({ data }) => <UpdateInterestDialog data={data} />)
|
||||
.with({ type: "resume.sections.volunteer.create" }, ({ data }) => <CreateVolunteerDialog data={data} />)
|
||||
.with({ type: "resume.sections.volunteer.update" }, ({ data }) => <UpdateVolunteerDialog data={data} />)
|
||||
.with({ type: "resume.sections.references.create" }, ({ data }) => <CreateReferenceDialog data={data} />)
|
||||
.with({ type: "resume.sections.references.update" }, ({ data }) => <UpdateReferenceDialog data={data} />)
|
||||
.with({ type: "resume.sections.summary.create" }, ({ data }) => <CreateSummaryItemDialog data={data} />)
|
||||
.with({ type: "resume.sections.summary.update" }, ({ data }) => <UpdateSummaryItemDialog data={data} />)
|
||||
.with({ type: "resume.sections.cover-letter.create" }, ({ data }) => <CreateCoverLetterDialog data={data} />)
|
||||
.with({ type: "resume.sections.cover-letter.update" }, ({ data }) => <UpdateCoverLetterDialog data={data} />)
|
||||
.with({ type: "resume.sections.custom.create" }, ({ data }) => <CreateCustomSectionDialog data={data} />)
|
||||
.with({ type: "resume.sections.custom.update" }, ({ data }) => <UpdateCustomSectionDialog data={data} />)
|
||||
.otherwise(() => null);
|
||||
const DialogContent = match(activeDialog)
|
||||
.with({ type: "auth.change-password" }, () => <ChangePasswordDialog />)
|
||||
.with({ type: "auth.two-factor.enable" }, () => <EnableTwoFactorDialog />)
|
||||
.with({ type: "auth.two-factor.disable" }, () => <DisableTwoFactorDialog />)
|
||||
.with({ type: "api-key.create" }, () => <CreateApiKeyDialog />)
|
||||
.with({ type: "resume.create" }, () => <CreateResumeDialog />)
|
||||
.with({ type: "resume.update" }, ({ data }) => <UpdateResumeDialog data={data} />)
|
||||
.with({ type: "resume.duplicate" }, ({ data }) => <DuplicateResumeDialog data={data} />)
|
||||
.with({ type: "resume.import" }, () => <ImportResumeDialog />)
|
||||
.with({ type: "resume.template.gallery" }, () => <TemplateGalleryDialog />)
|
||||
.with({ type: "resume.sections.profiles.create" }, ({ data }) => <CreateProfileDialog data={data} />)
|
||||
.with({ type: "resume.sections.profiles.update" }, ({ data }) => <UpdateProfileDialog data={data} />)
|
||||
.with({ type: "resume.sections.experience.create" }, ({ data }) => <CreateExperienceDialog data={data} />)
|
||||
.with({ type: "resume.sections.experience.update" }, ({ data }) => <UpdateExperienceDialog data={data} />)
|
||||
.with({ type: "resume.sections.education.create" }, ({ data }) => <CreateEducationDialog data={data} />)
|
||||
.with({ type: "resume.sections.education.update" }, ({ data }) => <UpdateEducationDialog data={data} />)
|
||||
.with({ type: "resume.sections.skills.create" }, ({ data }) => <CreateSkillDialog data={data} />)
|
||||
.with({ type: "resume.sections.skills.update" }, ({ data }) => <UpdateSkillDialog data={data} />)
|
||||
.with({ type: "resume.sections.projects.create" }, ({ data }) => <CreateProjectDialog data={data} />)
|
||||
.with({ type: "resume.sections.projects.update" }, ({ data }) => <UpdateProjectDialog data={data} />)
|
||||
.with({ type: "resume.sections.certifications.create" }, ({ data }) => <CreateCertificationDialog data={data} />)
|
||||
.with({ type: "resume.sections.certifications.update" }, ({ data }) => <UpdateCertificationDialog data={data} />)
|
||||
.with({ type: "resume.sections.languages.create" }, ({ data }) => <CreateLanguageDialog data={data} />)
|
||||
.with({ type: "resume.sections.languages.update" }, ({ data }) => <UpdateLanguageDialog data={data} />)
|
||||
.with({ type: "resume.sections.publications.create" }, ({ data }) => <CreatePublicationDialog data={data} />)
|
||||
.with({ type: "resume.sections.publications.update" }, ({ data }) => <UpdatePublicationDialog data={data} />)
|
||||
.with({ type: "resume.sections.awards.create" }, ({ data }) => <CreateAwardDialog data={data} />)
|
||||
.with({ type: "resume.sections.awards.update" }, ({ data }) => <UpdateAwardDialog data={data} />)
|
||||
.with({ type: "resume.sections.interests.create" }, ({ data }) => <CreateInterestDialog data={data} />)
|
||||
.with({ type: "resume.sections.interests.update" }, ({ data }) => <UpdateInterestDialog data={data} />)
|
||||
.with({ type: "resume.sections.volunteer.create" }, ({ data }) => <CreateVolunteerDialog data={data} />)
|
||||
.with({ type: "resume.sections.volunteer.update" }, ({ data }) => <UpdateVolunteerDialog data={data} />)
|
||||
.with({ type: "resume.sections.references.create" }, ({ data }) => <CreateReferenceDialog data={data} />)
|
||||
.with({ type: "resume.sections.references.update" }, ({ data }) => <UpdateReferenceDialog data={data} />)
|
||||
.with({ type: "resume.sections.summary.create" }, ({ data }) => <CreateSummaryItemDialog data={data} />)
|
||||
.with({ type: "resume.sections.summary.update" }, ({ data }) => <UpdateSummaryItemDialog data={data} />)
|
||||
.with({ type: "resume.sections.cover-letter.create" }, ({ data }) => <CreateCoverLetterDialog data={data} />)
|
||||
.with({ type: "resume.sections.cover-letter.update" }, ({ data }) => <UpdateCoverLetterDialog data={data} />)
|
||||
.with({ type: "resume.sections.custom.create" }, ({ data }) => <CreateCustomSectionDialog data={data} />)
|
||||
.with({ type: "resume.sections.custom.update" }, ({ data }) => <UpdateCustomSectionDialog data={data} />)
|
||||
.otherwise(() => null);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{DialogContent}
|
||||
</Dialog>
|
||||
);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{DialogContent}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
+240
-240
@@ -29,281 +29,281 @@ import { cn } from "@/utils/style";
|
||||
import { type DialogProps, useDialogStore } from "../store";
|
||||
|
||||
const formSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("") }),
|
||||
z.object({
|
||||
type: z.literal("pdf"),
|
||||
file: z.instanceof(File).refine((file) => file.type === "application/pdf", { message: "File must be a PDF" }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("docx"),
|
||||
file: z
|
||||
.instanceof(File)
|
||||
.refine(
|
||||
(file) =>
|
||||
file.type === "application/msword" ||
|
||||
file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
{ message: "File must be a Microsoft Word document" },
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("reactive-resume-json"),
|
||||
file: z
|
||||
.instanceof(File)
|
||||
.refine((file) => file.type === "application/json", { message: "File must be a JSON file" }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("reactive-resume-v4-json"),
|
||||
file: z
|
||||
.instanceof(File)
|
||||
.refine((file) => file.type === "application/json", { message: "File must be a JSON file" }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("json-resume-json"),
|
||||
file: z
|
||||
.instanceof(File)
|
||||
.refine((file) => file.type === "application/json", { message: "File must be a JSON file" }),
|
||||
}),
|
||||
z.object({ type: z.literal("") }),
|
||||
z.object({
|
||||
type: z.literal("pdf"),
|
||||
file: z.instanceof(File).refine((file) => file.type === "application/pdf", { message: "File must be a PDF" }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("docx"),
|
||||
file: z
|
||||
.instanceof(File)
|
||||
.refine(
|
||||
(file) =>
|
||||
file.type === "application/msword" ||
|
||||
file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
{ message: "File must be a Microsoft Word document" },
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("reactive-resume-json"),
|
||||
file: z
|
||||
.instanceof(File)
|
||||
.refine((file) => file.type === "application/json", { message: "File must be a JSON file" }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("reactive-resume-v4-json"),
|
||||
file: z
|
||||
.instanceof(File)
|
||||
.refine((file) => file.type === "application/json", { message: "File must be a JSON file" }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("json-resume-json"),
|
||||
file: z
|
||||
.instanceof(File)
|
||||
.refine((file) => file.type === "application/json", { message: "File must be a JSON file" }),
|
||||
}),
|
||||
]);
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
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);
|
||||
});
|
||||
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 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 prevTypeRef = useRef<string>("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const { mutateAsync: importResume } = useMutation(orpc.resume.import.mutationOptions());
|
||||
const { mutateAsync: importResume } = useMutation(orpc.resume.import.mutationOptions());
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
type: "",
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
type: "",
|
||||
},
|
||||
});
|
||||
|
||||
const type = useWatch({ control: form.control, name: "type" });
|
||||
const type = useWatch({ control: form.control, name: "type" });
|
||||
|
||||
useEffect(() => {
|
||||
if (prevTypeRef.current === type) return;
|
||||
prevTypeRef.current = type;
|
||||
form.resetField("file");
|
||||
}, [form.resetField, type]);
|
||||
useEffect(() => {
|
||||
if (prevTypeRef.current === type) return;
|
||||
prevTypeRef.current = type;
|
||||
form.resetField("file");
|
||||
}, [form.resetField, type]);
|
||||
|
||||
const onSelectFile = () => {
|
||||
if (!inputRef.current) return;
|
||||
inputRef.current.click();
|
||||
};
|
||||
const onSelectFile = () => {
|
||||
if (!inputRef.current) return;
|
||||
inputRef.current.click();
|
||||
};
|
||||
|
||||
const onUploadFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
form.setValue("file", file, { shouldDirty: true });
|
||||
};
|
||||
const onUploadFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
form.setValue("file", file, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const { blockEvents } = useFormBlocker(form);
|
||||
const { blockEvents } = useFormBlocker(form);
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
if (values.type === "") return;
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
if (values.type === "") return;
|
||||
|
||||
setIsLoading(true);
|
||||
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.`,
|
||||
});
|
||||
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;
|
||||
try {
|
||||
let data: ResumeData | undefined;
|
||||
|
||||
if (values.type === "json-resume-json") {
|
||||
const json = await values.file.text();
|
||||
const importer = new JSONResumeImporter();
|
||||
data = importer.parse(json);
|
||||
}
|
||||
if (values.type === "json-resume-json") {
|
||||
const json = await values.file.text();
|
||||
const importer = new JSONResumeImporter();
|
||||
data = importer.parse(json);
|
||||
}
|
||||
|
||||
if (values.type === "reactive-resume-json") {
|
||||
const json = await values.file.text();
|
||||
const importer = new ReactiveResumeJSONImporter();
|
||||
data = importer.parse(json);
|
||||
}
|
||||
if (values.type === "reactive-resume-json") {
|
||||
const json = await values.file.text();
|
||||
const importer = new ReactiveResumeJSONImporter();
|
||||
data = importer.parse(json);
|
||||
}
|
||||
|
||||
if (values.type === "reactive-resume-v4-json") {
|
||||
const json = await values.file.text();
|
||||
const importer = new ReactiveResumeV4JSONImporter();
|
||||
data = importer.parse(json);
|
||||
}
|
||||
if (values.type === "reactive-resume-v4-json") {
|
||||
const json = await values.file.text();
|
||||
const importer = new ReactiveResumeV4JSONImporter();
|
||||
data = importer.parse(json);
|
||||
}
|
||||
|
||||
if (values.type === "pdf") {
|
||||
if (!isAIEnabled)
|
||||
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
|
||||
if (values.type === "pdf") {
|
||||
if (!isAIEnabled)
|
||||
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
|
||||
|
||||
// const arrayBuffer = await values.file.arrayBuffer();
|
||||
const base64 = await fileToBase64(values.file);
|
||||
// const arrayBuffer = await values.file.arrayBuffer();
|
||||
const base64 = await fileToBase64(values.file);
|
||||
|
||||
data = await client.ai.parsePdf({
|
||||
provider,
|
||||
model,
|
||||
apiKey,
|
||||
baseURL,
|
||||
file: { name: values.file.name, data: base64 },
|
||||
});
|
||||
}
|
||||
data = await client.ai.parsePdf({
|
||||
provider,
|
||||
model,
|
||||
apiKey,
|
||||
baseURL,
|
||||
file: { name: values.file.name, data: base64 },
|
||||
});
|
||||
}
|
||||
|
||||
if (values.type === "docx") {
|
||||
if (!isAIEnabled)
|
||||
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
|
||||
if (values.type === "docx") {
|
||||
if (!isAIEnabled)
|
||||
throw new Error(t`This feature requires AI Integration to be enabled. Please enable it in the settings.`);
|
||||
|
||||
// const arrayBuffer = await values.file.arrayBuffer();
|
||||
const base64 = await fileToBase64(values.file);
|
||||
// const arrayBuffer = await values.file.arrayBuffer();
|
||||
const base64 = await fileToBase64(values.file);
|
||||
|
||||
const mediaType =
|
||||
values.file.type === "application/msword"
|
||||
? ("application/msword" as const)
|
||||
: ("application/vnd.openxmlformats-officedocument.wordprocessingml.document" as const);
|
||||
const mediaType =
|
||||
values.file.type === "application/msword"
|
||||
? ("application/msword" as const)
|
||||
: ("application/vnd.openxmlformats-officedocument.wordprocessingml.document" as const);
|
||||
|
||||
data = await client.ai.parseDocx({
|
||||
provider,
|
||||
model,
|
||||
apiKey,
|
||||
baseURL,
|
||||
mediaType,
|
||||
file: { name: values.file.name, data: base64 },
|
||||
});
|
||||
}
|
||||
data = await client.ai.parseDocx({
|
||||
provider,
|
||||
model,
|
||||
apiKey,
|
||||
baseURL,
|
||||
mediaType,
|
||||
file: { name: values.file.name, data: base64 },
|
||||
});
|
||||
}
|
||||
|
||||
if (!data) throw new Error("No data was returned from the AI provider.");
|
||||
if (!data) throw new Error("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) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message, { id: toastId, description: null });
|
||||
} else {
|
||||
toast.error(t`An unknown error occurred while importing your resume.`, { id: toastId, description: null });
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
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) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(error.message, { id: toastId, description: null });
|
||||
} else {
|
||||
toast.error(t`An unknown error occurred while importing your resume.`, { id: toastId, description: null });
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<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>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Import an existing resume</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Continue where you left off by importing an existing resume you created using Reactive Resume or any another
|
||||
resume builder. Supported formats include PDF, Microsoft Word, as well as JSON files from Reactive Resume.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Type</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
showClear={false}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
options={[
|
||||
{ value: "reactive-resume-json", label: "Reactive Resume (JSON)" },
|
||||
{ value: "reactive-resume-v4-json", label: "Reactive Resume v4 (JSON)" },
|
||||
{ value: "json-resume-json", label: "JSON Resume" },
|
||||
{
|
||||
value: "pdf",
|
||||
label: (
|
||||
<div className="flex items-center gap-x-2">
|
||||
PDF <Badge>{t`AI`}</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "docx",
|
||||
label: (
|
||||
<div className="flex items-center gap-x-2">
|
||||
Microsoft Word <Badge>{t`AI`}</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Type</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
showClear={false}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
options={[
|
||||
{ value: "reactive-resume-json", label: "Reactive Resume (JSON)" },
|
||||
{ value: "reactive-resume-v4-json", label: "Reactive Resume v4 (JSON)" },
|
||||
{ value: "json-resume-json", label: "JSON Resume" },
|
||||
{
|
||||
value: "pdf",
|
||||
label: (
|
||||
<div className="flex items-center gap-x-2">
|
||||
PDF <Badge>{t`AI`}</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "docx",
|
||||
label: (
|
||||
<div className="flex items-center gap-x-2">
|
||||
Microsoft Word <Badge>{t`AI`}</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
key={type}
|
||||
control={form.control}
|
||||
name="file"
|
||||
render={({ field }) => (
|
||||
<FormItem className={cn(!type && "hidden")}>
|
||||
<FormControl>
|
||||
<Input type="file" className="hidden" ref={inputRef} onChange={onUploadFile} />
|
||||
<FormField
|
||||
key={type}
|
||||
control={form.control}
|
||||
name="file"
|
||||
render={({ field }) => (
|
||||
<FormItem className={cn(!type && "hidden")}>
|
||||
<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.value ? (
|
||||
<>
|
||||
<FileIcon weight="thin" size={32} />
|
||||
<p>{field.value.name}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadSimpleIcon weight="thin" size={32} />
|
||||
<Trans>Click here to select a file to import</Trans>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto w-full flex-col border-dashed py-8 font-normal"
|
||||
onClick={onSelectFile}
|
||||
>
|
||||
{field.value ? (
|
||||
<>
|
||||
<FileIcon weight="thin" size={32} />
|
||||
<p>{field.value.name}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UploadSimpleIcon weight="thin" size={32} />
|
||||
<Trans>Click here to select a file to import</Trans>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={!type || isLoading}>
|
||||
{isLoading ? <Spinner /> : null}
|
||||
{isLoading ? t`Importing...` : t`Import`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={!type || isLoading}>
|
||||
{isLoading ? <Spinner /> : null}
|
||||
{isLoading ? t`Importing...` : t`Import`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
+285
-285
@@ -14,10 +14,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup } from "@/components/ui/button-group";
|
||||
import { DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -30,344 +30,344 @@ import { generateId, generateRandomName, slugify } from "@/utils/string";
|
||||
import { type DialogProps, useDialogStore } from "../store";
|
||||
|
||||
const formSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(64),
|
||||
slug: z.string().min(1).max(64).transform(slugify),
|
||||
tags: z.array(z.string()),
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(64),
|
||||
slug: z.string().min(1).max(64).transform(slugify),
|
||||
tags: z.array(z.string()),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateResumeDialog(_: DialogProps<"resume.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
|
||||
const { mutate: createResume, isPending } = useMutation(orpc.resume.create.mutationOptions());
|
||||
const { mutate: createResume, isPending } = useMutation(orpc.resume.create.mutationOptions());
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
name: "",
|
||||
slug: "",
|
||||
tags: [],
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
name: "",
|
||||
slug: "",
|
||||
tags: [],
|
||||
},
|
||||
});
|
||||
|
||||
const name = useWatch({ control: form.control, name: "name" });
|
||||
const name = useWatch({ control: form.control, name: "name" });
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue("slug", slugify(name), { shouldDirty: true });
|
||||
}, [form, name]);
|
||||
useEffect(() => {
|
||||
form.setValue("slug", slugify(name), { shouldDirty: true });
|
||||
}, [form, name]);
|
||||
|
||||
const { blockEvents } = useFormBlocker(form);
|
||||
const { blockEvents } = useFormBlocker(form);
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Creating your resume...`);
|
||||
const onSubmit = (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Creating your resume...`);
|
||||
|
||||
createResume(data, {
|
||||
onSuccess: () => {
|
||||
toast.success(t`Your resume has been created successfully.`, { id: toastId });
|
||||
closeDialog();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message === "RESUME_SLUG_ALREADY_EXISTS") {
|
||||
toast.error(t`A resume with this slug already exists.`, { id: toastId });
|
||||
return;
|
||||
}
|
||||
createResume(data, {
|
||||
onSuccess: () => {
|
||||
toast.success(t`Your resume has been created successfully.`, { id: toastId });
|
||||
closeDialog();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message === "RESUME_SLUG_ALREADY_EXISTS") {
|
||||
toast.error(t`A resume with this slug already exists.`, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(error.message, { id: toastId });
|
||||
},
|
||||
});
|
||||
};
|
||||
toast.error(error.message, { id: toastId });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onCreateSampleResume = () => {
|
||||
const values = form.getValues();
|
||||
const randomName = generateRandomName();
|
||||
const onCreateSampleResume = () => {
|
||||
const values = form.getValues();
|
||||
const randomName = generateRandomName();
|
||||
|
||||
const data = {
|
||||
name: values.name || randomName,
|
||||
slug: values.slug || slugify(randomName),
|
||||
tags: values.tags,
|
||||
withSampleData: true,
|
||||
} satisfies RouterInput["resume"]["create"];
|
||||
const 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...`);
|
||||
const toastId = toast.loading(t`Creating your resume...`);
|
||||
|
||||
createResume(data, {
|
||||
onSuccess: () => {
|
||||
toast.success(t`Your resume has been created successfully.`, { id: toastId });
|
||||
closeDialog();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
},
|
||||
});
|
||||
};
|
||||
createResume(data, {
|
||||
onSuccess: () => {
|
||||
toast.success(t`Your resume has been created successfully.`, { id: toastId });
|
||||
closeDialog();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<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>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new resume</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Start building your resume by giving it a name.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<ResumeForm />
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<ResumeForm />
|
||||
|
||||
<DialogFooter>
|
||||
<ButtonGroup aria-label="Create Resume with Options" className="gap-x-px rtl:flex-row-reverse">
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<ButtonGroup aria-label="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>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<DropdownMenuContent align="end" className="w-fit">
|
||||
<DropdownMenuItem onClick={onCreateSampleResume}>
|
||||
<TestTubeIcon />
|
||||
<Trans>Create a Sample Resume</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateResumeDialog({ data }: DialogProps<"resume.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
|
||||
const { mutate: updateResume, isPending } = useMutation(orpc.resume.update.mutationOptions());
|
||||
const { mutate: updateResume, isPending } = useMutation(orpc.resume.update.mutationOptions());
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
tags: data.tags,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
tags: data.tags,
|
||||
},
|
||||
});
|
||||
|
||||
const name = useWatch({ control: form.control, name: "name" });
|
||||
const name = useWatch({ control: form.control, name: "name" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!name) return;
|
||||
form.setValue("slug", slugify(name), { shouldDirty: true });
|
||||
}, [form, name]);
|
||||
useEffect(() => {
|
||||
if (!name) return;
|
||||
form.setValue("slug", slugify(name), { shouldDirty: true });
|
||||
}, [form, name]);
|
||||
|
||||
const { blockEvents } = useFormBlocker(form);
|
||||
const { blockEvents } = useFormBlocker(form);
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Updating your resume...`);
|
||||
const onSubmit = (data: FormValues) => {
|
||||
const toastId = toast.loading(t`Updating your resume...`);
|
||||
|
||||
updateResume(data, {
|
||||
onSuccess: () => {
|
||||
toast.success(t`Your resume has been updated successfully.`, { id: toastId });
|
||||
closeDialog();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message === "RESUME_SLUG_ALREADY_EXISTS") {
|
||||
toast.error(t`A resume with this slug already exists.`, { id: toastId });
|
||||
return;
|
||||
}
|
||||
updateResume(data, {
|
||||
onSuccess: () => {
|
||||
toast.success(t`Your resume has been updated successfully.`, { id: toastId });
|
||||
closeDialog();
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.message === "RESUME_SLUG_ALREADY_EXISTS") {
|
||||
toast.error(t`A resume with this slug already exists.`, { id: toastId });
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(error.message, { id: toastId });
|
||||
},
|
||||
});
|
||||
};
|
||||
toast.error(error.message, { id: toastId });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<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>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update Resume</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Changed your mind? Rename your resume to something more descriptive.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<ResumeForm />
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<ResumeForm />
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">) {
|
||||
const navigate = useNavigate();
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const navigate = useNavigate();
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
|
||||
const { mutate: duplicateResume, isPending } = useMutation(orpc.resume.duplicate.mutationOptions());
|
||||
const { mutate: duplicateResume, isPending } = useMutation(orpc.resume.duplicate.mutationOptions());
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.id,
|
||||
name: `${data.name} (Copy)`,
|
||||
slug: `${data.slug}-copy`,
|
||||
tags: data.tags,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.id,
|
||||
name: `${data.name} (Copy)`,
|
||||
slug: `${data.slug}-copy`,
|
||||
tags: data.tags,
|
||||
},
|
||||
});
|
||||
|
||||
const name = useWatch({ control: form.control, name: "name" });
|
||||
const name = useWatch({ control: form.control, name: "name" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!name) return;
|
||||
form.setValue("slug", slugify(name), { shouldDirty: true });
|
||||
}, [form, name]);
|
||||
useEffect(() => {
|
||||
if (!name) return;
|
||||
form.setValue("slug", slugify(name), { shouldDirty: true });
|
||||
}, [form, name]);
|
||||
|
||||
const { blockEvents } = useFormBlocker(form);
|
||||
const { blockEvents } = useFormBlocker(form);
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
const toastId = toast.loading(t`Duplicating your resume...`);
|
||||
const onSubmit = (values: FormValues) => {
|
||||
const toastId = toast.loading(t`Duplicating your resume...`);
|
||||
|
||||
duplicateResume(values, {
|
||||
onSuccess: async (id) => {
|
||||
toast.success(t`Your resume has been duplicated successfully.`, { id: toastId });
|
||||
closeDialog();
|
||||
duplicateResume(values, {
|
||||
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(error.message, { id: toastId });
|
||||
},
|
||||
});
|
||||
};
|
||||
if (!data.shouldRedirect) return;
|
||||
void navigate({ to: `/builder/$resumeId`, params: { resumeId: id } });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message, { id: toastId });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<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>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Duplicate Resume</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Duplicate your resume to create a new one, just like the original.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<ResumeForm />
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<ResumeForm />
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<Trans>Duplicate</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
<Trans>Duplicate</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumeForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const { data: session } = authClient.useSession();
|
||||
const form = useFormContext<FormValues>();
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const slugPrefix = useMemo(() => {
|
||||
return `${window.location.origin}/${session?.user.username ?? ""}/`;
|
||||
}, [session]);
|
||||
const slugPrefix = useMemo(() => {
|
||||
return `${window.location.origin}/${session?.user.username ?? ""}/`;
|
||||
}, [session]);
|
||||
|
||||
const onGenerateName = () => {
|
||||
form.setValue("name", generateRandomName(), { shouldDirty: true });
|
||||
};
|
||||
const onGenerateName = () => {
|
||||
form.setValue("name", generateRandomName(), { shouldDirty: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FormControl render={<Input min={1} max={64} {...field} />} />
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<FormControl render={<Input min={1} max={64} {...field} />} />
|
||||
|
||||
<Button size="icon" variant="outline" title={t`Generate a random name`} onClick={onGenerateName}>
|
||||
<MagicWandIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>Tip: You can name the resume referring to the position you are applying for.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button size="icon" variant="outline" title={t`Generate a random name`} onClick={onGenerateName}>
|
||||
<MagicWandIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>Tip: You can name the resume referring to the position you are applying for.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Slug</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start" className="hidden sm:flex">
|
||||
<InputGroupText>{slugPrefix}</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput min={1} max={64} className="ps-0!" {...field} />
|
||||
</InputGroup>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>This is a URL-friendly name for your resume.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<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!" {...field} />
|
||||
</InputGroup>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>This is a URL-friendly name for your resume.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tags"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Tags</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<ChipInput {...field} />} />
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>Tags can be used to categorize your resume by keywords.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tags"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Tags</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<ChipInput {...field} />} />
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>Tags can be used to categorize your resume by keywords.</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,222 +25,222 @@ const formSchema = awardItemSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
title: data?.item?.title ?? "",
|
||||
awarder: data?.item?.awarder ?? "",
|
||||
date: data?.item?.date ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
title: data?.item?.title ?? "",
|
||||
awarder: data?.item?.awarder ?? "",
|
||||
date: data?.item?.date ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.awards.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.awards.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new award</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new award</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<AwardForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<AwardForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
title: data.item.title,
|
||||
awarder: data.item.awarder,
|
||||
date: data.item.date,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
title: data.item.title,
|
||||
awarder: data.item.awarder,
|
||||
date: data.item.date,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.awards.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.awards.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.awards.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.awards.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing award</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing award</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<AwardForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<AwardForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function AwardForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const form = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Title</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Title</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="awarder"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans context="(noun) person, organization, or entity that gives an award">Awarder</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="awarder"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans context="(noun) person, organization, or entity that gives an award">Awarder</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="date"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Date</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="date"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Date</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,222 +25,222 @@ const formSchema = certificationItemSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateCertificationDialog({ data }: DialogProps<"resume.sections.certifications.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
title: data?.item?.title ?? "",
|
||||
issuer: data?.item?.issuer ?? "",
|
||||
date: data?.item?.date ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
title: data?.item?.title ?? "",
|
||||
issuer: data?.item?.issuer ?? "",
|
||||
date: data?.item?.date ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.certifications.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.certifications.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new certification</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new certification</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CertificationForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CertificationForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections.certifications.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
title: data.item.title,
|
||||
issuer: data.item.issuer,
|
||||
date: data.item.date,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
title: data.item.title,
|
||||
issuer: data.item.issuer,
|
||||
date: data.item.date,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.certifications.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.certifications.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.certifications.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.certifications.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing certification</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing certification</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CertificationForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CertificationForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function CertificationForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const form = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Title</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Title</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="issuer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Issuer</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="issuer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Issuer</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="date"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Date</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="date"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Date</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,149 +22,149 @@ const formSchema = coverLetterItemSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateCoverLetterDialog({ data }: DialogProps<"resume.sections.cover-letter.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
recipient: data?.item?.recipient ?? "",
|
||||
content: data?.item?.content ?? "",
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
recipient: data?.item?.recipient ?? "",
|
||||
content: data?.item?.content ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new cover letter</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new cover letter</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CoverLetterForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CoverLetterForm />
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateCoverLetterDialog({ data }: DialogProps<"resume.sections.cover-letter.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
recipient: data.item.recipient,
|
||||
content: data.item.content,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
recipient: data.item.recipient,
|
||||
content: data.item.content,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing cover letter</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing cover letter</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CoverLetterForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CoverLetterForm />
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function CoverLetterForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const form = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipient"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Recipient</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipient"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Recipient</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Content</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Content</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,182 +26,182 @@ const formSchema = customSectionSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
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` },
|
||||
{ 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` },
|
||||
];
|
||||
|
||||
export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections.custom.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
title: data?.title ?? "",
|
||||
type: data?.type ?? "experience",
|
||||
columns: data?.columns ?? 1,
|
||||
hidden: data?.hidden ?? false,
|
||||
items: data?.items ?? [],
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
title: data?.title ?? "",
|
||||
type: data?.type ?? "experience",
|
||||
columns: data?.columns ?? 1,
|
||||
hidden: data?.hidden ?? false,
|
||||
items: data?.items ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.customSections.push(formData);
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
draft.customSections.push(formData);
|
||||
|
||||
const lastPageIndex = draft.metadata.layout.pages.length - 1;
|
||||
if (lastPageIndex < 0) return;
|
||||
draft.metadata.layout.pages[lastPageIndex].main.push(formData.id);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const lastPageIndex = draft.metadata.layout.pages.length - 1;
|
||||
if (lastPageIndex < 0) return;
|
||||
draft.metadata.layout.pages[lastPageIndex].main.push(formData.id);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new custom section</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new custom section</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CustomSectionForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CustomSectionForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections.custom.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
type: data.type,
|
||||
columns: data.columns,
|
||||
hidden: data.hidden,
|
||||
items: data.items,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
type: data.type,
|
||||
columns: data.columns,
|
||||
hidden: data.hidden,
|
||||
items: data.items,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
const index = draft.customSections.findIndex((item) => item.id === formData.id);
|
||||
if (index === -1) return;
|
||||
draft.customSections[index] = formData;
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
const index = draft.customSections.findIndex((item) => item.id === formData.id);
|
||||
if (index === -1) return;
|
||||
draft.customSections[index] = formData;
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing custom section</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing custom section</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CustomSectionForm isUpdate />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CustomSectionForm isUpdate />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomSectionForm({ isUpdate = false }: { isUpdate?: boolean }) {
|
||||
const { i18n } = useLingui();
|
||||
const form = useFormContext<FormValues>();
|
||||
const { i18n } = useLingui();
|
||||
const form = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Title</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Title</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Section Type</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
{...field}
|
||||
value={field.value}
|
||||
disabled={isUpdate}
|
||||
onValueChange={field.onChange}
|
||||
options={SECTION_TYPE_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: i18n.t(option.label),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Section Type</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Combobox
|
||||
{...field}
|
||||
value={field.value}
|
||||
disabled={isUpdate}
|
||||
onValueChange={field.onChange}
|
||||
options={SECTION_TYPE_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: i18n.t(option.label),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,270 +25,270 @@ const formSchema = educationItemSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateEducationDialog({ data }: DialogProps<"resume.sections.education.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
school: data?.item?.school ?? "",
|
||||
degree: data?.item?.degree ?? "",
|
||||
area: data?.item?.area ?? "",
|
||||
grade: data?.item?.grade ?? "",
|
||||
location: data?.item?.location ?? "",
|
||||
period: data?.item?.period ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
school: data?.item?.school ?? "",
|
||||
degree: data?.item?.degree ?? "",
|
||||
area: data?.item?.area ?? "",
|
||||
grade: data?.item?.grade ?? "",
|
||||
location: data?.item?.location ?? "",
|
||||
period: data?.item?.period ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.education.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.education.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new education</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new education</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<EducationForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<EducationForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.education.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
school: data.item.school,
|
||||
degree: data.item.degree,
|
||||
area: data.item.area,
|
||||
grade: data.item.grade,
|
||||
location: data.item.location,
|
||||
period: data.item.period,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
school: data.item.school,
|
||||
degree: data.item.degree,
|
||||
area: data.item.area,
|
||||
grade: data.item.grade,
|
||||
location: data.item.location,
|
||||
period: data.item.period,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.education.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.education.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.education.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.education.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing education</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing education</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<EducationForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<EducationForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function EducationForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const form = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="school"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>School</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="school"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>School</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="degree"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Degree</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="degree"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Degree</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="area"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Area of Study</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="area"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Area of Study</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="grade"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Grade</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="grade"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Grade</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Location</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Location</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="period"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Period</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="period"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Period</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,379 +28,379 @@ const formSchema = experienceItemSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.experience.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
company: data?.item?.company ?? "",
|
||||
position: data?.item?.position ?? "",
|
||||
location: data?.item?.location ?? "",
|
||||
period: data?.item?.period ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
roles: data?.item?.roles ?? [],
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
company: data?.item?.company ?? "",
|
||||
position: data?.item?.position ?? "",
|
||||
location: data?.item?.location ?? "",
|
||||
period: data?.item?.period ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
roles: data?.item?.roles ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.experience.items.push(formData);
|
||||
}
|
||||
});
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.experience.items.push(formData);
|
||||
}
|
||||
});
|
||||
|
||||
closeDialog();
|
||||
};
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new experience</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new experience</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ExperienceForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ExperienceForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.experience.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
company: data.item.company,
|
||||
position: data.item.position,
|
||||
location: data.item.location,
|
||||
period: data.item.period,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
roles: data.item.roles ?? [],
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
company: data.item.company,
|
||||
position: data.item.position,
|
||||
location: data.item.location,
|
||||
period: data.item.period,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
roles: data.item.roles ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.experience.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.experience.items[index] = formData;
|
||||
}
|
||||
});
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.experience.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.experience.items[index] = formData;
|
||||
}
|
||||
});
|
||||
|
||||
closeDialog();
|
||||
};
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing experience</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing experience</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ExperienceForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ExperienceForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
type RoleFieldsProps = {
|
||||
role: RoleItem;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
role: RoleItem;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
function RoleFields({ role, index, onRemove }: RoleFieldsProps) {
|
||||
const form = useFormContext<FormValues>();
|
||||
const controls = useDragControls();
|
||||
const form = useFormContext<FormValues>();
|
||||
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 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>
|
||||
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 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>
|
||||
<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">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`roles.${index}.position`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Position</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid gap-4 p-4 sm:col-span-full sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`roles.${index}.position`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Position</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`roles.${index}.period`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Period</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`roles.${index}.period`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Period</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`roles.${index}.description`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`roles.${index}.description`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function ExperienceForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const form = useFormContext<FormValues>();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: "roles",
|
||||
keyName: "fieldId",
|
||||
control: form.control,
|
||||
});
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: "roles",
|
||||
keyName: "fieldId",
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
const hasRoles = useMemo(() => fields.length > 0, [fields]);
|
||||
const hasRoles = useMemo(() => fields.length > 0, [fields]);
|
||||
|
||||
const handleReorderRoles = (newOrder: RoleItem[]) => {
|
||||
form.setValue("roles", newOrder);
|
||||
};
|
||||
const handleReorderRoles = (newOrder: RoleItem[]) => {
|
||||
form.setValue("roles", newOrder);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="company"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Company</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="company"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Company</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="position"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{hasRoles ? <Trans>Overall Title (optional)</Trans> : <Trans>Position</Trans>}</FormLabel>
|
||||
<FormControl
|
||||
render={<Input {...field} placeholder={hasRoles ? "e.g. Software Engineer → Senior Engineer" : ""} />}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="position"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{hasRoles ? <Trans>Overall Title (optional)</Trans> : <Trans>Position</Trans>}</FormLabel>
|
||||
<FormControl
|
||||
render={<Input {...field} placeholder={hasRoles ? "e.g. Software Engineer → Senior Engineer" : ""} />}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Location</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Location</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="period"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{hasRoles ? <Trans>Overall Period</Trans> : <Trans>Period</Trans>}</FormLabel>
|
||||
<FormControl render={<Input {...field} placeholder={hasRoles ? "e.g. 2018 – Present" : ""} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="period"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{hasRoles ? <Trans>Overall Period</Trans> : <Trans>Period</Trans>}</FormLabel>
|
||||
<FormControl render={<Input {...field} placeholder={hasRoles ? "e.g. 2018 – Present" : ""} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel>
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel>
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 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-xs text-muted-foreground">
|
||||
<Trans>Add multiple roles to show career progression at the same company.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
{/* 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-xs text-muted-foreground">
|
||||
<Trans>Add multiple roles to show career progression at the same company.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={() => append({ id: generateId(), position: "", period: "", description: "" })}
|
||||
>
|
||||
<PlusIcon />
|
||||
<Trans>Add Role</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={() => append({ id: generateId(), position: "", period: "", description: "" })}
|
||||
>
|
||||
<PlusIcon />
|
||||
<Trans>Add Role</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasRoles && (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={fields}
|
||||
onReorder={handleReorderRoles}
|
||||
className="flex flex-col gap-4 sm:col-span-full"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{fields.map((field, index) => (
|
||||
<RoleFields key={field.id} role={fields[index]} index={index} onRemove={() => remove(index)} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
)}
|
||||
{hasRoles && (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={fields}
|
||||
onReorder={handleReorderRoles}
|
||||
className="flex flex-col gap-4 sm:col-span-full"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{fields.map((field, index) => (
|
||||
<RoleFields key={field.id} role={fields[index]} index={index} onRemove={() => remove(index)} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
)}
|
||||
|
||||
{/* Single Role Description — only show when no roles are defined */}
|
||||
{!hasRoles && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{/* Single Role Description — only show when no roles are defined */}
|
||||
{!hasRoles && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,177 +26,177 @@ const formSchema = interestItemSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateInterestDialog({ data }: DialogProps<"resume.sections.interests.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
icon: data?.item?.icon ?? "acorn",
|
||||
name: data?.item?.name ?? "",
|
||||
keywords: data?.item?.keywords ?? [],
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
icon: data?.item?.icon ?? "acorn",
|
||||
name: data?.item?.name ?? "",
|
||||
keywords: data?.item?.keywords ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.interests.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.interests.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new interest</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new interest</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<InterestForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<InterestForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.interests.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
icon: data.item.icon,
|
||||
name: data.item.name,
|
||||
keywords: data.item.keywords,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
icon: data.item.icon,
|
||||
name: data.item.name,
|
||||
keywords: data.item.keywords,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.interests.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.interests.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.interests.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.interests.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing interest</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing interest</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<InterestForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<InterestForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function InterestForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const nameState = useFormState({ control: form.control, name: "name" });
|
||||
const form = useFormContext<FormValues>();
|
||||
const nameState = useFormState({ control: form.control, name: "name" });
|
||||
|
||||
const isNameInvalid = useMemo(() => {
|
||||
return nameState.errors && Object.keys(nameState.errors).length > 0;
|
||||
}, [nameState]);
|
||||
const isNameInvalid = useMemo(() => {
|
||||
return nameState.errors && Object.keys(nameState.errors).length > 0;
|
||||
}, [nameState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("col-span-full flex items-end", isNameInvalid && "items-center")}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"icon"}
|
||||
render={({ field }) => (
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl
|
||||
render={
|
||||
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<div className={cn("col-span-full flex items-end", isNameInvalid && "items-center")}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"icon"}
|
||||
render={({ field }) => (
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl
|
||||
render={
|
||||
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input className="rounded-l-none!" {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input className="rounded-l-none!" {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keywords"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Keywords</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<ChipInput {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keywords"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Keywords</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<ChipInput {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,181 +24,181 @@ const formSchema = languageItemSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.languages.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
language: data?.item?.language ?? "",
|
||||
fluency: data?.item?.fluency ?? "",
|
||||
level: data?.item?.level ?? 0,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
language: data?.item?.language ?? "",
|
||||
fluency: data?.item?.fluency ?? "",
|
||||
level: data?.item?.level ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.languages.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.languages.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new language</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new language</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<LanguageForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<LanguageForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.languages.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
language: data.item.language,
|
||||
fluency: data.item.fluency,
|
||||
level: data.item.level,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
language: data.item.language,
|
||||
fluency: data.item.fluency,
|
||||
level: data.item.level,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.languages.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.languages.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.languages.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.languages.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing language</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing language</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<LanguageForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<LanguageForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function LanguageForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const form = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Language</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Language</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fluency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Fluency</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fluency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Fluency</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="level"
|
||||
render={({ field }) => (
|
||||
<FormItem className="gap-4 sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Level</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Slider
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={(value) => field.onChange(Array.isArray(value) ? value[0] : value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
<FormDescription>{Number(field.value) === 0 ? t`Hidden` : `${field.value} / 5`}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="level"
|
||||
render={({ field }) => (
|
||||
<FormItem className="gap-4 sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Level</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Slider
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={(value) => field.onChange(Array.isArray(value) ? value[0] : value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
<FormDescription>{Number(field.value) === 0 ? t`Hidden` : `${field.value} / 5`}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,221 +26,221 @@ const formSchema = profileItemSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profiles.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
icon: data?.item?.icon ?? "acorn",
|
||||
network: data?.item?.network ?? "",
|
||||
username: data?.item?.username ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
icon: data?.item?.icon ?? "acorn",
|
||||
network: data?.item?.network ?? "",
|
||||
username: data?.item?.username ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.profiles.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.profiles.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new profile</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new profile</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ProfileForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ProfileForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profiles.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
icon: data.item.icon,
|
||||
network: data.item.network,
|
||||
username: data.item.username,
|
||||
website: data.item.website,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
icon: data.item.icon,
|
||||
network: data.item.network,
|
||||
username: data.item.username,
|
||||
website: data.item.website,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.profiles.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.profiles.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.profiles.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.profiles.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing profile</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing profile</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ProfileForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ProfileForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const networkState = useFormState({ control: form.control, name: "network" });
|
||||
const form = useFormContext<FormValues>();
|
||||
const networkState = useFormState({ control: form.control, name: "network" });
|
||||
|
||||
const isNetworkInvalid = useMemo(() => {
|
||||
return networkState.errors && Object.keys(networkState.errors).length > 0;
|
||||
}, [networkState]);
|
||||
const isNetworkInvalid = useMemo(() => {
|
||||
return networkState.errors && Object.keys(networkState.errors).length > 0;
|
||||
}, [networkState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex items-end", isNetworkInvalid && "items-center")}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"icon"}
|
||||
render={({ field }) => (
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl
|
||||
render={
|
||||
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex items-end", isNetworkInvalid && "items-center")}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"icon"}
|
||||
render={({ field }) => (
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl
|
||||
render={
|
||||
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="network"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Network</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input className="rounded-l-none!" {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="network"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Network</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input className="rounded-l-none!" {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Username</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>
|
||||
<AtIcon />
|
||||
</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Username</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>
|
||||
<AtIcon />
|
||||
</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
|
||||
<FormControl render={<InputGroupInput {...field} />} />
|
||||
</InputGroup>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormControl render={<InputGroupInput {...field} />} />
|
||||
</InputGroup>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,206 +25,206 @@ const formSchema = projectItemSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateProjectDialog({ data }: DialogProps<"resume.sections.projects.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
name: data?.item?.name ?? "",
|
||||
period: data?.item?.period ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
name: data?.item?.name ?? "",
|
||||
period: data?.item?.period ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.projects.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.projects.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new project</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new project</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ProjectForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ProjectForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateProjectDialog({ data }: DialogProps<"resume.sections.projects.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
name: data.item.name,
|
||||
period: data.item.period,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
name: data.item.name,
|
||||
period: data.item.period,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.projects.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.projects.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.projects.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.projects.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing project</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing project</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ProjectForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ProjectForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const form = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="period"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Period</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="period"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Period</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2 sm:col-span-full">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,222 +25,222 @@ const formSchema = publicationItemSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreatePublicationDialog({ data }: DialogProps<"resume.sections.publications.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
title: data?.item?.title ?? "",
|
||||
publisher: data?.item?.publisher ?? "",
|
||||
date: data?.item?.date ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
title: data?.item?.title ?? "",
|
||||
publisher: data?.item?.publisher ?? "",
|
||||
date: data?.item?.date ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.publications.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.publications.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new publication</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new publication</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<PublicationForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<PublicationForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdatePublicationDialog({ data }: DialogProps<"resume.sections.publications.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
title: data.item.title,
|
||||
publisher: data.item.publisher,
|
||||
date: data.item.date,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
title: data.item.title,
|
||||
publisher: data.item.publisher,
|
||||
date: data.item.date,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.publications.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.publications.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.publications.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.publications.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing publication</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing publication</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<PublicationForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<PublicationForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicationForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const form = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Title</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Title</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="publisher"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Publisher</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="publisher"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Publisher</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="date"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Date</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="date"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Date</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,222 +25,222 @@ const formSchema = referenceItemSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateReferenceDialog({ data }: DialogProps<"resume.sections.references.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
name: data?.item?.name ?? "",
|
||||
position: data?.item?.position ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
phone: data?.item?.phone ?? "",
|
||||
description: data?.item?.description ?? "",
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
name: data?.item?.name ?? "",
|
||||
position: data?.item?.position ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
phone: data?.item?.phone ?? "",
|
||||
description: data?.item?.description ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.references.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.references.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new reference</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new reference</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ReferenceForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ReferenceForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateReferenceDialog({ data }: DialogProps<"resume.sections.references.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
name: data.item.name,
|
||||
position: data.item.position,
|
||||
website: data.item.website,
|
||||
phone: data.item.phone,
|
||||
description: data.item.description,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
name: data.item.name,
|
||||
position: data.item.position,
|
||||
website: data.item.website,
|
||||
phone: data.item.phone,
|
||||
description: data.item.description,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.references.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.references.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.references.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.references.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing reference</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing reference</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ReferenceForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ReferenceForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferenceForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const form = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="position"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Position</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="position"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Position</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Phone</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Phone</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,220 +28,220 @@ const formSchema = skillItemSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
icon: data?.item?.icon ?? "acorn",
|
||||
name: data?.item?.name ?? "",
|
||||
proficiency: data?.item?.proficiency ?? "",
|
||||
level: data?.item?.level ?? 0,
|
||||
keywords: data?.item?.keywords ?? [],
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
icon: data?.item?.icon ?? "acorn",
|
||||
name: data?.item?.name ?? "",
|
||||
proficiency: data?.item?.proficiency ?? "",
|
||||
level: data?.item?.level ?? 0,
|
||||
keywords: data?.item?.keywords ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.skills.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.skills.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new skill</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new skill</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SkillForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SkillForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
icon: data.item.icon,
|
||||
name: data.item.name,
|
||||
proficiency: data.item.proficiency,
|
||||
level: data.item.level,
|
||||
keywords: data.item.keywords,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
icon: data.item.icon,
|
||||
name: data.item.name,
|
||||
proficiency: data.item.proficiency,
|
||||
level: data.item.level,
|
||||
keywords: data.item.keywords,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.skills.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.skills.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.skills.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.skills.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing skill</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing skill</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SkillForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SkillForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const nameState = useFormState({ control: form.control, name: "name" });
|
||||
const form = useFormContext<FormValues>();
|
||||
const nameState = useFormState({ control: form.control, name: "name" });
|
||||
|
||||
const isNameInvalid = useMemo(() => {
|
||||
return nameState.errors && Object.keys(nameState.errors).length > 0;
|
||||
}, [nameState]);
|
||||
const isNameInvalid = useMemo(() => {
|
||||
return nameState.errors && Object.keys(nameState.errors).length > 0;
|
||||
}, [nameState]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex items-end", isNameInvalid && "items-center")}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"icon"}
|
||||
render={({ field }) => (
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl
|
||||
render={
|
||||
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex items-end", isNameInvalid && "items-center")}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={"icon"}
|
||||
render={({ field }) => (
|
||||
<FormItem className="shrink-0">
|
||||
<FormControl
|
||||
render={
|
||||
<IconPicker {...field} popoverProps={{ modal: true }} className="rounded-r-none! border-e-0!" />
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input className="rounded-l-none!" {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input className="rounded-l-none!" {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="proficiency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Proficiency</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="proficiency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Proficiency</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="level"
|
||||
render={({ field }) => (
|
||||
<FormItem className="gap-4 sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Level</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Slider
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={(value) => field.onChange(Array.isArray(value) ? value[0] : value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
<FormDescription>{Number(field.value) === 0 ? t`Hidden` : `${field.value} / 5`}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="level"
|
||||
render={({ field }) => (
|
||||
<FormItem className="gap-4 sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Level</Trans>
|
||||
</FormLabel>
|
||||
<FormControl
|
||||
render={
|
||||
<Slider
|
||||
min={0}
|
||||
max={5}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={(value) => field.onChange(Array.isArray(value) ? value[0] : value)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormMessage />
|
||||
<FormDescription>{Number(field.value) === 0 ? t`Hidden` : `${field.value} / 5`}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keywords"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Keywords</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<ChipInput {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keywords"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Keywords</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<ChipInput {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,131 +22,131 @@ const formSchema = summaryItemSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateSummaryItemDialog({ data }: DialogProps<"resume.sections.summary.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
content: data?.item?.content ?? "",
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
content: data?.item?.content ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new summary item</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new summary item</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SummaryItemForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SummaryItemForm />
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateSummaryItemDialog({ data }: DialogProps<"resume.sections.summary.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeStore = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeStore = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
content: data.item.content,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
content: data.item.content,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing summary item</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing summary item</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SummaryItemForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<SummaryItemForm />
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItemForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const form = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Content</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Content</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,222 +25,222 @@ const formSchema = volunteerItemSchema;
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateVolunteerDialog({ data }: DialogProps<"resume.sections.volunteer.create">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
organization: data?.item?.organization ?? "",
|
||||
location: data?.item?.location ?? "",
|
||||
period: data?.item?.period ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: generateId(),
|
||||
hidden: data?.item?.hidden ?? false,
|
||||
options: data?.item?.options ?? { showLinkInTitle: false },
|
||||
organization: data?.item?.organization ?? "",
|
||||
location: data?.item?.location ?? "",
|
||||
period: data?.item?.period ?? "",
|
||||
website: data?.item?.website ?? { url: "", label: "" },
|
||||
description: data?.item?.description ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.volunteer.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.volunteer.items.push(formData);
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new volunteer experience</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PlusIcon />
|
||||
<Trans>Create a new volunteer experience</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<VolunteerForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<VolunteerForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Create</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdateVolunteerDialog({ data }: DialogProps<"resume.sections.volunteer.update">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
organization: data.item.organization,
|
||||
location: data.item.location,
|
||||
period: data.item.period,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
},
|
||||
});
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
id: data.item.id,
|
||||
hidden: data.item.hidden,
|
||||
options: data.item.options ?? { showLinkInTitle: false },
|
||||
organization: data.item.organization,
|
||||
location: data.item.location,
|
||||
period: data.item.period,
|
||||
website: data.item.website,
|
||||
description: data.item.description,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.volunteer.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.volunteer.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
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 === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.volunteer.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.volunteer.items[index] = formData;
|
||||
}
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
const { blockEvents, requestClose } = useFormBlocker(form);
|
||||
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing volunteer experience</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
return (
|
||||
<DialogContent {...blockEvents}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-x-2">
|
||||
<PencilSimpleLineIcon />
|
||||
<Trans>Update an existing volunteer experience</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<VolunteerForm />
|
||||
<Form {...form}>
|
||||
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<VolunteerForm />
|
||||
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<DialogFooter className="sm:col-span-full">
|
||||
<Button variant="ghost" onClick={requestClose}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Trans>Save Changes</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function VolunteerForm() {
|
||||
const form = useFormContext<FormValues>();
|
||||
const form = useFormContext<FormValues>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organization"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Organization</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organization"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Organization</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Location</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Location</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="period"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Period</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="period"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Period</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<Input {...field} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="website"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Website</Trans>
|
||||
</FormLabel>
|
||||
<URLInput
|
||||
{...field}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
hideLabelButton={form.watch("options.showLinkInTitle")}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="options.showLinkInTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-x-2">
|
||||
<FormControl render={<Switch checked={field.value} onCheckedChange={field.onChange} />} />
|
||||
<FormLabel className="mt-0!">
|
||||
<Trans>Show link in title</Trans>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="sm:col-span-full">
|
||||
<FormLabel>
|
||||
<Trans>Description</Trans>
|
||||
</FormLabel>
|
||||
<FormControl render={<RichInput {...field} value={field.value} onChange={field.onChange} />} />
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,103 +5,103 @@ import { msg } from "@lingui/core/macro";
|
||||
import type { Template } from "@/schema/templates";
|
||||
|
||||
export type TemplateMetadata = {
|
||||
name: string;
|
||||
description: MessageDescriptor;
|
||||
imageUrl: string;
|
||||
tags: string[];
|
||||
sidebarPosition: "left" | "right" | "none";
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
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",
|
||||
},
|
||||
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>;
|
||||
|
||||
@@ -16,108 +16,108 @@ import { cn } from "@/utils/style";
|
||||
import { type TemplateMetadata, templates } from "./data";
|
||||
|
||||
export function TemplateGalleryDialog(_: DialogProps<"resume.template.gallery">) {
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const selectedTemplate = useResumeStore((state) => state.resume.data.metadata.template);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
const selectedTemplate = useResumeStore((state) => state.resume.data.metadata.template);
|
||||
const updateResumeData = useResumeStore((state) => state.updateResumeData);
|
||||
|
||||
function onSelectTemplate(template: Template) {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.template = template;
|
||||
});
|
||||
function onSelectTemplate(template: Template) {
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.template = template;
|
||||
});
|
||||
|
||||
closeDialog();
|
||||
}
|
||||
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>
|
||||
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>
|
||||
);
|
||||
<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;
|
||||
id: Template;
|
||||
isActive?: boolean;
|
||||
metadata: TemplateMetadata;
|
||||
onSelect: (template: Template) => void;
|
||||
};
|
||||
|
||||
function TemplateCard({ id, metadata, isActive, onSelect }: TemplateCardProps) {
|
||||
const { i18n } = useLingui();
|
||||
const { i18n } = useLingui();
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<CometCard translateDepth={3} rotateDepth={6} glareOpacity={0}>
|
||||
<HoverCardTrigger
|
||||
render={
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
return (
|
||||
<HoverCard>
|
||||
<CometCard translateDepth={3} rotateDepth={6} glareOpacity={0}>
|
||||
<HoverCardTrigger
|
||||
render={
|
||||
<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="leading-loose font-bold tracking-tight">{metadata.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="leading-loose font-bold 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="text-lg font-semibold">{metadata.name}</h3>
|
||||
<p className="text-muted-foreground">{i18n.t(metadata.description)}</p>
|
||||
</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="text-lg font-semibold">{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>
|
||||
);
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
+176
-176
@@ -2,159 +2,159 @@ import z from "zod";
|
||||
import { create } from "zustand/react";
|
||||
|
||||
import {
|
||||
awardItemSchema,
|
||||
certificationItemSchema,
|
||||
coverLetterItemSchema,
|
||||
customSectionSchema,
|
||||
educationItemSchema,
|
||||
experienceItemSchema,
|
||||
interestItemSchema,
|
||||
languageItemSchema,
|
||||
profileItemSchema,
|
||||
projectItemSchema,
|
||||
publicationItemSchema,
|
||||
referenceItemSchema,
|
||||
skillItemSchema,
|
||||
summaryItemSchema,
|
||||
volunteerItemSchema,
|
||||
awardItemSchema,
|
||||
certificationItemSchema,
|
||||
coverLetterItemSchema,
|
||||
customSectionSchema,
|
||||
educationItemSchema,
|
||||
experienceItemSchema,
|
||||
interestItemSchema,
|
||||
languageItemSchema,
|
||||
profileItemSchema,
|
||||
projectItemSchema,
|
||||
publicationItemSchema,
|
||||
referenceItemSchema,
|
||||
skillItemSchema,
|
||||
summaryItemSchema,
|
||||
volunteerItemSchema,
|
||||
} from "@/schema/resume/data";
|
||||
|
||||
const dialogTypeSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("auth.change-password"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("auth.two-factor.enable"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("auth.two-factor.disable"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("api-key.create"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("resume.create"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.update"),
|
||||
data: z.object({ id: z.string(), name: z.string(), slug: z.string(), tags: z.array(z.string()) }),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.import"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.duplicate"),
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
shouldRedirect: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.template.gallery"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.profiles.create"),
|
||||
data: z.object({ item: profileItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.profiles.update"),
|
||||
data: z.object({ item: profileItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.experience.create"),
|
||||
data: z.object({ item: experienceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.experience.update"),
|
||||
data: z.object({ item: experienceItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.education.create"),
|
||||
data: z.object({ item: educationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.education.update"),
|
||||
data: z.object({ item: educationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.projects.create"),
|
||||
data: z.object({ item: projectItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.projects.update"),
|
||||
data: z.object({ item: projectItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.skills.create"),
|
||||
data: z.object({ item: skillItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.skills.update"),
|
||||
data: z.object({ item: skillItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.languages.create"),
|
||||
data: z.object({ item: languageItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.languages.update"),
|
||||
data: z.object({ item: languageItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.awards.create"),
|
||||
data: z.object({ item: awardItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.awards.update"),
|
||||
data: z.object({ item: awardItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.certifications.create"),
|
||||
data: z.object({ item: certificationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.certifications.update"),
|
||||
data: z.object({ item: certificationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.publications.create"),
|
||||
data: z.object({ item: publicationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.publications.update"),
|
||||
data: z.object({ item: publicationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.interests.create"),
|
||||
data: z.object({ item: interestItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.interests.update"),
|
||||
data: z.object({ item: interestItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.volunteer.create"),
|
||||
data: z.object({ item: volunteerItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.volunteer.update"),
|
||||
data: z.object({ item: volunteerItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.references.create"),
|
||||
data: z.object({ item: referenceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.references.update"),
|
||||
data: z.object({ item: referenceItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.summary.create"),
|
||||
data: z.object({ item: summaryItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.summary.update"),
|
||||
data: z.object({ item: summaryItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.cover-letter.create"),
|
||||
data: z.object({ item: coverLetterItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.cover-letter.update"),
|
||||
data: z.object({ item: coverLetterItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.sections.custom.create"), data: customSectionSchema.optional() }),
|
||||
z.object({ type: z.literal("resume.sections.custom.update"), data: customSectionSchema }),
|
||||
z.object({ type: z.literal("auth.change-password"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("auth.two-factor.enable"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("auth.two-factor.disable"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("api-key.create"), data: z.undefined() }),
|
||||
z.object({ type: z.literal("resume.create"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.update"),
|
||||
data: z.object({ id: z.string(), name: z.string(), slug: z.string(), tags: z.array(z.string()) }),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.import"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.duplicate"),
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
tags: z.array(z.string()),
|
||||
shouldRedirect: z.boolean().optional(),
|
||||
}),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.template.gallery"), data: z.undefined() }),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.profiles.create"),
|
||||
data: z.object({ item: profileItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.profiles.update"),
|
||||
data: z.object({ item: profileItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.experience.create"),
|
||||
data: z.object({ item: experienceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.experience.update"),
|
||||
data: z.object({ item: experienceItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.education.create"),
|
||||
data: z.object({ item: educationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.education.update"),
|
||||
data: z.object({ item: educationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.projects.create"),
|
||||
data: z.object({ item: projectItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.projects.update"),
|
||||
data: z.object({ item: projectItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.skills.create"),
|
||||
data: z.object({ item: skillItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.skills.update"),
|
||||
data: z.object({ item: skillItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.languages.create"),
|
||||
data: z.object({ item: languageItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.languages.update"),
|
||||
data: z.object({ item: languageItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.awards.create"),
|
||||
data: z.object({ item: awardItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.awards.update"),
|
||||
data: z.object({ item: awardItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.certifications.create"),
|
||||
data: z.object({ item: certificationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.certifications.update"),
|
||||
data: z.object({ item: certificationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.publications.create"),
|
||||
data: z.object({ item: publicationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.publications.update"),
|
||||
data: z.object({ item: publicationItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.interests.create"),
|
||||
data: z.object({ item: interestItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.interests.update"),
|
||||
data: z.object({ item: interestItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.volunteer.create"),
|
||||
data: z.object({ item: volunteerItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.volunteer.update"),
|
||||
data: z.object({ item: volunteerItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.references.create"),
|
||||
data: z.object({ item: referenceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.references.update"),
|
||||
data: z.object({ item: referenceItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.summary.create"),
|
||||
data: z.object({ item: summaryItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.summary.update"),
|
||||
data: z.object({ item: summaryItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.cover-letter.create"),
|
||||
data: z.object({ item: coverLetterItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("resume.sections.cover-letter.update"),
|
||||
data: z.object({ item: coverLetterItemSchema, customSectionId: z.string().optional() }),
|
||||
}),
|
||||
z.object({ type: z.literal("resume.sections.custom.create"), data: customSectionSchema.optional() }),
|
||||
z.object({ type: z.literal("resume.sections.custom.update"), data: customSectionSchema }),
|
||||
]);
|
||||
|
||||
type DialogSchema = z.infer<typeof dialogTypeSchema>;
|
||||
@@ -167,39 +167,39 @@ type DialogPropsData<T extends DialogType> = DialogData<T> extends undefined ? {
|
||||
export type DialogProps<T extends DialogType> = DialogPropsData<T>;
|
||||
|
||||
interface DialogStoreState {
|
||||
open: boolean;
|
||||
activeDialog: DialogSchema | null;
|
||||
open: boolean;
|
||||
activeDialog: DialogSchema | null;
|
||||
}
|
||||
|
||||
interface DialogStoreActions {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
openDialog: <T extends DialogType>(type: T, data: DialogData<T>) => void;
|
||||
closeDialog: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
openDialog: <T extends DialogType>(type: T, data: DialogData<T>) => void;
|
||||
closeDialog: () => void;
|
||||
}
|
||||
|
||||
type DialogStore = DialogStoreState & DialogStoreActions;
|
||||
|
||||
export const useDialogStore = create<DialogStore>((set) => ({
|
||||
open: false,
|
||||
activeDialog: null,
|
||||
onOpenChange: (open) => {
|
||||
set({ open });
|
||||
open: false,
|
||||
activeDialog: null,
|
||||
onOpenChange: (open) => {
|
||||
set({ open });
|
||||
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
set({ activeDialog: null });
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
openDialog: (type, data) =>
|
||||
set({
|
||||
open: true,
|
||||
activeDialog: { type, data } as DialogSchema,
|
||||
}),
|
||||
closeDialog: () => {
|
||||
set({ open: false });
|
||||
setTimeout(() => {
|
||||
set({ activeDialog: null });
|
||||
}, 300);
|
||||
},
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
set({ activeDialog: null });
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
openDialog: (type, data) =>
|
||||
set({
|
||||
open: true,
|
||||
activeDialog: { type, data } as DialogSchema,
|
||||
}),
|
||||
closeDialog: () => {
|
||||
set({ open: false });
|
||||
setTimeout(() => {
|
||||
set({ activeDialog: null });
|
||||
}, 300);
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user