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