feat: live PDF preview, upload-to-branch diff, and copy markdown per branch

- Backend: authenticated GET preview endpoint generates PDF on-demand from any
  version without requiring a public asset publish
- Backend: POST upload endpoint on a version accepts a .docx, parses it to
  structured blocks, diffs against current blocks, and records patches
- Frontend: new Preview tab shows live PDF rendered from the authenticated
  endpoint (blob URL via fetch with auth header)
- Frontend: Upload DOCX (arrow-up) button in action bar sends the file to the
  branch, backend computes diff automatically
- Frontend: Copy Markdown button (clipboard icon) appears on branch hover in
  CVTree; copies block path/type/text as structured markdown to clipboard

https://claude.ai/code/session_01BTNfDfgFvcnehkve6r66nk
This commit is contained in:
Claude
2026-05-02 21:31:49 +00:00
parent a21f14ea87
commit 97ee914b1b
6 changed files with 316 additions and 10 deletions

View File

@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
from app.schemas import DocumentListResponse, DocumentResponse
from app.schemas import DocumentListResponse, DocumentResponse, VersionResponse
from app.services.documents import (
create_document,
delete_document,
@@ -14,8 +14,9 @@ from app.services.documents import (
list_documents,
)
from app.services.storage import storage_client
from app.services.versions import upload_docx_to_version
from dlib.auth import AuthenticatedUser
from dlib.cv import generate_patched_docx
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
router = APIRouter(prefix="/documents", tags=["documents"])
@@ -66,6 +67,50 @@ async def download_version_docx(
)
@router.get("/{document_id}/versions/{version_id}/preview")
async def preview_version_pdf(
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")
original = storage_client.download_bytes(key=version.artifact_docx_key)
patched = generate_patched_docx(original, version.structured_blocks or [])
pdf = docx_bytes_to_pdf(patched)
slug = f"{document.title.replace(' ', '-')}-{version.branch_name}"
return Response(
content=pdf,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{slug}.pdf"'},
)
@router.post("/{document_id}/versions/{version_id}/upload", response_model=VersionResponse)
async def upload_docx_to_branch(
document_id: str,
version_id: str,
file: UploadFile = File(...),
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:
raise HTTPException(status_code=404, detail="Version not found")
updated = await upload_docx_to_version(session, owner_id=user.sub, version_id=version_id, upload=file)
if not updated:
raise HTTPException(status_code=404, detail="Version not found")
return VersionResponse.model_validate(updated)
@router.post("", response_model=DocumentResponse)
async def upload_document(
title: str = Form(...),

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from fastapi import UploadFile
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -8,11 +9,34 @@ from dlib.cv import (
StructuredBlock,
StructuredDocument,
PatchPayload,
PatchOperation,
apply_patchset,
validate_patchset,
parse_docx_bytes,
)
from app.models import CvDocument, CvPatch, CvVersion, PublicAsset
from app.services.storage import persist_upload
def _diff_blocks(old: list[dict], new: list[dict]) -> list[PatchPayload]:
old_map = {b["path"]: b for b in old}
new_map = {b["path"]: b for b in new}
patches: list[PatchPayload] = []
for path, nb in new_map.items():
ob = old_map.get(path)
if ob and ob["text"] != nb["text"]:
patches.append(PatchPayload(
target_path=path, operation=PatchOperation.REPLACE_TEXT,
old_value=ob["text"], new_value=nb["text"],
))
for path, ob in old_map.items():
if path not in new_map and ob.get("block_type") != "heading":
patches.append(PatchPayload(
target_path=path, operation=PatchOperation.REMOVE_BLOCK,
old_value=ob["text"],
))
return patches
async def create_branch(
@@ -153,6 +177,55 @@ async def append_patches_to_version(
return result.scalars().one()
async def upload_docx_to_version(
session: AsyncSession,
*,
owner_id: str,
version_id: str,
upload: UploadFile,
) -> CvVersion | None:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
.options(selectinload(CvVersion.patches), selectinload(CvVersion.public_assets))
)
version = (await session.execute(stmt)).scalars().one_or_none()
if not version:
return None
artifact_key, file_bytes = await persist_upload(upload, owner_id)
new_blocks_parsed = parse_docx_bytes(file_bytes)
new_blocks = [b.model_dump() for b in new_blocks_parsed.blocks]
diff_patches = _diff_blocks(version.structured_blocks or [], new_blocks)
version.artifact_docx_key = artifact_key
version.structured_blocks = new_blocks
metadata = version.metadata_json or {}
metadata["patch_count"] = int(metadata.get("patch_count") or 0) + len(diff_patches)
version.metadata_json = metadata
for patch in diff_patches:
session.add(CvPatch(
version_id=version.id,
target_path=patch.target_path,
operation=patch.operation.value,
old_value=patch.old_value,
new_value=patch.new_value,
metadata_json=patch.metadata,
))
await session.commit()
stmt_refresh = (
select(CvVersion)
.where(CvVersion.id == version_id)
.options(selectinload(CvVersion.patches), selectinload(CvVersion.public_assets))
)
return (await session.execute(stmt_refresh)).scalars().one()
async def delete_version(
session: AsyncSession, owner_id: str, version_id: str
) -> bool | str:

View File

@@ -1,9 +1,10 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import CVTree from '@/components/cv/CVTree';
import CVTree, { versionToMarkdown } from '@/components/cv/CVTree';
import DiffViewer from '@/components/cv/DiffViewer';
import InsightsPanel from '@/components/cv/InsightsPanel';
import PDFPreview from '@/components/cv/PDFPreview';
import Link from 'next/link';
import {
appendPatches,
@@ -21,6 +22,7 @@ import {
updateSubmissionStatus,
updateSuggestion,
uploadDocument,
uploadDocxToBranch,
Version,
} from '@/libs/api';
import {
@@ -576,7 +578,7 @@ function SubmissionsTab({
// ── main dashboard ────────────────────────────────────────────────────────────
type Modal = 'upload' | 'branch' | 'submission' | 'publish' | null;
type Tab = 'content' | 'patches' | 'submissions' | 'insights';
type Tab = 'content' | 'patches' | 'submissions' | 'insights' | 'preview';
export default function Dashboard() {
const [docs, setDocs] = useState<Document[]>([]);
@@ -597,6 +599,9 @@ export default function Dashboard() {
const [applyLoading, setApplyLoading] = useState(false);
const [applyError, setApplyError] = useState('');
const [insights, setInsights] = useState<InsightsResult | null>(null);
const [uploadBranchLoading, setUploadBranchLoading] = useState(false);
const [uploadBranchError, setUploadBranchError] = useState('');
const uploadBranchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (IS_DEMO) {
@@ -717,6 +722,40 @@ export default function Dashboard() {
const discardEdits = () => setPendingEdits(new Map());
const handleUploadToBranch = async (file: File) => {
if (!selectedDocId || !selectedVersionId) return;
setUploadBranchLoading(true);
setUploadBranchError('');
try {
await uploadDocxToBranch(selectedDocId, selectedVersionId, file);
await refreshDocs();
} catch (e: unknown) {
setUploadBranchError(e instanceof Error ? e.message : 'Upload failed');
} finally {
setUploadBranchLoading(false);
}
};
const handleCopyMarkdown = (versionId: string) => {
if (!selectedDoc) return;
const versionMap = new Map(selectedDoc.versions.map(v => [v.id, v]));
const sections = selectedDoc.versions
.filter(v => v.id === versionId || selectedDoc.versions.some(x => x.id === versionId))
.map(v => versionToMarkdown(v, v.parent_version_id ? (versionMap.get(v.parent_version_id ?? '')?.branch_name) : undefined));
const md = `# ${selectedDoc.title}\n\n${sections.join('\n\n---\n\n')}`;
navigator.clipboard.writeText(md).catch(() => {});
};
const handleCopyBranchMarkdown = (versionId: string) => {
if (!selectedDoc) return;
const version = selectedDoc.versions.find(v => v.id === versionId);
if (!version) return;
const versionMap = new Map(selectedDoc.versions.map(v => [v.id, v]));
const parentName = version.parent_version_id ? versionMap.get(version.parent_version_id)?.branch_name : undefined;
const md = `# ${selectedDoc.title}\n\n${versionToMarkdown(version, parentName)}`;
navigator.clipboard.writeText(md).catch(() => {});
};
const applyStagedEdits = async () => {
if (!selectedVersionId || !stagedPatches.length) return;
setApplyLoading(true);
@@ -899,6 +938,7 @@ export default function Dashboard() {
selectedVersionId={selectedVersionId}
onSelect={selectVersion}
onDeleteVersion={handleDeleteVersion}
onCopyMarkdown={handleCopyBranchMarkdown}
/>
</div>
)}
@@ -951,6 +991,7 @@ export default function Dashboard() {
versions={selectedDoc.versions}
selectedVersionId={selectedVersionId}
onSelect={selectVersion}
onCopyMarkdown={handleCopyBranchMarkdown}
/>
</div>
@@ -1011,6 +1052,29 @@ export default function Dashboard() {
DOCX
</a>
)}
{!IS_DEMO && (
<>
<button
className="btn btn-ghost"
onClick={() => uploadBranchRef.current?.click()}
disabled={uploadBranchLoading}
title="Upload a new DOCX to this branch — diff is computed automatically"
>
{uploadBranchLoading ? 'Uploading…' : '↑ DOCX'}
</button>
<input
ref={uploadBranchRef}
type="file"
accept=".docx"
style={{ display: 'none' }}
onChange={e => {
const f = e.target.files?.[0];
if (f) handleUploadToBranch(f);
e.target.value = '';
}}
/>
</>
)}
</div>
</div>
@@ -1107,9 +1171,20 @@ export default function Dashboard() {
</div>
)}
{uploadBranchError && (
<div style={{
padding: '6px 12px', background: '#fef2f2', border: '1px solid #fca5a5',
borderRadius: 5, marginBottom: 12, fontSize: 12, color: '#b91c1c',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<span>{uploadBranchError}</span>
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={() => setUploadBranchError('')}>×</button>
</div>
)}
{/* tabs */}
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', overflowX: 'auto' }}>
{(['content', 'patches', 'submissions', 'insights'] as Tab[]).map(t => (
{(['content', 'patches', 'submissions', 'insights', 'preview'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setActiveTab(t)}
@@ -1128,7 +1203,7 @@ export default function Dashboard() {
</div>
{/* tab content */}
<div style={{ padding: '16px 20px', flex: 1, overflow: 'auto' }}>
<div style={{ padding: activeTab === 'preview' ? 0 : '16px 20px', flex: 1, overflow: activeTab === 'preview' ? 'hidden' : 'auto' }}>
{activeTab === 'content' && (
<ContentTab
blocks={selectedVersion.structured_blocks ?? []}
@@ -1156,6 +1231,11 @@ export default function Dashboard() {
{activeTab === 'insights' && (
<InsightsPanel data={insights} />
)}
{activeTab === 'preview' && selectedDoc && (
IS_DEMO
? <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-faint)', fontSize: 13 }}>Preview not available in demo mode.</div>
: <PDFPreview documentId={selectedDoc.id} versionId={selectedVersion.id} />
)}
</div>
</>
)}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { MouseEvent, useState } from 'react';
import { Version } from '@/libs/api';
type TreeNode = { version: Version; children: TreeNode[] };
@@ -15,21 +15,38 @@ function buildTree(versions: Version[]): TreeNode | null {
return root;
}
export function versionToMarkdown(version: Version, parentName?: string): string {
const header = `## ${version.version_label || version.branch_name}${parentName ? ` (from ${parentName})` : ''}`;
const blocks = (version.structured_blocks ?? []).map(b =>
`[${b.path}] ${b.block_type}: ${b.text}`
).join('\n');
return `${header}\n\n${blocks || '(no blocks)'}`;
}
const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2'];
function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
function Node({ node, depth, selectedId, onSelect, onDelete, onCopyMarkdown, colorIndex = 0 }: {
node: TreeNode; depth: number; selectedId: string | null;
onSelect: (id: string) => void;
onDelete?: (id: string) => void;
onCopyMarkdown?: (id: string) => void;
colorIndex?: number;
}) {
const [open, setOpen] = useState(true);
const [hovered, setHovered] = useState(false);
const [copied, setCopied] = useState(false);
const v = node.version;
const isRoot = !v.parent_version_id;
const isSelected = v.id === selectedId;
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
const handleCopy = (e: MouseEvent) => {
e.stopPropagation();
onCopyMarkdown?.(v.id);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div style={{ position: 'relative' }}>
{depth > 0 && (
@@ -93,6 +110,22 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
</span>
)}
{hovered && onCopyMarkdown && (
<button
onClick={handleCopy}
title="Copy Markdown"
aria-label="Copy Markdown"
style={{
width: 18, height: 18, display: 'flex', alignItems: 'center',
justifyContent: 'center', cursor: 'pointer', background: 'none',
border: 'none', padding: 0, color: copied ? '#059669' : 'var(--text-faint)',
flexShrink: 0, borderRadius: 3, fontSize: 11, lineHeight: 1,
}}
>
{copied ? '✓' : '⎘'}
</button>
)}
{!isRoot && onDelete && hovered && (
<button
onClick={e => { e.stopPropagation(); onDelete(v.id); }}
@@ -124,6 +157,7 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
selectedId={selectedId}
onSelect={onSelect}
onDelete={onDelete}
onCopyMarkdown={onCopyMarkdown}
colorIndex={depth === 0 ? i + 1 : colorIndex}
/>
))}
@@ -133,16 +167,17 @@ function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
);
}
export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion }: {
export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion, onCopyMarkdown }: {
versions: Version[]; selectedVersionId: string | null;
onSelect: (id: string) => void;
onDeleteVersion?: (id: string) => void;
onCopyMarkdown?: (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} onDelete={onDeleteVersion} />
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} onDelete={onDeleteVersion} onCopyMarkdown={onCopyMarkdown} />
</div>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { previewVersionPdfUrl } from '@/libs/api';
function getToken(): string | null {
if (typeof document === 'undefined') return null;
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('oidc_token_pub='))?.split('=').slice(1).join('=') ?? null;
}
export default function PDFPreview({ documentId, versionId }: { documentId: string; versionId: string }) {
const [src, setSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const prevUrl = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const token = getToken();
fetch(previewVersionPdfUrl(documentId, versionId), {
headers: token ? { authorization: `Bearer ${decodeURIComponent(token)}` } : {},
})
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.blob();
})
.then(blob => {
if (cancelled) return;
if (prevUrl.current) URL.revokeObjectURL(prevUrl.current);
const url = URL.createObjectURL(blob);
prevUrl.current = url;
setSrc(url);
})
.catch(e => { if (!cancelled) setError(e.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [documentId, versionId]);
useEffect(() => () => { if (prevUrl.current) URL.revokeObjectURL(prevUrl.current); }, []);
if (loading) return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-faint)', fontSize: 13 }}>
Rendering PDF
</div>
);
if (error) return (
<div style={{ padding: 16, fontSize: 12, color: '#dc2626' }}>
Preview unavailable: {error}
</div>
);
if (!src) return null;
return (
<iframe
src={src}
style={{ width: '100%', height: '100%', border: 'none', borderRadius: 4 }}
title="CV Preview"
/>
);
}

View File

@@ -144,6 +144,15 @@ export async function uploadDocument(title: string, description: string | null,
export const downloadVersionUrl = (documentId: string, versionId: string): string =>
`${API}/api/v1/documents/${documentId}/versions/${versionId}/download`;
export const previewVersionPdfUrl = (documentId: string, versionId: string): string =>
`${API}/api/v1/documents/${documentId}/versions/${versionId}/preview`;
export async function uploadDocxToBranch(documentId: string, versionId: string, file: File): Promise<Version> {
const form = new FormData();
form.append('file', file);
return req<Version>(`/api/v1/documents/${documentId}/versions/${versionId}/upload`, { method: 'POST', body: form });
}
export async function createBranch(
parentVersionId: string,
branchName: string,