mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-25 07:42:20 +10:00
v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205)
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { GearSixIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@reactive-resume/ui/components/sheet";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
|
||||
import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { AgentChat } from "@/routes/agent/-components/agent-chat";
|
||||
|
||||
type BuilderAiAssistantProps = {
|
||||
resumeId: string;
|
||||
};
|
||||
|
||||
type AiAssistantThreadProps = {
|
||||
threadId: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function CenteredState({ children }: { children: ReactNode }) {
|
||||
return <div className="grid h-full place-items-center p-6 text-center text-muted-foreground text-sm">{children}</div>;
|
||||
}
|
||||
|
||||
function NoProviderHint() {
|
||||
return (
|
||||
<div className="grid h-full place-items-center p-6">
|
||||
<div className="max-w-xs space-y-4 text-center">
|
||||
<SparkleIcon className="mx-auto size-8 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>Set up an AI provider to chat about this resume and apply edits automatically.</Trans>
|
||||
</p>
|
||||
<Button nativeButton={false} render={<Link to="/dashboard/settings/integrations" />}>
|
||||
<GearSixIcon />
|
||||
<Trans>Set up an AI provider</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loads the full thread detail (messages, actions) once the in-place thread is resolved, then mounts the shared
|
||||
// agent chat. The builder's own resume-update subscription applies the agent's server-side patches to the live
|
||||
// store, so edits appear in the preview and undo/version history without any extra wiring here.
|
||||
function AiAssistantThread({ threadId, onClose }: AiAssistantThreadProps) {
|
||||
const { data, isLoading, error } = useQuery(orpc.agent.threads.get.queryOptions({ input: { id: threadId } }));
|
||||
|
||||
if (isLoading || !data) {
|
||||
return <CenteredState>{error ? <Trans>This assistant could not be opened.</Trans> : <Spinner />}</CenteredState>;
|
||||
}
|
||||
|
||||
const readOnlyReason: "archived" | "missing" | null = data.isReadOnly
|
||||
? data.thread.status === "archived"
|
||||
? "archived"
|
||||
: "missing"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<AgentChat
|
||||
threadId={threadId}
|
||||
initialMessages={data.messages}
|
||||
isReadOnly={data.isReadOnly}
|
||||
readOnlyReason={readOnlyReason}
|
||||
threadStatus={data.thread.status}
|
||||
activeRunId={data.thread.activeRunId}
|
||||
actions={data.actions}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AiAssistantPanel({ resumeId, onClose }: BuilderAiAssistantProps & { onClose: () => void }) {
|
||||
const { hasUsableProvider, isLoading } = useHasUsableAiProvider();
|
||||
const getOrCreate = useMutation(orpc.agent.threads.getOrCreateForResume.mutationOptions());
|
||||
const [threadId, setThreadId] = useState<string | null>(null);
|
||||
const [setupFailed, setSetupFailed] = useState(false);
|
||||
const startedRef = useRef(false);
|
||||
|
||||
// Resolve (or create) the in-place assistant thread once, the first time the panel opens with a usable provider.
|
||||
useEffect(() => {
|
||||
if (!hasUsableProvider || startedRef.current) return;
|
||||
startedRef.current = true;
|
||||
getOrCreate.mutate(
|
||||
{ resumeId },
|
||||
{
|
||||
onSuccess: (thread) => setThreadId(thread.id),
|
||||
onError: (mutationError) => {
|
||||
setSetupFailed(true);
|
||||
toast.error(getOrpcErrorMessage(mutationError, { fallback: t`Failed to start the AI assistant.` }));
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [hasUsableProvider, resumeId, getOrCreate]);
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<CenteredState>
|
||||
<Spinner />
|
||||
</CenteredState>
|
||||
);
|
||||
if (!hasUsableProvider) return <NoProviderHint />;
|
||||
if (setupFailed)
|
||||
return (
|
||||
<CenteredState>
|
||||
<Trans>Failed to start the AI assistant.</Trans>
|
||||
</CenteredState>
|
||||
);
|
||||
if (!threadId)
|
||||
return (
|
||||
<CenteredState>
|
||||
<Spinner />
|
||||
</CenteredState>
|
||||
);
|
||||
|
||||
return <AiAssistantThread threadId={threadId} onClose={onClose} />;
|
||||
}
|
||||
|
||||
export function BuilderAiAssistant({ resumeId }: BuilderAiAssistantProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t`Open AI assistant`}
|
||||
aria-pressed={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SparkleIcon weight={open ? "fill" : "regular"} />
|
||||
</Button>
|
||||
|
||||
<SheetContent
|
||||
side="right"
|
||||
showCloseButton={false}
|
||||
className="w-full max-w-full gap-0 p-0 sm:max-w-md md:max-w-lg"
|
||||
>
|
||||
<SheetTitle className="sr-only">
|
||||
<Trans>AI assistant</Trans>
|
||||
</SheetTitle>
|
||||
{/* Remount per open so a fresh thread is resolved and stale chat state is dropped. */}
|
||||
{open ? <AiAssistantPanel resumeId={resumeId} onClose={() => setOpen(false)} /> : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,41 @@
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
import type { BuilderPreviewPageLayout } from "./page-layout";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
AlignCenterHorizontalIcon,
|
||||
AlignTopIcon,
|
||||
ArrowUUpLeftIcon,
|
||||
ArrowUUpRightIcon,
|
||||
ChatCircleDotsIcon,
|
||||
CircleNotchIcon,
|
||||
CubeFocusIcon,
|
||||
FileDocIcon,
|
||||
FileJsIcon,
|
||||
FilePdfIcon,
|
||||
LinkSimpleIcon,
|
||||
MagnifyingGlassMinusIcon,
|
||||
MagnifyingGlassPlusIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useHotkey } from "@tanstack/react-hotkeys";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { m } from "motion/react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useControls } from "react-zoom-pan-pinch";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useControls, useTransformComponent } from "react-zoom-pan-pinch";
|
||||
import { toast } from "sonner";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
import { buildDocx } from "@reactive-resume/docx";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume } from "@/features/resume/builder/draft";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import {
|
||||
isEditableElementFocused,
|
||||
useCanRedo,
|
||||
useCanUndo,
|
||||
useCurrentResume,
|
||||
useRedoResume,
|
||||
useUndoResume,
|
||||
} from "@/features/resume/builder/draft";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
|
||||
type BuilderDockProps = {
|
||||
@@ -40,9 +49,29 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const { zoomIn, zoomOut, centerView } = useControls();
|
||||
const { zoomIn, zoomOut, resetTransform } = useControls();
|
||||
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
const canUndo = useCanUndo();
|
||||
const canRedo = useCanRedo();
|
||||
const undo = useUndoResume();
|
||||
const redo = useRedoResume();
|
||||
|
||||
useHotkey("Mod+0", () => resetTransform());
|
||||
// App-level undo/redo of resume state, scoped to the builder. Mod maps to Cmd (mac) / Ctrl (win/linux).
|
||||
// Inside a focused text field, defer to the browser's native input undo; the dock buttons remain
|
||||
// available for resume-level history while editing a field.
|
||||
useHotkey("Mod+Z", () => {
|
||||
if (isEditableElementFocused()) return;
|
||||
undo();
|
||||
});
|
||||
useHotkey("Mod+Shift+Z", () => {
|
||||
if (isEditableElementFocused()) return;
|
||||
redo();
|
||||
});
|
||||
useHotkey("Control+Y", () => {
|
||||
if (isEditableElementFocused()) return;
|
||||
redo();
|
||||
});
|
||||
|
||||
const publicUrl = useMemo(() => {
|
||||
if (!session?.user.username || !resume?.slug) return "";
|
||||
@@ -54,48 +83,8 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
|
||||
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">
|
||||
<div className="fixed inset-x-0 bottom-20 flex items-center justify-center md:bottom-4">
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: -18 }}
|
||||
animate={{ opacity: 0.6, y: 0 }}
|
||||
@@ -103,9 +92,12 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
|
||||
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 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()} />
|
||||
<DockIcon icon={ArrowUUpLeftIcon} title={t`Undo`} disabled={!canUndo} onClick={() => undo()} />
|
||||
<DockIcon icon={ArrowUUpRightIcon} title={t`Redo`} disabled={!canRedo} onClick={() => redo()} />
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
<DockIcon icon={MagnifyingGlassMinusIcon} title={t`Zoom out`} onClick={() => zoomOut(0.15)} />
|
||||
<ZoomMenu />
|
||||
<DockIcon icon={MagnifyingGlassPlusIcon} title={t`Zoom in`} onClick={() => zoomIn(0.15)} />
|
||||
<DockIcon
|
||||
icon={pageLayout === "horizontal" ? AlignTopIcon : AlignCenterHorizontalIcon}
|
||||
title={t`Toggle page stacking`}
|
||||
@@ -121,20 +113,42 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
|
||||
/>
|
||||
<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")}
|
||||
/>
|
||||
</m.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ZoomMenu() {
|
||||
const scale = useTransformComponent((ctx) => ctx.state.scale);
|
||||
const { centerView, resetTransform } = useControls();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={t`Zoom level`}
|
||||
className="h-8 min-w-14 px-2 font-medium text-xs tabular-nums"
|
||||
>
|
||||
{Math.round(scale * 100)}%
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent side="top" align="center">
|
||||
<DropdownMenuItem onClick={() => centerView(1)}>
|
||||
<Trans>Actual size (100%)</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => resetTransform()}>
|
||||
<Trans>Fit to view</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
type DockIconProps = {
|
||||
title: string;
|
||||
icon: Icon;
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("BuilderSidebarEdge", () => {
|
||||
expect(wrapper.className).toContain("border-l");
|
||||
});
|
||||
|
||||
it("is hidden on mobile (hidden + sm:flex)", () => {
|
||||
it("is hidden on mobile (hidden + md:flex)", () => {
|
||||
const { container } = render(
|
||||
<BuilderSidebarEdge side="left">
|
||||
<span>x</span>
|
||||
@@ -44,6 +44,6 @@ describe("BuilderSidebarEdge", () => {
|
||||
);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.className).toContain("hidden");
|
||||
expect(wrapper.className).toContain("sm:flex");
|
||||
expect(wrapper.className).toContain("md:flex");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@ 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",
|
||||
// `md:` (not `sm:`) so the strip only shows on real desktop; the mobile shell takes over below 768px.
|
||||
"absolute inset-y-0 hidden min-h-0 w-12 flex-col items-center overflow-hidden bg-popover py-2.5 md:flex",
|
||||
side === "left" ? "inset-s-0 border-r" : "inset-e-0 border-l",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,17 +2,25 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
CaretDownIcon,
|
||||
CheckCircleIcon,
|
||||
CircleNotchIcon,
|
||||
CopySimpleIcon,
|
||||
DownloadSimpleIcon,
|
||||
FileDocIcon,
|
||||
FileJsIcon,
|
||||
HouseSimpleIcon,
|
||||
LockSimpleIcon,
|
||||
LockSimpleOpenIcon,
|
||||
PencilSimpleLineIcon,
|
||||
PrinterIcon,
|
||||
SidebarSimpleIcon,
|
||||
TrashSimpleIcon,
|
||||
WarningCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -22,11 +30,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useCurrentResume, usePatchResume } from "@/features/resume/builder/draft";
|
||||
import { useCurrentResume, usePatchResume, useSaveStatus } from "@/features/resume/builder/draft";
|
||||
import { useResumeExport } from "@/features/resume/export/use-resume-export";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { useBuilderSidebar } from "../-store/sidebar";
|
||||
import { BuilderAiAssistant } from "./ai-assistant";
|
||||
import { BuilderVersionHistory } from "./version-history";
|
||||
|
||||
export function BuilderHeader() {
|
||||
const resume = useCurrentResume();
|
||||
@@ -34,18 +45,23 @@ export function BuilderHeader() {
|
||||
const isLocked = resume.isLocked;
|
||||
const toggleSidebar = useBuilderSidebar((state) => state.toggleSidebar);
|
||||
|
||||
// Equal-width flex-1 side groups keep the center title group truly centered regardless of the
|
||||
// wider Download button on the right.
|
||||
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="absolute inset-x-0 top-0 z-50 flex h-14 items-center gap-x-2 border-b bg-popover px-1.5">
|
||||
<div className="flex flex-1 items-center justify-start">
|
||||
{/* Hidden below `md`: on mobile the sidebar panels never mount, so `toggleSidebar` no-ops — the bottom tab bar handles this. */}
|
||||
<Button size="icon" variant="ghost" className="hidden md:flex" 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>
|
||||
|
||||
<div className="flex items-center gap-x-1">
|
||||
<div className="flex min-w-0 items-center gap-x-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -61,23 +77,105 @@ export function BuilderHeader() {
|
||||
}
|
||||
/>
|
||||
<span className="me-2.5 text-muted-foreground">/</span>
|
||||
<h2 className="flex-1 truncate font-medium">{name}</h2>
|
||||
<h2 className="min-w-0 truncate font-medium">{name}</h2>
|
||||
{isLocked && <LockSimpleIcon className="ms-2 text-muted-foreground" />}
|
||||
<SaveStatusIndicator />
|
||||
<BuilderAiAssistant resumeId={resume.id} />
|
||||
<BuilderVersionHistory resumeId={resume.id} />
|
||||
<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 className="flex flex-1 items-center justify-end gap-x-1">
|
||||
<ResumeDownloadButton />
|
||||
|
||||
<Button size="icon" variant="ghost" className="hidden md:flex" 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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumeDownloadButton() {
|
||||
const resume = useCurrentResume();
|
||||
const { onDownloadPDF, onDownloadDOCX, onDownloadJSON, onPrint, isExporting } = useResumeExport(resume);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Button size="sm" className="rounded-e-none" disabled={isExporting} onClick={onDownloadPDF}>
|
||||
{isExporting ? <CircleNotchIcon className="me-1.5 animate-spin" /> : <DownloadSimpleIcon className="me-1.5" />}
|
||||
<Trans comment="Primary action in the builder header to download the resume as a PDF">Download PDF</Trans>
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isExporting}
|
||||
aria-label={t`More download options`}
|
||||
className="rounded-s-none border-primary-foreground/20 border-s px-1.5"
|
||||
>
|
||||
<CaretDownIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onDownloadDOCX}>
|
||||
<FileDocIcon className="me-2" />
|
||||
<Trans>Download DOCX</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onDownloadJSON}>
|
||||
<FileJsIcon className="me-2" />
|
||||
<Trans>Download JSON</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={onPrint}>
|
||||
<PrinterIcon className="me-2" />
|
||||
<Trans>Print</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveStatusIndicator() {
|
||||
const status = useSaveStatus();
|
||||
if (status === "idle") return null;
|
||||
|
||||
const { icon, label } = match(status)
|
||||
.with("saving", () => ({
|
||||
icon: <CircleNotchIcon className="animate-spin" />,
|
||||
label: t`Saving…`,
|
||||
}))
|
||||
.with("saved", () => ({ icon: <CheckCircleIcon />, label: t`Saved` }))
|
||||
.with("error", () => ({
|
||||
icon: <WarningCircleIcon className="text-destructive" />,
|
||||
label: t`Couldn't save`,
|
||||
}))
|
||||
.exhaustive();
|
||||
|
||||
return (
|
||||
<span
|
||||
className="ms-1 flex shrink-0 items-center gap-x-1 text-muted-foreground text-xs"
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
>
|
||||
{icon}
|
||||
<span className="hidden md:inline">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BuilderHeaderDropdown() {
|
||||
const confirm = useConfirm();
|
||||
const navigate = useNavigate();
|
||||
@@ -153,7 +251,7 @@ function BuilderHeaderDropdown() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<Button size="icon" variant="ghost" aria-label={t`Resume options`}>
|
||||
<CaretDownIcon />
|
||||
</Button>
|
||||
}
|
||||
@@ -162,7 +260,7 @@ function BuilderHeaderDropdown() {
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem disabled={isLocked} onClick={handleUpdate}>
|
||||
<PencilSimpleLineIcon className="me-2" />
|
||||
<Trans>Update</Trans>
|
||||
<Trans>Edit details</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={handleDuplicate}>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Resume } from "@/features/resume/builder/draft";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ClockCounterClockwiseIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { useResumeStore } from "@/features/resume/builder/draft";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
|
||||
{ amount: 31_536_000_000, unit: "year" },
|
||||
{ amount: 2_592_000_000, unit: "month" },
|
||||
{ amount: 604_800_000, unit: "week" },
|
||||
{ amount: 86_400_000, unit: "day" },
|
||||
{ amount: 3_600_000, unit: "hour" },
|
||||
{ amount: 60_000, unit: "minute" },
|
||||
];
|
||||
|
||||
function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
const diffMs = date.getTime() - Date.now();
|
||||
const absMs = Math.abs(diffMs);
|
||||
|
||||
// No division matches only when the gap is under a minute (the smallest division), so fall back to seconds.
|
||||
const division = RELATIVE_TIME_DIVISIONS.find((candidate) => absMs >= candidate.amount);
|
||||
if (!division) return formatter.format(0, "second");
|
||||
|
||||
return formatter.format(Math.round(diffMs / division.amount), division.unit);
|
||||
}
|
||||
|
||||
type BuilderVersionHistoryProps = {
|
||||
resumeId: string;
|
||||
};
|
||||
|
||||
export function BuilderVersionHistory({ resumeId }: BuilderVersionHistoryProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const confirm = useConfirm();
|
||||
const queryClient = useQueryClient();
|
||||
const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer);
|
||||
|
||||
const relativeTimeFormatter = useMemo(() => new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" }), []);
|
||||
|
||||
const { data: versions, isLoading } = useQuery({
|
||||
...orpc.resume.listVersions.queryOptions({ input: { resumeId } }),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { mutate: restoreVersion, isPending } = useMutation(orpc.resume.restoreVersion.mutationOptions());
|
||||
|
||||
const handleRestore = async (versionId: string) => {
|
||||
const confirmed = await confirm(t`Restore this version?`, {
|
||||
description: t`Earlier versions are kept; the builder's undo history is reset.`,
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
restoreVersion(
|
||||
{ resumeId, versionId },
|
||||
{
|
||||
onSuccess: (restored) => {
|
||||
replaceResumeFromServer(restored as Resume);
|
||||
queryClient.setQueryData(orpc.resume.getById.queryOptions({ input: { id: resumeId } }).queryKey, restored);
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.resume.listVersions.queryKey({ input: { resumeId } }) });
|
||||
toast.success(t`Your resume has been restored to the selected version.`);
|
||||
},
|
||||
onError: (error) => toast.error(getResumeErrorMessage(error)),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost" aria-label={t`Version history`}>
|
||||
<ClockCounterClockwiseIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Version history</Trans>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{isLoading && (
|
||||
<div className="px-2 py-3 text-muted-foreground text-xs">
|
||||
<Trans>Loading…</Trans>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (!versions || versions.length === 0) && (
|
||||
<div className="px-2 py-3 text-muted-foreground text-xs">
|
||||
<Trans>No saved versions yet.</Trans>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{versions?.map((version) => (
|
||||
<DropdownMenuItem
|
||||
key={version.id}
|
||||
disabled={isPending}
|
||||
className="flex-col items-start gap-0.5"
|
||||
onClick={() => handleRestore(version.id)}
|
||||
>
|
||||
<span className="font-medium">{version.label}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatRelativeTime(version.createdAt, relativeTimeFormatter)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user