From 5a1cc6585c8b539cadab757d600b895234d2c1a1 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sun, 5 Jul 2026 19:47:11 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20address=20React=20Doctor=20findings?= =?UTF-8?q?=20=E2=80=94=20compiler,=20purity,=20query,=20component=20struc?= =?UTF-8?q?ture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../web/src/components/animation/count-up.tsx | 18 +- .../src/components/input/rich-input.test.ts | 2 +- apps/web/src/components/input/rich-input.tsx | 8 +- .../src/components/input/rich-input.utils.ts | 7 + .../src/components/typography/combobox.tsx | 30 --- .../typography/get-next-weights.test.ts | 2 +- .../components/typography/get-next-weights.ts | 31 +++ apps/web/src/components/ui/combobox.tsx | 26 +- apps/web/src/dialogs/resume/import.test.ts | 2 +- apps/web/src/dialogs/resume/import.tsx | 23 +- apps/web/src/dialogs/resume/import.utils.ts | 19 ++ .../components/application-actions-menu.tsx | 20 +- .../applications/components/table-view.tsx | 67 ++++- .../pages/preferences/language.tsx | 14 +- apps/web/src/features/locale/combobox.test.ts | 2 +- apps/web/src/features/locale/combobox.tsx | 36 +-- .../src/features/locale/locale-options.tsx | 24 ++ .../features/resume/preview/pdf-canvas.tsx | 4 +- .../features/resume/preview/pdf-thumbnail.ts | 2 +- .../resume/preview/preview.browser.tsx | 6 +- .../preview/preview.shared.helpers.test.ts | 2 +- .../resume/preview/preview.shared.test.tsx | 8 +- .../resume/preview/preview.shared.tsx | 33 +-- .../resume/preview/preview.shared.utils.ts | 35 +++ .../src/features/resume/preview/preview.tsx | 3 +- apps/web/src/features/user/dropdown-menu.tsx | 14 +- apps/web/src/routes/agent/$threadId.tsx | 192 +------------- .../routes/agent/-components/resume-pane.tsx | 181 +++++++++++++ .../-components/desktop-builder-shell.tsx | 108 ++++++++ .../-components/mobile-builder-shell.tsx | 110 ++++++++ .../sections/education-experience.test.tsx | 16 +- .../left/sections/many-sections.test.tsx | 26 +- .../-sidebar/left/sections/profiles.test.tsx | 12 +- .../-sidebar/left/sections/projects.test.tsx | 8 +- .../-sidebar/left/sections/skills.test.tsx | 8 +- .../-sidebar/right/sections/custom-styles.tsx | 4 +- .../-sidebar/right/sections/page.tsx | 2 +- .../right/sections/resume-analysis.tsx | 20 +- .../right/sections/statistics.test.ts | 2 +- .../-sidebar/right/sections/statistics.tsx | 23 +- .../right/sections/statistics.utils.ts | 21 ++ .../-sidebar/right/sections/typography.tsx | 3 +- .../builder/$resumeId/-store/sidebar.ts | 12 + .../src/routes/builder/$resumeId/route.tsx | 238 +----------------- .../-components/cards/resume-thumbnail.tsx | 24 +- 45 files changed, 770 insertions(+), 678 deletions(-) create mode 100644 apps/web/src/components/input/rich-input.utils.ts create mode 100644 apps/web/src/components/typography/get-next-weights.ts create mode 100644 apps/web/src/dialogs/resume/import.utils.ts create mode 100644 apps/web/src/features/locale/locale-options.tsx create mode 100644 apps/web/src/features/resume/preview/preview.shared.utils.ts create mode 100644 apps/web/src/routes/agent/-components/resume-pane.tsx create mode 100644 apps/web/src/routes/builder/$resumeId/-components/desktop-builder-shell.tsx create mode 100644 apps/web/src/routes/builder/$resumeId/-components/mobile-builder-shell.tsx create mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.utils.ts diff --git a/apps/web/src/components/animation/count-up.tsx b/apps/web/src/components/animation/count-up.tsx index f9a3bd996..f54cdc188 100644 --- a/apps/web/src/components/animation/count-up.tsx +++ b/apps/web/src/components/animation/count-up.tsx @@ -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( diff --git a/apps/web/src/components/input/rich-input.test.ts b/apps/web/src/components/input/rich-input.test.ts index c6b007f99..3ac5e877c 100644 --- a/apps/web/src/components/input/rich-input.test.ts +++ b/apps/web/src/components/input/rich-input.test.ts @@ -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", () => { diff --git a/apps/web/src/components/input/rich-input.tsx b/apps/web/src/components/input/rich-input.tsx index 20f8fcf80..1a64cd21c 100644 --- a/apps/web/src/components/input/rich-input.tsx +++ b/apps/web/src/components/input/rich-input.tsx @@ -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; -export function resolveHighlightToolbarState(isHighlight: boolean, highlightColor: string | null) { - const visibleHighlightColor = highlightColor ?? (isHighlight ? defaultHighlightColor : undefined); - - return { visibleHighlightColor, canClearHighlight: isHighlight }; -} - type EditorToolbarProps = { editor: Editor; isFullscreen: boolean; diff --git a/apps/web/src/components/input/rich-input.utils.ts b/apps/web/src/components/input/rich-input.utils.ts new file mode 100644 index 000000000..6973e3db5 --- /dev/null +++ b/apps/web/src/components/input/rich-input.utils.ts @@ -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 }; +} diff --git a/apps/web/src/components/typography/combobox.tsx b/apps/web/src/components/typography/combobox.tsx index 7ee3ba626..6bd0d792e 100644 --- a/apps/web/src/components/typography/combobox.tsx +++ b/apps/web/src/components/typography/combobox.tsx @@ -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; export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxProps) { diff --git a/apps/web/src/components/typography/get-next-weights.test.ts b/apps/web/src/components/typography/get-next-weights.test.ts index 3ac34f865..b7b1ee20d 100644 --- a/apps/web/src/components/typography/get-next-weights.test.ts +++ b/apps/web/src/components/typography/get-next-weights.test.ts @@ -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)", () => { diff --git a/apps/web/src/components/typography/get-next-weights.ts b/apps/web/src/components/typography/get-next-weights.ts new file mode 100644 index 000000000..d2e80d826 --- /dev/null +++ b/apps/web/src/components/typography/get-next-weights.ts @@ -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; +} diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 82d80c7cd..d82c52745 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -79,6 +79,19 @@ type MultiComboboxProps = { type ComboboxProps = SingleComboboxProps | MultiComboboxProps; +const listContent = (item: ComboboxOption) => ( + + {item.label} + +); + +const groupedListContent = (group: GroupedComboboxOption) => ( + + {group.label !== null && group.label !== undefined ? {group.label} : null} + {listContent} + +); + function Combobox(props: ComboboxProps) { const { options, @@ -203,19 +216,6 @@ function Combobox(props: ComboboxProps< ? Array.isArray(selectedValue) && selectedValue.length > 0 : selectedValue !== null && selectedValue !== undefined; - const listContent = (item: ComboboxOption) => ( - - {item.label} - - ); - - const groupedListContent = (group: GroupedComboboxOption) => ( - - {group.label !== null && group.label !== undefined ? {group.label} : null} - {listContent} - - ); - const triggerNode = ( { it("detects JSON Resume by a top-level basics without Reactive Resume sections/metadata", () => { diff --git a/apps/web/src/dialogs/resume/import.tsx b/apps/web/src/dialogs/resume/import.tsx index 718a13cac..34b845b76 100644 --- a/apps/web/src/dialogs/resume/import.tsx +++ b/apps/web/src/dialogs/resume/import.tsx @@ -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; -type ImportType = FormValues["type"]; - function fileToBase64(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -120,24 +119,6 @@ async function detectImportType(file: File): Promise { return ""; } -export function detectJsonImportType(parsed: unknown): ImportType { - if (!parsed || typeof parsed !== "object") return ""; - const data = parsed as Record; - - // 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 | 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); diff --git a/apps/web/src/dialogs/resume/import.utils.ts b/apps/web/src/dialogs/resume/import.utils.ts new file mode 100644 index 000000000..426fef37c --- /dev/null +++ b/apps/web/src/dialogs/resume/import.utils.ts @@ -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; + + // 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 | undefined; + if (metadata && !("page" in metadata)) return "reactive-resume-v4-json"; + return "reactive-resume-json"; + } + + return ""; +} diff --git a/apps/web/src/features/applications/components/application-actions-menu.tsx b/apps/web/src/features/applications/components/application-actions-menu.tsx index 5ae2bb735..204dea64f 100644 --- a/apps/web/src/features/applications/components/application-actions-menu.tsx +++ b/apps/web/src/features/applications/components/application-actions-menu.tsx @@ -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 (
@@ -86,7 +98,7 @@ export function ApplicationActionsMenu({ application, onEdit, showOnHover, class } /> - + onEdit(application)}> Edit @@ -118,7 +130,7 @@ export function ApplicationActionsMenu({ application, onEdit, showOnHover, class - remove.mutate({ id: application.id })}> + Delete diff --git a/apps/web/src/features/applications/components/table-view.tsx b/apps/web/src/features/applications/components/table-view.tsx index 9c1f015e7..f618d19df 100644 --- a/apps/web/src/features/applications/components/table-view.tsx +++ b/apps/web/src/features/applications/components/table-view.tsx @@ -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) {
)} -
+ {/* Desktop: full table. Mobile: a stacked card list (below) instead of a 900px h-scroll. */} +
@@ -198,9 +200,13 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) { Source + - @@ -223,7 +229,12 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
- Last activity + Applied + + + Actions +
{app.source || "—"} - {new Date(app.updatedAt).toLocaleDateString()} + {new Date(app.appliedAt).toLocaleDateString()} @@ -266,6 +277,52 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
+ {/* Mobile: stacked cards (same paginated rows), tap to open. */} +
+ {rows.map((app) => { + const stage = stageOf(app.status); + return ( +
+ toggleOne(app.id)} + aria-label={t`Select ${app.company}`} + /> + + +
+ ); + })} +
+
diff --git a/apps/web/src/features/command-palette/pages/preferences/language.tsx b/apps/web/src/features/command-palette/pages/preferences/language.tsx index b436a498a..8724cc6c5 100644 --- a/apps/web/src/features/command-palette/pages/preferences/language.tsx +++ b/apps/web/src/features/command-palette/pages/preferences/language.tsx @@ -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 ( Language}> {Object.entries(localeMap).map(([value, label]) => ( diff --git a/apps/web/src/features/locale/combobox.test.ts b/apps/web/src/features/locale/combobox.test.ts index 95aa39944..6b192ef75 100644 --- a/apps/web/src/features/locale/combobox.test.ts +++ b/apps/web/src/features/locale/combobox.test.ts @@ -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: {} }); diff --git a/apps/web/src/features/locale/combobox.tsx b/apps/web/src/features/locale/combobox.tsx index 66797c51b..16eac5248 100644 --- a/apps/web/src/features/locale/combobox.tsx +++ b/apps/web/src/features/locale/combobox.tsx @@ -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; -export const getLocaleOptions = () => { - return Object.entries(localeMap).map(([value, label]) => { - const name = i18n.t(label); - - return { - value: value as Locale, - label: ( - - {value} - {name} - - ), - // 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 ( { + return Object.entries(localeMap).map(([value, label]) => { + const name = i18n.t(label); + + return { + value: value as Locale, + label: ( + + {value} + {name} + + ), + // 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)), + }; + }); +}; diff --git a/apps/web/src/features/resume/preview/pdf-canvas.tsx b/apps/web/src/features/resume/preview/pdf-canvas.tsx index 5a39a6f15..cf1ec98b6 100644 --- a/apps/web/src/features/resume/preview/pdf-canvas.tsx +++ b/apps/web/src/features/resume/preview/pdf-canvas.tsx @@ -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(); diff --git a/apps/web/src/features/resume/preview/pdf-thumbnail.ts b/apps/web/src/features/resume/preview/pdf-thumbnail.ts index 032e5496c..969a0780c 100644 --- a/apps/web/src/features/resume/preview/pdf-thumbnail.ts +++ b/apps/web/src/features/resume/preview/pdf-thumbnail.ts @@ -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) => { diff --git a/apps/web/src/features/resume/preview/preview.browser.tsx b/apps/web/src/features/resume/preview/preview.browser.tsx index 70ee0ab27..2ef28de53 100644 --- a/apps/web/src/features/resume/preview/preview.browser.tsx +++ b/apps/web/src/features/resume/preview/preview.browser.tsx @@ -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 = { diff --git a/apps/web/src/features/resume/preview/preview.shared.helpers.test.ts b/apps/web/src/features/resume/preview/preview.shared.helpers.test.ts index a2c92adb4..f00dd7b21 100644 --- a/apps/web/src/features/resume/preview/preview.shared.helpers.test.ts +++ b/apps/web/src/features/resume/preview/preview.shared.helpers.test.ts @@ -6,7 +6,7 @@ import { getPreviewCanvasScale, getResumePreviewGapValue, getScaledPreviewPageSize, -} from "./preview.shared"; +} from "./preview.shared.utils"; describe("getScaledPreviewPageSize", () => { it("multiplies both dimensions by the scale", () => { diff --git a/apps/web/src/features/resume/preview/preview.shared.test.tsx b/apps/web/src/features/resume/preview/preview.shared.test.tsx index 00c18bf3a..d8c11f94c 100644 --- a/apps/web/src/features/resume/preview/preview.shared.test.tsx +++ b/apps/web/src/features/resume/preview/preview.shared.test.tsx @@ -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", () => { diff --git a/apps/web/src/features/resume/preview/preview.shared.tsx b/apps/web/src/features/resume/preview/preview.shared.tsx index 8f854de3c..dff4888fd 100644 --- a/apps/web/src/features/resume/preview/preview.shared.tsx +++ b/apps/web/src/features/resume/preview/preview.shared.tsx @@ -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 & { pageCount?: number; pageGap?: CSSProperties["gap"]; @@ -31,35 +27,8 @@ type ResumePreviewLoaderProps = Pick { - 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, diff --git a/apps/web/src/features/resume/preview/preview.shared.utils.ts b/apps/web/src/features/resume/preview/preview.shared.utils.ts new file mode 100644 index 000000000..bf7df3686 --- /dev/null +++ b/apps/web/src/features/resume/preview/preview.shared.utils.ts @@ -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); diff --git a/apps/web/src/features/resume/preview/preview.tsx b/apps/web/src/features/resume/preview/preview.tsx index a34347770..231b15efa 100644 --- a/apps/web/src/features/resume/preview/preview.tsx +++ b/apps/web/src/features/resume/preview/preview.tsx @@ -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 })), diff --git a/apps/web/src/features/user/dropdown-menu.tsx b/apps/web/src/features/user/dropdown-menu.tsx index 0ab1d0982..e3c0ad06b 100644 --- a/apps/web/src/features/user/dropdown-menu.tsx +++ b/apps/web/src/features/user/dropdown-menu.tsx @@ -29,6 +29,13 @@ type Props = { children: ({ session }: { session: AuthSession }) => React.ComponentProps["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...`); diff --git a/apps/web/src/routes/agent/$threadId.tsx b/apps/web/src/routes/agent/$threadId.tsx index 70909e667..fad037048 100644 --- a/apps/web/src/routes/agent/$threadId.tsx +++ b/apps/web/src/routes/agent/$threadId.tsx @@ -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 & { - 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 ( - - - {children} - - } - /> - - {label} - - - ); -} - -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 ( -
-
-
-
- Resume -
-
{resume?.name ?? t`Missing working resume`}
-
-
- -
-
-
- setClampedZoom(zoom - PREVIEW_ZOOM_STEP)} - > - - - - { - const nextValue = Number(event.target.value.replace(/[^0-9.]/g, "")); - if (Number.isFinite(nextValue)) setClampedZoom(nextValue / 100); - }} - /> - } - /> - - Zoom level - - - setClampedZoom(zoom + PREVIEW_ZOOM_STEP)} - > - - -
-
- : undefined} - > - - - void onDownloadPDF()} - > - {isPrinting ? : } - -
-
-
- {resume ? ( - - ) : ( -
- The working resume was deleted. This thread is read-only. -
- )} -
-
-
- ); -} - function RouteComponent() { const { threadId } = Route.useParams(); const navigate = useNavigate(); diff --git a/apps/web/src/routes/agent/-components/resume-pane.tsx b/apps/web/src/routes/agent/-components/resume-pane.tsx new file mode 100644 index 000000000..48895ab1d --- /dev/null +++ b/apps/web/src/routes/agent/-components/resume-pane.tsx @@ -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 & { + 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 ( + + + {children} + + } + /> + + {label} + + + ); +} + +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 ( +
+
+
+
+ Resume +
+
{resume?.name ?? t`Missing working resume`}
+
+
+ +
+
+
+ setClampedZoom(zoom - PREVIEW_ZOOM_STEP)} + > + + + + { + const nextValue = Number(event.target.value.replace(/[^0-9.]/g, "")); + if (Number.isFinite(nextValue)) setClampedZoom(nextValue / 100); + }} + /> + } + /> + + Zoom level + + + setClampedZoom(zoom + PREVIEW_ZOOM_STEP)} + > + + +
+
+ : undefined} + > + + + void onDownloadPDF()} + > + {isPrinting ? : } + +
+
+
+ {resume ? ( + + ) : ( +
+ The working resume was deleted. This thread is read-only. +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/routes/builder/$resumeId/-components/desktop-builder-shell.tsx b/apps/web/src/routes/builder/$resumeId/-components/desktop-builder-shell.tsx new file mode 100644 index 000000000..56ececfb3 --- /dev/null +++ b/apps/web/src/routes/builder/$resumeId/-components/desktop-builder-shell.tsx @@ -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 ( +
+ + Skip to main content + + + + + + + + + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/routes/builder/$resumeId/-components/mobile-builder-shell.tsx b/apps/web/src/routes/builder/$resumeId/-components/mobile-builder-shell.tsx new file mode 100644 index 000000000..c8890cc5c --- /dev/null +++ b/apps/web/src/routes/builder/$resumeId/-components/mobile-builder-shell.tsx @@ -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 ( +
+ {children} +
+ ); +} + +type MobileBuilderTabBarProps = { + activeTab: MobileBuilderTab; + onTabChange: (tab: MobileBuilderTab) => void; +}; + +function MobileBuilderTabBar({ activeTab, onTabChange }: MobileBuilderTabBarProps) { + const labels: Record = { edit: t`Edit`, preview: t`Preview`, design: t`Design` }; + + return ( + + ); +} + +export function MobileBuilderShell() { + // Local state is enough — mobile view mode is shell-scoped and doesn't need to persist. + const [tab, setTab] = useState("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 ( +
+ + Skip to main content + + + + + {/* The preview (fixed inset-0) stays mounted so zoom/pan state survives tab switches. */} +
+ +
+ + {tab === "edit" && ( + + + + )} + {tab === "design" && ( + + + + )} + + +
+ ); +} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/education-experience.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/education-experience.test.tsx index de5be680f..b83d14d37 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/education-experience.test.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/education-experience.test.tsx @@ -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", () => ({ diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/many-sections.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/many-sections.test.tsx index 434e62881..9f0266e73 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/many-sections.test.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/many-sections.test.tsx @@ -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", () => ({ diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/profiles.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/profiles.test.tsx index 3a87b2a65..c10c2a98d 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/profiles.test.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/profiles.test.tsx @@ -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", () => ({ diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/projects.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/projects.test.tsx index 34ad9fc43..a0812efe8 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/projects.test.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/projects.test.tsx @@ -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", () => ({ diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/skills.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/skills.test.tsx index 41d5e67ea..d7dcac2cb 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/skills.test.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/skills.test.tsx @@ -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", () => ({ diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx index e9d707be1..0cf01aba5 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/custom-styles.tsx @@ -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]; diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/page.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/page.tsx index 7d6a0d70a..d1b91bea5 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/page.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/page.tsx @@ -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"; diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/resume-analysis.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/resume-analysis.tsx index cd716ccc8..a2e1bfbdd 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/resume-analysis.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/resume-analysis.tsx @@ -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(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() {
- {analysisQuery.isFetched && !analysis && !isPending && ( + {analysisFetched && !analysis && !isPending && (

Run your first analysis to get a scorecard, strengths, and prioritized suggestions. diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.ts b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.ts index fb26e42fd..c899d8afa 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.ts +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.ts @@ -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", () => { diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.tsx index 61d110da8..1bad83428 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.tsx @@ -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( diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.utils.ts b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.utils.ts new file mode 100644 index 000000000..e0fe37a40 --- /dev/null +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.utils.ts @@ -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(" "); +} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx index 669011fe5..3b16d74f2 100644 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx +++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx @@ -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"; diff --git a/apps/web/src/routes/builder/$resumeId/-store/sidebar.ts b/apps/web/src/routes/builder/$resumeId/-store/sidebar.ts index bf45ad459..4e6c7f655 100644 --- a/apps/web/src/routes/builder/$resumeId/-store/sidebar.ts +++ b/apps/web/src/routes/builder/$resumeId/-store/sidebar.ts @@ -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); +}; diff --git a/apps/web/src/routes/builder/$resumeId/route.tsx b/apps/web/src/routes/builder/$resumeId/route.tsx index f04808f8d..5733830a8 100644 --- a/apps/web/src/routes/builder/$resumeId/route.tsx +++ b/apps/web/src/routes/builder/$resumeId/route.tsx @@ -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 ; } -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 ; return ; } - -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 ( -

- - Skip to main content - - - - - - - - - - -
- -
-
- - - - -
-
- ); -} - -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("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 ( -
- - Skip to main content - - - - - {/* The preview (fixed inset-0) stays mounted so zoom/pan state survives tab switches. */} -
- -
- - {tab === "edit" && ( - - - - )} - {tab === "design" && ( - - - - )} - - -
- ); -} - -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 ( -
- {children} -
- ); -} - -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 = { edit: t`Edit`, preview: t`Preview`, design: t`Design` }; - - return ( - - ); -} - -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); -}; diff --git a/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.tsx b/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.tsx index 22975ebbb..c54b7e4dd 100644 --- a/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.tsx +++ b/apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.tsx @@ -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(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 (