mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-22 14:22:16 +10:00
Add application tracker (#3220)
* feat(applications): job application tracker with AI copilot Add an Applications module at /dashboard/applications: pipeline board (dnd-kit), table view with bulk actions, Insights (fit tiles, funnel, sources, shareable funnel-flow SVG), campaigns, tags, CSV import, and Add/Edit/Detail slide-overs. Each application links a live Reactive Resume. AI "Application Copilot" (applications.ai.*): job-posting autofill, resume↔job match score (fit ring), resume tailoring, and cover-letter / follow-up drafting — via the user's configured provider. Board cards + table rows get context menus (edit / move / archive / delete). Charts are CSS/SVG (no new chart dep); adds a UI Checkbox. Also includes local TanStack devtools setup and toolchain bumps. Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f * feat(applications): close follow-up gaps + squash migrations Finish the deferred/open items on the applications tracker: - Cover-letter upload re-enabled. Fix the storage blocker by deriving the key extension from content type (buildFileKey/EXTENSION_BY_CONTENT_TYPE) instead of hardcoding .jpeg, so PDFs serve correctly and non-JPEG image avatars keep working under FLAG_DISABLE_IMAGE_PROCESSING. Add coverLetterUrl/coverLetterName columns + Documents-section upload/remove. - Contacts editor in the detail sheet (add/edit/remove, keyed per app). - Board caps rendered cards per column (COLUMN_PAGE_SIZE=50 + "Show more"). - Extract new Lingui messages across locales. - Guard coverLetterUrl to http(s)/relative at the API boundary. Squash the five branch-only application-table migrations (create -> +tags -> +cover-letter -> drop -> re-add) into a single clean CREATE TABLE via drizzle-kit generate. Claude-Session: https://claude.ai/code/session_01TEeRHnEayw2MFCShFRyL5f * chore: update dependencies * 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. * feat(applications): improve performance * chore: fix knip issues * perf(builder): halve per-keystroke render cost Section-form fields called `form.handleSubmit()` on every keystroke, which re-validated the whole form and toggled submit state — firing the render cascade twice per character (~6809 renders/keystroke, FPS dropping to 9). Persist via a form-level `listeners.onChange` instead and drop the per-field `handleSubmit()` (basics, custom-fields, design). Narrow header/dock resume subscriptions to metadata slices so they no longer re-render on content edits. Cuts renders 6809 -> 3403 per keystroke (50%), 0 frame drops. Save, preview, and design controls verified working; 449/449 web tests pass. * perf(home): eliminate hero CLS from unreserved video box The hero <section> is `flex items-center` (shrink-to-fit), so the video wrapper's width depended on the video's intrinsic size, which only resolves after the media loads. aspect-ratio couldn't reserve height without a definite width, so the video grew from ~190px to ~563px after first paint and shoved the centered hero text down ~373px (CLS ~0.095). Give the wrapper a definite width (w-full + mx-auto on the CometCard) and set an explicit aspect ratio + width/height on the video so its box is reserved before load. CLS 0.095 -> 0; hero stays visually centered at max-w-4xl. * docs: add application tracker guides * chore(db): squash application migrations * fix(email): import React in auth template for server-side rendering compatibility * chore(release): v5.2.1 * Refactor resume rendering and builder workflows * fix: address application tracker review findings
This commit is contained in:
@@ -9,9 +9,12 @@ import { DirectionProvider } from "@base-ui/react/direction-provider";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { IconContext } from "@phosphor-icons/react";
|
||||
import { TanStackDevtools } from "@tanstack/react-devtools";
|
||||
import { HotkeysProvider } from "@tanstack/react-hotkeys";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
|
||||
import { createRootRouteWithContext, HeadContent, Outlet, useRouterState } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
||||
import { domAnimation, LazyMotion, MotionConfig } from "motion/react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Toaster } from "@reactive-resume/ui/components/sonner";
|
||||
@@ -135,6 +138,21 @@ function RootComponent() {
|
||||
<Toaster richColors position="bottom-center" />
|
||||
|
||||
{import.meta.env.DEV && <BreakpointIndicator />}
|
||||
{import.meta.env.DEV && (
|
||||
<TanStackDevtools
|
||||
config={{ position: "bottom-left" }}
|
||||
plugins={[
|
||||
{
|
||||
name: "TanStack Query",
|
||||
render: <ReactQueryDevtoolsPanel />,
|
||||
},
|
||||
{
|
||||
name: "TanStack Router",
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</PromptDialogProvider>
|
||||
</ConfirmDialogProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -17,20 +17,27 @@ export function Hero() {
|
||||
<Spotlight />
|
||||
|
||||
<m.div
|
||||
className="will-change-[transform,opacity]"
|
||||
className="w-full will-change-[transform,opacity]"
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1.1, ease: "easeOut" }}
|
||||
>
|
||||
<CometCard glareOpacity={0} className="relative -mb-12 3xl:max-w-7xl max-w-4xl px-8 md:-mb-24 md:px-12 lg:px-0">
|
||||
<CometCard
|
||||
glareOpacity={0}
|
||||
className="relative mx-auto -mb-12 3xl:max-w-7xl max-w-4xl px-8 md:-mb-24 md:px-12 lg:px-0"
|
||||
>
|
||||
<video
|
||||
loop
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
width={1146}
|
||||
height={720}
|
||||
src="/videos/timelapse.mp4"
|
||||
aria-label={t`Timelapse demonstration of building a resume with Reactive Resume`}
|
||||
className="pointer-events-none size-full rounded-md border object-cover"
|
||||
// Reserve the intrinsic aspect ratio so the box height is known before the video
|
||||
// metadata loads — otherwise it reflows the centered hero column and causes CLS (~0.10).
|
||||
className="pointer-events-none aspect-[1146/720] w-full rounded-md border object-cover"
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,11 @@ import {
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { isEditableElementFocused, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft";
|
||||
import {
|
||||
isEditableElementFocused,
|
||||
useCurrentBuilderResumeSelector,
|
||||
useResumeStore,
|
||||
} from "@/features/resume/builder/draft";
|
||||
import { authClient } from "@/libs/auth/client";
|
||||
|
||||
type BuilderDockProps = {
|
||||
@@ -38,7 +42,9 @@ type BuilderDockProps = {
|
||||
|
||||
export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps) {
|
||||
const { data: session } = authClient.useSession();
|
||||
const resume = useCurrentResume();
|
||||
// Narrow slices: selecting the whole resume re-renders the dock on every keystroke.
|
||||
const resumeSlug = useCurrentBuilderResumeSelector((resume) => resume.slug);
|
||||
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
@@ -67,9 +73,9 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
|
||||
});
|
||||
|
||||
const publicUrl = useMemo(() => {
|
||||
if (!session?.user.username || !resume?.slug) return "";
|
||||
return `${window.location.origin}/${session.user.username}/${resume.slug}`;
|
||||
}, [session?.user.username, resume?.slug]);
|
||||
if (!session?.user.username || !resumeSlug) return "";
|
||||
return `${window.location.origin}/${session.user.username}/${resumeSlug}`;
|
||||
}, [session?.user.username, resumeSlug]);
|
||||
|
||||
const onCopyUrl = useCallback(async () => {
|
||||
await copyToClipboard(publicUrl);
|
||||
@@ -100,8 +106,8 @@ export function BuilderDock({ pageLayout, onTogglePageLayout }: BuilderDockProps
|
||||
icon={ChatCircleDotsIcon}
|
||||
title={t`Open AI agent`}
|
||||
onClick={() => {
|
||||
if (!resume) return;
|
||||
void navigate({ to: "/agent/new", search: { resumeId: resume.id } });
|
||||
if (!resumeId) return;
|
||||
void navigate({ to: "/agent/new", search: { resumeId } });
|
||||
}}
|
||||
/>
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
|
||||
@@ -27,7 +27,12 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useCurrentResume, usePatchResume, useResumeStore } from "@/features/resume/builder/draft";
|
||||
import {
|
||||
useCurrentBuilderResumeSelector,
|
||||
useCurrentResume,
|
||||
usePatchResume,
|
||||
useResumeStore,
|
||||
} from "@/features/resume/builder/draft";
|
||||
import { ResumeDownloadDialog } from "@/features/resume/export/download-dialog";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
@@ -37,9 +42,11 @@ import { BuilderAiAssistant } from "./ai-assistant";
|
||||
import { BuilderVersionHistory } from "./version-history";
|
||||
|
||||
export function BuilderHeader() {
|
||||
const resume = useCurrentResume();
|
||||
const name = resume.name;
|
||||
const isLocked = resume.isLocked;
|
||||
// Subscribe to only the metadata fields this header renders. Selecting the whole resume re-renders
|
||||
// the header on every keystroke (immer replaces the resume reference on each content edit).
|
||||
const name = useCurrentBuilderResumeSelector((resume) => resume.name);
|
||||
const isLocked = useCurrentBuilderResumeSelector((resume) => resume.isLocked);
|
||||
const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id);
|
||||
const { toggleSidebar } = useBuilderSidebar();
|
||||
|
||||
// Equal-width flex-1 side groups keep the center title group truly centered regardless of the
|
||||
@@ -77,8 +84,8 @@ export function BuilderHeader() {
|
||||
<h2 className="min-w-0 truncate font-medium">{name}</h2>
|
||||
{isLocked && <LockSimpleIcon className="ms-2 text-muted-foreground" />}
|
||||
<SaveStatusIndicator />
|
||||
<BuilderAiAssistant resumeId={resume.id} />
|
||||
<BuilderVersionHistory resumeId={resume.id} />
|
||||
<BuilderAiAssistant resumeId={resumeId} />
|
||||
<BuilderVersionHistory resumeId={resumeId} />
|
||||
<BuilderHeaderDropdown />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function AwardsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.awards;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.awards);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof awardItemSchema>[]) => {
|
||||
|
||||
@@ -35,6 +35,14 @@ function BasicsSectionForm() {
|
||||
const form = useAppForm({
|
||||
defaultValues: basics,
|
||||
validators: { onChange: formSchema },
|
||||
// Persist on every field change via a form-level listener. Previously each field called
|
||||
// `form.handleSubmit()` on change, which re-validated the whole form AND toggled submit state —
|
||||
// firing the render cascade twice per keystroke. A listener persists once, without the submit churn.
|
||||
listeners: {
|
||||
onChange: ({ formApi }) => {
|
||||
persist(formApi.state.values);
|
||||
},
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
@@ -64,7 +72,6 @@ function BasicsSectionForm() {
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -88,7 +95,6 @@ function BasicsSectionForm() {
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -113,7 +119,6 @@ function BasicsSectionForm() {
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -137,7 +142,6 @@ function BasicsSectionForm() {
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -161,7 +165,6 @@ function BasicsSectionForm() {
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -182,7 +185,6 @@ function BasicsSectionForm() {
|
||||
value={field.state.value}
|
||||
onChange={(value) => {
|
||||
field.handleChange(value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
<FormMessage errors={field.state.meta.errors} />
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function CertificationsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.certifications;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.certifications);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof certificationItemSchema>[]) => {
|
||||
|
||||
@@ -37,7 +37,6 @@ export const CustomFieldsSection = withForm({
|
||||
values={customFieldsField.state.value}
|
||||
onReorder={(fields) => {
|
||||
customFieldsField.setValue(fields);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
{customFieldsField.state.value.map((field: CustomField, index: number) => (
|
||||
@@ -53,7 +52,6 @@ export const CustomFieldsSection = withForm({
|
||||
className="rounded-r-none! border-e-0!"
|
||||
onChange={(icon) => {
|
||||
iconField.handleChange(icon);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -73,7 +71,6 @@ export const CustomFieldsSection = withForm({
|
||||
className="rounded-l-none!"
|
||||
onChange={(e) => {
|
||||
textField.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -109,7 +106,6 @@ export const CustomFieldsSection = withForm({
|
||||
})}
|
||||
onChange={(e) => {
|
||||
linkField.handleChange(e.target.value);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -124,7 +120,6 @@ export const CustomFieldsSection = withForm({
|
||||
aria-label={t`Remove custom field`}
|
||||
onClick={() => {
|
||||
customFieldsField.removeValue(index);
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
@@ -136,7 +131,6 @@ export const CustomFieldsSection = withForm({
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
customFieldsField.pushValue({ id: generateId(), icon: "acorn", text: "", link: "" });
|
||||
void form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<ListPlusIcon />
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
import { stripHtml } from "@reactive-resume/utils/string";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getSectionTitle } from "@/libs/resume/section";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
@@ -122,8 +122,7 @@ function getItemSubtitle(type: CustomSectionType, item: CustomSectionItemType):
|
||||
}
|
||||
|
||||
export function CustomSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const customSections = resume.data.customSections;
|
||||
const customSections = useCurrentBuilderResumeSelector((resume) => resume.data.customSections);
|
||||
|
||||
return (
|
||||
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
|
||||
|
||||
+9
-7
@@ -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", () => ({
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function EducationSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.education;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.education);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof educationItemSchema>[]) => {
|
||||
|
||||
@@ -4,13 +4,12 @@ import { plural } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ExperienceSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.experience;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.experience);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof experienceItemSchema>[]) => {
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function InterestsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.interests;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.interests);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof interestItemSchema>[]) => {
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function LanguagesSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.languages;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.languages);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof languageItemSchema>[]) => {
|
||||
|
||||
+14
-12
@@ -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", () => ({
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
import { Slider } from "@reactive-resume/ui/components/slider";
|
||||
import "react-easy-crop/react-easy-crop.css";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
|
||||
import { getReadableErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
@@ -447,8 +447,7 @@ function PictureSectionForm() {
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
|
||||
const resume = useCurrentResume();
|
||||
const picture = resume.data.picture;
|
||||
const picture = useCurrentBuilderResumeSelector((resume) => resume.data.picture);
|
||||
const normalizedPictureUrl = normalizePictureUrl(picture.url, appOrigin);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ProfilesSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.profiles;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.profiles);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof profileItemSchema>[]) => {
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -3,13 +3,17 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
const buildSubtitle = (item: z.infer<typeof projectItemSchema>) => {
|
||||
const parts = [item.period, item.website.label].filter((part) => part && part.trim().length > 0);
|
||||
return parts.length > 0 ? parts.join(" • ") : undefined;
|
||||
};
|
||||
|
||||
export function ProjectsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.projects;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.projects);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof projectItemSchema>[]) => {
|
||||
@@ -18,11 +22,6 @@ export function ProjectsSectionBuilder() {
|
||||
});
|
||||
};
|
||||
|
||||
const buildSubtitle = (item: z.infer<typeof projectItemSchema>) => {
|
||||
const parts = [item.period, item.website.label].filter((part) => part && part.trim().length > 0);
|
||||
return parts.length > 0 ? parts.join(" • ") : undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionBase type="projects" className={cn("rounded-md border", section.items.length === 0 && "border-dashed")}>
|
||||
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function PublicationsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.publications;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.publications);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof publicationItemSchema>[]) => {
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function ReferencesSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.references;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.references);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof referenceItemSchema>[]) => {
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function SkillsSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.skills;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.skills);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof skillItemSchema>[]) => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function SummarySectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.summary;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.summary);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const onChange = (value: string) => {
|
||||
|
||||
@@ -3,13 +3,12 @@ import type z from "zod";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { AnimatePresence, Reorder } from "motion/react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
|
||||
|
||||
export function VolunteerSectionBuilder() {
|
||||
const resume = useCurrentResume();
|
||||
const section = resume.data.sections.volunteer;
|
||||
const section = useCurrentBuilderResumeSelector((resume) => resume.data.sections.volunteer);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const handleReorder = (items: z.infer<typeof volunteerItemSchema>[]) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@r
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { IconPicker } from "@/components/input/icon-picker";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { getSectionIcon, getSectionTitle } from "@/libs/resume/section";
|
||||
import { useSectionStore } from "../../../-store/section";
|
||||
import { SectionDropdownMenu } from "./section-menu";
|
||||
@@ -17,11 +17,13 @@ type Props = React.ComponentProps<typeof AccordionContent> & {
|
||||
};
|
||||
|
||||
export function SectionBase({ type, className, ...props }: Props) {
|
||||
const resume = useCurrentResume();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
const data = resume.data;
|
||||
const section =
|
||||
type === "basics"
|
||||
// Subscribe to only this section's slice, not the whole resume. Otherwise editing any field
|
||||
// (which replaces the resume reference) re-renders all ~15 section wrappers on every keystroke.
|
||||
// Immer keeps untouched slices reference-stable, so Zustand bails out of the unrelated sections.
|
||||
const section = useCurrentBuilderResumeSelector((resume) => {
|
||||
const data = resume.data;
|
||||
return type === "basics"
|
||||
? data.basics
|
||||
: type === "summary"
|
||||
? data.summary
|
||||
@@ -30,6 +32,7 @@ export function SectionBase({ type, className, ...props }: Props) {
|
||||
: type === "custom"
|
||||
? data.customSections
|
||||
: data.sections[type];
|
||||
});
|
||||
|
||||
const isHidden = "hidden" in section && section.hidden;
|
||||
const hasSectionIcon = !["picture", "basics", "custom"].includes(type);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -36,6 +36,11 @@ function useColorSectionForm(colors: ColorValues, persist: (data: ColorValues) =
|
||||
const form = useAppForm({
|
||||
defaultValues: colors,
|
||||
validators: { onChange: colorDesignSchema },
|
||||
listeners: {
|
||||
onChange: ({ formApi }) => {
|
||||
persist(formApi.state.values);
|
||||
},
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
@@ -59,10 +64,6 @@ function ColorSectionForm() {
|
||||
|
||||
const form = useColorSectionForm(colors, persist);
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
@@ -85,7 +86,6 @@ function ColorSectionForm() {
|
||||
active={color === field.state.value}
|
||||
onSelect={(color) => {
|
||||
field.handleChange(color as string);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -93,20 +93,9 @@ function ColorSectionForm() {
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<ColorFormField
|
||||
form={form}
|
||||
name="primary"
|
||||
label={<Trans>Primary Color</Trans>}
|
||||
controlled
|
||||
handleAutoSave={handleAutoSave}
|
||||
/>
|
||||
<ColorFormField form={form} name="text" label={<Trans>Text Color</Trans>} handleAutoSave={handleAutoSave} />
|
||||
<ColorFormField
|
||||
form={form}
|
||||
name="background"
|
||||
label={<Trans>Background Color</Trans>}
|
||||
handleAutoSave={handleAutoSave}
|
||||
/>
|
||||
<ColorFormField form={form} name="primary" label={<Trans>Primary Color</Trans>} controlled />
|
||||
<ColorFormField form={form} name="text" label={<Trans>Text Color</Trans>} />
|
||||
<ColorFormField form={form} name="background" label={<Trans>Background Color</Trans>} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -116,10 +105,9 @@ type ColorFormFieldProps = {
|
||||
name: keyof ColorValues;
|
||||
label: ReactNode;
|
||||
controlled?: boolean;
|
||||
handleAutoSave: () => void;
|
||||
};
|
||||
|
||||
function ColorFormField({ form, name, label, controlled, handleAutoSave }: ColorFormFieldProps) {
|
||||
function ColorFormField({ form, name, label, controlled }: ColorFormFieldProps) {
|
||||
return (
|
||||
<form.Field name={name}>
|
||||
{(field) => (
|
||||
@@ -130,7 +118,6 @@ function ColorFormField({ form, name, label, controlled, handleAutoSave }: Color
|
||||
{...(controlled ? { value: field.state.value } : { defaultValue: field.state.value })}
|
||||
onChange={(color) => {
|
||||
field.handleChange(color);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
<FormControl
|
||||
@@ -141,7 +128,6 @@ function ColorFormField({ form, name, label, controlled, handleAutoSave }: Color
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => {
|
||||
field.handleChange(e.target.value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -235,16 +221,17 @@ function LevelSectionForm() {
|
||||
const form = useAppForm({
|
||||
defaultValues: levelDesign,
|
||||
validators: { onChange: levelDesignSchema },
|
||||
listeners: {
|
||||
onChange: ({ formApi }) => {
|
||||
persist(formApi.state.values);
|
||||
},
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
persist(value);
|
||||
},
|
||||
});
|
||||
useSyncFormValues(form, levelDesign);
|
||||
|
||||
const handleAutoSave = () => {
|
||||
persist(form.state.values);
|
||||
};
|
||||
|
||||
const previewType = useStore(form.store, (s) => s.values.type);
|
||||
const previewIcon = useStore(form.store, (s) => s.values.icon);
|
||||
const iconFontSize = resolveStyleRuleFontSize(resume.data, { slot: "icon" });
|
||||
@@ -296,7 +283,6 @@ function LevelSectionForm() {
|
||||
value={field.state.value}
|
||||
onChange={(value) => {
|
||||
field.handleChange(value);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
@@ -318,7 +304,6 @@ function LevelSectionForm() {
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
field.handleChange(value as LevelType);
|
||||
handleAutoSave();
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ const renderExport = () =>
|
||||
|
||||
const openDialog = () => {
|
||||
const trigger = screen.getByText(
|
||||
"Choose PDF, DOCX, or JSON. Export your resume and cover letter separately when available.",
|
||||
"Choose PDF, DOCX, Markdown, or JSON. Export your resume and cover letter separately when available.",
|
||||
);
|
||||
fireEvent.click(trigger.closest("button") as HTMLButtonElement);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,9 @@ export function ExportSectionBuilder() {
|
||||
<Trans>Download</Trans>
|
||||
</h6>
|
||||
<p className="text-muted-foreground text-xs leading-normal">
|
||||
<Trans>Choose PDF, DOCX, or JSON. Export your resume and cover letter separately when available.</Trans>
|
||||
<Trans>
|
||||
Choose PDF, DOCX, Markdown, or JSON. Export your resume and cover letter separately when available.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -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,11 +1,12 @@
|
||||
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";
|
||||
|
||||
type PanelImperativeHandle = ReturnType<typeof usePanelRef>;
|
||||
|
||||
export const BUILDER_LAYOUT_COOKIE_NAME = "builder_layout";
|
||||
const BUILDER_LAYOUT_COOKIE_NAME = "builder_layout";
|
||||
|
||||
export type BuilderLayout = {
|
||||
left: number;
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
BrainIcon,
|
||||
BriefcaseIcon,
|
||||
ChatCircleDotsIcon,
|
||||
GearSixIcon,
|
||||
KeyIcon,
|
||||
@@ -50,6 +51,11 @@ const appSidebarItems = [
|
||||
label: msg`Resumes`,
|
||||
href: "/dashboard/resumes",
|
||||
},
|
||||
{
|
||||
icon: <BriefcaseIcon />,
|
||||
label: msg`Applications`,
|
||||
href: "/dashboard/applications",
|
||||
},
|
||||
{
|
||||
icon: <ChatCircleDotsIcon />,
|
||||
label: msg`Agents`,
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { Application } from "@/features/applications/types";
|
||||
import { msg, t } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
BriefcaseIcon,
|
||||
ChartBarIcon,
|
||||
DownloadSimpleIcon,
|
||||
FunnelIcon,
|
||||
KanbanIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PlusIcon,
|
||||
RowsIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, stripSearchParams, useNavigate } from "@tanstack/react-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import z from "zod";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@reactive-resume/ui/components/input-group";
|
||||
import { Label } from "@reactive-resume/ui/components/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { ApplicationDetailSheet } from "@/features/applications/components/application-detail-sheet";
|
||||
import { ApplicationFormSheet } from "@/features/applications/components/application-form-sheet";
|
||||
import { ApplicationBoard } from "@/features/applications/components/board";
|
||||
import { ImportApplicationsSheet } from "@/features/applications/components/import-applications-sheet";
|
||||
import { ApplicationInsights } from "@/features/applications/components/insights-view";
|
||||
import { ApplicationTable } from "@/features/applications/components/table-view";
|
||||
import { applicationsListQueryOptions } from "@/features/applications/queries";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { DashboardHeader } from "../-components/header";
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "updated", label: msg`Last updated` },
|
||||
{ value: "applied", label: msg`Date applied` },
|
||||
{ value: "company", label: msg`Company A–Z` },
|
||||
{ value: "role", label: msg`Role A–Z` },
|
||||
] as const;
|
||||
|
||||
type SortKey = (typeof SORT_OPTIONS)[number]["value"];
|
||||
|
||||
const searchSchema = z.object({
|
||||
search: z.string().default(""),
|
||||
view: z.enum(["board", "table", "insights"]).default("board"),
|
||||
tags: z.array(z.string()).default([]),
|
||||
sort: z.enum(["updated", "applied", "company", "role"]).default("updated"),
|
||||
archived: z.boolean().default(false),
|
||||
});
|
||||
type Search = z.output<typeof searchSchema>;
|
||||
const defaultSearch: Search = { search: "", view: "board", tags: [], sort: "updated", archived: false };
|
||||
|
||||
export const Route = createFileRoute("/dashboard/applications/")({
|
||||
component: RouteComponent,
|
||||
validateSearch: searchSchema,
|
||||
search: { middlewares: [stripSearchParams(defaultSearch)] },
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { i18n } = useLingui();
|
||||
const { search, view, tags, sort, archived } = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Application | null>(null);
|
||||
const [selected, setSelected] = useState<Application | null>(null);
|
||||
|
||||
// Editing from the detail panel: close the panel, open the edit form on the same application.
|
||||
const startEdit = (application: Application) => {
|
||||
setSelected(null);
|
||||
setEditing(application);
|
||||
};
|
||||
|
||||
const { data: applications } = useQuery(applicationsListQueryOptions());
|
||||
const { data: allTags } = useQuery(orpc.applications.tags.queryOptions());
|
||||
|
||||
// Board & table hide archived; tag/search filters + sort are applied client-side.
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
const rows = (applications ?? [])
|
||||
.filter((app) => archived || !app.archived)
|
||||
.filter((app) => tags.length === 0 || tags.every((tag: string) => app.tags.includes(tag)))
|
||||
.filter((app) => !query || app.company.toLowerCase().includes(query) || app.role.toLowerCase().includes(query));
|
||||
|
||||
const compare: Record<SortKey, (a: Application, b: Application) => number> = {
|
||||
updated: (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
applied: (a, b) => new Date(b.appliedAt).getTime() - new Date(a.appliedAt).getTime(),
|
||||
company: (a, b) => a.company.localeCompare(b.company),
|
||||
role: (a, b) => a.role.localeCompare(b.role),
|
||||
};
|
||||
return rows.sort(compare[sort as SortKey]);
|
||||
}, [applications, search, tags, sort, archived]);
|
||||
|
||||
const archivedCount = (applications ?? []).filter((app) => app.archived).length;
|
||||
|
||||
const isEmpty = (applications?.length ?? 0) === 0;
|
||||
|
||||
const setSearch = (patch: Partial<Search>) => void navigate({ search: (prev: Search) => ({ ...prev, ...patch }) });
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-2rem)] flex-col gap-4">
|
||||
<DashboardHeader
|
||||
icon={BriefcaseIcon}
|
||||
title={t`Applications`}
|
||||
actions={
|
||||
!isEmpty ? (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Import CSV</Trans>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
<PlusIcon />
|
||||
<Trans>Add application</Trans>
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
{isEmpty ? (
|
||||
<EmptyState onAdd={() => setAddOpen(true)} onImport={() => setImportOpen(true)} />
|
||||
) : (
|
||||
<>
|
||||
{/* One row: search grows, filters stay fixed, icon-only view switcher on the right. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<InputGroup className="min-w-24 max-w-72 flex-1">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<MagnifyingGlassIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={search}
|
||||
placeholder={t`Search applications…`}
|
||||
onChange={(event) => setSearch({ search: event.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{/* Desktop: filters inline. Mobile: collapsed into the Filters popover below. */}
|
||||
{(allTags?.length ?? 0) > 0 && (
|
||||
<Combobox
|
||||
multiple
|
||||
className="w-40 min-w-0 shrink max-sm:hidden"
|
||||
value={tags}
|
||||
placeholder={t`Filter by tags`}
|
||||
options={(allTags ?? []).map((tag) => ({ value: tag, label: tag }))}
|
||||
onValueChange={(value) => setSearch({ tags: value ?? [] })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view !== "insights" && (
|
||||
<Combobox
|
||||
className="w-40 min-w-0 shrink max-sm:hidden"
|
||||
value={sort}
|
||||
placeholder={t`Sort by…`}
|
||||
options={SORT_OPTIONS.map((option) => ({ value: option.value, label: i18n.t(option.label) }))}
|
||||
onValueChange={(value) => value && setSearch({ sort: value as SortKey })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{archivedCount > 0 && view !== "insights" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={archived ? "secondary" : "outline"}
|
||||
className="shrink-0 max-sm:hidden"
|
||||
onClick={() => setSearch({ archived: !archived })}
|
||||
>
|
||||
<ArchiveIcon />
|
||||
<Trans>Archived</Trans> ({archivedCount})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Mobile-only: one button holds every filter so the row never overflows on a phone. */}
|
||||
{view !== "insights" && (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button size="icon-sm" variant="outline" className="relative shrink-0 sm:hidden">
|
||||
<FunnelIcon />
|
||||
{(tags.length > 0 || archived) && (
|
||||
<span className="absolute end-1 top-1 size-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-64 p-3">
|
||||
{(allTags?.length ?? 0) > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
<Trans>Filter by tags</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
multiple
|
||||
className="w-full"
|
||||
value={tags}
|
||||
placeholder={t`Any tag`}
|
||||
options={(allTags ?? []).map((tag) => ({ value: tag, label: tag }))}
|
||||
onValueChange={(value) => setSearch({ tags: value ?? [] })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
<Trans>Sort by</Trans>
|
||||
</Label>
|
||||
<Combobox
|
||||
className="w-full"
|
||||
value={sort}
|
||||
options={SORT_OPTIONS.map((option) => ({ value: option.value, label: i18n.t(option.label) }))}
|
||||
onValueChange={(value) => value && setSearch({ sort: value as SortKey })}
|
||||
/>
|
||||
</div>
|
||||
{archivedCount > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={archived ? "secondary" : "outline"}
|
||||
className="w-full"
|
||||
onClick={() => setSearch({ archived: !archived })}
|
||||
>
|
||||
<ArchiveIcon />
|
||||
<Trans>Archived</Trans> ({archivedCount})
|
||||
</Button>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<Tabs className="ms-auto shrink-0" value={view}>
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
value="board"
|
||||
title={i18n.t(msg`Board`)}
|
||||
nativeButton={false}
|
||||
render={<Link to="." search={(p: Search) => ({ ...p, view: "board" })} />}
|
||||
>
|
||||
<KanbanIcon />
|
||||
<span className="sr-only">{i18n.t(msg`Board`)}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="table"
|
||||
title={i18n.t(msg`Table`)}
|
||||
nativeButton={false}
|
||||
render={<Link to="." search={(p: Search) => ({ ...p, view: "table" })} />}
|
||||
>
|
||||
<RowsIcon />
|
||||
<span className="sr-only">{i18n.t(msg`Table`)}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="insights"
|
||||
title={i18n.t(msg`Insights`)}
|
||||
nativeButton={false}
|
||||
render={<Link to="." search={(p: Search) => ({ ...p, view: "insights" })} />}
|
||||
>
|
||||
<ChartBarIcon />
|
||||
<span className="sr-only">{i18n.t(msg`Insights`)}</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{view !== "insights" && filtered.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center">
|
||||
<p className="font-medium text-sm">
|
||||
<Trans>No applications match your filters.</Trans>
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSearch({ search: "", tags: [], archived: false })}
|
||||
>
|
||||
<Trans>Clear filters</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{view === "board" && (
|
||||
<ApplicationBoard applications={filtered} onOpen={setSelected} onEdit={setEditing} />
|
||||
)}
|
||||
{view === "table" && (
|
||||
<ApplicationTable applications={filtered} onOpen={setSelected} onEdit={setEditing} />
|
||||
)}
|
||||
{view === "insights" && <ApplicationInsights applications={applications ?? []} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ApplicationFormSheet open={addOpen} onOpenChange={setAddOpen} />
|
||||
<ApplicationFormSheet open={!!editing} application={editing} onOpenChange={(open) => !open && setEditing(null)} />
|
||||
<ImportApplicationsSheet open={importOpen} onOpenChange={setImportOpen} />
|
||||
<ApplicationDetailSheet
|
||||
application={selected}
|
||||
onOpenChange={(open) => !open && setSelected(null)}
|
||||
onEdit={startEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ onAdd, onImport }: { onAdd: () => void; onImport: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<BriefcaseIcon className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<h2 className="font-semibold text-lg">
|
||||
<Trans>Track your first application</Trans>
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans>
|
||||
Add a job you're applying to, link the resume you sent, and move it through your pipeline as things
|
||||
progress.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onAdd}>
|
||||
<PlusIcon />
|
||||
<Trans>Add application</Trans>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onImport}>
|
||||
<DownloadSimpleIcon />
|
||||
<Trans>Import from CSV</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user