From f2a76b2f694b0cbaeb8a45b05de24e509b99e893 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 28 Sep 2026 17:18:04 +0200 Subject: [PATCH] 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. --- apps/web/package.json | 1 - .../features/resume/editor/page-overlay.tsx | 82 ++++++ .../features/resume/editor/save-status.tsx | 67 +++++ apps/web/src/features/resume/editor/store.ts | 69 +++++ .../resume/export/download-dialog.tsx | 14 +- .../features/resume/export/pdf-document.tsx | 8 + .../features/resume/preview/pdf-canvas.tsx | 18 +- .../resume/preview/preview.browser.test.tsx | 14 +- .../resume/preview/preview.browser.tsx | 33 ++- .../resume/preview/preview.shared.tsx | 9 +- .../src/features/resume/stylesheet/editor.tsx | 43 +-- .../resume/stylesheet/focus-mode.test.ts | 18 -- .../features/resume/stylesheet/focus-mode.ts | 24 -- .../$resumeId/-components/ai-assistant.tsx | 21 +- .../-components/desktop-builder-shell.tsx | 108 ------- .../builder/$resumeId/-components/dock.tsx | 182 ------------ .../$resumeId/-components/document-menu.tsx | 178 +++++++++++ .../$resumeId/-components/edge.test.tsx | 49 ---- .../builder/$resumeId/-components/edge.tsx | 20 -- .../$resumeId/-components/editor-bar.tsx | 260 ++++++++++++++++ .../$resumeId/-components/editor-shell.tsx | 227 ++++++++++++++ .../builder/$resumeId/-components/header.tsx | 277 ------------------ .../-components/mobile-builder-shell.tsx | 110 ------- .../$resumeId/-components/mode-panels.tsx | 75 +++++ .../$resumeId/-components/page-canvas.tsx | 153 ++++++++++ .../$resumeId/-components/page-layout.test.ts | 24 -- .../$resumeId/-components/page-layout.ts | 6 - .../$resumeId/-components/pan-focus.test.ts | 61 ---- .../$resumeId/-components/pan-focus.ts | 20 -- .../$resumeId/-components/preview-page.tsx | 53 ---- .../$resumeId/-components/reveal-selection.ts | 54 ++++ .../$resumeId/-components/share-sheet.tsx | 45 +++ .../-components/use-editor-hotkeys.ts | 52 ++++ .../-components/use-editor-mode.test.ts | 65 ++++ .../$resumeId/-components/use-editor-mode.ts | 30 ++ .../-components/use-open-issue-count.ts | 14 + .../$resumeId/-components/version-history.tsx | 13 +- .../builder/$resumeId/-sidebar/left/index.tsx | 112 +------ .../$resumeId/-sidebar/right/index.tsx | 110 ------- .../right/sections/ats-check.test.tsx | 23 +- .../-sidebar/right/sections/ats-check.tsx | 17 +- .../-sidebar/right/shared/section-base.tsx | 19 ++ .../builder/$resumeId/-store/sidebar.test.ts | 118 -------- .../builder/$resumeId/-store/sidebar.ts | 158 ---------- .../src/routes/builder/$resumeId/index.tsx | 2 +- .../src/routes/builder/$resumeId/route.tsx | 35 +-- pnpm-lock.yaml | 15 - 47 files changed, 1544 insertions(+), 1562 deletions(-) create mode 100644 apps/web/src/features/resume/editor/page-overlay.tsx create mode 100644 apps/web/src/features/resume/editor/save-status.tsx create mode 100644 apps/web/src/features/resume/editor/store.ts delete mode 100644 apps/web/src/features/resume/stylesheet/focus-mode.test.ts delete mode 100644 apps/web/src/features/resume/stylesheet/focus-mode.ts delete mode 100644 apps/web/src/routes/builder/$resumeId/-components/desktop-builder-shell.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-components/dock.tsx create mode 100644 apps/web/src/routes/builder/$resumeId/-components/document-menu.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-components/edge.test.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-components/edge.tsx create mode 100644 apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx create mode 100644 apps/web/src/routes/builder/$resumeId/-components/editor-shell.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-components/header.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-components/mobile-builder-shell.tsx create mode 100644 apps/web/src/routes/builder/$resumeId/-components/mode-panels.tsx create mode 100644 apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-components/page-layout.test.ts delete mode 100644 apps/web/src/routes/builder/$resumeId/-components/page-layout.ts delete mode 100644 apps/web/src/routes/builder/$resumeId/-components/pan-focus.test.ts delete mode 100644 apps/web/src/routes/builder/$resumeId/-components/pan-focus.ts delete mode 100644 apps/web/src/routes/builder/$resumeId/-components/preview-page.tsx create mode 100644 apps/web/src/routes/builder/$resumeId/-components/reveal-selection.ts create mode 100644 apps/web/src/routes/builder/$resumeId/-components/share-sheet.tsx create mode 100644 apps/web/src/routes/builder/$resumeId/-components/use-editor-hotkeys.ts create mode 100644 apps/web/src/routes/builder/$resumeId/-components/use-editor-mode.test.ts create mode 100644 apps/web/src/routes/builder/$resumeId/-components/use-editor-mode.ts create mode 100644 apps/web/src/routes/builder/$resumeId/-components/use-open-issue-count.ts delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/right/index.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-store/sidebar.test.ts delete mode 100644 apps/web/src/routes/builder/$resumeId/-store/sidebar.ts diff --git a/apps/web/package.json b/apps/web/package.json index d6a44c1bc..df460b367 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/features/resume/editor/page-overlay.tsx b/apps/web/src/features/resume/editor/page-overlay.tsx new file mode 100644 index 000000000..e4225a35e --- /dev/null +++ b/apps/web/src/features/resume/editor/page-overlay.tsx @@ -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) => { + const target = (event.target as HTMLElement).closest("[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. + + ); +} diff --git a/apps/web/src/features/resume/editor/save-status.tsx b/apps/web/src/features/resume/editor/save-status.tsx new file mode 100644 index 000000000..7d26c934f --- /dev/null +++ b/apps/web/src/features/resume/editor/save-status.tsx @@ -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 ( + + {status === "saving" && ( + <> + + Saving… + + )} + {(status === "saved" || status === "idle") && ( + <> + + Saved + + )} + {status === "offline" && ( + + + + Offline · saved on this device + + + )} + {status === "error" && ( + + + Not saved + + + + )} + + ); +} + +/** 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 ( + + + + You can keep editing. Changes sync when you're back online. Download and Share need a connection. + + + ); +} diff --git a/apps/web/src/features/resume/editor/store.ts b/apps/web/src/features/resume/editor/store.ts new file mode 100644 index 000000000..8693e061c --- /dev/null +++ b/apps/web/src/features/resume/editor/store.ts @@ -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()((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), +})); diff --git a/apps/web/src/features/resume/export/download-dialog.tsx b/apps/web/src/features/resume/export/download-dialog.tsx index 7add1e54c..8e0f13637 100644 --- a/apps/web/src/features/resume/export/download-dialog.tsx +++ b/apps/web/src/features/resume/export/download-dialog.tsx @@ -30,7 +30,11 @@ type DownloadableResume = Parameters[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("resume"); const [includeCoverLetterHeader, setIncludeCoverLetterHeader] = useState(false); const includeHeaderSwitchId = useId(); @@ -81,7 +87,7 @@ export function ResumeDownloadDialog({ resume, trigger }: ResumeDownloadDialogPr return ( - + {trigger && } diff --git a/apps/web/src/features/resume/export/pdf-document.tsx b/apps/web/src/features/resume/export/pdf-document.tsx index 195a7c280..89cc0d14d 100644 --- a/apps/web/src/features/resume/export/pdf-document.tsx +++ b/apps/web/src/features/resume/export/pdf-document.tsx @@ -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, }); }; diff --git a/apps/web/src/features/resume/preview/pdf-canvas.tsx b/apps/web/src/features/resume/preview/pdf-canvas.tsx index 18b38ad20..c5ce3ae2c 100644 --- a/apps/web/src/features/resume/preview/pdf-canvas.tsx +++ b/apps/web/src/features/resume/preview/pdf-canvas.tsx @@ -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 (
- {showPageNumbers ? ( -
- Page {pageNumber} of {totalPages} -
- ) : null} + {caption ?? + (showPageNumbers ? ( +
+ Page {pageNumber} of {totalPages} +
+ ) : null)} -
+
+ {overlay}
); diff --git a/apps/web/src/features/resume/preview/preview.browser.test.tsx b/apps/web/src/features/resume/preview/preview.browser.test.tsx index 5c76f9ff8..4a18aa3ee 100644 --- a/apps/web/src/features/resume/preview/preview.browser.test.tsx +++ b/apps/web/src/features/resume/preview/preview.browser.test.tsx @@ -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(); 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); }); diff --git a/apps/web/src/features/resume/preview/preview.browser.tsx b/apps/web/src/features/resume/preview/preview.browser.tsx index 5347c7ba6..457af3192 100644 --- a/apps/web/src/features/resume/preview/preview.browser.tsx +++ b/apps/web/src/features/resume/preview/preview.browser.tsx @@ -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; 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), diff --git a/apps/web/src/features/resume/preview/preview.shared.tsx b/apps/web/src/features/resume/preview/preview.shared.tsx index f76612420..3e08a4d9e 100644 --- a/apps/web/src/features/resume/preview/preview.shared.tsx +++ b/apps/web/src/features/resume/preview/preview.shared.tsx @@ -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 & { diff --git a/apps/web/src/features/resume/stylesheet/editor.tsx b/apps/web/src/features/resume/stylesheet/editor.tsx index 53253dc68..3495fdb99 100644 --- a/apps/web/src/features/resume/stylesheet/editor.tsx +++ b/apps/web/src/features/resume/stylesheet/editor.tsx @@ -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([]); const [colorTokens, setColorTokens] = useState([]); const [status, setStatus] = useState<"idle" | "compiling" | "error">("compiling"); const [compiler, setCompiler] = useState>(); - 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 = (

-
{editor}
+
{editor}
@@ -572,13 +541,13 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps) return (
- {!(isMobile && focusOpen) && editorChrome} - + {!focusOpen && editorChrome} + Semantic CSS stylesheet -
{isMobile && focusOpen ? editorChrome : null}
+
{focusOpen ? editorChrome : null}
diff --git a/apps/web/src/features/resume/stylesheet/focus-mode.test.ts b/apps/web/src/features/resume/stylesheet/focus-mode.test.ts deleted file mode 100644 index d9e6fe566..000000000 --- a/apps/web/src/features/resume/stylesheet/focus-mode.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/web/src/features/resume/stylesheet/focus-mode.ts b/apps/web/src/features/resume/stylesheet/focus-mode.ts deleted file mode 100644 index f05db84e7..000000000 --- a/apps/web/src/features/resume/stylesheet/focus-mode.ts +++ /dev/null @@ -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); - }; -} diff --git a/apps/web/src/routes/builder/$resumeId/-components/ai-assistant.tsx b/apps/web/src/routes/builder/$resumeId/-components/ai-assistant.tsx index 53072bc34..5fa975622 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/ai-assistant.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/ai-assistant.tsx @@ -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 ( - + className={cn("text-ink-2", open && "bg-accent-soft text-accent-text hover:bg-accent-soft")} + onClick={() => setOpen(!open)} + /> 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 ( -
- - Skip to main content - - - - - - - - - - -
- -
-
- - - - -
-
- ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-components/dock.tsx b/apps/web/src/routes/builder/$resumeId/-components/dock.tsx deleted file mode 100644 index 15c852f54..000000000 --- a/apps/web/src/routes/builder/$resumeId/-components/dock.tsx +++ /dev/null @@ -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 ( -
- - undo()} /> - redo()} /> -
- zoomOut(0.15, zoomDuration, "easeOutCubic")} - /> - - zoomIn(0.15, zoomDuration, "easeOutCubic")} - /> - - { - if (!resumeId) return; - void navigate({ to: "/agent/new", search: { resumeId } }); - }} - /> -
- { - await copyToClipboard(publicUrl); - toast.add({ type: "success", description: t`Resume link copied to clipboard.` }); - }} - /> - -
- ); -} - -function ZoomMenu() { - const scale = useTransformComponent((ctx) => ctx.state.scale); - const { centerView, resetTransform } = useControls(); - const zoomDuration = useReducedMotion() ? 0 : 200; - - return ( - - - {Math.round(scale * 100)}% - - } - /> - - - centerView(1, zoomDuration, "easeOutCubic")}> - Actual size (100%) - - resetTransform(zoomDuration, "easeOutCubic")}> - Fit to view - - - - ); -} - -type DockIconProps = { - title: string; - icon: Icon; - disabled?: boolean; - onClick: () => void; -}; - -function DockIcon({ icon: Icon, title, disabled, onClick }: DockIconProps) { - return ( - - {/* A disabled button ignores the pointer, so a wrapper keeps the tooltip working for Undo/Redo. */} - }> - - - - - {title} - - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-components/document-menu.tsx b/apps/web/src/routes/builder/$resumeId/-components/document-menu.tsx new file mode 100644 index 000000000..b1ea46967 --- /dev/null +++ b/apps/web/src/routes/builder/$resumeId/-components/document-menu.tsx @@ -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(null); + + return ( + <> + + + + {name} + {isLocked && } + + + + } + /> + + + + + !open && setDialog(null)}> + + + + Notes + + + + + + + + + !open && setDialog(null)}> + + + + Information + + + + + + + + + ); +} + +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 ( + + openDialog("resume.update", { id, name, slug, tags })}> + + Rename… + + openDialog("resume.duplicate", { id, name, slug, tags, shouldRedirect: true })}> + + Duplicate + + + + {isLocked ? Unlock editing : Lock editing} + + + onOpenDialog("notes")}> + + Notes + + onOpenDialog("information")}> + + Information + + void onPrint()}> + + Print + + + + + Delete… + + + ); +} diff --git a/apps/web/src/routes/builder/$resumeId/-components/edge.test.tsx b/apps/web/src/routes/builder/$resumeId/-components/edge.test.tsx deleted file mode 100644 index f935a7feb..000000000 --- a/apps/web/src/routes/builder/$resumeId/-components/edge.test.tsx +++ /dev/null @@ -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( - - child - , - ); - expect(getByText("child")).toBeInTheDocument(); - }); - - it("uses left-side classes (inset-s-0 + border-r) when side='left'", () => { - const { container } = render( - - x - , - ); - 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( - - x - , - ); - 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( - - x - , - ); - const wrapper = container.firstChild as HTMLElement; - expect(wrapper.className).toContain("hidden"); - expect(wrapper.className).toContain("md:flex"); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-components/edge.tsx b/apps/web/src/routes/builder/$resumeId/-components/edge.tsx deleted file mode 100644 index 76cd5cf77..000000000 --- a/apps/web/src/routes/builder/$resumeId/-components/edge.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx b/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx new file mode 100644 index 000000000..0da097210 --- /dev/null +++ b/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx @@ -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 ( +
+
+ + {layout === "tablet" && } +
+ +
+
+ + {layout === "mobile" ? : } + +
+ {layout === "desktop" && ( + <> + + + + + + + )} + + +
+
+ ); +} + +/** Leaving the editor is navigation, so it's a link styled as an icon button. */ +function BackLink() { + const label = t`Back to documents`; + + return ( + + + } + > + + + {label} + + ); +} + +const MODE_ICONS = { write: "edit", design: "palette", check: "fact_check" } as const; + +function ModeTabs() { + const issueCount = useOpenIssueCount(); + const labels: Record = { write: t`Write`, design: t`Design`, check: t`Check` }; + + return ( + + {(Object.keys(MODE_ICONS) as EditorMode[]).map((mode) => ( + + + {labels[mode]} + {mode === "check" && } + + ))} + + ); +} + +function CheckBadge({ count }: { count: number }) { + if (count === 0) { + return ( + + + + No open issues + + + ); + } + + return ( + + {count} + + open issues + + + ); +} + +function UndoButton() { + const canUndo = useResumeStore((state) => state.canUndo); + const undo = useResumeStore((state) => state.undo); + + return ( + + ); +} + +/** 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 && ( + setOpen(!open)} + /> + )} + {pinnable && (open || pinned) && ( + { + // 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 ? ( +
); } - -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((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 ( - -
-
-
- {railSections.map(({ key, section, target }) => ( - - scrollToSection(section, target)} - > - {getSectionIcon(section)} - - } - /> - - {getSectionTitle(section)} - - - ))} -
-
- - - {({ session }) => ( - - )} - -
-
- ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/index.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/index.tsx deleted file mode 100644 index 5f4a538d9..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/index.tsx +++ /dev/null @@ -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", () => ) - .with("layout", () => ) - .with("typography", () => ) - .with("design", () => ) - .with("styles", () => ) - .with("page", () => ) - .with("notes", () => ) - .with("sharing", () => ) - .with("statistics", () => ) - .with("ats", () => ) - .with("export", () => ) - .with("information", () => ) - .exhaustive(); -} - -export function BuilderSidebarRight() { - const scrollAreaRef = useRef(null); - - return ( - <> - - - -
- {rightSidebarSections.map((section) => ( - - {getSectionComponent(section)} - - - ))} - - -
-
- - ); -} - -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 ( - -
-
- {rightSidebarSections.map((section) => ( - - scrollToSection(section)} - > - {getSectionIcon(section)} - - } - /> - - {getSectionTitle(section)} - - - ))} -
-
-
- ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.test.tsx index b9946a91c..0c3545e6b 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.test.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.test.tsx @@ -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; diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.tsx index 3ce63f5ac..6a8a92c94 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.tsx @@ -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; diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/shared/section-base.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/shared/section-base.tsx index f97309b6e..a8e62011e 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/shared/section-base.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/shared/section-base.tsx @@ -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 & { 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 }) => ( + {children} +); + 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 ( +
+ {props.children as React.ReactNode} +
+ ); + } + return ( { - 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(); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-store/sidebar.ts b/apps/web/src/routes/builder/$resumeId/-store/sidebar.ts deleted file mode 100644 index 1db973df5..000000000 --- a/apps/web/src/routes/builder/$resumeId/-store/sidebar.ts +++ /dev/null @@ -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; - -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((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); -}; diff --git a/apps/web/src/routes/builder/$resumeId/index.tsx b/apps/web/src/routes/builder/$resumeId/index.tsx index 2b157ea64..f2d7db23e 100644 --- a/apps/web/src/routes/builder/$resumeId/index.tsx +++ b/apps/web/src/routes/builder/$resumeId/index.tsx @@ -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"), }); diff --git a/apps/web/src/routes/builder/$resumeId/route.tsx b/apps/web/src/routes/builder/$resumeId/route.tsx index bf24b1081..caa70a114 100644 --- a/apps/web/src/routes/builder/$resumeId/route.tsx +++ b/apps/web/src/routes/builder/$resumeId/route.tsx @@ -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 ; -} - -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 ; - return ; + return ; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4189a76a2..c82ee2721 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: