mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-05-31 16:53:38 +00:00
Finish MVP and dockerize
This commit is contained in:
@@ -18,4 +18,4 @@ export default async function DashboardPage() {
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
103
apps/webapp/src/components/cv/DocumentTree.tsx
Normal file
103
apps/webapp/src/components/cv/DocumentTree.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
apps/webapp/src/components/cv/UploadResumeCard.tsx
Normal file
115
apps/webapp/src/components/cv/UploadResumeCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
apps/webapp/src/libs/api.ts
Normal file
50
apps/webapp/src/libs/api.ts
Normal 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 }
|
||||
Reference in New Issue
Block a user