mirror of
https://github.com/velocitatem/cvfs.git
synced 2026-05-31 08:43:37 +00:00
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:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
64
apps/webapp/src/components/cv/PDFPreview.tsx
Normal file
64
apps/webapp/src/components/cv/PDFPreview.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user