v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205)

This commit is contained in:
Amruth Pillai
2026-07-04 14:57:25 +02:00
committed by GitHub
parent 09bc6ec521
commit 57e9c8c487
181 changed files with 46794 additions and 11348 deletions
@@ -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>
);
}