mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-14 06:47:00 +10:00
b404dbd42a
* 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
165 lines
5.6 KiB
TypeScript
165 lines
5.6 KiB
TypeScript
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
|
import type { ApplicationStatus } from "@reactive-resume/schema/applications/data";
|
|
import type { Application } from "../types";
|
|
import {
|
|
DndContext,
|
|
DragOverlay,
|
|
PointerSensor,
|
|
pointerWithin,
|
|
useDraggable,
|
|
useDroppable,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import { t } from "@lingui/core/macro";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useMemo, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { STAGES } from "@reactive-resume/schema/applications/data";
|
|
import { cn } from "@reactive-resume/utils/style";
|
|
import { orpc } from "@/libs/orpc/client";
|
|
import { applicationsListQueryKey } from "../queries";
|
|
import { ApplicationCard } from "./application-card";
|
|
|
|
type Props = {
|
|
applications: Application[];
|
|
onOpen: (application: Application) => void;
|
|
onEdit: (application: Application) => void;
|
|
};
|
|
|
|
export function ApplicationBoard({ applications, onOpen, onEdit }: Props) {
|
|
const queryClient = useQueryClient();
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
|
|
// A small activation distance so a click still opens the detail panel instead of starting a drag.
|
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
|
|
|
const listKey = applicationsListQueryKey();
|
|
|
|
const move = useMutation(
|
|
orpc.applications.update.mutationOptions({
|
|
onMutate: async ({ id, status }) => {
|
|
await queryClient.cancelQueries({ queryKey: listKey });
|
|
const previous = queryClient.getQueryData<Application[]>(listKey);
|
|
queryClient.setQueryData<Application[]>(listKey, (rows) =>
|
|
(rows ?? []).map((row) => (row.id === id && status ? { ...row, status } : row)),
|
|
);
|
|
return { previous };
|
|
},
|
|
onError: (_error, _vars, context) => {
|
|
if (context?.previous) queryClient.setQueryData(listKey, context.previous);
|
|
toast.error(t`Couldn't move the application. Please try again.`);
|
|
},
|
|
onSettled: () => void queryClient.invalidateQueries({ queryKey: listKey }),
|
|
}),
|
|
);
|
|
|
|
const byStage = useMemo(() => {
|
|
const map = new Map<ApplicationStatus, Application[]>(STAGES.map((s) => [s.value, []]));
|
|
for (const app of applications) map.get(app.status)?.push(app);
|
|
return map;
|
|
}, [applications]);
|
|
|
|
const activeApp = activeId ? applications.find((a) => a.id === activeId) : null;
|
|
|
|
const onDragStart = (event: DragStartEvent) => setActiveId(String(event.active.id));
|
|
|
|
const onDragEnd = (event: DragEndEvent) => {
|
|
setActiveId(null);
|
|
const { active, over } = event;
|
|
if (!over) return;
|
|
const target = over.id as ApplicationStatus;
|
|
const app = applications.find((a) => a.id === active.id);
|
|
if (!app || app.status === target) return;
|
|
move.mutate({ id: app.id, status: target });
|
|
};
|
|
|
|
return (
|
|
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
|
<div className="flex h-full min-h-0 gap-4 overflow-x-auto pb-4">
|
|
{STAGES.map((stage) => (
|
|
<Column
|
|
key={stage.value}
|
|
stage={stage}
|
|
applications={byStage.get(stage.value) ?? []}
|
|
onOpen={onOpen}
|
|
onEdit={onEdit}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<DragOverlay>{activeApp ? <ApplicationCard application={activeApp} dragging /> : null}</DragOverlay>
|
|
</DndContext>
|
|
);
|
|
}
|
|
|
|
type ColumnProps = {
|
|
stage: (typeof STAGES)[number];
|
|
applications: Application[];
|
|
onOpen: (application: Application) => void;
|
|
onEdit: (application: Application) => void;
|
|
};
|
|
|
|
// Cap the cards rendered per column so a stage with hundreds of applications doesn't mount
|
|
// hundreds of draggable nodes at once. Users reveal the rest in batches. Server-side paging
|
|
// isn't needed here — the list payload is small; the cost is DOM/drag nodes.
|
|
const COLUMN_PAGE_SIZE = 50;
|
|
|
|
function Column({ stage, applications, onOpen, onEdit }: ColumnProps) {
|
|
const { setNodeRef, isOver } = useDroppable({ id: stage.value });
|
|
const [visible, setVisible] = useState(COLUMN_PAGE_SIZE);
|
|
|
|
const shown = applications.slice(0, visible);
|
|
const remaining = applications.length - shown.length;
|
|
|
|
return (
|
|
<div className="flex w-72 shrink-0 flex-col rounded-2xl border border-border bg-muted/30">
|
|
<div className="flex items-center gap-2 px-3.5 py-3">
|
|
<span className="size-2.5 rounded-sm" style={{ background: stage.color }} />
|
|
<span className="font-semibold text-sm tracking-tight">{stage.label}</span>
|
|
<span className="rounded-full bg-muted px-2 py-0.5 font-semibold text-muted-foreground text-xs">
|
|
{applications.length}
|
|
</span>
|
|
</div>
|
|
<div
|
|
ref={setNodeRef}
|
|
className={cn(
|
|
"flex min-h-24 flex-1 flex-col gap-2.5 overflow-y-auto px-2.5 pb-3 transition-colors",
|
|
isOver && "bg-muted/60",
|
|
)}
|
|
>
|
|
{shown.map((app) => (
|
|
<DraggableCard key={app.id} application={app} onOpen={() => onOpen(app)} onEdit={onEdit} />
|
|
))}
|
|
{remaining > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setVisible((v) => v + COLUMN_PAGE_SIZE)}
|
|
className="rounded-lg border border-border border-dashed py-2 text-muted-foreground text-xs hover:bg-muted/60"
|
|
>
|
|
{t`Show ${Math.min(remaining, COLUMN_PAGE_SIZE)} more`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DraggableCard({
|
|
application,
|
|
onOpen,
|
|
onEdit,
|
|
}: {
|
|
application: Application;
|
|
onOpen: () => void;
|
|
onEdit: (application: Application) => void;
|
|
}) {
|
|
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ id: application.id });
|
|
|
|
return (
|
|
<div ref={setNodeRef} {...attributes} {...listeners} className={cn(isDragging && "opacity-30")}>
|
|
<ApplicationCard application={application} onClick={onOpen} onEdit={onEdit} />
|
|
</div>
|
|
);
|
|
}
|