mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-05-31 08:43:37 +00:00
Replace mock data with real API integration in dashboard
This commit is contained in:
@@ -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(...),
|
||||||
|
|||||||
@@ -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',
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
newValue: 'Applied AI research engineer with 3+ years building production ML systems for large-scale applications',
|
return (
|
||||||
context: 'Summary section',
|
<div>
|
||||||
},
|
<div className="label" style={{ padding: '0 0 8px' }}>{title}</div>
|
||||||
{
|
{children}
|
||||||
path: 'experience[0].bullets[1]',
|
</div>
|
||||||
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',
|
// ─── modals ───────────────────────────────────────────────────────────────────
|
||||||
},
|
|
||||||
{
|
function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: Document) => void }) {
|
||||||
path: 'skills.technical',
|
const [title, setTitle] = useState('');
|
||||||
type: 'added',
|
const [desc, setDesc] = useState('');
|
||||||
newValue: 'Constitutional AI, RLHF, Transformer architectures',
|
const [file, setFile] = useState<File | null>(null);
|
||||||
context: 'Technical skills section',
|
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() {
|
export default function Dashboard() {
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string>('root-1');
|
const [docs, setDocs] = useState<Document[]>([]);
|
||||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
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) => {
|
useEffect(() => {
|
||||||
setSelectedNodeId(nodeId);
|
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) => {
|
const selectedDoc = docs.find(d => d.id === selectedDocId) ?? null;
|
||||||
// TODO: Implement branch creation
|
const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null;
|
||||||
console.log('Creating branch from:', parentId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateSubmission = (branchId: string) => {
|
const refreshDocs = async () => {
|
||||||
// TODO: Implement submission creation
|
try {
|
||||||
console.log('Creating submission from:', branchId);
|
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 (
|
const onBranchDone = (v: Version) => {
|
||||||
<div className="min-h-screen bg-gray-50">
|
refreshDocs().then(() => setSelectedVersionId(v.id));
|
||||||
{/* Header */}
|
setModal(null);
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
return (
|
||||||
<div className="flex h-[calc(100vh-80px)]">
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden', background: 'var(--bg)' }}>
|
||||||
{/* Left Panel - CV Tree */}
|
{/* top bar */}
|
||||||
<div className="w-1/3 border-r border-gray-200 bg-white overflow-y-auto">
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px', height: 44, borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||||
<CVTree
|
<a href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}>
|
||||||
treeData={mockTreeData}
|
Resume Branches
|
||||||
selectedNodeId={selectedNodeId}
|
</a>
|
||||||
onNodeSelect={handleNodeSelect}
|
<button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: 12 }} onClick={() => setModal('upload')}>
|
||||||
onCreateBranch={handleCreateBranch}
|
+ Upload CV
|
||||||
onCreateSubmission={handleCreateSubmission}
|
</button>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Center Panel - Version Details */}
|
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||||
<div className="flex-1 p-6 overflow-y-auto">
|
{/* left panel */}
|
||||||
<div className="max-w-4xl">
|
<div style={{ width: 240, flexShrink: 0, borderRight: '1px solid var(--border)', background: 'var(--surface)', overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
||||||
{selectedNode && (
|
{loading && <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>Loading…</div>}
|
||||||
<div className="space-y-6">
|
{error && <div style={{ padding: 16, fontSize: 13, color: '#dc2626' }}>{error}</div>}
|
||||||
{/* Version Header */}
|
|
||||||
<div className="card p-6">
|
{!loading && !error && docs.length === 0 && (
|
||||||
<div className="flex items-start justify-between">
|
<div style={{ padding: 16 }}>
|
||||||
<div>
|
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}>No CVs yet.</p>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
<button className="btn btn-primary" style={{ width: '100%' }} onClick={() => setModal('upload')}>
|
||||||
{selectedNode.label}
|
Upload your first CV
|
||||||
</h2>
|
</button>
|
||||||
<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>
|
|
||||||
|
{docs.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<>
|
||||||
{selectedNode.metadata?.isPublic && (
|
{/* document selector */}
|
||||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
<div style={{ padding: '10px 12px 6px' }}>
|
||||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
<div className="label" style={{ marginBottom: 6 }}>Documents</div>
|
||||||
<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" />
|
{docs.map(d => (
|
||||||
</svg>
|
<div
|
||||||
Public
|
key={d.id}
|
||||||
</span>
|
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' }}
|
||||||
<button className="btn-ghost">
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
{d.title}
|
||||||
<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" />
|
</div>
|
||||||
</svg>
|
))}
|
||||||
</button>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* main content */}
|
||||||
<div className="flex gap-3">
|
<div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>
|
||||||
<button className="btn-primary">
|
{!selectedVersion && !loading && (
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div style={{ paddingTop: 60, textAlign: 'center', color: 'var(--text-faint)', fontSize: 13 }}>
|
||||||
<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" />
|
Select a version to view details.
|
||||||
</svg>
|
</div>
|
||||||
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 */}
|
{selectedVersion && (
|
||||||
{selectedNode.type !== 'root' && (
|
<div style={{ maxWidth: 680 }}>
|
||||||
<DiffViewer
|
{/* version header */}
|
||||||
patches={mockPatches}
|
<div style={{ marginBottom: 20 }}>
|
||||||
title={`Changes from ${selectedNode.parentId === 'root-1' ? 'Master Resume' : 'Parent Branch'}`}
|
<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 */}
|
{/* action bar */}
|
||||||
<div className="card">
|
<div style={{ display: 'flex', gap: 6, marginBottom: 24, flexWrap: 'wrap' }}>
|
||||||
<div className="p-4 border-b border-gray-200">
|
<button className="btn btn-ghost" onClick={() => setModal('branch')}>New branch</button>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">Document Preview</h3>
|
<button className="btn btn-ghost" onClick={() => setModal('submission')}>New submission</button>
|
||||||
</div>
|
<button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>
|
||||||
<div className="p-6">
|
{selectedVersion.artifact_docx_key && selectedDoc && (
|
||||||
<div className="bg-gray-100 rounded-lg p-8 text-center">
|
<a
|
||||||
<svg className="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
href={downloadVersionUrl(selectedDoc.id, selectedVersion.id)}
|
||||||
<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" />
|
download
|
||||||
</svg>
|
className="btn btn-ghost"
|
||||||
<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>
|
↓ DOCX
|
||||||
</div>
|
</a>
|
||||||
</div>
|
)}
|
||||||
|
</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>
|
</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>
|
||||||
</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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" }}>
|
||||||
Git for CVs
|
<p style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--text-faint)", marginBottom: 16 }}>
|
||||||
</h1>
|
Resume Branches
|
||||||
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
</p>
|
||||||
Manage your resume like code: branch, version, and tailor for different roles
|
<h1 style={{ fontSize: 40, fontWeight: 700, lineHeight: 1.1, marginBottom: 16, letterSpacing: "-0.02em" }}>
|
||||||
while preserving ATS formatting. Never lose track of your career story again.
|
Git for CVs
|
||||||
</p>
|
</h1>
|
||||||
<div className="flex gap-4 justify-center">
|
<p style={{ fontSize: 16, color: "var(--text-muted)", lineHeight: 1.6, marginBottom: 32 }}>
|
||||||
<Link
|
Upload your ATS-safe DOCX. Branch it by role. Tailor per company without
|
||||||
href="/dashboard"
|
losing structure. Publish stable public links.
|
||||||
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"
|
</p>
|
||||||
>
|
<div style={{ display: "flex", gap: 10, justifyContent: "center" }}>
|
||||||
Get Started
|
<Link href="/dashboard" className="btn btn-primary" style={{ padding: "9px 20px", fontSize: 14 }}>
|
||||||
</Link>
|
Open Dashboard
|
||||||
<Link
|
</Link>
|
||||||
href="/demo"
|
</div>
|
||||||
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"
|
</div>
|
||||||
>
|
</section>
|
||||||
View Demo
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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">
|
</div>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
))}
|
||||||
</svg>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
<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">
|
<section style={{ padding: "48px 24px", borderTop: "1px solid var(--border)", background: "var(--surface)" }}>
|
||||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
<div style={{ maxWidth: 480, margin: "0 auto" }}>
|
||||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<ol style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 20 }}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
{[
|
||||||
</svg>
|
["Upload your master DOCX", "The canonical ATS-formatted document becomes your root node."],
|
||||||
</div>
|
["Create specialization branches", "ml-engineer, backend, research — each branch tracks a career path."],
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Version Control</h3>
|
["Tailor per submission", "Paste a job description, accept AI suggestions, export the tailored DOCX."],
|
||||||
<p className="text-gray-600">
|
["Publish selected versions", "One-click stable public links for portfolios or direct recruiter sharing."],
|
||||||
Create branches for different career paths: ML Engineer, Backend Dev, Research.
|
].map(([step, desc], i) => (
|
||||||
Track every change with full history and rollback capability.
|
<li key={i} style={{ display: "flex", gap: 16 }}>
|
||||||
</p>
|
<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 style={{ fontWeight: 600, fontSize: 13, marginBottom: 2 }}>{step}</div>
|
||||||
<div className="card p-6 text-center hover:shadow-lg transition-shadow">
|
<div style={{ fontSize: 13, color: "var(--text-muted)" }}>{desc}</div>
|
||||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-4">
|
</div>
|
||||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</li>
|
||||||
<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>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">Smart Tailoring</h3>
|
</section>
|
||||||
<p className="text-gray-600">
|
</main>
|
||||||
Never wonder "what did I tell them about my React experience?" again.
|
<Footer />
|
||||||
</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
|
|
||||||
"what did I tell them about my React experience?" 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>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* How it Works */}
|
|
||||||
<section className="px-6 py-16 bg-gray-50">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<h2 className="text-3xl font-bold text-center text-gray-900 mb-12">
|
|
||||||
How It Works
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
|
||||||
<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">
|
|
||||||
1
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Upload Your Master Resume</h3>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
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: "ML Engineer", "Backend Developer", "Research Scientist".
|
|
||||||
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>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* CTA */}
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,21 @@
|
|||||||
import Link from "next/link";
|
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">
|
Resume Branches
|
||||||
<div className="flex items-center">
|
|
||||||
<Link href="/" className="text-xl font-bold text-gray-900 hover:text-blue-600 transition-colors">
|
|
||||||
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="/"
|
</Link>
|
||||||
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
|
))}
|
||||||
>
|
<Link href="/dashboard" className="btn btn-primary" style={{ padding: "5px 14px", fontSize: 13 }}>
|
||||||
Home
|
Open app
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
</nav>
|
||||||
href="/dashboard"
|
</header>
|
||||||
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
|
);
|
||||||
>
|
|
||||||
Dashboard
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/docs"
|
|
||||||
className="text-gray-600 hover:text-gray-900 font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Docs
|
|
||||||
</Link>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 hasChildren = node.children.length > 0;
|
const v = node.version;
|
||||||
const isSelected = selectedNodeId === node.id;
|
const isSelected = v.id === selectedId;
|
||||||
|
const hasChildren = node.children.length > 0;
|
||||||
|
|
||||||
const handleNodeClick = () => {
|
return (
|
||||||
onNodeSelect(node.id);
|
<div>
|
||||||
};
|
<div
|
||||||
|
onClick={() => onSelect(v.id)}
|
||||||
const handleCreateBranch = (e: React.MouseEvent) => {
|
style={{
|
||||||
e.stopPropagation();
|
display: 'flex', alignItems: 'center', gap: 4,
|
||||||
onCreateBranch(node.id);
|
paddingLeft: 12 + depth * 16, paddingRight: 8,
|
||||||
};
|
height: 30, cursor: 'pointer',
|
||||||
|
background: isSelected ? 'var(--selected-bg)' : 'transparent',
|
||||||
const handleCreateSubmission = (e: React.MouseEvent) => {
|
borderLeft: isSelected ? '2px solid var(--selected-border)' : '2px solid transparent',
|
||||||
e.stopPropagation();
|
transition: 'background 0.1s',
|
||||||
onCreateSubmission(node.id);
|
}}
|
||||||
};
|
onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'var(--hover)'; }}
|
||||||
|
onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
|
||||||
const toggleExpanded = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsExpanded(!isExpanded);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="select-none">
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-all
|
|
||||||
${NODE_COLORS[node.type]}
|
|
||||||
${isSelected ? 'ring-2 ring-blue-500 ring-offset-1' : ''}
|
|
||||||
hover:shadow-sm
|
|
||||||
`}
|
|
||||||
style={{ marginLeft: `${level * 24}px` }}
|
|
||||||
onClick={handleNodeClick}
|
|
||||||
>
|
|
||||||
{hasChildren && (
|
|
||||||
<button
|
|
||||||
onClick={toggleExpanded}
|
|
||||||
className="flex-shrink-0 w-4 h-4 flex items-center justify-center hover:bg-black/10 rounded"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={`w-3 h-3 transform transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
|
||||||
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" />
|
<button
|
||||||
</svg>
|
onClick={e => { e.stopPropagation(); setOpen(o => !o); }}
|
||||||
</button>
|
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 width="8" height="8" viewBox="0 0 8 8" fill="currentColor" style={{ transform: open ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s' }}>
|
||||||
{!hasChildren && <div className="w-4" />}
|
<path d="M2 1l4 3-4 3V1z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<span style={{ flex: 1, fontSize: 13, fontWeight: !v.parent_version_id ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text)' }}>
|
||||||
<div className="flex items-center gap-2">
|
{v.version_label || v.branch_name}
|
||||||
<span className="font-medium truncate">{node.label}</span>
|
</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>
|
|
||||||
)}
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{node.metadata?.companyName && (
|
|
||||||
<div className="text-sm text-gray-600 truncate">
|
|
||||||
{node.metadata.companyName} • {node.metadata.roleTitle}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
{open && node.children.map(child => (
|
||||||
|
<Node key={child.version.id} node={child} depth={depth + 1} selectedId={selectedId} onSelect={onSelect} />
|
||||||
{node.metadata?.lastModified && (
|
))}
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
Updated {new Date(node.metadata.lastModified).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
return (
|
||||||
}: CVTreeProps) {
|
<div style={{ paddingBottom: 8 }}>
|
||||||
return (
|
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} />
|
||||||
<div className="p-4 space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<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>
|
);
|
||||||
|
}
|
||||||
<div className="space-y-3">
|
|
||||||
<TreeNode
|
|
||||||
node={treeData}
|
|
||||||
selectedNodeId={selectedNodeId}
|
|
||||||
onNodeSelect={onNodeSelect}
|
|
||||||
onCreateBranch={onCreateBranch}
|
|
||||||
onCreateSubmission={onCreateSubmission}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</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 (
|
return (
|
||||||
<div className={`card p-6 text-center ${className}`}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<div className="text-gray-500">
|
{patches.map(p => (
|
||||||
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div key={p.id} style={{ borderLeft: '2px solid var(--border-strong)', paddingLeft: 12 }}>
|
||||||
<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" />
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
</svg>
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
<p className="font-medium">No changes</p>
|
{OP_SYMBOL[p.operation] ?? '·'} {p.target_path}
|
||||||
<p className="text-sm">This version is identical to its parent</p>
|
</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>
|
||||||
|
)}
|
||||||
|
{p.new_value && (
|
||||||
|
<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' }}>
|
||||||
|
+ {p.new_value}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</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 (
|
|
||||||
<div className={`card ${className}`}>
|
|
||||||
<div className="p-4 border-b border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
|
||||||
{addedCount > 0 && (
|
|
||||||
<span className="flex items-center gap-1 text-green-600">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
|
||||||
{addedCount} added
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{changedCount > 0 && (
|
|
||||||
<span className="flex items-center gap-1 text-yellow-600">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
|
||||||
{changedCount} changed
|
|
||||||
</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 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 type Submission = {
|
||||||
|
id: string;
|
||||||
|
version_id: string;
|
||||||
|
company_name: string;
|
||||||
|
role_title: string;
|
||||||
|
job_url?: string | null;
|
||||||
|
job_description?: string | null;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicAsset = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
artifact_key: string;
|
||||||
|
is_public: boolean;
|
||||||
|
url?: string | null;
|
||||||
|
version_id?: string | null;
|
||||||
|
submission_id?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function req<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}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchDocuments(): Promise<Document[]> {
|
export const fetchDocuments = (): Promise<Document[]> =>
|
||||||
const response = await fetch(`${API_BASE_URL}/api/v1/documents`, {
|
req<{ items: Document[] }>("/api/v1/documents", { cache: "no-store" }).then(r => r.items);
|
||||||
cache: "no-store",
|
|
||||||
headers: {
|
export const fetchDocument = (id: string): Promise<Document> =>
|
||||||
accept: "application/json",
|
req<Document>(`/api/v1/documents/${id}`, { cache: "no-store" });
|
||||||
},
|
|
||||||
})
|
export async function uploadDocument(title: string, description: string | null, file: File): Promise<Document> {
|
||||||
if (!response.ok) {
|
const form = new FormData();
|
||||||
throw new Error("Unable to load documents")
|
form.append("title", title);
|
||||||
}
|
if (description) form.append("description", description);
|
||||||
const payload = await response.json()
|
form.append("file", file);
|
||||||
return payload?.items ?? []
|
return req<Document>("/api/v1/documents", { method: "POST", body: form });
|
||||||
}
|
}
|
||||||
|
|
||||||
export { API_BASE_URL }
|
export const downloadVersionUrl = (documentId: string, versionId: string): string =>
|
||||||
|
`${API}/api/v1/documents/${documentId}/versions/${versionId}/download`;
|
||||||
|
|
||||||
|
export async function createBranch(
|
||||||
|
parentVersionId: string,
|
||||||
|
branchName: string,
|
||||||
|
versionLabel?: string | null,
|
||||||
|
patches: Record<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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user