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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user