mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-02 01:34:26 +10:00
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:
@@ -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"),
|
||||
});
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
Generated
-15
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user