Finish MVP and dockerize

This commit is contained in:
2026-04-02 19:15:47 +02:00
parent 90ad5e0260
commit 30cb18b55e
50 changed files with 2346 additions and 17 deletions

View File

@@ -18,4 +18,4 @@ export default async function DashboardPage() {
</form>
</div>
)
}
}

View File

@@ -0,0 +1,103 @@
import type { Document, Version, StructuredBlock } from "@/libs/api"
type Props = {
documents: Document[]
}
const gradientPalette = ["from-amber-200/60", "from-sky-200/50", "from-rose-200/50", "from-emerald-200/50"]
export function DocumentTree({ documents }: Props) {
if (!documents.length) {
return (
<div className="rounded-3xl border border-white/10 bg-gradient-to-br from-slate-900/40 to-slate-800/60 p-10 text-white/80">
<p className="text-lg font-semibold">No resumes ingested yet</p>
<p className="mt-3 text-sm text-white/60">
Upload your ATS-safe DOCX to create the canonical root. Each specialization will appear here as a branch with its own patch history.
</p>
</div>
)
}
return (
<div className="space-y-6">
{documents.map((doc, docIndex) => (
<div
key={doc.id}
className={`rounded-3xl border border-white/10 bg-gradient-to-br ${gradientPalette[docIndex % gradientPalette.length]} to-slate-900/50 p-6 text-white`}
>
<div className="flex flex-col gap-1 border-b border-white/15 pb-4">
<span className="text-sm uppercase tracking-[0.2em] text-white/60">Root CV</span>
<h3 className="text-2xl font-semibold">{doc.title}</h3>
{doc.description ? (
<p className="text-white/70">{doc.description}</p>
) : null}
</div>
<div className="mt-4 space-y-4">
{doc.versions.map((version) => (
<div key={version.id}>
<BranchCard version={version} />
</div>
))}
</div>
</div>
))}
</div>
)
}
function BranchCard({ version }: { version: Version }) {
const patches = version.patches ?? []
const structured = version.structured_blocks ?? []
const blockPreview = structured.slice(0, 2)
return (
<div className="rounded-2xl border border-white/10 bg-black/30 p-4 shadow-inner">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm text-white/60">{version.branch_name}</p>
<p className="text-lg font-semibold">{version.version_label ?? "untitled"}</p>
</div>
<span className="rounded-full bg-white/10 px-3 py-1 text-xs uppercase tracking-wide text-white/70">
{patches.length} patches
</span>
</div>
{blockPreview.length ? (
<div className="mt-4 space-y-3">
{blockPreview.map((block) => (
<KeywordChip key={block.path} block={block} />
))}
</div>
) : null}
{patches.length ? (
<ul className="mt-4 divide-y divide-white/5">
{patches.slice(-3).map((patch) => (
<li key={patch.id} className="py-2 text-sm text-white/80">
<span className="font-mono text-white/60">{patch.target_path}</span>
<span className="ml-2 text-white"> {patch.operation}</span>
</li>
))}
</ul>
) : (
<p className="mt-4 text-sm text-white/60">No tailoring applied yet.</p>
)}
</div>
)
}
function KeywordChip({ block }: { block: StructuredBlock }) {
const keywords = block.keywords.slice(0, 3)
return (
<div className="rounded-2xl border border-white/10 bg-white/5 p-3">
<p className="text-xs uppercase tracking-wide text-white/60">{block.path}</p>
<p className="text-sm text-white/90">{block.text}</p>
{keywords.length ? (
<div className="mt-2 flex flex-wrap gap-2 text-xs text-white/70">
{keywords.map((keyword) => (
<span key={keyword} className="rounded-full bg-white/10 px-2 py-0.5">
{keyword}
</span>
))}
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,115 @@
"use client"
import { useRouter } from "next/navigation"
import { useState, ChangeEvent, FormEvent } from "react"
import { API_BASE_URL } from "@/libs/api"
export function UploadResumeCard() {
const router = useRouter()
const [file, setFile] = useState<File | null>(null)
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [status, setStatus] = useState<"idle" | "uploading" | "success" | "error">("idle")
const [error, setError] = useState<string | null>(null)
function onFileChange(event: ChangeEvent<HTMLInputElement>) {
const nextFile = event.target.files?.[0]
if (nextFile) {
setFile(nextFile)
setTitle((prev: string) => (prev ? prev : nextFile.name.replace(/\.docx$/i, "")))
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!file || !title) {
setError("Title and DOCX are both required")
return
}
setStatus("uploading")
setError(null)
const formData = new FormData()
formData.append("title", title)
if (description) {
formData.append("description", description)
}
formData.append("file", file)
const response = await fetch(`${API_BASE_URL}/api/v1/documents`, {
method: "POST",
body: formData,
})
if (!response.ok) {
setStatus("error")
setError("Upload failed. Ensure the FastAPI backend is reachable.")
return
}
setStatus("success")
setFile(null)
setDescription("")
router.refresh()
setTimeout(() => setStatus("idle"), 2500)
}
return (
<form
onSubmit={handleSubmit}
className="rounded-3xl border border-white/10 bg-slate-900/70 p-6 text-white shadow-2xl"
>
<div className="flex items-start justify-between">
<div>
<p className="text-sm uppercase tracking-[0.3em] text-white/60">Canonical CV</p>
<h2 className="mt-2 text-2xl font-semibold">Upload ATS-safe DOCX</h2>
</div>
{status === "uploading" ? (
<div className="rounded-full border border-white/30 px-3 py-1 text-xs uppercase tracking-wide text-white/80">
Uploading
</div>
) : null}
</div>
<div className="mt-6 space-y-4">
<label className="block text-sm text-white/70">
Title
<input
value={title}
onChange={(event: ChangeEvent<HTMLInputElement>) => setTitle(event.target.value)}
placeholder="Resume Branch name"
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-2 text-white outline-none focus:border-white/40"
required
/>
</label>
<label className="block text-sm text-white/70">
Description
<textarea
value={description}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => setDescription(event.target.value)}
placeholder="Optional context for this CV"
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none focus:border-white/40"
rows={3}
/>
</label>
<label className="block text-sm text-white/70">
DOCX File
<input
type="file"
accept=".docx"
className="mt-2 w-full rounded-2xl border border-dashed border-white/25 bg-black/10 px-4 py-4 text-sm text-white/70"
onChange={onFileChange}
required
/>
</label>
</div>
{error ? <p className="mt-4 text-sm text-red-300">{error}</p> : null}
{status === "success" ? (
<p className="mt-4 text-sm text-emerald-300">Ingestion queued. Blocks appear in the tree within seconds.</p>
) : null}
<button
type="submit"
className="mt-6 w-full rounded-2xl bg-white/90 px-4 py-3 text-center text-slate-900 transition hover:bg-white"
disabled={status === "uploading"}
>
Ingest Canonical CV
</button>
</form>
)
}

View File

@@ -0,0 +1,50 @@
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:9812";
export type StructuredBlock = {
path: string
block_type: string
text: string
keywords: string[]
}
export type Patch = {
id: string
target_path: string
operation: string
rationale?: string | null
new_value?: string | null
created_at: string
}
export type Version = {
id: string
branch_name: string
version_label?: string | null
parent_version_id?: string | null
structured_blocks?: StructuredBlock[] | null
patches?: Patch[]
}
export type Document = {
id: string
title: string
description?: string | null
owner_id: string
versions: Version[]
}
export async function fetchDocuments(): Promise<Document[]> {
const response = await fetch(`${API_BASE_URL}/api/v1/documents`, {
cache: "no-store",
headers: {
accept: "application/json",
},
})
if (!response.ok) {
throw new Error("Unable to load documents")
}
const payload = await response.json()
return payload?.items ?? []
}
export { API_BASE_URL }