mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 23:32:19 +10:00
v5.1.0 (#2970)
* chore(release): v5.1.0 * feat: implement resume thumbnails * fix: remove unused mcp tools * docs: fix formatting of docs
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
ArrowUUpLeftIcon,
|
||||
ArrowUUpRightIcon,
|
||||
CircleNotchIcon,
|
||||
CubeFocusIcon,
|
||||
FileDocIcon,
|
||||
FileJsIcon,
|
||||
FilePdfIcon,
|
||||
LinkSimpleIcon,
|
||||
MagnifyingGlassMinusIcon,
|
||||
MagnifyingGlassPlusIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useHotkeys } from "@tanstack/react-hotkeys";
|
||||
import { motion } from "motion/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useControls } from "react-zoom-pan-pinch";
|
||||
import { toast } from "sonner";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { buildDocx } from "@reactive-resume/utils/resume/docx";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useResumeHistory } from "@/components/resume/use-resume";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
import { createResumePdfBlob } from "@/libs/resume/pdf-document";
|
||||
|
||||
export function BuilderDock() {
|
||||
const { data: session } = authClient.useSession();
|
||||
const resume = useCurrentResume();
|
||||
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const { zoomIn, zoomOut, centerView } = useControls();
|
||||
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
|
||||
const { undo, redo, canUndo, canRedo } = useResumeHistory();
|
||||
|
||||
useHotkeys([
|
||||
{ hotkey: "Mod+Z", callback: () => undo() },
|
||||
{ hotkey: "Mod+Y", callback: () => redo() },
|
||||
]);
|
||||
|
||||
const publicUrl = useMemo(() => {
|
||||
if (!session?.user.username || !resume?.slug) return "";
|
||||
return `${window.location.origin}/${session.user.username}/${resume.slug}`;
|
||||
}, [session?.user.username, resume?.slug]);
|
||||
|
||||
const onCopyUrl = useCallback(async () => {
|
||||
await copyToClipboard(publicUrl);
|
||||
toast.success(t`A link to your resume has been copied to clipboard.`);
|
||||
}, [publicUrl, copyToClipboard]);
|
||||
|
||||
const onDownloadJSON = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "json");
|
||||
const jsonString = JSON.stringify(resume.data, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadDOCX = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "docx");
|
||||
|
||||
try {
|
||||
const blob = await buildDocx(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the DOCX, please try again.`);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
|
||||
const filename = generateFilename(resume.name, "pdf");
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
|
||||
|
||||
setIsPrinting(true);
|
||||
|
||||
try {
|
||||
const blob = await createResumePdfBlob(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the PDF, please try again.`);
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-4 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -18 }}
|
||||
animate={{ opacity: 0.6, y: 0 }}
|
||||
whileHover={{ opacity: 1, y: -2, scale: 1.01 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
className="flex items-center rounded-r-full rounded-l-full bg-popover px-2 shadow-xl will-change-[transform,opacity]"
|
||||
>
|
||||
<DockIcon
|
||||
disabled={!canUndo}
|
||||
onClick={() => undo()}
|
||||
icon={ArrowUUpLeftIcon}
|
||||
title={t({
|
||||
context: "'Ctrl' may be replaced with the locale-specific equivalent (e.g. 'Strg' for QWERTZ layouts).",
|
||||
message: "Undo (Ctrl+Z)",
|
||||
})}
|
||||
/>
|
||||
<DockIcon
|
||||
disabled={!canRedo}
|
||||
onClick={() => redo()}
|
||||
icon={ArrowUUpRightIcon}
|
||||
title={t({
|
||||
context: "'Ctrl' may be replaced with the locale-specific equivalent (e.g. 'Strg' for QWERTZ layouts).",
|
||||
message: "Redo (Ctrl+Y)",
|
||||
})}
|
||||
/>
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
<DockIcon icon={MagnifyingGlassPlusIcon} title={t`Zoom in`} onClick={() => zoomIn(0.1)} />
|
||||
<DockIcon icon={MagnifyingGlassMinusIcon} title={t`Zoom out`} onClick={() => zoomOut(0.1)} />
|
||||
<DockIcon icon={CubeFocusIcon} title={t`Center view`} onClick={() => centerView()} />
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
|
||||
<DockIcon icon={FileJsIcon} title={t`Download JSON`} onClick={() => onDownloadJSON()} />
|
||||
<DockIcon icon={FileDocIcon} title={t`Download DOCX`} onClick={() => onDownloadDOCX()} />
|
||||
<DockIcon
|
||||
title={t`Download PDF`}
|
||||
disabled={isPrinting}
|
||||
onClick={() => onDownloadPDF()}
|
||||
icon={isPrinting ? CircleNotchIcon : FilePdfIcon}
|
||||
iconClassName={cn(isPrinting && "animate-spin")}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DockIconProps = {
|
||||
title: string;
|
||||
icon: Icon;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
iconClassName?: string;
|
||||
};
|
||||
|
||||
function DockIcon({ icon: Icon, title, disabled, onClick, iconClassName }: DockIconProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<motion.div
|
||||
className="will-change-transform"
|
||||
whileHover={disabled ? undefined : { y: -1, scale: 1.04 }}
|
||||
whileTap={disabled ? undefined : { scale: 0.97 }}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick}>
|
||||
<Icon className={cn("size-4", iconClassName)} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
}
|
||||
/>
|
||||
|
||||
<TooltipContent side="top" align="center" className="font-medium">
|
||||
{title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
type Props = {
|
||||
side: "left" | "right";
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function BuilderSidebarEdge({ side, children }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 hidden min-h-0 w-12 flex-col items-center overflow-hidden bg-popover py-2.5 sm:flex",
|
||||
side === "left" ? "inset-s-0 border-r" : "inset-e-0 border-l",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
CaretDownIcon,
|
||||
CopySimpleIcon,
|
||||
HouseSimpleIcon,
|
||||
LockSimpleIcon,
|
||||
LockSimpleOpenIcon,
|
||||
PencilSimpleLineIcon,
|
||||
SidebarSimpleIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { useCurrentResume, usePatchResume } from "@/components/resume/use-resume";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useBuilderSidebar } from "../-store/sidebar";
|
||||
|
||||
export function BuilderHeader() {
|
||||
const resume = useCurrentResume();
|
||||
const name = resume.name;
|
||||
const isLocked = resume.isLocked;
|
||||
const toggleSidebar = useBuilderSidebar((state) => state.toggleSidebar);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-x-0 top-0 z-50 flex h-14 items-center justify-between border-b bg-popover px-1.5">
|
||||
<Button size="icon" variant="ghost" onClick={() => toggleSidebar("left")}>
|
||||
<SidebarSimpleIcon />
|
||||
<span className="sr-only">
|
||||
<Trans comment="Screen-reader label for opening or closing the left sidebar in resume builder">
|
||||
Toggle left sidebar
|
||||
</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-x-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t({
|
||||
comment: "Accessible label for button navigating from builder to resumes dashboard",
|
||||
message: "Go to resumes dashboard",
|
||||
})}
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/dashboard/resumes" search={{ sort: "lastUpdatedAt", tags: [] }}>
|
||||
<HouseSimpleIcon />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<span className="me-2.5 text-muted-foreground">/</span>
|
||||
<h2 className="flex-1 truncate font-medium">{name}</h2>
|
||||
{isLocked && <LockSimpleIcon className="ms-2 text-muted-foreground" />}
|
||||
<BuilderHeaderDropdown />
|
||||
</div>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={() => toggleSidebar("right")}>
|
||||
<SidebarSimpleIcon className="-scale-x-100" />
|
||||
<span className="sr-only">
|
||||
<Trans comment="Screen-reader label for opening or closing the right sidebar in resume builder">
|
||||
Toggle right sidebar
|
||||
</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BuilderHeaderDropdown() {
|
||||
const confirm = useConfirm();
|
||||
const navigate = useNavigate();
|
||||
const { openDialog } = useDialogStore();
|
||||
|
||||
const resume = useCurrentResume();
|
||||
const patchResume = usePatchResume();
|
||||
const id = resume.id;
|
||||
const name = resume.name;
|
||||
const slug = resume.slug;
|
||||
const tags = resume.tags;
|
||||
const isLocked = resume.isLocked;
|
||||
|
||||
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
|
||||
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
|
||||
|
||||
const handleUpdate = () => {
|
||||
openDialog("resume.update", { id, name, slug, tags });
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
openDialog("resume.duplicate", { id, name, slug, tags, shouldRedirect: true });
|
||||
};
|
||||
|
||||
const handleToggleLock = async () => {
|
||||
if (!isLocked) {
|
||||
const confirmation = await confirm(t`Are you sure you want to lock this resume?`, {
|
||||
description: t`When locked, the resume cannot be updated or deleted.`,
|
||||
});
|
||||
|
||||
if (!confirmation) return;
|
||||
}
|
||||
|
||||
setLockedResume(
|
||||
{ id, isLocked: !isLocked },
|
||||
{
|
||||
onSuccess: () => {
|
||||
patchResume((draft) => {
|
||||
draft.isLocked = !isLocked;
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getResumeErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const confirmation = await confirm(t`Are you sure you want to delete this resume?`, {
|
||||
description: t`This action cannot be undone.`,
|
||||
});
|
||||
|
||||
if (!confirmation) return;
|
||||
|
||||
const toastId = toast.loading(t`Deleting your resume...`);
|
||||
|
||||
deleteResume(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t`Your resume has been deleted successfully.`, { id: toastId });
|
||||
void navigate({ to: "/dashboard/resumes", search: { sort: "lastUpdatedAt", tags: [] } });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getResumeErrorMessage(error), { id: toastId });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<CaretDownIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem disabled={isLocked} onClick={handleUpdate}>
|
||||
<PencilSimpleLineIcon className="me-2" />
|
||||
<Trans>Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleDuplicate}>
|
||||
<CopySimpleIcon className="me-2" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleToggleLock}>
|
||||
{isLocked ? <LockSimpleOpenIcon className="me-2" /> : <LockSimpleIcon className="me-2" />}
|
||||
{isLocked ? <Trans>Unlock</Trans> : <Trans>Lock</Trans>}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem variant="destructive" disabled={isLocked} onClick={handleDelete}>
|
||||
<TrashSimpleIcon className="me-2" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { FloppyDiskIcon } from "@phosphor-icons/react";
|
||||
import { useHotkey } from "@tanstack/react-hotkeys";
|
||||
import { Suspense } from "react";
|
||||
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { ResumePreview } from "@/components/resume/preview";
|
||||
import { BuilderDock } from "./dock";
|
||||
|
||||
export function PreviewPage() {
|
||||
useHotkey("Mod+S", () => {
|
||||
toast.info(t`Your changes are saved automatically.`, { id: "auto-save", icon: <FloppyDiskIcon /> });
|
||||
});
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LoadingScreen />}>
|
||||
<div className="fixed inset-0">
|
||||
<TransformWrapper
|
||||
centerOnInit
|
||||
maxScale={6}
|
||||
minScale={0.3}
|
||||
initialScale={0.6}
|
||||
limitToBounds={false}
|
||||
wheel={{ step: 0.001 }}
|
||||
>
|
||||
<TransformComponent wrapperClass="h-full! w-full!">
|
||||
<ResumePreview pageGap="2rem" showPageNumbers />
|
||||
</TransformComponent>
|
||||
|
||||
<BuilderDock />
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user