mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 23:32:19 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user