mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-05-31 08:43:37 +00:00
Redesign webapp with minimal style and full backend integration
- New monochrome design system in globals.css (no shadows, tight spacing, system-font)
- Root layout stripped to html/body; home page owns its Header/Footer
- Dashboard fully wired to FastAPI backend: upload, branch, submission, publish, download
- CVTree rebuilt as compact file-tree component driven by real version data
- DiffViewer rebuilt as minimal git-diff display using real patch data
- API client (libs/api.ts) extended with all CRUD operations
- Header redesigned to match minimal style
- Backend: added GET /documents/{id}/versions/{id}/download endpoint for DOCX export
https://claude.ai/code/session_01Xmxm2QLgFBgRJyYD6VukR6
This commit is contained in:
@@ -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 (
|
||||
<div>
|
||||
<div className="label" style={{ padding: '0 0 8px' }}>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── modals ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: Document) => void }) {
|
||||
const [title, setTitle] = useState('');
|
||||
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);
|
||||
|
||||
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 [selectedNodeId, setSelectedNodeId] = useState<string>('root-1');
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
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);
|
||||
|
||||
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 (
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Resume Branches</h1>
|
||||
<p className="text-gray-600">Manage your CV versions like code</p>
|
||||
</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>
|
||||
const onBranchDone = (v: Version) => {
|
||||
refreshDocs().then(() => setSelectedVersionId(v.id));
|
||||
setModal(null);
|
||||
};
|
||||
|
||||
{/* 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>
|
||||
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>
|
||||
|
||||
{/* 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 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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Diff Viewer */}
|
||||
{selectedNode.type !== 'root' && (
|
||||
<DiffViewer
|
||||
patches={mockPatches}
|
||||
title={`Changes from ${selectedNode.parentId === 'root-1' ? 'Master Resume' : 'Parent Branch'}`}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
</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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user