feat(web): replace the builder with the Desk & Paper editor shell

One editor at /builder/$resumeId: a bar with the document menu and save
status, the Write, Design and Check modes (in ?mode=), undo, history,
assistant, Share and the Download PDF split button, over the panel and the
page canvas. Tablets get a drawer that can be pinned in landscape; phones
get Write, Page, Design and Check tabs.

The canvas renders pages at the zoom level (60-150%, Fit), outlines the
selected entry, links page clicks to the panel and panel focus to the page,
and offers Edit entry on phones. Share & export hosts sharing, statistics and
every download format. Offline, the panel explains the state and Share and
Download wait for a connection.

Until their milestones land, the modes host today's section editors, design
sections and ATS check. The resizable-panel shells, rails, dock, header,
mobile shell and react-zoom-pan-pinch are removed.
This commit is contained in:
Amruth Pillai
2026-09-28 17:18:04 +02:00
parent 639abf12b6
commit f2a76b2f69
47 changed files with 1544 additions and 1562 deletions
-1
View File
@@ -87,7 +87,6 @@
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.14.1",
"react-window": "^2.3.3",
"react-zoom-pan-pinch": "^4.2.0",
"remark-gfm": "^4.0.1",
"ts-pattern": "^5.9.0",
"usehooks-ts": "^3.1.1",
@@ -0,0 +1,82 @@
import type { PageMap, PageMapNode } from "@reactive-resume/pdf/page-map";
import type { MouseEvent } from "react";
import type { EditorSelection } from "./store";
import { Trans } from "@lingui/react/macro";
import { cn } from "@reactive-resume/utils/style";
import { isSameSelection, useEditorStore } from "./store";
type PageOverlayProps = {
pageIndex: number;
pageMap: PageMap | undefined;
onSelect: (selection: EditorSelection) => void;
};
const toSelection = (node: PageMapNode): EditorSelection => {
if (node.kind === "item") return { kind: "item", sectionId: node.sectionId, itemId: node.itemId };
if (node.kind === "section") return { kind: "section", sectionId: node.sectionId };
return { kind: "header" };
};
const readSelection = (element: HTMLElement): EditorSelection | null => {
const { kind, sectionId, itemId } = element.dataset;
if (kind === "header") return { kind: "header" };
if (kind === "section" && sectionId) return { kind: "section", sectionId };
if (kind === "item" && sectionId && itemId) return { kind: "item", sectionId, itemId };
return null;
};
/**
* Pointer layer over one rendered page: hovering a block tints it, clicking selects its entry, and the
* selected block gets an accent outline with an "Editing" tag.
*/
export function PageOverlay({ pageIndex, pageMap, onSelect }: PageOverlayProps) {
const selection = useEditorStore((state) => state.selection);
const page = pageMap?.pages[pageIndex];
if (!pageMap || !page || page.width <= 0 || page.height <= 0) return null;
const nodes = pageMap.nodes.filter((node) => node.page === pageIndex);
// A section with entries on this page is selected through its entries; only entry-less sections
// (the summary, for example) are blocks of their own.
const sectionsWithItems = new Set(nodes.flatMap((node) => (node.kind === "item" ? [node.sectionId] : [])));
const blocks = nodes.filter((node) => node.kind !== "section" || !sectionsWithItems.has(node.sectionId));
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
const target = (event.target as HTMLElement).closest<HTMLElement>("[data-kind]");
const next = target ? readSelection(target) : null;
if (next) onSelect(next);
};
return (
// A pointer shortcut, hidden from assistive tech: the panel selects the same entries by keyboard.
<div aria-hidden="true" data-slot="page-overlay" className="absolute inset-0" onClick={handleClick}>
{blocks.map((node) => {
const selected = isSameSelection(selection, toSelection(node));
return (
<div
key={`${node.key}:${node.y}`}
data-kind={node.kind}
data-section-id={node.kind === "header" ? undefined : node.sectionId}
data-item-id={node.kind === "item" ? node.itemId : undefined}
className={cn(
"absolute cursor-pointer rounded-[4px] transition-[background-color,outline-color] duration-quick hover:bg-[oklch(0.5_0.1_150/0.06)]",
selected && "outline-[1.5px] outline-accent outline-solid",
)}
style={{
left: `calc(${(node.x / page.width) * 100}% - 4px)`,
top: `calc(${(node.y / page.height) * 100}% - 3px)`,
width: `calc(${(node.width / page.width) * 100}% + 8px)`,
height: `calc(${(node.height / page.height) * 100}% + 6px)`,
}}
>
{selected && (
<span className="absolute start-1 -top-2 rounded-[3px] bg-accent px-1 font-semibold text-[8px] text-on-accent leading-3">
<Trans>Editing</Trans>
</span>
)}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,67 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { useResumeStore } from "@/features/resume/builder/draft";
/** The save state under the document name: Saved, Saving…, Offline · saved on this device, or Not saved · Retry. */
export function SaveStatus() {
const status = useResumeStore((state) => state.saveStatus);
const retrySave = useResumeStore((state) => state.retrySave);
return (
<span role="status" aria-live="polite" className="flex min-w-0 items-center gap-1.5 text-ink-3 text-xs leading-4">
{status === "saving" && (
<>
<Spinner decorative className="size-3 border-[1.5px]" />
<Trans>Saving…</Trans>
</>
)}
{(status === "saved" || status === "idle") && (
<>
<Icon name="cloud_done" size={16} />
<Trans>Saved</Trans>
</>
)}
{status === "offline" && (
<span className="flex min-w-0 items-center gap-1.5 text-warn-text">
<Icon name="cloud_off" size={16} />
<span className="truncate">
<Trans>Offline · saved on this device</Trans>
</span>
</span>
)}
{status === "error" && (
<span className="flex min-w-0 items-center gap-1.5 text-danger-text">
<Icon name="sync_problem" size={16} />
<Trans>Not saved</Trans>
<span aria-hidden="true">·</span>
<button
type="button"
onClick={retrySave}
aria-label={t`Retry saving`}
className="rounded-sm font-semibold underline underline-offset-2 hover:opacity-80"
>
<Trans>Retry</Trans>
</button>
</span>
)}
</span>
);
}
/** Shown at the top of the panel while offline: editing carries on, Download and Share wait for a connection. */
export function OfflineBanner({ className }: { className?: string }) {
const offline = useResumeStore((state) => state.saveStatus === "offline");
if (!offline) return null;
return (
<Alert variant="warn" role="status" className={className}>
<Icon name="wifi_off" size={20} />
<AlertDescription>
<Trans>You can keep editing. Changes sync when you're back online. Download and Share need a connection.</Trans>
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,69 @@
import type { PageMapTarget } from "@reactive-resume/pdf/page-map";
import { create } from "zustand/react";
export const EDITOR_MODES = ["write", "design", "check"] as const;
export type EditorMode = (typeof EDITOR_MODES)[number];
/** What's selected in the editor: shared by the panel and the page, so each can outline the other. */
export type EditorSelection = PageMapTarget;
export const ZOOM_MIN = 0.6;
export const ZOOM_MAX = 1.5;
export const ZOOM_STEP = 0.1;
type EditorStore = {
/** The mode just picked, shown while `?mode=` catches up (every navigation refetches the session first). */
pendingMode: EditorMode | null;
selection: EditorSelection | null;
/** "fit" fits the page width to the canvas; otherwise an explicit scale between 60% and 150%. */
zoom: number | "fit";
/** Tablet only: the panel is a drawer over the page. */
drawerOpen: boolean;
/** Tablet in landscape: the panel sits beside the page instead of over it. */
drawerPinned: boolean;
shareOpen: boolean;
downloadOpen: boolean;
assistantOpen: boolean;
select: (selection: EditorSelection | null) => void;
setZoom: (zoom: number | "fit") => void;
setDrawerOpen: (open: boolean) => void;
setDrawerPinned: (pinned: boolean) => void;
setShareOpen: (open: boolean) => void;
setDownloadOpen: (open: boolean) => void;
setAssistantOpen: (open: boolean) => void;
reset: () => void;
};
const clampZoom = (zoom: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(zoom * 100) / 100));
export const isSameSelection = (a: EditorSelection | null, b: EditorSelection | null) => {
if (!a || !b) return a === b;
if (a.kind !== b.kind) return false;
if (a.kind === "header") return true;
if (a.kind === "section" && b.kind === "section") return a.sectionId === b.sectionId;
if (a.kind === "item" && b.kind === "item") return a.sectionId === b.sectionId && a.itemId === b.itemId;
return false;
};
const initialState = {
pendingMode: null,
selection: null,
zoom: "fit",
drawerOpen: false,
drawerPinned: false,
shareOpen: false,
downloadOpen: false,
assistantOpen: false,
} as const;
export const useEditorStore = create<EditorStore>()((set) => ({
...initialState,
select: (selection) => set({ selection }),
setZoom: (zoom) => set({ zoom: zoom === "fit" ? "fit" : clampZoom(zoom) }),
setDrawerOpen: (drawerOpen) => set({ drawerOpen }),
setDrawerPinned: (drawerPinned) => set({ drawerPinned }),
setShareOpen: (shareOpen) => set({ shareOpen }),
setDownloadOpen: (downloadOpen) => set({ downloadOpen }),
setAssistantOpen: (assistantOpen) => set({ assistantOpen }),
reset: () => set(initialState),
}));
@@ -30,7 +30,11 @@ type DownloadableResume = Parameters<typeof useResumeExport>[0];
type ResumeDownloadDialogProps = {
resume: DownloadableResume;
trigger: (disabled: boolean) => ReactElement;
/** Omitted when something else opens the dialog through `open`. */
trigger?: (disabled: boolean) => ReactElement;
/** Controlled open state, e.g. for the ⌘⇧E shortcut. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
type FormatRowProps = {
@@ -61,8 +65,10 @@ function FormatRow({ action, description, disabled, icon, title }: FormatRowProp
);
}
export function ResumeDownloadDialog({ resume, trigger }: ResumeDownloadDialogProps) {
const [open, setOpen] = useState(false);
export function ResumeDownloadDialog({ resume, trigger, open: openProp, onOpenChange }: ResumeDownloadDialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const open = openProp ?? uncontrolledOpen;
const setOpen = onOpenChange ?? setUncontrolledOpen;
const [scope, setScope] = useState<ResumeExportTarget>("resume");
const [includeCoverLetterHeader, setIncludeCoverLetterHeader] = useState(false);
const includeHeaderSwitchId = useId();
@@ -81,7 +87,7 @@ export function ResumeDownloadDialog({ resume, trigger }: ResumeDownloadDialogPr
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={trigger(disabled)} />
{trigger && <DialogTrigger render={trigger(disabled)} />}
<DialogContent className="gap-5 sm:max-w-lg">
<DialogHeader className="pe-8">
<DialogTitle>
@@ -1,3 +1,4 @@
import type { PageMap } from "@reactive-resume/pdf/page-map";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { useMemo } from "react";
@@ -9,6 +10,11 @@ type ResumePdfRenderOptions = {
includeCoverLetterHeader?: boolean;
};
type CreateResumePdfBlobExtras = {
/** Receives the page map (header, section and item boxes) of this render; used by the editor canvas. */
onPageMap?: (pageMap: PageMap) => void;
};
export const useLocalizedResumeDocument = (data?: ResumeData, template?: Template) => {
const sectionTitleResolver = useSectionTitleResolver(data?.metadata.page.locale);
@@ -29,6 +35,7 @@ export const createResumePdfBlob = async (
data: ResumeData,
template?: Template,
renderOptions?: ResumePdfRenderOptions,
{ onPageMap }: CreateResumePdfBlobExtras = {},
) => {
const sectionTitleResolver = await createSectionTitleResolverForLocale(data.metadata.page.locale);
@@ -37,5 +44,6 @@ export const createResumePdfBlob = async (
template,
...(renderOptions ? { renderOptions } : {}),
resolveSectionTitle: sectionTitleResolver,
onPageMap,
});
};
@@ -20,7 +20,9 @@ type PdfCanvasDocumentProps = {
};
type PdfCanvasPageProps = {
caption?: ReactNode;
className?: string;
overlay?: ReactNode;
document: PDFDocumentProxy;
onLoadSuccess: (pageNumber: number, pageSize: PreviewPageSize) => void;
onRenderSuccess?: () => void;
@@ -84,7 +86,9 @@ export function PdfCanvasDocument({ children, file, onLoadSuccess }: PdfCanvasDo
}
export function PdfCanvasPage({
caption,
className,
overlay,
document,
onLoadSuccess,
onRenderSuccess,
@@ -179,14 +183,16 @@ export function PdfCanvasPage({
return (
<figure className="shrink-0">
{showPageNumbers ? (
<figcaption className="mb-1 font-medium text-[0.625rem] text-muted-foreground">
Page {pageNumber} of {totalPages}
</figcaption>
) : null}
{caption ??
(showPageNumbers ? (
<figcaption className="mb-1 font-medium text-[0.625rem] text-muted-foreground">
Page {pageNumber} of {totalPages}
</figcaption>
) : null)}
<div style={scaledPageSize} className={cn("aspect-page overflow-hidden rounded-md", className)}>
<div style={scaledPageSize} className={cn("relative aspect-page overflow-hidden rounded-md", className)}>
<canvas ref={canvasRef} aria-label={`Resume page ${pageNumber} of ${totalPages}`} />
{overlay}
</div>
</figure>
);
@@ -108,7 +108,12 @@ describe("ResumePreviewClient", () => {
expect(previewMock.toBlob).toHaveBeenCalledTimes(1);
});
expect(previewMock.toBlob).toHaveBeenCalledWith(sampleResumeData);
expect(previewMock.toBlob).toHaveBeenCalledWith(
sampleResumeData,
undefined,
undefined,
expect.objectContaining({ onPageMap: expect.any(Function) }),
);
});
it("keeps the rendered template identity on the active layer while its replacement renders", async () => {
@@ -145,7 +150,12 @@ describe("ResumePreviewClient", () => {
render(<ResumePreviewClient pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
await waitFor(() => expect(previewMock.toBlob).toHaveBeenCalledTimes(1));
expect(previewMock.toBlob).toHaveBeenCalledWith(previewMock.builderResumeData);
expect(previewMock.toBlob).toHaveBeenCalledWith(
previewMock.builderResumeData,
undefined,
undefined,
expect.objectContaining({ onPageMap: expect.any(Function) }),
);
expect(previewMock.toBlob).toHaveBeenCalledTimes(1);
});
@@ -1,3 +1,4 @@
import type { PageMap } from "@reactive-resume/pdf/page-map";
import type { Template } from "@reactive-resume/schema/templates";
import type { CSSProperties } from "react";
import type { ResolvedResumePreviewProps } from "./preview.shared";
@@ -19,6 +20,7 @@ import { ResumeAccessibleText } from "./resume-accessible-text";
type PreviewPdf = {
file: Blob;
id: number;
pageMap: PageMap | undefined;
numPages: number;
pageSizes: Record<number, PreviewPageSize>;
phase: "active" | "exiting" | "staged";
@@ -32,9 +34,16 @@ const UPDATE_DEBOUNCE_MS = 100;
const INCOMING_TRANSITION = { duration: 0.15, ease: EASE_OUT_STRONG };
const EXITING_TRANSITION = { duration: 0.1, delay: 0.18 };
const createPreviewPdf = (file: Blob, id: number, hasExistingPreview: boolean, template: Template): PreviewPdf => ({
const createPreviewPdf = (
file: Blob,
id: number,
hasExistingPreview: boolean,
template: Template,
pageMap: PageMap | undefined,
): PreviewPdf => ({
file,
id,
pageMap,
numPages: 0,
pageSizes: {},
phase: hasExistingPreview ? "staged" : "active",
@@ -102,6 +111,9 @@ export function ResumePreviewClient({
pageScale,
pageClassName,
showPageNumbers,
renderPageCaption,
renderPageOverlay,
onPageCount,
}: ResolvedResumePreviewProps) {
const builderResumeData = useResumeData();
const resumeData = data ?? builderResumeData;
@@ -125,7 +137,12 @@ export function ResumePreviewClient({
const generatePdfPreview = async () => {
try {
if (cancelled || requestId !== requestIdRef.current) return;
const blob = await createResumePdfBlob(resumeData);
let pageMap: PageMap | undefined;
const blob = await createResumePdfBlob(resumeData, undefined, undefined, {
onPageMap: (map) => {
pageMap = map;
},
});
if (!cancelled && requestId === requestIdRef.current) {
const nextPdf = createPreviewPdf(
@@ -133,6 +150,7 @@ export function ResumePreviewClient({
pdfIdRef.current++,
hasPreviewRef.current,
resumeData.metadata.template,
pageMap,
);
hasPreviewRef.current = true;
@@ -158,6 +176,11 @@ export function ResumePreviewClient({
};
}, [paused, resumeData]);
const activePageCount = getActivePreviewLayer(previewLayers)?.numPages ?? 0;
useEffect(() => {
if (activePageCount > 0) onPageCount?.(activePageCount);
}, [activePageCount, onPageCount]);
if (!resumeData) return null;
const visiblePdf = getActivePreviewLayer(previewLayers);
@@ -228,6 +251,12 @@ export function ResumePreviewClient({
totalPages={totalPages}
className={pageClassName}
showPageNumbers={showPageNumbers}
caption={renderPageCaption?.({ pageNumber, totalPages })}
overlay={
visiblePdf.phase === "active"
? renderPageOverlay?.({ pageIndex: index, pageMap: visiblePdf.pageMap })
: undefined
}
onLoadSuccess={(_, pageSize) => {
setPreviewLayers((current) =>
setPreviewPageSize(current, visiblePdf.id, pageNumber, pageSize),
@@ -1,5 +1,6 @@
import type { PageMap } from "@reactive-resume/pdf/page-map";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { CSSProperties } from "react";
import type { CSSProperties, ReactNode } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { DEFAULT_PDF_PAGE_SIZE, getResumePreviewGapValue, getScaledPreviewPageSize } from "./preview.shared.utils";
@@ -12,6 +13,12 @@ export type ResumePreviewProps = {
pageScale?: number;
pageClassName?: string;
showPageNumbers?: boolean;
/** Drawn above each page (e.g. "Page 1 · Letter"). Replaces the small page-number caption. */
renderPageCaption?: (page: { pageNumber: number; totalPages: number }) => ReactNode;
/** Drawn over each page, in page-relative coordinates; receives the page map of the render on screen. */
renderPageOverlay?: (page: { pageIndex: number; pageMap: PageMap | undefined }) => ReactNode;
/** Called with the number of physical pages whenever the render on screen changes. */
onPageCount?: (pageCount: number) => void;
};
export type ResolvedResumePreviewProps = ResumePreviewProps & {
@@ -30,14 +30,11 @@ import {
import { isFatalStylesheetDiagnostic } from "@reactive-resume/resume/stylesheet";
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Sheet, SheetContent, SheetTitle } from "@reactive-resume/ui/components/sheet";
import { useIsMobile } from "@reactive-resume/ui/hooks/use-mobile";
import { ColorPicker } from "@/components/input/color-picker";
import { useIsResumeLocked, useResumeData, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useTheme } from "@/features/theme/provider";
import { useBuilderSidebarStore } from "@/routes/builder/$resumeId/-store/sidebar";
import { serializeStylesheetColor, toStylesheetPickerColor } from "./color-format";
import { compositionAwareDocumentListener, createSemanticCssEditorExtensions } from "./editor-extensions";
import { enterStylesheetFocusMode } from "./focus-mode";
import { formatEditorDocument } from "./formatter";
import { LegacyStylesheetBanner } from "./legacy-banner";
import { StylesheetStatus } from "./status";
@@ -383,13 +380,11 @@ const createEditorMetadata = (data: ResumeData): SemanticCssEditorMetadata => {
function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps) {
const { resolvedTheme: theme } = useTheme();
const isMobile = useIsMobile();
const [focusOpen, setFocusOpen] = useState(false);
const [diagnostics, setDiagnostics] = useState<readonly SemanticCssDiagnostic[]>([]);
const [colorTokens, setColorTokens] = useState<readonly SemanticCssColorToken[]>([]);
const [status, setStatus] = useState<"idle" | "compiling" | "error">("compiling");
const [compiler, setCompiler] = useState<ReturnType<typeof createCompileWorkerClient>>();
const restoreDesktopRef = useRef<(() => void) | null>(null);
const data = useResumeData();
const updateResumeData = useUpdateResumeData();
const isLocked = useIsResumeLocked();
@@ -423,13 +418,6 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
const isChecking = status === "compiling";
const disabled = readOnly || isLocked;
useEffect(
() => () => {
restoreDesktopRef.current?.();
},
[],
);
useEffect(() => {
if (!compiler || !data) return;
let cancelled = false;
@@ -485,27 +473,8 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
});
};
const toggleFocus = () => {
if (isMobile) {
setFocusOpen((open) => !open);
return;
}
if (restoreDesktopRef.current) {
restoreDesktopRef.current();
restoreDesktopRef.current = null;
setFocusOpen(false);
return;
}
const { rightSidebar, layout, setLayout } = useBuilderSidebarStore.getState();
restoreDesktopRef.current = enterStylesheetFocusMode({
rightPanel: rightSidebar,
currentLayout: layout,
setLayout,
});
setFocusOpen(true);
};
// Focus mode opens the editor in a large dialog; the editor panel has a fixed width.
const toggleFocus = () => setFocusOpen((open) => !open);
const editor = (
<StylesheetCodeEditor
@@ -564,7 +533,7 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
</span>
</p>
<div className={focusOpen ? (isMobile ? "h-[55svh]" : "h-[calc(100svh-14rem)]") : "h-72"}>{editor}</div>
<div className={focusOpen ? "h-[55svh] sm:h-[calc(100svh-14rem)]" : "h-72"}>{editor}</div>
<StylesheetStatus mode={mode} status={status} diagnostics={diagnostics} />
</div>
@@ -572,13 +541,13 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
return (
<div>
{!(isMobile && focusOpen) && editorChrome}
<Sheet open={isMobile && focusOpen} onOpenChange={setFocusOpen}>
{!focusOpen && editorChrome}
<Sheet open={focusOpen} onOpenChange={setFocusOpen}>
<SheetContent side="right" className="w-full max-w-full gap-3 overflow-hidden p-4 sm:max-w-full">
<SheetTitle>
<Trans>Semantic CSS stylesheet</Trans>
</SheetTitle>
<div className="min-h-0 flex-1 overflow-y-auto">{isMobile && focusOpen ? editorChrome : null}</div>
<div className="min-h-0 flex-1 overflow-y-auto">{focusOpen ? editorChrome : null}</div>
</SheetContent>
</Sheet>
</div>
@@ -1,18 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { enterStylesheetFocusMode } from "./focus-mode";
describe("stylesheet focus mode", () => {
it("resizes and restores the desktop right panel", () => {
const currentLayout = { left: 22, artboard: 56, right: 22 };
const resize = vi.fn();
const setLayout = vi.fn();
const rightPanel = { current: { resize } };
const restore = enterStylesheetFocusMode({ rightPanel, currentLayout, setLayout });
expect(resize).toHaveBeenCalledWith("45%");
restore();
expect(resize).toHaveBeenLastCalledWith("22%");
expect(setLayout).toHaveBeenCalledWith(currentLayout);
});
});
@@ -1,24 +0,0 @@
import type { BuilderLayout } from "@/routes/builder/$resumeId/-store/sidebar";
type FocusPanel = {
current: { resize(size: string): void } | null;
};
export type StylesheetFocusModeInput = {
rightPanel: FocusPanel | null;
currentLayout: BuilderLayout;
setLayout(layout: BuilderLayout): void;
};
export function enterStylesheetFocusMode({
rightPanel,
currentLayout,
setLayout,
}: StylesheetFocusModeInput): () => void {
rightPanel?.current?.resize("45%");
return () => {
rightPanel?.current?.resize(`${currentLayout.right}%`);
setLayout(currentLayout);
};
}
@@ -14,9 +14,12 @@ import {
EmptyMedia,
EmptyTitle,
} from "@reactive-resume/ui/components/empty";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { Sheet, SheetContent, SheetTitle } from "@reactive-resume/ui/components/sheet";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { useEditorStore } from "@/features/resume/editor/store";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
@@ -140,19 +143,19 @@ function AiAssistantPanel({ resumeId, onClose }: BuilderAiAssistantProps & { onC
}
export function BuilderAiAssistant({ resumeId }: BuilderAiAssistantProps) {
const [open, setOpen] = useState(false);
const open = useEditorStore((state) => state.assistantOpen);
const setOpen = useEditorStore((state) => state.setAssistantOpen);
return (
<Sheet open={open} onOpenChange={setOpen}>
<Button
size="icon"
variant="ghost"
aria-label={t`Open AI assistant`}
<IconButton
icon="auto_awesome"
label={t`Assistant`}
shortcut="⌘J"
aria-pressed={open}
onClick={() => setOpen(true)}
>
<SparkleIcon weight={open ? "fill" : "regular"} />
</Button>
className={cn("text-ink-2", open && "bg-accent-soft text-accent-text hover:bg-accent-soft")}
onClick={() => setOpen(!open)}
/>
<SheetContent
side="right"
@@ -1,108 +0,0 @@
import type { Layout } from "react-resizable-panels";
import type { BuilderLayout } from "../-store/sidebar";
import { Trans } from "@lingui/react/macro";
import { Outlet } from "@tanstack/react-router";
import { useEffect, useRef } from "react";
import { usePanelRef } from "react-resizable-panels";
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
import { BuilderSidebarLeft } from "../-sidebar/left";
import { BuilderSidebarRight } from "../-sidebar/right";
import {
mapPanelLayoutToBuilderLayout,
setBuilderLayout,
useBuilderSidebar,
useBuilderSidebarStore,
} from "../-store/sidebar";
import { BuilderHeader } from "./header";
export type BuilderLayoutShellProps = {
initialLayout: BuilderLayout;
};
export 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();
const rightSidebarRef = usePanelRef();
const setLeftSidebar = useBuilderSidebarStore((state) => state.setLeftSidebar);
const setRightSidebar = useBuilderSidebarStore((state) => state.setRightSidebar);
const setLayout = useBuilderSidebarStore((state) => state.setLayout);
const { maxSidebarSize, minSidebarSize, collapsedSidebarSize, groupResizeBehavior } = useBuilderSidebar();
useEffect(() => {
setLayout(initialLayout);
canPersistLayoutRef.current = true;
}, [initialLayout, setLayout]);
const onLayoutChanged = (layout: Layout) => {
const nextLayout = mapPanelLayoutToBuilderLayout(layout);
if (!canPersistLayoutRef.current) return;
setLayout(nextLayout);
setBuilderLayout(nextLayout);
};
useEffect(() => {
if (!leftSidebarRef || !rightSidebarRef) return;
setLeftSidebar(leftSidebarRef);
setRightSidebar(rightSidebarRef);
}, [leftSidebarRef, rightSidebarRef, setLeftSidebar, setRightSidebar]);
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}>
<ResizablePanel
collapsible
id="left"
panelRef={leftSidebarRef}
groupResizeBehavior={groupResizeBehavior}
maxSize={maxSidebarSize}
minSize={sidebarMinSize}
collapsedSize={sidebarCollapsedSize}
defaultSize={leftSidebarSize}
className="z-20 h-[calc(100svh-3.5rem)]"
>
<BuilderSidebarLeft />
</ResizablePanel>
<ResizableSeparator withHandle className="z-50 border-s" />
<ResizablePanel id="artboard" defaultSize={artboardSize} className="h-[calc(100svh-3.5rem)]">
<main id="main-content" className="h-full">
<Outlet />
</main>
</ResizablePanel>
<ResizableSeparator withHandle className="z-50 border-e" />
<ResizablePanel
collapsible
id="right"
panelRef={rightSidebarRef}
groupResizeBehavior={groupResizeBehavior}
maxSize={maxSidebarSize}
minSize={sidebarMinSize}
collapsedSize={sidebarCollapsedSize}
defaultSize={rightSidebarSize}
className="z-20 h-[calc(100svh-3.5rem)]"
>
<BuilderSidebarRight />
</ResizablePanel>
</ResizableGroup>
</div>
);
}
@@ -1,182 +0,0 @@
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,
LinkSimpleIcon,
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
} from "@phosphor-icons/react";
import { useHotkey } from "@tanstack/react-hotkeys";
import { useNavigate } from "@tanstack/react-router";
import { m, useReducedMotion } from "motion/react";
import { useControls, useTransformComponent } from "react-zoom-pan-pinch";
import { useCopyToClipboard } from "usehooks-ts";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { toast } from "@reactive-resume/ui/components/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import {
isEditableElementFocused,
useCurrentBuilderResumeSelector,
useResumeStore,
} from "@/features/resume/builder/draft";
import { authClient } from "@/libs/auth/client";
import { EASE_OUT_STRONG } from "@/libs/motion";
type BuilderDockProps = {
pageLayout: BuilderPreviewPageLayout;
onTogglePageLayout: () => void;
};
export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps) {
const { data: session } = authClient.useSession();
// Narrow slices: selecting the whole resume re-renders the dock on every keystroke.
const resumeSlug = useCurrentBuilderResumeSelector((resume) => resume.slug);
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
const navigate = useNavigate();
const [_, copyToClipboard] = useCopyToClipboard();
const { zoomIn, zoomOut, resetTransform } = useControls();
const zoomDuration = useReducedMotion() ? 0 : 150;
const canUndo = useResumeStore((state) => state.canUndo);
const canRedo = useResumeStore((state) => state.canRedo);
const undo = useResumeStore((state) => state.undo);
const redo = useResumeStore((state) => state.redo);
// Keyboard actions snap instantly; animating a shortcut makes it feel sluggish.
useHotkey("Mod+0", () => resetTransform(0));
// 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 =
session?.user.username && resumeSlug ? `${window.location.origin}/${session.user.username}/${resumeSlug}` : "";
return (
<div className="fixed inset-x-0 bottom-20 flex items-center justify-center md:bottom-4">
<m.div
initial={{ opacity: 0, transform: "translateY(8px)" }}
animate={{ opacity: 1, transform: "translateY(0px)" }}
transition={{ duration: 0.2, ease: EASE_OUT_STRONG }}
className="flex items-center rounded-r-full rounded-l-full bg-popover px-2 shadow-xl"
>
<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, zoomDuration, "easeOutCubic")}
/>
<ZoomMenu />
<DockIcon
icon={MagnifyingGlassPlusIcon}
title={t`Zoom in`}
onClick={() => zoomIn(0.15, zoomDuration, "easeOutCubic")}
/>
<DockIcon
icon={pageLayout === "horizontal" ? AlignTopIcon : AlignCenterHorizontalIcon}
title={t`Toggle page stacking`}
onClick={onTogglePageLayout}
/>
<DockIcon
icon={ChatCircleDotsIcon}
title={t`Open AI agent`}
onClick={() => {
if (!resumeId) return;
void navigate({ to: "/agent/new", search: { resumeId } });
}}
/>
<div className="mx-1 h-8 w-px bg-border" />
<DockIcon
icon={LinkSimpleIcon}
title={t`Copy URL`}
onClick={async () => {
await copyToClipboard(publicUrl);
toast.add({ type: "success", description: t`Resume link copied to clipboard.` });
}}
/>
</m.div>
</div>
);
}
function ZoomMenu() {
const scale = useTransformComponent((ctx) => ctx.state.scale);
const { centerView, resetTransform } = useControls();
const zoomDuration = useReducedMotion() ? 0 : 200;
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, zoomDuration, "easeOutCubic")}>
<Trans>Actual size (100%)</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => resetTransform(zoomDuration, "easeOutCubic")}>
<Trans>Fit to view</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
type DockIconProps = {
title: string;
icon: Icon;
disabled?: boolean;
onClick: () => void;
};
function DockIcon({ icon: Icon, title, disabled, onClick }: DockIconProps) {
return (
<Tooltip>
{/* A disabled button ignores the pointer, so a wrapper keeps the tooltip working for Undo/Redo. */}
<TooltipTrigger render={<span className="inline-flex" />}>
<Button size="icon" variant="ghost" disabled={disabled} onClick={onClick} aria-label={title}>
<Icon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" align="center" className="font-medium">
{title}
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,178 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@reactive-resume/ui/components/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { Icon } from "@reactive-resume/ui/components/icon";
import { toast } from "@reactive-resume/ui/components/toast";
import { useDialogStore } from "@/dialogs/store";
import { useCurrentBuilderResumeSelector, useCurrentResume, usePatchResume } from "@/features/resume/builder/draft";
import { SaveStatus } from "@/features/resume/editor/save-status";
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 { InformationSectionBuilder } from "../-sidebar/right/sections/information";
import { NotesSectionBuilder } from "../-sidebar/right/sections/notes";
import { BareSectionChrome } from "../-sidebar/right/shared/section-base";
type DocumentDialog = "notes" | "information" | null;
/**
* The document name in the editor bar opens the document menu: rename, duplicate, lock, notes,
* information, print and delete.
*/
export function DocumentMenu() {
const name = useCurrentBuilderResumeSelector((resume) => resume.name);
const isLocked = useCurrentBuilderResumeSelector((resume) => resume.isLocked);
const [dialog, setDialog] = useState<DocumentDialog>(null);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
type="button"
aria-label={t`Document menu: ${name}`}
className="-mx-1.5 flex min-w-0 max-w-[calc(100%+0.75rem)] flex-col items-start rounded-md px-1.5 py-0.5 text-start transition-colors duration-quick hover:bg-hover"
>
<span className="flex min-w-0 max-w-full items-center gap-1.5">
<span className="truncate font-semibold text-ink text-sm leading-[18px]">{name}</span>
{isLocked && <Icon name="lock" size={16} className="text-ink-3" />}
<Icon name="expand_more" size={16} className="text-ink-3" />
</span>
</button>
}
/>
<DocumentMenuItems onOpenDialog={setDialog} />
</DropdownMenu>
<SaveStatus />
<Dialog open={dialog === "notes"} onOpenChange={(open) => !open && setDialog(null)}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>
<Trans>Notes</Trans>
</DialogTitle>
</DialogHeader>
<BareSectionChrome>
<NotesSectionBuilder />
</BareSectionChrome>
</DialogContent>
</Dialog>
<Dialog open={dialog === "information"} onOpenChange={(open) => !open && setDialog(null)}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>
<Trans>Information</Trans>
</DialogTitle>
</DialogHeader>
<BareSectionChrome>
<InformationSectionBuilder />
</BareSectionChrome>
</DialogContent>
</Dialog>
</>
);
}
type DocumentMenuItemsProps = {
onOpenDialog: (dialog: DocumentDialog) => void;
};
function DocumentMenuItems({ onOpenDialog }: DocumentMenuItemsProps) {
const confirm = useConfirm();
const navigate = useNavigate();
const { openDialog } = useDialogStore();
const resume = useCurrentResume();
const patchResume = usePatchResume();
const { onPrint } = useResumeExport(resume);
const { id, name, slug, tags, isLocked } = resume;
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
// Locking is reversible, so it doesn't ask for confirmation.
const handleToggleLock = () => {
setLockedResume(
{ id, isLocked: !isLocked },
{
onSuccess: () => {
patchResume((draft) => {
draft.isLocked = !isLocked;
});
},
onError: (error) => {
toast.add({ type: "error", description: getResumeErrorMessage(error) });
},
},
);
};
// ponytail: permanent delete with a confirmation until Trash exists (plan M6), then this moves to Trash with undo.
const handleDelete = async () => {
const confirmation = await confirm(t`Are you sure you want to delete this resume?`, {
description: t`This action cannot be undone.`,
});
if (!confirmation) return;
const toastId = toast.add({ type: "loading", description: t`Deleting your resume...` });
deleteResume(
{ id },
{
onSuccess: () => {
toast.add({ type: "success", description: t`Your resume has been deleted.`, id: toastId });
void navigate({ to: "/dashboard/resumes", search: { sort: "lastUpdatedAt", tags: [] } });
},
onError: (error) => {
toast.add({ type: "error", description: getResumeErrorMessage(error), id: toastId });
},
},
);
};
return (
<DropdownMenuContent align="start" className="w-60">
<DropdownMenuItem disabled={isLocked} onClick={() => openDialog("resume.update", { id, name, slug, tags })}>
<Icon name="edit" />
<Trans>Rename…</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => openDialog("resume.duplicate", { id, name, slug, tags, shouldRedirect: true })}>
<Icon name="content_copy" />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleToggleLock}>
<Icon name={isLocked ? "lock_open" : "lock"} />
{isLocked ? <Trans>Unlock editing</Trans> : <Trans>Lock editing</Trans>}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onOpenDialog("notes")}>
<Icon name="sticky_note_2" />
<Trans>Notes</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onOpenDialog("information")}>
<Icon name="info" />
<Trans>Information</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void onPrint()}>
<Icon name="print" />
<Trans>Print</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={isLocked} onClick={handleDelete}>
<Icon name="delete" />
<Trans>Delete…</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
);
}
@@ -1,49 +0,0 @@
// @vitest-environment happy-dom
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { BuilderSidebarEdge } from "./edge";
describe("BuilderSidebarEdge", () => {
it("renders its children inside the edge container", () => {
const { getByText } = render(
<BuilderSidebarEdge side="left">
<span>child</span>
</BuilderSidebarEdge>,
);
expect(getByText("child")).toBeInTheDocument();
});
it("uses left-side classes (inset-s-0 + border-r) when side='left'", () => {
const { container } = render(
<BuilderSidebarEdge side="left">
<span>x</span>
</BuilderSidebarEdge>,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("inset-s-0");
expect(wrapper.className).toContain("border-r");
});
it("uses right-side classes (inset-e-0 + border-l) when side='right'", () => {
const { container } = render(
<BuilderSidebarEdge side="right">
<span>x</span>
</BuilderSidebarEdge>,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("inset-e-0");
expect(wrapper.className).toContain("border-l");
});
it("is hidden on mobile (hidden + md:flex)", () => {
const { container } = render(
<BuilderSidebarEdge side="left">
<span>x</span>
</BuilderSidebarEdge>,
);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("hidden");
expect(wrapper.className).toContain("md:flex");
});
});
@@ -1,20 +0,0 @@
import { cn } from "@reactive-resume/utils/style";
type Props = {
side: "left" | "right";
children: React.ReactNode;
};
export function BuilderSidebarEdge({ side, children }: Props) {
return (
<div
className={cn(
// `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",
)}
>
{children}
</div>
);
}
@@ -0,0 +1,260 @@
import type { EditorMode } from "@/features/resume/editor/store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Link } from "@tanstack/react-router";
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
import { ButtonGroup } from "@reactive-resume/ui/components/button-group";
import { Icon } from "@reactive-resume/ui/components/icon";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft";
import { useEditorStore } from "@/features/resume/editor/store";
import { ResumeDownloadDialog } from "@/features/resume/export/download-dialog";
import { useResumeExport } from "@/features/resume/export/use-resume-export";
import { BuilderAiAssistant } from "./ai-assistant";
import { DocumentMenu } from "./document-menu";
import { useOpenIssueCount } from "./use-open-issue-count";
import { BuilderVersionHistory } from "./version-history";
type EditorBarProps = {
layout: "desktop" | "tablet" | "mobile";
/** Tablet in landscape: the panel can be pinned beside the page. */
pinnable: boolean;
};
/**
* The 56px editor bar. Desktop: back, name and save state · Write/Design/Check · undo, history, assistant,
* Share and Download PDF. The mode switch stays centered through a `1fr auto 1fr` grid.
*/
export function EditorBar({ layout, pinnable }: EditorBarProps) {
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
// Download and Share need a connection.
const offline = useResumeStore((state) => state.saveStatus === "offline");
return (
<header className="grid h-(--editor-bar) grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-line border-b bg-surface px-3">
<div className="flex min-w-0 items-center gap-1.5">
<BackLink />
{layout === "tablet" && <DrawerControls pinnable={pinnable} />}
<div className="flex min-w-0 flex-col items-start">
<DocumentMenu />
</div>
</div>
{layout === "mobile" ? <span /> : <ModeTabs />}
<div className="flex items-center justify-end gap-1">
{layout === "desktop" && (
<>
<UndoButton />
<BuilderVersionHistory resumeId={resumeId} />
<span className="me-1.5">
<BuilderAiAssistant resumeId={resumeId} />
</span>
</>
)}
<ShareButton compact={layout !== "desktop"} disabled={offline} />
<DownloadButtons compact={layout === "mobile"} disabled={offline} />
</div>
</header>
);
}
/** Leaving the editor is navigation, so it's a link styled as an icon button. */
function BackLink() {
const label = t`Back to documents`;
return (
<Tooltip>
<TooltipTrigger
render={
<Link
to="/dashboard/resumes"
search={{ sort: "lastUpdatedAt", tags: [] }}
aria-label={label}
className={buttonVariants({ variant: "ghost", size: "icon", className: "text-ink-2" })}
/>
}
>
<Icon name="arrow_back" />
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
}
const MODE_ICONS = { write: "edit", design: "palette", check: "fact_check" } as const;
function ModeTabs() {
const issueCount = useOpenIssueCount();
const labels: Record<EditorMode, string> = { write: t`Write`, design: t`Design`, check: t`Check` };
return (
<TabsList aria-label={t`Editor mode`} className="h-9">
{(Object.keys(MODE_ICONS) as EditorMode[]).map((mode) => (
<TabsTrigger key={mode} value={mode} className="px-4">
<Icon name={MODE_ICONS[mode]} size={18} />
{labels[mode]}
{mode === "check" && <CheckBadge count={issueCount} />}
</TabsTrigger>
))}
</TabsList>
);
}
function CheckBadge({ count }: { count: number }) {
if (count === 0) {
return (
<span className="inline-flex text-accent-text">
<Icon name="check" size={16} />
<span className="sr-only">
<Trans>No open issues</Trans>
</span>
</span>
);
}
return (
<span className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-warn-soft px-[5px] font-semibold text-[11px] text-warn-text">
{count}
<span className="sr-only">
<Trans>open issues</Trans>
</span>
</span>
);
}
function UndoButton() {
const canUndo = useResumeStore((state) => state.canUndo);
const undo = useResumeStore((state) => state.undo);
return (
<IconButton icon="undo" label={t`Undo`} shortcut="⌘Z" className="text-ink-2" disabled={!canUndo} onClick={undo} />
);
}
/** Tablet: show or hide the panel drawer and, in landscape, pin it beside the page. */
function DrawerControls({ pinnable }: { pinnable: boolean }) {
const open = useEditorStore((state) => state.drawerOpen);
const pinned = useEditorStore((state) => state.drawerPinned) && pinnable;
const setOpen = useEditorStore((state) => state.setDrawerOpen);
const setPinned = useEditorStore((state) => state.setDrawerPinned);
return (
<>
{!pinned && (
<IconButton
icon={open ? "left_panel_close" : "left_panel_open"}
label={open ? t`Hide panel` : t`Show panel`}
aria-expanded={open}
className="text-ink-2"
onClick={() => setOpen(!open)}
/>
)}
{pinnable && (open || pinned) && (
<IconButton
icon="vertical_split"
label={t`Keep the panel beside the page`}
aria-pressed={pinned}
className={cn("text-ink-2", pinned && "bg-accent-soft text-accent-text")}
onClick={() => {
// Unpinning leaves the drawer open over the page.
setPinned(!pinned);
setOpen(true);
}}
/>
)}
</>
);
}
type ToolbarActionProps = {
compact: boolean;
disabled: boolean;
};
function ShareButton({ compact, disabled }: ToolbarActionProps) {
const isPublic = useCurrentBuilderResumeSelector((resume) => resume.isPublic ?? false);
const setShareOpen = useEditorStore((state) => state.setShareOpen);
const liveDot = isPublic ? (
<span aria-hidden="true" className="absolute end-1.5 top-1.5 size-[7px] rounded-full bg-accent" />
) : null;
if (compact) {
return (
<span className="relative">
<IconButton
icon="ios_share"
label={t`Share`}
shortcut="⌘⇧S"
disabled={disabled}
onClick={() => setShareOpen(true)}
/>
{liveDot}
</span>
);
}
return (
<Button variant="secondary" className="relative gap-1.5" disabled={disabled} onClick={() => setShareOpen(true)}>
<Icon name="ios_share" />
<Trans>Share</Trans>
{isPublic && (
<span className="sr-only">
<Trans>(link is live)</Trans>
</span>
)}
{liveDot}
</Button>
);
}
/** Download PDF is the only filled button; the ▾ segment opens every format. */
function DownloadButtons({ compact, disabled }: ToolbarActionProps) {
const resume = useCurrentResume();
const { onDownloadPDF, isExporting } = useResumeExport(resume);
const downloadOpen = useEditorStore((state) => state.downloadOpen);
const setDownloadOpen = useEditorStore((state) => state.setDownloadOpen);
if (compact) {
return (
<>
<IconButton
icon="download"
label={t`Download PDF`}
shortcut="⌘P"
disabled={disabled || isExporting}
onClick={() => void onDownloadPDF()}
/>
{/* Opened from the Share sheet. */}
<ResumeDownloadDialog resume={resume} open={downloadOpen} onOpenChange={setDownloadOpen} />
</>
);
}
return (
<ButtonGroup aria-label={t`Download`}>
<Button loading={isExporting} disabled={disabled} className="gap-1.5" onClick={() => void onDownloadPDF()}>
{!isExporting && <Icon name="download" />}
{isExporting ? <Trans>Preparing…</Trans> : <Trans>Download PDF</Trans>}
</Button>
<ResumeDownloadDialog
resume={resume}
open={downloadOpen}
onOpenChange={setDownloadOpen}
trigger={(exporting) => (
<Button
size="icon"
disabled={disabled || exporting}
aria-label={t`More download formats`}
className="w-8 border-s border-s-[oklch(1_0_0/0.25)]"
>
<Icon name="expand_more" />
</Button>
)}
/>
</ButtonGroup>
);
}
@@ -0,0 +1,227 @@
import type { IconName } from "@reactive-resume/ui/components/icon";
import type { EditorMode } from "@/features/resume/editor/store";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Outlet } from "@tanstack/react-router";
import { useEffect, useState, useSyncExternalStore } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Tabs, TabsContent } from "@reactive-resume/ui/components/tabs";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { usePreviewPausedStore } from "@/features/resume/builder/draft";
import { useEditorStore } from "@/features/resume/editor/store";
import { EditorBar } from "./editor-bar";
import { ModePanel } from "./mode-panels";
import { revealSelectionInPanel } from "./reveal-selection";
import { ShareSheet } from "./share-sheet";
import { useEditorHotkeys } from "./use-editor-hotkeys";
import { useEditorMode } from "./use-editor-mode";
const LANDSCAPE_QUERY = "(orientation: landscape)";
function subscribeToOrientation(onChange: () => void) {
const list = window.matchMedia(LANDSCAPE_QUERY);
list.addEventListener("change", onChange);
return () => list.removeEventListener("change", onChange);
}
const useIsLandscape = () =>
useSyncExternalStore(
subscribeToOrientation,
() => window.matchMedia(LANDSCAPE_QUERY).matches,
() => false,
);
/**
* The editor: a 56px bar over a 400px panel and the page canvas (desktop). On tablets the panel is a 380px
* drawer over the page, pinnable beside it in landscape; on phones one view shows at a time, switched by
* Write · Page · Design · Check tabs.
*/
export function EditorShell() {
const [mode, setMode] = useEditorMode();
const breakpoint = useBreakpoint();
const layout = breakpoint === "mobile" ? "mobile" : breakpoint === "tablet" ? "tablet" : "desktop";
const landscape = useIsLandscape();
const pinnable = layout === "tablet" && landscape;
const pinned = useEditorStore((state) => state.drawerPinned) && pinnable;
const resetEditor = useEditorStore((state) => state.reset);
// Selection, zoom and open sheets belong to one document.
useEffect(() => resetEditor, [resetEditor]);
return (
<Tabs value={mode} onValueChange={(value) => setMode(value as EditorMode)} className="contents">
<div className="grid h-svh grid-rows-[var(--editor-bar)_minmax(0,1fr)] overflow-hidden bg-bg">
<a
href="#main-content"
className="sr-only rounded-md bg-raised px-4 py-2 text-sm focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
>
<Trans>Skip to the page</Trans>
</a>
<EditorBar layout={layout} pinnable={pinnable} />
{(layout === "desktop" || pinned) && <DesktopBody mode={mode} />}
{layout === "tablet" && !pinned && <TabletBody mode={mode} />}
{layout === "mobile" && <MobileBody mode={mode} onModeChange={setMode} />}
<ShareSheet />
<EditorHotkeys onModeChange={setMode} />
</div>
</Tabs>
);
}
function EditorHotkeys({ onModeChange }: { onModeChange: (mode: EditorMode) => void }) {
useEditorHotkeys(onModeChange);
return null;
}
const panelLabels = (): Record<EditorMode, string> => ({ write: t`Content`, design: t`Design`, check: t`Check` });
/** The panel beside the page: 400px on desktop, 380px when pinned on a tablet. */
function DesktopBody({ mode }: { mode: EditorMode }) {
return (
<div className="grid min-h-0 grid-cols-[var(--editor-panel)_minmax(0,1fr)] max-lg:grid-cols-[380px_minmax(0,1fr)]">
<TabsContent
value={mode}
aria-label={panelLabels()[mode]}
className="min-h-0 overflow-y-auto border-line border-e bg-surface"
>
<ModePanel mode={mode} />
</TabsContent>
<main id="main-content" className="min-h-0 min-w-0">
<Outlet />
</main>
</div>
);
}
function TabletBody({ mode }: { mode: EditorMode }) {
const drawerOpen = useEditorStore((state) => state.drawerOpen);
return (
<div className="relative min-h-0">
<main id="main-content" className="h-full min-w-0">
<Outlet />
</main>
<TabsContent
value={mode}
aria-label={panelLabels()[mode]}
inert={!drawerOpen}
className={cn(
"absolute inset-y-0 start-0 z-20 w-[380px] max-w-[calc(100%-3rem)] overflow-y-auto border-line border-e bg-surface shadow-e3 transition-transform duration-emphasized ease-enter",
!drawerOpen && "-translate-x-full rtl:translate-x-full",
)}
>
<ModePanel mode={mode} />
</TabsContent>
</div>
);
}
type MobileView = "page" | EditorMode;
type MobileBodyProps = {
mode: EditorMode;
onModeChange: (mode: EditorMode) => void;
};
const MOBILE_TABS: { view: MobileView; icon: IconName }[] = [
{ view: "write", icon: "edit" },
{ view: "page", icon: "description" },
{ view: "design", icon: "palette" },
{ view: "check", icon: "fact_check" },
];
function MobileBody({ mode, onModeChange }: MobileBodyProps) {
const [view, setView] = useState<MobileView>(mode);
const setPreviewPaused = usePreviewPausedStore((state) => state.setPaused);
const labels: Record<MobileView, string> = { write: t`Write`, page: t`Page`, design: t`Design`, check: t`Check` };
// The page stays mounted so zoom survives switching views; it doesn't re-render while hidden.
useEffect(() => {
setPreviewPaused(view !== "page");
return () => setPreviewPaused(false);
}, [view, setPreviewPaused]);
return (
<div className="grid min-h-0 grid-rows-[minmax(0,1fr)_auto]">
<div className="relative min-h-0">
<main id="main-content" className={cn("h-full", view !== "page" && "invisible")}>
<Outlet />
</main>
{view === "page" && (
<SelectionBar
onEdit={() => {
setView("write");
onModeChange("write");
}}
/>
)}
{view !== "page" && (
<TabsContent value={mode} className="absolute inset-0 overflow-y-auto bg-surface">
<ModePanel mode={mode} />
</TabsContent>
)}
</div>
<nav
aria-label={t`Editor views`}
className="flex border-line border-t bg-surface pb-[env(safe-area-inset-bottom)]"
>
{MOBILE_TABS.map(({ view: tab, icon }) => {
const active = tab === view;
return (
<button
key={tab}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => {
setView(tab);
if (tab !== "page") onModeChange(tab);
}}
className={cn(
"flex min-h-[52px] flex-1 flex-col items-center justify-center gap-0.5 text-[11px]",
active ? "font-semibold text-ink" : "text-ink-2",
)}
>
<span
className={cn(
"flex h-7 w-[52px] items-center justify-center rounded-full transition-colors duration-quick",
active && "bg-accent-soft text-accent-text",
)}
>
<Icon name={icon} size={24} filled={active} />
</span>
{labels[tab]}
</button>
);
})}
</nav>
</div>
);
}
/** Phones: tapping a line on the page selects it and offers to edit that entry. Improve joins it in M10. */
function SelectionBar({ onEdit }: { onEdit: () => void }) {
const selection = useEditorStore((state) => state.selection);
if (!selection) return null;
return (
<div className="absolute bottom-[76px] left-1/2 z-10 flex -translate-x-1/2 items-center rounded-xl bg-ink p-1 text-bg shadow-e3">
<button
type="button"
className="flex h-10 items-center gap-1.5 rounded-lg px-3 font-semibold text-[15px]"
onClick={() => {
onEdit();
revealSelectionInPanel(selection);
}}
>
<Icon name="edit" size={20} />
<Trans>Edit entry</Trans>
</button>
</div>
);
}
@@ -1,277 +0,0 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
CaretDownIcon,
CheckCircleIcon,
CircleNotchIcon,
CopySimpleIcon,
DownloadSimpleIcon,
HouseSimpleIcon,
LockSimpleIcon,
LockSimpleOpenIcon,
PencilSimpleLineIcon,
SidebarSimpleIcon,
TrashSimpleIcon,
WarningCircleIcon,
} from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { match } from "ts-pattern";
import { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
import { useDialogStore } from "@/dialogs/store";
import {
useCurrentBuilderResumeSelector,
useCurrentResume,
usePatchResume,
useResumeStore,
} from "@/features/resume/builder/draft";
import { ResumeDownloadDialog } from "@/features/resume/export/download-dialog";
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() {
// Subscribe to only the metadata fields this header renders. Selecting the whole resume re-renders
// the header on every keystroke (immer replaces the resume reference on each content edit).
const name = useCurrentBuilderResumeSelector((resume) => resume.name);
const isLocked = useCurrentBuilderResumeSelector((resume) => resume.isLocked);
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
const { toggleSidebar } = useBuilderSidebar();
// 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 min-w-0 items-center gap-x-2 border-b bg-popover px-1.5">
<div className="flex min-w-0 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 min-w-0 items-center gap-x-1">
<Button
size="icon"
variant="ghost"
aria-label={t({
comment: "Accessible label for button navigating from builder to resumes dashboard",
message: "Go to resumes dashboard",
})}
nativeButton={false}
render={
<Link to="/dashboard/resumes" search={{ sort: "lastUpdatedAt", tags: [] }}>
<HouseSimpleIcon />
</Link>
}
/>
<span className="me-2.5 text-muted-foreground">/</span>
<h2 className="min-w-0 truncate font-medium">{name}</h2>
{isLocked && <LockSimpleIcon className="ms-2 text-muted-foreground" />}
<SaveStatusIndicator />
<BuilderAiAssistant resumeId={resumeId} />
<BuilderVersionHistory resumeId={resumeId} />
<BuilderHeaderDropdown />
</div>
<div className="flex min-w-0 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();
return (
<ResumeDownloadDialog
resume={resume}
trigger={(disabled) => (
<Button
size="sm"
aria-label={t({
comment: "Primary action in the builder header to open resume download options",
message: "Download options",
})}
disabled={disabled}
className="px-2 sm:px-2.5"
>
{disabled ? (
<CircleNotchIcon className="animate-spin sm:me-1.5" />
) : (
<DownloadSimpleIcon className="sm:me-1.5" />
)}
<span className="hidden sm:inline">
<Trans comment="Primary action in the builder header to open resume download options">Download</Trans>
</span>
</Button>
)}
/>
);
}
function SaveStatusIndicator() {
const status = useResumeStore((state) => state.saveStatus);
const { icon, label } = match(status)
.with("idle", () => ({ icon: null, label: "" }))
.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
// Always reserve the slot so the header buttons don't shift when the status appears or clears.
className={cn(
"ms-1 flex min-w-3 shrink-0 items-center gap-x-1 text-muted-foreground text-xs md:min-w-18",
status === "idle" && "invisible",
)}
aria-live="polite"
role="status"
>
{icon}
<span className="hidden md:inline">{label}</span>
</span>
);
}
function BuilderHeaderDropdown() {
const confirm = useConfirm();
const navigate = useNavigate();
const { openDialog } = useDialogStore();
const resume = useCurrentResume();
const patchResume = usePatchResume();
const id = resume.id;
const name = resume.name;
const slug = resume.slug;
const tags = resume.tags;
const isLocked = resume.isLocked;
const { mutate: deleteResume } = useMutation(orpc.resume.delete.mutationOptions());
const { mutate: setLockedResume } = useMutation(orpc.resume.setLocked.mutationOptions());
const handleUpdate = () => {
openDialog("resume.update", { id, name, slug, tags });
};
const handleDuplicate = () => {
openDialog("resume.duplicate", { id, name, slug, tags, shouldRedirect: true });
};
const handleToggleLock = async () => {
if (!isLocked) {
const confirmation = await confirm(t`Are you sure you want to lock this resume?`, {
description: t`When locked, the resume cannot be updated or deleted.`,
});
if (!confirmation) return;
}
setLockedResume(
{ id, isLocked: !isLocked },
{
onSuccess: () => {
patchResume((draft) => {
draft.isLocked = !isLocked;
});
},
onError: (error) => {
toast.add({ type: "error", description: getResumeErrorMessage(error) });
},
},
);
};
const handleDelete = async () => {
const confirmation = await confirm(t`Are you sure you want to delete this resume?`, {
description: t`This action cannot be undone.`,
});
if (!confirmation) return;
const toastId = toast.add({ type: "loading", description: t`Deleting your resume...` });
deleteResume(
{ id },
{
onSuccess: () => {
toast.add({ type: "success", description: t`Your resume has been deleted.`, id: toastId });
void navigate({ to: "/dashboard/resumes", search: { sort: "lastUpdatedAt", tags: [] } });
},
onError: (error) => {
toast.add({ type: "error", description: getResumeErrorMessage(error), id: toastId });
},
},
);
};
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size="icon" variant="ghost" aria-label={t`Resume options`}>
<CaretDownIcon />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuItem disabled={isLocked} onClick={handleUpdate}>
<PencilSimpleLineIcon className="me-2" />
<Trans>Edit details</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDuplicate}>
<CopySimpleIcon className="me-2" />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleToggleLock}>
{isLocked ? <LockSimpleOpenIcon className="me-2" /> : <LockSimpleIcon className="me-2" />}
{isLocked ? <Trans>Unlock</Trans> : <Trans>Lock</Trans>}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={isLocked} onClick={handleDelete}>
<TrashSimpleIcon className="me-2" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,110 +0,0 @@
import type { Icon } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { EyeIcon, NotePencilIcon, PaletteIcon } from "@phosphor-icons/react";
import { Outlet } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import { usePreviewPausedStore } from "@/features/resume/builder/draft";
import { BuilderSidebarLeft } from "../-sidebar/left";
import { BuilderSidebarRight } from "../-sidebar/right";
import { BuilderHeader } from "./header";
type MobileBuilderTab = "edit" | "preview" | "design";
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 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>
);
}
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-[color,scale] duration-150 ease-out-strong active:scale-[0.97]",
isActive ? "text-primary" : "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-5" weight={isActive ? "fill" : "regular"} />
<span>{labels[value]}</span>
</button>
);
})}
</nav>
);
}
export 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>
);
}
@@ -0,0 +1,75 @@
import type { EditorMode } from "@/features/resume/editor/store";
import { Fragment } from "react";
import { Separator } from "@reactive-resume/ui/components/separator";
import { OfflineBanner } from "@/features/resume/editor/save-status";
import { useEditorStore } from "@/features/resume/editor/store";
import { BuilderWritePanel } from "../-sidebar/left";
import { AtsCheckSectionBuilder } from "../-sidebar/right/sections/ats-check";
import { CustomStylesSectionBuilder } from "../-sidebar/right/sections/custom-styles";
import { DesignSectionBuilder } from "../-sidebar/right/sections/design";
import { LayoutSectionBuilder } from "../-sidebar/right/sections/layout";
import { PageSectionBuilder } from "../-sidebar/right/sections/page";
import { TemplateSectionBuilder } from "../-sidebar/right/sections/template";
import { TypographySectionBuilder } from "../-sidebar/right/sections/typography";
import { BareSectionChrome } from "../-sidebar/right/shared/section-base";
import { selectionFromPanelElement } from "./reveal-selection";
// Until the Design mode is rebuilt (M4), it hosts the existing design sections in this order.
const DESIGN_SECTIONS = [
TemplateSectionBuilder,
LayoutSectionBuilder,
TypographySectionBuilder,
DesignSectionBuilder,
PageSectionBuilder,
CustomStylesSectionBuilder,
] as const;
function DesignPanel() {
return (
<div className="@container space-y-4 p-4">
{DESIGN_SECTIONS.map((Section, index) => (
<Fragment key={Section.name}>
{index > 0 && <Separator />}
<Section />
</Fragment>
))}
</div>
);
}
// Until Check mode is rebuilt (M7), it hosts the existing ATS check.
function CheckPanel() {
return (
<div className="@container p-4">
<BareSectionChrome>
<AtsCheckSectionBuilder />
</BareSectionChrome>
</div>
);
}
function WritePanel() {
const select = useEditorStore((state) => state.select);
return (
// Focus events bubble here from every field; the handler only reads where focus landed.
// biome-ignore lint/a11y/noStaticElementInteractions: not an interactive element, see above.
<div
onFocus={(event) => {
const selection = selectionFromPanelElement(event.target);
if (selection) select(selection);
}}
>
<BuilderWritePanel />
</div>
);
}
export function ModePanel({ mode }: { mode: EditorMode }) {
return (
<>
<OfflineBanner className="mx-4 mt-4 w-auto" />
{mode === "design" ? <DesignPanel /> : mode === "check" ? <CheckPanel /> : <WritePanel />}
</>
);
}
@@ -0,0 +1,153 @@
import type { EditorSelection } from "@/features/resume/editor/store";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { useHotkey } from "@tanstack/react-hotkeys";
import { useEffect, useRef, useState } from "react";
import { Icon } from "@reactive-resume/ui/components/icon";
import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint";
import { cn } from "@reactive-resume/utils/style";
import { useCurrentBuilderResumeSelector } from "@/features/resume/builder/draft";
import { PageOverlay } from "@/features/resume/editor/page-overlay";
import { useEditorStore, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from "@/features/resume/editor/store";
import { ResumePreview } from "@/features/resume/preview/preview";
import { revealSelectionInPanel } from "./reveal-selection";
import { useEditorMode } from "./use-editor-mode";
// Page widths in PDF points; 1pt renders as 1 CSS px at 100%.
const PAGE_WIDTH = { a4: 595.28, letter: 612, "free-form": 595.28 } as const;
// Horizontal room the canvas keeps around the page: 40px each side, 16px on phones.
const CANVAS_GUTTER = { wide: 80, narrow: 32 } as const;
function useCanvasWidth() {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver(([entry]) => setWidth(entry?.contentRect.width ?? 0));
observer.observe(element);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
}
/**
* The page canvas: the live resume on the sunken desk, with a caption above the first page, a pointer layer
* that links lines to entries, and the zoom bar.
*/
export function PageCanvas() {
const format = useCurrentBuilderResumeSelector((resume) => resume.data.metadata.page.format);
const zoom = useEditorStore((state) => state.zoom);
const select = useEditorStore((state) => state.select);
const setDrawerOpen = useEditorStore((state) => state.setDrawerOpen);
const breakpoint = useBreakpoint();
const [mode] = useEditorMode();
const [canvasRef, canvasWidth] = useCanvasWidth();
const [pageCount, setPageCount] = useState(1);
const isPhone = breakpoint === "mobile";
const gutter = isPhone ? CANVAS_GUTTER.narrow : CANVAS_GUTTER.wide;
const fitScale = canvasWidth > 0 ? Math.min(ZOOM_MAX, (canvasWidth - gutter) / PAGE_WIDTH[format]) : 1;
const pageScale = zoom === "fit" ? Math.max(0.25, fitScale) : zoom;
const formatLabel = { a4: "A4", letter: t`Letter`, "free-form": t`Free-form` }[format];
const onSelect = (selection: EditorSelection) => {
select(selection);
// Tablet: tapping a line opens the drawer on that entry.
if (breakpoint === "tablet") setDrawerOpen(true);
// Phones show the page and the panel one at a time; the selection bar opens the entry instead.
if (mode === "write" && !isPhone) revealSelectionInPanel(selection);
};
return (
<div className="relative h-full min-h-0 bg-sunken">
<div
ref={canvasRef}
// Tapping the page (not a line) closes the tablet drawer but keeps the selection; on phones it
// dismisses the selection bar.
onPointerDown={(event) => {
if ((event.target as HTMLElement).closest("[data-kind]")) return;
if (breakpoint === "tablet") setDrawerOpen(false);
if (isPhone) select(null);
}}
className={cn("absolute inset-0 overflow-auto pt-7 pb-24", isPhone ? "px-4" : "px-10")}
>
<ResumePreview
pageLayout="vertical"
pageGap={24}
pageScale={pageScale}
className="mx-auto w-fit"
pageClassName="rounded-none shadow-page"
onPageCount={setPageCount}
renderPageCaption={({ pageNumber }) => (
<figcaption className="mb-2.5 text-center font-medium text-ink-3 text-xs">
{pageNumber === 1 ? (
<Trans>Page 1 · {formatLabel} · click any line to edit it</Trans>
) : (
<Trans>Page {pageNumber}</Trans>
)}
</figcaption>
)}
renderPageOverlay={({ pageIndex, pageMap }) => (
<PageOverlay pageIndex={pageIndex} pageMap={pageMap} onSelect={onSelect} />
)}
/>
</div>
<ZoomBar fitScale={fitScale} pageCount={pageCount} />
</div>
);
}
type ZoomBarProps = {
fitScale: number;
pageCount: number;
};
/** − · Fit · + and the page count, floating 18px above the bottom of the canvas. Zoom runs from 60% to 150%. */
function ZoomBar({ fitScale, pageCount }: ZoomBarProps) {
const zoom = useEditorStore((state) => state.zoom);
const setZoom = useEditorStore((state) => state.setZoom);
const current = zoom === "fit" ? fitScale : zoom;
useHotkey("Mod+0", () => setZoom("fit"));
const buttonClassName =
"flex size-8 items-center justify-center rounded-[7px] text-ink-2 transition-colors duration-quick hover:bg-hover disabled:text-ink-3 disabled:hover:bg-transparent";
return (
<div className="absolute bottom-[18px] left-1/2 flex -translate-x-1/2 items-center gap-0.5 rounded-lg border border-line bg-raised p-1 shadow-e2">
<button
type="button"
aria-label={t`Zoom out`}
disabled={current <= ZOOM_MIN}
className={buttonClassName}
onClick={() => setZoom(current - ZOOM_STEP)}
>
<Icon name="remove" />
</button>
<button
type="button"
aria-label={t`Fit page to width`}
className="h-8 min-w-[52px] rounded-[7px] px-1.5 font-medium font-mono text-ink text-xs transition-colors duration-quick hover:bg-hover"
onClick={() => setZoom("fit")}
>
{zoom === "fit" ? <Trans>Fit</Trans> : `${Math.round(current * 100)}%`}
</button>
<button
type="button"
aria-label={t`Zoom in`}
disabled={current >= ZOOM_MAX}
className={buttonClassName}
onClick={() => setZoom(current + ZOOM_STEP)}
>
<Icon name="add" />
</button>
<span className="whitespace-nowrap px-2 font-mono text-ink-3 text-xs">
<Plural value={pageCount} one="# page" other="# pages" />
</span>
</div>
);
}
@@ -1,24 +0,0 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_BUILDER_PREVIEW_PAGE_LAYOUT, getNextBuilderPreviewPageLayout } from "./page-layout";
describe("DEFAULT_BUILDER_PREVIEW_PAGE_LAYOUT", () => {
it("defaults to horizontal", () => {
expect(DEFAULT_BUILDER_PREVIEW_PAGE_LAYOUT).toBe("horizontal");
});
});
describe("getNextBuilderPreviewPageLayout", () => {
it("returns vertical when given horizontal", () => {
expect(getNextBuilderPreviewPageLayout("horizontal")).toBe("vertical");
});
it("returns horizontal when given vertical", () => {
expect(getNextBuilderPreviewPageLayout("vertical")).toBe("horizontal");
});
it("is its own inverse", () => {
const start: "horizontal" | "vertical" = "horizontal";
const back = getNextBuilderPreviewPageLayout(getNextBuilderPreviewPageLayout(start));
expect(back).toBe(start);
});
});
@@ -1,6 +0,0 @@
export type BuilderPreviewPageLayout = "horizontal" | "vertical";
export const DEFAULT_BUILDER_PREVIEW_PAGE_LAYOUT: BuilderPreviewPageLayout = "horizontal";
export const getNextBuilderPreviewPageLayout = (pageLayout: BuilderPreviewPageLayout): BuilderPreviewPageLayout =>
pageLayout === "horizontal" ? "vertical" : "horizontal";
@@ -1,61 +0,0 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { blurFocusedElementOnPan } from "./pan-focus";
afterEach(() => {
document.body.innerHTML = "";
});
describe("blurFocusedElementOnPan", () => {
it("blurs the focused element", () => {
const button = document.createElement("button");
document.body.append(button);
button.focus();
expect(document.activeElement).toBe(button);
blurFocusedElementOnPan();
expect(document.activeElement).not.toBe(button);
});
it("blurs a focused text field so panning does not keep it active", () => {
const input = document.createElement("input");
document.body.append(input);
input.focus();
blurFocusedElementOnPan();
expect(document.activeElement).not.toBe(input);
});
it("does nothing when focus already rests on the body", () => {
const blur = vi.spyOn(document.body, "blur");
blurFocusedElementOnPan();
expect(blur).not.toHaveBeenCalled();
blur.mockRestore();
});
// The issue: closing a dialog restores focus to its trigger, and because react-zoom-pan-pinch
// preventDefault()s the canvas mousedown the browser never moves focus away. Space then
// activates the still-focused trigger on keyup and reopens the dialog.
// https://github.com/reactive-resume/reactive-resume/issues/3300
it("stops a Space keypress after panning from re-activating the last dialog trigger", () => {
const trigger = document.createElement("button");
const openDialog = vi.fn();
trigger.addEventListener("click", openDialog);
document.body.append(trigger);
// Focus restored to the trigger when the dialog closed.
trigger.focus();
blurFocusedElementOnPan();
// happy-dom does not synthesise the activation click that a browser fires on Space keyup,
// so assert the precondition for it instead: the trigger no longer holds focus.
expect(document.activeElement).not.toBe(trigger);
expect(openDialog).not.toHaveBeenCalled();
});
});
@@ -1,20 +0,0 @@
/**
* Drops focus from whatever element currently holds it, mirroring what the browser would have
* done natively when the user presses the mouse down on the (non-focusable) preview canvas.
*
* `react-zoom-pan-pinch` calls `preventDefault()` on its window-level `mousedown` listener so
* that dragging the canvas does not select text. That also cancels the browser's native focus
* shift, so focus stays on whatever was focused before the pan — typically the sidebar button
* that opened the last dialog, since closing a dialog restores focus to its trigger. A Space
* keypress then activates that still-focused button on keyup and the dialog reopens.
*
* @see https://github.com/reactive-resume/reactive-resume/issues/3300
*/
export function blurFocusedElementOnPan(): void {
if (typeof document === "undefined") return;
const element = document.activeElement;
if (!(element instanceof HTMLElement) || element === document.body) return;
element.blur();
}
@@ -1,53 +0,0 @@
import { t } from "@lingui/core/macro";
import { useHotkey } from "@tanstack/react-hotkeys";
import { useReducedMotion } from "motion/react";
import { Suspense, useState } from "react";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import { toast } from "@reactive-resume/ui/components/toast";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { ResumePreview } from "@/features/resume/preview/preview";
import { BuilderDock } from "./dock";
import { DEFAULT_BUILDER_PREVIEW_PAGE_LAYOUT, getNextBuilderPreviewPageLayout } from "./page-layout";
import { blurFocusedElementOnPan } from "./pan-focus";
export function PreviewPage() {
const [pageLayout, setPageLayout] = useState(DEFAULT_BUILDER_PREVIEW_PAGE_LAYOUT);
const reduceMotion = useReducedMotion();
useHotkey("Mod+S", () => {
toast.add({
type: "info",
description: t`Your changes are saved automatically.`,
id: "auto-save",
});
});
return (
<Suspense fallback={<LoadingScreen />}>
<div className="fixed inset-0">
<TransformWrapper
centerOnInit
maxScale={5}
minScale={0.5}
initialScale={0.75}
limitToBounds={false}
wheel={{ step: 0.001 }}
doubleClick={{ animationType: "easeOutCubic", animationTime: reduceMotion ? 0 : 200 }}
onPanningStart={blurFocusedElementOnPan}
>
{/* Zoom transforms use left-origin coordinates, regardless of the interface language. */}
<TransformComponent wrapperClass="h-full! w-full!" wrapperProps={{ dir: "ltr" }}>
<ResumePreview showPageNumbers pageLayout={pageLayout} />
</TransformComponent>
<BuilderDock
pageLayout={pageLayout}
onTogglePageLayout={() => {
setPageLayout((current) => getNextBuilderPreviewPageLayout(current));
}}
/>
</TransformWrapper>
</div>
</Suspense>
);
}
@@ -0,0 +1,54 @@
import type { EditorSelection } from "@/features/resume/editor/store";
import type { LeftSidebarSection } from "@/libs/resume/section";
import {
focusCustomSidebarSection,
focusLeftSidebarSection,
getScrollBehavior,
} from "@/features/resume/builder/section-recovery";
import { atsFindingItemElementId } from "@/libs/resume/ats";
import { leftSidebarSections } from "@/libs/resume/section";
import { useSectionStore } from "../-store/section";
const isBuiltInSection = (sectionId: string): sectionId is LeftSidebarSection =>
(leftSidebarSections as readonly string[]).includes(sectionId);
const SECTION_ANCHOR = "sidebar-";
const ITEM_ANCHOR = atsFindingItemElementId("");
/**
* The block an element in the Write panel edits, read from the section and entry anchors around it, so focusing
* a field outlines its block on the page. Picture and Basics edit the header.
*/
export function selectionFromPanelElement(element: Element): EditorSelection | null {
const sectionId = element.closest(`[id^="${SECTION_ANCHOR}"]`)?.id.slice(SECTION_ANCHOR.length);
// "sidebar-hidden-…" rows restore hidden sections; they have no block on the page.
if (!sectionId || sectionId.startsWith("hidden-")) return null;
if (sectionId === "basics" || sectionId === "picture") return { kind: "header" };
const itemId = element.closest(`[id^="${ITEM_ANCHOR}"]`)?.id.slice(ITEM_ANCHOR.length);
return itemId ? { kind: "item", sectionId, itemId } : { kind: "section", sectionId };
}
/**
* Brings the selected block's editor into view in the Write panel: expands its section and scrolls the
* entry to the middle. The Write panel is rebuilt in M3; until then this targets the existing section list.
*/
export function revealSelectionInPanel(selection: EditorSelection) {
const sectionKey: LeftSidebarSection =
selection.kind === "header" ? "basics" : isBuiltInSection(selection.sectionId) ? selection.sectionId : "custom";
useSectionStore.getState().setCollapsed(sectionKey, false);
requestAnimationFrame(() => {
if (selection.kind === "item") {
const row = document.getElementById(atsFindingItemElementId(selection.itemId));
if (row) {
row.scrollIntoView({ block: "center", behavior: getScrollBehavior() });
return;
}
}
if (selection.kind === "header") focusLeftSidebarSection("basics");
else if (isBuiltInSection(selection.sectionId)) focusLeftSidebarSection(selection.sectionId);
else focusCustomSidebarSection(selection.sectionId);
});
}
@@ -0,0 +1,45 @@
import { Trans } from "@lingui/react/macro";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@reactive-resume/ui/components/sheet";
import { useEditorStore } from "@/features/resume/editor/store";
import { SharingSectionBuilder } from "../-sidebar/right/sections/sharing";
import { StatisticsSectionBuilder } from "../-sidebar/right/sections/statistics";
import { BareSectionChrome } from "../-sidebar/right/shared/section-base";
/** The public link, its statistics and every download format. History joins this sheet in M5. */
export function ShareSheet() {
const open = useEditorStore((state) => state.shareOpen);
const setOpen = useEditorStore((state) => state.setShareOpen);
const setDownloadOpen = useEditorStore((state) => state.setDownloadOpen);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-[440px]">
<SheetHeader>
<SheetTitle>
<Trans>Share & export</Trans>
</SheetTitle>
</SheetHeader>
<div className="space-y-8 px-6 pb-8">
<BareSectionChrome>
<SharingSectionBuilder />
<StatisticsSectionBuilder />
</BareSectionChrome>
<Button
variant="secondary"
className="w-full gap-1.5"
onClick={() => {
setOpen(false);
setDownloadOpen(true);
}}
>
<Icon name="download" />
<Trans>More download formats…</Trans>
</Button>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,52 @@
import type { EditorMode } from "@/features/resume/editor/store";
import { t } from "@lingui/core/macro";
import { useHotkey } from "@tanstack/react-hotkeys";
import { toast } from "@reactive-resume/ui/components/toast";
import { isEditableElementFocused, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft";
import { useEditorStore } from "@/features/resume/editor/store";
import { useResumeExport } from "@/features/resume/export/use-resume-export";
/**
* The editor's keyboard map: 1/2/3 switch modes outside fields, ⌘Z and ⇧⌘Z undo and redo outside fields
* (fields keep their own undo), ⌘P downloads the PDF, ⌘⇧S opens Share, ⌘⇧E opens Download, ⌘J toggles
* the assistant, and Esc clears the page selection. Download and Share need a connection, so their keys do
* nothing offline.
*/
export function useEditorHotkeys(setMode: (mode: EditorMode) => void) {
const undo = useResumeStore((state) => state.undo);
const redo = useResumeStore((state) => state.redo);
const resume = useCurrentResume();
const { onDownloadPDF } = useResumeExport(resume);
const { select, setShareOpen, setDownloadOpen, setAssistantOpen } = useEditorStore.getState();
const isOnline = () => useResumeStore.getState().saveStatus !== "offline";
useHotkey("1", () => setMode("write"));
useHotkey("2", () => setMode("design"));
useHotkey("3", () => setMode("check"));
useHotkey("Mod+Z", () => {
if (!isEditableElementFocused()) undo();
});
useHotkey("Mod+Shift+Z", () => {
if (!isEditableElementFocused()) redo();
});
useHotkey("Control+Y", () => {
if (!isEditableElementFocused()) redo();
});
useHotkey("Mod+P", () => {
if (isOnline()) void onDownloadPDF();
});
useHotkey("Mod+Shift+S", () => {
if (isOnline()) setShareOpen(true);
});
useHotkey("Mod+Shift+E", () => {
if (isOnline()) setDownloadOpen(true);
});
useHotkey("Mod+J", () => setAssistantOpen(!useEditorStore.getState().assistantOpen));
useHotkey("Escape", () => select(null), { preventDefault: false, stopPropagation: false });
useHotkey("Mod+S", () => {
toast.add({ type: "info", description: t`Your changes are saved automatically.`, id: "auto-save" });
});
}
@@ -0,0 +1,65 @@
// @vitest-environment happy-dom
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useEditorStore } from "@/features/resume/editor/store";
import { useEditorMode } from "./use-editor-mode";
const router = vi.hoisted(() => ({
search: {} as { mode?: "write" | "design" | "check" },
navigations: [] as { mode?: string; resolve: () => void }[],
}));
vi.mock("@tanstack/react-router", () => ({
getRouteApi: () => ({
useSearch: () => router.search,
useNavigate: () => (options: { search: { mode?: string } }) =>
new Promise<void>((resolve) => {
router.navigations.push({ mode: options.search.mode, resolve });
}),
}),
}));
afterEach(() => {
router.search = {};
router.navigations = [];
useEditorStore.getState().reset();
});
describe("useEditorMode", () => {
it("follows ?mode=, defaulting to write", () => {
router.search = { mode: "check" };
expect(renderHook(() => useEditorMode()).result.current[0]).toBe("check");
router.search = {};
expect(renderHook(() => useEditorMode()).result.current[0]).toBe("write");
});
it("shows a picked mode before the URL catches up, then hands over to the URL", async () => {
const { result, rerender } = renderHook(() => useEditorMode());
act(() => result.current[1]("design"));
expect(result.current[0]).toBe("design");
expect(router.navigations.map((navigation) => navigation.mode)).toEqual(["design"]);
router.search = { mode: "design" };
await act(async () => router.navigations[0]?.resolve());
rerender();
expect(useEditorStore.getState().pendingMode).toBeNull();
expect(result.current[0]).toBe("design");
});
it("keeps the latest pick when an earlier navigation settles first", async () => {
const { result } = renderHook(() => useEditorMode());
act(() => result.current[1]("design"));
act(() => result.current[1]("write"));
expect(router.navigations.map((navigation) => navigation.mode)).toEqual(["design", undefined]);
await act(async () => router.navigations[0]?.resolve());
expect(result.current[0]).toBe("write");
await act(async () => router.navigations[1]?.resolve());
expect(useEditorStore.getState().pendingMode).toBeNull();
expect(result.current[0]).toBe("write");
});
});
@@ -0,0 +1,30 @@
import type { EditorMode } from "@/features/resume/editor/store";
import { getRouteApi } from "@tanstack/react-router";
import { useCallback } from "react";
import { useEditorStore } from "@/features/resume/editor/store";
const routeApi = getRouteApi("/builder/$resumeId");
/**
* The editor mode lives in the URL (`?mode=design`), so refreshing and deep links keep it. Write is the default.
* A picked mode shows at once and the URL follows.
*/
export function useEditorMode() {
const { mode: urlMode = "write" } = routeApi.useSearch();
const pendingMode = useEditorStore((state) => state.pendingMode);
const navigate = routeApi.useNavigate();
const setMode = useCallback(
(next: EditorMode) => {
useEditorStore.setState({ pendingMode: next });
// The builder has no other search params, so the object form is safe.
void navigate({ to: ".", search: { mode: next === "write" ? undefined : next }, replace: true }).finally(() => {
// A later pick may still be on its way; only the latest one hands over to the URL.
useEditorStore.setState((state) => (state.pendingMode === next ? { pendingMode: null } : state));
});
},
[navigate],
);
return [pendingMode ?? urlMode, setMode] as const;
}
@@ -0,0 +1,14 @@
import { useDeferredValue, useMemo } from "react";
import { lintResumeForAts } from "@reactive-resume/resume/ats";
import { useResumeData } from "@/features/resume/builder/draft";
/** Open Check issues (errors and warnings from the live checks) for the badge on the Check tab. */
export function useOpenIssueCount(): number {
const data = useDeferredValue(useResumeData());
return useMemo(() => {
if (!data) return 0;
const { counts } = lintResumeForAts(data);
return counts.error + counts.warning;
}, [data]);
}
@@ -2,10 +2,8 @@ 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 { Button } from "@reactive-resume/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -15,6 +13,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
import { toast } from "@reactive-resume/ui/components/toast";
import { useResumeStore } from "@/features/resume/builder/draft";
import { useConfirm } from "@/hooks/use-confirm";
@@ -61,15 +60,9 @@ export function BuilderVersionHistory({ resumeId }: BuilderVersionHistoryProps)
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
render={
<Button size="icon" variant="ghost" aria-label={t`Version history`}>
<ClockCounterClockwiseIcon />
</Button>
}
/>
<DropdownMenuTrigger render={<IconButton icon="history" label={t`Version history`} className="text-ink-2" />} />
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuGroup>
<DropdownMenuLabel>
<Trans>Version history</Trans>
@@ -1,33 +1,14 @@
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 { useCallback, useRef } from "react";
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 { toast } from "@reactive-resume/ui/components/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { getInitials } from "@reactive-resume/utils/string";
import {
useCurrentBuilderResumeSelector,
useCurrentResume,
useIsResumeLocked,
usePatchResume,
} from "@/features/resume/builder/draft";
import {
focusCustomSidebarSection,
focusLeftSidebarSection,
SectionEditorList,
} from "@/features/resume/builder/section-recovery";
import { UserDropdownMenu } from "@/features/user/dropdown-menu";
import { useCurrentResume, useIsResumeLocked, usePatchResume } from "@/features/resume/builder/draft";
import { SectionEditorList } from "@/features/resume/builder/section-recovery";
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";
import { BasicsSectionBuilder } from "./sections/basics";
import { CustomSectionBuilder } from "./sections/custom";
import { PictureSectionBuilder } from "./sections/picture";
@@ -58,24 +39,18 @@ function getSectionComponent(type: LeftSidebarSection) {
.exhaustive();
}
export function BuilderSidebarLeft() {
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
/** The Write mode panel: the section editors. It's rebuilt as the outline in M3. */
export function BuilderWritePanel() {
const isLocked = useIsResumeLocked();
return (
<>
<SidebarEdge />
<div className="@container space-y-4 p-4">
{isLocked && <LockBanner />}
<ScrollArea ref={scrollAreaRef} className="@container h-[calc(100svh-3.5rem)] bg-background sm:ms-12">
<div className="space-y-4 p-4">
{isLocked && <LockBanner />}
<fieldset disabled={isLocked} className="m-0 min-w-0 space-y-4 border-0 p-0">
<SectionEditorList renderSection={getSectionComponent} />
</fieldset>
</div>
</ScrollArea>
</>
<fieldset disabled={isLocked} className="m-0 min-w-0 space-y-4 border-0 p-0">
<SectionEditorList renderSection={getSectionComponent} />
</fieldset>
</div>
);
}
@@ -117,70 +92,3 @@ function LockBanner() {
</div>
);
}
function SidebarEdge() {
const { toggleSidebar } = useBuilderSidebar();
const coverLetterSectionId = useCurrentBuilderResumeSelector(
(resume) => resume.data.customSections.find((section) => section.type === "cover-letter")?.id ?? null,
);
type SidebarRailSection = LeftSidebarSection | "cover-letter";
type SidebarRailItem = { key: string; section: SidebarRailSection; target: string };
const railSections = leftSidebarSections.flatMap<SidebarRailItem>((section) => {
if (section !== "custom" || !coverLetterSectionId) return [{ key: section, section, target: section }];
return [
{ key: "cover-letter", section: "cover-letter" as const, target: coverLetterSectionId },
{ key: section, section, target: section },
];
});
const scrollToSection = useCallback(
(section: LeftSidebarSection | "cover-letter", target: LeftSidebarSection | string) => {
toggleSidebar("left", true);
if (section === "cover-letter") focusCustomSidebarSection(target);
else focusLeftSidebarSection(section);
},
[toggleSidebar],
);
return (
<BuilderSidebarEdge side="left">
<div className="flex min-h-0 w-full flex-1 flex-col items-center gap-y-2 overflow-hidden">
<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">
{railSections.map(({ key, section, target }) => (
<Tooltip key={key}>
<TooltipTrigger
render={
<Button
size="icon"
variant="ghost"
aria-label={getSectionTitle(section)}
onClick={() => scrollToSection(section, target)}
>
{getSectionIcon(section)}
</Button>
}
/>
<TooltipContent side="right" className="font-medium">
{getSectionTitle(section)}
</TooltipContent>
</Tooltip>
))}
</div>
</div>
<UserDropdownMenu>
{({ session }) => (
<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>
</Avatar>
</Button>
)}
</UserDropdownMenu>
</div>
</BuilderSidebarEdge>
);
}
@@ -1,110 +0,0 @@
import type { RightSidebarSection } from "@/libs/resume/section";
import { Fragment, useCallback, useRef } from "react";
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 { getScrollBehavior } from "@/features/resume/builder/section-recovery";
import { getSectionIcon, getSectionTitle, rightSidebarSections } from "@/libs/resume/section";
import { BuilderSidebarEdge } from "../../-components/edge";
import { useBuilderSidebar } from "../../-store/sidebar";
import { AtsCheckSectionBuilder } from "./sections/ats-check";
import { CustomStylesSectionBuilder } from "./sections/custom-styles";
import { DesignSectionBuilder } from "./sections/design";
import { ExportSectionBuilder } from "./sections/export";
import { InformationSectionBuilder } from "./sections/information";
import { LayoutSectionBuilder } from "./sections/layout";
import { NotesSectionBuilder } from "./sections/notes";
import { PageSectionBuilder } from "./sections/page";
import { SharingSectionBuilder } from "./sections/sharing";
import { StatisticsSectionBuilder } from "./sections/statistics";
import { TemplateSectionBuilder } from "./sections/template";
import { TypographySectionBuilder } from "./sections/typography";
function getSectionComponent(type: RightSidebarSection) {
return match(type)
.with("template", () => <TemplateSectionBuilder />)
.with("layout", () => <LayoutSectionBuilder />)
.with("typography", () => <TypographySectionBuilder />)
.with("design", () => <DesignSectionBuilder />)
.with("styles", () => <CustomStylesSectionBuilder />)
.with("page", () => <PageSectionBuilder />)
.with("notes", () => <NotesSectionBuilder />)
.with("sharing", () => <SharingSectionBuilder />)
.with("statistics", () => <StatisticsSectionBuilder />)
.with("ats", () => <AtsCheckSectionBuilder />)
.with("export", () => <ExportSectionBuilder />)
.with("information", () => <InformationSectionBuilder />)
.exhaustive();
}
export function BuilderSidebarRight() {
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
return (
<>
<SidebarEdge />
<ScrollArea
ref={scrollAreaRef}
className="@container h-[calc(100svh-3.5rem)] overflow-hidden bg-background sm:me-12"
>
<div className="space-y-4 p-4">
{rightSidebarSections.map((section) => (
<Fragment key={section}>
{getSectionComponent(section)}
<Separator />
</Fragment>
))}
<Copyright className="mx-auto py-2 text-center" />
</div>
</ScrollArea>
</>
);
}
function SidebarEdge() {
const { toggleSidebar } = useBuilderSidebar();
const scrollToSection = useCallback(
(section: RightSidebarSection) => {
toggleSidebar("right", true);
// Section ids are globally unique; document.getElementById reliably resolves the scroll target.
document
.getElementById(`sidebar-${section}`)
?.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
},
[toggleSidebar],
);
return (
<BuilderSidebarEdge side="right">
<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) => (
<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>
</BuilderSidebarEdge>
);
}
@@ -2,13 +2,13 @@
import type { ExperienceItem, ResumeData } from "@reactive-resume/schema/resume/data";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
const resumeState = vi.hoisted(() => ({ data: undefined as ResumeData | undefined }));
const sidebarState = vi.hoisted(() => ({ toggleSidebar: vi.fn() }));
const editorModeState = vi.hoisted(() => ({ setMode: vi.fn() }));
const sectionState = vi.hoisted(() => ({ setCollapsed: vi.fn() }));
const deepCheckMocks = vi.hoisted(() => ({
@@ -55,8 +55,8 @@ vi.mock("@/features/ats-checker/ai-review/ai-review-card", () => ({
vi.mock("@/features/resume/builder/draft", () => ({
useResumeData: () => resumeState.data,
}));
vi.mock("../../../-store/sidebar", () => ({
useBuilderSidebar: () => sidebarState,
vi.mock("../../../-components/use-editor-mode", () => ({
useEditorMode: () => ["check", editorModeState.setMode],
}));
vi.mock("../../../-store/section", () => ({
useSectionStore: (selector: SectionStoreSelector) => selector(sectionState),
@@ -158,7 +158,7 @@ describe("AtsCheckSectionBuilder", () => {
fireEvent.click(screen.getByRole("button", { name: /Basics/ }));
expect(sidebarState.toggleSidebar).toHaveBeenCalledWith("left", true);
expect(editorModeState.setMode).toHaveBeenCalledWith("write");
expect(sectionState.setCollapsed).toHaveBeenCalledWith("basics", false);
});
@@ -170,7 +170,7 @@ describe("AtsCheckSectionBuilder", () => {
fireEvent.click(screen.getByRole("button", { name: /Typography/ }));
expect(sidebarState.toggleSidebar).toHaveBeenCalledWith("right", true);
expect(editorModeState.setMode).toHaveBeenCalledWith("design");
expect(sectionState.setCollapsed).toHaveBeenCalledWith("typography", false);
});
});
@@ -229,6 +229,17 @@ describe("the deep check tier", () => {
});
describe("navigating to a finding", () => {
// The jump switches editor mode first and scrolls on the next frame, once the panel has rendered.
beforeEach(() => {
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 0;
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
const stubElement = (id: string) => {
const element = document.createElement("div");
element.id = id;
@@ -27,8 +27,8 @@ import {
getAtsFindingMessage,
getAtsFindingTarget,
} from "@/libs/resume/ats";
import { useEditorMode } from "../../../-components/use-editor-mode";
import { useSectionStore } from "../../../-store/section";
import { useBuilderSidebar } from "../../../-store/sidebar";
import { SectionBase } from "../shared/section-base";
const MAX_JOB_DESCRIPTION_CHARS = 20_000;
@@ -105,7 +105,7 @@ function AtsFindingRow({ finding, onJump }: AtsFindingRowProps) {
function LiveLintTier() {
const data = useResumeData();
const { toggleSidebar } = useBuilderSidebar();
const [, setMode] = useEditorMode();
const setCollapsed = useSectionStore((state) => state.setCollapsed);
const report = useMemo(() => (data ? lintResumeForAts(data) : null), [data]);
@@ -115,14 +115,17 @@ function LiveLintTier() {
const target = getAtsFindingTarget(pointer, data);
if (!target) return;
toggleSidebar(target.side, true);
// Content findings open Write, design findings open Design; then the field scrolls into view.
setMode(target.side === "left" ? "write" : "design");
setCollapsed(target.section, false);
const item = target.itemId ? document.getElementById(atsFindingItemElementId(target.itemId)) : null;
const destination = item ?? document.getElementById(`sidebar-${target.section}`);
destination?.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
requestAnimationFrame(() => {
const item = target.itemId ? document.getElementById(atsFindingItemElementId(target.itemId)) : null;
const destination = item ?? document.getElementById(`sidebar-${target.section}`);
destination?.scrollIntoView({ block: "start", inline: "nearest", behavior: getScrollBehavior() });
});
},
[data, setCollapsed, toggleSidebar],
[data, setCollapsed, setMode],
);
if (!report) return null;
@@ -1,6 +1,7 @@
import type { RightSidebarSection } from "@/libs/resume/section";
import { t } from "@lingui/core/macro";
import { CaretDownIcon } from "@phosphor-icons/react";
import { createContext, use } from "react";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
import { Button } from "@reactive-resume/ui/components/button";
import { cn } from "@reactive-resume/utils/style";
@@ -11,11 +12,29 @@ type Props = React.ComponentProps<typeof AccordionContent> & {
type: RightSidebarSection;
};
/**
* When a section is shown inside a host that already titles it (a dialog, a sheet or an editor mode),
* it renders its content only, without the collapsible heading.
*/
const SectionChromeContext = createContext<"collapsible" | "bare">("collapsible");
export const BareSectionChrome = ({ children }: { children: React.ReactNode }) => (
<SectionChromeContext value="bare">{children}</SectionChromeContext>
);
export function SectionBase({ type, className, ...props }: Props) {
const chrome = use(SectionChromeContext);
const collapsed = useSectionStore((state) => state.sections[type]?.collapsed ?? false);
const toggleCollapsed = useSectionStore((state) => state.toggleCollapsed);
const sectionTitle = getSectionTitle(type);
if (chrome === "bare") {
return (
<div id={`sidebar-${type}`} className={cn("space-y-4", className)}>
{props.children as React.ReactNode}
</div>
);
}
return (
<Accordion
className="space-y-4"
@@ -1,118 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import {
DEFAULT_BUILDER_LAYOUT,
DESKTOP_BUILDER_SIDEBAR_COLLAPSED_SIZE,
DESKTOP_BUILDER_SIDEBAR_MIN_SIZE,
getBuilderSidebarResizeConfig,
mapPanelLayoutToBuilderLayout,
parseBuilderLayoutCookie,
useBuilderSidebarStore,
} from "./sidebar";
afterEach(() => {
useBuilderSidebarStore.setState({
layout: DEFAULT_BUILDER_LAYOUT,
leftSidebar: null,
rightSidebar: null,
});
});
describe("parseBuilderLayoutCookie", () => {
it("returns the default layout when value is undefined", () => {
expect(parseBuilderLayoutCookie(undefined)).toEqual(DEFAULT_BUILDER_LAYOUT);
});
it("returns the default layout when value is null", () => {
expect(parseBuilderLayoutCookie(null)).toEqual(DEFAULT_BUILDER_LAYOUT);
});
it("returns the default layout when value is an empty string", () => {
expect(parseBuilderLayoutCookie("")).toEqual(DEFAULT_BUILDER_LAYOUT);
});
it("returns the default layout when value is malformed JSON", () => {
expect(parseBuilderLayoutCookie("{not-json")).toEqual(DEFAULT_BUILDER_LAYOUT);
});
it("returns the default layout when value is a JSON array", () => {
expect(parseBuilderLayoutCookie("[1,2,3]")).toEqual(DEFAULT_BUILDER_LAYOUT);
});
it("returns the default layout when value is a JSON primitive", () => {
expect(parseBuilderLayoutCookie("42")).toEqual(DEFAULT_BUILDER_LAYOUT);
expect(parseBuilderLayoutCookie("null")).toEqual(DEFAULT_BUILDER_LAYOUT);
expect(parseBuilderLayoutCookie('"string"')).toEqual(DEFAULT_BUILDER_LAYOUT);
});
it("returns the default layout when any field is missing or not a number", () => {
expect(parseBuilderLayoutCookie('{"left":10,"artboard":50}')).toEqual(DEFAULT_BUILDER_LAYOUT);
expect(parseBuilderLayoutCookie('{"left":"10","artboard":50,"right":40}')).toEqual(DEFAULT_BUILDER_LAYOUT);
});
it("returns the parsed layout when all fields are numeric", () => {
const json = JSON.stringify({ left: 10, artboard: 60, right: 30 });
expect(parseBuilderLayoutCookie(json)).toEqual({ left: 10, artboard: 60, right: 30 });
});
});
describe("mapPanelLayoutToBuilderLayout", () => {
it("returns the default layout if any panel size is missing", () => {
expect(mapPanelLayoutToBuilderLayout({} as never)).toEqual(DEFAULT_BUILDER_LAYOUT);
expect(mapPanelLayoutToBuilderLayout({ left: 10, artboard: 60 } as never)).toEqual(DEFAULT_BUILDER_LAYOUT);
});
it("returns the layout when all panel sizes are numeric", () => {
const layout = mapPanelLayoutToBuilderLayout({ left: 15, artboard: 70, right: 15 } as never);
expect(layout).toEqual({ left: 15, artboard: 70, right: 15 });
});
});
describe("getBuilderSidebarResizeConfig", () => {
it("uses a desktop minimum width that is larger than the collapsed rail", () => {
const config = getBuilderSidebarResizeConfig({ isMobile: false, width: 1280 });
expect(config.minSidebarSize).toBe(DESKTOP_BUILDER_SIDEBAR_MIN_SIZE);
expect(config.collapsedSidebarSize).toBe(DESKTOP_BUILDER_SIDEBAR_COLLAPSED_SIZE);
expect(config.minSidebarSize).toBeGreaterThan(config.collapsedSidebarSize);
expect(config.groupResizeBehavior).toBe("preserve-pixel-size");
});
it("allows mobile sidebars to collapse fully without a desktop minimum", () => {
const config = getBuilderSidebarResizeConfig({ isMobile: true, width: 390 });
expect(config.minSidebarSize).toBe(0);
expect(config.collapsedSidebarSize).toBe(0);
expect(config.maxSidebarSize).toBe("95%");
});
});
describe("useBuilderSidebarStore", () => {
it("starts with default layout and null panel refs", () => {
const state = useBuilderSidebarStore.getState();
expect(state.layout).toEqual(DEFAULT_BUILDER_LAYOUT);
expect(state.leftSidebar).toBeNull();
expect(state.rightSidebar).toBeNull();
});
it("setLayout replaces the layout", () => {
useBuilderSidebarStore.getState().setLayout({ left: 1, artboard: 98, right: 1 });
expect(useBuilderSidebarStore.getState().layout).toEqual({ left: 1, artboard: 98, right: 1 });
});
it("setLeftSidebar and setRightSidebar store refs", () => {
const left = { foo: "left" } as never;
const right = { foo: "right" } as never;
useBuilderSidebarStore.getState().setLeftSidebar(left);
useBuilderSidebarStore.getState().setRightSidebar(right);
expect(useBuilderSidebarStore.getState().leftSidebar).toBe(left);
expect(useBuilderSidebarStore.getState().rightSidebar).toBe(right);
});
it("accepts null to clear sidebar refs", () => {
useBuilderSidebarStore.getState().setLeftSidebar({ foo: "x" } as never);
useBuilderSidebarStore.getState().setLeftSidebar(null);
expect(useBuilderSidebarStore.getState().leftSidebar).toBeNull();
});
});
@@ -1,158 +0,0 @@
import type { Layout, usePanelRef } from "react-resizable-panels";
import Cookies from "js-cookie";
import { useCallback } from "react";
import { useWindowSize } from "usehooks-ts";
import { create } from "zustand/react";
import { useIsMobile } from "@reactive-resume/ui/hooks/use-mobile";
type PanelImperativeHandle = ReturnType<typeof usePanelRef>;
const BUILDER_LAYOUT_COOKIE_NAME = "builder_layout";
export type BuilderLayout = {
left: number;
artboard: number;
right: number;
};
export const DEFAULT_BUILDER_LAYOUT: BuilderLayout = {
left: 22,
artboard: 56,
right: 22,
};
export const DESKTOP_BUILDER_SIDEBAR_COLLAPSED_SIZE = 48;
export const DESKTOP_BUILDER_SIDEBAR_MIN_SIZE = 320;
type BuilderSidebarResizeConfigInput = {
isMobile: boolean;
width: number;
};
export const getBuilderSidebarResizeConfig = ({ isMobile, width }: BuilderSidebarResizeConfigInput) => ({
maxSidebarSize: !width ? 0 : isMobile ? "95%" : "45%",
minSidebarSize: !width ? 0 : isMobile ? 0 : DESKTOP_BUILDER_SIDEBAR_MIN_SIZE,
collapsedSidebarSize: !width ? 0 : isMobile ? 0 : DESKTOP_BUILDER_SIDEBAR_COLLAPSED_SIZE,
expandSize: isMobile ? "95%" : "30%",
groupResizeBehavior: "preserve-pixel-size" as const,
});
export const mapPanelLayoutToBuilderLayout = (layout: Layout): BuilderLayout => {
const left = layout.left;
const artboard = layout.artboard;
const right = layout.right;
if (typeof left !== "number" || typeof artboard !== "number" || typeof right !== "number")
return DEFAULT_BUILDER_LAYOUT;
return { left, artboard, right };
};
export const parseBuilderLayoutCookie = (value?: string | null): BuilderLayout => {
if (!value) return DEFAULT_BUILDER_LAYOUT;
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return DEFAULT_BUILDER_LAYOUT;
if (typeof parsed !== "object" || parsed === null) return DEFAULT_BUILDER_LAYOUT;
const left = (parsed as { left?: unknown }).left;
const artboard = (parsed as { artboard?: unknown }).artboard;
const right = (parsed as { right?: unknown }).right;
if (typeof left !== "number" || typeof artboard !== "number" || typeof right !== "number")
return DEFAULT_BUILDER_LAYOUT;
return { left, artboard, right };
} catch {
return DEFAULT_BUILDER_LAYOUT;
}
};
interface BuilderSidebarState {
layout: BuilderLayout;
leftSidebar: PanelImperativeHandle | null;
rightSidebar: PanelImperativeHandle | null;
}
interface BuilderSidebarActions {
setLayout: (layout: BuilderLayout) => void;
setLeftSidebar: (ref: PanelImperativeHandle | null) => void;
setRightSidebar: (ref: PanelImperativeHandle | null) => void;
}
type BuilderSidebar = BuilderSidebarState & BuilderSidebarActions;
export const useBuilderSidebarStore = create<BuilderSidebar>((set) => ({
layout: DEFAULT_BUILDER_LAYOUT,
leftSidebar: null,
rightSidebar: null,
setLayout: (layout) => set({ layout }),
setLeftSidebar: (ref) => set({ leftSidebar: ref }),
setRightSidebar: (ref) => set({ rightSidebar: ref }),
}));
type UseBuilderSidebarReturn = {
maxSidebarSize: string | number;
minSidebarSize: number;
collapsedSidebarSize: number;
groupResizeBehavior: "preserve-pixel-size";
isCollapsed: (side: "left" | "right") => boolean;
toggleSidebar: (side: "left" | "right", forceState?: boolean) => void;
};
export function useBuilderSidebar(): UseBuilderSidebarReturn {
const isMobile = useIsMobile();
const { width } = useWindowSize();
const { maxSidebarSize, minSidebarSize, collapsedSidebarSize, expandSize, groupResizeBehavior } =
getBuilderSidebarResizeConfig({ isMobile, width });
const isCollapsed = useCallback((side: "left" | "right") => {
const sidebar =
side === "left"
? useBuilderSidebarStore.getState().leftSidebar?.current
: useBuilderSidebarStore.getState().rightSidebar?.current;
if (!sidebar) return false;
return sidebar.isCollapsed();
}, []);
const toggleSidebar = useCallback(
(side: "left" | "right", forceState?: boolean) => {
const sidebar =
side === "left"
? useBuilderSidebarStore.getState().leftSidebar?.current
: useBuilderSidebarStore.getState().rightSidebar?.current;
if (!sidebar) return;
const shouldExpand = forceState === undefined ? sidebar.isCollapsed() : forceState;
if (shouldExpand) sidebar.resize(expandSize);
else sidebar.collapse();
},
[expandSize],
);
return {
maxSidebarSize,
minSidebarSize,
collapsedSidebarSize,
groupResizeBehavior,
isCollapsed,
toggleSidebar,
};
}
export const setBuilderLayout = (data: BuilderLayout) => {
const layout = parseBuilderLayoutCookie(JSON.stringify(data));
Cookies.set(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(layout), { path: "/" });
};
export const getBuilderLayout = (): BuilderLayout => {
const layout = Cookies.get(BUILDER_LAYOUT_COOKIE_NAME);
if (!layout) return DEFAULT_BUILDER_LAYOUT;
return parseBuilderLayoutCookie(layout);
};
@@ -1,5 +1,5 @@
import { createFileRoute, lazyRouteComponent } from "@tanstack/react-router";
export const Route = createFileRoute("/builder/$resumeId/")({
component: lazyRouteComponent(() => import("./-components/preview-page"), "PreviewPage"),
component: lazyRouteComponent(() => import("./-components/page-canvas"), "PageCanvas"),
});
+14 -21
View File
@@ -1,28 +1,31 @@
import type { BuilderLayout } from "./-store/sidebar";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useEffect } from "react";
import { useIsMobile } from "@reactive-resume/ui/hooks/use-mobile";
import z from "zod";
import { useBuilderResumeUpdateSubscription, useResumeCleanup, useResumeStore } from "@/features/resume/builder/draft";
import { EDITOR_MODES } from "@/features/resume/editor/store";
import { orpc } from "@/libs/orpc/client";
import { createNoindexFollowMeta } from "@/libs/seo";
import { DesktopBuilderShell } from "./-components/desktop-builder-shell";
import { MobileBuilderShell } from "./-components/mobile-builder-shell";
import { getBuilderLayout } from "./-store/sidebar";
import { EditorShell } from "./-components/editor-shell";
const searchSchema = z.object({
// Write is the default and stays out of the URL.
mode: z.enum(EDITOR_MODES).optional().catch(undefined),
});
export const Route = createFileRoute("/builder/$resumeId")({
component: RouteComponent,
validateSearch: searchSchema,
beforeLoad: ({ context }) => {
if (!context.session) throw redirect({ to: "/auth/login", replace: true });
return { session: context.session };
},
loader: async ({ params, context }) => {
const [layout, resume] = await Promise.all([
getBuilderLayout(),
context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions({ input: { id: params.resumeId } })),
]);
const resume = await context.queryClient.ensureQueryData(
orpc.resume.getById.queryOptions({ input: { id: params.resumeId } }),
);
return { layout, name: resume.name };
return { name: resume.name };
},
head: ({ loaderData }) => ({
meta: loaderData
@@ -32,8 +35,6 @@ export const Route = createFileRoute("/builder/$resumeId")({
});
function RouteComponent() {
const { layout: initialLayout } = Route.useLoaderData();
const { resumeId } = Route.useParams();
const { data: resume } = useSuspenseQuery(orpc.resume.getById.queryOptions({ input: { id: resumeId } }));
const initializeResumeStore = useResumeStore((state) => state.initialize);
@@ -68,13 +69,5 @@ function RouteComponent() {
if (!isInitialized) return null;
return <BuilderLayoutShell initialLayout={initialLayout} />;
}
function BuilderLayoutShell({ initialLayout }: { initialLayout: BuilderLayout }) {
// Single breakpoint (below `md`) switches between the desktop resizable panels and the mobile tabbed shell.
const isMobile = useIsMobile();
if (isMobile) return <MobileBuilderShell />;
return <DesktopBuilderShell initialLayout={initialLayout} />;
return <EditorShell />;
}
-15
View File
@@ -711,9 +711,6 @@ importers:
react-window:
specifier: ^2.3.3
version: 2.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
react-zoom-pan-pinch:
specifier: ^4.2.0
version: 4.2.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
remark-gfm:
specifier: ^4.0.1
version: 4.0.1(supports-color@7.2.0)
@@ -8127,13 +8124,6 @@ packages:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react-zoom-pan-pinch@4.2.0:
resolution: {integrity: sha512-QSw1dvr6QGv5zHCUY7SEIY7EWRDbx1y6plylrWf2Nko9KOMW1MEoPC6oJ+Y/qOSeQjhvbkMV0m5bFF7pBxCH4A==}
engines: {node: '>=8', npm: '>=5'}
peerDependencies:
react: '*'
react-dom: '*'
react@19.3.0:
resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==}
engines: {node: '>=0.10.0'}
@@ -15725,11 +15715,6 @@ snapshots:
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
react-zoom-pan-pinch@4.2.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
react: 19.3.0
react-dom: 19.3.0(react@19.3.0)
react@19.3.0: {}
readable-stream@2.3.8: