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 (
+
+ );
+}
+
+// ─── 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}
}
+
+ Cancel
+
+ {loading ? 'Uploading…' : 'Upload'}
+
+
+
+
+
+ );
+}
+
+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}
}
+
+ Cancel
+
+ {loading ? 'Creating…' : 'Create'}
+
+
+
+
+
+ );
+}
+
+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}
+
+
+
+ );
+}
+
+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}
}
+
+ Cancel
+
+ {loading ? 'Publishing…' : 'Publish'}
+
+
+
+
+
+ );
+}
+
+// ─── 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
-
-
- setShowUploadModal(true)}
- className="btn-secondary"
- >
- Upload New CV
-
-
- Export Selected
-
-
-
-
+ const onBranchDone = (v: Version) => {
+ refreshDocs().then(() => setSelectedVersionId(v.id));
+ setModal(null);
+ };
- {/* Main Content */}
-
- {/* Left Panel - CV Tree */}
-
-
-
+ return (
+
+ {/* top bar */}
+
- {/* 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.
+
setModal('upload')}>
+ Upload your first CV
+
- )}
-
-
-
- {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 && (
+
+ )}
+ >
+ )}
- {/* Action Buttons */}
-
-
-
-
-
- Download DOCX
-
-
-
-
-
-
- Edit Version
-
-
- {!selectedNode.metadata?.isPublic && (
-
-
-
-
- Publish
-
- )}
-
+ {/* 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 */}
+
+
setModal('branch')}>New branch
+
setModal('submission')}>New submission
+
setModal('publish')}>Publish
+ {selectedVersion.artifact_docx_key && selectedDoc && (
+
+ ↓ DOCX
+
+ )}
+
+
+ {publishedUrl && (
+
+ Published:{' '}
+
{publishedUrl}
+
setPublishedUrl(null)} style={{ float: 'right', background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 14 }}>×
+
+ )}
+
+
+
+ {/* 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
-
-
- setShowUploadModal(false)}
- >
- Cancel
-
-
- Upload
-
-
-
-
- )}
-
- );
-}
-
-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 }) => (
+
+ ))}
+
+
-
-
-
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) => (
+
+ {i + 1}
+
+
+ ))}
+
+
+
+
+
+ >
+ );
}
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 (
+
-
-
-
- Home
-
-
- Dashboard
-
-
- Docs
-
-
-
-
-
- Sign In
-
-
- Get Started
-
-
-
-
-
- );
+
+ {[["Dashboard", "/dashboard"], ["Docs", "/docs"]].map(([label, href]) => (
+
+ {label}
+
+ ))}
+
+ Open app
+
+
+
+ );
}
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 && (
-
-
+ onSelect(v.id)}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 4,
+ paddingLeft: 12 + depth * 16, paddingRight: 8,
+ height: 30, cursor: 'pointer',
+ background: isSelected ? 'var(--selected-bg)' : 'transparent',
+ borderLeft: isSelected ? '2px solid var(--selected-border)' : '2px solid transparent',
+ transition: 'background 0.1s',
+ }}
+ onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'var(--hover)'; }}
+ onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
>
-
-
-
- )}
-
- {!hasChildren &&
}
+
{ e.stopPropagation(); setOpen(o => !o); }}
+ style={{ width: 14, height: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: hasChildren ? 1 : 0, cursor: 'pointer', background: 'none', border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0 }}
+ >
+
+
+
+
-
-
- {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}
-
- {isExpanded ? 'Hide' : 'Show'} details
-
-
-
- {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 };