mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-05-31 16:53:38 +00:00
feat(dashboard): complete CV branching dashboard with auth and full editing workflow
- Visual branch heritage tree with colored dots and connecting lines, depth-aware expand/collapse - Dashboard 3-tab layout: Content (inline block editing + patch staging), Patches (diff view), Submissions (AI suggestions) - Inline block editing: click to edit any CV block, stage edits, save as named branch with pre-filled patches - Submissions tab: create applications, request AI tailoring suggestions, accept/reject per suggestion - Simple hardcoded login (username/password via env vars LOGIN_USER/LOGIN_PASS, defaults admin/admin) - Authentik OIDC integration: authorize redirect + callback exchange, configurable via NEXT_PUBLIC_AUTHENTIK_* - Middleware protecting /dashboard with session cookie verification (HMAC-SHA256) - Auth API routes: /api/auth/login, /api/auth/logout, /api/auth/callback, /api/auth/token - Backend: GET/PATCH submission routes for listing submissions and accepting/rejecting AI suggestions - API client: OIDC bearer token forwarding from client-readable cookie https://claude.ai/code/session_01CdisLhbC2kVt2hxfJ7TNPf
This commit is contained in:
@@ -15,45 +15,106 @@ function buildTree(versions: Version[]): TreeNode | null {
|
||||
return root;
|
||||
}
|
||||
|
||||
function Node({ node, depth, selectedId, onSelect }: {
|
||||
node: TreeNode; depth: number; selectedId: string | null; onSelect: (id: string) => void;
|
||||
const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2'];
|
||||
|
||||
function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
|
||||
node: TreeNode; depth: number; selectedId: string | null;
|
||||
onSelect: (id: string) => void; colorIndex?: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const v = node.version;
|
||||
const isRoot = !v.parent_version_id;
|
||||
const isSelected = v.id === selectedId;
|
||||
const hasChildren = node.children.length > 0;
|
||||
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
{/* horizontal connector from parent's vertical line */}
|
||||
{depth > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', left: -1, top: 15,
|
||||
width: 14, height: 1, background: 'var(--border-strong)', zIndex: 1,
|
||||
}} />
|
||||
)}
|
||||
|
||||
<div
|
||||
onClick={() => onSelect(v.id)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
paddingLeft: 12 + depth * 16, paddingRight: 8,
|
||||
height: 30, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
paddingLeft: depth > 0 ? 18 : 8, paddingRight: 8,
|
||||
height: 30, cursor: 'pointer', borderRadius: 4, userSelect: 'none',
|
||||
background: isSelected ? 'var(--selected-bg)' : 'transparent',
|
||||
borderLeft: isSelected ? '2px solid var(--selected-border)' : '2px solid transparent',
|
||||
transition: 'background 0.1s',
|
||||
borderLeft: isSelected && depth === 0 ? '2px solid var(--selected-border)' : '2px solid transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'var(--hover)'; }}
|
||||
onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
|
||||
>
|
||||
{/* expand toggle */}
|
||||
<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 }}
|
||||
style={{
|
||||
width: 12, height: 12, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', cursor: 'pointer', background: 'none',
|
||||
border: 'none', padding: 0, color: 'var(--text-faint)', flexShrink: 0,
|
||||
opacity: node.children.length > 0 ? 1 : 0, pointerEvents: node.children.length > 0 ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" style={{ transform: open ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.15s' }}>
|
||||
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor"
|
||||
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.12s' }}>
|
||||
<path d="M2 1l4 3-4 3V1z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span style={{ flex: 1, fontSize: 13, fontWeight: !v.parent_version_id ? 600 : 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text)' }}>
|
||||
{/* dot indicator */}
|
||||
<span style={{
|
||||
width: isRoot ? 8 : 7, height: isRoot ? 8 : 7,
|
||||
borderRadius: '50%', flexShrink: 0,
|
||||
background: isRoot || isSelected ? dotColor : 'transparent',
|
||||
border: `2px solid ${dotColor}`,
|
||||
transition: 'background 0.1s',
|
||||
}} />
|
||||
|
||||
{/* label */}
|
||||
<span style={{
|
||||
flex: 1, fontSize: 13,
|
||||
fontWeight: isRoot ? 600 : 400,
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
color: isSelected ? 'var(--text)' : isRoot ? 'var(--text)' : 'var(--text-muted)',
|
||||
}}>
|
||||
{v.version_label || v.branch_name}
|
||||
</span>
|
||||
|
||||
{/* patch count */}
|
||||
{v.patches.length > 0 && (
|
||||
<span style={{
|
||||
fontSize: 10, color: 'var(--text-faint)',
|
||||
background: 'var(--hover)', padding: '1px 4px',
|
||||
borderRadius: 3, flexShrink: 0,
|
||||
}}>
|
||||
{v.patches.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{open && node.children.map(child => (
|
||||
<Node key={child.version.id} node={child} depth={depth + 1} selectedId={selectedId} onSelect={onSelect} />
|
||||
))}
|
||||
|
||||
{/* children with vertical line */}
|
||||
{open && node.children.length > 0 && (
|
||||
<div style={{
|
||||
marginLeft: depth > 0 ? 22 : 14,
|
||||
borderLeft: `1px solid var(--border)`,
|
||||
paddingLeft: 0,
|
||||
}}>
|
||||
{node.children.map((child, i) => (
|
||||
<Node
|
||||
key={child.version.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
colorIndex={depth === 0 ? i + 1 : colorIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user