refactor(v4.0.0-alpha): beginning of a new era

This commit is contained in:
Amruth Pillai
2023-11-05 12:31:42 +01:00
parent 0ba6a444e2
commit 22933bd412
505 changed files with 81829 additions and 0 deletions

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@ -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>
);

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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"
/>
);
};

View File

@ -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."
/>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View 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>
</>
);
};

View 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>
);
};

View 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>
);
};

View File

@ -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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
</>
);