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,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 })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
@@ -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 />);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
Reference in New Issue
Block a user