mirror of
https://github.com/prdlk/cv.git
synced 2026-08-02 17:31:41 +00:00
126 lines
4.0 KiB
TypeScript
126 lines
4.0 KiB
TypeScript
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>
|
|
);
|
|
}
|