mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 14:52:18 +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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
import type { LeftSidebarSection } from "@/libs/resume/section";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { LockSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@reactive-resume/ui/components/avatar";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { getInitials } from "@reactive-resume/utils/string";
|
||||
import { useCurrentResume, useIsResumeLocked, usePatchResume } from "@/features/resume/builder/draft";
|
||||
import { UserDropdownMenu } from "@/features/user/dropdown-menu";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { getSectionIcon, getSectionTitle, leftSidebarSections } from "@/libs/resume/section";
|
||||
import { BuilderSidebarEdge } from "../../-components/edge";
|
||||
import { useBuilderSidebar } from "../../-store/sidebar";
|
||||
@@ -50,41 +59,82 @@ function getSectionComponent(type: LeftSidebarSection) {
|
||||
|
||||
export function BuilderSidebarLeft() {
|
||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const isLocked = useIsResumeLocked();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarEdge scrollAreaRef={scrollAreaRef} />
|
||||
<SidebarEdge />
|
||||
|
||||
<ScrollArea ref={scrollAreaRef} className="@container h-[calc(100svh-3.5rem)] bg-background sm:ms-12">
|
||||
<div className="space-y-4 p-4">
|
||||
{leftSidebarSections.map((section) => (
|
||||
<Fragment key={section}>
|
||||
{getSectionComponent(section)}
|
||||
<Separator />
|
||||
</Fragment>
|
||||
))}
|
||||
{isLocked && <LockBanner />}
|
||||
|
||||
<fieldset disabled={isLocked} className="m-0 min-w-0 space-y-4 border-0 p-0">
|
||||
{leftSidebarSections.map((section) => (
|
||||
<Fragment key={section}>
|
||||
{getSectionComponent(section)}
|
||||
<Separator />
|
||||
</Fragment>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarEdgeProps = {
|
||||
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
function LockBanner() {
|
||||
const resume = useCurrentResume();
|
||||
const patchResume = usePatchResume();
|
||||
const { mutate: setLocked, isPending } = useMutation(orpc.resume.setLocked.mutationOptions());
|
||||
|
||||
function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
|
||||
const handleUnlock = () => {
|
||||
setLocked(
|
||||
{ id: resume.id, isLocked: false },
|
||||
{
|
||||
onSuccess: () => {
|
||||
patchResume((draft) => {
|
||||
draft.isLocked = false;
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getResumeErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-3 rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<LockSimpleIcon className="size-5 shrink-0 text-amber-600 dark:text-amber-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>This resume is locked</Trans>
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>Editing is disabled until you unlock it.</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" disabled={isPending} onClick={handleUnlock}>
|
||||
<Trans>Enable editing</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarEdge() {
|
||||
const toggleSidebar = useBuilderSidebar((state) => state.toggleSidebar);
|
||||
|
||||
const scrollToSection = useCallback(
|
||||
(section: LeftSidebarSection) => {
|
||||
if (!scrollAreaRef.current) return;
|
||||
toggleSidebar("left", true);
|
||||
|
||||
const sectionElement = scrollAreaRef.current.querySelector(`#sidebar-${section}`);
|
||||
sectionElement?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
|
||||
// Section ids are globally unique; document.getElementById reliably resolves the scroll target
|
||||
// (querying through the ScrollArea ref did not — its ref does not expose the scroll container).
|
||||
document
|
||||
.getElementById(`sidebar-${section}`)
|
||||
?.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
|
||||
},
|
||||
[toggleSidebar, scrollAreaRef],
|
||||
[toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -93,22 +143,30 @@ function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
|
||||
<div className="no-scrollbar min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="flex min-h-full flex-col items-center justify-center gap-y-2">
|
||||
{leftSidebarSections.map((section) => (
|
||||
<Button
|
||||
key={section}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={getSectionTitle(section)}
|
||||
onClick={() => scrollToSection(section)}
|
||||
>
|
||||
{getSectionIcon(section)}
|
||||
</Button>
|
||||
<Tooltip key={section}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={getSectionTitle(section)}
|
||||
onClick={() => scrollToSection(section)}
|
||||
>
|
||||
{getSectionIcon(section)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="right" className="font-medium">
|
||||
{getSectionTitle(section)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UserDropdownMenu>
|
||||
{({ session }) => (
|
||||
<Button size="icon" variant="ghost">
|
||||
<Button size="icon" variant="ghost" aria-label={t`Account menu`}>
|
||||
<Avatar className="size-6">
|
||||
<AvatarImage src={session.user.image ?? undefined} />
|
||||
<AvatarFallback className="text-[0.5rem]">{getInitials(session.user.name)}</AvatarFallback>
|
||||
|
||||
@@ -87,7 +87,7 @@ export const CustomFieldsSection = withForm({
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost" className="ms-1">
|
||||
<Button size="icon" variant="ghost" aria-label={t`Add link`} className="ms-1">
|
||||
<LinkIcon />
|
||||
</Button>
|
||||
}
|
||||
@@ -121,6 +121,7 @@ export const CustomFieldsSection = withForm({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t`Remove custom field`}
|
||||
onClick={() => {
|
||||
customFieldsField.removeValue(index);
|
||||
void form.handleSubmit();
|
||||
@@ -169,6 +170,7 @@ function CustomFieldItem({ field, children }: CustomFieldItemProps) {
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t`Reorder custom field`}
|
||||
className="me-2 touch-none"
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -266,7 +266,7 @@ function CustomSectionDropdownMenu({ section }: CustomSectionDropdownMenuProps)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger aria-label={t`Section options`}>
|
||||
<DotsThreeVerticalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import type { Area } from "react-easy-crop";
|
||||
import type z from "zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { EyeIcon, EyeSlashIcon, TrashSimpleIcon, UploadSimpleIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
MagnifyingGlassMinusIcon,
|
||||
MagnifyingGlassPlusIcon,
|
||||
TrashSimpleIcon,
|
||||
UploadSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Cropper from "react-easy-crop";
|
||||
import { toast } from "sonner";
|
||||
import { pictureSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ButtonGroup } from "@reactive-resume/ui/components/button-group";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@reactive-resume/ui/components/dialog";
|
||||
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
|
||||
import { Input } from "@reactive-resume/ui/components/input";
|
||||
import {
|
||||
@@ -16,6 +33,8 @@ import {
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Slider } from "@reactive-resume/ui/components/slider";
|
||||
import "react-easy-crop/react-easy-crop.css";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
|
||||
@@ -109,6 +128,7 @@ function PicturePreviewControls({
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={picture.hidden ? t`Show picture` : t`Hide picture`}
|
||||
onClick={() => {
|
||||
form.setFieldValue("hidden", !picture.hidden);
|
||||
onAutoSave();
|
||||
@@ -362,6 +382,44 @@ function normalizePictureUrl(url: string, origin: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
async function getCroppedImageBlob(imageSrc: string, pixelCrop: Area): Promise<Blob> {
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const element = new Image();
|
||||
element.addEventListener("load", () => {
|
||||
resolve(element);
|
||||
});
|
||||
element.addEventListener("error", () => {
|
||||
reject(new Error("Failed to load image for cropping"));
|
||||
});
|
||||
element.src = imageSrc;
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas 2D context is not available");
|
||||
|
||||
canvas.width = Math.round(pixelCrop.width);
|
||||
canvas.height = Math.round(pixelCrop.height);
|
||||
context.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) resolve(blob);
|
||||
else reject(new Error("Canvas is empty"));
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
async function createPicturePreviewUrl(url: string, signal: AbortSignal) {
|
||||
const response = await fetch(url, { signal });
|
||||
|
||||
@@ -388,10 +446,20 @@ function usePictureSettingsForm(picture: PictureValues, persist: (data: PictureV
|
||||
|
||||
type PictureSettingsForm = ReturnType<typeof usePictureSettingsForm>;
|
||||
|
||||
type CropState = {
|
||||
file: File;
|
||||
imageSrc: string;
|
||||
};
|
||||
|
||||
function PictureSectionForm() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const appOrigin = typeof window === "undefined" ? "" : window.location.origin;
|
||||
|
||||
const [cropState, setCropState] = useState<CropState | null>(null);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
|
||||
const resume = useCurrentResume();
|
||||
const picture = resume.data.picture;
|
||||
const normalizedPictureUrl = normalizePictureUrl(picture.url, appOrigin);
|
||||
@@ -441,10 +509,7 @@ function PictureSectionForm() {
|
||||
handleAutoSave();
|
||||
};
|
||||
|
||||
const onUploadPicture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const uploadPictureFile = (file: File) => {
|
||||
const toastId = toast.loading(t`Uploading picture…`);
|
||||
|
||||
uploadFile(file, {
|
||||
@@ -469,6 +534,41 @@ function PictureSectionForm() {
|
||||
});
|
||||
};
|
||||
|
||||
const onUploadPicture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Open the interactive crop step instead of uploading immediately.
|
||||
setCropState({ file, imageSrc: URL.createObjectURL(file) });
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setCroppedAreaPixels(null);
|
||||
};
|
||||
|
||||
const closeCropDialog = () => {
|
||||
if (cropState) URL.revokeObjectURL(cropState.imageSrc);
|
||||
setCropState(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const onConfirmCrop = async () => {
|
||||
if (!cropState) return;
|
||||
|
||||
let fileToUpload: File = cropState.file;
|
||||
try {
|
||||
if (croppedAreaPixels) {
|
||||
const blob = await getCroppedImageBlob(cropState.imageSrc, croppedAreaPixels);
|
||||
fileToUpload = new File([blob], cropState.file.name, { type: blob.type });
|
||||
}
|
||||
} catch {
|
||||
// ponytail: canvas crop can fail (tainted image, no context) — fall back to the original file.
|
||||
fileToUpload = cropState.file;
|
||||
}
|
||||
|
||||
uploadPictureFile(fileToUpload);
|
||||
closeCropDialog();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const objectUrl = picturePreviewQuery.data;
|
||||
|
||||
@@ -477,142 +577,223 @@ function PictureSectionForm() {
|
||||
};
|
||||
}, [picturePreviewQuery.data]);
|
||||
|
||||
const cropAspect = Number(form.state.values.aspectRatio) || 1;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<PicturePreviewControls
|
||||
fileInputRef={fileInputRef}
|
||||
form={form}
|
||||
normalizedPictureUrl={normalizedPictureUrl}
|
||||
picture={picture}
|
||||
pictureSrc={pictureSrc}
|
||||
onAutoSave={handleAutoSave}
|
||||
onDeletePicture={onDeletePicture}
|
||||
onSelectPicture={onSelectPicture}
|
||||
onUploadPicture={onUploadPicture}
|
||||
/>
|
||||
<>
|
||||
<Dialog
|
||||
open={cropState !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeCropDialog();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Crop picture</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Drag to reposition and use the slider to zoom before uploading.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid @md:grid-cols-2 grid-cols-1 gap-4">
|
||||
<PictureGeometryFields form={form} onAutoSave={handleAutoSave} />
|
||||
{cropState && (
|
||||
<div className="relative h-64 w-full overflow-hidden rounded-md bg-secondary ring-1 ring-border ring-inset">
|
||||
<Cropper
|
||||
image={cropState.imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={cropAspect}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={(_, areaPixels) => {
|
||||
setCroppedAreaPixels(areaPixels);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-x-3">
|
||||
<form.Field name="borderColor">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="mb-1.5 shrink-0"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel className="mb-0">
|
||||
<Trans>Zoom</Trans>
|
||||
</FormLabel>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">{zoom.toFixed(1)}×</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<MagnifyingGlassMinusIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Slider
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={[zoom]}
|
||||
aria-label={t`Zoom`}
|
||||
className="flex-1"
|
||||
onValueChange={(value) => {
|
||||
setZoom(Array.isArray(value) ? value[0] : value);
|
||||
}}
|
||||
/>
|
||||
<MagnifyingGlassPlusIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form.Field name="borderWidth">
|
||||
{(field) => (
|
||||
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Border Width</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeCropDialog}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
void onConfirmCrop();
|
||||
}}
|
||||
>
|
||||
<Trans>Save & Upload</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<PicturePreviewControls
|
||||
fileInputRef={fileInputRef}
|
||||
form={form}
|
||||
normalizedPictureUrl={normalizedPictureUrl}
|
||||
picture={picture}
|
||||
pictureSrc={pictureSrc}
|
||||
onAutoSave={handleAutoSave}
|
||||
onDeletePicture={onDeletePicture}
|
||||
onSelectPicture={onSelectPicture}
|
||||
onUploadPicture={onUploadPicture}
|
||||
/>
|
||||
|
||||
<div className="grid @md:grid-cols-2 grid-cols-1 gap-4">
|
||||
<PictureGeometryFields form={form} onAutoSave={handleAutoSave} />
|
||||
|
||||
<div className="flex items-end gap-x-3">
|
||||
<form.Field name="borderColor">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="mb-1.5 shrink-0"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<div className="flex items-end gap-x-3">
|
||||
<form.Field name="shadowColor">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="mb-1.5 shrink-0"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
<form.Field name="borderWidth">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="flex-1"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Border Width</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Field name="shadowWidth">
|
||||
{(field) => (
|
||||
<FormItem className="flex-1" hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
|
||||
<FormLabel>
|
||||
<Trans>Shadow Width</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<div className="flex items-end gap-x-3">
|
||||
<form.Field name="shadowColor">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="mb-1.5 shrink-0"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
<ColorPicker
|
||||
defaultValue={field.state.value}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="shadowWidth">
|
||||
{(field) => (
|
||||
<FormItem
|
||||
className="flex-1"
|
||||
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
|
||||
>
|
||||
<FormLabel>
|
||||
<Trans>Shadow Width</Trans>
|
||||
</FormLabel>
|
||||
<InputGroup>
|
||||
<FormControl
|
||||
render={
|
||||
<InputGroupInput
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") field.handleChange("" as unknown as number);
|
||||
else field.handleChange(Number(value));
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText>pt</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import type { LeftSidebarSection } from "@/libs/resume/section";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { CaretDownIcon } from "@phosphor-icons/react";
|
||||
import { getDefaultSectionIconName } from "@reactive-resume/schema/resume/section-icons";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
|
||||
@@ -36,6 +37,8 @@ export function SectionBase({ type, className, ...props }: Props) {
|
||||
const fallbackIcon = hasSectionIcon ? getDefaultSectionIconName(type as "summary" | SectionType) : "";
|
||||
const sectionIcon = rawIcon === "none" ? "" : rawIcon || fallbackIcon;
|
||||
|
||||
const sectionTitle = ("title" in section && section.title) || getSectionTitle(type);
|
||||
|
||||
const collapsed = useSectionStore((state) => state.sections[type]?.collapsed ?? false);
|
||||
const toggleCollapsed = useSectionStore((state) => state.toggleCollapsed);
|
||||
|
||||
@@ -64,7 +67,7 @@ export function SectionBase({ type, className, ...props }: Props) {
|
||||
<AccordionTrigger
|
||||
className="me-2 items-center justify-center"
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<Button size="icon" variant="ghost" aria-label={t`Toggle ${sectionTitle} section`}>
|
||||
<CaretDownIcon className="transition-transform duration-200 group-data-closed/accordion-item:-rotate-90" />
|
||||
</Button>
|
||||
}
|
||||
@@ -76,9 +79,7 @@ export function SectionBase({ type, className, ...props }: Props) {
|
||||
) : (
|
||||
getSectionIcon(type)
|
||||
)}
|
||||
<h2 className="line-clamp-1 font-semibold text-2xl tracking-tight">
|
||||
{("title" in section && section.title) || getSectionTitle(type)}
|
||||
</h2>
|
||||
<h2 className="line-clamp-1 font-semibold text-2xl tracking-tight">{sectionTitle}</h2>
|
||||
</div>
|
||||
|
||||
{!["picture", "basics", "custom"].includes(type) && (
|
||||
|
||||
@@ -276,7 +276,10 @@ export function SectionItem<T extends CustomSectionItem | SectionItemType>({
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 focus:outline-none focus-visible:ring-1 group-hover:opacity-100">
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t`Options for ${title}`}
|
||||
className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 focus:outline-none focus-visible:ring-1 group-hover:opacity-100"
|
||||
>
|
||||
<DotsThreeVerticalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ export function SectionDropdownMenu({ type }: Props) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<Button size="icon" variant="ghost" aria-label={t`Section options`}>
|
||||
<ListIcon />
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { match } from "ts-pattern";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { Copyright } from "@/components/ui/copyright";
|
||||
import { getSectionIcon, getSectionTitle, rightSidebarSections } from "@/libs/resume/section";
|
||||
import { BuilderSidebarEdge } from "../../-components/edge";
|
||||
@@ -43,7 +44,7 @@ export function BuilderSidebarRight() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarEdge scrollAreaRef={scrollAreaRef} />
|
||||
<SidebarEdge />
|
||||
|
||||
<ScrollArea
|
||||
ref={scrollAreaRef}
|
||||
@@ -64,22 +65,18 @@ export function BuilderSidebarRight() {
|
||||
);
|
||||
}
|
||||
|
||||
type SidebarEdgeProps = {
|
||||
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
|
||||
function SidebarEdge() {
|
||||
const toggleSidebar = useBuilderSidebar((state) => state.toggleSidebar);
|
||||
|
||||
const scrollToSection = useCallback(
|
||||
(section: RightSidebarSection) => {
|
||||
if (!scrollAreaRef.current) return;
|
||||
toggleSidebar("right", true);
|
||||
|
||||
const sectionElement = scrollAreaRef.current.querySelector(`#sidebar-${section}`);
|
||||
sectionElement?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
|
||||
// Section ids are globally unique; document.getElementById reliably resolves the scroll target.
|
||||
document
|
||||
.getElementById(`sidebar-${section}`)
|
||||
?.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
|
||||
},
|
||||
[toggleSidebar, scrollAreaRef],
|
||||
[toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -87,15 +84,23 @@ function SidebarEdge({ scrollAreaRef }: SidebarEdgeProps) {
|
||||
<div className="no-scrollbar min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="flex min-h-full flex-col items-center justify-center gap-y-2">
|
||||
{rightSidebarSections.map((section) => (
|
||||
<Button
|
||||
key={section}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={getSectionTitle(section)}
|
||||
onClick={() => scrollToSection(section)}
|
||||
>
|
||||
{getSectionIcon(section)}
|
||||
</Button>
|
||||
<Tooltip key={section}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={getSectionTitle(section)}
|
||||
onClick={() => scrollToSection(section)}
|
||||
>
|
||||
{getSectionIcon(section)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="left" className="font-medium">
|
||||
{getSectionTitle(section)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type z from "zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { AnimatePresence, m } from "motion/react";
|
||||
@@ -221,6 +222,7 @@ function QuickColorCircle({ color, active, onSelect, className, ...props }: Quic
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Use color ${color}`}
|
||||
onClick={() => onSelect(color)}
|
||||
className={cn(
|
||||
"relative flex size-8 items-center justify-center rounded-md bg-transparent",
|
||||
|
||||
@@ -1,58 +1,13 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CircleNotchIcon, FileDocIcon, FileJsIcon, FilePdfIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { buildDocx } from "@reactive-resume/docx";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { useResume } from "@/features/resume/builder/draft";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import { useResumeExport } from "@/features/resume/export/use-resume-export";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function ExportSectionBuilder() {
|
||||
const resumeData = useResume();
|
||||
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
const resume = resumeData;
|
||||
|
||||
const onDownloadJSON = useCallback(() => {
|
||||
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]);
|
||||
const resume = useResume();
|
||||
const { onDownloadJSON, onDownloadDOCX, onDownloadPDF, isExporting } = useResumeExport(resume);
|
||||
|
||||
if (!resume) return null;
|
||||
|
||||
@@ -94,11 +49,11 @@ export function ExportSectionBuilder() {
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isPrinting}
|
||||
disabled={isExporting}
|
||||
onClick={onDownloadPDF}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
|
||||
>
|
||||
{isPrinting ? (
|
||||
{isExporting ? (
|
||||
<CircleNotchIcon className="size-6 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<FilePdfIcon className="size-6 shrink-0" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import type { CSSProperties, HTMLAttributes, Ref } from "react";
|
||||
import {
|
||||
closestCorners,
|
||||
@@ -13,10 +14,29 @@ import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy }
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DotsSixVerticalIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
ArrowBendUpRightIcon,
|
||||
DotsSixVerticalIcon,
|
||||
DotsThreeVerticalIcon,
|
||||
FileIcon,
|
||||
PlusCircleIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useCallback, useId, useState } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
@@ -385,24 +405,202 @@ type SortableLayoutItemProps = {
|
||||
columnId: ColumnId;
|
||||
};
|
||||
|
||||
function SortableLayoutItem({ id }: SortableLayoutItemProps) {
|
||||
function SortableLayoutItem({ id, pageIndex, columnId }: SortableLayoutItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
|
||||
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
return (
|
||||
<LayoutItemContent ref={setNodeRef} id={id} style={style} isDragging={isDragging} {...attributes} {...listeners} />
|
||||
<LayoutItemContent
|
||||
ref={setNodeRef}
|
||||
id={id}
|
||||
pageIndex={pageIndex}
|
||||
columnId={columnId}
|
||||
style={style}
|
||||
isDragging={isDragging}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type MoveToSubmenuProps = {
|
||||
id: string;
|
||||
pageIndex: number;
|
||||
columnId: ColumnId;
|
||||
};
|
||||
|
||||
/**
|
||||
* "Move to" submenu that mirrors the left-panel item menu but works at the
|
||||
* section level: it splices the section out of its current page/column and
|
||||
* pushes it onto the chosen target (or a brand new page).
|
||||
*/
|
||||
function MoveToSubmenu({ id, pageIndex, columnId }: MoveToSubmenuProps) {
|
||||
const resume = useCurrentResume();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const pages = resume.data.metadata.layout.pages;
|
||||
// When the template collapses the sidebar, no page has a usable sidebar column.
|
||||
const sidebarCollapsed = templates[resume.data.metadata.template].sidebarPosition === "none";
|
||||
|
||||
const moveTo = (targetPageIndex: number, targetColumnId: ColumnId) => {
|
||||
updateResumeData((draft) => {
|
||||
const from = draft.metadata.layout.pages[pageIndex][columnId];
|
||||
const index = from.indexOf(id);
|
||||
if (index === -1) return;
|
||||
from.splice(index, 1);
|
||||
draft.metadata.layout.pages[targetPageIndex][targetColumnId].push(id);
|
||||
});
|
||||
};
|
||||
|
||||
const moveToNewPage = () => {
|
||||
updateResumeData((draft) => {
|
||||
const from = draft.metadata.layout.pages[pageIndex][columnId];
|
||||
const index = from.indexOf(id);
|
||||
if (index === -1) return;
|
||||
from.splice(index, 1);
|
||||
draft.metadata.layout.pages.push({ fullWidth: false, main: [id], sidebar: [] });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ArrowBendUpRightIcon />
|
||||
<Trans>Move to</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
{pages.map((page, targetPageIndex) => {
|
||||
// Full-width pages hide their sidebar, so never offer it as a target.
|
||||
const sidebarHidden = sidebarCollapsed || page.fullWidth;
|
||||
|
||||
return (
|
||||
<DropdownMenuSub key={`page-${targetPageIndex}`}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FileIcon />
|
||||
<Trans>Page {targetPageIndex + 1}</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
disabled={targetPageIndex === pageIndex && columnId === "main"}
|
||||
onClick={() => moveTo(targetPageIndex, "main")}
|
||||
>
|
||||
{getColumnLabel("main")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{!sidebarHidden && (
|
||||
<DropdownMenuItem
|
||||
disabled={targetPageIndex === pageIndex && columnId === "sidebar"}
|
||||
onClick={() => moveTo(targetPageIndex, "sidebar")}
|
||||
>
|
||||
{getColumnLabel("sidebar")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
})}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={moveToNewPage}>
|
||||
<PlusCircleIcon />
|
||||
<Trans>New Page</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
type SectionBreakField = "keepTogether" | "startOnNewPage";
|
||||
|
||||
const readSectionBreak = (data: ResumeData, id: string, field: SectionBreakField): boolean => {
|
||||
if (id === "summary") return data.summary[field];
|
||||
if (id in data.sections) return data.sections[id as SectionType][field];
|
||||
return data.customSections.find((section) => section.id === id)?.[field] ?? false;
|
||||
};
|
||||
|
||||
type SectionBreakItemsProps = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-section page-break controls. These write the declarative `keepTogether` /
|
||||
* `startOnNewPage` flags onto the section metadata (summary, standard, or custom),
|
||||
* which the PDF renderer applies as `wrap` / `break` on the section container.
|
||||
*/
|
||||
function SectionBreakItems({ id }: SectionBreakItemsProps) {
|
||||
const resume = useCurrentResume();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const keepTogether = readSectionBreak(resume.data, id, "keepTogether");
|
||||
const startOnNewPage = readSectionBreak(resume.data, id, "startOnNewPage");
|
||||
|
||||
const toggle = (field: SectionBreakField) => {
|
||||
updateResumeData((draft) => {
|
||||
if (id === "summary") {
|
||||
draft.summary[field] = !draft.summary[field];
|
||||
return;
|
||||
}
|
||||
if (id in draft.sections) {
|
||||
const section = draft.sections[id as SectionType];
|
||||
section[field] = !section[field];
|
||||
return;
|
||||
}
|
||||
const custom = draft.customSections.find((section) => section.id === id);
|
||||
if (custom) custom[field] = !custom[field];
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={keepTogether}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={() => toggle("keepTogether")}
|
||||
>
|
||||
<Trans comment="Layout editor toggle that prevents a section from splitting across pages">Keep together</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
||||
<p className="px-2 pb-1 text-muted-foreground text-xs">
|
||||
<Trans comment="Helper note explaining the keep-together limitation">
|
||||
Only applies when the section fits on a single page.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={startOnNewPage}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={() => toggle("startOnNewPage")}
|
||||
>
|
||||
<Trans comment="Layout editor toggle that forces a section to begin on a new page">Start on new page</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type LayoutItemContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
id: string;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
pageIndex?: number;
|
||||
columnId?: ColumnId;
|
||||
isDragging?: boolean;
|
||||
isOverlay?: boolean;
|
||||
};
|
||||
|
||||
function LayoutItemContent({ id, ref, isDragging, isOverlay, className, style, ...rest }: LayoutItemContentProps) {
|
||||
function LayoutItemContent({
|
||||
id,
|
||||
ref,
|
||||
pageIndex,
|
||||
columnId,
|
||||
isDragging,
|
||||
isOverlay,
|
||||
className,
|
||||
style,
|
||||
...rest
|
||||
}: LayoutItemContentProps) {
|
||||
const resume = useCurrentResume();
|
||||
const title = resume ? resolveLayoutSectionTitle(resume.data, id) : id;
|
||||
|
||||
@@ -422,7 +620,26 @@ function LayoutItemContent({ id, ref, isDragging, isOverlay, className, style, .
|
||||
{...rest}
|
||||
>
|
||||
<DotsSixVerticalIcon className="opacity-40 transition-opacity group-hover/item:opacity-100" />
|
||||
<span className="truncate">{title}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{title}</span>
|
||||
|
||||
{/* The drag overlay renders without a location; only real rows get the menu. */}
|
||||
{!isOverlay && pageIndex !== undefined && columnId !== undefined && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t`Move section to another column or page`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
className="flex cursor-context-menu items-center rounded p-0.5 opacity-40 transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1 group-hover/item:opacity-100"
|
||||
>
|
||||
<DotsThreeVerticalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<MoveToSubmenu id={id} pageIndex={pageIndex} columnId={columnId} />
|
||||
<DropdownMenuSeparator />
|
||||
<SectionBreakItems id={id} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export function SharingSectionBuilder() {
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input readOnly id="sharing-url" value={publicUrl} />
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={onCopyUrl}>
|
||||
<Button size="icon" variant="ghost" aria-label={t`Copy URL`} onClick={onCopyUrl}>
|
||||
<ClipboardIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeDelta, getSparklinePoints } from "./statistics";
|
||||
|
||||
describe("computeDelta", () => {
|
||||
it("returns null when the prior period had no activity", () => {
|
||||
// Prior 2 days are all zero -> no baseline to compare against.
|
||||
expect(computeDelta([0, 0, 5, 5], 2)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a positive percentage when the recent period grew", () => {
|
||||
// previous sum = 2, recent sum = 4 -> +100%
|
||||
expect(computeDelta([1, 1, 2, 2], 2)).toBe(100);
|
||||
});
|
||||
|
||||
it("returns a negative percentage when the recent period shrank", () => {
|
||||
// previous sum = 4, recent sum = 2 -> -50%
|
||||
expect(computeDelta([2, 2, 1, 1], 2)).toBe(-50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSparklinePoints", () => {
|
||||
it("returns null for a single point", () => {
|
||||
expect(getSparklinePoints([5], 80, 24)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an all-zero series", () => {
|
||||
expect(getSparklinePoints([0, 0, 0], 80, 24)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns points for all-equal non-zero values (flat line at the top)", () => {
|
||||
// max === value, so every y is 0; x spans 0..width.
|
||||
expect(getSparklinePoints([3, 3, 3], 80, 24)).toBe("0.0,0.0 40.0,0.0 80.0,0.0");
|
||||
});
|
||||
});
|
||||
@@ -17,18 +17,29 @@ const queryResult = vi.hoisted(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const dailyResult = vi.hoisted(() => ({
|
||||
data: undefined as undefined | { date: string; views: number; downloads: number }[],
|
||||
}));
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => queryResult,
|
||||
useQuery: (options: { __key?: string }) => (options.__key === "daily" ? dailyResult : queryResult),
|
||||
}));
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useParams: () => ({ resumeId: "r1" }),
|
||||
}));
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: { resume: { statistics: { getById: { queryOptions: () => ({}) } } } },
|
||||
orpc: {
|
||||
resume: {
|
||||
statistics: {
|
||||
getById: { queryOptions: () => ({ __key: "getById" }) },
|
||||
getDailyById: { queryOptions: () => ({ __key: "daily" }) },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
|
||||
@@ -42,6 +53,7 @@ beforeAll(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
queryResult.data = undefined;
|
||||
dailyResult.data = undefined;
|
||||
});
|
||||
|
||||
const renderStats = () =>
|
||||
@@ -84,6 +96,24 @@ describe("StatisticsSectionBuilder", () => {
|
||||
expect(screen.getByText("Downloads")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a prior-period delta from the daily series", () => {
|
||||
queryResult.data = {
|
||||
isPublic: true,
|
||||
views: 30,
|
||||
downloads: 0,
|
||||
lastViewedAt: null,
|
||||
lastDownloadedAt: null,
|
||||
};
|
||||
// 60 days: prior 30 sum to 10, recent 30 sum to 20 -> +100%.
|
||||
dailyResult.data = Array.from({ length: 60 }, (_, i) => ({
|
||||
date: `2024-01-${String(i + 1).padStart(2, "0")}`,
|
||||
views: i < 30 ? (i < 10 ? 1 : 0) : i < 50 ? 1 : 0,
|
||||
downloads: 0,
|
||||
}));
|
||||
renderStats();
|
||||
expect(screen.getByText(/\+100%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 'last viewed/downloaded' timestamps when present", () => {
|
||||
queryResult.data = {
|
||||
isPublic: true,
|
||||
|
||||
@@ -5,17 +5,53 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import { Accordion, AccordionContent, AccordionItem } from "@reactive-resume/ui/components/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
// Fetch 60 days so we can render a 30-day sparkline and compare it against the prior 30 days.
|
||||
const TREND_DAYS = 60;
|
||||
const WINDOW = 30;
|
||||
|
||||
// Percent change of the most recent `window` days vs the `window` days before it.
|
||||
// Returns null when the prior period had no activity (division by zero / no baseline).
|
||||
export function computeDelta(series: number[], window: number): number | null {
|
||||
const recent = series.slice(-window);
|
||||
const previous = series.slice(-window * 2, -window);
|
||||
const recentSum = recent.reduce((sum, n) => sum + n, 0);
|
||||
const previousSum = previous.reduce((sum, n) => sum + n, 0);
|
||||
if (previousSum === 0) return null;
|
||||
return Math.round(((recentSum - previousSum) / previousSum) * 100);
|
||||
}
|
||||
|
||||
// Polyline points for the sparkline, or null for degenerate inputs (fewer than two
|
||||
// points, or an all-zero series) where there is nothing meaningful to draw.
|
||||
export function getSparklinePoints(values: number[], width: number, height: number): string | null {
|
||||
if (values.length < 2 || values.every((n) => n === 0)) return null;
|
||||
const max = Math.max(...values, 1);
|
||||
const step = width / (values.length - 1);
|
||||
return values
|
||||
.map((value, index) => `${(index * step).toFixed(1)},${(height - (value / max) * height).toFixed(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function StatisticsSectionBuilder() {
|
||||
const params = useParams({ from: "/builder/$resumeId" });
|
||||
const { data: statistics } = useQuery(
|
||||
orpc.resume.statistics.getById.queryOptions({ input: { id: params.resumeId } }),
|
||||
);
|
||||
const { data: daily } = useQuery(
|
||||
orpc.resume.statistics.getDailyById.queryOptions({
|
||||
input: { id: params.resumeId, days: TREND_DAYS },
|
||||
enabled: Boolean(statistics?.isPublic),
|
||||
}),
|
||||
);
|
||||
|
||||
if (!statistics) return null;
|
||||
|
||||
const viewsSeries = daily?.map((day) => day.views) ?? [];
|
||||
const downloadsSeries = daily?.map((day) => day.downloads) ?? [];
|
||||
|
||||
return (
|
||||
<SectionBase type="statistics">
|
||||
<Accordion value={statistics.isPublic ? ["isPublic"] : ["isPrivate"]}>
|
||||
@@ -41,12 +77,14 @@ export function StatisticsSectionBuilder() {
|
||||
<StatisticsItem
|
||||
label={t`Views`}
|
||||
value={statistics.views}
|
||||
series={viewsSeries}
|
||||
timestamp={statistics.lastViewedAt ? t`Last viewed on ${statistics.lastViewedAt.toDateString()}` : null}
|
||||
/>
|
||||
|
||||
<StatisticsItem
|
||||
label={t`Downloads`}
|
||||
value={statistics.downloads}
|
||||
series={downloadsSeries}
|
||||
timestamp={
|
||||
statistics.lastDownloadedAt ? t`Last downloaded on ${statistics.lastDownloadedAt.toDateString()}` : null
|
||||
}
|
||||
@@ -61,15 +99,59 @@ export function StatisticsSectionBuilder() {
|
||||
type StatisticsItemProps = {
|
||||
label: string;
|
||||
value: number;
|
||||
series: number[];
|
||||
timestamp: string | null;
|
||||
};
|
||||
|
||||
function StatisticsItem({ label, value, timestamp }: StatisticsItemProps) {
|
||||
function StatisticsItem({ label, value, series, timestamp }: StatisticsItemProps) {
|
||||
const recent = series.slice(-WINDOW);
|
||||
const delta = computeDelta(series, WINDOW);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="mb-1 font-mono font-semibold text-4xl">{value}</h4>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<h4 className="font-mono font-semibold text-4xl">{value}</h4>
|
||||
<Sparkline title={t`${label} over the last 30 days`} values={recent} />
|
||||
</div>
|
||||
<p className="font-medium text-muted-foreground leading-none">{label}</p>
|
||||
{timestamp && <span className="text-muted-foreground text-xs">{timestamp}</span>}
|
||||
{delta === null ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<Trans>No prior data</Trans>
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn("text-xs", delta >= 0 ? "text-emerald-600 dark:text-emerald-500" : "text-red-600")}>
|
||||
{`${delta >= 0 ? "+" : ""}${delta}% `}
|
||||
<Trans>vs previous 30 days</Trans>
|
||||
</span>
|
||||
)}
|
||||
{timestamp && <span className="block text-muted-foreground text-xs">{timestamp}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SparklineProps = {
|
||||
title: string;
|
||||
values: number[];
|
||||
};
|
||||
|
||||
function Sparkline({ title, values }: SparklineProps) {
|
||||
const width = 80;
|
||||
const height = 24;
|
||||
const points = getSparklinePoints(values, width, height);
|
||||
|
||||
if (!points) return null;
|
||||
|
||||
return (
|
||||
<svg className="text-primary" height={height} role="img" viewBox={`0 0 ${width} ${height}`} width={width}>
|
||||
<title>{title}</title>
|
||||
<polyline
|
||||
fill="none"
|
||||
points={points}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { SwapIcon } from "@phosphor-icons/react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/ui/components/hover-card";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useCurrentResume } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
// Lazy so the browser PDF pipeline (pdf.js) loads only when a preview card actually opens, keeping it out
|
||||
// of the SSR/module graph — mirrors the `ResumePreview` entry convention.
|
||||
const TemplateLivePreview = lazy(() =>
|
||||
import("@/features/resume/preview/template-live-preview").then((module) => ({
|
||||
default: module.TemplateLivePreview,
|
||||
})),
|
||||
);
|
||||
|
||||
export function TemplateSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="template">
|
||||
@@ -29,19 +40,43 @@ function TemplateSectionForm() {
|
||||
|
||||
return (
|
||||
<div className="flex @md:flex-row flex-col items-stretch gap-x-4 gap-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onOpenTemplateGallery}
|
||||
className="group/preview relative h-auto w-40 shrink-0 cursor-pointer p-0"
|
||||
>
|
||||
<div className="relative z-10 aspect-page size-full overflow-hidden rounded-md opacity-100 transition-opacity group-hover/preview:opacity-50">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
|
||||
</div>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger
|
||||
delay={300}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onOpenTemplateGallery}
|
||||
className="group/preview relative h-auto w-40 shrink-0 cursor-pointer p-0"
|
||||
>
|
||||
<div className="relative z-10 aspect-page size-full overflow-hidden rounded-md opacity-100 transition-opacity group-hover/preview:opacity-50">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<SwapIcon size={48} weight="thin" className="size-12" />
|
||||
</div>
|
||||
</Button>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<SwapIcon size={48} weight="thin" className="size-12" />
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<HoverCardContent side="right" align="start" className="w-64 p-1.5">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="aspect-page w-full overflow-hidden rounded-md bg-white">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-contain" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TemplateLivePreview
|
||||
data={resume.data}
|
||||
template={template}
|
||||
fallbackSrc={metadata.imageUrl}
|
||||
alt={t`Live preview of your resume in the ${metadata.name} template`}
|
||||
/>
|
||||
</Suspense>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-y-4 @md:pt-1 @md:pb-3">
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RightSidebarSection } from "@/libs/resume/section";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { CaretDownIcon } from "@phosphor-icons/react";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
@@ -13,6 +14,7 @@ type Props = React.ComponentProps<typeof AccordionContent> & {
|
||||
export function SectionBase({ type, className, ...props }: Props) {
|
||||
const collapsed = useSectionStore((state) => state.sections[type]?.collapsed ?? false);
|
||||
const toggleCollapsed = useSectionStore((state) => state.toggleCollapsed);
|
||||
const sectionTitle = getSectionTitle(type);
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
@@ -26,7 +28,7 @@ export function SectionBase({ type, className, ...props }: Props) {
|
||||
<AccordionTrigger
|
||||
className="me-2 items-center justify-center"
|
||||
render={
|
||||
<Button size="icon" variant="ghost">
|
||||
<Button size="icon" variant="ghost" aria-label={t`Toggle ${sectionTitle} section`}>
|
||||
<CaretDownIcon className="transition-transform duration-200 group-data-closed/accordion-item:-rotate-90" />
|
||||
</Button>
|
||||
}
|
||||
@@ -34,7 +36,7 @@ export function SectionBase({ type, className, ...props }: Props) {
|
||||
|
||||
<div className="flex flex-1 items-center gap-x-4">
|
||||
{getSectionIcon(type)}
|
||||
<h2 className="line-clamp-1 font-semibold text-2xl tracking-tight">{getSectionTitle(type)}</h2>
|
||||
<h2 className="line-clamp-1 font-semibold text-2xl tracking-tight">{sectionTitle}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import type React from "react";
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Layout } from "react-resizable-panels";
|
||||
import type { BuilderLayout } from "./-store/sidebar";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { EyeIcon, NotePencilIcon, PaletteIcon } from "@phosphor-icons/react";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
|
||||
import Cookies from "js-cookie";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePanelRef } from "react-resizable-panels";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import {
|
||||
useBuilderResumeUpdateSubscription,
|
||||
useInitializeResumeStore,
|
||||
useMergeResumeMetadata,
|
||||
usePreviewPausedStore,
|
||||
useResumeCleanup,
|
||||
useResumeStore,
|
||||
} from "@/features/resume/builder/draft";
|
||||
@@ -89,12 +95,20 @@ function RouteComponent() {
|
||||
return <BuilderLayoutShell initialLayout={initialLayout} />;
|
||||
}
|
||||
|
||||
type BuilderLayoutShellProps = React.ComponentProps<"div"> & {
|
||||
type BuilderLayoutShellProps = {
|
||||
initialLayout: BuilderLayout;
|
||||
};
|
||||
|
||||
function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
// Single breakpoint (below `md`) switches between the desktop resizable panels and the mobile tabbed shell.
|
||||
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
|
||||
|
||||
if (isMobile) return <MobileBuilderShell />;
|
||||
return <DesktopBuilderShell initialLayout={initialLayout} />;
|
||||
}
|
||||
|
||||
function DesktopBuilderShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
// Only rendered when `BuilderLayoutShell` has already decided we're on desktop, so sidebar sizing is unconditional.
|
||||
const canPersistLayoutRef = useRef(false);
|
||||
|
||||
const leftSidebarRef = usePanelRef();
|
||||
@@ -130,14 +144,21 @@ function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
setRightSidebar(rightSidebarRef);
|
||||
}, [leftSidebarRef, rightSidebarRef, setLeftSidebar, setRightSidebar]);
|
||||
|
||||
const sidebarMinSize = isMobile ? "0%" : `${minSidebarSize}px`;
|
||||
const sidebarCollapsedSize = isMobile ? "0%" : `${collapsedSidebarSize}px`;
|
||||
const leftSidebarSize = isMobile ? "0%" : `${initialLayout.left}%`;
|
||||
const rightSidebarSize = isMobile ? "0%" : `${initialLayout.right}%`;
|
||||
const artboardSize = isMobile ? "100%" : `${initialLayout.artboard}%`;
|
||||
const sidebarMinSize = `${minSidebarSize}px`;
|
||||
const sidebarCollapsedSize = `${collapsedSidebarSize}px`;
|
||||
const leftSidebarSize = `${initialLayout.left}%`;
|
||||
const rightSidebarSize = `${initialLayout.right}%`;
|
||||
const artboardSize = `${initialLayout.artboard}%`;
|
||||
|
||||
return (
|
||||
<div className="flex h-svh flex-col">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only rounded-md bg-popover px-4 py-2 text-sm ring-2 ring-ring focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
|
||||
>
|
||||
<Trans>Skip to main content</Trans>
|
||||
</a>
|
||||
|
||||
<BuilderHeader />
|
||||
|
||||
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChanged={onLayoutChanged}>
|
||||
@@ -156,7 +177,9 @@ function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle className="z-50 border-s" />
|
||||
<ResizablePanel id="artboard" defaultSize={artboardSize} className="h-[calc(100svh-3.5rem)]">
|
||||
<Outlet />
|
||||
<main id="main-content" className="h-full">
|
||||
<Outlet />
|
||||
</main>
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle className="z-50 border-e" />
|
||||
<ResizablePanel
|
||||
@@ -177,6 +200,104 @@ function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type MobileBuilderTab = "edit" | "preview" | "design";
|
||||
|
||||
function MobileBuilderShell() {
|
||||
// Local state is enough — mobile view mode is shell-scoped and doesn't need to persist.
|
||||
const [tab, setTab] = useState<MobileBuilderTab>("edit");
|
||||
const setPreviewPaused = usePreviewPausedStore((state) => state.setPaused);
|
||||
|
||||
// The preview stays mounted under the Edit/Design overlay; pause its render while it's covered.
|
||||
useEffect(() => {
|
||||
setPreviewPaused(tab !== "preview");
|
||||
return () => setPreviewPaused(false);
|
||||
}, [tab, setPreviewPaused]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only rounded-md bg-popover px-4 py-2 text-sm ring-2 ring-ring focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
|
||||
>
|
||||
<Trans>Skip to main content</Trans>
|
||||
</a>
|
||||
|
||||
<BuilderHeader />
|
||||
|
||||
{/* The preview (fixed inset-0) stays mounted so zoom/pan state survives tab switches. */}
|
||||
<main id="main-content" className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{tab === "edit" && (
|
||||
<MobileSidebarPanel>
|
||||
<BuilderSidebarLeft />
|
||||
</MobileSidebarPanel>
|
||||
)}
|
||||
{tab === "design" && (
|
||||
<MobileSidebarPanel>
|
||||
<BuilderSidebarRight />
|
||||
</MobileSidebarPanel>
|
||||
)}
|
||||
|
||||
<MobileBuilderTabBar activeTab={tab} onTabChange={setTab} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MobileSidebarPanelProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function MobileSidebarPanel({ children }: MobileSidebarPanelProps) {
|
||||
// Sits below the header (top-14) and above the tab bar (bottom-16). The sidebar's ScrollArea hardcodes
|
||||
// `h-[calc(100svh-3.5rem)]`; override it to fill this panel so its last item isn't hidden under the tab bar.
|
||||
return (
|
||||
<div className="fixed inset-x-0 top-14 bottom-16 z-40 bg-background [&_[data-slot=scroll-area]]:h-full">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MOBILE_BUILDER_TABS = [
|
||||
{ value: "edit", icon: NotePencilIcon },
|
||||
{ value: "preview", icon: EyeIcon },
|
||||
{ value: "design", icon: PaletteIcon },
|
||||
] as const satisfies readonly { value: MobileBuilderTab; icon: Icon }[];
|
||||
|
||||
type MobileBuilderTabBarProps = {
|
||||
activeTab: MobileBuilderTab;
|
||||
onTabChange: (tab: MobileBuilderTab) => void;
|
||||
};
|
||||
|
||||
function MobileBuilderTabBar({ activeTab, onTabChange }: MobileBuilderTabBarProps) {
|
||||
const labels: Record<MobileBuilderTab, string> = { edit: t`Edit`, preview: t`Preview`, design: t`Design` };
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-50 flex border-t bg-popover pb-[env(safe-area-inset-bottom)]">
|
||||
{MOBILE_BUILDER_TABS.map(({ value, icon: Icon }) => {
|
||||
const isActive = value === activeTab;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
onClick={() => onTabChange(value)}
|
||||
className={cn(
|
||||
"flex min-h-16 flex-1 flex-col items-center justify-center gap-1 text-xs transition-colors",
|
||||
isActive ? "text-primary" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" weight={isActive ? "fill" : "regular"} />
|
||||
<span>{labels[value]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const setBuilderLayout = (data: BuilderLayout) => {
|
||||
const layout = parseBuilderLayoutCookie(JSON.stringify(data));
|
||||
Cookies.set(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(layout), { path: "/" });
|
||||
|
||||
Reference in New Issue
Block a user