mirror of
https://github.com/prdlk/cv.git
synced 2026-08-02 17:31:41 +00:00
init(app): Setup client package for web api specific logic
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user