mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-24 21:51:34 +10:00
refactor(v4.0.0-alpha): beginning of a new era
This commit is contained in:
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user