fix(web): address React Doctor findings — compiler, purity, query, component structure

prefer-module-scope-pure-function: hoist buildSubtitle, getDecimalPlaces,
handleLocaleChange, onLocaleChange, stop, listContent/groupedListContent to
module scope so they aren't rebuilt on every render.

react-compiler-todo (??=): rewrite draft.metadata.styleRules ??= [] to the
non-assignment form to unblock auto-memoization.

set-state-in-effect: derive updatedAtLabel at render time instead of syncing
it through useState + useEffect.

query-destructure-result: destructure useQuery results at call site in
resume-analysis and resume-thumbnail to follow TanStack Query v5 convention.

only-export-components: extract non-component exports to sibling .ts files so
Fast Refresh can preserve component state:
  - getNextWeights → typography/get-next-weights.ts
  - detectJsonImportType + ImportType → dialogs/resume/import.utils.ts
  - getLocaleOptions → features/locale/locale-options.tsx
  - preview helpers + DEFAULT_PDF_PAGE_SIZE → preview.shared.utils.ts
  - resolveHighlightToolbarState + defaultHighlightColor → rich-input.utils.ts
  - computeDelta + getSparklinePoints → statistics.utils.ts

no-multi-comp: split multi-component files into focused companions:
  - ResumePane + ToolbarButton → routes/agent/-components/resume-pane.tsx
  - DesktopBuilderShell → builder/$resumeId/-components/desktop-builder-shell.tsx
  - MobileBuilderShell + helpers → builder/$resumeId/-components/mobile-builder-shell.tsx
  - setBuilderLayout/getBuilderLayout moved to -store/sidebar.ts

fix(tests): add Resume type import to section-builder mocks and cast partial
mock data as unknown as Resume to satisfy stricter type checking; fix
noExplicitAny Biome errors in the same mocks.
This commit is contained in:
Amruth Pillai
2026-07-05 19:47:11 +02:00
parent a27425e350
commit 5a1cc6585c
45 changed files with 770 additions and 678 deletions
@@ -11,6 +11,15 @@ type CountUpProps = {
"aria-atomic"?: boolean | "true" | "false";
};
const getDecimalPlaces = (num: number): number => {
const str = num.toString();
if (str.includes(".")) {
const decimals = str.split(".")[1];
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
}
return 0;
};
// ponytail: from/direction/delay/startWhen/onStart/onEnd removed — no production caller passes them
export function CountUp({
to,
@@ -31,15 +40,6 @@ export function CountUp({
const isInView = useInView(ref, { once: true, margin: "0px" });
const getDecimalPlaces = (num: number): number => {
const str = num.toString();
if (str.includes(".")) {
const decimals = str.split(".")[1];
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
}
return 0;
};
const maxDecimals = getDecimalPlaces(to);
const formatValue = useCallback(
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveHighlightToolbarState } from "./rich-input";
import { resolveHighlightToolbarState } from "./rich-input.utils";
describe("resolveHighlightToolbarState", () => {
it("shows legacy colorless highlights as default yellow and clearable", () => {
+1 -7
View File
@@ -57,9 +57,9 @@ import { cn } from "@reactive-resume/utils/style";
import { usePrompt } from "@/hooks/use-prompt";
import { isRTL } from "@/libs/locale";
import { ColorPicker } from "./color-picker";
import { defaultHighlightColor, resolveHighlightToolbarState } from "./rich-input.utils";
const defaultTextColor = "rgba(0, 0, 0, 1)";
const defaultHighlightColor = "rgba(255, 255, 0, 1)";
const extensions = [
StarterKit.configure({
@@ -351,12 +351,6 @@ function useEditorToolbarState(editor: Editor) {
type EditorToolbarState = ReturnType<typeof useEditorToolbarState>;
export function resolveHighlightToolbarState(isHighlight: boolean, highlightColor: string | null) {
const visibleHighlightColor = highlightColor ?? (isHighlight ? defaultHighlightColor : undefined);
return { visibleHighlightColor, canClearHighlight: isHighlight };
}
type EditorToolbarProps = {
editor: Editor;
isFullscreen: boolean;
@@ -0,0 +1,7 @@
export const defaultHighlightColor = "rgba(255, 255, 0, 1)";
export function resolveHighlightToolbarState(isHighlight: boolean, highlightColor: string | null) {
const visibleHighlightColor = highlightColor ?? (isHighlight ? defaultHighlightColor : undefined);
return { visibleHighlightColor, canClearHighlight: isHighlight };
}
@@ -5,36 +5,6 @@ import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { FontDisplay } from "./font-display";
type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
export function getNextWeights(fontFamily: string): Weight[] | null {
const fontData = getFont(fontFamily);
if (!fontData || !Array.isArray(fontData.weights) || fontData.weights.length === 0) return null;
const uniqueWeights = Array.from(new Set(fontData.weights)) as Weight[];
// Try to pick 400 and 600 if available
const weights: Weight[] = [];
if (uniqueWeights.includes("400")) weights.push("400");
if (uniqueWeights.includes("600")) weights.push("600");
const selectedWeights = new Set(weights);
// If we didn't find both, fill in with first/last, ensuring uniqueness
while (weights.length < 2 && uniqueWeights.length > 0) {
// candidateIndex: 0 (first), 1 (last)
const lastIndex = uniqueWeights.length - 1;
const candidate = weights.length === 0 ? uniqueWeights[0] : uniqueWeights[lastIndex];
if (!selectedWeights.has(candidate)) {
weights.push(candidate);
selectedWeights.add(candidate);
} else break;
}
return weights.length > 0 ? weights : null;
}
type FontFamilyComboboxProps = Omit<SingleComboboxProps, "options">;
export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxProps) {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getNextWeights } from "./combobox";
import { getNextWeights } from "./get-next-weights";
describe("getNextWeights", () => {
it("returns 400 and 600 when both are available (the preferred default)", () => {
@@ -0,0 +1,31 @@
import { getFont } from "@reactive-resume/fonts";
type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
export function getNextWeights(fontFamily: string): Weight[] | null {
const fontData = getFont(fontFamily);
if (!fontData || !Array.isArray(fontData.weights) || fontData.weights.length === 0) return null;
const uniqueWeights = Array.from(new Set(fontData.weights)) as Weight[];
// Try to pick 400 and 600 if available
const weights: Weight[] = [];
if (uniqueWeights.includes("400")) weights.push("400");
if (uniqueWeights.includes("600")) weights.push("600");
const selectedWeights = new Set(weights);
// If we didn't find both, fill in with first/last, ensuring uniqueness
while (weights.length < 2 && uniqueWeights.length > 0) {
// candidateIndex: 0 (first), 1 (last)
const lastIndex = uniqueWeights.length - 1;
const candidate = weights.length === 0 ? uniqueWeights[0] : uniqueWeights[lastIndex];
if (!selectedWeights.has(candidate)) {
weights.push(candidate);
selectedWeights.add(candidate);
} else break;
}
return weights.length > 0 ? weights : null;
}
+13 -13
View File
@@ -79,6 +79,19 @@ type MultiComboboxProps<TValue extends string | number = string> = {
type ComboboxProps<TValue extends string | number = string> = SingleComboboxProps<TValue> | MultiComboboxProps<TValue>;
const listContent = <TValue extends string | number>(item: ComboboxOption<TValue>) => (
<ComboboxItem key={String(item.value)} value={item} disabled={item.disabled}>
{item.label}
</ComboboxItem>
);
const groupedListContent = <TValue extends string | number>(group: GroupedComboboxOption<TValue>) => (
<ComboboxGroup key={group.key} items={group.items}>
{group.label !== null && group.label !== undefined ? <ComboboxLabel>{group.label}</ComboboxLabel> : null}
<ComboboxCollection>{listContent}</ComboboxCollection>
</ComboboxGroup>
);
function Combobox<TValue extends string | number = string>(props: ComboboxProps<TValue>) {
const {
options,
@@ -203,19 +216,6 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
? Array.isArray(selectedValue) && selectedValue.length > 0
: selectedValue !== null && selectedValue !== undefined;
const listContent = (item: ComboboxOption<TValue>) => (
<ComboboxItem key={String(item.value)} value={item} disabled={item.disabled}>
{item.label}
</ComboboxItem>
);
const groupedListContent = (group: GroupedComboboxOption<TValue>) => (
<ComboboxGroup key={group.key} items={group.items}>
{group.label !== null && group.label !== undefined ? <ComboboxLabel>{group.label}</ComboboxLabel> : null}
<ComboboxCollection>{listContent}</ComboboxCollection>
</ComboboxGroup>
);
const triggerNode = (
<ComboboxTrigger
id={id}
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { detectJsonImportType } from "./import";
import { detectJsonImportType } from "./import.utils";
describe("detectJsonImportType", () => {
it("detects JSON Resume by a top-level basics without Reactive Resume sections/metadata", () => {
+2 -21
View File
@@ -1,5 +1,6 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { DialogProps } from "../store";
import type { ImportType } from "./import.utils";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react";
@@ -31,6 +32,7 @@ import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
import { useAppForm } from "@/libs/tanstack-form";
import { useDialogStore } from "../store";
import { detectJsonImportType } from "./import.utils";
const formSchema = z.discriminatedUnion("type", [
z.object({
@@ -72,9 +74,6 @@ const formSchema = z.discriminatedUnion("type", [
}),
]);
type FormValues = z.infer<typeof formSchema>;
type ImportType = FormValues["type"];
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@@ -120,24 +119,6 @@ async function detectImportType(file: File): Promise<ImportType> {
return "";
}
export function detectJsonImportType(parsed: unknown): ImportType {
if (!parsed || typeof parsed !== "object") return "";
const data = parsed as Record<string, unknown>;
// JSON Resume standard: top-level `basics`, without Reactive Resume's `sections`/`metadata`.
if ("basics" in data && !("sections" in data) && !("metadata" in data)) return "json-resume-json";
// Reactive Resume exports carry `sections` + `metadata`; the current schema's metadata has a `page` key,
// the legacy v4 schema does not. Best-effort guess — the user can override the type below.
if ("sections" in data || "metadata" in data) {
const metadata = data.metadata as Record<string, unknown> | undefined;
if (metadata && !("page" in metadata)) return "reactive-resume-v4-json";
return "reactive-resume-json";
}
return "";
}
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const navigate = useNavigate();
const closeDialog = useDialogStore((state) => state.closeDialog);
@@ -0,0 +1,19 @@
export type ImportType = "" | "pdf" | "docx" | "reactive-resume-json" | "reactive-resume-v4-json" | "json-resume-json";
export function detectJsonImportType(parsed: unknown): ImportType {
if (!parsed || typeof parsed !== "object") return "";
const data = parsed as Record<string, unknown>;
// JSON Resume standard: top-level `basics`, without Reactive Resume's `sections`/`metadata`.
if ("basics" in data && !("sections" in data) && !("metadata" in data)) return "json-resume-json";
// Reactive Resume exports carry `sections` + `metadata`; the current schema's metadata has a `page` key,
// the legacy v4 schema does not. Best-effort guess — the user can override the type below.
if ("sections" in data || "metadata" in data) {
const metadata = data.metadata as Record<string, unknown> | undefined;
if (metadata && !("page" in metadata)) return "reactive-resume-v4-json";
return "reactive-resume-json";
}
return "";
}
@@ -24,6 +24,7 @@ import {
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { cn } from "@reactive-resume/utils/style";
import { useConfirm } from "@/hooks/use-confirm";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
@@ -35,9 +36,15 @@ type Props = {
className?: string;
};
// Stop pointer/click from reaching the card (which would start a drag or open the detail panel).
// React portals bubble synthetic events through the React tree, so a menu-item click would
// otherwise reach the card's onClick even though the menu is portaled in the DOM.
const stop = (event: React.SyntheticEvent) => event.stopPropagation();
// Shared kebab menu for board cards and table rows: edit, move stage, archive, delete.
export function ApplicationActionsMenu({ application, onEdit, showOnHover, className }: Props) {
const queryClient = useQueryClient();
const confirm = useConfirm();
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
@@ -62,8 +69,13 @@ export function ApplicationActionsMenu({ application, onEdit, showOnHover, class
}),
);
// Stop pointer/click from reaching the card (which would start a drag or open the detail panel).
const stop = (event: React.SyntheticEvent) => event.stopPropagation();
const onDelete = async () => {
const confirmed = await confirm(t`Delete this application?`, {
description: t`"${application.role} · ${application.company}" and its full timeline will be permanently deleted. This can't be undone.`,
confirmText: t`Delete`,
});
if (confirmed) remove.mutate({ id: application.id });
};
return (
<div className={cn("shrink-0", className)}>
@@ -86,7 +98,7 @@ export function ApplicationActionsMenu({ application, onEdit, showOnHover, class
</Button>
}
/>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuContent align="end" className="w-44" onClick={stop}>
<DropdownMenuItem onClick={() => onEdit(application)}>
<PencilSimpleIcon />
<Trans>Edit</Trans>
@@ -118,7 +130,7 @@ export function ApplicationActionsMenu({ application, onEdit, showOnHover, class
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => remove.mutate({ id: application.id })}>
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<TrashIcon />
<Trans>Delete</Trans>
</DropdownMenuItem>
@@ -21,6 +21,7 @@ import { getInitials } from "@reactive-resume/utils/string";
import { cn } from "@reactive-resume/utils/style";
import { orpc } from "@/libs/orpc/client";
import { applicationsListQueryKey } from "../queries";
import { tileColor } from "../tile-color";
import { ApplicationActionsMenu } from "./application-actions-menu";
const PAGE_SIZE = 25;
@@ -167,7 +168,8 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
</div>
)}
<div className="min-h-0 flex-1 overflow-auto rounded-xl border border-border">
{/* Desktop: full table. Mobile: a stacked card list (below) instead of a 900px h-scroll. */}
<div className="min-h-0 flex-1 overflow-auto rounded-xl border border-border max-sm:hidden">
<table className="w-full min-w-[900px] border-collapse text-sm">
<thead className="sticky top-0 z-10 bg-muted/50 backdrop-blur">
<tr className="[&>th]:whitespace-nowrap [&>th]:px-3 [&>th]:py-2.5 [&>th]:text-left [&>th]:font-medium [&>th]:text-muted-foreground [&>th]:text-xs [&>th]:uppercase [&>th]:tracking-wide">
@@ -198,9 +200,13 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
<Trans>Source</Trans>
</th>
<th>
<Trans>Last activity</Trans>
<Trans>Applied</Trans>
</th>
<th className="w-10">
<span className="sr-only">
<Trans>Actions</Trans>
</span>
</th>
<th className="w-10" />
</tr>
</thead>
<tbody>
@@ -223,7 +229,12 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
</td>
<td className="px-3 py-2">
<button type="button" className="flex items-center gap-2.5 text-left" onClick={() => onOpen(app)}>
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted font-bold text-[10px]">
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md font-bold text-[10px] text-white",
tileColor(app.company),
)}
>
{getInitials(app.company)}
</span>
<div className="min-w-0">
@@ -254,7 +265,7 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
</td>
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">{app.source || "—"}</td>
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">
{new Date(app.updatedAt).toLocaleDateString()}
{new Date(app.appliedAt).toLocaleDateString()}
</td>
<td className="px-1 py-2">
<ApplicationActionsMenu application={app} onEdit={onEdit} />
@@ -266,6 +277,52 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
</table>
</div>
{/* Mobile: stacked cards (same paginated rows), tap to open. */}
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-auto sm:hidden">
{rows.map((app) => {
const stage = stageOf(app.status);
return (
<div
key={app.id}
className={cn(
"flex items-center gap-3 rounded-xl border border-border p-3",
selected.has(app.id) && "bg-primary/5",
)}
>
<Checkbox
checked={selected.has(app.id)}
onCheckedChange={() => toggleOne(app.id)}
aria-label={t`Select ${app.company}`}
/>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
onClick={() => onOpen(app)}
>
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-md font-bold text-[10px] text-white",
tileColor(app.company),
)}
>
{getInitials(app.company)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm">{app.role}</div>
<div className="truncate text-muted-foreground text-xs">{app.company}</div>
<div className="mt-1 flex items-center gap-1.5 text-xs">
<span className="size-2 shrink-0 rounded-sm" style={{ background: stage?.color }} />
<span className="text-muted-foreground">{stage?.label ?? app.status}</span>
{app.salary && <span className="truncate font-medium">· {app.salary}</span>}
</div>
</div>
</button>
<ApplicationActionsMenu application={app} onEdit={onEdit} />
</div>
);
})}
</div>
<div className="flex items-center gap-3 text-muted-foreground text-sm">
<span>
<Trans>
@@ -4,16 +4,16 @@ import { CommandItem } from "@reactive-resume/ui/components/command";
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
import { BaseCommandGroup } from "../base";
const handleLocaleChange = async (value: string) => {
if (!value || !isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
export function LanguageCommandPage() {
const { i18n } = useLingui();
const handleLocaleChange = async (value: string) => {
if (!value || !isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
return (
<BaseCommandGroup page="language" heading={<Trans>Language</Trans>}>
{Object.entries(localeMap).map(([value, label]) => (
@@ -3,7 +3,7 @@
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { localeMap } from "@/libs/locale";
import { getLocaleOptions } from "./combobox";
import { getLocaleOptions } from "./locale-options";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
+7 -29
View File
@@ -1,43 +1,21 @@
import type { Locale } from "@reactive-resume/utils/locale";
import type { SingleComboboxProps } from "@/components/ui/combobox";
import { i18n } from "@lingui/core";
import { useLingui } from "@lingui/react";
import { Combobox } from "@/components/ui/combobox";
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
import { isLocale, loadLocale, setLocaleCookie } from "@/libs/locale";
import { getLocaleOptions } from "./locale-options";
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
export const getLocaleOptions = () => {
return Object.entries(localeMap).map(([value, label]) => {
const name = i18n.t(label);
return {
value: value as Locale,
label: (
<span className="flex items-center gap-x-2">
<span className="font-mono text-muted-foreground text-xs">{value}</span>
{name}
</span>
),
// Shown in the collapsed trigger (a ReactNode label would otherwise fall back to the ISO code).
textValue: name,
// Match against the translated name, the ISO code, and the untranslated English name so
// the list stays searchable regardless of the active UI locale.
keywords: [name, value.toLowerCase(), label.message].filter((keyword): keyword is string => Boolean(keyword)),
};
});
const onLocaleChange = async (value: string | null) => {
if (!value || !isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
export function LocaleCombobox(props: Props) {
const { i18n } = useLingui();
const onLocaleChange = async (value: string | null) => {
if (!value || !isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
return (
<Combobox
showClear={false}
@@ -0,0 +1,24 @@
import type { Locale } from "@reactive-resume/utils/locale";
import { i18n } from "@lingui/core";
import { localeMap } from "@/libs/locale";
export const getLocaleOptions = () => {
return Object.entries(localeMap).map(([value, label]) => {
const name = i18n.t(label);
return {
value: value as Locale,
label: (
<span className="flex items-center gap-x-2">
<span className="font-mono text-muted-foreground text-xs">{value}</span>
{name}
</span>
),
// Shown in the collapsed trigger (a ReactNode label would otherwise fall back to the ISO code).
textValue: name,
// Match against the translated name, the ISO code, and the untranslated English name so
// the list stays searchable regardless of the active UI locale.
keywords: [name, value.toLowerCase(), label.message].filter((keyword): keyword is string => Boolean(keyword)),
};
});
};
@@ -1,6 +1,6 @@
import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from "pdfjs-dist/legacy/build/pdf.mjs";
import type { ReactNode } from "react";
import type { PreviewPageSize } from "./preview.shared";
import type { PreviewPageSize } from "./preview.shared.utils";
import {
AnnotationMode,
GlobalWorkerOptions,
@@ -9,7 +9,7 @@ import {
} from "pdfjs-dist/legacy/build/pdf.mjs";
import { useEffect, useRef, useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import { DEFAULT_PDF_PAGE_SIZE, getPreviewCanvasScale, getScaledPreviewPageSize } from "./preview.shared";
import { DEFAULT_PDF_PAGE_SIZE, getPreviewCanvasScale, getScaledPreviewPageSize } from "./preview.shared.utils";
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
@@ -1,4 +1,4 @@
import type { PreviewPageSize } from "./preview.shared";
import type { PreviewPageSize } from "./preview.shared.utils";
import { getResumeThumbnailRenderSize, RESUME_THUMBNAIL_TARGET_WIDTH } from "./resume-thumbnail.shared";
const canvasToBlob = async (canvas: HTMLCanvasElement) => {
@@ -1,5 +1,6 @@
import type { CSSProperties } from "react";
import type { PreviewPageSize, ResolvedResumePreviewProps } from "./preview.shared";
import type { ResolvedResumePreviewProps } from "./preview.shared";
import type { PreviewPageSize } from "./preview.shared.utils";
import { AnimatePresence, m } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { isRTL } from "@reactive-resume/utils/locale";
@@ -7,7 +8,8 @@ import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { usePreviewPausedStore, useResumeData } from "../builder/draft";
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
import { getResumePreviewGapValue, getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
import { ResumePreviewLoader } from "./preview.shared";
import { getResumePreviewGapValue, getResumePreviewPageCount } from "./preview.shared.utils";
import { ResumeAccessibleText } from "./resume-accessible-text";
type PreviewPdf = {
@@ -6,7 +6,7 @@ import {
getPreviewCanvasScale,
getResumePreviewGapValue,
getScaledPreviewPageSize,
} from "./preview.shared";
} from "./preview.shared.utils";
describe("getScaledPreviewPageSize", () => {
it("multiplies both dimensions by the scale", () => {
@@ -3,12 +3,8 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import {
DEFAULT_PDF_PAGE_SIZE,
getResumePreviewPageCount,
getScaledPreviewPageSize,
ResumePreviewLoader,
} from "./preview.shared";
import { ResumePreviewLoader } from "./preview.shared";
import { DEFAULT_PDF_PAGE_SIZE, getResumePreviewPageCount, getScaledPreviewPageSize } from "./preview.shared.utils";
describe("ResumePreviewLoader", () => {
it("uses the same scaled page dimensions as the preview page", () => {
@@ -2,6 +2,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { CSSProperties } 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";
export type ResumePreviewProps = {
className?: string;
@@ -19,11 +20,6 @@ export type ResolvedResumePreviewProps = ResumePreviewProps & {
showPageNumbers: boolean;
};
export type PreviewPageSize = {
height: number;
width: number;
};
type ResumePreviewLoaderProps = Pick<ResumePreviewProps, "pageClassName" | "showPageNumbers"> & {
pageCount?: number;
pageGap?: CSSProperties["gap"];
@@ -31,35 +27,8 @@ type ResumePreviewLoaderProps = Pick<ResumePreviewProps, "pageClassName" | "show
pageScale?: number;
};
const PDF_PAGE_RENDER_SCALE = 4;
const MAX_PREVIEW_CANVAS_PIXELS = 16_777_216; // 4096 * 4096
export const DEFAULT_PDF_PAGE_SIZE: PreviewPageSize = {
height: 841.89,
width: 595.28,
};
// ponytail: normalizeResumePreviewProps deleted — defaults now live in ResumePreview destructuring
export const getPreviewCanvasScale = (width: number, height: number) => {
const devicePixelRatio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
const desiredScale = Math.max(PDF_PAGE_RENDER_SCALE, devicePixelRatio);
const desiredPixels = width * height * desiredScale * desiredScale;
if (desiredPixels <= MAX_PREVIEW_CANVAS_PIXELS) return desiredScale;
return Math.sqrt(MAX_PREVIEW_CANVAS_PIXELS / (width * height));
};
export const getScaledPreviewPageSize = (pageSize: PreviewPageSize, pageScale: number): PreviewPageSize => ({
height: pageSize.height * pageScale,
width: pageSize.width * pageScale,
});
export const getResumePreviewGapValue = (pageGap: CSSProperties["gap"]) =>
typeof pageGap === "number" && pageGap !== 0 ? `${pageGap}px` : pageGap;
export const getResumePreviewPageCount = (data?: ResumeData) => Math.max(1, data?.metadata.layout.pages.length ?? 1);
export function ResumePreviewLoader({
pageCount = 1,
pageClassName,
@@ -0,0 +1,35 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { CSSProperties } from "react";
export type PreviewPageSize = {
height: number;
width: number;
};
const PDF_PAGE_RENDER_SCALE = 4;
const MAX_PREVIEW_CANVAS_PIXELS = 16_777_216; // 4096 * 4096
export const DEFAULT_PDF_PAGE_SIZE: PreviewPageSize = {
height: 841.89,
width: 595.28,
};
export const getPreviewCanvasScale = (width: number, height: number) => {
const devicePixelRatio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
const desiredScale = Math.max(PDF_PAGE_RENDER_SCALE, devicePixelRatio);
const desiredPixels = width * height * desiredScale * desiredScale;
if (desiredPixels <= MAX_PREVIEW_CANVAS_PIXELS) return desiredScale;
return Math.sqrt(MAX_PREVIEW_CANVAS_PIXELS / (width * height));
};
export const getScaledPreviewPageSize = (pageSize: PreviewPageSize, pageScale: number): PreviewPageSize => ({
height: pageSize.height * pageScale,
width: pageSize.width * pageScale,
});
export const getResumePreviewGapValue = (pageGap: CSSProperties["gap"]) =>
typeof pageGap === "number" && pageGap !== 0 ? `${pageGap}px` : pageGap;
export const getResumePreviewPageCount = (data?: ResumeData) => Math.max(1, data?.metadata.layout.pages.length ?? 1);
@@ -2,7 +2,8 @@ import type { ResumePreviewProps } from "./preview.shared";
import { lazy, Suspense } from "react";
import { useIsClient } from "usehooks-ts";
import { useResumeData } from "../builder/draft";
import { getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
import { ResumePreviewLoader } from "./preview.shared";
import { getResumePreviewPageCount } from "./preview.shared.utils";
const ResumePreviewClient = lazy(() =>
import("./preview.browser").then((module) => ({ default: module.ResumePreviewClient })),
+7 -7
View File
@@ -29,6 +29,13 @@ type Props = {
children: ({ session }: { session: AuthSession }) => React.ComponentProps<typeof DropdownMenuTrigger>["render"];
};
const handleLocaleChange = async (value: string) => {
if (!isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
export function UserDropdownMenu({ children }: Props) {
const isClient = useIsClient();
const router = useRouter();
@@ -41,13 +48,6 @@ export function UserDropdownMenu({ children }: Props) {
setTheme(value);
};
const handleLocaleChange = async (value: string) => {
if (!isLocale(value)) return;
setLocaleCookie(value);
await loadLocale(value);
window.location.reload();
};
const handleLogout = async () => {
const toastId = toast.loading(t`Signing out...`);
+4 -188
View File
@@ -1,208 +1,24 @@
import type * as React from "react";
import type { PanelImperativeHandle } from "react-resizable-panels";
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import {
ArrowSquareOutIcon,
ChatCircleDotsIcon,
CircleNotchIcon,
FilePdfIcon,
MinusIcon,
PlusIcon,
SidebarSimpleIcon,
SquaresFourIcon,
} from "@phosphor-icons/react";
import { ChatCircleDotsIcon, SidebarSimpleIcon, SquaresFourIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useCallback, useRef, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { cn } from "@reactive-resume/utils/style";
import { LoadingScreen } from "@/components/layout/loading-screen";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { ResumePreview } from "@/features/resume/preview/preview";
import { orpc } from "@/libs/orpc/client";
import { AgentChat } from "./-components/agent-chat";
import { ResumePane } from "./-components/resume-pane";
import { AgentThreadSidebar } from "./-components/thread-sidebar";
import { useAgentResumeUpdateSubscription } from "./-hooks/use-agent-resume-updates";
type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"];
type ToolbarButtonProps = React.ComponentProps<typeof Button> & {
label: string;
};
type ResumePaneProps = {
resume: AgentThreadDetail["resume"];
};
export const Route = createFileRoute("/agent/$threadId")({
component: RouteComponent,
});
const AGENT_PREVIEW_ZOOM_STORAGE_KEY = "reactive-resume:agent-preview-zoom:v3";
const MIN_PREVIEW_ZOOM = 0.4;
const MAX_PREVIEW_ZOOM = 1.5;
const PREVIEW_ZOOM_STEP = 0.05;
const DEFAULT_PREVIEW_ZOOM = 1;
function clampPreviewZoom(value: number) {
return Math.min(MAX_PREVIEW_ZOOM, Math.max(MIN_PREVIEW_ZOOM, value));
}
function getInitialPreviewZoom() {
if (typeof window === "undefined") return DEFAULT_PREVIEW_ZOOM;
// ponytail: Number(null) === 0, so guard with a null-check before parsing
const raw = window.localStorage.getItem(AGENT_PREVIEW_ZOOM_STORAGE_KEY);
if (raw === null) return DEFAULT_PREVIEW_ZOOM;
const stored = Number(raw);
return Number.isFinite(stored) ? clampPreviewZoom(stored) : DEFAULT_PREVIEW_ZOOM;
}
function ToolbarButton({ label, children, ...props }: ToolbarButtonProps) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button size="icon-sm" variant="ghost" aria-label={label} {...props}>
{children}
</Button>
}
/>
<TooltipContent side="bottom" align="center">
{label}
</TooltipContent>
</Tooltip>
);
}
function ResumePane({ resume }: ResumePaneProps) {
const [zoom, setZoom] = useState(getInitialPreviewZoom);
const [isPrinting, setIsPrinting] = useState(false);
useEffect(() => {
window.localStorage.setItem(AGENT_PREVIEW_ZOOM_STORAGE_KEY, String(zoom));
}, [zoom]);
const setClampedZoom = useCallback((value: number) => {
setZoom(clampPreviewZoom(Number(value.toFixed(2))));
}, []);
const onDownloadPDF = useCallback(async () => {
if (!resume) return;
const filename = generateFilename(resume.name || resume.data.basics.name || resume.id, "pdf");
const toastId = toast.loading(t`Please wait while your PDF is being generated…`);
setIsPrinting(true);
try {
const blob = await createResumePdfBlob(resume.data);
downloadWithAnchor(blob, filename);
} catch {
toast.error(t`There was a problem while generating the PDF, please try again.`);
} finally {
setIsPrinting(false);
toast.dismiss(toastId);
}
}, [resume]);
const zoomPercent = Math.round(zoom * 100);
return (
<section className="flex h-full min-h-0 flex-col bg-muted/30">
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
<div>
<div className="font-semibold">
<Trans>Resume</Trans>
</div>
<div className="text-muted-foreground text-xs">{resume?.name ?? t`Missing working resume`}</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<div className="sticky top-0 z-10 flex h-10 items-center justify-between border-b bg-background/90 px-2 backdrop-blur">
<div className="flex items-center gap-1">
<ToolbarButton
label={t`Decrease zoom`}
disabled={!resume}
onClick={() => setClampedZoom(zoom - PREVIEW_ZOOM_STEP)}
>
<MinusIcon />
</ToolbarButton>
<Tooltip>
<TooltipTrigger
render={
<input
type="text"
inputMode="numeric"
value={`${zoomPercent}%`}
disabled={!resume}
aria-label={t`Zoom level`}
className="h-8 w-14 rounded-md border bg-background px-1 text-center text-xs outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50"
onChange={(event) => {
const nextValue = Number(event.target.value.replace(/[^0-9.]/g, ""));
if (Number.isFinite(nextValue)) setClampedZoom(nextValue / 100);
}}
/>
}
/>
<TooltipContent side="bottom" align="center">
<Trans>Zoom level</Trans>
</TooltipContent>
</Tooltip>
<ToolbarButton
label={t`Increase zoom`}
disabled={!resume}
onClick={() => setClampedZoom(zoom + PREVIEW_ZOOM_STEP)}
>
<PlusIcon />
</ToolbarButton>
</div>
<div className="flex items-center gap-1">
<ToolbarButton
label={t`Open in builder`}
disabled={!resume}
nativeButton={false}
render={resume ? <Link to="/builder/$resumeId" params={{ resumeId: resume.id }} /> : undefined}
>
<ArrowSquareOutIcon />
</ToolbarButton>
<ToolbarButton
label={t`Download PDF`}
disabled={!resume || isPrinting}
onClick={() => void onDownloadPDF()}
>
{isPrinting ? <CircleNotchIcon className="animate-spin" /> : <FilePdfIcon />}
</ToolbarButton>
</div>
</div>
<div className="p-4">
{resume ? (
<ResumePreview
data={resume.data}
pageLayout="vertical"
pageScale={zoom}
showPageNumbers
className="mx-auto"
pageClassName="shadow-lg"
/>
) : (
<div className="rounded-md border border-dashed p-6 text-center text-muted-foreground">
<Trans>The working resume was deleted. This thread is read-only.</Trans>
</div>
)}
</div>
</div>
</section>
);
}
function RouteComponent() {
const { threadId } = Route.useParams();
const navigate = useNavigate();
@@ -0,0 +1,181 @@
import type * as React from "react";
import type { RouterOutput } from "@/libs/orpc/client";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowSquareOutIcon, CircleNotchIcon, FilePdfIcon, MinusIcon, PlusIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@reactive-resume/ui/components/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { ResumePreview } from "@/features/resume/preview/preview";
type ToolbarButtonProps = React.ComponentProps<typeof Button> & {
label: string;
};
type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"];
export type ResumePaneProps = {
resume: AgentThreadDetail["resume"];
};
const AGENT_PREVIEW_ZOOM_STORAGE_KEY = "reactive-resume:agent-preview-zoom:v3";
const MIN_PREVIEW_ZOOM = 0.4;
const MAX_PREVIEW_ZOOM = 1.5;
const PREVIEW_ZOOM_STEP = 0.05;
const DEFAULT_PREVIEW_ZOOM = 1;
function clampPreviewZoom(value: number) {
return Math.min(MAX_PREVIEW_ZOOM, Math.max(MIN_PREVIEW_ZOOM, value));
}
function getInitialPreviewZoom() {
if (typeof window === "undefined") return DEFAULT_PREVIEW_ZOOM;
// ponytail: Number(null) === 0, so guard with a null-check before parsing
const raw = window.localStorage.getItem(AGENT_PREVIEW_ZOOM_STORAGE_KEY);
if (raw === null) return DEFAULT_PREVIEW_ZOOM;
const stored = Number(raw);
return Number.isFinite(stored) ? clampPreviewZoom(stored) : DEFAULT_PREVIEW_ZOOM;
}
function ToolbarButton({ label, children, ...props }: ToolbarButtonProps) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button size="icon-sm" variant="ghost" aria-label={label} {...props}>
{children}
</Button>
}
/>
<TooltipContent side="bottom" align="center">
{label}
</TooltipContent>
</Tooltip>
);
}
export function ResumePane({ resume }: ResumePaneProps) {
const [zoom, setZoom] = useState(getInitialPreviewZoom);
const [isPrinting, setIsPrinting] = useState(false);
useEffect(() => {
window.localStorage.setItem(AGENT_PREVIEW_ZOOM_STORAGE_KEY, String(zoom));
}, [zoom]);
const setClampedZoom = useCallback((value: number) => {
setZoom(clampPreviewZoom(Number(value.toFixed(2))));
}, []);
const onDownloadPDF = useCallback(async () => {
if (!resume) return;
const filename = generateFilename(resume.name || resume.data.basics.name || resume.id, "pdf");
const toastId = toast.loading(t`Please wait while your PDF is being generated…`);
setIsPrinting(true);
try {
const blob = await createResumePdfBlob(resume.data);
downloadWithAnchor(blob, filename);
} catch {
toast.error(t`There was a problem while generating the PDF, please try again.`);
} finally {
setIsPrinting(false);
toast.dismiss(toastId);
}
}, [resume]);
const zoomPercent = Math.round(zoom * 100);
return (
<section className="flex h-full min-h-0 flex-col bg-muted/30">
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
<div>
<div className="font-semibold">
<Trans>Resume</Trans>
</div>
<div className="text-muted-foreground text-xs">{resume?.name ?? t`Missing working resume`}</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
<div className="sticky top-0 z-10 flex h-10 items-center justify-between border-b bg-background/90 px-2 backdrop-blur">
<div className="flex items-center gap-1">
<ToolbarButton
label={t`Decrease zoom`}
disabled={!resume}
onClick={() => setClampedZoom(zoom - PREVIEW_ZOOM_STEP)}
>
<MinusIcon />
</ToolbarButton>
<Tooltip>
<TooltipTrigger
render={
<input
type="text"
inputMode="numeric"
value={`${zoomPercent}%`}
disabled={!resume}
aria-label={t`Zoom level`}
className="h-8 w-14 rounded-md border bg-background px-1 text-center text-xs outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50"
onChange={(event) => {
const nextValue = Number(event.target.value.replace(/[^0-9.]/g, ""));
if (Number.isFinite(nextValue)) setClampedZoom(nextValue / 100);
}}
/>
}
/>
<TooltipContent side="bottom" align="center">
<Trans>Zoom level</Trans>
</TooltipContent>
</Tooltip>
<ToolbarButton
label={t`Increase zoom`}
disabled={!resume}
onClick={() => setClampedZoom(zoom + PREVIEW_ZOOM_STEP)}
>
<PlusIcon />
</ToolbarButton>
</div>
<div className="flex items-center gap-1">
<ToolbarButton
label={t`Open in builder`}
disabled={!resume}
nativeButton={false}
render={resume ? <Link to="/builder/$resumeId" params={{ resumeId: resume.id }} /> : undefined}
>
<ArrowSquareOutIcon />
</ToolbarButton>
<ToolbarButton
label={t`Download PDF`}
disabled={!resume || isPrinting}
onClick={() => void onDownloadPDF()}
>
{isPrinting ? <CircleNotchIcon className="animate-spin" /> : <FilePdfIcon />}
</ToolbarButton>
</div>
</div>
<div className="p-4">
{resume ? (
<ResumePreview
data={resume.data}
pageLayout="vertical"
pageScale={zoom}
showPageNumbers
className="mx-auto"
pageClassName="shadow-lg"
/>
) : (
<div className="rounded-md border border-dashed p-6 text-center text-muted-foreground">
<Trans>The working resume was deleted. This thread is read-only.</Trans>
</div>
)}
</div>
</div>
</section>
);
}
@@ -0,0 +1,108 @@
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>
);
}
@@ -0,0 +1,110 @@
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-colors",
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>
);
}
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import type { Resume } from "@/features/resume/builder/draft";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
@@ -75,14 +76,15 @@ type SectionItemProps = {
};
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentResume: () => ({
data: {
sections: {
education: { title: "Education", columns: 1, hidden: false, items: educationItems },
experience: { title: "Experience", columns: 1, hidden: false, items: experienceItems },
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
selector({
data: {
sections: {
education: { title: "Education", columns: 1, hidden: false, items: educationItems },
experience: { title: "Experience", columns: 1, hidden: false, items: experienceItems },
},
},
},
}),
} as unknown as Resume),
useUpdateResumeData: () => vi.fn(),
}));
vi.mock("../shared/section-base", () => ({
@@ -5,6 +5,7 @@
// shape: render a SectionItem per data row with title/subtitle mapped to specific
// fields, plus an "Add a new X" button. Test them together to amortize the mock setup.
import type { Resume } from "@/features/resume/builder/draft";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
@@ -87,19 +88,20 @@ type SectionItemProps = {
};
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentResume: () => ({
data: {
sections: {
awards: { title: "Awards", columns: 1, hidden: false, items: sections.awards },
certifications: { title: "Certifications", columns: 1, hidden: false, items: sections.certifications },
interests: { title: "Interests", columns: 1, hidden: false, items: sections.interests },
languages: { title: "Languages", columns: 1, hidden: false, items: sections.languages },
publications: { title: "Publications", columns: 1, hidden: false, items: sections.publications },
references: { title: "References", columns: 1, hidden: false, items: sections.references },
volunteer: { title: "Volunteer", columns: 1, hidden: false, items: sections.volunteer },
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
selector({
data: {
sections: {
awards: { title: "Awards", columns: 1, hidden: false, items: sections.awards },
certifications: { title: "Certifications", columns: 1, hidden: false, items: sections.certifications },
interests: { title: "Interests", columns: 1, hidden: false, items: sections.interests },
languages: { title: "Languages", columns: 1, hidden: false, items: sections.languages },
publications: { title: "Publications", columns: 1, hidden: false, items: sections.publications },
references: { title: "References", columns: 1, hidden: false, items: sections.references },
volunteer: { title: "Volunteer", columns: 1, hidden: false, items: sections.volunteer },
},
},
},
}),
} as unknown as Resume),
useUpdateResumeData: () => vi.fn(),
}));
vi.mock("../shared/section-base", () => ({
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import type { Resume } from "@/features/resume/builder/draft";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
@@ -41,11 +42,12 @@ type SectionItemProps = {
};
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentResume: () => ({
data: {
sections: { profiles: { title: "Profiles", columns: 1, hidden: false, items: sectionItems } },
},
}),
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
selector({
data: {
sections: { profiles: { title: "Profiles", columns: 1, hidden: false, items: sectionItems } },
},
} as unknown as Resume),
useUpdateResumeData: () => vi.fn(),
}));
vi.mock("../shared/section-base", () => ({
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import type { Resume } from "@/features/resume/builder/draft";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
@@ -49,9 +50,10 @@ type SectionItemProps = {
};
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentResume: () => ({
data: { sections: { projects: { title: "Projects", columns: 1, hidden: false, items } } },
}),
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
selector({
data: { sections: { projects: { title: "Projects", columns: 1, hidden: false, items } } },
} as unknown as Resume),
useUpdateResumeData: () => vi.fn(),
}));
vi.mock("../shared/section-base", () => ({
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import type { Resume } from "@/features/resume/builder/draft";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
@@ -25,9 +26,10 @@ type SectionItemProps = {
};
vi.mock("@/features/resume/builder/draft", () => ({
useCurrentResume: () => ({
data: { sections: { skills: { title: "Skills", columns: 1, hidden: false, items: sectionItems } } },
}),
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
selector({
data: { sections: { skills: { title: "Skills", columns: 1, hidden: false, items: sectionItems } } },
} as unknown as Resume),
useUpdateResumeData: () => vi.fn(),
}));
vi.mock("../shared/section-base", () => ({
@@ -135,7 +135,9 @@ function CustomStylesSectionForm() {
const nextIntent = compactIntent({ ...currentIntent, ...patch });
updateResumeData((draft) => {
draft.metadata.styleRules ??= [];
// Plain `?? ` assignment (not `??=`) so React Compiler can memoize this component;
// the compiler bails on logical-assignment operators today. Behavior is identical.
draft.metadata.styleRules = draft.metadata.styleRules ?? [];
const rules = draft.metadata.styleRules;
const existingIndex = rules.findIndex((rule) => rule.id === ruleId);
const existingRule = rules[existingIndex];
@@ -11,7 +11,7 @@ import {
} from "@reactive-resume/ui/components/input-group";
import { Switch } from "@reactive-resume/ui/components/switch";
import { Combobox } from "@/components/ui/combobox";
import { getLocaleOptions } from "@/features/locale/combobox";
import { getLocaleOptions } from "@/features/locale/locale-options";
import { useResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
import { useAppForm } from "@/libs/tanstack-form";
@@ -3,7 +3,7 @@ import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, InfoIcon, LightningIcon, SparkleIcon } from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import { toast } from "sonner";
import { match } from "ts-pattern";
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
@@ -51,11 +51,10 @@ export function ResumeAnalysisSectionBuilder() {
const resume = useResume();
const resumeId = resume?.id ?? "";
const providersQuery = useQuery(orpc.aiProviders.list.queryOptions());
const aiEnabled =
providersQuery.data?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
const { data: providers } = useQuery(orpc.aiProviders.list.queryOptions());
const aiEnabled = providers?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
const analysisQuery = useQuery({
const { data: analysis, isFetched: analysisFetched } = useQuery({
...orpc.resume.analysis.getById.queryOptions({ input: { id: resumeId } }),
enabled: !!resume,
});
@@ -88,10 +87,11 @@ export function ResumeAnalysisSectionBuilder() {
},
});
const analysis = analysisQuery.data;
const score = analysis?.overallScore ?? null;
const updatedAt = analysis?.updatedAt ?? null;
const [updatedAtLabel, setUpdatedAtLabel] = useState<string | null>(null);
// Derived during render (not via state+effect): the analysis comes from a client-fetched query,
// so the server render has no date and there's no hydration mismatch to defer around.
const updatedAtLabel = updatedAt ? new Date(updatedAt).toLocaleString() : null;
const analyzeLabel = isPending ? t`Analyzing…` : t`Analyze Resume`;
const scoreTone = useMemo(() => {
@@ -101,10 +101,6 @@ export function ResumeAnalysisSectionBuilder() {
return "bg-rose-600";
}, [score]);
useEffect(() => {
setUpdatedAtLabel(updatedAt ? new Date(updatedAt).toLocaleString() : null);
}, [updatedAt]);
const onAnalyze = () => {
if (!resume) return;
@@ -168,7 +164,7 @@ export function ResumeAnalysisSectionBuilder() {
</div>
</div>
{analysisQuery.isFetched && !analysis && !isPending && (
{analysisFetched && !analysis && !isPending && (
<div className="rounded-md border border-dashed p-3">
<p className="max-w-xs text-muted-foreground text-sm">
<Trans>Run your first analysis to get a scorecard, strengths, and prioritized suggestions.</Trans>
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { computeDelta, getSparklinePoints } from "./statistics";
import { computeDelta, getSparklinePoints } from "./statistics.utils";
describe("computeDelta", () => {
it("returns null when the prior period had no activity", () => {
@@ -8,33 +8,12 @@ import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/compone
import { cn } from "@reactive-resume/utils/style";
import { orpc } from "@/libs/orpc/client";
import { SectionBase } from "../shared/section-base";
import { computeDelta, getSparklinePoints } from "./statistics.utils";
// Fetch 60 days so we can render a 30-day sparkline and compare it against the prior 30 days.
const TREND_DAYS = 60;
const WINDOW = 30;
// Percent change of the most recent `window` days vs the `window` days before it.
// Returns null when the prior period had no activity (division by zero / no baseline).
export function computeDelta(series: number[], window: number): number | null {
const recent = series.slice(-window);
const previous = series.slice(-window * 2, -window);
const recentSum = recent.reduce((sum, n) => sum + n, 0);
const previousSum = previous.reduce((sum, n) => sum + n, 0);
if (previousSum === 0) return null;
return Math.round(((recentSum - previousSum) / previousSum) * 100);
}
// Polyline points for the sparkline, or null for degenerate inputs (fewer than two
// points, or an all-zero series) where there is nothing meaningful to draw.
export function getSparklinePoints(values: number[], width: number, height: number): string | null {
if (values.length < 2 || values.every((n) => n === 0)) return null;
const max = Math.max(...values, 1);
const step = width / (values.length - 1);
return values
.map((value, index) => `${(index * step).toFixed(1)},${(height - (value / max) * height).toFixed(1)}`)
.join(" ");
}
export function StatisticsSectionBuilder() {
const params = useParams({ from: "/builder/$resumeId" });
const { data: statistics } = useQuery(
@@ -0,0 +1,21 @@
// Percent change of the most recent `window` days vs the `window` days before it.
// Returns null when the prior period had no activity (division by zero / no baseline).
export function computeDelta(series: number[], window: number): number | null {
const recent = series.slice(-window);
const previous = series.slice(-window * 2, -window);
const recentSum = recent.reduce((sum, n) => sum + n, 0);
const previousSum = previous.reduce((sum, n) => sum + n, 0);
if (previousSum === 0) return null;
return Math.round(((recentSum - previousSum) / previousSum) * 100);
}
// Polyline points for the sparkline, or null for degenerate inputs (fewer than two
// points, or an all-zero series) where there is nothing meaningful to draw.
export function getSparklinePoints(values: number[], width: number, height: number): string | null {
if (values.length < 2 || values.every((n) => n === 0)) return null;
const max = Math.max(...values, 1);
const step = width / (values.length - 1);
return values
.map((value, index) => `${(index * step).toFixed(1)},${(height - (value / max) * height).toFixed(1)}`)
.join(" ");
}
@@ -11,7 +11,8 @@ import {
InputGroupText,
} from "@reactive-resume/ui/components/input-group";
import { Separator } from "@reactive-resume/ui/components/separator";
import { FontFamilyCombobox, FontWeightCombobox, getNextWeights } from "@/components/typography/combobox";
import { FontFamilyCombobox, FontWeightCombobox } from "@/components/typography/combobox";
import { getNextWeights } from "@/components/typography/get-next-weights";
import { useResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
import { useAppForm } from "@/libs/tanstack-form";
@@ -1,4 +1,5 @@
import type { Layout, usePanelRef } from "react-resizable-panels";
import Cookies from "js-cookie";
import { useCallback, useMemo } from "react";
import { useMediaQuery, useWindowSize } from "usehooks-ts";
import { create } from "zustand/react";
@@ -146,3 +147,14 @@ export function useBuilderSidebar(): UseBuilderSidebarReturn {
};
}, [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);
};
+7 -231
View File
@@ -1,37 +1,14 @@
import type { Icon } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import type { Layout } from "react-resizable-panels";
import type { BuilderLayout } from "./-store/sidebar";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { EyeIcon, NotePencilIcon, PaletteIcon } from "@phosphor-icons/react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import Cookies from "js-cookie";
import { useEffect, useRef, useState } from "react";
import { usePanelRef } from "react-resizable-panels";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useEffect } from "react";
import { useMediaQuery } from "usehooks-ts";
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
import { cn } from "@reactive-resume/utils/style";
import {
useBuilderResumeUpdateSubscription,
usePreviewPausedStore,
useResumeCleanup,
useResumeStore,
} from "@/features/resume/builder/draft";
import { useBuilderResumeUpdateSubscription, useResumeCleanup, useResumeStore } from "@/features/resume/builder/draft";
import { orpc } from "@/libs/orpc/client";
import { createNoindexFollowMeta } from "@/libs/seo";
import { BuilderHeader } from "./-components/header";
import { BuilderSidebarLeft } from "./-sidebar/left";
import { BuilderSidebarRight } from "./-sidebar/right";
import {
BUILDER_LAYOUT_COOKIE_NAME,
DEFAULT_BUILDER_LAYOUT,
mapPanelLayoutToBuilderLayout,
parseBuilderLayoutCookie,
useBuilderSidebar,
useBuilderSidebarStore,
} from "./-store/sidebar";
import { DesktopBuilderShell } from "./-components/desktop-builder-shell";
import { MobileBuilderShell } from "./-components/mobile-builder-shell";
import { getBuilderLayout } from "./-store/sidebar";
export const Route = createFileRoute("/builder/$resumeId")({
component: RouteComponent,
@@ -93,211 +70,10 @@ function RouteComponent() {
return <BuilderLayoutShell initialLayout={initialLayout} />;
}
type BuilderLayoutShellProps = {
initialLayout: BuilderLayout;
};
function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
function BuilderLayoutShell({ initialLayout }: { initialLayout: BuilderLayout }) {
// Single breakpoint (below `md`) switches between the desktop resizable panels and the mobile tabbed shell.
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
if (isMobile) return <MobileBuilderShell />;
return <DesktopBuilderShell initialLayout={initialLayout} />;
}
function DesktopBuilderShell({ initialLayout }: BuilderLayoutShellProps) {
// Only rendered when `BuilderLayoutShell` has already decided we're on desktop, so sidebar sizing is unconditional.
const canPersistLayoutRef = useRef(false);
const leftSidebarRef = usePanelRef();
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>
);
}
type MobileBuilderTab = "edit" | "preview" | "design";
function MobileBuilderShell() {
// Local state is enough — mobile view mode is shell-scoped and doesn't need to persist.
const [tab, setTab] = useState<MobileBuilderTab>("edit");
const setPreviewPaused = usePreviewPausedStore((state) => state.setPaused);
// The preview stays mounted under the Edit/Design overlay; pause its render while it's covered.
useEffect(() => {
setPreviewPaused(tab !== "preview");
return () => setPreviewPaused(false);
}, [tab, setPreviewPaused]);
return (
<div className="flex min-h-[100dvh] flex-col">
<a
href="#main-content"
className="sr-only rounded-md bg-popover px-4 py-2 text-sm ring-2 ring-ring focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
>
<Trans>Skip to main content</Trans>
</a>
<BuilderHeader />
{/* The preview (fixed inset-0) stays mounted so zoom/pan state survives tab switches. */}
<main id="main-content" className="flex-1">
<Outlet />
</main>
{tab === "edit" && (
<MobileSidebarPanel>
<BuilderSidebarLeft />
</MobileSidebarPanel>
)}
{tab === "design" && (
<MobileSidebarPanel>
<BuilderSidebarRight />
</MobileSidebarPanel>
)}
<MobileBuilderTabBar activeTab={tab} onTabChange={setTab} />
</div>
);
}
type MobileSidebarPanelProps = {
children: ReactNode;
};
function MobileSidebarPanel({ children }: MobileSidebarPanelProps) {
// Sits below the header (top-14) and above the tab bar (bottom-16). The sidebar's ScrollArea hardcodes
// `h-[calc(100svh-3.5rem)]`; override it to fill this panel so its last item isn't hidden under the tab bar.
return (
<div className="fixed inset-x-0 top-14 bottom-16 z-40 bg-background [&_[data-slot=scroll-area]]:h-full">
{children}
</div>
);
}
const MOBILE_BUILDER_TABS = [
{ value: "edit", icon: NotePencilIcon },
{ value: "preview", icon: EyeIcon },
{ value: "design", icon: PaletteIcon },
] as const satisfies readonly { value: MobileBuilderTab; icon: Icon }[];
type MobileBuilderTabBarProps = {
activeTab: MobileBuilderTab;
onTabChange: (tab: MobileBuilderTab) => void;
};
function MobileBuilderTabBar({ activeTab, onTabChange }: MobileBuilderTabBarProps) {
const labels: Record<MobileBuilderTab, string> = { edit: t`Edit`, preview: t`Preview`, design: t`Design` };
return (
<nav className="fixed inset-x-0 bottom-0 z-50 flex border-t bg-popover pb-[env(safe-area-inset-bottom)]">
{MOBILE_BUILDER_TABS.map(({ value, icon: Icon }) => {
const isActive = value === activeTab;
return (
<button
key={value}
type="button"
aria-current={isActive ? "page" : undefined}
onClick={() => onTabChange(value)}
className={cn(
"flex min-h-16 flex-1 flex-col items-center justify-center gap-1 text-xs transition-colors",
isActive ? "text-primary" : "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="size-5" weight={isActive ? "fill" : "regular"} />
<span>{labels[value]}</span>
</button>
);
})}
</nav>
);
}
const setBuilderLayout = (data: BuilderLayout) => {
const layout = parseBuilderLayoutCookie(JSON.stringify(data));
Cookies.set(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(layout), { path: "/" });
};
const getBuilderLayout = (): BuilderLayout => {
const layout = Cookies.get(BUILDER_LAYOUT_COOKIE_NAME);
if (!layout) return DEFAULT_BUILDER_LAYOUT;
return parseBuilderLayoutCookie(layout);
};
@@ -39,7 +39,11 @@ const createResumeThumbnailUrl = async (data: ResumeData, signal: AbortSignal) =
};
function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | undefined): ThumbnailState {
const thumbnailQuery = useQuery({
const {
data: thumbnailData,
error: thumbnailError,
isError: thumbnailIsError,
} = useQuery({
queryKey: ["resume-thumbnail", cacheKey],
queryFn: ({ signal }) => {
if (!data) throw new Error("Resume data is required to generate a thumbnail.");
@@ -50,20 +54,20 @@ function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | und
});
useEffect(() => {
if (thumbnailQuery.error) console.error("Failed to generate resume thumbnail", thumbnailQuery.error);
}, [thumbnailQuery.error]);
if (thumbnailError) console.error("Failed to generate resume thumbnail", thumbnailError);
}, [thumbnailError]);
useEffect(() => {
const url = thumbnailQuery.data;
const url = thumbnailData;
return () => {
if (url) URL.revokeObjectURL(url);
};
}, [thumbnailQuery.data]);
}, [thumbnailData]);
if (!data || !cacheKey) return { status: "idle" };
if (thumbnailQuery.isError) return { status: "error" };
if (thumbnailQuery.data) return { status: "ready", url: thumbnailQuery.data };
if (thumbnailIsError) return { status: "error" };
if (thumbnailData) return { status: "ready", url: thumbnailData };
return { status: "loading" };
}
@@ -71,15 +75,15 @@ function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | und
export function ResumeThumbnail({ isLocked, resume }: ResumeThumbnailProps) {
const containerRef = useRef<HTMLDivElement>(null);
const isInView = useInView(containerRef, { amount: 0.1, margin: "240px", once: true });
const resumeQuery = useQuery({
const { data: resumeData, isError: resumeIsError } = useQuery({
...orpc.resume.getById.queryOptions({ input: { id: resume.id } }),
enabled: isInView,
});
const thumbnail = useResumeThumbnail(
resumeQuery.data?.data,
resumeData?.data,
isInView ? getResumeThumbnailCacheKey(resume.id, resume.updatedAt) : undefined,
);
const hasFailed = resumeQuery.isError || thumbnail.status === "error";
const hasFailed = resumeIsError || thumbnail.status === "error";
return (
<div