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:
Claude
2026-04-02 18:12:25 +00:00
parent b57db1fe7b
commit e6c29f3bd4
9 changed files with 800 additions and 984 deletions

View File

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

View File

@@ -1,197 +1,77 @@
'use client';
import { useState } from 'react';
import { CVTreeNode } from '@/types/cv';
import { Version } from '@/libs/api';
interface CVTreeProps {
treeData: CVTreeNode;
selectedNodeId?: string;
onNodeSelect: (nodeId: string) => void;
onCreateBranch: (parentId: string) => void;
onCreateSubmission: (branchId: string) => void;
type TreeNode = { version: Version; children: TreeNode[] };
function buildTree(versions: Version[]): TreeNode | null {
const map = new Map(versions.map(v => [v.id, { version: v, children: [] as TreeNode[] }]));
let root: TreeNode | null = null;
for (const node of map.values()) {
const pid = node.version.parent_version_id;
if (!pid) { root = node; } else { map.get(pid)?.children.push(node); }
}
return root;
}
const NODE_COLORS = {
root: 'bg-blue-100 border-blue-300 text-blue-900',
branch: 'bg-green-100 border-green-300 text-green-900',
submission: 'bg-yellow-100 border-yellow-300 text-yellow-900',
const STATUS_CLASS: Record<string, string> = {
draft: 'badge badge-draft', submitted: 'badge badge-submitted',
interviewing: 'badge badge-interviewing', offer: 'badge badge-offer',
rejected: 'badge badge-rejected', closed: 'badge badge-closed',
};
const STATUS_COLORS = {
draft: 'bg-gray-100 text-gray-700',
submitted: 'bg-yellow-100 text-yellow-700',
interviewing: 'bg-blue-100 text-blue-700',
offer: 'bg-green-100 text-green-700',
rejected: 'bg-red-100 text-red-700',
closed: 'bg-gray-100 text-gray-700',
};
function TreeNode({
node,
level = 0,
selectedNodeId,
onNodeSelect,
onCreateBranch,
onCreateSubmission
}: {
node: CVTreeNode;
level?: number;
selectedNodeId?: string;
onNodeSelect: (nodeId: string) => void;
onCreateBranch: (parentId: string) => void;
onCreateSubmission: (branchId: string) => void;
function Node({ node, depth, selectedId, onSelect }: {
node: TreeNode; depth: number; selectedId: string | null; onSelect: (id: string) => void;
}) {
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children.length > 0;
const isSelected = selectedNodeId === node.id;
const [open, setOpen] = useState(true);
const v = node.version;
const isSelected = v.id === selectedId;
const hasChildren = node.children.length > 0;
const handleNodeClick = () => {
onNodeSelect(node.id);
};
const handleCreateBranch = (e: React.MouseEvent) => {
e.stopPropagation();
onCreateBranch(node.id);
};
const handleCreateSubmission = (e: React.MouseEvent) => {
e.stopPropagation();
onCreateSubmission(node.id);
};
const toggleExpanded = (e: React.MouseEvent) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
};
return (
<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"
return (
<div>
<div
onClick={() => onSelect(v.id)}
style={{
display: 'flex', alignItems: 'center', gap: 4,
paddingLeft: 12 + depth * 16, paddingRight: 8,
height: 30, cursor: 'pointer',
background: isSelected ? 'var(--selected-bg)' : 'transparent',
borderLeft: isSelected ? '2px solid var(--selected-border)' : '2px solid transparent',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'var(--hover)'; }}
onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
>
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</button>
)}
{!hasChildren && <div className="w-4" />}
<button
onClick={e => { e.stopPropagation(); setOpen(o => !o); }}
style={{ width: 14, height: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: hasChildren ? 1 : 0, cursor: 'pointer', background: 'none', border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0 }}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" style={{ transform: open ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s' }}>
<path d="M2 1l4 3-4 3V1z" />
</svg>
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{node.label}</span>
{node.metadata?.isPublic && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Public
</span>
)}
{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}
<span style={{ flex: 1, fontSize: 13, fontWeight: !v.parent_version_id ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text)' }}>
{v.version_label || v.branch_name}
</span>
</div>
)}
{node.metadata?.lastModified && (
<div className="text-xs text-gray-500">
Updated {new Date(node.metadata.lastModified).toLocaleDateString()}
</div>
)}
{open && node.children.map(child => (
<Node key={child.version.id} node={child} depth={depth + 1} selectedId={selectedId} onSelect={onSelect} />
))}
</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({
treeData,
selectedNodeId,
onNodeSelect,
onCreateBranch,
onCreateSubmission
}: CVTreeProps) {
return (
<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
export default function CVTree({ versions, selectedVersionId, onSelect }: {
versions: Version[]; selectedVersionId: string | null; onSelect: (id: string) => void;
}) {
const tree = buildTree(versions);
if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>;
return (
<div style={{ paddingBottom: 8 }}>
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} />
</div>
</div>
<div className="space-y-3">
<TreeNode
node={treeData}
selectedNodeId={selectedNodeId}
onNodeSelect={onNodeSelect}
onCreateBranch={onCreateBranch}
onCreateSubmission={onCreateSubmission}
/>
</div>
</div>
);
}
);
}

View File

@@ -1,180 +1,42 @@
'use client';
import { useState } from 'react';
import { PatchDiff } from '@/types/cv';
import { Patch } from '@/libs/api';
interface DiffViewerProps {
patches: PatchDiff[];
title?: string;
className?: string;
}
const OP_SYMBOL: Record<string, string> = {
replace_text: '±', remove_block: '', reorder_section: '↕', boost_keyword: '+',
};
function DiffLine({
diff,
isExpanded,
onToggle
}: {
diff: PatchDiff;
isExpanded: boolean;
onToggle: () => void;
}) {
const getTypeColor = (type: string) => {
switch (type) {
case 'added': return 'bg-green-50 border-green-200';
case 'removed': return 'bg-red-50 border-red-200';
case 'changed': return 'bg-yellow-50 border-yellow-200';
default: return 'bg-gray-50 border-gray-200';
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'added':
export default function DiffViewer({ patches }: { patches: Patch[] }) {
if (!patches.length) {
return (
<div className="w-5 h-5 rounded-full bg-green-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="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 style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>
No patches identical to parent.
</div>
)}
</div>
</div>
</div>
);
}
export default function DiffViewer({ patches, title = "Changes", className = "" }: DiffViewerProps) {
const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set());
const toggleExpanded = (index: number) => {
const newExpanded = new Set(expandedItems);
if (newExpanded.has(index)) {
newExpanded.delete(index);
} else {
newExpanded.add(index);
);
}
setExpandedItems(newExpanded);
};
if (patches.length === 0) {
return (
<div className={`card p-6 text-center ${className}`}>
<div className="text-gray-500">
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="font-medium">No changes</p>
<p className="text-sm">This version is identical to its parent</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{patches.map(p => (
<div key={p.id} style={{ borderLeft: '2px solid var(--border-strong)', paddingLeft: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)' }}>
{OP_SYMBOL[p.operation] ?? '·'} {p.target_path}
</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>
);
}
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>
);
}
}