Replace mock data with real API integration in dashboard

This commit is contained in:
Daniel Alves Rösel
2026-04-02 22:14:44 +04:00
committed by GitHub
9 changed files with 800 additions and 984 deletions

View File

@@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
from app.schemas import DocumentListResponse, DocumentResponse from app.schemas import DocumentListResponse, DocumentResponse
from app.services.documents import create_document, get_document, list_documents from app.services.documents import create_document, get_document, list_documents
from app.services.storage import storage_client
from dlib.auth import AuthenticatedUser from dlib.auth import AuthenticatedUser
@@ -34,6 +36,28 @@ async def get_user_document(
return DocumentResponse.model_validate(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) @router.post("/", response_model=DocumentResponse)
async def upload_document( async def upload_document(
title: str = Form(...), title: str = Form(...),

View File

@@ -1,318 +1,379 @@
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import CVTree from '@/components/cv/CVTree'; import CVTree from '@/components/cv/CVTree';
import DiffViewer from '@/components/cv/DiffViewer'; 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 // ─── tiny helpers ────────────────────────────────────────────────────────────
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: [],
},
],
},
],
};
const mockPatches: PatchDiff[] = [ function fmt(iso: string) {
{ return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
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',
},
];
export default function Dashboard() {
const [selectedNodeId, setSelectedNodeId] = useState<string>('root-1');
const [showUploadModal, setShowUploadModal] = useState(false);
const handleNodeSelect = (nodeId: string) => {
setSelectedNodeId(nodeId);
};
const handleCreateBranch = (parentId: string) => {
// TODO: Implement branch creation
console.log('Creating branch from:', parentId);
};
const handleCreateSubmission = (branchId: string) => {
// TODO: Implement submission creation
console.log('Creating submission from:', branchId);
};
const selectedNode = findNodeById(mockTreeData, selectedNodeId);
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900">Resume Branches</h1> <div className="label" style={{ padding: '0 0 8px' }}>{title}</div>
<p className="text-gray-600">Manage your CV versions like code</p> {children}
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowUploadModal(true)}
className="btn-secondary"
>
Upload New CV
</button>
<button className="btn-primary">
Export Selected
</button>
</div>
</div>
</div>
{/* Main Content */}
<div className="flex h-[calc(100vh-80px)]">
{/* Left Panel - CV Tree */}
<div className="w-1/3 border-r border-gray-200 bg-white overflow-y-auto">
<CVTree
treeData={mockTreeData}
selectedNodeId={selectedNodeId}
onNodeSelect={handleNodeSelect}
onCreateBranch={handleCreateBranch}
onCreateSubmission={handleCreateSubmission}
/>
</div>
{/* Center Panel - Version Details */}
<div className="flex-1 p-6 overflow-y-auto">
<div className="max-w-4xl">
{selectedNode && (
<div className="space-y-6">
{/* Version Header */}
<div className="card p-6">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
{selectedNode.label}
</h2>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>Version {selectedNode.versionId}</span>
{selectedNode.metadata?.lastModified && (
<span>
Updated {new Date(selectedNode.metadata.lastModified).toLocaleDateString()}
</span>
)}
{selectedNode.metadata?.status && (
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${{
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',
}[selectedNode.metadata.status] || 'bg-gray-100 text-gray-700'}`}>
{selectedNode.metadata.status}
</span>
)}
</div>
{selectedNode.metadata?.companyName && (
<div className="mt-3">
<p className="text-lg font-medium text-gray-900">
{selectedNode.metadata.companyName}
</p>
<p className="text-gray-600">{selectedNode.metadata.roleTitle}</p>
</div>
)}
</div>
<div className="flex items-center gap-2">
{selectedNode.metadata?.isPublic && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM4.332 8.027a6.012 6.012 0 011.912-2.706C6.512 5.73 6.974 6 7.5 6A1.5 1.5 0 019 7.5V8a2 2 0 004 0 2 2 0 011.523-1.943A5.977 5.977 0 0116 10c0 .34-.028.675-.083 1H15a2 2 0 00-2 2v2.197A5.973 5.973 0 0110 16v-2a2 2 0 00-2-2 2 2 0 01-2-2 2 2 0 00-1.668-1.973z" clipRule="evenodd" />
</svg>
Public
</span>
)}
<button className="btn-ghost">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-3">
<button className="btn-primary">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Download DOCX
</button>
<button className="btn-secondary">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
Edit Version
</button>
{!selectedNode.metadata?.isPublic && (
<button className="btn-ghost">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z" />
</svg>
Publish
</button>
)}
</div>
{/* Diff Viewer */}
{selectedNode.type !== 'root' && (
<DiffViewer
patches={mockPatches}
title={`Changes from ${selectedNode.parentId === 'root-1' ? 'Master Resume' : 'Parent Branch'}`}
/>
)}
{/* Preview Section */}
<div className="card">
<div className="p-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">Document Preview</h3>
</div>
<div className="p-6">
<div className="bg-gray-100 rounded-lg p-8 text-center">
<svg className="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-gray-600 mb-2">Document preview will appear here</p>
<p className="text-sm text-gray-500">Upload a CV to get started</p>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Upload Modal */}
{showUploadModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold mb-4">Upload New CV</h3>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<svg className="w-12 h-12 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p className="text-gray-600 mb-2">Drag and drop your DOCX file here</p>
<p className="text-sm text-gray-500">or click to browse</p>
</div>
<div className="flex gap-3 mt-6">
<button
className="btn-secondary flex-1"
onClick={() => setShowUploadModal(false)}
>
Cancel
</button>
<button className="btn-primary flex-1">
Upload
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
function findNodeById(node: CVTreeNode, id: string): CVTreeNode | null { // ─── modals ───────────────────────────────────────────────────────────────────
if (node.id === id) return node;
for (const child of node.children) { function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: Document) => void }) {
const found = findNodeById(child, id); const [title, setTitle] = useState('');
if (found) return found; const [desc, setDesc] = useState('');
} const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const ref = useRef<HTMLInputElement>(null);
return 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 (
<div className="overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">Upload CV</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input placeholder="Title (e.g. My Resume)" value={title} onChange={e => setTitle(e.target.value)} />
<input placeholder="Description (optional)" value={desc} onChange={e => setDesc(e.target.value)} />
<div
onClick={() => 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'}
</div>
<input ref={ref} type="file" accept=".docx" style={{ display: 'none' }} onChange={e => setFile(e.target.files?.[0] ?? null)} />
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Uploading…' : 'Upload'}
</button>
</div>
</div>
</div>
</div>
);
}
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 (
<div className="overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">New branch from <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 400 }}>{version.branch_name}</span></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input placeholder="Branch name (e.g. ml-engineer)" value={name} onChange={e => setName(e.target.value)} />
<input placeholder="Label (optional)" value={label} onChange={e => setLabel(e.target.value)} />
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Creating…' : 'Create'}
</button>
</div>
</div>
</div>
</div>
);
}
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 (
<div className="overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">New submission from <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 400 }}>{version.branch_name}</span></div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input placeholder="Company name" value={company} onChange={e => setCompany(e.target.value)} />
<input placeholder="Role title" value={role} onChange={e => setRole(e.target.value)} />
<input placeholder="Job URL (optional)" value={url} onChange={e => setUrl(e.target.value)} />
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Saving…' : 'Create'}
</button>
</div>
</div>
</div>
</div>
);
}
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 (
<div className="overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">Publish version</div>
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}>
Creates an immutable public artifact. The link stays stable even if you edit further.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input placeholder="Custom slug (optional)" value={slug} onChange={e => setSlug(e.target.value)} />
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Publishing…' : 'Publish'}
</button>
</div>
</div>
</div>
</div>
);
}
// ─── main dashboard ───────────────────────────────────────────────────────────
type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null;
export default function Dashboard() {
const [docs, setDocs] = useState<Document[]>([]);
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modal, setModal] = useState<Modal>(null);
const [publishedUrl, setPublishedUrl] = useState<string | null>(null);
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 selectedDoc = docs.find(d => d.id === selectedDocId) ?? null;
const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null;
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 onUploadDone = (doc: Document) => {
setDocs(prev => [doc, ...prev.filter(d => d.id !== doc.id)]);
setSelectedDocId(doc.id);
setSelectedVersionId(doc.root_version_id ?? null);
setModal(null);
};
const onBranchDone = (v: Version) => {
refreshDocs().then(() => setSelectedVersionId(v.id));
setModal(null);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden', background: 'var(--bg)' }}>
{/* top bar */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px', height: 44, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<a href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}>
Resume Branches
</a>
<button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: 12 }} onClick={() => setModal('upload')}>
+ Upload CV
</button>
</div>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* left panel */}
<div style={{ width: 240, flexShrink: 0, borderRight: '1px solid var(--border)', background: 'var(--surface)', overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
{loading && <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>Loading</div>}
{error && <div style={{ padding: 16, fontSize: 13, color: '#dc2626' }}>{error}</div>}
{!loading && !error && docs.length === 0 && (
<div style={{ padding: 16 }}>
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}>No CVs yet.</p>
<button className="btn btn-primary" style={{ width: '100%' }} onClick={() => setModal('upload')}>
Upload your first CV
</button>
</div>
)}
{docs.length > 0 && (
<>
{/* document selector */}
<div style={{ padding: '10px 12px 6px' }}>
<div className="label" style={{ marginBottom: 6 }}>Documents</div>
{docs.map(d => (
<div
key={d.id}
onClick={() => { 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}
</div>
))}
</div>
<hr className="divider" style={{ margin: '6px 0' }} />
{/* version tree */}
{selectedDoc && (
<div style={{ padding: '6px 0' }}>
<div className="label" style={{ padding: '0 12px 6px' }}>Versions</div>
<CVTree
versions={selectedDoc.versions}
selectedVersionId={selectedVersionId}
onSelect={setSelectedVersionId}
/>
</div>
)}
</>
)}
</div>
{/* main content */}
<div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>
{!selectedVersion && !loading && (
<div style={{ paddingTop: 60, textAlign: 'center', color: 'var(--text-faint)', fontSize: 13 }}>
Select a version to view details.
</div>
)}
{selectedVersion && (
<div style={{ maxWidth: 680 }}>
{/* version header */}
<div style={{ marginBottom: 20 }}>
<h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 4 }}>
{selectedVersion.version_label || selectedVersion.branch_name}
</h2>
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-muted)' }}>
{selectedVersion.parent_version_id && (
<span>
branched from{' '}
<span style={{ fontFamily: 'var(--font-mono)' }}>
{selectedDoc?.versions.find(v => v.id === selectedVersion.parent_version_id)?.branch_name ?? '…'}
</span>
</span>
)}
{!selectedVersion.parent_version_id && <span style={{ fontFamily: 'var(--font-mono)' }}>root</span>}
<span>{fmt(selectedVersion.created_at)}</span>
{selectedVersion.patches.length > 0 && (
<span>{selectedVersion.patches.length} patch{selectedVersion.patches.length !== 1 ? 'es' : ''}</span>
)}
</div>
</div>
{/* action bar */}
<div style={{ display: 'flex', gap: 6, marginBottom: 24, flexWrap: 'wrap' }}>
<button className="btn btn-ghost" onClick={() => setModal('branch')}>New branch</button>
<button className="btn btn-ghost" onClick={() => setModal('submission')}>New submission</button>
<button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>
{selectedVersion.artifact_docx_key && selectedDoc && (
<a
href={downloadVersionUrl(selectedDoc.id, selectedVersion.id)}
download
className="btn btn-ghost"
>
DOCX
</a>
)}
</div>
{publishedUrl && (
<div style={{ padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 5, marginBottom: 20, fontSize: 13 }}>
Published:{' '}
<a href={publishedUrl} target="_blank" rel="noreferrer" style={{ color: '#166534', wordBreak: 'break-all' }}>{publishedUrl}</a>
<button onClick={() => setPublishedUrl(null)} style={{ float: 'right', background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 14 }}>×</button>
</div>
)}
<hr className="divider" style={{ marginBottom: 24 }} />
{/* structured blocks */}
{(selectedVersion.structured_blocks?.length ?? 0) > 0 && (
<Section title={`Content (${selectedVersion.structured_blocks!.length} blocks)`}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginBottom: 24 }}>
{selectedVersion.structured_blocks!.map((b, i) => (
<div key={i} style={{ display: 'flex', gap: 12, padding: '4px 0', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-faint)', flexShrink: 0, width: 110, paddingTop: 1 }}>{b.path}</span>
<span style={{ fontSize: 13, color: 'var(--text)', lineHeight: 1.5, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{b.text}
</span>
</div>
))}
</div>
</Section>
)}
{/* patches */}
<Section title={`Patches (${selectedVersion.patches.length} changes from parent)`}>
<DiffViewer patches={selectedVersion.patches} />
</Section>
</div>
)}
</div>
</div>
{/* modals */}
{modal === 'upload' && (
<UploadModal onClose={() => setModal(null)} onDone={onUploadDone} />
)}
{modal === 'branch' && selectedVersion && (
<BranchModal version={selectedVersion} onClose={() => setModal(null)} onDone={onBranchDone} />
)}
{modal === 'submission' && selectedVersion && (
<SubmissionModal version={selectedVersion} onClose={() => setModal(null)} onDone={() => { setModal(null); }} />
)}
{modal === 'publish' && selectedVersion && (
<PublishModal
version={selectedVersion}
onClose={() => setModal(null)}
onDone={url => { setPublishedUrl(url); setModal(null); }}
/>
)}
</div>
);
} }

View File

@@ -1,66 +1,152 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
--background: #ffffff; --bg: #ffffff;
--foreground: #171717; --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 { @theme inline {
--color-background: var(--background); --font-sans: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
--color-foreground: var(--foreground); --font-mono: "Consolas", "Monaco", "Fira Code", monospace;
--font-sans: Inter, system-ui, sans-serif;
--font-mono: Consolas, Monaco, monospace;
} }
@media (prefers-color-scheme: dark) { * {
:root { box-sizing: border-box;
--background: #0f172a;
--foreground: #e2e8f0;
}
} }
body { body {
background: var(--background); background: var(--bg);
color: var(--foreground); color: var(--text);
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
} }
/* Custom scrollbar */
::-webkit-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 { /* utilities */
background: transparent; .mono { font-family: var(--font-mono); }
}
::-webkit-scrollbar-thumb { .btn {
background: #cbd5e1; display: inline-flex;
border-radius: 3px; align-items: center;
} gap: 6px;
padding: 5px 12px;
::-webkit-scrollbar-thumb:hover { font-size: 13px;
background: #94a3b8; font-weight: 500;
} border-radius: 5px;
border: 1px solid transparent;
/* Focus styles */ cursor: pointer;
.focus-ring { transition: background 0.1s, border-color 0.1s;
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2; white-space: nowrap;
}
/* Component base styles */
.card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
} }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { .btn-primary {
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium px-4 py-2 rounded-md transition-colors; background: var(--text);
} color: #fff;
border-color: var(--text);
.btn-secondary {
@apply bg-gray-100 hover:bg-gray-200 text-gray-900 font-medium px-4 py-2 rounded-md transition-colors;
} }
.btn-primary:hover:not(:disabled) { background: #333; }
.btn-ghost { .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;
} }

View File

@@ -1,29 +1,15 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
const fontVariables = "font-sans";
export const metadata: Metadata = { 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", description: "Manage your CV like code: branch, version, and tailor for different roles while preserving ATS formatting",
}; };
export default function RootLayout({ export default function RootLayout({ children }: { children: React.ReactNode }) {
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return ( return (
<html lang="en"> <html lang="en">
<body className={`${fontVariables} antialiased`}> <body>{children}</body>
<Header />
<main className="min-h-screen">
{children}
</main>
<Footer />
</body>
</html> </html>
); );
} }

View File

@@ -1,203 +1,69 @@
import Link from "next/link"; import Link from "next/link";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
export default function Home() { export default function Home() {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-blue-50"> <>
{/* Hero Section */} <Header />
<section className="px-6 pt-20 pb-16"> <main>
<div className="max-w-4xl mx-auto text-center"> <section style={{ padding: "80px 24px 64px", textAlign: "center", borderBottom: "1px solid var(--border)" }}>
<h1 className="text-5xl font-bold text-gray-900 mb-6"> <div style={{ maxWidth: 560, margin: "0 auto" }}>
<p style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-faint)", marginBottom: 16 }}>
Resume Branches
</p>
<h1 style={{ fontSize: 40, fontWeight: 700, lineHeight: 1.1, marginBottom: 16, letterSpacing: "-0.02em" }}>
Git for CVs Git for CVs
</h1> </h1>
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto"> <p style={{ fontSize: 16, color: "var(--text-muted)", lineHeight: 1.6, marginBottom: 32 }}>
Manage your resume like code: branch, version, and tailor for different roles Upload your ATS-safe DOCX. Branch it by role. Tailor per company without
while preserving ATS formatting. Never lose track of your career story again. losing structure. Publish stable public links.
</p> </p>
<div className="flex gap-4 justify-center"> <div style={{ display: "flex", gap: 10, justifyContent: "center" }}>
<Link <Link href="/dashboard" className="btn btn-primary" style={{ padding: "9px 20px", fontSize: 14 }}>
href="/dashboard" Open Dashboard
className="bg-blue-600 hover:bg-blue-700 text-white text-lg px-8 py-3 rounded-lg shadow-lg hover:shadow-xl transition-all"
>
Get Started
</Link>
<Link
href="/demo"
className="bg-gray-100 hover:bg-gray-200 text-gray-900 text-lg px-8 py-3 rounded-lg shadow-lg hover:shadow-xl transition-all"
>
View Demo
</Link> </Link>
</div> </div>
</div> </div>
</section> </section>
{/* Features Grid */} <section style={{ padding: "64px 24px", maxWidth: 800, margin: "0 auto" }}>
<section className="px-6 py-16"> <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 32 }}>
<div className="max-w-6xl mx-auto"> {[
<h2 className="text-3xl font-bold text-center text-gray-900 mb-12"> { title: "ATS-safe edits", body: "Patches apply directly to text nodes in your original DOCX. Layout, styles, and fonts are never touched." },
Why Resume Branches? { title: "Branching tree", body: "root → ml-engineer → Anthropic internship. Every variant traces back to a single source of truth." },
</h2> { title: "Public links", body: "Freeze a version and publish it as an immutable, shareable link. Revoke or expire anytime." },
].map(({ title, body }) => (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8"> <div key={title}>
<div className="card p-6 text-center hover:shadow-lg transition-shadow"> <h3 style={{ fontSize: 14, fontWeight: 600, marginBottom: 8 }}>{title}</h3>
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-4"> <p style={{ fontSize: 13, color: "var(--text-muted)", lineHeight: 1.6 }}>{body}</p>
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Preserve ATS Formatting</h3>
<p className="text-gray-600">
Keep your original DOCX structure intact. Our system only edits text content,
never layouts or styles that could break ATS parsing.
</p>
</div>
<div className="card p-6 text-center hover:shadow-lg transition-shadow">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Version Control</h3>
<p className="text-gray-600">
Create branches for different career paths: ML Engineer, Backend Dev, Research.
Track every change with full history and rollback capability.
</p>
</div>
<div className="card p-6 text-center hover:shadow-lg transition-shadow">
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Smart Tailoring</h3>
<p className="text-gray-600">
Never wonder &quot;what did I tell them about my React experience?&quot; again.
</p>
</div>
<div className="card p-6 text-center hover:shadow-lg transition-shadow">
<div className="w-12 h-12 bg-yellow-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Public Sharing</h3>
<p className="text-gray-600">
Publish selected versions as stable, trackable links. Perfect for portfolios,
applications, or quick sharing with recruiters.
</p>
</div>
<div className="card p-6 text-center hover:shadow-lg transition-shadow">
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Track Applications</h3>
<p className="text-gray-600">
Keep a complete record of which version you sent where. Never wonder
&quot;what did I tell them about my React experience?&quot; again.
</p>
</div>
<div className="card p-6 text-center hover:shadow-lg transition-shadow">
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">Privacy First</h3>
<p className="text-gray-600">
Your data stays yours. Work on private versions, share only what you choose,
and maintain complete control over your professional narrative.
</p>
</div>
</div> </div>
))}
</div> </div>
</section> </section>
{/* How it Works */} <section style={{ padding: "48px 24px", borderTop: "1px solid var(--border)", background: "var(--surface)" }}>
<section className="px-6 py-16 bg-gray-50"> <div style={{ maxWidth: 480, margin: "0 auto" }}>
<div className="max-w-4xl mx-auto"> <ol style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 20 }}>
<h2 className="text-3xl font-bold text-center text-gray-900 mb-12"> {[
How It Works ["Upload your master DOCX", "The canonical ATS-formatted document becomes your root node."],
</h2> ["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."],
<div className="space-y-8"> ["Publish selected versions", "One-click stable public links for portfolios or direct recruiter sharing."],
<div className="flex gap-6"> ].map(([step, desc], i) => (
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-semibold"> <li key={i} style={{ display: "flex", gap: 16 }}>
1 <span style={{ flexShrink: 0, width: 22, height: 22, border: "1px solid var(--border-strong)", borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, fontWeight: 600, color: "var(--text-muted)", marginTop: 2 }}>{i + 1}</span>
</div>
<div> <div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Upload Your Master Resume</h3> <div style={{ fontWeight: 600, fontSize: 13, marginBottom: 2 }}>{step}</div>
<p className="text-gray-600"> <div style={{ fontSize: 13, color: "var(--text-muted)" }}>{desc}</div>
Start with your best ATS-formatted DOCX file. This becomes your canonical source of truth.
</p>
</div>
</div>
<div className="flex gap-6">
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-semibold">
2
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Create Specialization Branches</h3>
<p className="text-gray-600">
Branch into different career paths: &quot;ML Engineer&quot;, &quot;Backend Developer&quot;, &quot;Research Scientist&quot;.
Each branch maintains its connection to your master resume.
</p>
</div>
</div>
<div className="flex gap-6">
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-semibold">
3
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Tailor for Specific Roles</h3>
<p className="text-gray-600">
For each application, create a submission that fine-tunes your branch for that specific company and role.
Track everything with full history.
</p>
</div>
</div>
<div className="flex gap-6">
<div className="flex-shrink-0 w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-semibold">
4
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Share and Track</h3>
<p className="text-gray-600">
Publish selected versions as public links for portfolios or quick sharing.
Always know which version went where.
</p>
</div>
</div>
</div> </div>
</li>
))}
</ol>
</div> </div>
</section> </section>
</main>
{/* CTA */} <Footer />
<section className="px-6 py-16"> </>
<div className="max-w-2xl mx-auto text-center">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Ready to Version Your Career?
</h2>
<p className="text-gray-600 mb-8">
Join developers who manage their resumes like they manage their code.
</p>
<Link
href="/dashboard"
className="bg-blue-600 hover:bg-blue-700 text-white text-lg px-8 py-3 rounded-lg shadow-lg hover:shadow-xl transition-all"
>
Start Your CV Tree
</Link>
</div>
</section>
</div>
); );
} }

View File

@@ -2,52 +2,20 @@ import Link from "next/link";
export default function Header() { export default function Header() {
return ( return (
<header className="bg-white border-b border-gray-200 sticky top-0 z-40"> <header style={{ borderBottom: "1px solid var(--border)", padding: "0 24px", height: 52, display: "flex", alignItems: "center", justifyContent: "space-between", background: "#fff", position: "sticky", top: 0, zIndex: 40 }}>
<div className="max-w-7xl mx-auto px-6 py-4"> <Link href="/" style={{ fontSize: 14, fontWeight: 600, color: "var(--text)", textDecoration: "none" }}>
<div className="flex items-center justify-between">
<div className="flex items-center">
<Link href="/" className="text-xl font-bold text-gray-900 hover:text-blue-600 transition-colors">
Resume Branches Resume Branches
</Link> </Link>
</div> <nav style={{ display: "flex", alignItems: "center", gap: 24 }}>
{[["Dashboard", "/dashboard"], ["Docs", "/docs"]].map(([label, href]) => (
<nav className="hidden md:flex items-center space-x-8"> <Link key={href} href={href} style={{ fontSize: 13, color: "var(--text-muted)", textDecoration: "none" }}>
<Link {label}
href="/"
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
>
Home
</Link> </Link>
<Link ))}
href="/dashboard" <Link href="/dashboard" className="btn btn-primary" style={{ padding: "5px 14px", fontSize: 13 }}>
className="text-gray-600 hover:text-gray-900 font-medium transition-colors" Open app
>
Dashboard
</Link>
<Link
href="/docs"
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
>
Docs
</Link> </Link>
</nav> </nav>
<div className="flex items-center gap-3">
<Link
href="/login"
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
>
Sign In
</Link>
<Link
href="/dashboard"
className="btn-primary"
>
Get Started
</Link>
</div>
</div>
</div>
</header> </header>
); );
} }

View File

@@ -1,197 +1,77 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { CVTreeNode } from '@/types/cv'; import { Version } from '@/libs/api';
interface CVTreeProps { type TreeNode = { version: Version; children: TreeNode[] };
treeData: CVTreeNode;
selectedNodeId?: string; function buildTree(versions: Version[]): TreeNode | null {
onNodeSelect: (nodeId: string) => void; const map = new Map(versions.map(v => [v.id, { version: v, children: [] as TreeNode[] }]));
onCreateBranch: (parentId: string) => void; let root: TreeNode | null = null;
onCreateSubmission: (branchId: string) => void; 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 = { const STATUS_CLASS: Record<string, string> = {
root: 'bg-blue-100 border-blue-300 text-blue-900', draft: 'badge badge-draft', submitted: 'badge badge-submitted',
branch: 'bg-green-100 border-green-300 text-green-900', interviewing: 'badge badge-interviewing', offer: 'badge badge-offer',
submission: 'bg-yellow-100 border-yellow-300 text-yellow-900', rejected: 'badge badge-rejected', closed: 'badge badge-closed',
}; };
const STATUS_COLORS = { function Node({ node, depth, selectedId, onSelect }: {
draft: 'bg-gray-100 text-gray-700', node: TreeNode; depth: number; selectedId: string | null; onSelect: (id: string) => void;
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;
}) { }) {
const [isExpanded, setIsExpanded] = useState(true); const [open, setOpen] = useState(true);
const v = node.version;
const isSelected = v.id === selectedId;
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const isSelected = selectedNodeId === node.id;
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 ( return (
<div className="select-none"> <div>
<div <div
className={` onClick={() => onSelect(v.id)}
flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-all style={{
${NODE_COLORS[node.type]} display: 'flex', alignItems: 'center', gap: 4,
${isSelected ? 'ring-2 ring-blue-500 ring-offset-1' : ''} paddingLeft: 12 + depth * 16, paddingRight: 8,
hover:shadow-sm height: 30, cursor: 'pointer',
`} background: isSelected ? 'var(--selected-bg)' : 'transparent',
style={{ marginLeft: `${level * 24}px` }} borderLeft: isSelected ? '2px solid var(--selected-border)' : '2px solid transparent',
onClick={handleNodeClick} 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 && (
<button <button
onClick={toggleExpanded} onClick={e => { e.stopPropagation(); setOpen(o => !o); }}
className="flex-shrink-0 w-4 h-4 flex items-center justify-center hover:bg-black/10 rounded" 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 }}
> >
<svg <svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" style={{ transform: open ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s' }}>
className={`w-3 h-3 transform transition-transform ${isExpanded ? 'rotate-90' : ''}`} <path d="M2 1l4 3-4 3V1z" />
fill="currentColor"
viewBox="0 0 20 20"
>
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg> </svg>
</button> </button>
)}
{!hasChildren && <div className="w-4" />} <span style={{ flex: 1, fontSize: 13, fontWeight: !v.parent_version_id ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text)' }}>
{v.version_label || v.branch_name}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{node.label}</span>
{node.metadata?.isPublic && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Public
</span> </span>
)}
{node.metadata?.status && (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${STATUS_COLORS[node.metadata.status as keyof typeof STATUS_COLORS] || 'bg-gray-100 text-gray-700'}`}>
{node.metadata.status}
</span>
)}
</div> </div>
{open && node.children.map(child => (
{node.metadata?.companyName && ( <Node key={child.version.id} node={child} depth={depth + 1} selectedId={selectedId} onSelect={onSelect} />
<div className="text-sm text-gray-600 truncate">
{node.metadata.companyName} {node.metadata.roleTitle}
</div>
)}
{node.metadata?.lastModified && (
<div className="text-xs text-gray-500">
Updated {new Date(node.metadata.lastModified).toLocaleDateString()}
</div>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-1">
{(node.type === 'root' || node.type === 'branch') && (
<button
onClick={handleCreateBranch}
className="p-1 rounded hover:bg-black/10 transition-colors"
title="Create branch"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</button>
)}
{node.type === 'branch' && (
<button
onClick={handleCreateSubmission}
className="p-1 rounded hover:bg-black/10 transition-colors"
title="Create submission"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</button>
)}
</div>
</div>
{hasChildren && isExpanded && (
<div className="mt-2 space-y-2">
{node.children.map((child) => (
<TreeNode
key={child.id}
node={child}
level={level + 1}
selectedNodeId={selectedNodeId}
onNodeSelect={onNodeSelect}
onCreateBranch={onCreateBranch}
onCreateSubmission={onCreateSubmission}
/>
))} ))}
</div> </div>
)}
</div>
); );
} }
export default function CVTree({ export default function CVTree({ versions, selectedVersionId, onSelect }: {
treeData, versions: Version[]; selectedVersionId: string | null; onSelect: (id: string) => void;
selectedNodeId, }) {
onNodeSelect, const tree = buildTree(versions);
onCreateBranch, if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>;
onCreateSubmission
}: CVTreeProps) {
return ( return (
<div className="p-4 space-y-4"> <div style={{ paddingBottom: 8 }}>
<div className="flex items-center justify-between"> <Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} />
<h2 className="text-lg font-semibold text-gray-900">CV Versions</h2>
<div className="text-sm text-gray-500">
{treeData.children.reduce((acc, branch) => acc + branch.children.length + 1, 1)} versions
</div>
</div>
<div className="space-y-3">
<TreeNode
node={treeData}
selectedNodeId={selectedNodeId}
onNodeSelect={onNodeSelect}
onCreateBranch={onCreateBranch}
onCreateSubmission={onCreateSubmission}
/>
</div>
</div> </div>
); );
} }

View File

@@ -1,180 +1,42 @@
'use client'; 'use client';
import { useState } from 'react'; import { Patch } from '@/libs/api';
import { PatchDiff } from '@/types/cv';
interface DiffViewerProps { const OP_SYMBOL: Record<string, string> = {
patches: PatchDiff[]; replace_text: '±', remove_block: '', reorder_section: '↕', boost_keyword: '+',
title?: string; };
className?: string;
}
function DiffLine({ export default function DiffViewer({ patches }: { patches: Patch[] }) {
diff, if (!patches.length) {
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':
return ( return (
<div className="w-5 h-5 rounded-full bg-green-500 flex items-center justify-center"> <div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"> No patches identical to parent.
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
</div>
);
case 'removed':
return (
<div className="w-5 h-5 rounded-full bg-red-500 flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
</div>
);
case 'changed':
return (
<div className="w-5 h-5 rounded-full bg-yellow-500 flex items-center justify-center">
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
</svg>
</div>
);
default:
return <div className="w-5 h-5 rounded-full bg-gray-300" />;
}
};
return (
<div className={`border rounded-lg p-4 ${getTypeColor(diff.type)}`}>
<div className="flex items-start gap-3">
{getTypeIcon(diff.type)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">{diff.path}</span>
<button
onClick={onToggle}
className="text-xs text-gray-500 hover:text-gray-700 font-medium"
>
{isExpanded ? 'Hide' : 'Show'} details
</button>
</div>
{diff.context && (
<div className="text-xs text-gray-600 mt-1">{diff.context}</div>
)}
{isExpanded && (
<div className="mt-3 space-y-2">
{diff.oldValue && (
<div className="bg-red-100 border border-red-200 rounded p-2">
<div className="text-xs font-medium text-red-800 mb-1">- Removed</div>
<div className="text-sm text-red-700">{diff.oldValue}</div>
</div>
)}
{diff.newValue && (
<div className="bg-green-100 border border-green-200 rounded p-2">
<div className="text-xs font-medium text-green-800 mb-1">+ Added</div>
<div className="text-sm text-green-700">{diff.newValue}</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
export default function DiffViewer({ patches, title = "Changes", className = "" }: DiffViewerProps) {
const [expandedItems, setExpandedItems] = useState<Set<number>>(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 (
<div className={`card p-6 text-center ${className}`}>
<div className="text-gray-500">
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="font-medium">No changes</p>
<p className="text-sm">This version is identical to its parent</p>
</div>
</div> </div>
); );
} }
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 ( return (
<div className={`card ${className}`}> <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="p-4 border-b border-gray-200"> {patches.map(p => (
<div className="flex items-center justify-between"> <div key={p.id} style={{ borderLeft: '2px solid var(--border-strong)', paddingLeft: 12 }}>
<h3 className="text-lg font-semibold text-gray-900">{title}</h3> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div className="flex items-center gap-4 text-sm"> <span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)' }}>
{addedCount > 0 && ( {OP_SYMBOL[p.operation] ?? '·'} {p.target_path}
<span className="flex items-center gap-1 text-green-600">
<div className="w-3 h-3 rounded-full bg-green-500" />
{addedCount} added
</span> </span>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{p.operation}</span>
</div>
{p.old_value && (
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, padding: '3px 6px', background: '#fef2f2', borderRadius: 3, marginBottom: 3, color: '#991b1b', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{p.old_value}
</div>
)} )}
{changedCount > 0 && ( {p.new_value && (
<span className="flex items-center gap-1 text-yellow-600"> <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.5, padding: '3px 6px', background: '#f0fdf4', borderRadius: 3, color: '#166534', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
<div className="w-3 h-3 rounded-full bg-yellow-500" /> + {p.new_value}
{changedCount} changed </div>
</span>
)}
{removedCount > 0 && (
<span className="flex items-center gap-1 text-red-600">
<div className="w-3 h-3 rounded-full bg-red-500" />
{removedCount} removed
</span>
)} )}
</div> </div>
</div>
<div className="text-sm text-gray-600 mt-1">
{changeCount} {changeCount === 1 ? 'change' : 'changes'} detected
</div>
</div>
<div className="p-4 space-y-3 max-h-96 overflow-y-auto">
{patches.map((patch, index) => (
<DiffLine
key={index}
diff={patch}
isExpanded={expandedItems.has(index)}
onToggle={() => toggleExpanded(index)}
/>
))} ))}
</div> </div>
</div>
); );
} }

View File

@@ -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 = { export type StructuredBlock = {
path: string path: string;
block_type: string block_type: string;
text: string text: string;
keywords: string[] keywords: string[];
} };
export type Patch = { export type Patch = {
id: string id: string;
target_path: string target_path: string;
operation: string operation: string;
rationale?: string | null old_value?: string | null;
new_value?: string | null new_value?: string | null;
created_at: string metadata_json?: Record<string, unknown> | null;
} created_at: string;
};
export type Version = { export type Version = {
id: string id: string;
branch_name: string branch_name: string;
version_label?: string | null version_label?: string | null;
parent_version_id?: string | null parent_version_id?: string | null;
structured_blocks?: StructuredBlock[] | null structured_blocks?: StructuredBlock[] | null;
patches?: Patch[] artifact_docx_key?: string | null;
} patches: Patch[];
created_at: string;
updated_at: string;
};
export type Document = { export type Document = {
id: string id: string;
title: string title: string;
description?: string | null description?: string | null;
owner_id: string owner_id: string;
versions: Version[] root_version_id?: string | null;
} versions: Version[];
created_at: string;
updated_at: string;
};
export async function fetchDocuments(): Promise<Document[]> { export type Submission = {
const response = await fetch(`${API_BASE_URL}/api/v1/documents`, { id: string;
cache: "no-store", version_id: string;
headers: { company_name: string;
accept: "application/json", role_title: string;
}, job_url?: string | null;
}) job_description?: string | null;
if (!response.ok) { status: string;
throw new Error("Unable to load documents") 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<T>(path: string, init?: RequestInit): Promise<T> {
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}`);
} }
const payload = await response.json() return res.json();
return payload?.items ?? []
} }
export { API_BASE_URL } export const fetchDocuments = (): Promise<Document[]> =>
req<{ items: Document[] }>("/api/v1/documents", { cache: "no-store" }).then(r => r.items);
export const fetchDocument = (id: string): Promise<Document> =>
req<Document>(`/api/v1/documents/${id}`, { cache: "no-store" });
export async function uploadDocument(title: string, description: string | null, file: File): Promise<Document> {
const form = new FormData();
form.append("title", title);
if (description) form.append("description", description);
form.append("file", file);
return req<Document>("/api/v1/documents", { method: "POST", body: form });
}
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<string, unknown>[] = [],
): Promise<Version> {
return req<Version>("/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<Submission> {
return req<Submission>("/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<PublicAsset> {
return req<PublicAsset>("/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 };