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:
Claude
2026-04-03 13:45:51 +00:00
parent 9a8add0bcd
commit 01f34915f6
14 changed files with 1023 additions and 217 deletions

View File

@@ -9,14 +9,43 @@ from app.schemas import (
SubmissionCreateRequest, SubmissionCreateRequest,
SubmissionResponse, SubmissionResponse,
SuggestionResponse, SuggestionResponse,
SuggestionUpdateRequest,
)
from app.services.submissions import (
create_submission,
get_submission,
list_submissions,
request_ai_suggestions,
update_suggestion,
) )
from app.services.submissions import create_submission, request_ai_suggestions
from dlib.auth import AuthenticatedUser from dlib.auth import AuthenticatedUser
router = APIRouter(prefix="/submissions", tags=["submissions"]) router = APIRouter(prefix="/submissions", tags=["submissions"])
@router.get("", response_model=list[SubmissionResponse])
async def list_submissions_endpoint(
version_id: str | None = None,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
items = await list_submissions(session, owner_id=user.sub, version_id=version_id)
return [SubmissionResponse.model_validate(s) for s in items]
@router.get("/{submission_id}", response_model=SubmissionResponse)
async def get_submission_endpoint(
submission_id: str,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
submission = await get_submission(session, owner_id=user.sub, submission_id=submission_id)
if not submission:
raise HTTPException(status_code=404, detail="Submission not found")
return SubmissionResponse.model_validate(submission)
@router.post("", response_model=SubmissionResponse) @router.post("", response_model=SubmissionResponse)
async def create_submission_endpoint( async def create_submission_endpoint(
payload: SubmissionCreateRequest, payload: SubmissionCreateRequest,
@@ -54,3 +83,23 @@ async def request_submissions_ai(
if suggestions is None: if suggestions is None:
raise HTTPException(status_code=404, detail="Submission not found") raise HTTPException(status_code=404, detail="Submission not found")
return [SuggestionResponse.model_validate(item) for item in suggestions] return [SuggestionResponse.model_validate(item) for item in suggestions]
@router.patch("/{submission_id}/suggestions/{suggestion_id}", response_model=SuggestionResponse)
async def update_suggestion_endpoint(
submission_id: str,
suggestion_id: str,
payload: SuggestionUpdateRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
suggestion = await update_suggestion(
session,
owner_id=user.sub,
submission_id=submission_id,
suggestion_id=suggestion_id,
accepted=payload.accepted,
)
if not suggestion:
raise HTTPException(status_code=404, detail="Suggestion not found")
return SuggestionResponse.model_validate(suggestion)

View File

@@ -10,6 +10,7 @@ from .cv import (
SubmissionCreateRequest, SubmissionCreateRequest,
SubmissionResponse, SubmissionResponse,
SuggestionResponse, SuggestionResponse,
SuggestionUpdateRequest,
VersionResponse, VersionResponse,
) )
@@ -23,6 +24,7 @@ __all__ = [
"SubmissionResponse", "SubmissionResponse",
"AiSuggestionRequest", "AiSuggestionRequest",
"SuggestionResponse", "SuggestionResponse",
"SuggestionUpdateRequest",
"PublishRequest", "PublishRequest",
"PublicAssetResponse", "PublicAssetResponse",
"PublicAssetLookupResponse", "PublicAssetLookupResponse",

View File

@@ -123,3 +123,7 @@ class PublicAssetResponse(BaseModel):
class PublicAssetLookupResponse(BaseModel): class PublicAssetLookupResponse(BaseModel):
asset: PublicAssetResponse asset: PublicAssetResponse
class SuggestionUpdateRequest(BaseModel):
accepted: bool

View File

@@ -84,6 +84,55 @@ async def request_ai_suggestions(
return created return created
async def list_submissions(
session: AsyncSession,
*,
owner_id: str,
version_id: str | None = None,
) -> list[Submission]:
stmt = (
select(Submission)
.join(Submission.version)
.join(CvVersion.document)
.where(CvDocument.owner_id == owner_id)
.options(selectinload(Submission.suggestions))
)
if version_id:
stmt = stmt.where(Submission.version_id == version_id)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_submission(
session: AsyncSession, *, owner_id: str, submission_id: str
) -> Submission | None:
return await _get_submission_for_owner(session, owner_id, submission_id)
async def update_suggestion(
session: AsyncSession,
*,
owner_id: str,
submission_id: str,
suggestion_id: str,
accepted: bool,
) -> AiSuggestion | None:
submission = await _get_submission_for_owner(session, owner_id, submission_id)
if not submission:
return None
stmt = select(AiSuggestion).where(
AiSuggestion.id == suggestion_id, AiSuggestion.submission_id == submission_id
)
result = await session.execute(stmt)
suggestion = result.scalars().one_or_none()
if not suggestion:
return None
suggestion.accepted = accepted
await session.commit()
await session.refresh(suggestion)
return suggestion
async def _get_version_for_owner( async def _get_version_for_owner(
session: AsyncSession, owner_id: str, version_id: str session: AsyncSession, owner_id: str, version_id: str
) -> CvVersion | None: ) -> CvVersion | None:

View File

@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
const { searchParams, origin } = new URL(req.url);
const code = searchParams.get('code');
if (!code) return NextResponse.redirect(`${origin}/login?error=no_code`);
const issuer = process.env.AUTHENTIK_ISSUER;
const clientId = process.env.AUTHENTIK_CLIENT_ID;
const clientSecret = process.env.AUTHENTIK_CLIENT_SECRET;
const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL ?? origin}/api/auth/callback`;
if (!issuer || !clientId || !clientSecret) {
return NextResponse.redirect(`${origin}/login?error=oidc_not_configured`);
}
const tokenRes = await fetch(`${issuer}/application/o/token/`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code', code,
redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret,
}),
}).catch(() => null);
if (!tokenRes?.ok) return NextResponse.redirect(`${origin}/login?error=token_exchange`);
const tokens = await tokenRes.json();
const res = NextResponse.redirect(`${origin}/dashboard`);
res.cookies.set('oidc_token', tokens.access_token, {
httpOnly: true, sameSite: 'lax', path: '/',
maxAge: tokens.expires_in ?? 3600,
secure: process.env.NODE_ENV === 'production',
});
// non-httpOnly copy for client-side API bearer usage
res.cookies.set('oidc_token_pub', tokens.access_token, {
httpOnly: false, sameSite: 'lax', path: '/',
maxAge: tokens.expires_in ?? 3600,
secure: process.env.NODE_ENV === 'production',
});
return res;
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
const LOGIN_USER = process.env.LOGIN_USER ?? 'admin';
const LOGIN_PASS = process.env.LOGIN_PASS ?? 'admin';
function sign(value: string) {
return crypto.createHmac('sha256', SECRET).update(value).digest('hex');
}
export async function POST(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const { username, password } = body as Record<string, string>;
if (!username || !password || username !== LOGIN_USER || password !== LOGIN_PASS) {
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
}
const payload = `${username}:${Date.now()}`;
const token = `${payload}.${sign(payload)}`;
const res = NextResponse.json({ ok: true });
res.cookies.set('session', token, {
httpOnly: true, sameSite: 'lax', path: '/',
maxAge: 60 * 60 * 24 * 7,
secure: process.env.NODE_ENV === 'production',
});
return res;
}

View File

@@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.delete('session');
res.cookies.delete('oidc_token');
return res;
}

View File

@@ -0,0 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
const token = req.cookies.get('oidc_token')?.value ?? null;
return NextResponse.json({ token });
}

View File

@@ -6,25 +6,25 @@ import DiffViewer from '@/components/cv/DiffViewer';
import Link from 'next/link'; import Link from 'next/link';
import { import {
createBranch, createSubmission, Document, downloadVersionUrl, createBranch, createSubmission, Document, downloadVersionUrl,
fetchDocuments, publishVersion, uploadDocument, Version, fetchDocuments, fetchSubmissions, publishVersion, requestAiSuggestions,
Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version,
} from '@/libs/api'; } from '@/libs/api';
// ─── tiny helpers ──────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────────
function fmt(iso: string) { function fmt(iso: string) {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} }
function Section({ title, children }: { title: string; children: React.ReactNode }) { function statusBadge(status: string) {
return ( const cls = ({
<div> draft: 'badge-draft', tailoring: 'badge-submitted', pending_review: 'badge-interviewing',
<div className="label" style={{ padding: '0 0 8px' }}>{title}</div> published: 'badge-public', archived: 'badge-closed',
{children} } as Record<string, string>)[status] ?? 'badge-draft';
</div> return <span className={`badge ${cls}`}>{status.replace('_', ' ')}</span>;
);
} }
// ── modals ─────────────────────────────────────────────────────────────────── // ── modals ───────────────────────────────────────────────────────────────────
function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: Document) => void }) { function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: Document) => void }) {
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@@ -46,17 +46,18 @@ function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: D
<div className="modal" onClick={e => e.stopPropagation()}> <div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">Upload CV</div> <div className="modal-title">Upload CV</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <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="Title" value={title} onChange={e => setTitle(e.target.value)} autoFocus />
<input placeholder="Description (optional)" value={desc} onChange={e => setDesc(e.target.value)} /> <input placeholder="Description (optional)" value={desc} onChange={e => setDesc(e.target.value)} />
<div <div onClick={() => ref.current?.click()} style={{
onClick={() => ref.current?.click()} border: '1px dashed var(--border-strong)', borderRadius: 5, padding: '16px 0',
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)' }} textAlign: 'center', cursor: 'pointer', fontSize: 13,
> color: file ? 'var(--text)' : 'var(--text-muted)',
{file ? file.name : 'Click to select .docx file'} }}>
{file ? file.name : 'Click to select .docx'}
</div> </div>
<input ref={ref} type="file" accept=".docx" style={{ display: 'none' }} onChange={e => setFile(e.target.files?.[0] ?? null)} /> <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>} {error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}> <div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button> <button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}> <button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Uploading…' : 'Upload'} {loading ? 'Uploading…' : 'Upload'}
@@ -68,31 +69,51 @@ function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: D
); );
} }
function BranchModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (v: Version) => void }) { function BranchModal({
version, initialPatches, onClose, onDone,
}: {
version: Version;
initialPatches?: Array<{ target_path: string; operation: string; old_value: string; new_value: string }>;
onClose: () => void;
onDone: (v: Version) => void;
}) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [label, setLabel] = useState(''); const [label, setLabel] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const patches = initialPatches ?? [];
const submit = async () => { const submit = async () => {
if (!name.trim()) { setError('Branch name required.'); return; } if (!name.trim()) { setError('Branch name required.'); return; }
setLoading(true); setError(''); setLoading(true); setError('');
try { onDone(await createBranch(version.id, name.trim(), label.trim() || null)); } try { onDone(await createBranch(version.id, name.trim(), label.trim() || null, patches as Record<string, unknown>[])); }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
}; };
return ( return (
<div className="overlay" onClick={onClose}> <div className="overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 460 }}>
<div className="modal-title">New branch from <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 400 }}>{version.branch_name}</span></div> <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 }}> <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="Branch name (e.g. ml-engineer)" value={name} onChange={e => setName(e.target.value)} autoFocus />
<input placeholder="Label (optional)" value={label} onChange={e => setLabel(e.target.value)} /> <input placeholder="Label (optional)" value={label} onChange={e => setLabel(e.target.value)} />
{patches.length > 0 && (
<div style={{ padding: '8px 10px', background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 4, fontSize: 12 }}>
<div className="label" style={{ marginBottom: 6 }}>Staged edits ({patches.length})</div>
{patches.map((p, i) => (
<div key={i} style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-muted)', marginBottom: 2 }}>
± {p.target_path}
</div>
))}
</div>
)}
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>} {error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}> <div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button> <button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}> <button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Creating…' : 'Create'} {loading ? 'Creating…' : 'Create branch'}
</button> </button>
</div> </div>
</div> </div>
@@ -101,30 +122,38 @@ function BranchModal({ version, onClose, onDone }: { version: Version; onClose:
); );
} }
function SubmissionModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: () => void }) { function SubmissionModal({ version, onClose, onDone }: { version: Version; onClose: () => void; onDone: (s: Submission) => void }) {
const [company, setCompany] = useState(''); const [company, setCompany] = useState('');
const [role, setRole] = useState(''); const [role, setRole] = useState('');
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [jd, setJd] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const submit = async () => { const submit = async () => {
if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; } if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; }
setLoading(true); setError(''); setLoading(true); setError('');
try { await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null); onDone(); } try { onDone(await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null, jd.trim() || null)); }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); } catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
}; };
return ( return (
<div className="overlay" onClick={onClose}> <div className="overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}> <div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 480 }}>
<div className="modal-title">New submission from <span style={{ fontFamily: 'var(--font-mono)', fontWeight: 400 }}>{version.branch_name}</span></div> <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 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input placeholder="Company name" value={company} onChange={e => setCompany(e.target.value)} /> <div style={{ display: 'flex', gap: 8 }}>
<input placeholder="Company" value={company} onChange={e => setCompany(e.target.value)} autoFocus />
<input placeholder="Role title" value={role} onChange={e => setRole(e.target.value)} /> <input placeholder="Role title" value={role} onChange={e => setRole(e.target.value)} />
</div>
<input placeholder="Job URL (optional)" value={url} onChange={e => setUrl(e.target.value)} /> <input placeholder="Job URL (optional)" value={url} onChange={e => setUrl(e.target.value)} />
<textarea
placeholder="Paste job description (used for AI tailoring)"
value={jd} onChange={e => setJd(e.target.value)}
style={{ height: 100, resize: 'vertical' }}
/>
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>} {error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}> <div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button> <button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}> <button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Saving…' : 'Create'} {loading ? 'Saving…' : 'Create'}
@@ -154,12 +183,12 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
<div className="modal" onClick={e => e.stopPropagation()}> <div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-title">Publish version</div> <div className="modal-title">Publish version</div>
<p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}> <p style={{ fontSize: 13, color: 'var(--text-muted)', marginBottom: 12 }}>
Creates an immutable public artifact. The link stays stable even if you edit further. Freezes an immutable public artifact. Existing shares remain stable.
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<input placeholder="Custom slug (optional)" value={slug} onChange={e => setSlug(e.target.value)} /> <input placeholder="Custom slug (optional)" value={slug} onChange={e => setSlug(e.target.value)} autoFocus />
{error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>} {error && <div style={{ fontSize: 12, color: '#dc2626' }}>{error}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}> <div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button> <button className="btn btn-ghost" style={{ flex: 1 }} onClick={onClose}>Cancel</button>
<button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}> <button className="btn btn-primary" style={{ flex: 1 }} onClick={submit} disabled={loading}>
{loading ? 'Publishing…' : 'Publish'} {loading ? 'Publishing…' : 'Publish'}
@@ -171,9 +200,275 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
); );
} }
// ─── main dashboard ─────────────────────────────────────────────────────────── // ── content tab with inline editing ──────────────────────────────────────────
type PendingEdit = { old_value: string; new_value: string };
function ContentTab({
blocks,
pendingEdits,
onEdit,
}: {
blocks: StructuredBlock[];
pendingEdits: Map<string, PendingEdit>;
onEdit: (path: string, oldVal: string, newVal: string) => void;
}) {
const [editing, setEditing] = useState<string | null>(null);
const [draft, setDraft] = useState('');
const startEdit = (b: StructuredBlock) => {
setEditing(b.path);
setDraft(pendingEdits.get(b.path)?.new_value ?? b.text);
};
const saveEdit = (b: StructuredBlock) => {
if (draft.trim() && draft !== b.text) {
onEdit(b.path, b.text, draft.trim());
}
setEditing(null);
};
const cancelEdit = () => setEditing(null);
if (!blocks.length) return (
<div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>No content blocks parsed.</div>
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{blocks.map((b) => {
const pending = pendingEdits.get(b.path);
const isEditing = editing === b.path;
return (
<div key={b.path} style={{
borderBottom: '1px solid var(--border)',
padding: '6px 0',
background: pending ? '#fffbeb' : 'transparent',
}}>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-faint)',
flexShrink: 0, width: 100, paddingTop: 3,
}}>
{b.path}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
{isEditing ? (
<>
<textarea
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && e.metaKey) saveEdit(b); if (e.key === 'Escape') cancelEdit(); }}
style={{ width: '100%', minHeight: 60, fontSize: 13, resize: 'vertical', marginBottom: 6 }}
autoFocus
/>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-primary" style={{ fontSize: 11, padding: '3px 8px' }} onClick={() => saveEdit(b)}>
Stage edit
</button>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '3px 8px' }} onClick={cancelEdit}>
Cancel
</button>
</div>
</>
) : (
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<span style={{
fontSize: 13, color: pending ? '#92400e' : 'var(--text)',
lineHeight: 1.5, flex: 1,
}}>
{pending ? pending.new_value : b.text}
</span>
<button
className="btn btn-ghost"
style={{ fontSize: 11, padding: '2px 7px', flexShrink: 0 }}
onClick={() => startEdit(b)}
>
Edit
</button>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
);
}
// ── submissions tab ───────────────────────────────────────────────────────────
function SubmissionsTab({
submissions, loading, versionId,
onNewSubmission, onRefresh,
}: {
submissions: Submission[];
loading: boolean;
versionId: string;
onNewSubmission: () => void;
onRefresh: () => void;
}) {
const [expanded, setExpanded] = useState<string | null>(null);
const [aiLoading, setAiLoading] = useState<string | null>(null);
const [aiJd, setAiJd] = useState<Record<string, string>>({});
const [suggestions, setSuggestions] = useState<Record<string, Suggestion[]>>({});
const loadAi = async (s: Submission) => {
const jd = aiJd[s.id] ?? s.job_description ?? '';
if (!jd.trim()) return;
setAiLoading(s.id);
try {
const res = await requestAiSuggestions(s.id, jd);
setSuggestions(prev => ({ ...prev, [s.id]: res }));
onRefresh();
} catch { /* ignore */ }
finally { setAiLoading(null); }
};
const toggleSuggestion = async (sub: Submission, sug: Suggestion, accepted: boolean) => {
try {
await updateSuggestion(sub.id, sug.id, accepted);
setSuggestions(prev => ({
...prev,
[sub.id]: (prev[sub.id] ?? sub.suggestions).map(s => s.id === sug.id ? { ...s, accepted } : s),
}));
} catch { /* ignore */ }
};
if (loading) return <div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>Loading</div>;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>{submissions.length} submission{submissions.length !== 1 ? 's' : ''}</span>
<button className="btn btn-ghost" style={{ fontSize: 12 }} onClick={onNewSubmission}>+ New submission</button>
</div>
{submissions.length === 0 && (
<div style={{ padding: '20px 0', color: 'var(--text-faint)', fontSize: 13 }}>
No submissions yet. Create one to track a job application and get AI tailoring suggestions.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{submissions.map(s => {
const isOpen = expanded === s.id;
const sugs = suggestions[s.id] ?? s.suggestions;
return (
<div key={s.id} style={{ border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
{/* header row */}
<div
onClick={() => setExpanded(isOpen ? null : s.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px',
cursor: 'pointer', background: isOpen ? 'var(--hover)' : 'transparent',
}}
>
<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor" style={{ color: 'var(--text-faint)', transform: isOpen ? 'rotate(90deg)' : 'none', transition: 'transform 0.12s', flexShrink: 0 }}>
<path d="M2 1l4 3-4 3V1z" />
</svg>
<span style={{ fontSize: 13, fontWeight: 500, flex: 1 }}>
{s.company_name} {s.role_title}
</span>
{statusBadge(s.status)}
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{fmt(s.created_at)}</span>
</div>
{/* expanded body */}
{isOpen && (
<div style={{ padding: '12px 14px', borderTop: '1px solid var(--border)', background: 'var(--surface)' }}>
{s.job_url && (
<a href={s.job_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: 'var(--text-muted)', display: 'block', marginBottom: 10 }}>
{s.job_url}
</a>
)}
{/* AI tailoring */}
<div style={{ marginBottom: 12 }}>
<div className="label" style={{ marginBottom: 6 }}>AI tailoring</div>
<textarea
placeholder="Paste or edit job description for AI suggestions…"
value={aiJd[s.id] ?? s.job_description ?? ''}
onChange={e => setAiJd(prev => ({ ...prev, [s.id]: e.target.value }))}
style={{ height: 80, resize: 'vertical', fontSize: 12, marginBottom: 6 }}
/>
<button
className="btn btn-ghost"
style={{ fontSize: 12 }}
disabled={aiLoading === s.id || !(aiJd[s.id] ?? s.job_description)}
onClick={() => loadAi(s)}
>
{aiLoading === s.id ? 'Generating…' : sugs.length > 0 ? 'Regenerate suggestions' : 'Get AI suggestions'}
</button>
</div>
{/* suggestions list */}
{sugs.length > 0 && (
<div>
<div className="label" style={{ marginBottom: 8 }}>Suggestions ({sugs.length})</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{sugs.map(sug => (
<div key={sug.id} style={{
borderLeft: `3px solid ${sug.accepted === true ? '#22c55e' : sug.accepted === false ? '#ef4444' : 'var(--border-strong)'}`,
paddingLeft: 10,
}}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 3 }}>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-muted)' }}>
± {sug.target_path}
</span>
{sug.metadata_json?.confidence !== undefined && (
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>
{Math.round((sug.metadata_json.confidence as number) * 100)}% conf
</span>
)}
</div>
{sug.proposed_text && (
<div style={{ fontSize: 12, color: 'var(--text)', marginBottom: 4, lineHeight: 1.4 }}>
{sug.proposed_text}
</div>
)}
{sug.rationale && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 6, fontStyle: 'italic' }}>
{sug.rationale}
</div>
)}
<div style={{ display: 'flex', gap: 6 }}>
<button
className="btn btn-ghost"
style={{ fontSize: 11, padding: '2px 8px', color: sug.accepted === true ? '#166534' : 'var(--text-muted)', borderColor: sug.accepted === true ? '#86efac' : 'var(--border)' }}
onClick={() => toggleSuggestion(s, sug, true)}
>
Accept
</button>
<button
className="btn btn-ghost"
style={{ fontSize: 11, padding: '2px 8px', color: sug.accepted === false ? '#991b1b' : 'var(--text-muted)', borderColor: sug.accepted === false ? '#fca5a5' : 'var(--border)' }}
onClick={() => toggleSuggestion(s, sug, false)}
>
Reject
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
// ── main dashboard ────────────────────────────────────────────────────────────
type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null; type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null;
type Tab = 'content' | 'patches' | 'submissions';
export default function Dashboard() { export default function Dashboard() {
const [docs, setDocs] = useState<Document[]>([]); const [docs, setDocs] = useState<Document[]>([]);
@@ -183,24 +478,46 @@ export default function Dashboard() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [modal, setModal] = useState<Modal>(null); const [modal, setModal] = useState<Modal>(null);
const [publishedUrl, setPublishedUrl] = useState<string | null>(null); const [publishedUrl, setPublishedUrl] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<Tab>('content');
const [submissions, setSubmissions] = useState<Submission[]>([]);
const [subsLoading, setSubsLoading] = useState(false);
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
useEffect(() => { useEffect(() => {
fetchDocuments() fetchDocuments()
.then(d => { setDocs(d); if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); } }) .then(d => {
setDocs(d);
if (d.length) { setSelectedDocId(d[0].id); setSelectedVersionId(d[0].root_version_id ?? null); }
})
.catch(e => setError(e.message)) .catch(e => setError(e.message))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
useEffect(() => {
setPendingEdits(new Map());
}, [selectedVersionId]);
useEffect(() => {
if (activeTab !== 'submissions' || !selectedVersionId) return;
setSubsLoading(true);
fetchSubmissions(selectedVersionId)
.then(setSubmissions)
.catch(() => { })
.finally(() => setSubsLoading(false));
}, [activeTab, selectedVersionId]);
const selectedDoc = docs.find(d => d.id === selectedDocId) ?? null; const selectedDoc = docs.find(d => d.id === selectedDocId) ?? null;
const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null; const selectedVersion = selectedDoc?.versions.find(v => v.id === selectedVersionId) ?? null;
const refreshDocs = async () => { const refreshDocs = async () => {
try { const fresh = await fetchDocuments().catch(() => docs);
const fresh = await fetchDocuments();
setDocs(fresh); setDocs(fresh);
const doc = fresh.find(d => d.id === selectedDocId) ?? fresh[0] ?? null; return fresh;
if (doc) { setSelectedDocId(doc.id); } };
} catch { /* silent */ }
const refreshSubs = () => {
if (!selectedVersionId) return;
fetchSubmissions(selectedVersionId).then(setSubmissions).catch(() => { });
}; };
const onUploadDone = (doc: Document) => { const onUploadDone = (doc: Document) => {
@@ -210,26 +527,62 @@ export default function Dashboard() {
setModal(null); setModal(null);
}; };
const onBranchDone = (v: Version) => { const onBranchDone = async (v: Version) => {
refreshDocs().then(() => setSelectedVersionId(v.id)); const fresh = await refreshDocs();
const doc = fresh.find(d => d.id === selectedDocId);
if (doc?.versions.find(x => x.id === v.id)) setSelectedVersionId(v.id);
setPendingEdits(new Map());
setModal(null); setModal(null);
}; };
const onSubmissionDone = (s: Submission) => {
setSubmissions(prev => [s, ...prev]);
setModal(null);
setActiveTab('submissions');
};
const stageEdit = (path: string, old_value: string, new_value: string) => {
setPendingEdits(prev => new Map(prev).set(path, { old_value, new_value }));
};
const discardEdits = () => setPendingEdits(new Map());
const pendingCount = pendingEdits.size;
const stagedPatches = [...pendingEdits.entries()].map(([path, { old_value, new_value }]) => ({
target_path: path, operation: 'replace_text', old_value, new_value,
}));
const logout = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
window.location.href = '/login';
};
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden', background: 'var(--bg)' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden', background: 'var(--bg)' }}>
{/* top bar */} {/* top bar */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px', height: 44, borderBottom: '1px solid var(--border)', flexShrink: 0 }}> <div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 16px', height: 44, borderBottom: '1px solid var(--border)', flexShrink: 0,
}}>
<Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}> <Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}>
Resume Branches Resume Branches
</Link> </Link>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: 12 }} onClick={() => setModal('upload')}> <button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: 12 }} onClick={() => setModal('upload')}>
+ Upload CV + Upload CV
</button> </button>
<button className="btn btn-ghost" style={{ padding: '4px 10px', fontSize: 12 }} onClick={logout}>
Sign out
</button>
</div>
</div> </div>
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}> <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* left panel */} {/* left panel */}
<div style={{ width: 240, flexShrink: 0, borderRight: '1px solid var(--border)', background: 'var(--surface)', overflow: 'auto', display: 'flex', flexDirection: 'column' }}> <div style={{
width: 240, flexShrink: 0, borderRight: '1px solid var(--border)',
background: 'var(--surface)', overflow: 'auto', display: 'flex', flexDirection: 'column',
}}>
{loading && <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>Loading</div>} {loading && <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>Loading</div>}
{error && <div style={{ padding: 16, fontSize: 13, color: '#dc2626' }}>{error}</div>} {error && <div style={{ padding: 16, fontSize: 13, color: '#dc2626' }}>{error}</div>}
@@ -244,30 +597,39 @@ export default function Dashboard() {
{docs.length > 0 && ( {docs.length > 0 && (
<> <>
{/* document selector */}
<div style={{ padding: '10px 12px 6px' }}> <div style={{ padding: '10px 12px 6px' }}>
<div className="label" style={{ marginBottom: 6 }}>Documents</div> <div className="label" style={{ marginBottom: 6 }}>Documents</div>
{docs.map(d => ( {docs.map(d => (
<div <div
key={d.id} key={d.id}
onClick={() => { setSelectedDocId(d.id); setSelectedVersionId(d.root_version_id ?? null); }} onClick={() => {
style={{ padding: '5px 8px', borderRadius: 4, cursor: 'pointer', fontSize: 13, fontWeight: d.id === selectedDocId ? 600 : 400, background: d.id === selectedDocId ? 'var(--selected-bg)' : 'transparent' }} setSelectedDocId(d.id);
setSelectedVersionId(d.root_version_id ?? null);
setActiveTab('content');
}}
style={{
padding: '5px 8px', borderRadius: 4, cursor: 'pointer',
fontSize: 13, fontWeight: d.id === selectedDocId ? 600 : 400,
background: d.id === selectedDocId ? 'var(--selected-bg)' : 'transparent',
}}
> >
{d.title} <div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
{d.versions.length} version{d.versions.length !== 1 ? 's' : ''}
</div>
</div> </div>
))} ))}
</div> </div>
<hr className="divider" style={{ margin: '6px 0' }} /> <hr className="divider" style={{ margin: '4px 0' }} />
{/* version tree */}
{selectedDoc && ( {selectedDoc && (
<div style={{ padding: '6px 0' }}> <div style={{ padding: '6px 0' }}>
<div className="label" style={{ padding: '0 12px 6px' }}>Versions</div> <div className="label" style={{ padding: '0 12px 6px' }}>Branches</div>
<CVTree <CVTree
versions={selectedDoc.versions} versions={selectedDoc.versions}
selectedVersionId={selectedVersionId} selectedVersionId={selectedVersionId}
onSelect={setSelectedVersionId} onSelect={id => { setSelectedVersionId(id); setActiveTab('content'); }}
/> />
</div> </div>
)} )}
@@ -275,31 +637,34 @@ export default function Dashboard() {
)} )}
</div> </div>
{/* main content */} {/* main panel */}
<div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}> <div style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
{!selectedVersion && !loading && ( {!selectedVersion && !loading && (
<div style={{ paddingTop: 60, textAlign: 'center', color: 'var(--text-faint)', fontSize: 13 }}> <div style={{ paddingTop: 60, textAlign: 'center', color: 'var(--text-faint)', fontSize: 13 }}>
Select a version to view details. Select a branch to view details.
</div> </div>
)} )}
{selectedVersion && ( {selectedVersion && (
<div style={{ maxWidth: 680 }}> <>
{/* version header */} {/* version header */}
<div style={{ marginBottom: 20 }}> <div style={{ padding: '16px 24px 0', flexShrink: 0 }}>
<h2 style={{ fontSize: 18, fontWeight: 600, marginBottom: 4 }}> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12 }}>
<div>
<h2 style={{ fontSize: 17, fontWeight: 600, marginBottom: 3 }}>
{selectedVersion.version_label || selectedVersion.branch_name} {selectedVersion.version_label || selectedVersion.branch_name}
</h2> </h2>
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-muted)' }}> <div style={{ display: 'flex', gap: 14, fontSize: 12, color: 'var(--text-muted)', flexWrap: 'wrap' }}>
{selectedVersion.parent_version_id && ( {selectedVersion.parent_version_id ? (
<span> <span>
branched from{' '} branched from{' '}
<span style={{ fontFamily: 'var(--font-mono)' }}> <span style={{ fontFamily: 'var(--font-mono)' }}>
{selectedDoc?.versions.find(v => v.id === selectedVersion.parent_version_id)?.branch_name ?? '…'} {selectedDoc?.versions.find(v => v.id === selectedVersion.parent_version_id)?.branch_name ?? '…'}
</span> </span>
</span> </span>
) : (
<span className="badge badge-draft" style={{ fontFamily: 'var(--font-mono)' }}>root</span>
)} )}
{!selectedVersion.parent_version_id && <span style={{ fontFamily: 'var(--font-mono)' }}>root</span>}
<span>{fmt(selectedVersion.created_at)}</span> <span>{fmt(selectedVersion.created_at)}</span>
{selectedVersion.patches.length > 0 && ( {selectedVersion.patches.length > 0 && (
<span>{selectedVersion.patches.length} patch{selectedVersion.patches.length !== 1 ? 'es' : ''}</span> <span>{selectedVersion.patches.length} patch{selectedVersion.patches.length !== 1 ? 'es' : ''}</span>
@@ -307,53 +672,95 @@ export default function Dashboard() {
</div> </div>
</div> </div>
{/* action bar */} {/* action buttons */}
<div style={{ display: 'flex', gap: 6, marginBottom: 24, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button className="btn btn-ghost" onClick={() => setModal('branch')}>New branch</button> <button className="btn btn-ghost" onClick={() => setModal('branch')}>Branch</button>
<button className="btn btn-ghost" onClick={() => setModal('submission')}>New submission</button> <button className="btn btn-ghost" onClick={() => { setModal('submission'); }}>Submit</button>
<button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button> <button className="btn btn-ghost" onClick={() => setModal('publish')}>Publish</button>
{selectedVersion.artifact_docx_key && selectedDoc && ( {selectedVersion.artifact_docx_key && selectedDoc && (
<a <a href={downloadVersionUrl(selectedDoc.id, selectedVersion.id)} download className="btn btn-ghost">
href={downloadVersionUrl(selectedDoc.id, selectedVersion.id)}
download
className="btn btn-ghost"
>
DOCX DOCX
</a> </a>
)} )}
</div> </div>
</div>
{publishedUrl && ( {publishedUrl && (
<div style={{ padding: '10px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 5, marginBottom: 20, fontSize: 13 }}> <div style={{
Published:{' '} padding: '8px 12px', background: '#f0fdf4', border: '1px solid #bbf7d0',
<a href={publishedUrl} target="_blank" rel="noreferrer" style={{ color: '#166534', wordBreak: 'break-all' }}>{publishedUrl}</a> borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', gap: 8, alignItems: 'center',
<button onClick={() => setPublishedUrl(null)} style={{ float: 'right', background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 14 }}>×</button> }}>
<span style={{ color: '#166534' }}>Published:</span>
<a href={publishedUrl} target="_blank" rel="noreferrer" style={{ color: '#166534', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{publishedUrl}</a>
<button onClick={() => setPublishedUrl(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#166534', fontSize: 16, lineHeight: 1 }}>×</button>
</div> </div>
)} )}
<hr className="divider" style={{ marginBottom: 24 }} /> {/* staged edits bar */}
{pendingCount > 0 && (
{/* structured blocks */} <div style={{
{(selectedVersion.structured_blocks?.length ?? 0) > 0 && ( padding: '8px 12px', background: '#fffbeb', border: '1px solid #fde68a',
<Section title={`Content (${selectedVersion.structured_blocks!.length} blocks)`}> borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', gap: 10, alignItems: 'center',
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginBottom: 24 }}> }}>
{selectedVersion.structured_blocks!.map((b, i) => ( <span style={{ color: '#92400e', flex: 1 }}>
<div key={i} style={{ display: 'flex', gap: 12, padding: '4px 0', borderBottom: '1px solid var(--border)' }}> {pendingCount} staged edit{pendingCount !== 1 ? 's' : ''}
<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> </span>
<button
className="btn btn-primary"
style={{ fontSize: 12, padding: '3px 10px', background: '#92400e', borderColor: '#92400e' }}
onClick={() => setModal('branch')}
>
Save as branch
</button>
<button className="btn btn-ghost" style={{ fontSize: 12, padding: '3px 8px' }} onClick={discardEdits}>
Discard
</button>
</div> </div>
)}
{/* tabs */}
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)' }}>
{(['content', 'patches', 'submissions'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setActiveTab(t)}
style={{
padding: '6px 14px', fontSize: 13, background: 'none', border: 'none',
cursor: 'pointer', color: activeTab === t ? 'var(--text)' : 'var(--text-muted)',
borderBottom: activeTab === t ? '2px solid var(--text)' : '2px solid transparent',
fontWeight: activeTab === t ? 500 : 400,
marginBottom: -1, transition: 'color 0.1s',
}}
>
{t === 'patches' ? `Patches (${selectedVersion.patches.length})` : t.charAt(0).toUpperCase() + t.slice(1)}
</button>
))} ))}
</div> </div>
</Section>
)}
{/* patches */}
<Section title={`Patches (${selectedVersion.patches.length} changes from parent)`}>
<DiffViewer patches={selectedVersion.patches} />
</Section>
</div> </div>
{/* tab content */}
<div style={{ padding: '16px 24px', flex: 1, overflow: 'auto' }}>
{activeTab === 'content' && (
<ContentTab
blocks={selectedVersion.structured_blocks ?? []}
pendingEdits={pendingEdits}
onEdit={stageEdit}
/>
)}
{activeTab === 'patches' && (
<DiffViewer patches={selectedVersion.patches} />
)}
{activeTab === 'submissions' && (
<SubmissionsTab
submissions={submissions}
loading={subsLoading}
versionId={selectedVersionId!}
onNewSubmission={() => setModal('submission')}
onRefresh={refreshSubs}
/>
)}
</div>
</>
)} )}
</div> </div>
</div> </div>
@@ -363,10 +770,15 @@ export default function Dashboard() {
<UploadModal onClose={() => setModal(null)} onDone={onUploadDone} /> <UploadModal onClose={() => setModal(null)} onDone={onUploadDone} />
)} )}
{modal === 'branch' && selectedVersion && ( {modal === 'branch' && selectedVersion && (
<BranchModal version={selectedVersion} onClose={() => setModal(null)} onDone={onBranchDone} /> <BranchModal
version={selectedVersion}
initialPatches={stagedPatches}
onClose={() => setModal(null)}
onDone={onBranchDone}
/>
)} )}
{modal === 'submission' && selectedVersion && ( {modal === 'submission' && selectedVersion && (
<SubmissionModal version={selectedVersion} onClose={() => setModal(null)} onDone={() => { setModal(null); }} /> <SubmissionModal version={selectedVersion} onClose={() => setModal(null)} onDone={onSubmissionDone} />
)} )}
{modal === 'publish' && selectedVersion && ( {modal === 'publish' && selectedVersion && (
<PublishModal <PublishModal

View File

@@ -1,46 +1,3 @@
'use server' // Auth is handled via /api/auth/* route handlers.
// This file retained for compatibility; Supabase sign-in is no longer used.
import { revalidatePath } from 'next/cache' export {};
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export async function login(formData: FormData) {
const supabase = await createClient()
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signInWithPassword(data)
if (error) {
redirect('/error')
}
revalidatePath('/', 'layout')
redirect('/')
}
export async function signup(formData: FormData) {
const supabase = await createClient()
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email: formData.get('email') as string,
password: formData.get('password') as string,
}
const { error } = await supabase.auth.signUp(data)
if (error) {
redirect('/error')
}
revalidatePath('/', 'layout')
redirect('/')
}

View File

@@ -1,14 +1,134 @@
import { login, signup } from './actions' 'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
function authentikUrl() {
const issuer = process.env.NEXT_PUBLIC_AUTHENTIK_ISSUER;
const clientId = process.env.NEXT_PUBLIC_AUTHENTIK_CLIENT_ID;
const base = process.env.NEXT_PUBLIC_BASE_URL ?? (typeof window !== 'undefined' ? window.location.origin : '');
if (!issuer || !clientId) return null;
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
redirect_uri: `${base}/api/auth/callback`,
scope: 'openid email profile',
});
return `${issuer}/application/o/authorize/?${params}`;
}
export default function LoginPage() { export default function LoginPage() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const oidcUrl = typeof window !== 'undefined' ? authentikUrl() : null;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) return;
setLoading(true); setError('');
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (res.ok) {
router.push('/dashboard');
} else {
const j = await res.json().catch(() => ({}));
setError(j.error ?? 'Login failed');
setLoading(false);
}
};
return ( return (
<form> <div style={{
<label htmlFor="email">Email:</label> minHeight: '100vh', display: 'flex', alignItems: 'center',
<input id="email" name="email" type="email" required /> justifyContent: 'center', background: 'var(--bg)',
<label htmlFor="password">Password:</label> }}>
<input id="password" name="password" type="password" required /> <div style={{ width: '100%', maxWidth: 360, padding: '0 20px' }}>
<button formAction={login}>Log in</button> {/* brand */}
<button formAction={signup}>Sign up</button> <div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-0.01em', marginBottom: 6 }}>
Resume Branches
</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
Sign in to your account
</div>
</div>
{/* form card */}
<div style={{
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, padding: '24px 24px 20px',
}}>
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 5, color: 'var(--text-muted)' }}>
Username
</label>
<input
type="text" autoComplete="username" autoFocus
value={username} onChange={e => setUsername(e.target.value)}
placeholder="admin"
/>
</div>
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 500, marginBottom: 5, color: 'var(--text-muted)' }}>
Password
</label>
<input
type="password" autoComplete="current-password"
value={password} onChange={e => setPassword(e.target.value)}
placeholder="••••••••"
/>
</div>
{error && (
<div style={{ fontSize: 12, color: '#dc2626', padding: '6px 10px', background: '#fef2f2', borderRadius: 4 }}>
{error}
</div>
)}
<button
type="submit" className="btn btn-primary"
style={{ width: '100%', justifyContent: 'center', marginTop: 4 }}
disabled={loading || !username || !password}
>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form> </form>
)
{oidcUrl && (
<>
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
margin: '16px 0', color: 'var(--text-faint)', fontSize: 12,
}}>
<hr className="divider" style={{ flex: 1 }} />
<span>or</span>
<hr className="divider" style={{ flex: 1 }} />
</div>
<a href={oidcUrl} style={{ textDecoration: 'none', display: 'block' }}>
<button className="btn btn-ghost" style={{ width: '100%', justifyContent: 'center' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ flexShrink: 0 }}>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
Sign in with Authentik
</button>
</a>
</>
)}
</div>
<p style={{ textAlign: 'center', fontSize: 12, color: 'var(--text-faint)', marginTop: 20 }}>
Resume Branches private CV control plane
</p>
</div>
</div>
);
} }

View File

@@ -15,46 +15,107 @@ function buildTree(versions: Version[]): TreeNode | null {
return root; return root;
} }
function Node({ node, depth, selectedId, onSelect }: { const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2'];
node: TreeNode; depth: number; selectedId: string | null; onSelect: (id: string) => void;
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 [open, setOpen] = useState(true);
const v = node.version; const v = node.version;
const isRoot = !v.parent_version_id;
const isSelected = v.id === selectedId; const isSelected = v.id === selectedId;
const hasChildren = node.children.length > 0; const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
return ( 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 <div
onClick={() => onSelect(v.id)} onClick={() => onSelect(v.id)}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 4, display: 'flex', alignItems: 'center', gap: 6,
paddingLeft: 12 + depth * 16, paddingRight: 8, paddingLeft: depth > 0 ? 18 : 8, paddingRight: 8,
height: 30, cursor: 'pointer', height: 30, cursor: 'pointer', borderRadius: 4, userSelect: 'none',
background: isSelected ? 'var(--selected-bg)' : 'transparent', background: isSelected ? 'var(--selected-bg)' : 'transparent',
borderLeft: isSelected ? '2px solid var(--selected-border)' : '2px solid transparent', borderLeft: isSelected && depth === 0 ? '2px solid var(--selected-border)' : '2px solid transparent',
transition: 'background 0.1s',
}} }}
onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'var(--hover)'; }} onMouseEnter={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'var(--hover)'; }}
onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'transparent'; }} onMouseLeave={e => { if (!isSelected) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
> >
{/* expand toggle */}
<button <button
onClick={e => { e.stopPropagation(); setOpen(o => !o); }} 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" /> <path d="M2 1l4 3-4 3V1z" />
</svg> </svg>
</button> </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} {v.version_label || v.branch_name}
</span> </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> </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>
)}
</div>
); );
} }

View File

@@ -1,5 +1,3 @@
// Empty base: all API calls go to /api/* which Next.js rewrites to the backend.
// The actual backend URL is set via API_BASE_URL env var in next.config.ts (server-side, runtime).
const API = ""; const API = "";
export type StructuredBlock = { export type StructuredBlock = {
@@ -42,6 +40,16 @@ export type Document = {
updated_at: string; updated_at: string;
}; };
export type Suggestion = {
id: string;
target_path: string;
operation: string;
proposed_text?: string | null;
rationale?: string | null;
accepted?: boolean | null;
metadata_json?: { keywords?: string[]; confidence?: number } | null;
};
export type Submission = { export type Submission = {
id: string; id: string;
version_id: string; version_id: string;
@@ -50,6 +58,7 @@ export type Submission = {
job_url?: string | null; job_url?: string | null;
job_description?: string | null; job_description?: string | null;
status: string; status: string;
suggestions: Suggestion[];
created_at: string; created_at: string;
}; };
@@ -64,10 +73,17 @@ export type PublicAsset = {
created_at: string; created_at: string;
}; };
// reads OIDC bearer token from client-readable cookie (set by /api/auth/callback)
function getAuthHeader(): Record<string, string> {
if (typeof document === 'undefined') return {};
const token = document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('oidc_token_pub='))?.split('=')[1];
return token ? { authorization: `Bearer ${decodeURIComponent(token)}` } : {};
}
async function req<T>(path: string, init?: RequestInit): Promise<T> { async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, { const res = await fetch(`${API}${path}`, {
...init, ...init,
headers: { accept: "application/json", ...init?.headers }, headers: { accept: 'application/json', ...getAuthHeader(), ...init?.headers },
}); });
if (!res.ok) { if (!res.ok) {
const detail = await res.text().catch(() => res.statusText); const detail = await res.text().catch(() => res.statusText);
@@ -77,17 +93,17 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
} }
export const fetchDocuments = (): Promise<Document[]> => export const fetchDocuments = (): Promise<Document[]> =>
req<{ items: Document[] }>("/api/v1/documents", { cache: "no-store" }).then(r => r.items); req<{ items: Document[] }>('/api/v1/documents', { cache: 'no-store' }).then(r => r.items);
export const fetchDocument = (id: string): Promise<Document> => export const fetchDocument = (id: string): Promise<Document> =>
req<Document>(`/api/v1/documents/${id}`, { cache: "no-store" }); req<Document>(`/api/v1/documents/${id}`, { cache: 'no-store' });
export async function uploadDocument(title: string, description: string | null, file: File): Promise<Document> { export async function uploadDocument(title: string, description: string | null, file: File): Promise<Document> {
const form = new FormData(); const form = new FormData();
form.append("title", title); form.append('title', title);
if (description) form.append("description", description); if (description) form.append('description', description);
form.append("file", file); form.append('file', file);
return req<Document>("/api/v1/documents", { method: "POST", body: form }); return req<Document>('/api/v1/documents', { method: 'POST', body: form });
} }
export const downloadVersionUrl = (documentId: string, versionId: string): string => export const downloadVersionUrl = (documentId: string, versionId: string): string =>
@@ -99,9 +115,9 @@ export async function createBranch(
versionLabel?: string | null, versionLabel?: string | null,
patches: Record<string, unknown>[] = [], patches: Record<string, unknown>[] = [],
): Promise<Version> { ): Promise<Version> {
return req<Version>("/api/v1/versions/branches", { return req<Version>('/api/v1/versions/branches', {
method: "POST", method: 'POST',
headers: { "content-type": "application/json" }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ parent_version_id: parentVersionId, branch_name: branchName, version_label: versionLabel ?? null, patches }), body: JSON.stringify({ parent_version_id: parentVersionId, branch_name: branchName, version_label: versionLabel ?? null, patches }),
}); });
} }
@@ -113,22 +129,51 @@ export async function createSubmission(
jobUrl?: string | null, jobUrl?: string | null,
jobDescription?: string | null, jobDescription?: string | null,
): Promise<Submission> { ): Promise<Submission> {
return req<Submission>("/api/v1/submissions", { return req<Submission>('/api/v1/submissions', {
method: "POST", method: 'POST',
headers: { "content-type": "application/json" }, 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 }), body: JSON.stringify({ version_id: versionId, company_name: companyName, role_title: roleTitle, job_url: jobUrl ?? null, job_description: jobDescription ?? null }),
}); });
} }
export const fetchSubmissions = (versionId: string): Promise<Submission[]> =>
req<Submission[]>(`/api/v1/submissions?version_id=${versionId}`);
export const fetchSubmission = (id: string): Promise<Submission> =>
req<Submission>(`/api/v1/submissions/${id}`);
export async function requestAiSuggestions(
submissionId: string,
jobDescription: string,
focusKeywords: string[] = [],
): Promise<Suggestion[]> {
return req<Suggestion[]>(`/api/v1/submissions/${submissionId}/ai`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ job_description: jobDescription, focus_keywords: focusKeywords }),
});
}
export async function updateSuggestion(
submissionId: string,
suggestionId: string,
accepted: boolean,
): Promise<Suggestion> {
return req<Suggestion>(`/api/v1/submissions/${submissionId}/suggestions/${suggestionId}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ accepted }),
});
}
export async function publishVersion( export async function publishVersion(
versionId?: string | null, versionId?: string | null,
submissionId?: string | null, submissionId?: string | null,
slug?: string | null, slug?: string | null,
): Promise<PublicAsset> { ): Promise<PublicAsset> {
return req<PublicAsset>("/api/v1/public/publish", { return req<PublicAsset>('/api/v1/public/publish', {
method: "POST", method: 'POST',
headers: { "content-type": "application/json" }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }), body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }),
}); });
} }

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';
const SECRET = process.env.SESSION_SECRET ?? 'dev-secret-change-in-production';
function verifySession(token: string): boolean {
const lastDot = token.lastIndexOf('.');
if (lastDot === -1) return false;
const payload = token.slice(0, lastDot);
const sig = token.slice(lastDot + 1);
const expected = crypto.createHmac('sha256', SECRET).update(payload).digest('hex');
return sig === expected;
}
export function middleware(req: NextRequest) {
if (!req.nextUrl.pathname.startsWith('/dashboard')) return NextResponse.next();
const session = req.cookies.get('session')?.value;
const oidc = req.cookies.get('oidc_token')?.value;
if ((session && verifySession(session)) || oidc) return NextResponse.next();
return NextResponse.redirect(new URL('/login', req.url));
}
export const config = { matcher: ['/dashboard/:path*'] };