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
+50
View File
@@ -0,0 +1,50 @@
import { useCallback, useState } from "react";
import type { ChatMessage, JobExtract, ResumePlan } from "../shared/types";
import { Home } from "./screens/Home";
import { PdfScreen } from "./screens/Pdf";
import { Workspace } from "./screens/Workspace";
export interface GeneratedResume {
plan: ResumePlan;
source: "ai" | "fallback";
note?: string;
latex: string;
pdfUrl: string;
}
type Stage =
| { name: "home" }
| { name: "workspace"; jd: JobExtract }
| { name: "pdf"; jd: JobExtract; result: GeneratedResume };
export function App() {
const [stage, setStage] = useState<Stage>({ name: "home" });
const [chat, setChat] = useState<ChatMessage[]>([]);
const openWorkspace = useCallback((jd: JobExtract) => {
setChat([]);
setStage({ name: "workspace", jd });
}, []);
if (stage.name === "home") return <Home onExtracted={openWorkspace} />;
if (stage.name === "workspace") {
return (
<Workspace
jd={stage.jd}
messages={chat}
onMessagesChange={setChat}
onBack={() => setStage({ name: "home" })}
onGenerated={(result) => setStage({ name: "pdf", jd: stage.jd, result })}
/>
);
}
return (
<PdfScreen
result={stage.result}
onBack={() => setStage({ name: "workspace", jd: stage.jd })}
onRecompiled={(result) => setStage({ name: "pdf", jd: stage.jd, result })}
/>
);
}
+57
View File
@@ -0,0 +1,57 @@
export class ApiRequestError extends Error {
readonly log?: string;
constructor(message: string, log?: string) {
super(message);
this.name = "ApiRequestError";
this.log = log;
}
}
async function errorOf(res: Response, fallback: string): Promise<ApiRequestError> {
let message = fallback;
let log: string | undefined;
try {
const data: unknown = await res.json();
if (data && typeof data === "object") {
if ("error" in data && typeof data.error === "string") message = data.error;
if ("log" in data && typeof data.log === "string") log = data.log;
}
} catch {
/* non-JSON error body */
}
return new ApiRequestError(message, log);
}
export async function post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw await errorOf(res, `${path} failed (${res.status})`);
return res.json() as Promise<T>;
}
/**
* Compile LaTeX to a PDF blob.
* Dev: the Vite middleware at /local/compile runs the machine's real pdflatex.
* Deployed: that route 404s and we fall back to the Worker's remote compile proxy.
*/
export async function compilePdf(latex: string): Promise<Blob> {
const local = await fetch("/local/compile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ latex }),
});
if (local.ok) return local.blob();
if (local.status === 422) throw await errorOf(local, "pdflatex failed");
const remote = await fetch("/api/pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ latex }),
});
if (remote.ok) return remote.blob();
throw await errorOf(remote, `PDF compile failed (${remote.status})`);
}
+7
View File
@@ -0,0 +1,7 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing #root element");
createRoot(rootEl).render(<App />);
+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>
);
}
+492
View File
@@ -0,0 +1,492 @@
:root {
color-scheme: dark;
--bg: #0d1117;
--panel: #161b22;
--panel-2: #1c2129;
--border: #2d333b;
--text: #e6edf3;
--muted: #8b949e;
--accent: #4493f8;
--accent-dim: #1f6feb;
--danger: #f85149;
--ok: #3fb950;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", sans-serif;
}
h1,
h2 {
margin: 0;
}
h2 {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
margin-bottom: 10px;
}
.muted {
color: var(--muted);
font-weight: 400;
font-size: 12px;
}
.error {
color: var(--danger);
background: rgba(248, 81, 73, 0.08);
border: 1px solid rgba(248, 81, 73, 0.4);
border-radius: 6px;
padding: 8px 12px;
margin-top: 10px;
white-space: pre-wrap;
font-size: 13px;
}
.error.banner {
margin: 0 16px 12px;
max-height: 180px;
overflow: auto;
}
/* ---------- buttons ---------- */
.btn {
appearance: none;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
border-radius: 6px;
padding: 8px 14px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.btn:hover:not(:disabled) {
border-color: var(--muted);
}
.btn:disabled {
opacity: 0.55;
cursor: default;
}
.btn.primary {
background: var(--accent-dim);
border-color: var(--accent-dim);
}
.btn.primary:hover:not(:disabled) {
background: var(--accent);
border-color: var(--accent);
}
.btn.ghost {
background: transparent;
}
/* ---------- home ---------- */
.home {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.home-card {
width: min(640px, 100%);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 40px;
}
.home-card h1 {
font-size: 32px;
margin-bottom: 8px;
}
.home-card .sub {
color: var(--muted);
margin: 0 0 24px;
}
.home-form {
display: flex;
gap: 10px;
}
.home-form input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 10px 12px;
font-size: 14px;
}
.home-form input:focus {
outline: none;
border-color: var(--accent);
}
.paste-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.paste-form input,
.paste-form textarea {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 10px 12px;
font-size: 14px;
}
.paste-form textarea {
resize: vertical;
min-height: 160px;
font: 13px/1.5 inherit;
}
.paste-form input:focus,
.paste-form textarea:focus {
outline: none;
border-color: var(--accent);
}
.paste-form .btn {
align-self: flex-end;
}
.link {
appearance: none;
background: none;
border: none;
padding: 0;
margin: 12px 0 0;
color: var(--accent);
font-size: 13px;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.link:hover {
color: var(--text);
}
.hint {
color: var(--muted);
font-size: 12px;
margin: 16px 0 0;
}
/* ---------- shared layout ---------- */
.workspace,
.pdf-screen {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.topbar-title {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.topbar-title strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-title .muted {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.topbar-actions {
display: flex;
gap: 8px;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ---------- workspace ---------- */
.workspace-grid {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px 16px;
min-height: 0;
}
.jd-text {
flex: 1;
overflow: auto;
white-space: pre-wrap;
font: 13px/1.55 inherit;
margin: 0;
color: var(--text);
}
.loading {
color: var(--muted);
padding: 20px 0;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.chip {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
}
.chip em {
color: var(--muted);
font-style: normal;
}
.chip.language {
border-color: #3fb95066;
}
.chip.framework {
border-color: #4493f866;
}
.chip.methodology {
border-color: #d2a8ff66;
}
.chip.domain {
border-color: #f0883e66;
}
.chip.mini {
padding: 1px 8px;
font-size: 11px;
}
.items {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.item-card {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
}
.item-card header {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.item-card ul {
margin: 8px 0 0;
padding-left: 18px;
color: var(--muted);
font-size: 12.5px;
}
.item-card li {
margin-bottom: 4px;
}
.mini-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.badge {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
border-radius: 4px;
padding: 1px 6px;
font-weight: 700;
}
.badge.experience {
background: #1f6feb33;
color: var(--accent);
}
.badge.project {
background: #3fb95033;
color: var(--ok);
}
/* ---------- chat ---------- */
.chat {
margin: 0 16px 16px;
height: 240px;
flex-shrink: 0;
}
.chat-log {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 4px;
}
.chat-empty {
padding: 8px 0;
}
.msg {
max-width: 72%;
padding: 8px 12px;
border-radius: 10px;
font-size: 13.5px;
white-space: pre-wrap;
}
.msg.user {
align-self: flex-end;
background: var(--accent-dim);
}
.msg.assistant {
align-self: flex-start;
background: var(--panel-2);
border: 1px solid var(--border);
}
.msg.pending {
color: var(--muted);
}
.chat-form {
display: flex;
gap: 8px;
margin-top: 10px;
}
.chat-form input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 8px 12px;
font-size: 14px;
}
.chat-form input:focus {
outline: none;
border-color: var(--accent);
}
/* ---------- pdf screen ---------- */
.pdf-grid {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px 16px;
min-height: 0;
}
.latex-editor {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
padding: 10px;
resize: none;
white-space: pre;
}
.latex-editor:focus {
outline: none;
border-color: var(--accent);
}
.preview-frame {
flex: 1;
border: 1px solid var(--border);
border-radius: 6px;
background: #525659;
width: 100%;
}