mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-19 03:01:53 +10:00
refactor(v4.0.0-alpha): beginning of a new era
This commit is contained in:
129
apps/client/src/pages/dashboard/_components/sidebar.tsx
Normal file
129
apps/client/src/pages/dashboard/_components/sidebar.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { FadersHorizontal, ReadCvLogo } from "@phosphor-icons/react";
|
||||
import { Button, KeyboardShortcut, Separator } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { motion } from "framer-motion";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import useKeyboardShortcut from "use-keyboard-shortcut";
|
||||
|
||||
import { Copyright } from "@/client/components/copyright";
|
||||
import { Icon } from "@/client/components/icon";
|
||||
import { UserAvatar } from "@/client/components/user-avatar";
|
||||
import { UserOptions } from "@/client/components/user-options";
|
||||
import { useUser } from "@/client/services/user";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ActiveIndicator = ({ className }: Props) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className={cn(
|
||||
"h-1.5 w-1.5 animate-pulse rounded-full bg-info shadow-[0_0_12px] shadow-info",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
interface SidebarItem {
|
||||
path: string;
|
||||
name: string;
|
||||
shortcut?: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{
|
||||
path: "/dashboard/resumes",
|
||||
name: "Resumes",
|
||||
shortcut: "⇧R",
|
||||
icon: <ReadCvLogo />,
|
||||
},
|
||||
{
|
||||
path: "/dashboard/settings",
|
||||
name: "Settings",
|
||||
shortcut: "⇧S",
|
||||
icon: <FadersHorizontal />,
|
||||
},
|
||||
];
|
||||
|
||||
type SidebarItemProps = SidebarItem & {
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const SidebarItem = ({ path, name, shortcut, icon, onClick }: SidebarItemProps) => {
|
||||
const isActive = useLocation().pathname === path;
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"h-auto justify-start px-4 py-3",
|
||||
isActive && "pointer-events-none bg-secondary/50 text-secondary-foreground",
|
||||
)}
|
||||
>
|
||||
<Link to={path}>
|
||||
<div className="mr-3">{icon}</div>
|
||||
<span>{name}</span>
|
||||
{!isActive && <KeyboardShortcut className="ml-auto">{shortcut}</KeyboardShortcut>}
|
||||
{isActive && <ActiveIndicator className="ml-auto" />}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
type SidebarProps = {
|
||||
setOpen?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const Sidebar = ({ setOpen }: SidebarProps) => {
|
||||
const { user } = useUser();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useKeyboardShortcut(["shift", "r"], () => {
|
||||
navigate("/dashboard/resumes");
|
||||
setOpen?.(false);
|
||||
});
|
||||
|
||||
useKeyboardShortcut(["shift", "s"], () => {
|
||||
navigate("/dashboard/settings");
|
||||
setOpen?.(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-y-4">
|
||||
<div className="ml-12 flex justify-center lg:ml-0">
|
||||
<Button asChild size="icon" variant="ghost" className="h-10 w-10 p-0">
|
||||
<Link to="/">
|
||||
<Icon size={24} className="mx-auto hidden lg:block" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="opacity-50" />
|
||||
|
||||
<div className="grid gap-y-2">
|
||||
{sidebarItems.map((item) => (
|
||||
<SidebarItem {...item} key={item.path} onClick={() => setOpen?.(false)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Separator className="opacity-50" />
|
||||
|
||||
<UserOptions>
|
||||
<Button size="lg" variant="ghost" className="w-full justify-start px-3">
|
||||
<UserAvatar size={24} className="mr-3" />
|
||||
<span>{user?.name}</span>
|
||||
</Button>
|
||||
</UserOptions>
|
||||
|
||||
<Copyright className="ml-2" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
49
apps/client/src/pages/dashboard/layout.tsx
Normal file
49
apps/client/src/pages/dashboard/layout.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { SidebarSimple } from "@phosphor-icons/react";
|
||||
import { Button, Sheet, SheetClose, SheetContent, SheetTrigger } from "@reactive-resume/ui";
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
import { Sidebar } from "./_components/sidebar";
|
||||
|
||||
export const DashboardLayout = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sticky top-0 z-50 flex items-center justify-between p-4 lg:hidden">
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button size="icon" variant="ghost" className="bg-background">
|
||||
<SidebarSimple />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
|
||||
<SheetContent showClose={false} side="left" className="focus-visible:outline-none">
|
||||
<SheetClose asChild className="absolute left-4 top-4">
|
||||
<Button size="icon" variant="ghost">
|
||||
<SidebarSimple />
|
||||
</Button>
|
||||
</SheetClose>
|
||||
|
||||
<Sidebar setOpen={setOpen} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ x: -320 }}
|
||||
animate={{ x: 0 }}
|
||||
className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-[320px] lg:flex-col"
|
||||
>
|
||||
<div className="h-full rounded p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<main className="mx-6 my-4 lg:mx-8 lg:pl-[320px]">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
315
apps/client/src/pages/dashboard/resumes/_dialogs/import.tsx
Normal file
315
apps/client/src/pages/dashboard/resumes/_dialogs/import.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Check, DownloadSimple, Warning } from "@phosphor-icons/react";
|
||||
import {
|
||||
JsonResume,
|
||||
JsonResumeParser,
|
||||
LinkedIn,
|
||||
LinkedInParser,
|
||||
ReactiveResumeParser,
|
||||
ReactiveResumeV3,
|
||||
ReactiveResumeV3Parser,
|
||||
} from "@reactive-resume/parser";
|
||||
import { ResumeData } from "@reactive-resume/schema";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Label,
|
||||
ScrollArea,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z, ZodError } from "zod";
|
||||
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { useImportResume } from "@/client/services/resume/import";
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
enum ImportType {
|
||||
"reactive-resume-json" = "reactive-resume-json",
|
||||
"reactive-resume-v3-json" = "reactive-resume-v3-json",
|
||||
"json-resume-json" = "json-resume-json",
|
||||
"linkedin-data-export-zip" = "linkedin-data-export-zip",
|
||||
}
|
||||
|
||||
const formSchema = z.object({
|
||||
file: z.instanceof(File),
|
||||
type: z.nativeEnum(ImportType),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
type ValidationResult =
|
||||
| {
|
||||
isValid: false;
|
||||
errors: string;
|
||||
}
|
||||
| {
|
||||
isValid: true;
|
||||
type: ImportType;
|
||||
result: ResumeData | ReactiveResumeV3 | LinkedIn | JsonResume;
|
||||
};
|
||||
|
||||
export const ImportDialog = () => {
|
||||
const { toast } = useToast();
|
||||
const { isOpen, close } = useDialog("import");
|
||||
const { importResume, loading, error: importError } = useImportResume();
|
||||
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
const filetype = form.watch("type");
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) onReset();
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ file: undefined, type: filetype });
|
||||
setValidationResult(null);
|
||||
}, [filetype]);
|
||||
|
||||
const accept = useMemo(() => {
|
||||
if (!filetype) return "";
|
||||
if (filetype.includes("json")) return ".json";
|
||||
if (filetype.includes("zip")) return ".zip";
|
||||
return "";
|
||||
}, [filetype]);
|
||||
|
||||
const onValidate = async () => {
|
||||
const { file, type } = formSchema.parse(form.getValues());
|
||||
|
||||
try {
|
||||
if (type === ImportType["reactive-resume-json"]) {
|
||||
const parser = new ReactiveResumeParser();
|
||||
const data = await parser.readFile(file);
|
||||
const result = parser.validate(data);
|
||||
|
||||
setValidationResult({ isValid: true, type, result });
|
||||
}
|
||||
|
||||
if (type === ImportType["reactive-resume-v3-json"]) {
|
||||
const parser = new ReactiveResumeV3Parser();
|
||||
const data = await parser.readFile(file);
|
||||
const result = parser.validate(data);
|
||||
|
||||
setValidationResult({ isValid: true, type, result });
|
||||
}
|
||||
|
||||
if (type === ImportType["json-resume-json"]) {
|
||||
const parser = new JsonResumeParser();
|
||||
const data = await parser.readFile(file);
|
||||
const result = parser.validate(data);
|
||||
|
||||
setValidationResult({ isValid: true, type, result });
|
||||
}
|
||||
|
||||
if (type === ImportType["linkedin-data-export-zip"]) {
|
||||
const parser = new LinkedInParser();
|
||||
const data = await parser.readFile(file);
|
||||
const result = await parser.validate(data);
|
||||
|
||||
setValidationResult({ isValid: true, type, result });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
setValidationResult({
|
||||
isValid: false,
|
||||
errors: error.toString(),
|
||||
});
|
||||
|
||||
toast({
|
||||
variant: "error",
|
||||
icon: <Warning size={16} weight="bold" />,
|
||||
title: "An error occurred while validating the file.",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onImport = async () => {
|
||||
const { type } = formSchema.parse(form.getValues());
|
||||
|
||||
if (!validationResult?.isValid || validationResult.type !== type) return;
|
||||
|
||||
try {
|
||||
if (type === ImportType["reactive-resume-json"]) {
|
||||
const parser = new ReactiveResumeParser();
|
||||
const data = parser.convert(validationResult.result as ResumeData);
|
||||
|
||||
await importResume({ data });
|
||||
}
|
||||
|
||||
if (type === ImportType["reactive-resume-v3-json"]) {
|
||||
const parser = new ReactiveResumeV3Parser();
|
||||
const data = parser.convert(validationResult.result as ReactiveResumeV3);
|
||||
|
||||
await importResume({ data });
|
||||
}
|
||||
|
||||
if (type === ImportType["json-resume-json"]) {
|
||||
const parser = new JsonResumeParser();
|
||||
const data = parser.convert(validationResult.result as JsonResume);
|
||||
|
||||
await importResume({ data });
|
||||
}
|
||||
|
||||
if (type === ImportType["linkedin-data-export-zip"]) {
|
||||
const parser = new LinkedInParser();
|
||||
const data = parser.convert(validationResult.result as LinkedIn);
|
||||
|
||||
await importResume({ data });
|
||||
}
|
||||
|
||||
close();
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "error",
|
||||
icon: <Warning size={16} weight="bold" />,
|
||||
title: "An error occurred while importing your resume.",
|
||||
description: importError?.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
form.reset();
|
||||
setValidationResult(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={close}>
|
||||
<DialogContent>
|
||||
<Form {...form}>
|
||||
<form className="space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<DownloadSimple />
|
||||
<h2>Import an existing resume</h2>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload a file from an external source to parse an existing resume and import it into
|
||||
Reactive Resume for easier editing.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FormField
|
||||
name="type"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Please select a file type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="reactive-resume-json">
|
||||
Reactive Resume (.json)
|
||||
</SelectItem>
|
||||
<SelectItem value="reactive-resume-v3-json">
|
||||
Reactive Resume v3 (.json)
|
||||
</SelectItem>
|
||||
<SelectItem value="json-resume-json">JSON Resume (.json)</SelectItem>
|
||||
<SelectItem value="linkedin-data-export-zip">
|
||||
LinkedIn Data Export (.zip)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="file"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>File</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="file"
|
||||
key={accept}
|
||||
accept={accept}
|
||||
onChange={(event) => {
|
||||
if (!event.target.files || !event.target.files.length) return;
|
||||
field.onChange(event.target.files[0]);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{accept && <FormDescription>Accepts only {accept} files</FormDescription>}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{validationResult?.isValid === false && validationResult.errors !== undefined && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-error">Errors during Validation</Label>
|
||||
<ScrollArea orientation="vertical" className="h-[180px]">
|
||||
<div className="whitespace-pre-wrap rounded bg-secondary-accent p-4 font-mono text-xs leading-relaxed">
|
||||
{JSON.stringify(validationResult.errors, null, 4)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<AnimatePresence presenceAffectsLayout>
|
||||
{(!validationResult ?? false) && (
|
||||
<Button type="button" onClick={onValidate}>
|
||||
Validate
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{validationResult !== null && !validationResult.isValid && (
|
||||
<Button type="button" variant="secondary" onClick={onReset}>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{validationResult !== null && validationResult.isValid && (
|
||||
<>
|
||||
<Button type="button" onClick={onImport} disabled={loading}>
|
||||
Import
|
||||
</Button>
|
||||
|
||||
<Button disabled type="button" variant="success">
|
||||
<Check size={16} weight="bold" className="mr-2" />
|
||||
Validated
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
247
apps/client/src/pages/dashboard/resumes/_dialogs/resume.tsx
Normal file
247
apps/client/src/pages/dashboard/resumes/_dialogs/resume.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { MagicWand, Plus } from "@phosphor-icons/react";
|
||||
import { createResumeSchema, ResumeDto } from "@reactive-resume/dto";
|
||||
import { idSchema } from "@reactive-resume/schema";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Tooltip,
|
||||
} from "@reactive-resume/ui";
|
||||
import { generateRandomName, kebabCase } from "@reactive-resume/utils";
|
||||
import { AxiosError } from "axios";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { useCreateResume, useDeleteResume, useUpdateResume } from "@/client/services/resume";
|
||||
import { useImportResume } from "@/client/services/resume/import";
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
const formSchema = createResumeSchema.extend({ id: idSchema.optional() });
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const ResumeDialog = () => {
|
||||
const { toast } = useToast();
|
||||
const { isOpen, mode, payload, close } = useDialog<ResumeDto>("resume");
|
||||
|
||||
const isCreate = mode === "create";
|
||||
const isUpdate = mode === "update";
|
||||
const isDelete = mode === "delete";
|
||||
const isDuplicate = mode === "duplicate";
|
||||
|
||||
const { createResume, loading: createLoading } = useCreateResume();
|
||||
const { updateResume, loading: updateLoading } = useUpdateResume();
|
||||
const { deleteResume, loading: deleteLoading } = useDeleteResume();
|
||||
const { importResume: duplicateResume, loading: duplicateLoading } = useImportResume();
|
||||
|
||||
const loading = createLoading || updateLoading || deleteLoading || duplicateLoading;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { title: "", slug: "" },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) onReset();
|
||||
}, [isOpen, payload]);
|
||||
|
||||
useEffect(() => {
|
||||
const slug = kebabCase(form.watch("title"));
|
||||
form.setValue("slug", slug);
|
||||
}, [form.watch("title")]);
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
if (isCreate) {
|
||||
await createResume({ slug: values.slug, title: values.title, visibility: "private" });
|
||||
}
|
||||
|
||||
if (isUpdate) {
|
||||
if (!payload.item?.id) return;
|
||||
|
||||
await updateResume({
|
||||
...payload.item,
|
||||
title: values.title,
|
||||
slug: values.slug,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDuplicate) {
|
||||
if (!payload.item?.id) return;
|
||||
|
||||
await duplicateResume({
|
||||
title: values.title,
|
||||
slug: values.slug,
|
||||
data: payload.item.data,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDelete) {
|
||||
if (!payload.item?.id) return;
|
||||
|
||||
await deleteResume({ id: payload.item?.id });
|
||||
}
|
||||
|
||||
close();
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
const message = error.response?.data?.message || error.message;
|
||||
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "An error occurred while trying process your request.",
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onReset = () => {
|
||||
if (isCreate) form.reset({ title: "", slug: "" });
|
||||
if (isUpdate)
|
||||
form.reset({ id: payload.item?.id, title: payload.item?.title, slug: payload.item?.slug });
|
||||
if (isDuplicate)
|
||||
form.reset({ title: `${payload.item?.title} (Copy)`, slug: `${payload.item?.slug}-copy` });
|
||||
if (isDelete)
|
||||
form.reset({ id: payload.item?.id, title: payload.item?.title, slug: payload.item?.slug });
|
||||
};
|
||||
|
||||
const onGenerateRandomName = () => {
|
||||
const name = generateRandomName();
|
||||
form.setValue("title", name);
|
||||
form.setValue("slug", kebabCase(name));
|
||||
};
|
||||
|
||||
if (isDelete) {
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={close}>
|
||||
<AlertDialogContent>
|
||||
<Form {...form}>
|
||||
<form>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure you want to delete your resume?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete your resume and cannot
|
||||
be recovered.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction variant="error" onClick={form.handleSubmit(onSubmit)}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={close}>
|
||||
<DialogContent>
|
||||
<Form {...form}>
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<Plus />
|
||||
<h2>
|
||||
{isCreate && "Create a new resume"}
|
||||
{isUpdate && "Update an existing resume"}
|
||||
{isDuplicate && "Duplicate an existing resume"}
|
||||
</h2>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isCreate && "Start building your resume by giving it a name."}
|
||||
{isUpdate && "Changed your mind about the name? Give it a new one."}
|
||||
{isDuplicate && "Give your old resume a new name."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FormField
|
||||
name="title"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<Input {...field} className="flex-1" />
|
||||
|
||||
{(isCreate || isDuplicate) && (
|
||||
<Tooltip content="Generate a random name">
|
||||
<Button
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onGenerateRandomName}
|
||||
>
|
||||
<MagicWand />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Tip: You can name the resume referring to the position you are applying for.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="slug"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{isCreate && "Create"}
|
||||
{isUpdate && "Save Changes"}
|
||||
{isDuplicate && "Duplicate"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,25 @@
|
||||
import { Card } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import Tilt from "react-parallax-tilt";
|
||||
|
||||
import { defaultTiltProps } from "@/client/constants/parallax-tilt";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const BaseCard = ({ children, className, onClick }: Props) => (
|
||||
<Tilt {...defaultTiltProps}>
|
||||
<Card
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative flex aspect-[1/1.4142] scale-100 cursor-pointer items-center justify-center bg-secondary/50 p-0 transition-transform active:scale-95",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
</Tilt>
|
||||
);
|
||||
@ -0,0 +1,31 @@
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { KeyboardShortcut } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
import { BaseCard } from "./base-card";
|
||||
|
||||
export const CreateResumeCard = () => {
|
||||
const { open } = useDialog("resume");
|
||||
|
||||
return (
|
||||
<BaseCard onClick={() => open("create")}>
|
||||
<Plus size={64} weight="thin" />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 z-10 flex flex-col justify-end space-y-0.5 p-4 pt-12",
|
||||
"bg-gradient-to-t from-background/80 to-transparent",
|
||||
)}
|
||||
>
|
||||
<h4 className="font-medium">
|
||||
Create a new resume
|
||||
<KeyboardShortcut className="ml-2">(^N)</KeyboardShortcut>
|
||||
</h4>
|
||||
|
||||
<p className="text-xs opacity-75">Start from scratch</p>
|
||||
</div>
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
import { DownloadSimple } from "@phosphor-icons/react";
|
||||
import { KeyboardShortcut } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
import { BaseCard } from "./base-card";
|
||||
|
||||
export const ImportResumeCard = () => {
|
||||
const { open } = useDialog("import");
|
||||
|
||||
return (
|
||||
<BaseCard onClick={() => open("create")}>
|
||||
<DownloadSimple size={64} weight="thin" />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 z-10 flex flex-col justify-end space-y-0.5 p-4 pt-12",
|
||||
"bg-gradient-to-t from-background/80 to-transparent",
|
||||
)}
|
||||
>
|
||||
<h4 className="line-clamp-1 font-medium">
|
||||
Import an existing resume
|
||||
<KeyboardShortcut className="ml-2">(^I)</KeyboardShortcut>
|
||||
</h4>
|
||||
|
||||
<p className="line-clamp-1 text-xs opacity-75">LinkedIn, JSON Resume, etc.</p>
|
||||
</div>
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,121 @@
|
||||
import {
|
||||
CircleNotch,
|
||||
CopySimple,
|
||||
FolderOpen,
|
||||
PencilSimple,
|
||||
TrashSimple,
|
||||
} from "@phosphor-icons/react";
|
||||
import { ResumeDto } from "@reactive-resume/dto";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import dayjs from "dayjs";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useResumePreview } from "@/client/services/resume/preview";
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
import { BaseCard } from "./base-card";
|
||||
|
||||
type Props = {
|
||||
resume: ResumeDto;
|
||||
};
|
||||
|
||||
export const ResumeCard = ({ resume }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const { open } = useDialog<ResumeDto>("resume");
|
||||
|
||||
const { url, loading } = useResumePreview(resume.id);
|
||||
|
||||
const lastUpdated = dayjs().to(resume.updatedAt);
|
||||
|
||||
const onOpen = () => {
|
||||
navigate(`/builder/${resume.id}`);
|
||||
};
|
||||
|
||||
const onUpdate = () => {
|
||||
open("update", { id: "resume", item: resume });
|
||||
};
|
||||
|
||||
const onDuplicate = () => {
|
||||
open("duplicate", { id: "resume", item: resume });
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
open("delete", { id: "resume", item: resume });
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<BaseCard onClick={onOpen}>
|
||||
<AnimatePresence presenceAffectsLayout>
|
||||
{loading && (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<CircleNotch
|
||||
size={64}
|
||||
weight="thin"
|
||||
opacity={0.5}
|
||||
className="animate-spin self-center justify-self-center"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!loading && url && (
|
||||
<motion.img
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
src={url}
|
||||
loading="lazy"
|
||||
alt={resume.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 bottom-0 z-10 flex flex-col justify-end space-y-0.5 p-4 pt-12",
|
||||
"bg-gradient-to-t from-background/80 to-transparent",
|
||||
)}
|
||||
>
|
||||
<h4 className="line-clamp-2 font-medium">{resume.title}</h4>
|
||||
<p className="line-clamp-1 text-xs opacity-75">{`Last updated ${lastUpdated}`}</p>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onOpen}>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
Open
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onUpdate}>
|
||||
<PencilSimple size={14} className="mr-2" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onDuplicate}>
|
||||
<CopySimple size={14} className="mr-2" />
|
||||
Duplicate
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onDelete} className="text-error">
|
||||
<TrashSimple size={14} className="mr-2" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,57 @@
|
||||
import { sortByDate } from "@reactive-resume/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { useResumes } from "@/client/services/resume";
|
||||
|
||||
import { BaseCard } from "./_components/base-card";
|
||||
import { CreateResumeCard } from "./_components/create-card";
|
||||
import { ImportResumeCard } from "./_components/import-card";
|
||||
import { ResumeCard } from "./_components/resume-card";
|
||||
|
||||
export const GridView = () => {
|
||||
const { resumes, loading } = useResumes();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
<motion.div initial={{ opacity: 0, x: -50 }} animate={{ opacity: 1, x: 0 }}>
|
||||
<CreateResumeCard />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0, transition: { delay: 0.1 } }}
|
||||
>
|
||||
<ImportResumeCard />
|
||||
</motion.div>
|
||||
|
||||
{loading &&
|
||||
[...Array(4)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="duration-300 animate-in fade-in"
|
||||
style={{ animationFillMode: "backwards", animationDelay: `${i * 300}ms` }}
|
||||
>
|
||||
<BaseCard />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{resumes && (
|
||||
<AnimatePresence>
|
||||
{resumes
|
||||
.sort((a, b) => sortByDate(a, b, "updatedAt"))
|
||||
.map((resume, index) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={resume.id}
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0, transition: { delay: (index + 2) * 0.1 } }}
|
||||
exit={{ opacity: 0, filter: "blur(8px)", transition: { duration: 0.5 } }}
|
||||
>
|
||||
<ResumeCard resume={resume} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
|
||||
type Props = {
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
start?: React.ReactNode;
|
||||
end?: React.ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const BaseListItem = ({ title, description, start, end, className, onClick }: Props) => (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center rounded p-4 transition-colors hover:bg-secondary/30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex h-5 w-5 items-center justify-center">{start}</div>
|
||||
<h4 className="w-[220px] truncate font-medium lg:w-[320px]">{title}</h4>
|
||||
<p className="hidden text-xs opacity-75 sm:block">{description}</p>
|
||||
</div>
|
||||
|
||||
{end && <div className="flex h-5 w-5 items-center justify-center">{end}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -0,0 +1,25 @@
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { ResumeDto } from "@reactive-resume/dto";
|
||||
import { KeyboardShortcut } from "@reactive-resume/ui";
|
||||
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
import { BaseListItem } from "./base-item";
|
||||
|
||||
export const CreateResumeListItem = () => {
|
||||
const { open } = useDialog<ResumeDto>("resume");
|
||||
|
||||
return (
|
||||
<BaseListItem
|
||||
start={<Plus size={18} />}
|
||||
onClick={() => open("create")}
|
||||
title={
|
||||
<>
|
||||
<span>Create a new resume</span>
|
||||
<KeyboardShortcut className="ml-2">(^N)</KeyboardShortcut>
|
||||
</>
|
||||
}
|
||||
description="Start building from scratch"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
import { DownloadSimple } from "@phosphor-icons/react";
|
||||
import { KeyboardShortcut } from "@reactive-resume/ui";
|
||||
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
import { BaseListItem } from "./base-item";
|
||||
|
||||
export const ImportResumeListItem = () => {
|
||||
const { open } = useDialog("import");
|
||||
|
||||
return (
|
||||
<BaseListItem
|
||||
start={<DownloadSimple size={18} />}
|
||||
onClick={() => open("create")}
|
||||
title={
|
||||
<>
|
||||
<span>Import an existing resume</span>
|
||||
<KeyboardShortcut className="ml-2">(^I)</KeyboardShortcut>
|
||||
</>
|
||||
}
|
||||
description="LinkedIn, JSON Resume, etc."
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,163 @@
|
||||
import {
|
||||
CopySimple,
|
||||
DotsThreeVertical,
|
||||
FolderOpen,
|
||||
PencilSimple,
|
||||
TrashSimple,
|
||||
} from "@phosphor-icons/react";
|
||||
import { ResumeDto } from "@reactive-resume/dto";
|
||||
import {
|
||||
Button,
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@reactive-resume/ui";
|
||||
import dayjs from "dayjs";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useResumePreview } from "@/client/services/resume/preview";
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
import { BaseListItem } from "./base-item";
|
||||
|
||||
type Props = {
|
||||
resume: ResumeDto;
|
||||
};
|
||||
|
||||
export const ResumeListItem = ({ resume }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const { open } = useDialog<ResumeDto>("resume");
|
||||
|
||||
const { url } = useResumePreview(resume.id);
|
||||
|
||||
const lastUpdated = dayjs().to(resume.updatedAt);
|
||||
|
||||
const onOpen = () => {
|
||||
navigate(`/builder/${resume.id}`);
|
||||
};
|
||||
|
||||
const onUpdate = () => {
|
||||
open("update", { id: "resume", item: resume });
|
||||
};
|
||||
|
||||
const onDuplicate = () => {
|
||||
open("duplicate", { id: "resume", item: resume });
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
open("delete", { id: "resume", item: resume });
|
||||
};
|
||||
|
||||
const dropdownMenu = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="aspect-square">
|
||||
<Button size="icon" variant="ghost">
|
||||
<DotsThreeVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
Open
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onUpdate();
|
||||
}}
|
||||
>
|
||||
<PencilSimple size={14} className="mr-2" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<CopySimple size={14} className="mr-2" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-error"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<TrashSimple size={14} className="mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger className="even:bg-secondary/20">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<BaseListItem
|
||||
onClick={onOpen}
|
||||
className="group"
|
||||
title={resume.title}
|
||||
description={`Last updated ${lastUpdated}`}
|
||||
end={dropdownMenu}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="end" className="p-0" sideOffset={-100} alignOffset={100}>
|
||||
<AnimatePresence>
|
||||
{url && (
|
||||
<motion.img
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
src={url}
|
||||
loading="lazy"
|
||||
alt={resume.title}
|
||||
className="aspect-[1/1.4142] w-60 rounded-sm object-cover"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</ContextMenuTrigger>
|
||||
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onOpen}>
|
||||
<FolderOpen size={14} className="mr-2" />
|
||||
Open
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onUpdate}>
|
||||
<PencilSimple size={14} className="mr-2" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onDuplicate}>
|
||||
<CopySimple size={14} className="mr-2" />
|
||||
Duplicate
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onDelete} className="text-error">
|
||||
<TrashSimple size={14} className="mr-2" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,56 @@
|
||||
import { sortByDate } from "@reactive-resume/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { useResumes } from "@/client/services/resume";
|
||||
|
||||
import { BaseListItem } from "./_components/base-item";
|
||||
import { CreateResumeListItem } from "./_components/create-item";
|
||||
import { ImportResumeListItem } from "./_components/import-item";
|
||||
import { ResumeListItem } from "./_components/resume-item";
|
||||
|
||||
export const ListView = () => {
|
||||
const { resumes, loading } = useResumes();
|
||||
|
||||
return (
|
||||
<div className="grid gap-y-2">
|
||||
<motion.div initial={{ opacity: 0, y: -50 }} animate={{ opacity: 1, y: 0 }}>
|
||||
<CreateResumeListItem />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { delay: 0.1 } }}
|
||||
>
|
||||
<ImportResumeListItem />
|
||||
</motion.div>
|
||||
|
||||
{loading &&
|
||||
[...Array(4)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="duration-300 animate-in fade-in"
|
||||
style={{ animationFillMode: "backwards", animationDelay: `${i * 300}ms` }}
|
||||
>
|
||||
<BaseListItem className="bg-secondary/40" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{resumes && (
|
||||
<AnimatePresence>
|
||||
{resumes
|
||||
.sort((a, b) => sortByDate(a, b, "updatedAt"))
|
||||
.map((resume, index) => (
|
||||
<motion.div
|
||||
key={resume.id}
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={{ opacity: 1, y: 0, transition: { delay: (index + 2) * 0.1 } }}
|
||||
exit={{ opacity: 0, filter: "blur(8px)", transition: { duration: 0.5 } }}
|
||||
>
|
||||
<ResumeListItem resume={resume} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
apps/client/src/pages/dashboard/resumes/page.tsx
Normal file
54
apps/client/src/pages/dashboard/resumes/page.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { List, SquaresFour } from "@phosphor-icons/react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui";
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
import { GridView } from "./_layouts/grid";
|
||||
import { ListView } from "./_layouts/list";
|
||||
|
||||
type Layout = "grid" | "list";
|
||||
|
||||
export const ResumesPage = () => {
|
||||
const [layout, setLayout] = useState<Layout>("grid");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Resumes - Reactive Resume</title>
|
||||
</Helmet>
|
||||
|
||||
<Tabs value={layout} onValueChange={(value) => setLayout(value as Layout)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="text-4xl font-bold tracking-tight"
|
||||
>
|
||||
Resumes
|
||||
</motion.h1>
|
||||
|
||||
<TabsList>
|
||||
<TabsTrigger value="grid" className="h-8 w-8 p-0 sm:h-8 sm:w-auto sm:px-4">
|
||||
<SquaresFour />
|
||||
<span className="ml-2 hidden sm:block">Grid</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="list" className="h-8 w-8 p-0 sm:h-8 sm:w-auto sm:px-4">
|
||||
<List />
|
||||
<span className="ml-2 hidden sm:block">List</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 md:mt-8">
|
||||
<TabsContent value="grid">
|
||||
<GridView />
|
||||
</TabsContent>
|
||||
<TabsContent value="list">
|
||||
<ListView />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
};
|
||||
264
apps/client/src/pages/dashboard/settings/_dialogs/two-factor.tsx
Normal file
264
apps/client/src/pages/dashboard/settings/_dialogs/two-factor.tsx
Normal file
@ -0,0 +1,264 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { QrCode } from "@phosphor-icons/react";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AxiosError } from "axios";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { queryClient } from "@/client/libs/query-client";
|
||||
import { useDisable2FA, useEnable2FA, useSetup2FA } from "@/client/services/auth";
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
// We're using the pre-existing "mode" state to determine the stage of 2FA setup the user is in.
|
||||
// - "create" mode is used to enable 2FA.
|
||||
// - "update" mode is used to verify 2FA, displaying a QR Code, once enabled.
|
||||
// - "duplicate" mode is used to display the backup codes after initial verification.
|
||||
// - "delete" mode is used to disable 2FA.
|
||||
|
||||
const formSchema = z.object({
|
||||
uri: z.literal("").or(z.string().optional()),
|
||||
code: z.literal("").or(z.string().regex(/^\d{6}$/, "Code must be exactly 6 digits long.")),
|
||||
backupCodes: z.array(z.string()),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const TwoFactorDialog = () => {
|
||||
const { toast } = useToast();
|
||||
const { isOpen, mode, open, close } = useDialog("two-factor");
|
||||
|
||||
const isCreate = mode === "create";
|
||||
const isUpdate = mode === "update";
|
||||
const isDelete = mode === "delete";
|
||||
const isDuplicate = mode === "duplicate";
|
||||
|
||||
const { setup2FA, loading: setupLoading } = useSetup2FA();
|
||||
const { enable2FA, loading: enableLoading } = useEnable2FA();
|
||||
const { disable2FA, loading: disableLoading } = useDisable2FA();
|
||||
|
||||
const loading = setupLoading || enableLoading || disableLoading;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { uri: "", code: "", backupCodes: [] },
|
||||
});
|
||||
|
||||
// If the user is enabling 2FA, we need to get the QR code URI from the server.
|
||||
// And display the QR code to the user.
|
||||
useEffect(() => {
|
||||
const initialize = async () => {
|
||||
const data = await setup2FA();
|
||||
form.setValue("uri", data.message);
|
||||
};
|
||||
|
||||
if (isCreate) initialize();
|
||||
}, [isCreate]);
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
if (isCreate) {
|
||||
open("update");
|
||||
}
|
||||
|
||||
if (isUpdate) {
|
||||
if (!values.code) return;
|
||||
|
||||
try {
|
||||
const data = await enable2FA({ code: values.code });
|
||||
form.setValue("backupCodes", data.backupCodes);
|
||||
await queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||
|
||||
open("duplicate");
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
const message = error.response?.data?.message || error.message;
|
||||
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "An error occurred while trying to enable two-factor authentication.",
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDuplicate) {
|
||||
close();
|
||||
}
|
||||
|
||||
if (isDelete) {
|
||||
const data = await disable2FA();
|
||||
toast({ variant: "success", title: data.message });
|
||||
await queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
if (isDelete) {
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={close}>
|
||||
<AlertDialogContent>
|
||||
<Form {...form}>
|
||||
<form className="space-y-4">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to disable two-factor authentication?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
If you disable two-factor authentication, you will no longer be required to enter
|
||||
a verification code when logging in.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<Alert variant="info">
|
||||
<AlertDescription>Note: This will make your account less secure.</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction variant="error" onClick={form.handleSubmit(onSubmit)}>
|
||||
Disable
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={close}>
|
||||
<DialogContent className="!max-w-md">
|
||||
<Form {...form}>
|
||||
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<QrCode />
|
||||
<h2>
|
||||
{mode === "create" && "Setup two-factor authentication on your account"}
|
||||
{mode === "update" &&
|
||||
"Verify that two-factor authentication has been setup correctly"}
|
||||
{mode === "duplicate" && "Store your backup codes securely"}
|
||||
</h2>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isCreate &&
|
||||
"Scan the QR code below with your authenticator app to setup 2FA on your account."}
|
||||
{isUpdate &&
|
||||
"Enter the 6-digit code from your authenticator app to verify that 2FA has been setup correctly."}
|
||||
{isDuplicate && "You have enabled two-factor authentication successfully."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isCreate && (
|
||||
<FormField
|
||||
name="uri"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
<QRCodeSVG value={field.value!} size={256} className="mx-auto" />
|
||||
<Input readOnly {...field} className="opacity-75" />
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
In case you don't have access to your camera, you can also copy-paste this URI
|
||||
to your authenticator app.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isUpdate && (
|
||||
<FormField
|
||||
name="code"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Code</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="123456" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDuplicate && (
|
||||
<>
|
||||
<FormField
|
||||
name="backupCodes"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="mx-auto grid max-w-xs grid-cols-2 rounded-sm bg-secondary/50 p-4 text-center font-mono leading-loose">
|
||||
{field.value.map((code) => (
|
||||
<p key={code}>{code}</p>
|
||||
))}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<p className="text-xs leading-relaxed">
|
||||
Please store your backup codes in a secure location. You can use one of these
|
||||
one-time use codes to login in case you lose access to your authenticator app.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{isCreate && <Button disabled={loading}>Continue</Button>}
|
||||
{isUpdate && (
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => open("create")}>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button disabled={loading}>Continue</Button>
|
||||
</>
|
||||
)}
|
||||
{isDuplicate && <Button disabled={loading}>Close</Button>}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
231
apps/client/src/pages/dashboard/settings/_sections/account.tsx
Normal file
231
apps/client/src/pages/dashboard/settings/_sections/account.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Check, UploadSimple, Warning } from "@phosphor-icons/react";
|
||||
import { UpdateUserDto, updateUserSchema } from "@reactive-resume/dto";
|
||||
import {
|
||||
Button,
|
||||
buttonVariants,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { UserAvatar } from "@/client/components/user-avatar";
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { useResendVerificationEmail } from "@/client/services/auth";
|
||||
import { useUploadImage } from "@/client/services/storage";
|
||||
import { useUpdateUser, useUser } from "@/client/services/user";
|
||||
|
||||
export const AccountSettings = () => {
|
||||
const { user } = useUser();
|
||||
const { toast } = useToast();
|
||||
const { updateUser, loading } = useUpdateUser();
|
||||
const { uploadImage, loading: isUploading } = useUploadImage();
|
||||
const { resendVerificationEmail } = useResendVerificationEmail();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const form = useForm<UpdateUserDto>({
|
||||
resolver: zodResolver(updateUserSchema),
|
||||
defaultValues: {
|
||||
picture: "",
|
||||
name: "",
|
||||
username: "",
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
user && onReset();
|
||||
}, [user]);
|
||||
|
||||
const onReset = () => {
|
||||
if (!user) return;
|
||||
|
||||
form.reset({
|
||||
picture: user.picture ?? "",
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (data: UpdateUserDto) => {
|
||||
if (!user) return;
|
||||
|
||||
// Check if email has changed and display a toast message to confirm the email change
|
||||
if (user.email !== data.email) {
|
||||
toast({
|
||||
variant: "info",
|
||||
title: "Check your email for the confirmation link to update your email address.",
|
||||
});
|
||||
}
|
||||
|
||||
await updateUser({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
picture: data.picture,
|
||||
username: data.username,
|
||||
});
|
||||
|
||||
form.reset(data);
|
||||
};
|
||||
|
||||
const onSelectImage = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files.length > 0) {
|
||||
const file = event.target.files[0];
|
||||
const response = await uploadImage(file);
|
||||
const url = response.data;
|
||||
|
||||
await updateUser({ picture: url });
|
||||
}
|
||||
};
|
||||
|
||||
const onResendVerificationEmail = async () => {
|
||||
const data = await resendVerificationEmail();
|
||||
|
||||
toast({ variant: "success", title: data.message });
|
||||
};
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">Account</h3>
|
||||
<p className="leading-relaxed opacity-75">
|
||||
Here, you can update your account information such as your profile picture, name and
|
||||
username.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="picture"
|
||||
control={form.control}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<div className={cn("flex items-end gap-x-4 sm:col-span-2", error && "items-center")}>
|
||||
<UserAvatar />
|
||||
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Picture</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://..." {...field} value={field.value || ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
{!user.picture && (
|
||||
<>
|
||||
<input hidden type="file" ref={inputRef} onChange={onSelectImage} />
|
||||
|
||||
<motion.button
|
||||
disabled={isUploading}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={cn(buttonVariants({ size: "icon", variant: "ghost" }))}
|
||||
>
|
||||
<UploadSimple />
|
||||
</motion.button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="username"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
{fieldState.error && (
|
||||
<FormDescription className="text-error">
|
||||
{fieldState.error.message}
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="email"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription
|
||||
className={cn(
|
||||
"flex items-center gap-x-1.5 font-medium opacity-100",
|
||||
user.emailVerified ? "text-success-accent" : "text-warning-accent",
|
||||
)}
|
||||
>
|
||||
{user.emailVerified ? <Check size={12} /> : <Warning size={12} />}
|
||||
{user.emailVerified ? "Verified" : "Unverified"}
|
||||
{!user.emailVerified && (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto text-xs"
|
||||
onClick={onResendVerificationEmail}
|
||||
>
|
||||
Resend confirmation link
|
||||
</Button>
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<AnimatePresence presenceAffectsLayout>
|
||||
{form.formState.isDirty && (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
className="flex items-center space-x-2 self-center sm:col-start-2"
|
||||
>
|
||||
<Button type="submit" disabled={loading}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button type="reset" variant="ghost" onClick={onReset}>
|
||||
Reset
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,96 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCounter } from "usehooks-ts";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { useLogout } from "@/client/services/auth";
|
||||
import { useDeleteUser } from "@/client/services/user";
|
||||
|
||||
const formSchema = z.object({
|
||||
deleteConfirm: z.literal("delete"),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const DangerZoneSettings = () => {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useLogout();
|
||||
const { count, increment } = useCounter(0);
|
||||
const { deleteUser, loading } = useDeleteUser();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
deleteConfirm: "" as FormValues["deleteConfirm"],
|
||||
},
|
||||
});
|
||||
|
||||
const onDelete = async () => {
|
||||
// On the first click, increment the counter
|
||||
increment();
|
||||
|
||||
// On the second click, delete the account
|
||||
if (count === 1) {
|
||||
await Promise.all([deleteUser, logout]);
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Your account has been deleted successfully.",
|
||||
});
|
||||
|
||||
navigate("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">Danger Zone</h3>
|
||||
<p className="leading-relaxed opacity-75">
|
||||
In this section, you can delete your account and all the data associated to your user, but
|
||||
please keep in mind that{" "}
|
||||
<span className="font-semibold">this action is irreversible</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onDelete)} className="grid gap-6 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="deleteConfirm"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Delete Account</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="delete" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Type <code className="font-bold">delete</code> to confirm deleting your account.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center space-x-2 self-center">
|
||||
<Button type="submit" variant="error" disabled={!form.formState.isValid || loading}>
|
||||
{count === 1 ? "Are you sure?" : "Delete Account"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
147
apps/client/src/pages/dashboard/settings/_sections/openai.tsx
Normal file
147
apps/client/src/pages/dashboard/settings/_sections/openai.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { LockSimple, LockSimpleOpen, TrashSimple } from "@phosphor-icons/react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useOpenAiStore } from "@/client/stores/openai";
|
||||
|
||||
const formSchema = z.object({
|
||||
apiKey: z
|
||||
.string()
|
||||
.regex(/^sk-[a-zA-Z0-9]+$/, "That doesn't look like a valid OpenAI API key.")
|
||||
.default(""),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const OpenAISettings = () => {
|
||||
const { apiKey, setApiKey } = useOpenAiStore();
|
||||
const isEnabled = !!apiKey;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { apiKey: apiKey ?? "" },
|
||||
});
|
||||
|
||||
const onSubmit = async ({ apiKey }: FormValues) => {
|
||||
setApiKey(apiKey);
|
||||
};
|
||||
|
||||
const onRemove = () => {
|
||||
setApiKey(null);
|
||||
form.reset({ apiKey: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">OpenAI Integration</h3>
|
||||
<p className="leading-relaxed opacity-75">
|
||||
You can make use of the OpenAI API to help you generate content, or improve your writing
|
||||
while composing your resume.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="prose prose-sm prose-zinc max-w-full dark:prose-invert">
|
||||
<p>
|
||||
You have the option to{" "}
|
||||
<a
|
||||
href="https://www.howtogeek.com/885918/how-to-get-an-openai-api-key/"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
obtain your own OpenAI API key
|
||||
</a>
|
||||
. This key empowers you to leverage the API as you see fit. Alternatively, if you wish to
|
||||
disable the AI features in Reactive Resume altogether, you can simply remove the key from
|
||||
your settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="apiKey"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="sk-..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-2 self-end sm:col-start-2",
|
||||
!!form.formState.errors.apiKey && "self-center",
|
||||
)}
|
||||
>
|
||||
<Button type="submit" disabled={isEnabled || !form.formState.isDirty}>
|
||||
{!isEnabled && <LockSimpleOpen className="mr-2" />}
|
||||
{isEnabled && <LockSimple className="mr-2" />}
|
||||
{isEnabled ? "Saved" : "Save Locally"}
|
||||
</Button>
|
||||
|
||||
{isEnabled && (
|
||||
<Button type="reset" variant="ghost" onClick={onRemove}>
|
||||
<TrashSimple className="mr-2" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="prose prose-sm prose-zinc max-w-full dark:prose-invert">
|
||||
<p>
|
||||
Your API key is securely stored in the browser's local storage and is only utilized when
|
||||
making requests to OpenAI via their official SDK. Rest assured that your key is not
|
||||
transmitted to any external server except when interacting with OpenAI's services.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Alert variant="warning">
|
||||
<div className="prose prose-neutral max-w-full text-xs leading-relaxed text-primary dark:prose-invert">
|
||||
<span className="font-medium">Note: </span>
|
||||
<span>
|
||||
By utilizing the OpenAI API, you acknowledge and accept the{" "}
|
||||
<a
|
||||
href="https://openai.com/policies/terms-of-use"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
terms of use
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href="https://openai.com/policies/privacy-policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
privacy policy
|
||||
</a>{" "}
|
||||
outlined by OpenAI. Please note that Reactive Resume bears no responsibility for any
|
||||
improper or unauthorized utilization of the service, and any resulting repercussions or
|
||||
liabilities solely rest on the user.
|
||||
</span>
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
138
apps/client/src/pages/dashboard/settings/_sections/profile.tsx
Normal file
138
apps/client/src/pages/dashboard/settings/_sections/profile.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTheme } from "@reactive-resume/hooks";
|
||||
import { Button } from "@reactive-resume/ui";
|
||||
import { Combobox } from "@reactive-resume/ui";
|
||||
import { Form, FormDescription, FormField, FormItem, FormLabel } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useUpdateUser, useUser } from "@/client/services/user";
|
||||
|
||||
const formSchema = z.object({
|
||||
theme: z.enum(["system", "light", "dark"]).default("system"),
|
||||
language: z.string().default("en"),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const ProfileSettings = () => {
|
||||
const { user } = useUser();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { updateUser, loading } = useUpdateUser();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { theme, language: "en" },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
user && onReset();
|
||||
}, [user]);
|
||||
|
||||
const onReset = () => {
|
||||
if (!user) return;
|
||||
|
||||
form.reset({ theme, language: user.language ?? "en" });
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
if (!user) return;
|
||||
|
||||
setTheme(data.theme);
|
||||
|
||||
if (user.language !== data.language) {
|
||||
await updateUser({ language: data.language });
|
||||
}
|
||||
|
||||
form.reset(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">Profile</h3>
|
||||
<p className="leading-relaxed opacity-75">
|
||||
Here, you can update your profile to customize and personalize your experience.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="theme"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Theme</FormLabel>
|
||||
<div className="w-full">
|
||||
<Combobox
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
options={[
|
||||
{ label: "System", value: "system" },
|
||||
{ label: "Light", value: "light" },
|
||||
{ label: "Dark", value: "dark" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="language"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<div className="w-full">
|
||||
<Combobox
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
options={[
|
||||
{
|
||||
value: "en",
|
||||
label: <p>English</p>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<FormDescription>
|
||||
<span>
|
||||
Don't see your language?{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
href="https://translate.rxresu.me/"
|
||||
className="font-medium underline underline-offset-2"
|
||||
>
|
||||
Help translate the app.
|
||||
</a>
|
||||
</span>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"hidden items-center space-x-2 self-center sm:col-start-2",
|
||||
form.formState.isDirty && "flex animate-in fade-in",
|
||||
)}
|
||||
>
|
||||
<Button type="submit" disabled={loading}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button type="reset" variant="ghost" onClick={onReset}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
162
apps/client/src/pages/dashboard/settings/_sections/security.tsx
Normal file
162
apps/client/src/pages/dashboard/settings/_sections/security.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
Input,
|
||||
} from "@reactive-resume/ui";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useToast } from "@/client/hooks/use-toast";
|
||||
import { useUpdatePassword } from "@/client/services/auth";
|
||||
import { useUser } from "@/client/services/user";
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
password: z.string().min(6),
|
||||
confirmPassword: z.string().min(6),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "The passwords you entered do not match.",
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export const SecuritySettings = () => {
|
||||
const { user } = useUser();
|
||||
const { toast } = useToast();
|
||||
const { open } = useDialog("two-factor");
|
||||
const { updatePassword, loading } = useUpdatePassword();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { password: "", confirmPassword: "" },
|
||||
});
|
||||
|
||||
const onReset = () => {
|
||||
form.reset({ password: "", confirmPassword: "" });
|
||||
};
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
await updatePassword({ password: data.password });
|
||||
|
||||
toast({
|
||||
variant: "success",
|
||||
title: "Your password has been updated successfully.",
|
||||
});
|
||||
|
||||
onReset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold leading-relaxed tracking-tight">Security</h3>
|
||||
<p className="leading-relaxed opacity-75">
|
||||
In this section, you can change your password and enable/disable two-factor
|
||||
authentication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Accordion type="multiple" defaultValue={["password", "two-factor"]}>
|
||||
<AccordionItem value="password">
|
||||
<AccordionTrigger>Password</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-6 sm:grid-cols-2">
|
||||
<FormField
|
||||
name="password"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name="confirmPassword"
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm New Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
{fieldState.error && (
|
||||
<FormDescription className="text-error-foreground">
|
||||
{fieldState.error?.message}
|
||||
</FormDescription>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<AnimatePresence presenceAffectsLayout>
|
||||
{form.formState.isDirty && (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -10 }}
|
||||
className="flex items-center space-x-2 self-center sm:col-start-2"
|
||||
>
|
||||
<Button type="submit" disabled={loading}>
|
||||
Change Password
|
||||
</Button>
|
||||
<Button type="reset" variant="ghost" onClick={onReset}>
|
||||
Reset
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</Form>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="two-factor">
|
||||
<AccordionTrigger>Two-Factor Authentication</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{user?.twoFactorEnabled ? (
|
||||
<p className="mb-4 leading-relaxed opacity-75">
|
||||
<strong>Two-factor authentication is enabled.</strong> You will be asked to enter a
|
||||
code every time you sign in.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-4 leading-relaxed opacity-75">
|
||||
<strong>Two-factor authentication is currently disabled.</strong> You can enable it
|
||||
by adding an authenticator app to your account.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{user?.twoFactorEnabled ? (
|
||||
<Button variant="outline" onClick={() => open("delete")}>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => open("create")}>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
37
apps/client/src/pages/dashboard/settings/page.tsx
Normal file
37
apps/client/src/pages/dashboard/settings/page.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Separator } from "@reactive-resume/ui";
|
||||
import { motion } from "framer-motion";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
|
||||
import { AccountSettings } from "./_sections/account";
|
||||
import { DangerZoneSettings } from "./_sections/danger";
|
||||
import { OpenAISettings } from "./_sections/openai";
|
||||
import { ProfileSettings } from "./_sections/profile";
|
||||
import { SecuritySettings } from "./_sections/security";
|
||||
|
||||
export const SettingsPage = () => (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Settings - Reactive Resume</title>
|
||||
</Helmet>
|
||||
|
||||
<div className="max-w-2xl space-y-8 pb-12">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="text-4xl font-bold tracking-tight"
|
||||
>
|
||||
Settings
|
||||
</motion.h1>
|
||||
|
||||
<AccountSettings />
|
||||
<Separator />
|
||||
<SecuritySettings />
|
||||
<Separator />
|
||||
<ProfileSettings />
|
||||
<Separator />
|
||||
<OpenAISettings />
|
||||
<Separator />
|
||||
<DangerZoneSettings />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
Reference in New Issue
Block a user