Compare commits

..

3 Commits

Author SHA1 Message Date
Daniel Alves Rösel
f625dead76 Merge pull request #11 from velocitatem/claude/improve-docs-preview-94gyn
Add PDF preview and DOCX upload to version branches
2026-05-03 01:34:13 +04:00
Claude
97ee914b1b 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
2026-05-02 21:31:49 +00:00
a21f14ea87 hotfix: internal server rror feedback 2026-04-18 12:42:10 +02:00
16 changed files with 376 additions and 235 deletions

View File

@@ -57,17 +57,6 @@ AUTHENTIK_CLIENT_SECRET=
# Leave blank to use the built-in rule-based tailoring instead of Claude.
ANTHROPIC_API_KEY=
# ── Paperless-ngx integration (optional) ─────────────────────────────────────
# When enabled, published CVs are uploaded to your paperless-ngx instance and
# shared via paperless share links (with optional expiry). MinIO is still used
# for DOCX artifact storage; paperless handles the published PDF + sharing.
PAPERLESS_ENABLED=false
PAPERLESS_BASE_URL=http://localhost:8000
# API token — obtain via POST /api/token/ with your paperless credentials.
PAPERLESS_TOKEN=
# Comma-separated tag IDs to apply to uploaded CV documents (optional).
PAPERLESS_TAG_IDS=
# ── Demo mode ─────────────────────────────────────────────────────────────────
# Set to true to enable standalone demo mode in the webapp.
# Demo mode uses hardcoded dummy data — no backend or DB required.

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,6 +1,5 @@
from __future__ import annotations
import asyncio
import hashlib
from datetime import datetime, timezone
@@ -17,13 +16,11 @@ from app.schemas import (
PublicAssetLookupResponse,
PublicAssetResponse,
PublishRequest,
ShareLinkRequest,
)
from app.services.publication import publish_version
from app.services.storage import storage_client
from dlib.auth import AuthenticatedUser
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
from dlib.integrations.paperless import get_paperless_client
router = APIRouter(prefix="/public", tags=["public"])
@@ -51,18 +48,6 @@ async def _get_public_asset(session: AsyncSession, slug: str) -> PublicAsset:
return asset
async def _assert_owner(session: AsyncSession, asset: PublicAsset, owner_id: str) -> None:
if not asset.version_id:
raise HTTPException(status_code=403, detail="Not authorized")
stmt = (
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == asset.version_id, CvDocument.owner_id == owner_id)
)
if not (await session.execute(stmt)).scalars().one_or_none():
raise HTTPException(status_code=403, detail="Not authorized")
@router.post("/publish", response_model=PublicAssetResponse)
async def publish(
payload: PublishRequest,
@@ -75,40 +60,12 @@ async def publish(
version_id=payload.version_id,
submission_id=payload.submission_id,
slug=payload.slug,
expires_at=payload.expires_at,
)
if not asset:
raise HTTPException(status_code=404, detail="Version or submission not found")
return _response_from_asset(asset)
@router.post("/{slug}/share-links", response_model=PublicAssetResponse)
async def create_share_link(
slug: str,
payload: ShareLinkRequest,
session: AsyncSession = Depends(get_db),
user: AuthenticatedUser = Depends(get_current_user),
):
asset = await _get_public_asset(session, slug)
await _assert_owner(session, asset, user.sub)
if not asset.paperless_document_id:
raise HTTPException(status_code=409, detail="Asset not synced to paperless")
settings = get_settings()
client = get_paperless_client(settings)
if not client:
raise HTTPException(status_code=503, detail="Paperless integration not enabled")
_, share_url = await asyncio.to_thread(
client.create_share_link, asset.paperless_document_id, payload.expiration_date
)
asset.paperless_share_slug = share_url.split("/share/")[-1]
await session.commit()
await session.refresh(asset)
return _response_from_asset(asset)
@router.get("/{slug}/analytics", response_model=PublicAssetAnalyticsResponse)
async def get_analytics(
slug: str,
@@ -116,7 +73,17 @@ async def get_analytics(
user: AuthenticatedUser = Depends(get_current_user),
):
asset = await _get_public_asset(session, slug)
await _assert_owner(session, asset, user.sub)
if asset.version_id:
stmt = (
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == asset.version_id, CvDocument.owner_id == user.sub)
)
if not (await session.execute(stmt)).scalars().one_or_none():
raise HTTPException(status_code=403, detail="Not authorized")
else:
raise HTTPException(status_code=403, detail="Not authorized")
view_count = (
await session.execute(
@@ -170,11 +137,7 @@ async def get_public_asset(slug: str, request: Request, session: AsyncSession =
def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
settings = get_settings()
base = settings.public_base_url.rstrip("/")
paperless_share_url = (
f"{settings.paperless_base_url}/share/{asset.paperless_share_slug}"
if settings.paperless_base_url and asset.paperless_share_slug
else None
)
url = f"{base}/cv/{asset.slug}"
return PublicAssetResponse(
id=asset.id,
slug=asset.slug,
@@ -183,6 +146,5 @@ def _response_from_asset(asset: PublicAsset) -> PublicAssetResponse:
created_at=asset.created_at,
version_id=asset.version_id,
submission_id=asset.submission_id,
url=f"{base}/cv/{asset.slug}",
paperless_share_url=paperless_share_url,
url=url,
)

View File

@@ -47,11 +47,6 @@ class Settings(BaseSettings):
)
publish_domain: str = Field(default="cv.alves.world", alias="CV_PUBLIC_DOMAIN")
paperless_enabled: bool = Field(default=False, alias="PAPERLESS_ENABLED")
paperless_base_url: str | None = Field(default=None, alias="PAPERLESS_BASE_URL")
paperless_token: str | None = Field(default=None, alias="PAPERLESS_TOKEN")
paperless_tag_ids: list[int] = Field(default_factory=list, alias="PAPERLESS_TAG_IDS")
class Config:
env_file = ".env"
extra = "ignore"
@@ -72,20 +67,13 @@ class Settings(BaseSettings):
return [origin.strip() for origin in value.split(",") if origin.strip()]
return value
@field_validator("storage_endpoint_url", "paperless_base_url", "paperless_token", mode="before")
@field_validator("storage_endpoint_url", mode="before")
@classmethod
def _empty_str_to_none(cls, value):
def _empty_endpoint_to_none(cls, value):
if isinstance(value, str) and not value.strip():
return None
return value
@field_validator("paperless_tag_ids", mode="before")
@classmethod
def _parse_tag_ids(cls, value):
if isinstance(value, str):
return [int(v.strip()) for v in value.split(",") if v.strip()]
return value
@lru_cache(maxsize=1)
def get_settings() -> Settings:

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import enum
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, Integer, String, Text
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -148,8 +148,6 @@ class PublicAsset(Base, IdentifierMixin, TimestampMixin):
expires_at: Mapped[str | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
paperless_document_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
paperless_share_slug: Mapped[str | None] = mapped_column(String(160), nullable=True)
submission: Mapped[Submission | None] = relationship(
"Submission", back_populates="public_asset"

View File

@@ -9,7 +9,6 @@ from .cv import (
PublicAssetLookupResponse,
PublicAssetResponse,
PublishRequest,
ShareLinkRequest,
SubmissionCreateRequest,
SubmissionResponse,
SubmissionStatusUpdateRequest,
@@ -32,7 +31,6 @@ __all__ = [
"SuggestionResponse",
"SuggestionUpdateRequest",
"PublishRequest",
"ShareLinkRequest",
"PublicAssetResponse",
"PublicAssetLookupResponse",
"PublicAssetAnalyticsResponse",

View File

@@ -121,11 +121,6 @@ class PublishRequest(BaseModel):
version_id: str | None = None
submission_id: str | None = None
slug: str | None = None
expires_at: datetime | None = None
class ShareLinkRequest(BaseModel):
expiration_date: datetime | None = None
class PublicAssetResponse(BaseModel):
@@ -139,7 +134,6 @@ class PublicAssetResponse(BaseModel):
version_id: str | None = None
submission_id: str | None = None
url: str | None = None
paperless_share_url: str | None = None
class PublicAssetLookupResponse(BaseModel):

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import asyncio
import re
from datetime import datetime
from uuid import uuid4
@@ -8,11 +7,7 @@ from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.models import CvDocument, CvVersion, PublicAsset, Submission
from app.services.storage import storage_client
from dlib.cv import docx_bytes_to_pdf, generate_patched_docx
from dlib.integrations.paperless import get_paperless_client
async def publish_version(
@@ -22,7 +17,6 @@ async def publish_version(
version_id: str | None,
submission_id: str | None,
slug: str | None,
expires_at: datetime | None = None,
) -> PublicAsset | None:
target_version: CvVersion | None = None
target_submission: Submission | None = None
@@ -61,27 +55,11 @@ async def publish_version(
slug=resolved_slug,
artifact_key=target_version.artifact_docx_key,
is_public=True,
expires_at=expires_at,
expires_at=None,
)
session.add(asset)
await session.commit()
await session.refresh(asset)
settings = get_settings()
client = get_paperless_client(settings)
if client:
docx = storage_client.download_bytes(target_version.artifact_docx_key)
blocks = target_version.structured_blocks or []
pdf = docx_bytes_to_pdf(generate_patched_docx(docx, blocks))
doc_id = await asyncio.to_thread(
client.upload_document, pdf, resolved_slug, settings.paperless_tag_ids or []
)
_, share_url = await asyncio.to_thread(client.create_share_link, doc_id, expires_at)
asset.paperless_document_id = doc_id
asset.paperless_share_slug = share_url.split("/share/")[-1]
await session.commit()
await session.refresh(asset)
return asset

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(
@@ -78,7 +102,10 @@ async def create_branch(
stmt_refresh = (
select(CvVersion)
.where(CvVersion.id == new_version.id)
.options(selectinload(CvVersion.patches))
.options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
)
result = await session.execute(stmt_refresh)
return result.scalars().one()
@@ -95,7 +122,10 @@ async def append_patches_to_version(
select(CvVersion)
.join(CvVersion.document)
.where(CvVersion.id == version_id, CvDocument.owner_id == owner_id)
.options(selectinload(CvVersion.patches))
.options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
)
result = await session.execute(stmt)
version = result.scalars().one_or_none()
@@ -138,12 +168,64 @@ async def append_patches_to_version(
stmt_refresh = (
select(CvVersion)
.where(CvVersion.id == version_id)
.options(selectinload(CvVersion.patches))
.options(
selectinload(CvVersion.patches),
selectinload(CvVersion.public_assets),
)
)
result = await session.execute(stmt_refresh)
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 {
@@ -74,8 +76,14 @@ function UploadModal({ onClose, onDone }: { onClose: () => void; onDone: (doc: D
const submit = async () => {
if (!title.trim() || !file) { setError('Title and file required.'); return; }
setLoading(true); setError('');
try { onDone(await uploadDocument(title.trim(), desc.trim() || null, file)); }
catch (e: unknown) { setError(e instanceof Error ? e.message : 'Upload failed'); setLoading(false); }
try {
const doc = await uploadDocument(title.trim(), desc.trim() || null, file);
await Promise.resolve(onDone(doc));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Upload failed');
} finally {
setLoading(false);
}
};
return (
@@ -123,8 +131,14 @@ function BranchModal({
const submit = async () => {
if (!name.trim()) { setError('Branch name required.'); return; }
setLoading(true); setError('');
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); }
try {
const v = await createBranch(version.id, name.trim(), label.trim() || null, patches as Record<string, unknown>[]);
await Promise.resolve(onDone(v));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed');
} finally {
setLoading(false);
}
};
return (
@@ -170,8 +184,14 @@ function SubmissionModal({ version, onClose, onDone }: { version: Version; onClo
const submit = async () => {
if (!company.trim() || !role.trim()) { setError('Company and role required.'); return; }
setLoading(true); setError('');
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); }
try {
const s = await createSubmission(version.id, company.trim(), role.trim(), url.trim() || null, jd.trim() || null);
await Promise.resolve(onDone(s));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed');
} finally {
setLoading(false);
}
};
return (
@@ -211,8 +231,12 @@ function PublishModal({ version, onClose, onDone }: { version: Version; onClose:
setLoading(true); setError('');
try {
const asset = await publishVersion(version.id, null, slug.trim() || null);
onDone(asset);
} catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed'); setLoading(false); }
await Promise.resolve(onDone(asset));
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed');
} finally {
setLoading(false);
}
};
return (
@@ -554,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[]>([]);
@@ -575,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) {
@@ -695,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);
@@ -877,6 +938,7 @@ export default function Dashboard() {
selectedVersionId={selectedVersionId}
onSelect={selectVersion}
onDeleteVersion={handleDeleteVersion}
onCopyMarkdown={handleCopyBranchMarkdown}
/>
</div>
)}
@@ -929,6 +991,7 @@ export default function Dashboard() {
versions={selectedDoc.versions}
selectedVersionId={selectedVersionId}
onSelect={selectVersion}
onCopyMarkdown={handleCopyBranchMarkdown}
/>
</div>
@@ -989,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>
@@ -1085,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)}
@@ -1106,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 ?? []}
@@ -1134,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

@@ -80,7 +80,6 @@ export type PublicAsset = {
version_id?: string | null;
submission_id?: string | null;
created_at: string;
paperless_share_url?: string | null;
};
export type PublicAssetAnalytics = {
@@ -145,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,
@@ -230,23 +238,11 @@ export async function publishVersion(
versionId?: string | null,
submissionId?: string | null,
slug?: string | null,
expiresAt?: string | null,
): Promise<PublicAsset> {
return req<PublicAsset>('/api/v1/public/publish', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null, expires_at: expiresAt ?? null }),
});
}
export async function createShareLink(
slug: string,
expirationDate?: string | null,
): Promise<PublicAsset> {
return req<PublicAsset>(`/api/v1/public/${encodeURIComponent(slug)}/share-links`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ expiration_date: expirationDate ?? null }),
body: JSON.stringify({ version_id: versionId ?? null, submission_id: submissionId ?? null, slug: slug ?? null }),
});
}

View File

@@ -64,8 +64,6 @@ export interface PublicAsset {
isPublic: boolean;
expiresAt?: string;
viewCount: number;
url?: string | null;
paperlessShareUrl?: string | null;
}
export interface AISuggestion {

View File

@@ -1,88 +0,0 @@
from __future__ import annotations
import time
from datetime import datetime
from typing import TYPE_CHECKING
import httpx
if TYPE_CHECKING:
from app.core.config import Settings
class PaperlessClient:
def __init__(self, base_url: str, token: str) -> None:
self._base = base_url.rstrip("/")
self._headers = {"Authorization": f"Token {token}"}
def _get(self, path: str, **params) -> dict:
r = httpx.get(f"{self._base}{path}", headers=self._headers, params=params, timeout=30)
r.raise_for_status()
return r.json()
def _post(self, path: str, **kwargs) -> dict:
r = httpx.post(f"{self._base}{path}", headers=self._headers, timeout=30, **kwargs)
r.raise_for_status()
return r.json()
def _delete(self, path: str) -> None:
r = httpx.delete(f"{self._base}{path}", headers=self._headers, timeout=30)
r.raise_for_status()
def upload_document(self, pdf_bytes: bytes, title: str, tags: list[int] | None = None) -> int:
"""Upload PDF to paperless and return the created document_id (polls until task completes)."""
files = {"document": (f"{title}.pdf", pdf_bytes, "application/pdf")}
data: dict = {"title": title}
if tags:
data["tags"] = tags
resp = self._post("/api/documents/post_document/", files=files, data=data)
task_id = resp if isinstance(resp, str) else resp.get("task_id", resp)
return self._poll_task(str(task_id))
def _poll_task(self, task_id: str, max_wait: int = 60) -> int:
delay = 2
elapsed = 0
while elapsed < max_wait:
time.sleep(delay)
elapsed += delay
result = self._get("/api/tasks/", task_id=task_id)
tasks = result if isinstance(result, list) else result.get("results", [])
if not tasks:
delay = min(delay * 2, 10)
continue
task = tasks[0]
if task.get("status") == "SUCCESS":
return int(task["related_document"])
if task.get("status") in ("FAILURE", "REVOKED"):
raise RuntimeError(f"Paperless task {task_id} failed: {task.get('result')}")
delay = min(delay * 2, 10)
raise TimeoutError(f"Paperless task {task_id} did not complete within {max_wait}s")
def create_share_link(
self, document_id: int, expiration: datetime | None = None
) -> tuple[int, str]:
"""Create a share link for document_id. Returns (share_link_id, full share URL)."""
payload: dict = {"document": document_id}
if expiration:
payload["expiration_date"] = expiration.isoformat()
resp = self._post("/api/share_links/", json=payload)
slug = resp["slug"]
link_id = int(resp["id"])
return link_id, f"{self._base}/share/{slug}"
def get_share_links(self, document_id: int) -> list[dict]:
return self._get(f"/api/documents/{document_id}/share_links/").get("results", [])
def delete_share_link(self, share_link_id: int) -> None:
self._delete(f"/api/share_links/{share_link_id}/")
def delete_document(self, document_id: int) -> None:
self._delete(f"/api/documents/{document_id}/")
def get_paperless_client(settings: "Settings") -> PaperlessClient | None:
if not settings.paperless_enabled:
return None
if not settings.paperless_base_url or not settings.paperless_token:
return None
return PaperlessClient(settings.paperless_base_url, settings.paperless_token)