init(app): Setup client package for web api specific logic

This commit is contained in:
Prad Nukala
2026-07-02 17:33:29 -04:00
parent f56116a26b
commit bcfeea93c9
27 changed files with 3104 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
import { useState } from "react";
import type { JobExtract } from "../../shared/types";
import { ApiRequestError, post } from "../api";
const MIN_TEXT_CHARS = 120;
export function Home({ onExtracted }: { onExtracted: (jd: JobExtract) => void }) {
const [mode, setMode] = useState<"url" | "paste">("url");
const [url, setUrl] = useState("");
const [title, setTitle] = useState("");
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submitUrl(): Promise<void> {
if (busy || url.trim().length === 0) return;
setBusy(true);
setError("");
try {
onExtracted(await post<JobExtract>("/api/extract", { url }));
} catch (err) {
setError(err instanceof ApiRequestError ? err.message : String(err));
setBusy(false);
}
}
function submitPaste(): void {
const trimmedTitle = title.trim();
const trimmedText = text.trim();
if (trimmedTitle.length === 0) {
setError("Give the job opportunity a title.");
return;
}
if (trimmedText.length < MIN_TEXT_CHARS) {
setError(`Paste the full job description (at least ${MIN_TEXT_CHARS} characters).`);
return;
}
onExtracted({ url: "", title: trimmedTitle, text: trimmedText, source: "manual" });
}
function switchMode(next: "url" | "paste"): void {
setMode(next);
setError("");
}
return (
<div className="home">
<div className="home-card">
<h1>CV Tailor</h1>
<p className="sub">
{mode === "url"
? "Paste a link to a job posting. The résumé corpus is matched against it, you refine the selection with an AI agent, and a one-page LaTeX résumé is compiled to PDF."
: "Paste the job description below. The résumé corpus is matched against it, you refine the selection with an AI agent, and a one-page LaTeX résumé is compiled to PDF."}
</p>
{mode === "url" ? (
<form
className="home-form"
onSubmit={(e) => {
e.preventDefault();
void submitUrl();
}}
>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://boards.greenhouse.io/company/jobs/123456"
autoFocus
required
/>
<button type="submit" className="btn primary" disabled={busy}>
{busy ? "Extracting…" : "Tailor Résumé"}
</button>
</form>
) : (
<form
className="paste-form"
onSubmit={(e) => {
e.preventDefault();
submitPaste();
}}
>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Job opportunity title, e.g. Senior Platform Engineer at Aptos"
autoFocus
required
/>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Paste the full job description here…"
rows={12}
required
/>
<button type="submit" className="btn primary">
Tailor Résumé
</button>
</form>
)}
{error && <div className="error">{error}</div>}
{mode === "url" ? (
<>
<p className="hint">
Server-rendered boards (Greenhouse, Lever, Workable, Ashby job-post pages) extract
best.
</p>
<button type="button" className="link" onClick={() => switchMode("paste")}>
Or manually paste job description
</button>
</>
) : (
<button type="button" className="link" onClick={() => switchMode("url")}>
Or extract from a job posting URL
</button>
)}
</div>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useState } from "react";
import type { GeneratedResume } from "../App";
import { ApiRequestError, compilePdf } from "../api";
interface PdfScreenProps {
result: GeneratedResume;
onBack: () => void;
onRecompiled: (result: GeneratedResume) => void;
}
export function PdfScreen({ result, onBack, onRecompiled }: PdfScreenProps) {
const [latex, setLatex] = useState(result.latex);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const dirty = latex !== result.latex;
async function recompile(): Promise<void> {
if (busy) return;
setBusy(true);
setError("");
try {
const blob = await compilePdf(latex);
URL.revokeObjectURL(result.pdfUrl);
onRecompiled({ ...result, latex, pdfUrl: URL.createObjectURL(blob) });
} catch (err) {
if (err instanceof ApiRequestError && err.log) {
setError(`${err.message}\n\n${err.log}`);
} else {
setError(err instanceof Error ? err.message : String(err));
}
} finally {
setBusy(false);
}
}
return (
<div className="pdf-screen">
<header className="topbar">
<button type="button" className="btn ghost" onClick={onBack}>
Back to workspace
</button>
<div className="topbar-title">
<strong>Tailored résumé</strong>
<span className="muted">
plan source: {result.source}
{result.note ? ` (${result.note})` : ""}
</span>
</div>
<div className="topbar-actions">
<button type="button" className="btn" onClick={() => void recompile()} disabled={busy || !dirty}>
{busy ? "Compiling…" : "Recompile"}
</button>
<a className="btn primary" href={result.pdfUrl} download="prad-nukala-resume.pdf">
Download PDF
</a>
</div>
</header>
{error && <pre className="error banner">{error}</pre>}
<div className="pdf-grid">
<section className="panel">
<h2>LaTeX source {dirty && <span className="muted">(edited recompile to refresh preview)</span>}</h2>
<textarea
className="latex-editor"
value={latex}
onChange={(e) => setLatex(e.target.value)}
spellCheck={false}
/>
</section>
<section className="panel">
<h2>Preview</h2>
<iframe className="preview-frame" title="PDF preview" src={result.pdfUrl} />
</section>
</div>
</div>
);
}
+198
View File
@@ -0,0 +1,198 @@
import { useEffect, useRef, useState } from "react";
import type { ChatMessage, JobExtract, MatchResult, PlanResponse } from "../../shared/types";
import type { GeneratedResume } from "../App";
import { ApiRequestError, compilePdf, post } from "../api";
interface WorkspaceProps {
jd: JobExtract;
messages: ChatMessage[];
onMessagesChange: (messages: ChatMessage[]) => void;
onBack: () => void;
onGenerated: (result: GeneratedResume) => void;
}
export function Workspace({ jd, messages, onMessagesChange, onBack, onGenerated }: WorkspaceProps) {
const [match, setMatch] = useState<MatchResult | null>(null);
const [matchError, setMatchError] = useState("");
const [draft, setDraft] = useState("");
const [chatBusy, setChatBusy] = useState(false);
const [genStep, setGenStep] = useState("");
const [genError, setGenError] = useState("");
const chatEndRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
let cancelled = false;
post<MatchResult>("/api/match", { text: jd.text })
.then((m) => {
if (!cancelled) setMatch(m);
})
.catch((err: unknown) => {
if (!cancelled) setMatchError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
};
}, [jd.text]);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, chatBusy]);
async function sendChat(): Promise<void> {
const content = draft.trim();
if (content.length === 0 || chatBusy || !match) return;
const next: ChatMessage[] = [...messages, { role: "user", content }];
onMessagesChange(next);
setDraft("");
setChatBusy(true);
try {
const { reply } = await post<{ reply: string }>("/api/chat", { jd, match, messages: next });
onMessagesChange([...next, { role: "assistant", content: reply }]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
onMessagesChange([...next, { role: "assistant", content: `(error) ${message}` }]);
} finally {
setChatBusy(false);
}
}
async function generate(): Promise<void> {
if (!match || genStep) return;
setGenError("");
try {
setGenStep("Planning selection with AI…");
const planRes = await post<PlanResponse>("/api/plan", { jd, match, chat: messages });
setGenStep("Rendering LaTeX…");
const { latex } = await post<{ latex: string }>("/api/latex", { plan: planRes.plan });
setGenStep("Compiling PDF…");
const blob = await compilePdf(latex);
onGenerated({
plan: planRes.plan,
source: planRes.source,
note: planRes.note,
latex,
pdfUrl: URL.createObjectURL(blob),
});
} catch (err) {
if (err instanceof ApiRequestError && err.log) {
setGenError(`${err.message}\n\n${err.log}`);
} else {
setGenError(err instanceof Error ? err.message : String(err));
}
setGenStep("");
}
}
const matchedItems = match?.items.filter((i) => i.score > 0) ?? [];
return (
<div className="workspace">
<header className="topbar">
<button type="button" className="btn ghost" onClick={onBack}>
New posting
</button>
<div className="topbar-title">
<strong>{jd.title}</strong>
<span className="muted">
{jd.source === "manual" ? "pasted manually" : `${jd.url} · extracted via ${jd.source}`}
</span>
</div>
<button type="button" className="btn primary" onClick={() => void generate()} disabled={!match || genStep !== ""}>
{genStep !== "" ? genStep : "Generate PDF"}
</button>
</header>
{genError && <pre className="error banner">{genError}</pre>}
<div className="workspace-grid">
<section className="panel">
<h2>Job Posting</h2>
<pre className="jd-text">{jd.text}</pre>
</section>
<section className="panel">
<h2>
Matched Material
{match && <span className="muted"> {match.skills.length} skills hit</span>}
</h2>
{matchError && <div className="error">{matchError}</div>}
{!match && !matchError && <div className="loading">Matching against the corpus</div>}
{match && (
<>
<div className="chips">
{match.skills.map((s) => (
<span key={s.name} className={`chip ${s.category}`} title={s.category}>
{s.name} <em>×{s.hits}</em>
</span>
))}
{match.skills.length === 0 && <span className="muted">No skill overlap found.</span>}
</div>
<div className="items">
{matchedItems.map((item) => (
<article key={`${item.type}:${item.slug}`} className="item-card">
<header>
<span className={`badge ${item.type}`}>{item.type}</span>
<strong>{item.title}</strong>
<span className="muted">
{item.company ? `${item.company} · ` : ""}
{item.dates} · score {item.score}
</span>
</header>
<div className="mini-chips">
{item.matchedSkills.map((s) => (
<span key={s} className="chip mini">
{s}
</span>
))}
</div>
<ul>
{item.bullets.slice(0, 3).map((b) => (
<li key={b.slice(0, 40)}>{b}</li>
))}
</ul>
</article>
))}
</div>
</>
)}
</section>
</div>
<section className="chat panel">
<h2>Refine with the agent</h2>
<div className="chat-log">
{messages.length === 0 && (
<div className="muted chat-empty">
Ask the agent to emphasize certain skills, pick different projects, or adjust the
headline the conversation is folded into the plan when you hit Generate.
</div>
)}
{messages.map((m, i) => (
<div key={`${i}-${m.content.slice(0, 20)}`} className={`msg ${m.role}`}>
{m.content}
</div>
))}
{chatBusy && <div className="msg assistant pending"></div>}
<div ref={chatEndRef} />
</div>
<form
className="chat-form"
onSubmit={(e) => {
e.preventDefault();
void sendChat();
}}
>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={match ? "e.g. Lead with the cryptography work, skip consumer apps" : "Waiting for match…"}
disabled={!match}
/>
<button type="submit" className="btn" disabled={!match || chatBusy || draft.trim().length === 0}>
Send
</button>
</form>
</section>
</div>
);
}