mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-14 00:32:35 +10:00
feat(resume): ✨ implement resume locking feature
This commit is contained in:
@ -489,5 +489,7 @@ export const sampleResume: ResumeData = {
|
||||
lineHeight: 1.5,
|
||||
underlineLinks: true,
|
||||
},
|
||||
notes:
|
||||
"<p>I sent this resume to Deloitte back in July 2022. I am yet to hear back from them.</p>",
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { HouseSimple, SidebarSimple } from "@phosphor-icons/react";
|
||||
import { HouseSimple, Lock, SidebarSimple } from "@phosphor-icons/react";
|
||||
import { useBreakpoint } from "@reactive-resume/hooks";
|
||||
import { Button } from "@reactive-resume/ui";
|
||||
import { Button, Tooltip } from "@reactive-resume/ui";
|
||||
import { cn } from "@reactive-resume/utils";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@ -11,8 +11,10 @@ export const BuilderHeader = () => {
|
||||
const { isDesktop } = useBreakpoint();
|
||||
const defaultPanelSize = isDesktop ? 25 : 0;
|
||||
|
||||
const toggle = useBuilderStore((state) => state.toggle);
|
||||
const title = useResumeStore((state) => state.resume.title);
|
||||
const locked = useResumeStore((state) => state.resume.locked);
|
||||
|
||||
const toggle = useBuilderStore((state) => state.toggle);
|
||||
const isDragging = useBuilderStore(
|
||||
(state) => state.panel.left.isDragging || state.panel.right.isDragging,
|
||||
);
|
||||
@ -48,6 +50,12 @@ export const BuilderHeader = () => {
|
||||
<span className="mr-2 text-xs opacity-40">{"/"}</span>
|
||||
|
||||
<h1 className="font-medium">{title}</h1>
|
||||
|
||||
{locked && (
|
||||
<Tooltip content="This resume is locked, please unlock to make further changes.">
|
||||
<Lock size={14} className="ml-2 opacity-75" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={() => onToggle("right")}>
|
||||
|
||||
@ -2,9 +2,9 @@ import {
|
||||
ArrowCounterClockwise,
|
||||
Broom,
|
||||
Columns,
|
||||
DotsThreeVertical,
|
||||
Eye,
|
||||
EyeSlash,
|
||||
List,
|
||||
PencilSimple,
|
||||
Plus,
|
||||
TrashSimple,
|
||||
@ -55,7 +55,7 @@ export const SectionOptions = ({ id }: Props) => {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<DotsThreeVertical weight="bold" />
|
||||
<List weight="bold" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-48">
|
||||
|
||||
@ -7,6 +7,7 @@ import { ThemeSwitch } from "@/client/components/theme-switch";
|
||||
import { ExportSection } from "./sections/export";
|
||||
import { InformationSection } from "./sections/information";
|
||||
import { LayoutSection } from "./sections/layout";
|
||||
import { NotesSection } from "./sections/notes";
|
||||
import { PageSection } from "./sections/page";
|
||||
import { SharingSection } from "./sections/sharing";
|
||||
import { StatisticsSection } from "./sections/statistics";
|
||||
@ -43,6 +44,8 @@ export const RightSidebar = () => {
|
||||
<Separator />
|
||||
<ExportSection />
|
||||
<Separator />
|
||||
<NotesSection />
|
||||
<Separator />
|
||||
<InformationSection />
|
||||
<Separator />
|
||||
<Copyright className="text-center" />
|
||||
@ -63,6 +66,18 @@ export const RightSidebar = () => {
|
||||
<SectionIcon id="theme" name="Theme" onClick={() => scrollIntoView("#theme")} />
|
||||
<SectionIcon id="page" name="Page" onClick={() => scrollIntoView("#page")} />
|
||||
<SectionIcon id="sharing" name="Sharing" onClick={() => scrollIntoView("#sharing")} />
|
||||
<SectionIcon
|
||||
id="statistics"
|
||||
name="Statistics"
|
||||
onClick={() => scrollIntoView("#statistics")}
|
||||
/>
|
||||
<SectionIcon id="export" name="Export" onClick={() => scrollIntoView("#export")} />
|
||||
<SectionIcon id="notes" name="Notes" onClick={() => scrollIntoView("#notes")} />
|
||||
<SectionIcon
|
||||
id="information"
|
||||
name="Information"
|
||||
onClick={() => scrollIntoView("#information")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ThemeSwitch size={14} />
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
import { RichInput } from "@reactive-resume/ui";
|
||||
|
||||
import { useResumeStore } from "@/client/stores/resume";
|
||||
|
||||
import { getSectionIcon } from "../shared/section-icon";
|
||||
|
||||
export const NotesSection = () => {
|
||||
const setValue = useResumeStore((state) => state.setValue);
|
||||
const notes = useResumeStore((state) => state.resume.data.metadata.notes);
|
||||
|
||||
return (
|
||||
<section id="notes" className="grid gap-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{getSectionIcon("notes")}
|
||||
<h2 className="line-clamp-1 text-3xl font-bold">Notes</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid gap-y-4">
|
||||
<p className="leading-relaxed">
|
||||
This section is reserved for your personal notes specific to this resume. The content here
|
||||
remains private and is not shared with anyone else.
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<RichInput content={notes} onChange={(content) => setValue("metadata.notes", content)} />
|
||||
|
||||
<p className="text-xs leading-relaxed opacity-75">
|
||||
For example, information regarding which companies you sent this resume to or the links
|
||||
to the job descriptions can be noted down here.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@ -4,6 +4,7 @@ import {
|
||||
IconProps,
|
||||
Info,
|
||||
Layout,
|
||||
Note,
|
||||
Palette,
|
||||
ReadCvLogo,
|
||||
ShareFat,
|
||||
@ -13,6 +14,7 @@ import {
|
||||
import { Button, ButtonProps, Tooltip } from "@reactive-resume/ui";
|
||||
|
||||
export type MetadataKey =
|
||||
| "notes"
|
||||
| "template"
|
||||
| "layout"
|
||||
| "typography"
|
||||
@ -26,6 +28,8 @@ export type MetadataKey =
|
||||
export const getSectionIcon = (id: MetadataKey, props: IconProps = {}) => {
|
||||
switch (id) {
|
||||
// Left Sidebar
|
||||
case "notes":
|
||||
return <Note size={18} {...props} />;
|
||||
case "template":
|
||||
return <DiamondsFour size={18} {...props} />;
|
||||
case "layout":
|
||||
|
||||
58
apps/client/src/pages/dashboard/resumes/_dialogs/lock.tsx
Normal file
58
apps/client/src/pages/dashboard/resumes/_dialogs/lock.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { ResumeDto } from "@reactive-resume/dto";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@reactive-resume/ui";
|
||||
|
||||
import { useLockResume } from "@/client/services/resume/lock";
|
||||
import { useDialog } from "@/client/stores/dialog";
|
||||
|
||||
export const LockDialog = () => {
|
||||
const { isOpen, mode, payload, close } = useDialog<ResumeDto>("lock");
|
||||
|
||||
const isLockMode = mode === "create";
|
||||
const isUnlockMode = mode === "update";
|
||||
|
||||
const { lockResume, loading } = useLockResume();
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!payload.item) return;
|
||||
|
||||
await lockResume({ id: payload.item.id, set: isLockMode });
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={close}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{isLockMode && "Are you sure you want to lock this resume?"}
|
||||
{isUnlockMode && "Are you sure you want to unlock this resume?"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isLockMode &&
|
||||
"Locking a resume will prevent any further changes to it. This is useful when you have already shared your resume with someone and you don't want to accidentally make any changes to it."}
|
||||
{isUnlockMode && "Unlocking a resume will allow you to make changes to it again."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction variant="info" disabled={loading} onClick={onSubmit}>
|
||||
{isLockMode && "Lock"}
|
||||
{isUnlockMode && "Unlock"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@ -2,6 +2,8 @@ import {
|
||||
CircleNotch,
|
||||
CopySimple,
|
||||
FolderOpen,
|
||||
Lock,
|
||||
LockOpen,
|
||||
PencilSimple,
|
||||
TrashSimple,
|
||||
} from "@phosphor-icons/react";
|
||||
@ -30,6 +32,7 @@ type Props = {
|
||||
export const ResumeCard = ({ resume }: Props) => {
|
||||
const navigate = useNavigate();
|
||||
const { open } = useDialog<ResumeDto>("resume");
|
||||
const { open: lockOpen } = useDialog<ResumeDto>("lock");
|
||||
|
||||
const { url, loading } = useResumePreview(resume.id);
|
||||
|
||||
@ -47,6 +50,10 @@ export const ResumeCard = ({ resume }: Props) => {
|
||||
open("duplicate", { id: "resume", item: resume });
|
||||
};
|
||||
|
||||
const onLockChange = () => {
|
||||
lockOpen(resume.locked ? "update" : "create", { id: "lock", item: resume });
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
open("delete", { id: "resume", item: resume });
|
||||
};
|
||||
@ -54,7 +61,7 @@ export const ResumeCard = ({ resume }: Props) => {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<BaseCard onClick={onOpen}>
|
||||
<BaseCard onClick={onOpen} className="space-y-0">
|
||||
<AnimatePresence presenceAffectsLayout>
|
||||
{loading && (
|
||||
<motion.div
|
||||
@ -85,6 +92,19 @@ export const ResumeCard = ({ resume }: Props) => {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{resume.locked && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 flex items-center justify-center bg-background/75 backdrop-blur-sm"
|
||||
>
|
||||
<Lock size={42} />
|
||||
</motion.div>
|
||||
)}
|
||||
</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",
|
||||
@ -110,6 +130,17 @@ export const ResumeCard = ({ resume }: Props) => {
|
||||
<CopySimple size={14} className="mr-2" />
|
||||
Duplicate
|
||||
</ContextMenuItem>
|
||||
{resume.locked ? (
|
||||
<ContextMenuItem onClick={onLockChange}>
|
||||
<LockOpen size={14} className="mr-2" />
|
||||
Unlock
|
||||
</ContextMenuItem>
|
||||
) : (
|
||||
<ContextMenuItem onClick={onLockChange}>
|
||||
<Lock size={14} className="mr-2" />
|
||||
Lock
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onDelete} className="text-error">
|
||||
<TrashSimple size={14} className="mr-2" />
|
||||
|
||||
@ -12,6 +12,7 @@ import { ReferencesDialog } from "../pages/builder/sidebars/left/dialogs/referen
|
||||
import { SkillsDialog } from "../pages/builder/sidebars/left/dialogs/skills";
|
||||
import { VolunteerDialog } from "../pages/builder/sidebars/left/dialogs/volunteer";
|
||||
import { ImportDialog } from "../pages/dashboard/resumes/_dialogs/import";
|
||||
import { LockDialog } from "../pages/dashboard/resumes/_dialogs/lock";
|
||||
import { ResumeDialog } from "../pages/dashboard/resumes/_dialogs/resume";
|
||||
import { TwoFactorDialog } from "../pages/dashboard/settings/_dialogs/two-factor";
|
||||
import { useResumeStore } from "../stores/resume";
|
||||
@ -29,6 +30,7 @@ export const DialogProvider = ({ children }: Props) => {
|
||||
|
||||
<div id="dialog-root">
|
||||
<ResumeDialog />
|
||||
<LockDialog />
|
||||
<ImportDialog />
|
||||
<TwoFactorDialog />
|
||||
|
||||
|
||||
38
apps/client/src/services/resume/lock.ts
Normal file
38
apps/client/src/services/resume/lock.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { ResumeDto } from "@reactive-resume/dto";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { axios } from "@/client/libs/axios";
|
||||
import { queryClient } from "@/client/libs/query-client";
|
||||
|
||||
type LockResumeArgs = {
|
||||
id: string;
|
||||
set: boolean;
|
||||
};
|
||||
|
||||
export const lockResume = async ({ id, set }: LockResumeArgs) => {
|
||||
const response = await axios.patch(`/resume/${id}/lock`, { set });
|
||||
|
||||
queryClient.setQueryData<ResumeDto>(["resume", { id: response.data.id }], response.data);
|
||||
|
||||
queryClient.setQueryData<ResumeDto[]>(["resumes"], (cache) => {
|
||||
if (!cache) return [response.data];
|
||||
return cache.map((resume) => {
|
||||
if (resume.id === response.data.id) return response.data;
|
||||
return resume;
|
||||
});
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const useLockResume = () => {
|
||||
const {
|
||||
error,
|
||||
isPending: loading,
|
||||
mutateAsync: lockResumeFn,
|
||||
} = useMutation({
|
||||
mutationFn: lockResume,
|
||||
});
|
||||
|
||||
return { lockResume: lockResumeFn, loading, error };
|
||||
};
|
||||
@ -1,28 +1,41 @@
|
||||
import { ResumeDto, UpdateResumeDto } from "@reactive-resume/dto";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { AxiosError, AxiosResponse } from "axios";
|
||||
import debounce from "lodash.debounce";
|
||||
|
||||
import { toast } from "@/client/hooks/use-toast";
|
||||
import { axios } from "@/client/libs/axios";
|
||||
import { queryClient } from "@/client/libs/query-client";
|
||||
|
||||
export const updateResume = async (data: UpdateResumeDto) => {
|
||||
const response = await axios.patch<ResumeDto, AxiosResponse<ResumeDto>, UpdateResumeDto>(
|
||||
`/resume/${data.id}`,
|
||||
data,
|
||||
);
|
||||
try {
|
||||
const response = await axios.patch<ResumeDto, AxiosResponse<ResumeDto>, UpdateResumeDto>(
|
||||
`/resume/${data.id}`,
|
||||
data,
|
||||
);
|
||||
|
||||
queryClient.setQueryData<ResumeDto>(["resume", { id: response.data.id }], response.data);
|
||||
queryClient.setQueryData<ResumeDto>(["resume", { id: response.data.id }], response.data);
|
||||
|
||||
queryClient.setQueryData<ResumeDto[]>(["resumes"], (cache) => {
|
||||
if (!cache) return [response.data];
|
||||
return cache.map((resume) => {
|
||||
if (resume.id === response.data.id) return response.data;
|
||||
return resume;
|
||||
queryClient.setQueryData<ResumeDto[]>(["resumes"], (cache) => {
|
||||
if (!cache) return [response.data];
|
||||
return cache.map((resume) => {
|
||||
if (resume.id === response.data.id) return response.data;
|
||||
return resume;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return response.data;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
const message = error.response?.data.message ?? error.message;
|
||||
|
||||
toast({
|
||||
variant: "error",
|
||||
title: "There was an error while updating your resume.",
|
||||
description: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const debouncedUpdateResume = debounce(updateResume, 500);
|
||||
@ -34,17 +47,6 @@ export const useUpdateResume = () => {
|
||||
mutateAsync: updateResumeFn,
|
||||
} = useMutation({
|
||||
mutationFn: updateResume,
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData<ResumeDto>(["resume", { id: data.id }], data);
|
||||
|
||||
queryClient.setQueryData<ResumeDto[]>(["resumes"], (cache) => {
|
||||
if (!cache) return [data];
|
||||
return cache.map((resume) => {
|
||||
if (resume.id === data.id) return data;
|
||||
return resume;
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { updateResume: updateResumeFn, loading, error };
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { SectionKey } from "@reactive-resume/schema";
|
||||
import { create } from "zustand";
|
||||
|
||||
export type DialogName = "resume" | "import" | "two-factor" | SectionKey;
|
||||
export type DialogName = "resume" | "lock" | "import" | "two-factor" | SectionKey;
|
||||
|
||||
export type DialogMode = "create" | "update" | "duplicate" | "delete";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user