mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-05-31 08:43:37 +00:00
Merge pull request #2 from velocitatem/copilot/add-mobile-support-and-deleting-functions
Add mobile support, CV/branch deletion, and fix DOCX export to include patches
This commit is contained in:
@@ -6,9 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_current_user, get_db
|
from app.api.deps import get_current_user, get_db
|
||||||
from app.schemas import DocumentListResponse, DocumentResponse
|
from app.schemas import DocumentListResponse, DocumentResponse
|
||||||
from app.services.documents import create_document, get_document, list_documents
|
from app.services.documents import create_document, delete_document, get_document, list_documents
|
||||||
from app.services.storage import storage_client
|
from app.services.storage import storage_client
|
||||||
from dlib.auth import AuthenticatedUser
|
from dlib.auth import AuthenticatedUser
|
||||||
|
from dlib.cv import generate_patched_docx
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/documents", tags=["documents"])
|
router = APIRouter(prefix="/documents", tags=["documents"])
|
||||||
@@ -49,7 +50,8 @@ async def download_version_docx(
|
|||||||
version = next((v for v in document.versions if v.id == version_id), None)
|
version = next((v for v in document.versions if v.id == version_id), None)
|
||||||
if not version or not version.artifact_docx_key:
|
if not version or not version.artifact_docx_key:
|
||||||
raise HTTPException(status_code=404, detail="Version artifact not found")
|
raise HTTPException(status_code=404, detail="Version artifact not found")
|
||||||
data = storage_client.download_bytes(key=version.artifact_docx_key)
|
original = storage_client.download_bytes(key=version.artifact_docx_key)
|
||||||
|
data = generate_patched_docx(original, version.structured_blocks or [])
|
||||||
slug = f"{document.title.replace(' ', '-')}-{version.branch_name}.docx"
|
slug = f"{document.title.replace(' ', '-')}-{version.branch_name}.docx"
|
||||||
return Response(
|
return Response(
|
||||||
content=data,
|
content=data,
|
||||||
@@ -74,3 +76,14 @@ async def upload_document(
|
|||||||
upload=file,
|
upload=file,
|
||||||
)
|
)
|
||||||
return DocumentResponse.model_validate(document)
|
return DocumentResponse.model_validate(document)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{document_id}", status_code=204)
|
||||||
|
async def delete_user_document(
|
||||||
|
document_id: str,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
deleted = await delete_document(session, owner_id=user.sub, document_id=document_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_current_user, get_db
|
from app.api.deps import get_current_user, get_db
|
||||||
from app.schemas import BranchCreateRequest, VersionResponse
|
from app.schemas import BranchCreateRequest, VersionResponse
|
||||||
from app.services.versions import create_branch
|
from app.services.versions import create_branch, delete_version
|
||||||
from dlib.auth import AuthenticatedUser
|
from dlib.auth import AuthenticatedUser
|
||||||
|
|
||||||
|
|
||||||
@@ -29,3 +29,18 @@ async def create_version_branch(
|
|||||||
if not version:
|
if not version:
|
||||||
raise HTTPException(status_code=404, detail="Parent version not found")
|
raise HTTPException(status_code=404, detail="Parent version not found")
|
||||||
return VersionResponse.model_validate(version)
|
return VersionResponse.model_validate(version)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{version_id}", status_code=204)
|
||||||
|
async def delete_version_branch(
|
||||||
|
version_id: str,
|
||||||
|
session: AsyncSession = Depends(get_db),
|
||||||
|
user: AuthenticatedUser = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
result = await delete_version(session, owner_id=user.sub, version_id=version_id)
|
||||||
|
if result is False:
|
||||||
|
raise HTTPException(status_code=404, detail="Version not found")
|
||||||
|
if result == "root":
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete root version")
|
||||||
|
if result == "has_children":
|
||||||
|
raise HTTPException(status_code=409, detail="Delete child branches first")
|
||||||
|
|||||||
@@ -68,3 +68,14 @@ async def get_document(
|
|||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalars().unique().one_or_none()
|
return result.scalars().unique().one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_document(
|
||||||
|
session: AsyncSession, owner_id: str, document_id: str
|
||||||
|
) -> bool:
|
||||||
|
doc = await get_document(session, owner_id, document_id)
|
||||||
|
if not doc:
|
||||||
|
return False
|
||||||
|
await session.delete(doc)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|||||||
@@ -82,3 +82,28 @@ async def create_branch(
|
|||||||
)
|
)
|
||||||
result = await session.execute(stmt_refresh)
|
result = await session.execute(stmt_refresh)
|
||||||
return result.scalars().one()
|
return result.scalars().one()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_version(
|
||||||
|
session: AsyncSession, owner_id: str, version_id: str
|
||||||
|
) -> bool | str:
|
||||||
|
"""Delete a non-root branch. Returns False if not found, 'root' if root, True on success."""
|
||||||
|
stmt = (
|
||||||
|
select(CvVersion)
|
||||||
|
.join(CvVersion.document)
|
||||||
|
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
version = result.scalars().one_or_none()
|
||||||
|
if not version:
|
||||||
|
return False
|
||||||
|
if not version.parent_version_id:
|
||||||
|
return "root"
|
||||||
|
# Refuse if child branches exist
|
||||||
|
child_stmt = select(CvVersion.id).where(CvVersion.parent_version_id == version_id).limit(1)
|
||||||
|
child_result = await session.execute(child_stmt)
|
||||||
|
if child_result.scalar_one_or_none():
|
||||||
|
return "has_children"
|
||||||
|
await session.delete(version)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import CVTree from '@/components/cv/CVTree';
|
|||||||
import DiffViewer from '@/components/cv/DiffViewer';
|
import DiffViewer from '@/components/cv/DiffViewer';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {
|
import {
|
||||||
createBranch, createSubmission, Document, downloadVersionUrl,
|
createBranch, createSubmission, deleteDocument, deleteVersion,
|
||||||
|
Document, downloadVersionUrl,
|
||||||
fetchDocuments, fetchSubmissions, publishVersion, requestAiSuggestions,
|
fetchDocuments, fetchSubmissions, publishVersion, requestAiSuggestions,
|
||||||
Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version,
|
Submission, StructuredBlock, Suggestion, updateSuggestion, uploadDocument, Version,
|
||||||
} from '@/libs/api';
|
} from '@/libs/api';
|
||||||
@@ -482,6 +483,8 @@ export default function Dashboard() {
|
|||||||
const [submissions, setSubmissions] = useState<Submission[]>([]);
|
const [submissions, setSubmissions] = useState<Submission[]>([]);
|
||||||
const [subsLoading, setSubsLoading] = useState(false);
|
const [subsLoading, setSubsLoading] = useState(false);
|
||||||
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
|
const [pendingEdits, setPendingEdits] = useState<Map<string, { old_value: string; new_value: string }>>(new Map());
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [docHovered, setDocHovered] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDocuments()
|
fetchDocuments()
|
||||||
@@ -525,6 +528,7 @@ export default function Dashboard() {
|
|||||||
setSelectedDocId(doc.id);
|
setSelectedDocId(doc.id);
|
||||||
setSelectedVersionId(doc.root_version_id ?? null);
|
setSelectedVersionId(doc.root_version_id ?? null);
|
||||||
setModal(null);
|
setModal(null);
|
||||||
|
setSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBranchDone = async (v: Version) => {
|
const onBranchDone = async (v: Version) => {
|
||||||
@@ -547,6 +551,41 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
const discardEdits = () => setPendingEdits(new Map());
|
const discardEdits = () => setPendingEdits(new Map());
|
||||||
|
|
||||||
|
const handleDeleteDoc = async (docId: string) => {
|
||||||
|
if (!confirm('Delete this CV and all its branches? This cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
await deleteDocument(docId);
|
||||||
|
const updated = docs.filter(d => d.id !== docId);
|
||||||
|
setDocs(updated);
|
||||||
|
if (selectedDocId === docId) {
|
||||||
|
setSelectedDocId(updated[0]?.id ?? null);
|
||||||
|
setSelectedVersionId(updated[0]?.root_version_id ?? null);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Delete failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteVersion = async (versionId: string) => {
|
||||||
|
if (!confirm('Delete this branch? This cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
await deleteVersion(versionId);
|
||||||
|
const fresh = await refreshDocs();
|
||||||
|
if (selectedVersionId === versionId) {
|
||||||
|
const doc = fresh.find(d => d.id === selectedDocId);
|
||||||
|
setSelectedVersionId(doc?.root_version_id ?? null);
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
alert(e instanceof Error ? e.message : 'Delete failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectVersion = (id: string) => {
|
||||||
|
setSelectedVersionId(id);
|
||||||
|
setActiveTab('content');
|
||||||
|
setSidebarOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const pendingCount = pendingEdits.size;
|
const pendingCount = pendingEdits.size;
|
||||||
const stagedPatches = [...pendingEdits.entries()].map(([path, { old_value, new_value }]) => ({
|
const stagedPatches = [...pendingEdits.entries()].map(([path, { old_value, new_value }]) => ({
|
||||||
target_path: path, operation: 'replace_text', old_value, new_value,
|
target_path: path, operation: 'replace_text', old_value, new_value,
|
||||||
@@ -558,15 +597,22 @@ export default function Dashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden', background: 'var(--bg)' }}>
|
<div className="dashboard-root">
|
||||||
{/* top bar */}
|
{/* top bar */}
|
||||||
<div style={{
|
<div className="topbar">
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
padding: '0 16px', height: 44, borderBottom: '1px solid var(--border)', flexShrink: 0,
|
<button
|
||||||
}}>
|
className="btn btn-ghost sidebar-toggle"
|
||||||
<Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}>
|
style={{ padding: '4px 8px', fontSize: 16 }}
|
||||||
Resume Branches
|
onClick={() => setSidebarOpen(o => !o)}
|
||||||
</Link>
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<Link href="/" style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', textDecoration: 'none' }}>
|
||||||
|
Resume Branches
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
<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
|
||||||
@@ -577,12 +623,17 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
<div className="dashboard-body">
|
||||||
|
{/* sidebar overlay on mobile */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="sidebar-overlay"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* left panel */}
|
{/* left panel */}
|
||||||
<div style={{
|
<div className={`sidebar${sidebarOpen ? ' sidebar-open' : ''}`}>
|
||||||
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>}
|
||||||
|
|
||||||
@@ -602,21 +653,41 @@ export default function Dashboard() {
|
|||||||
{docs.map(d => (
|
{docs.map(d => (
|
||||||
<div
|
<div
|
||||||
key={d.id}
|
key={d.id}
|
||||||
|
onMouseEnter={() => setDocHovered(d.id)}
|
||||||
|
onMouseLeave={() => setDocHovered(null)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedDocId(d.id);
|
setSelectedDocId(d.id);
|
||||||
setSelectedVersionId(d.root_version_id ?? null);
|
setSelectedVersionId(d.root_version_id ?? null);
|
||||||
setActiveTab('content');
|
setActiveTab('content');
|
||||||
|
setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '5px 8px', borderRadius: 4, cursor: 'pointer',
|
padding: '5px 8px', borderRadius: 4, cursor: 'pointer',
|
||||||
fontSize: 13, fontWeight: d.id === selectedDocId ? 600 : 400,
|
fontSize: 13, fontWeight: d.id === selectedDocId ? 600 : 400,
|
||||||
background: d.id === selectedDocId ? 'var(--selected-bg)' : 'transparent',
|
background: d.id === selectedDocId ? 'var(--selected-bg)' : 'transparent',
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.title}</div>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
|
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.title}</div>
|
||||||
{d.versions.length} version{d.versions.length !== 1 ? 's' : ''}
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>
|
||||||
|
{d.versions.length} version{d.versions.length !== 1 ? 's' : ''}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{docHovered === d.id && (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); handleDeleteDoc(d.id); }}
|
||||||
|
title="Delete CV"
|
||||||
|
aria-label="Delete CV"
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
color: '#dc2626', fontSize: 14, lineHeight: 1, padding: '2px 2px',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -629,7 +700,8 @@ export default function Dashboard() {
|
|||||||
<CVTree
|
<CVTree
|
||||||
versions={selectedDoc.versions}
|
versions={selectedDoc.versions}
|
||||||
selectedVersionId={selectedVersionId}
|
selectedVersionId={selectedVersionId}
|
||||||
onSelect={id => { setSelectedVersionId(id); setActiveTab('content'); }}
|
onSelect={selectVersion}
|
||||||
|
onDeleteVersion={handleDeleteVersion}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -638,7 +710,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* main panel */}
|
{/* main panel */}
|
||||||
<div style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column' }}>
|
<div className="main-panel">
|
||||||
{!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 branch to view details.
|
Select a branch to view details.
|
||||||
@@ -648,10 +720,10 @@ export default function Dashboard() {
|
|||||||
{selectedVersion && (
|
{selectedVersion && (
|
||||||
<>
|
<>
|
||||||
{/* version header */}
|
{/* version header */}
|
||||||
<div style={{ padding: '16px 24px 0', flexShrink: 0 }}>
|
<div style={{ padding: '16px 20px 0', flexShrink: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}>
|
||||||
<div>
|
<div style={{ minWidth: 0 }}>
|
||||||
<h2 style={{ fontSize: 17, fontWeight: 600, marginBottom: 3 }}>
|
<h2 style={{ fontSize: 17, fontWeight: 600, marginBottom: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{selectedVersion.version_label || selectedVersion.branch_name}
|
{selectedVersion.version_label || selectedVersion.branch_name}
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{ display: 'flex', gap: 14, fontSize: 12, color: 'var(--text-muted)', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 14, fontSize: 12, color: 'var(--text-muted)', flexWrap: 'wrap' }}>
|
||||||
@@ -673,7 +745,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* action buttons */}
|
{/* action buttons */}
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
<div className="action-buttons">
|
||||||
<button className="btn btn-ghost" onClick={() => setModal('branch')}>Branch</button>
|
<button className="btn btn-ghost" onClick={() => setModal('branch')}>Branch</button>
|
||||||
<button className="btn btn-ghost" onClick={() => { setModal('submission'); }}>Submit</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>
|
||||||
@@ -700,7 +772,7 @@ export default function Dashboard() {
|
|||||||
{pendingCount > 0 && (
|
{pendingCount > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '8px 12px', background: '#fffbeb', border: '1px solid #fde68a',
|
padding: '8px 12px', background: '#fffbeb', border: '1px solid #fde68a',
|
||||||
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', gap: 10, alignItems: 'center',
|
borderRadius: 5, marginBottom: 12, fontSize: 13, display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap',
|
||||||
}}>
|
}}>
|
||||||
<span style={{ color: '#92400e', flex: 1 }}>
|
<span style={{ color: '#92400e', flex: 1 }}>
|
||||||
{pendingCount} staged edit{pendingCount !== 1 ? 's' : ''}
|
{pendingCount} staged edit{pendingCount !== 1 ? 's' : ''}
|
||||||
@@ -719,7 +791,7 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* tabs */}
|
{/* tabs */}
|
||||||
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)' }}>
|
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--border)', overflowX: 'auto' }}>
|
||||||
{(['content', 'patches', 'submissions'] as Tab[]).map(t => (
|
{(['content', 'patches', 'submissions'] as Tab[]).map(t => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
@@ -729,7 +801,7 @@ export default function Dashboard() {
|
|||||||
cursor: 'pointer', color: activeTab === t ? 'var(--text)' : 'var(--text-muted)',
|
cursor: 'pointer', color: activeTab === t ? 'var(--text)' : 'var(--text-muted)',
|
||||||
borderBottom: activeTab === t ? '2px solid var(--text)' : '2px solid transparent',
|
borderBottom: activeTab === t ? '2px solid var(--text)' : '2px solid transparent',
|
||||||
fontWeight: activeTab === t ? 500 : 400,
|
fontWeight: activeTab === t ? 500 : 400,
|
||||||
marginBottom: -1, transition: 'color 0.1s',
|
marginBottom: -1, transition: 'color 0.1s', whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t === 'patches' ? `Patches (${selectedVersion.patches.length})` : t.charAt(0).toUpperCase() + t.slice(1)}
|
{t === 'patches' ? `Patches (${selectedVersion.patches.length})` : t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
@@ -739,7 +811,7 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* tab content */}
|
{/* tab content */}
|
||||||
<div style={{ padding: '16px 24px', flex: 1, overflow: 'auto' }}>
|
<div style={{ padding: '16px 20px', flex: 1, overflow: 'auto' }}>
|
||||||
{activeTab === 'content' && (
|
{activeTab === 'content' && (
|
||||||
<ContentTab
|
<ContentTab
|
||||||
blocks={selectedVersion.structured_blocks ?? []}
|
blocks={selectedVersion.structured_blocks ?? []}
|
||||||
|
|||||||
@@ -150,3 +150,111 @@ input:focus, textarea:focus, select:focus {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── dashboard layout ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.dashboard-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
height: 44px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-toggle { display: none; }
|
||||||
|
|
||||||
|
.dashboard-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay { display: none; }
|
||||||
|
|
||||||
|
.main-panel {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── mobile breakpoint ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sidebar-toggle { display: inline-flex; }
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 44px;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 40;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.sidebar-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 44px 0 0 0;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
z-index: 39;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
max-width: 100% !important;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons .btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,19 +17,22 @@ function buildTree(versions: Version[]): TreeNode | null {
|
|||||||
|
|
||||||
const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2'];
|
const DOT_COLORS = ['#0a0a0a', '#2563eb', '#7c3aed', '#059669', '#d97706', '#dc2626', '#0891b2'];
|
||||||
|
|
||||||
function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
|
function Node({ node, depth, selectedId, onSelect, onDelete, colorIndex = 0 }: {
|
||||||
node: TreeNode; depth: number; selectedId: string | null;
|
node: TreeNode; depth: number; selectedId: string | null;
|
||||||
onSelect: (id: string) => void; colorIndex?: number;
|
onSelect: (id: string) => void;
|
||||||
|
onDelete?: (id: string) => void;
|
||||||
|
colorIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
const v = node.version;
|
const v = node.version;
|
||||||
const isRoot = !v.parent_version_id;
|
const isRoot = !v.parent_version_id;
|
||||||
const isSelected = v.id === selectedId;
|
const isSelected = v.id === selectedId;
|
||||||
|
const isLeaf = node.children.length === 0;
|
||||||
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
|
const dotColor = DOT_COLORS[colorIndex % DOT_COLORS.length];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
{/* horizontal connector from parent's vertical line */}
|
|
||||||
{depth > 0 && (
|
{depth > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', left: -1, top: 15,
|
position: 'absolute', left: -1, top: 15,
|
||||||
@@ -39,17 +42,16 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={() => onSelect(v.id)}
|
onClick={() => onSelect(v.id)}
|
||||||
|
onMouseEnter={() => setHovered(true)}
|
||||||
|
onMouseLeave={() => setHovered(false)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 6,
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
paddingLeft: depth > 0 ? 18 : 8, paddingRight: 8,
|
paddingLeft: depth > 0 ? 18 : 8, paddingRight: 4,
|
||||||
height: 30, cursor: 'pointer', borderRadius: 4, userSelect: 'none',
|
height: 30, cursor: 'pointer', borderRadius: 4, userSelect: 'none',
|
||||||
background: isSelected ? 'var(--selected-bg)' : 'transparent',
|
background: isSelected ? 'var(--selected-bg)' : hovered ? 'var(--hover)' : 'transparent',
|
||||||
borderLeft: isSelected && depth === 0 ? '2px solid var(--selected-border)' : '2px solid transparent',
|
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
|
<button
|
||||||
onClick={e => { e.stopPropagation(); setOpen(o => !o); }}
|
onClick={e => { e.stopPropagation(); setOpen(o => !o); }}
|
||||||
style={{
|
style={{
|
||||||
@@ -65,7 +67,6 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* dot indicator */}
|
|
||||||
<span style={{
|
<span style={{
|
||||||
width: isRoot ? 8 : 7, height: isRoot ? 8 : 7,
|
width: isRoot ? 8 : 7, height: isRoot ? 8 : 7,
|
||||||
borderRadius: '50%', flexShrink: 0,
|
borderRadius: '50%', flexShrink: 0,
|
||||||
@@ -74,7 +75,6 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
|
|||||||
transition: 'background 0.1s',
|
transition: 'background 0.1s',
|
||||||
}} />
|
}} />
|
||||||
|
|
||||||
{/* label */}
|
|
||||||
<span style={{
|
<span style={{
|
||||||
flex: 1, fontSize: 13,
|
flex: 1, fontSize: 13,
|
||||||
fontWeight: isRoot ? 600 : 400,
|
fontWeight: isRoot ? 600 : 400,
|
||||||
@@ -84,7 +84,6 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
|
|||||||
{v.version_label || v.branch_name}
|
{v.version_label || v.branch_name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* patch count */}
|
|
||||||
{v.patches.length > 0 && (
|
{v.patches.length > 0 && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: 10, color: 'var(--text-faint)',
|
fontSize: 10, color: 'var(--text-faint)',
|
||||||
@@ -94,9 +93,24 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
|
|||||||
{v.patches.length}
|
{v.patches.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!isRoot && isLeaf && onDelete && hovered && (
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onDelete(v.id); }}
|
||||||
|
title="Delete branch"
|
||||||
|
aria-label="Delete branch"
|
||||||
|
style={{
|
||||||
|
width: 18, height: 18, display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'center', cursor: 'pointer', background: 'none',
|
||||||
|
border: 'none', padding: 0, color: '#dc2626', flexShrink: 0,
|
||||||
|
borderRadius: 3, fontSize: 14, lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* children with vertical line */}
|
|
||||||
{open && node.children.length > 0 && (
|
{open && node.children.length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginLeft: depth > 0 ? 22 : 14,
|
marginLeft: depth > 0 ? 22 : 14,
|
||||||
@@ -110,6 +124,7 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
|
|||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
|
onDelete={onDelete}
|
||||||
colorIndex={depth === 0 ? i + 1 : colorIndex}
|
colorIndex={depth === 0 ? i + 1 : colorIndex}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -119,14 +134,17 @@ function Node({ node, depth, selectedId, onSelect, colorIndex = 0 }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CVTree({ versions, selectedVersionId, onSelect }: {
|
export default function CVTree({ versions, selectedVersionId, onSelect, onDeleteVersion }: {
|
||||||
versions: Version[]; selectedVersionId: string | null; onSelect: (id: string) => void;
|
versions: Version[]; selectedVersionId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onDeleteVersion?: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const tree = buildTree(versions);
|
const tree = buildTree(versions);
|
||||||
if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>;
|
if (!tree) return <div style={{ padding: 16, fontSize: 13, color: 'var(--text-faint)' }}>No versions</div>;
|
||||||
return (
|
return (
|
||||||
<div style={{ paddingBottom: 8 }}>
|
<div style={{ paddingBottom: 8 }}>
|
||||||
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} />
|
<Node node={tree} depth={0} selectedId={selectedVersionId} onSelect={onSelect} onDelete={onDeleteVersion} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -177,3 +177,25 @@ export async function publishVersion(
|
|||||||
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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteDocument(documentId: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/api/v1/documents/${documentId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { accept: 'application/json', ...getAuthHeader() },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(() => res.statusText);
|
||||||
|
throw new Error(detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteVersion(versionId: string): Promise<void> {
|
||||||
|
const res = await fetch(`${API}/api/v1/versions/${versionId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { accept: 'application/json', ...getAuthHeader() },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text().catch(() => res.statusText);
|
||||||
|
throw new Error(detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .schema import (
|
|||||||
from .parser import parse_docx_bytes, summarize_keywords
|
from .parser import parse_docx_bytes, summarize_keywords
|
||||||
from .patcher import apply_patchset
|
from .patcher import apply_patchset
|
||||||
from .ats_guard import validate_patchset
|
from .ats_guard import validate_patchset
|
||||||
|
from .docx_export import generate_patched_docx
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"StructuredBlock",
|
"StructuredBlock",
|
||||||
@@ -19,4 +20,5 @@ __all__ = [
|
|||||||
"summarize_keywords",
|
"summarize_keywords",
|
||||||
"apply_patchset",
|
"apply_patchset",
|
||||||
"validate_patchset",
|
"validate_patchset",
|
||||||
|
"generate_patched_docx",
|
||||||
]
|
]
|
||||||
|
|||||||
76
dlib/cv/docx_export.py
Normal file
76
dlib/cv/docx_export.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
|
||||||
|
from .parser import _detect_block_type
|
||||||
|
|
||||||
|
|
||||||
|
def _path_to_para_map(doc: Document) -> dict[str, int]:
|
||||||
|
counters: defaultdict[str, int] = defaultdict(int)
|
||||||
|
result: dict[str, int] = {}
|
||||||
|
for idx, para in enumerate(doc.paragraphs):
|
||||||
|
if not para.text.strip():
|
||||||
|
continue
|
||||||
|
block_type = _detect_block_type(getattr(para.style, "name", None), para)
|
||||||
|
counters[block_type] += 1
|
||||||
|
result[f"{block_type}[{counters[block_type]}]"] = idx
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_para_text(para, new_text: str) -> None:
|
||||||
|
"""Replace paragraph text preserving the first run's character formatting."""
|
||||||
|
if not para.runs:
|
||||||
|
para.add_run(new_text)
|
||||||
|
return
|
||||||
|
first = para.runs[0]
|
||||||
|
for run in para.runs[1:]:
|
||||||
|
run.text = ""
|
||||||
|
first.text = new_text
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_paragraph(paragraph) -> None:
|
||||||
|
p = paragraph._element
|
||||||
|
p.getparent().remove(p)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_patched_docx(
|
||||||
|
original_bytes: bytes, structured_blocks: list[dict]
|
||||||
|
) -> bytes:
|
||||||
|
"""Return DOCX bytes with text patches from structured_blocks applied.
|
||||||
|
|
||||||
|
Compares each block's text against the original paragraph and replaces it
|
||||||
|
when different. Blocks absent from structured_blocks are removed.
|
||||||
|
"""
|
||||||
|
if not structured_blocks:
|
||||||
|
return original_bytes
|
||||||
|
|
||||||
|
doc = Document(BytesIO(original_bytes))
|
||||||
|
path_map = _path_to_para_map(doc)
|
||||||
|
|
||||||
|
original_paths = set(path_map.keys())
|
||||||
|
patched = {b["path"]: b["text"] for b in structured_blocks}
|
||||||
|
patched_paths = set(patched.keys())
|
||||||
|
|
||||||
|
# Apply text replacements first (indices stay stable)
|
||||||
|
for path, new_text in patched.items():
|
||||||
|
idx = path_map.get(path)
|
||||||
|
if idx is None:
|
||||||
|
continue
|
||||||
|
para = doc.paragraphs[idx]
|
||||||
|
if para.text.strip() != new_text:
|
||||||
|
_replace_para_text(para, new_text)
|
||||||
|
|
||||||
|
# Remove blocks no longer present; process in reverse index order
|
||||||
|
removed = sorted(
|
||||||
|
[path_map[p] for p in (original_paths - patched_paths) if p in path_map],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for idx in removed:
|
||||||
|
_remove_paragraph(doc.paragraphs[idx])
|
||||||
|
|
||||||
|
out = BytesIO()
|
||||||
|
doc.save(out)
|
||||||
|
return out.getvalue()
|
||||||
Reference in New Issue
Block a user