From e6c29f3bd41f5173c6d667845cdfe4d965ea55ef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 18:12:25 +0000 Subject: [PATCH] Redesign webapp with minimal style and full backend integration - New monochrome design system in globals.css (no shadows, tight spacing, system-font) - Root layout stripped to html/body; home page owns its Header/Footer - Dashboard fully wired to FastAPI backend: upload, branch, submission, publish, download - CVTree rebuilt as compact file-tree component driven by real version data - DiffViewer rebuilt as minimal git-diff display using real patch data - API client (libs/api.ts) extended with all CRUD operations - Header redesigned to match minimal style - Backend: added GET /documents/{id}/versions/{id}/download endpoint for DOCX export https://claude.ai/code/session_01Xmxm2QLgFBgRJyYD6VukR6 --- .../fastapi/app/api/routes/documents.py | 24 + apps/webapp/src/app/dashboard/page.tsx | 649 ++++++++++-------- apps/webapp/src/app/globals.css | 168 +++-- apps/webapp/src/app/layout.tsx | 20 +- apps/webapp/src/app/page.tsx | 260 ++----- apps/webapp/src/components/Header.tsx | 64 +- apps/webapp/src/components/cv/CVTree.tsx | 240 ++----- apps/webapp/src/components/cv/DiffViewer.tsx | 200 +----- apps/webapp/src/libs/api.ts | 159 ++++- 9 files changed, 800 insertions(+), 984 deletions(-) diff --git a/apps/backend/fastapi/app/api/routes/documents.py b/apps/backend/fastapi/app/api/routes/documents.py index 5d1ec7e..2ac140a 100644 --- a/apps/backend/fastapi/app/api/routes/documents.py +++ b/apps/backend/fastapi/app/api/routes/documents.py @@ -1,11 +1,13 @@ from __future__ import annotations from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi.responses import Response from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, get_db from app.schemas import DocumentListResponse, DocumentResponse from app.services.documents import create_document, get_document, list_documents +from app.services.storage import storage_client from dlib.auth import AuthenticatedUser @@ -34,6 +36,28 @@ async def get_user_document( return DocumentResponse.model_validate(document) +@router.get("/{document_id}/versions/{version_id}/download") +async def download_version_docx( + document_id: str, + version_id: str, + session: AsyncSession = Depends(get_db), + user: AuthenticatedUser = Depends(get_current_user), +): + document = await get_document(session, owner_id=user.sub, document_id=document_id) + if not document: + raise HTTPException(status_code=404, detail="Document not found") + version = next((v for v in document.versions if v.id == version_id), None) + if not version or not version.artifact_docx_key: + raise HTTPException(status_code=404, detail="Version artifact not found") + data = storage_client.download_bytes(key=version.artifact_docx_key) + slug = f"{document.title.replace(' ', '-')}-{version.branch_name}.docx" + return Response( + content=data, + media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + headers={"Content-Disposition": f'attachment; filename="{slug}"'}, + ) + + @router.post("/", response_model=DocumentResponse) async def upload_document( title: str = Form(...), diff --git a/apps/webapp/src/app/dashboard/page.tsx b/apps/webapp/src/app/dashboard/page.tsx index a3bfa33..f07dc87 100644 --- a/apps/webapp/src/app/dashboard/page.tsx +++ b/apps/webapp/src/app/dashboard/page.tsx @@ -1,318 +1,379 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import CVTree from '@/components/cv/CVTree'; import DiffViewer from '@/components/cv/DiffViewer'; -import { CVTreeNode, PatchDiff } from '@/types/cv'; +import { + createBranch, createSubmission, Document, downloadVersionUrl, + fetchDocuments, publishVersion, uploadDocument, Version, +} from '@/libs/api'; -// Mock data for demonstration -const mockTreeData: CVTreeNode = { - id: 'root-1', - label: 'Master Resume', - type: 'root', - versionId: 'v-root-1', - children: [ - { - id: 'branch-ml', - label: 'ML Engineer', - type: 'branch', - versionId: 'v-ml-1', - parentId: 'root-1', - metadata: { - lastModified: '2024-01-15T10:30:00Z', - }, - children: [ - { - id: 'sub-anthropic', - label: 'Anthropic Applied AI', - type: 'submission', - versionId: 'v-sub-anthropic-1', - parentId: 'branch-ml', - metadata: { - companyName: 'Anthropic', - roleTitle: 'Applied AI Research Engineer', - status: 'interviewing', - lastModified: '2024-01-20T14:20:00Z', - }, - children: [], - }, - { - id: 'sub-openai', - label: 'OpenAI Research', - type: 'submission', - versionId: 'v-sub-openai-1', - parentId: 'branch-ml', - metadata: { - companyName: 'OpenAI', - roleTitle: 'Research Engineer', - status: 'submitted', - isPublic: true, - lastModified: '2024-01-18T09:15:00Z', - }, - children: [], - }, - ], - }, - { - id: 'branch-backend', - label: 'Backend Engineer', - type: 'branch', - versionId: 'v-backend-1', - parentId: 'root-1', - metadata: { - lastModified: '2024-01-12T16:45:00Z', - }, - children: [ - { - id: 'sub-stripe', - label: 'Stripe Infrastructure', - type: 'submission', - versionId: 'v-sub-stripe-1', - parentId: 'branch-backend', - metadata: { - companyName: 'Stripe', - roleTitle: 'Senior Backend Engineer', - status: 'draft', - lastModified: '2024-01-22T11:30:00Z', - }, - children: [], - }, - ], - }, - ], -}; +// ─── tiny helpers ──────────────────────────────────────────────────────────── -const mockPatches: PatchDiff[] = [ - { - path: 'summary.paragraph_1', - type: 'changed', - oldValue: 'Machine learning engineer with 3+ years building production systems', - newValue: 'Applied AI research engineer with 3+ years building production ML systems for large-scale applications', - context: 'Summary section', - }, - { - path: 'experience[0].bullets[1]', - type: 'changed', - oldValue: 'Built recommendation system serving 10M+ users', - newValue: 'Built and scaled recommendation system using deep learning, serving 10M+ users with 40% improvement in engagement', - context: 'Senior ML Engineer at TechCorp', - }, - { - path: 'skills.technical', - type: 'added', - newValue: 'Constitutional AI, RLHF, Transformer architectures', - context: 'Technical skills section', - }, -]; +function fmt(iso: string) { + return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +// ─── modals ─────────────────────────────────────────────────────────────────── + +function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: Document) => void }) { + const [title, setTitle] = useState(''); + const [desc, setDesc] = useState(''); + const [file, setFile] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const ref = useRef(null); + + const submit = async () => { + if (!title.trim() || !file) { setError('Title and file required.'); return; } + setLoading(true); setError(''); + try { onDone(await uploadDocument(title.trim(), desc.trim() || null, file)); } + catch (e: unknown) { setError(e instanceof Error ? e.message : 'Upload failed'); setLoading(false); } + }; + + return ( +
+
e.stopPropagation()}> +
Upload CV
+
+ setTitle(e.target.value)} /> + setDesc(e.target.value)} /> +
ref.current?.click()} + style={{ border: '1px dashed var(--border-strong)', borderRadius: 5, padding: '18px 0', textAlign: 'center', cursor: 'pointer', fontSize: 13, color: file ? 'var(--text)' : 'var(--text-muted)' }} + > + {file ? file.name : 'Click to select .docx file'} +
+ setFile(e.target.files?.[0] ?? null)} /> + {error &&
{error}
} +
+ + +
+
+
+
+ ); +} + +function BranchModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (v: Version) => void }) { + const [name, setName] = useState(''); + const [label, setLabel] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const submit = async () => { + if (!name.trim()) { setError('Branch name required.'); return; } + setLoading(true); setError(''); + try { onDone(await createBranch(version.id, name.trim(), label.trim() || null)); } + catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); } + }; + + return ( +
+
e.stopPropagation()}> +
New branch from {version.branch_name}
+
+ setName(e.target.value)} /> + setLabel(e.target.value)} /> + {error &&
{error}
} +
+ + +
+
+
+
+ ); +} + +function SubmissionModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: () => void }) { + const [company, setCompany] = useState(''); + const [role, setRole] = useState(''); + const [url, setUrl] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const submit = async () => { + if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; } + setLoading(true); setError(''); + try { await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null); onDone(); } + catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); } + }; + + return ( +
+
e.stopPropagation()}> +
New submission from {version.branch_name}
+
+ setCompany(e.target.value)} /> + setRole(e.target.value)} /> + setUrl(e.target.value)} /> + {error &&
{error}
} +
+ + +
+
+
+
+ ); +} + +function PublishModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (url: string) => void }) { + const [slug, setSlug] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const submit = async () => { + setLoading(true); setError(''); + try { + const asset = await publishVersion(version.id, null, slug.trim() || null); + onDone(asset.url ?? asset.slug); + } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); } + }; + + return ( +
+
e.stopPropagation()}> +
Publish version
+

+ Creates an immutable public artifact. The link stays stable even if you edit further. +

+
+ setSlug(e.target.value)} /> + {error &&
{error}
} +
+ + +
+
+
+
+ ); +} + +// ─── main dashboard ─────────────────────────────────────────────────────────── + +type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null; export default function Dashboard() { - const [selectedNodeId, setSelectedNodeId] = useState('root-1'); - const [showUploadModal, setShowUploadModal] = useState(false); + const [docs, setDocs] = useState([]); + const [selectedDocId, setSelectedDocId] = useState(null); + const [selectedVersionId, setSelectedVersionId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [modal, setModal] = useState(null); + const [publishedUrl, setPublishedUrl] = useState(null); - const handleNodeSelect = (nodeId: string) => { - setSelectedNodeId(nodeId); - }; + useEffect(() => { + fetchDocuments() + .then(d => { setDocs(d); if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); } }) + .catch(e => setError(e.message)) + .finally(() => setLoading(false)); + }, []); - const handleCreateBranch = (parentId: string) => { - // TODO: Implement branch creation - console.log('Creating branch from:', parentId); - }; + const selectedDoc = docs.find(d => d.id === selectedDocId) ?? null; + const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null; - const handleCreateSubmission = (branchId: string) => { - // TODO: Implement submission creation - console.log('Creating submission from:', branchId); - }; + const refreshDocs = async () => { + try { + const fresh = await fetchDocuments(); + setDocs(fresh); + const doc = fresh.find(d => d.id === selectedDocId) ?? fresh[0] ?? null; + if (doc) { setSelectedDocId(doc.id); } + } catch { /* silent */ } + }; - const selectedNode = findNodeById(mockTreeData, selectedNodeId); + const onUploadDone = (doc: Document) => { + setDocs(prev => [doc, ...prev.filter(d => d.id !== doc.id)]); + setSelectedDocId(doc.id); + setSelectedVersionId(doc.root_version_id ?? null); + setModal(null); + }; - return ( -
- {/* Header */} -
-
-
-

Resume Branches

-

Manage your CV versions like code

-
-
- - -
-
-
+ const onBranchDone = (v: Version) => { + refreshDocs().then(() => setSelectedVersionId(v.id)); + setModal(null); + }; - {/* Main Content */} -
- {/* Left Panel - CV Tree */} -
- -
+ return ( +
+ {/* top bar */} +
+ + Resume Branches + + +
- {/* Center Panel - Version Details */} -
-
- {selectedNode && ( -
- {/* Version Header */} -
-
-
-

- {selectedNode.label} -

-
- Version {selectedNode.versionId} - {selectedNode.metadata?.lastModified && ( - - Updated {new Date(selectedNode.metadata.lastModified).toLocaleDateString()} - - )} - {selectedNode.metadata?.status && ( - - {selectedNode.metadata.status} - - )} -
- {selectedNode.metadata?.companyName && ( -
-

- {selectedNode.metadata.companyName} -

-

{selectedNode.metadata.roleTitle}

+
+ {/* left panel */} +
+ {loading &&
Loading…
} + {error &&
{error}
} + + {!loading && !error && docs.length === 0 && ( +
+

No CVs yet.

+
- )} -
- -
- {selectedNode.metadata?.isPublic && ( - - - - - Public - - )} - -
-
+ )} + + {docs.length > 0 && ( + <> + {/* document selector */} +
+
Documents
+ {docs.map(d => ( +
{ setSelectedDocId(d.id); setSelectedVersionId(d.root_version_id ?? null); }} + style={{ padding: '5px 8px', borderRadius: 4, cursor: 'pointer', fontSize: 13, fontWeight: d.id === selectedDocId ? 600 : 400, background: d.id === selectedDocId ? 'var(--selected-bg)' : 'transparent' }} + > + {d.title} +
+ ))} +
+ +
+ + {/* version tree */} + {selectedDoc && ( +
+
Versions
+ +
+ )} + + )}
- {/* Action Buttons */} -
- - - - - {!selectedNode.metadata?.isPublic && ( - - )} -
+ {/* main content */} +
+ {!selectedVersion && !loading && ( +
+ Select a version to view details. +
+ )} - {/* Diff Viewer */} - {selectedNode.type !== 'root' && ( - - )} + {selectedVersion && ( +
+ {/* version header */} +
+

+ {selectedVersion.version_label || selectedVersion.branch_name} +

+
+ {selectedVersion.parent_version_id && ( + + branched from{' '} + + {selectedDoc?.versions.find(v => v.id === selectedVersion.parent_version_id)?.branch_name ?? '…'} + + + )} + {!selectedVersion.parent_version_id && root} + {fmt(selectedVersion.created_at)} + {selectedVersion.patches.length > 0 && ( + {selectedVersion.patches.length} patch{selectedVersion.patches.length !== 1 ? 'es' : ''} + )} +
+
- {/* Preview Section */} -
-
-

Document Preview

-
-
-
- - - -

Document preview will appear here

-

Upload a CV to get started

-
-
+ {/* action bar */} +
+ + + + {selectedVersion.artifact_docx_key && selectedDoc && ( + + ↓ DOCX + + )} +
+ + {publishedUrl && ( +
+ Published:{' '} + {publishedUrl} + +
+ )} + +
+ + {/* structured blocks */} + {(selectedVersion.structured_blocks?.length ?? 0) > 0 && ( +
+
+ {selectedVersion.structured_blocks!.map((b, i) => ( +
+ {b.path} + + {b.text} + +
+ ))} +
+
+ )} + + {/* patches */} +
+ +
+
+ )}
-
+
+ + {/* modals */} + {modal === 'upload' && ( + setModal(null)} onDone={onUploadDone} /> + )} + {modal === 'branch' && selectedVersion && ( + setModal(null)} onDone={onBranchDone} /> + )} + {modal === 'submission' && selectedVersion && ( + setModal(null)} onDone={() => { setModal(null); }} /> + )} + {modal === 'publish' && selectedVersion && ( + setModal(null)} + onDone={url => { setPublishedUrl(url); setModal(null); }} + /> )} -
-
- - {/* Upload Modal */} - {showUploadModal && ( -
-
-

Upload New CV

-
- - - -

Drag and drop your DOCX file here

-

or click to browse

-
-
- - -
-
-
- )} -
- ); -} - -function findNodeById(node: CVTreeNode, id: string): CVTreeNode | null { - if (node.id === id) return node; - - for (const child of node.children) { - const found = findNodeById(child, id); - if (found) return found; - } - - return null; + ); } diff --git a/apps/webapp/src/app/globals.css b/apps/webapp/src/app/globals.css index 66f1204..7d9692c 100644 --- a/apps/webapp/src/app/globals.css +++ b/apps/webapp/src/app/globals.css @@ -1,66 +1,152 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --bg: #ffffff; + --surface: #fafafa; + --border: #e8e8e8; + --border-strong: #d4d4d4; + --text: #0a0a0a; + --text-muted: #737373; + --text-faint: #a3a3a3; + --hover: #f5f5f5; + --selected-bg: #f0f0f0; + --selected-border: #0a0a0a; } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: Inter, system-ui, sans-serif; - --font-mono: Consolas, Monaco, monospace; + --font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif; + --font-mono: "Consolas", "Monaco", "Fira Code", monospace; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0f172a; - --foreground: #e2e8f0; - } +* { + box-sizing: border-box; } body { - background: var(--background); - color: var(--foreground); + background: var(--bg); + color: var(--text); font-family: var(--font-sans); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; } -/* Custom scrollbar */ ::-webkit-scrollbar { - width: 6px; + width: 4px; + height: 4px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 2px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-faint); } -::-webkit-scrollbar-track { - background: transparent; -} +/* utilities */ +.mono { font-family: var(--font-mono); } -::-webkit-scrollbar-thumb { - background: #cbd5e1; - border-radius: 3px; -} - -::-webkit-scrollbar-thumb:hover { - background: #94a3b8; -} - -/* Focus styles */ -.focus-ring { - @apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2; -} - -/* Component base styles */ -.card { - @apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700; +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + font-size: 13px; + font-weight: 500; + border-radius: 5px; + border: 1px solid transparent; + cursor: pointer; + transition: background 0.1s, border-color 0.1s; + white-space: nowrap; } +.btn:disabled { opacity: 0.45; cursor: not-allowed; } .btn-primary { - @apply bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 rounded-md transition-colors; -} - -.btn-secondary { - @apply bg-gray-100 hover:bg-gray-200 text-gray-900 font-medium px-4 py-2 rounded-md transition-colors; + background: var(--text); + color: #fff; + border-color: var(--text); } +.btn-primary:hover:not(:disabled) { background: #333; } .btn-ghost { - @apply hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium px-4 py-2 rounded-md transition-colors; + background: transparent; + color: var(--text-muted); + border-color: var(--border); +} +.btn-ghost:hover:not(:disabled) { background: var(--hover); color: var(--text); } + +.btn-danger { + background: transparent; + color: #dc2626; + border-color: #fecaca; +} +.btn-danger:hover:not(:disabled) { background: #fef2f2; } + +.divider { + height: 1px; + background: var(--border); + border: none; + margin: 0; +} + +.label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-faint); +} + +.badge { + display: inline-flex; + align-items: center; + padding: 1px 6px; + border-radius: 3px; + font-size: 11px; + font-weight: 500; +} +.badge-draft { background: #f5f5f5; color: #737373; } +.badge-submitted { background: #fefce8; color: #854d0e; } +.badge-interviewing { background: #eff6ff; color: #1d4ed8; } +.badge-offer { background: #f0fdf4; color: #166534; } +.badge-rejected { background: #fef2f2; color: #991b1b; } +.badge-closed { background: #f5f5f5; color: #737373; } +.badge-public { background: #f0fdf4; color: #166534; } + +input, textarea, select { + width: 100%; + padding: 6px 10px; + font-size: 13px; + background: #fff; + border: 1px solid var(--border-strong); + border-radius: 5px; + color: var(--text); + outline: none; + transition: border-color 0.1s; + font-family: var(--font-sans); +} +input:focus, textarea:focus, select:focus { + border-color: var(--text); +} + +.overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; +} + +.modal { + background: #fff; + border: 1px solid var(--border); + border-radius: 8px; + padding: 24px; + width: 100%; + max-width: 420px; + box-shadow: 0 4px 24px rgba(0,0,0,0.08); +} + +.modal-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 16px; } diff --git a/apps/webapp/src/app/layout.tsx b/apps/webapp/src/app/layout.tsx index 388cdf7..0a1ffb3 100644 --- a/apps/webapp/src/app/layout.tsx +++ b/apps/webapp/src/app/layout.tsx @@ -1,29 +1,15 @@ import type { Metadata } from "next"; import "./globals.css"; -import Header from "@/components/Header"; -import Footer from "@/components/Footer"; - -const fontVariables = "font-sans"; export const metadata: Metadata = { - title: "Resume Branches - Git for CVs", + title: "Resume Branches", description: "Manage your CV like code: branch, version, and tailor for different roles while preserving ATS formatting", }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - -
-
- {children} -
-
- + {children} ); } diff --git a/apps/webapp/src/app/page.tsx b/apps/webapp/src/app/page.tsx index a1c608b..536ff1b 100644 --- a/apps/webapp/src/app/page.tsx +++ b/apps/webapp/src/app/page.tsx @@ -1,203 +1,69 @@ import Link from "next/link"; +import Header from "@/components/Header"; +import Footer from "@/components/Footer"; export default function Home() { - return ( -
- {/* Hero Section */} -
-
-

- Git for CVs -

-

- Manage your resume like code: branch, version, and tailor for different roles - while preserving ATS formatting. Never lose track of your career story again. -

-
- - Get Started - - - View Demo - -
-
-
+ return ( + <> +
+
+
+
+

+ Resume Branches +

+

+ Git for CVs +

+

+ Upload your ATS-safe DOCX. Branch it by role. Tailor per company without + losing structure. Publish stable public links. +

+
+ + Open Dashboard + +
+
+
- {/* Features Grid */} -
-
-

- Why Resume Branches? -

- -
-
-
- - - -
-

Preserve ATS Formatting

-

- Keep your original DOCX structure intact. Our system only edits text content, - never layouts or styles that could break ATS parsing. -

-
+
+
+ {[ + { title: "ATS-safe edits", body: "Patches apply directly to text nodes in your original DOCX. Layout, styles, and fonts are never touched." }, + { title: "Branching tree", body: "root → ml-engineer → Anthropic internship. Every variant traces back to a single source of truth." }, + { title: "Public links", body: "Freeze a version and publish it as an immutable, shareable link. Revoke or expire anytime." }, + ].map(({ title, body }) => ( +
+

{title}

+

{body}

+
+ ))} +
+
-
-
- - - -
-

Version Control

-

- Create branches for different career paths: ML Engineer, Backend Dev, Research. - Track every change with full history and rollback capability. -

-
- -
-
- - - -
-

Smart Tailoring

-

- Never wonder "what did I tell them about my React experience?" again. -

-
- -
-
- - - - -
-

Public Sharing

-

- Publish selected versions as stable, trackable links. Perfect for portfolios, - applications, or quick sharing with recruiters. -

-
- -
-
- - - -
-

Track Applications

-

- Keep a complete record of which version you sent where. Never wonder - "what did I tell them about my React experience?" again. -

-
- -
-
- - - -
-

Privacy First

-

- Your data stays yours. Work on private versions, share only what you choose, - and maintain complete control over your professional narrative. -

-
-
-
-
- - {/* How it Works */} -
-
-

- How It Works -

- -
-
-
- 1 -
-
-

Upload Your Master Resume

-

- Start with your best ATS-formatted DOCX file. This becomes your canonical source of truth. -

-
-
- -
-
- 2 -
-
-

Create Specialization Branches

-

- Branch into different career paths: "ML Engineer", "Backend Developer", "Research Scientist". - Each branch maintains its connection to your master resume. -

-
-
- -
-
- 3 -
-
-

Tailor for Specific Roles

-

- For each application, create a submission that fine-tunes your branch for that specific company and role. - Track everything with full history. -

-
-
- -
-
- 4 -
-
-

Share and Track

-

- Publish selected versions as public links for portfolios or quick sharing. - Always know which version went where. -

-
-
-
-
-
- - {/* CTA */} -
-
-

- Ready to Version Your Career? -

-

- Join developers who manage their resumes like they manage their code. -

- - Start Your CV Tree - -
-
-
- ); +
+
+
    + {[ + ["Upload your master DOCX", "The canonical ATS-formatted document becomes your root node."], + ["Create specialization branches", "ml-engineer, backend, research — each branch tracks a career path."], + ["Tailor per submission", "Paste a job description, accept AI suggestions, export the tailored DOCX."], + ["Publish selected versions", "One-click stable public links for portfolios or direct recruiter sharing."], + ].map(([step, desc], i) => ( +
  1. + {i + 1} +
    +
    {step}
    +
    {desc}
    +
    +
  2. + ))} +
+
+
+ +
+ + ); } diff --git a/apps/webapp/src/components/Header.tsx b/apps/webapp/src/components/Header.tsx index 94a66f2..b838f20 100644 --- a/apps/webapp/src/components/Header.tsx +++ b/apps/webapp/src/components/Header.tsx @@ -1,53 +1,21 @@ import Link from "next/link"; export default function Header() { - return ( -
-
-
-
- - Resume Branches + return ( +
+ + Resume Branches -
- - - -
- - Sign In - - - Get Started - -
-
-
-
- ); + +
+ ); } diff --git a/apps/webapp/src/components/cv/CVTree.tsx b/apps/webapp/src/components/cv/CVTree.tsx index 630591f..d998a1f 100644 --- a/apps/webapp/src/components/cv/CVTree.tsx +++ b/apps/webapp/src/components/cv/CVTree.tsx @@ -1,197 +1,77 @@ 'use client'; import { useState } from 'react'; -import { CVTreeNode } from '@/types/cv'; +import { Version } from '@/libs/api'; -interface CVTreeProps { - treeData: CVTreeNode; - selectedNodeId?: string; - onNodeSelect: (nodeId: string) => void; - onCreateBranch: (parentId: string) => void; - onCreateSubmission: (branchId: string) => void; +type TreeNode = { version: Version; children: TreeNode[] }; + +function buildTree(versions: Version[]): TreeNode | null { + const map = new Map(versions.map(v => [v.id, { version: v, children: [] as TreeNode[] }])); + let root: TreeNode | null = null; + for (const node of map.values()) { + const pid = node.version.parent_version_id; + if (!pid) { root = node; } else { map.get(pid)?.children.push(node); } + } + return root; } -const NODE_COLORS = { - root: 'bg-blue-100 border-blue-300 text-blue-900', - branch: 'bg-green-100 border-green-300 text-green-900', - submission: 'bg-yellow-100 border-yellow-300 text-yellow-900', +const STATUS_CLASS: Record = { + draft: 'badge badge-draft', submitted: 'badge badge-submitted', + interviewing: 'badge badge-interviewing', offer: 'badge badge-offer', + rejected: 'badge badge-rejected', closed: 'badge badge-closed', }; -const STATUS_COLORS = { - draft: 'bg-gray-100 text-gray-700', - submitted: 'bg-yellow-100 text-yellow-700', - interviewing: 'bg-blue-100 text-blue-700', - offer: 'bg-green-100 text-green-700', - rejected: 'bg-red-100 text-red-700', - closed: 'bg-gray-100 text-gray-700', -}; - -function TreeNode({ - node, - level = 0, - selectedNodeId, - onNodeSelect, - onCreateBranch, - onCreateSubmission -}: { - node: CVTreeNode; - level?: number; - selectedNodeId?: string; - onNodeSelect: (nodeId: string) => void; - onCreateBranch: (parentId: string) => void; - onCreateSubmission: (branchId: string) => void; +function Node({ node, depth, selectedId, onSelect }: { + node: TreeNode; depth: number; selectedId: string | null; onSelect: (id: string) => void; }) { - const [isExpanded, setIsExpanded] = useState(true); - const hasChildren = node.children.length > 0; - const isSelected = selectedNodeId === node.id; + const [open, setOpen] = useState(true); + const v = node.version; + const isSelected = v.id === selectedId; + const hasChildren = node.children.length > 0; - const handleNodeClick = () => { - onNodeSelect(node.id); - }; - - const handleCreateBranch = (e: React.MouseEvent) => { - e.stopPropagation(); - onCreateBranch(node.id); - }; - - const handleCreateSubmission = (e: React.MouseEvent) => { - e.stopPropagation(); - onCreateSubmission(node.id); - }; - - const toggleExpanded = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsExpanded(!isExpanded); - }; - - return ( -
-
- {hasChildren && ( - - )} - - {!hasChildren &&
} + -
-
- {node.label} - {node.metadata?.isPublic && ( - - Public - - )} - {node.metadata?.status && ( - - {node.metadata.status} - - )} -
- - {node.metadata?.companyName && ( -
- {node.metadata.companyName} • {node.metadata.roleTitle} + + {v.version_label || v.branch_name} +
- )} - - {node.metadata?.lastModified && ( -
- Updated {new Date(node.metadata.lastModified).toLocaleDateString()} -
- )} + {open && node.children.map(child => ( + + ))}
- -
- {(node.type === 'root' || node.type === 'branch') && ( - - )} - - {node.type === 'branch' && ( - - )} -
-
- - {hasChildren && isExpanded && ( -
- {node.children.map((child) => ( - - ))} -
- )} -
- ); + ); } -export default function CVTree({ - treeData, - selectedNodeId, - onNodeSelect, - onCreateBranch, - onCreateSubmission -}: CVTreeProps) { - return ( -
-
-

CV Versions

-
- {treeData.children.reduce((acc, branch) => acc + branch.children.length + 1, 1)} versions +export default function CVTree({ versions, selectedVersionId, onSelect }: { + versions: Version[]; selectedVersionId: string | null; onSelect: (id: string) => void; +}) { + const tree = buildTree(versions); + if (!tree) return
No versions
; + return ( +
+
-
- -
- -
-
- ); -} \ No newline at end of file + ); +} diff --git a/apps/webapp/src/components/cv/DiffViewer.tsx b/apps/webapp/src/components/cv/DiffViewer.tsx index 20b7ef1..7761f26 100644 --- a/apps/webapp/src/components/cv/DiffViewer.tsx +++ b/apps/webapp/src/components/cv/DiffViewer.tsx @@ -1,180 +1,42 @@ 'use client'; -import { useState } from 'react'; -import { PatchDiff } from '@/types/cv'; +import { Patch } from '@/libs/api'; -interface DiffViewerProps { - patches: PatchDiff[]; - title?: string; - className?: string; -} +const OP_SYMBOL: Record = { + replace_text: '±', remove_block: '−', reorder_section: '↕', boost_keyword: '+', +}; -function DiffLine({ - diff, - isExpanded, - onToggle -}: { - diff: PatchDiff; - isExpanded: boolean; - onToggle: () => void; -}) { - const getTypeColor = (type: string) => { - switch (type) { - case 'added': return 'bg-green-50 border-green-200'; - case 'removed': return 'bg-red-50 border-red-200'; - case 'changed': return 'bg-yellow-50 border-yellow-200'; - default: return 'bg-gray-50 border-gray-200'; - } - }; - - const getTypeIcon = (type: string) => { - switch (type) { - case 'added': +export default function DiffViewer({ patches }: { patches: Patch[] }) { + if (!patches.length) { return ( -
- - - -
- ); - case 'removed': - return ( -
- - - -
- ); - case 'changed': - return ( -
- - - -
- ); - default: - return
; - } - }; - - return ( -
-
- {getTypeIcon(diff.type)} - -
-
- {diff.path} - -
- - {diff.context && ( -
{diff.context}
- )} - - {isExpanded && ( -
- {diff.oldValue && ( -
-
- Removed
-
{diff.oldValue}
-
- )} - - {diff.newValue && ( -
-
+ Added
-
{diff.newValue}
-
- )} +
+ No patches — identical to parent.
- )} -
-
-
- ); -} - -export default function DiffViewer({ patches, title = "Changes", className = "" }: DiffViewerProps) { - const [expandedItems, setExpandedItems] = useState>(new Set()); - - const toggleExpanded = (index: number) => { - const newExpanded = new Set(expandedItems); - if (newExpanded.has(index)) { - newExpanded.delete(index); - } else { - newExpanded.add(index); + ); } - setExpandedItems(newExpanded); - }; - if (patches.length === 0) { return ( -
-
- - - -

No changes

-

This version is identical to its parent

+
+ {patches.map(p => ( +
+
+ + {OP_SYMBOL[p.operation] ?? '·'} {p.target_path} + + {p.operation} +
+ {p.old_value && ( +
+ − {p.old_value} +
+ )} + {p.new_value && ( +
+ + {p.new_value} +
+ )} +
+ ))}
-
); - } - - const changeCount = patches.length; - const addedCount = patches.filter(p => p.type === 'added').length; - const removedCount = patches.filter(p => p.type === 'removed').length; - const changedCount = patches.filter(p => p.type === 'changed').length; - - return ( -
-
-
-

{title}

-
- {addedCount > 0 && ( - -
- {addedCount} added - - )} - {changedCount > 0 && ( - -
- {changedCount} changed - - )} - {removedCount > 0 && ( - -
- {removedCount} removed - - )} -
-
- -
- {changeCount} {changeCount === 1 ? 'change' : 'changes'} detected -
-
- -
- {patches.map((patch, index) => ( - toggleExpanded(index)} - /> - ))} -
-
- ); -} \ No newline at end of file +} diff --git a/apps/webapp/src/libs/api.ts b/apps/webapp/src/libs/api.ts index bc0be87..e3f17af 100644 --- a/apps/webapp/src/libs/api.ts +++ b/apps/webapp/src/libs/api.ts @@ -1,50 +1,133 @@ -const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:9812"; +const API = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:9812"; export type StructuredBlock = { - path: string - block_type: string - text: string - keywords: string[] -} + 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 -} + id: string; + target_path: string; + operation: string; + old_value?: string | null; + new_value?: string | null; + metadata_json?: Record | 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[] -} + id: string; + branch_name: string; + version_label?: string | null; + parent_version_id?: string | null; + structured_blocks?: StructuredBlock[] | null; + artifact_docx_key?: string | null; + patches: Patch[]; + created_at: string; + updated_at: string; +}; export type Document = { - id: string - title: string - description?: string | null - owner_id: string - versions: Version[] + id: string; + title: string; + description?: string | null; + owner_id: string; + root_version_id?: string | null; + versions: Version[]; + created_at: string; + updated_at: string; +}; + +export type Submission = { + id: string; + version_id: string; + company_name: string; + role_title: string; + job_url?: string | null; + job_description?: string | null; + status: string; + created_at: string; +}; + +export type PublicAsset = { + id: string; + slug: string; + artifact_key: string; + is_public: boolean; + url?: string | null; + version_id?: string | null; + submission_id?: string | null; + created_at: string; +}; + +async function req(path: string, init?: RequestInit): Promise { + const res = await fetch(`${API}${path}`, { + ...init, + headers: { accept: "application/json", ...init?.headers }, + }); + if (!res.ok) { + const detail = await res.text().catch(() => res.statusText); + throw new Error(detail || `HTTP ${res.status}`); + } + return res.json(); } -export async function fetchDocuments(): Promise { - 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 const fetchDocuments = (): Promise => + req<{ items: Document[] }>("/api/v1/documents", { cache: "no-store" }).then(r => r.items); + +export const fetchDocument = (id: string): Promise => + req(`/api/v1/documents/${id}`, { cache: "no-store" }); + +export async function uploadDocument(title: string, description: string | null, file: File): Promise { + const form = new FormData(); + form.append("title", title); + if (description) form.append("description", description); + form.append("file", file); + return req("/api/v1/documents", { method: "POST", body: form }); } -export { API_BASE_URL } +export const downloadVersionUrl = (documentId: string, versionId: string): string => + `${API}/api/v1/documents/${documentId}/versions/${versionId}/download`; + +export async function createBranch( + parentVersionId: string, + branchName: string, + versionLabel?: string | null, + patches: Record[] = [], +): Promise { + return req("/api/v1/versions/branches", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ parent_version_id: parentVersionId, branch_name: branchName, version_label: versionLabel ?? null, patches }), + }); +} + +export async function createSubmission( + versionId: string, + companyName: string, + roleTitle: string, + jobUrl?: string | null, + jobDescription?: string | null, +): Promise { + return req("/api/v1/submissions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ version_id: versionId, company_name: companyName, role_title: roleTitle, job_url: jobUrl ?? null, job_description: jobDescription ?? null }), + }); +} + +export async function publishVersion( + versionId?: string | null, + submissionId?: string | null, + slug?: string | null, +): Promise { + return req("/api/v1/public/publish", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }), + }); +} + +export { API as API_BASE_URL };