Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Link Revision Pages</title> | |
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"> | |
| <link rel="stylesheet" href="https://unpkg.com/@xyflow/react@12.8.5/dist/style.css"> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "react": "https://esm.sh/react@18.2.0", | |
| "react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime", | |
| "react/jsx-dev-runtime": "https://esm.sh/react@18.2.0/jsx-dev-runtime", | |
| "react-dom": "https://esm.sh/react-dom@18.2.0", | |
| "react-dom/client": "https://esm.sh/react-dom@18.2.0/client", | |
| "@xyflow/react": "https://esm.sh/@xyflow/react@12.8.5?external=react,react-dom,react-dom/client" | |
| } | |
| } | |
| </script> | |
| <style> | |
| html, body { | |
| height: 100%; | |
| overflow: hidden; | |
| background: #f5f6fa; | |
| } | |
| #app { | |
| height: 100%; | |
| } | |
| /* ── Shell ── */ | |
| .app-shell { | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| overflow: hidden; | |
| } | |
| .app-header { | |
| flex: 0 0 auto; | |
| z-index: 20; | |
| } | |
| .app-body { | |
| flex: 1 1 0; | |
| min-height: 0; | |
| display: grid; | |
| grid-template-columns: 260px 1fr 260px; | |
| gap: 0.75rem; | |
| padding: 0 0.75rem 0.75rem; | |
| overflow: hidden; | |
| } | |
| /* ── Side Panels ── */ | |
| .side-panel { | |
| display: flex; | |
| flex-direction: column; | |
| min-height: 0; | |
| overflow: hidden; | |
| } | |
| .side-panel .card { | |
| display: flex; | |
| flex-direction: column; | |
| flex: 1 1 0; | |
| min-height: 0; | |
| overflow: hidden; | |
| } | |
| .side-panel .card-header { | |
| flex: 0 0 auto; | |
| } | |
| .side-panel .card-body { | |
| flex: 1 1 0; | |
| min-height: 0; | |
| overflow: hidden; | |
| padding: 0; | |
| } | |
| .scroll-list { | |
| height: 100%; | |
| overflow-y: auto; | |
| overscroll-behavior-y: contain; | |
| -webkit-overflow-scrolling: touch; | |
| padding: 0.75rem; | |
| } | |
| /* ── Center Column ── */ | |
| .center-col { | |
| display: flex; | |
| flex-direction: column; | |
| min-height: 0; | |
| gap: 0.75rem; | |
| overflow: hidden; | |
| } | |
| .center-col .canvas-wrap { | |
| flex: 1 1 0; | |
| min-height: 0; | |
| overflow: hidden; | |
| } | |
| .canvas-wrap .card { | |
| height: 100%; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .canvas-wrap .card-body { | |
| flex: 1 1 0; | |
| min-height: 0; | |
| padding: 0; | |
| position: relative; | |
| } | |
| .canvas-stage { | |
| width: 100%; | |
| height: 100%; | |
| position: absolute; | |
| inset: 0; | |
| } | |
| /* ── Item Cards ── */ | |
| .q-item, .p-item { | |
| border: 1px solid var(--bs-border-color); | |
| border-radius: 0.5rem; | |
| padding: 0.625rem; | |
| background: #fff; | |
| cursor: pointer; | |
| transition: border-color .15s, box-shadow .15s; | |
| } | |
| .q-item:hover, .p-item:hover { | |
| border-color: var(--bs-primary); | |
| } | |
| .q-item.active { | |
| border-color: var(--bs-primary); | |
| box-shadow: 0 0 0 .2rem rgba(13,110,253,.15); | |
| } | |
| .p-item.active { | |
| border-color: var(--bs-success); | |
| box-shadow: 0 0 0 .2rem rgba(25,135,84,.15); | |
| } | |
| .q-item.linked { | |
| background: #f0f7ff; | |
| border-color: #86b7fe; | |
| } | |
| .p-item.on-canvas { | |
| background: #f0fdf4; | |
| border-color: #86efac; | |
| } | |
| .item-thumb { | |
| width: 100%; | |
| max-height: 120px; | |
| object-fit: contain; | |
| border-radius: 0.375rem; | |
| background: #f8f9fa; | |
| } | |
| /* ── Flow Nodes ── */ | |
| .node-card { | |
| width: 195px; | |
| border-radius: 0.5rem; | |
| border: 2px solid transparent; | |
| background: #fff; | |
| box-shadow: 0 2px 8px rgba(0,0,0,.08); | |
| overflow: hidden; | |
| font-size: .8125rem; | |
| } | |
| .node-card.node-selected { | |
| border-color: var(--bs-primary); | |
| box-shadow: 0 0 0 3px rgba(13,110,253,.18); | |
| } | |
| .node-header { | |
| padding: 0.375rem 0.625rem; | |
| font-weight: 700; | |
| font-size: .8125rem; | |
| } | |
| .question-node .node-header { background: #e7f1ff; color: #084298; } | |
| .page-node .node-header { background: #d1e7dd; color: #0f5132; } | |
| .node-body { | |
| padding: 0.5rem; | |
| } | |
| .node-thumb { | |
| width: 100%; | |
| max-height: 100px; | |
| object-fit: contain; | |
| border-radius: 0.375rem; | |
| background: #f8f9fa; | |
| } | |
| /* ── Upload Drop Zone ── */ | |
| .upload-zone { | |
| border: 2px dashed var(--bs-border-color); | |
| border-radius: 0.75rem; | |
| padding: 2.5rem 1.5rem; | |
| text-align: center; | |
| background: #fbfcfe; | |
| } | |
| /* ── Responsive ── */ | |
| @media (max-width: 1200px) { | |
| .app-body { grid-template-columns: 220px 1fr 220px; } | |
| } | |
| @media (max-width: 991px) { | |
| .app-body { grid-template-columns: 190px 1fr 190px; gap: 0.5rem; padding: 0 0.5rem 0.5rem; } | |
| .item-thumb { max-height: 90px; } | |
| } | |
| @media (max-width: 767px) { | |
| html, body { overflow: auto; } | |
| .app-shell { height: auto; min-height: 100vh; } | |
| .app-body { | |
| grid-template-columns: 1fr; | |
| grid-template-rows: auto; | |
| height: auto; | |
| overflow: visible; | |
| } | |
| .side-panel .card, | |
| .canvas-wrap .card { | |
| max-height: 60vh; | |
| } | |
| .center-col { min-height: 50vh; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"></div> | |
| <script> | |
| </script> | |
| <script type="module"> | |
| import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |
| import { createRoot } from 'react-dom/client'; | |
| import { | |
| ReactFlow, | |
| Background, | |
| Controls, | |
| MiniMap, | |
| Handle, | |
| Position, | |
| MarkerType, | |
| addEdge, | |
| useEdgesState, | |
| useNodesState | |
| } from '@xyflow/react'; | |
| const h = React.createElement; | |
| const sessionId = window.GRAPH_DATA.sessionId; | |
| const questions = window.GRAPH_DATA.questions || []; | |
| const pdfPages = window.GRAPH_DATA.pdfPages || []; | |
| const existingLinks = window.GRAPH_DATA.existingLinks || []; | |
| /* ═══════ Custom Nodes ═══════ */ | |
| function QuestionNode({ data, selected }) { | |
| return h('div', { className: `node-card question-node ${selected ? 'node-selected' : ''}` }, | |
| h('div', { className: 'node-header' }, `Q${data.questionNumber}`), | |
| h('div', { className: 'node-body' }, | |
| data.thumbUrl | |
| ? h('img', { src: data.thumbUrl, className: 'node-thumb mb-1', alt: '' }) | |
| : null, | |
| h('div', { className: 'text-muted small text-truncate' }, data.subject || '—'), | |
| h('div', { className: 'small fw-semibold text-truncate' }, data.chapter || '—'), | |
| h(Handle, { type: 'target', position: Position.Left, style: { width: 9, height: 9, background: '#198754' } }), | |
| h(Handle, { type: 'source', position: Position.Right, style: { width: 9, height: 9, background: '#0d6efd' } }) | |
| ) | |
| ); | |
| } | |
| function PageNode({ data, selected }) { | |
| return h('div', { className: `node-card page-node ${selected ? 'node-selected' : ''}` }, | |
| h('div', { className: 'node-header' }, `Page ${data.pageNumber}`), | |
| h('div', { className: 'node-body' }, | |
| data.imageUrl | |
| ? h('img', { src: data.imageUrl, className: 'node-thumb mb-1', alt: '' }) | |
| : null, | |
| h('div', { className: 'text-muted small' }, 'Revision page'), | |
| h(Handle, { type: 'target', position: Position.Left, style: { width: 9, height: 9, background: '#198754' } }), | |
| h(Handle, { type: 'source', position: Position.Right, style: { width: 9, height: 9, background: '#0d6efd' } }) | |
| ) | |
| ); | |
| } | |
| const nodeTypes = { questionNode: QuestionNode, pageNode: PageNode }; | |
| /* ═══════ Graph Builders ═══════ */ | |
| function buildQuestionNodes(list) { | |
| return list.map((q, i) => ({ | |
| id: `q-${q.image_id}`, | |
| type: 'questionNode', | |
| position: { x: 40, y: 40 + i * 230 }, | |
| draggable: true, | |
| data: { | |
| imageId: q.image_id, | |
| questionNumber: q.question_number || i + 1, | |
| subject: q.subject || '', | |
| chapter: q.chapter || '', | |
| thumbUrl: q.thumb_url || '' | |
| } | |
| })); | |
| } | |
| function buildInitialGraph(qList, pList, links) { | |
| const qNodes = buildQuestionNodes(qList); | |
| const linkedPageIds = [...new Set(links.map(l => l.page_id).filter(Boolean))]; | |
| const pageMap = new Map(pList.map(p => [p.image_id, p])); | |
| const pNodes = linkedPageIds.map((pid, i) => { | |
| const pg = pageMap.get(pid); | |
| if (!pg) return null; | |
| return { | |
| id: `p-${pg.image_id}`, | |
| type: 'pageNode', | |
| position: { x: 400 + (i % 2) * 250, y: 40 + Math.floor(i / 2) * 230 }, | |
| draggable: true, | |
| data: { pageImageId: pg.image_id, pageNumber: pg.page_number, imageUrl: pg.image_url } | |
| }; | |
| }).filter(Boolean); | |
| const edges = links.map(l => ({ | |
| id: `p-${l.page_id}__q-${l.question_image_id}`, | |
| source: `p-${l.page_id}`, | |
| target: `q-${l.question_image_id}`, | |
| type: 'smoothstep', | |
| markerEnd: { type: MarkerType.ArrowClosed, color: '#0d6efd' }, | |
| style: { stroke: '#0d6efd', strokeWidth: 2.5 } | |
| })).filter(e => pNodes.some(n => n.id === e.source) && qNodes.some(n => n.id === e.target)); | |
| return { nodes: qNodes.concat(pNodes), edges }; | |
| } | |
| /* ═══════ App Component ═══════ */ | |
| function App() { | |
| const initial = useMemo(() => buildInitialGraph(questions, pdfPages, existingLinks), []); | |
| const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes); | |
| const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges); | |
| const [selQ, setSelQ] = useState(null); | |
| const [selP, setSelP] = useState(null); | |
| const [directMode, setDirectMode] = useState(true); | |
| const [saving, setSaving] = useState(false); | |
| const [uploading, setUploading] = useState(false); | |
| const [toast, setToast] = useState(null); | |
| const fileRef = useRef(null); | |
| const addedPageIds = useMemo( | |
| () => new Set(nodes.filter(n => n.id.startsWith('p-')).map(n => n.data.pageImageId)), | |
| [nodes] | |
| ); | |
| const linkedQIds = useMemo(() => new Set(edges.map(e => e.target)), [edges]); | |
| useEffect(() => { | |
| setNodes(cur => cur.map(n => { | |
| const s = n.id === selQ || n.id === selP; | |
| return n.selected === s ? n : { ...n, selected: s }; | |
| })); | |
| }, [selQ, selP, setNodes]); | |
| const flash = useCallback((msg, type = 'info') => { | |
| setToast({ msg, type }); | |
| clearTimeout(flash._t); | |
| flash._t = setTimeout(() => setToast(null), 2800); | |
| }, []); | |
| const addPageNode = useCallback((page) => { | |
| const nid = `p-${page.image_id}`; | |
| if (addedPageIds.has(page.image_id)) { setSelP(nid); return nid; } | |
| const cnt = nodes.filter(n => n.id.startsWith('p-')).length; | |
| setNodes(cur => [ | |
| ...cur.map(n => ({ ...n, selected: false })), | |
| { | |
| id: nid, type: 'pageNode', draggable: true, | |
| position: { x: 400 + (cnt % 2) * 250, y: 40 + Math.floor(cnt / 2) * 230 }, | |
| data: { pageImageId: page.image_id, pageNumber: page.page_number, imageUrl: page.image_url } | |
| } | |
| ]); | |
| setSelP(nid); | |
| return nid; | |
| }, [addedPageIds, nodes, setNodes]); | |
| const removeSelPage = useCallback(() => { | |
| if (!selP) return; | |
| setNodes(c => c.filter(n => n.id !== selP)); | |
| setEdges(c => c.filter(e => e.source !== selP && e.target !== selP)); | |
| setSelP(null); | |
| }, [selP, setNodes, setEdges]); | |
| const linkPair = useCallback((pid, qid, msg) => { | |
| if (!pid || !qid) return; | |
| setSelP(pid); setSelQ(qid); | |
| setEdges(cur => { | |
| const eid = `${pid}__${qid}`; | |
| const cleaned = cur.filter(e => e.target !== qid); | |
| return [...cleaned, { | |
| id: eid, source: pid, target: qid, type: 'smoothstep', | |
| markerEnd: { type: MarkerType.ArrowClosed, color: '#0d6efd' }, | |
| style: { stroke: '#0d6efd', strokeWidth: 2.5 } | |
| }]; | |
| }); | |
| flash(msg || 'Link created.', 'success'); | |
| }, [setEdges, flash]); | |
| const createLink = useCallback(() => { | |
| if (!selQ || !selP) { flash('Select a question and a page first.', 'warning'); return; } | |
| linkPair(selP, selQ); | |
| }, [selQ, selP, linkPair, flash]); | |
| const onConnect = useCallback(p => { if (p.source && p.target) linkPair(p.source, p.target); }, [linkPair]); | |
| const clearLinks = useCallback(() => { setEdges([]); flash('All links cleared.', 'secondary'); }, [setEdges, flash]); | |
| const handleNodeClick = useCallback((_, n) => { | |
| if (n.id.startsWith('q-')) setSelQ(n.id); | |
| if (n.id.startsWith('p-')) setSelP(n.id); | |
| }, []); | |
| const handleSave = useCallback(async () => { | |
| const links = edges.map(e => { | |
| const src = nodes.find(n => n.id === e.source); | |
| const tgt = nodes.find(n => n.id === e.target); | |
| return { page_id: src?.data?.pageImageId, question_image_id: tgt?.data?.imageId, page_number: src?.data?.pageNumber }; | |
| }).filter(l => l.page_id && l.question_image_id); | |
| if (!links.length) { flash('No links to save.', 'warning'); return; } | |
| setSaving(true); | |
| try { | |
| const r = await fetch('/session/graph/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: sessionId, links }) }); | |
| const j = await r.json(); | |
| if (!r.ok || !j.success) throw new Error(j.error || 'Save failed'); | |
| flash(`Saved ${links.length} link(s).`, 'success'); | |
| setTimeout(() => { window.location.href = `/question_entry_v2/${sessionId}`; }, 900); | |
| } catch (err) { flash(err.message, 'danger'); } finally { setSaving(false); } | |
| }, [edges, nodes, flash]); | |
| const uploadPdf = useCallback(async (file) => { | |
| if (!file || file.type !== 'application/pdf') { flash('Select a PDF file.', 'warning'); return; } | |
| setUploading(true); | |
| try { | |
| const fd = new FormData(); fd.append('pdf', file); fd.append('session_id', sessionId); | |
| const r = await fetch('/session/graph/upload_pdf', { method: 'POST', body: fd }); | |
| const j = await r.json(); | |
| if (!r.ok || !j.success) throw new Error(j.error || 'Upload failed'); | |
| flash('Uploaded. Reloading…', 'success'); | |
| setTimeout(() => { window.location.href = j.redirect || `/session/${sessionId}/graph`; }, 700); | |
| } catch (err) { flash(err.message, 'danger'); } finally { setUploading(false); } | |
| }, [flash]); | |
| const onFile = useCallback(e => { const f = e.target.files?.[0]; if (f) uploadPdf(f); e.target.value = ''; }, [uploadPdf]); | |
| const tapQuestion = useCallback(qid => { | |
| if (directMode && selP) { linkPair(selP, qid, 'Linked!'); return; } | |
| setSelQ(qid); | |
| }, [directMode, selP, linkPair]); | |
| const tapPage = useCallback(page => { | |
| const nid = addPageNode(page); | |
| if (directMode && selQ) { setTimeout(() => linkPair(`p-${page.image_id}`, selQ, 'Linked!'), 0); return; } | |
| setSelP(`p-${page.image_id}`); | |
| }, [addPageNode, directMode, selQ, linkPair]); | |
| const selectPageDropdown = useCallback(val => { | |
| if (!val) { setSelP(null); return; } | |
| const raw = val.startsWith('p-') ? val.slice(2) : val; | |
| const pg = pdfPages.find(p => String(p.image_id) === String(raw)); | |
| if (pg) { addPageNode(pg); setSelP(`p-${pg.image_id}`); } | |
| }, [addPageNode]); | |
| /* ═══ Render ═══ */ | |
| return h('div', { className: 'app-shell' }, | |
| /* ── Header ── */ | |
| h('div', { className: 'app-header bg-white border-bottom px-3 py-2' }, | |
| h('div', { className: 'd-flex align-items-center justify-content-between flex-wrap gap-2' }, | |
| h('div', null, | |
| h('h1', { className: 'h5 mb-0 fw-bold' }, | |
| h('i', { className: 'bi bi-diagram-3 text-primary me-2' }), | |
| 'Link Revision Pages' | |
| ), | |
| h('small', { className: 'text-body-secondary' }, | |
| directMode ? 'Tap a page → then tap a question to link' : 'Select items or drag handles on canvas' | |
| ) | |
| ), | |
| h('div', { className: 'd-flex gap-2 align-items-center flex-wrap' }, | |
| h('div', { className: 'form-check form-switch mb-0 me-2' }, | |
| h('input', { className: 'form-check-input', type: 'checkbox', id: 'dm', checked: directMode, onChange: e => setDirectMode(e.target.checked) }), | |
| h('label', { className: 'form-check-label small', htmlFor: 'dm' }, 'Direct link') | |
| ), | |
| h('a', { href: `/question_entry_v2/${sessionId}`, className: 'btn btn-sm btn-outline-secondary' }, | |
| h('i', { className: 'bi bi-arrow-left me-1' }), 'Back' | |
| ), | |
| h('button', { className: 'btn btn-sm btn-outline-success', onClick: () => fileRef.current?.click(), disabled: uploading }, | |
| h('i', { className: `bi ${uploading ? 'bi-arrow-repeat' : 'bi-file-earmark-arrow-up'} me-1` }), | |
| uploading ? 'Uploading…' : 'Upload PDF' | |
| ), | |
| h('button', { className: 'btn btn-sm btn-primary', onClick: handleSave, disabled: saving || !edges.length }, | |
| h('i', { className: 'bi bi-check2-circle me-1' }), | |
| saving ? 'Saving…' : `Save (${edges.length})` | |
| ) | |
| ) | |
| ), | |
| h('input', { ref: fileRef, type: 'file', accept: '.pdf,application/pdf', className: 'd-none', onChange: onFile }), | |
| toast ? h('div', { className: `alert alert-${toast.type} alert-dismissible fade show py-2 mb-0 mt-2 small` }, | |
| toast.msg, | |
| h('button', { type: 'button', className: 'btn-close btn-close-sm', onClick: () => setToast(null) }) | |
| ) : null | |
| ), | |
| /* ── Body Grid ── */ | |
| h('div', { className: 'app-body pt-2' }, | |
| /* Left: Questions */ | |
| h('div', { className: 'side-panel' }, | |
| h('div', { className: 'card border-0 shadow-sm' }, | |
| h('div', { className: 'card-header bg-white border-bottom d-flex align-items-center justify-content-between py-2' }, | |
| h('span', { className: 'fw-semibold small' }, | |
| h('i', { className: 'bi bi-patch-question text-primary me-1' }), | |
| `Questions (${questions.length})` | |
| ), | |
| h('span', { className: 'badge text-bg-primary rounded-pill' }, linkedQIds.size) | |
| ), | |
| h('div', { className: 'card-body' }, | |
| h('div', { className: 'scroll-list d-grid gap-2' }, | |
| questions.map((q, i) => { | |
| const qid = `q-${q.image_id}`; | |
| const linked = linkedQIds.has(qid); | |
| const sel = selQ === qid; | |
| return h('div', { | |
| key: qid, | |
| className: `q-item ${sel ? 'active' : ''} ${linked ? 'linked' : ''}`, | |
| onClick: () => tapQuestion(qid), | |
| role: 'button' | |
| }, | |
| h('div', { className: 'd-flex justify-content-between align-items-center mb-1' }, | |
| h('span', { className: 'fw-bold small' }, `Q${q.question_number || i + 1}`), | |
| linked | |
| ? h('span', { className: 'badge text-bg-success rounded-pill' }, h('i', { className: 'bi bi-link-45deg' })) | |
| : null | |
| ), | |
| q.thumb_url ? h('img', { src: q.thumb_url, className: 'item-thumb mb-1', alt: '' }) : null, | |
| h('div', { className: 'text-body-secondary small text-truncate' }, q.subject || 'No subject'), | |
| h('div', { className: 'small text-truncate' }, q.chapter || '—') | |
| ); | |
| }) | |
| ) | |
| ) | |
| ) | |
| ), | |
| /* Center: Toolbar + Canvas */ | |
| h('div', { className: 'center-col' }, | |
| /* Pairing toolbar */ | |
| h('div', { className: 'card border-0 shadow-sm' }, | |
| h('div', { className: 'card-body p-2' }, | |
| h('div', { className: 'd-flex gap-2 align-items-center flex-wrap' }, | |
| h('select', { | |
| className: 'form-select form-select-sm', style: { maxWidth: 160 }, | |
| value: selP || '', onChange: e => selectPageDropdown(e.target.value || null) | |
| }, | |
| h('option', { value: '' }, '— Page —'), | |
| pdfPages.map(p => h('option', { key: p.image_id, value: `p-${p.image_id}` }, `Page ${p.page_number}`)) | |
| ), | |
| h('i', { className: 'bi bi-arrow-right text-muted' }), | |
| h('select', { | |
| className: 'form-select form-select-sm', style: { maxWidth: 160 }, | |
| value: selQ || '', onChange: e => setSelQ(e.target.value || null) | |
| }, | |
| h('option', { value: '' }, '— Question —'), | |
| questions.map((q, i) => h('option', { key: q.image_id, value: `q-${q.image_id}` }, `Q${q.question_number || i + 1}`)) | |
| ), | |
| h('button', { className: 'btn btn-sm btn-primary', onClick: createLink }, | |
| h('i', { className: 'bi bi-link-45deg me-1' }), 'Link' | |
| ), | |
| h('div', { className: 'vr d-none d-md-block' }), | |
| selP ? h('button', { className: 'btn btn-sm btn-outline-danger', onClick: removeSelPage }, | |
| h('i', { className: 'bi bi-trash3 me-1' }), 'Remove' | |
| ) : null, | |
| edges.length ? h('button', { className: 'btn btn-sm btn-outline-secondary', onClick: clearLinks }, | |
| h('i', { className: 'bi bi-x-lg me-1' }), 'Clear all' | |
| ) : null, | |
| edges.length ? h('span', { className: 'badge text-bg-light ms-auto' }, `${edges.length} link${edges.length > 1 ? 's' : ''}`) : null | |
| ) | |
| ) | |
| ), | |
| /* Canvas */ | |
| h('div', { className: 'canvas-wrap' }, | |
| h('div', { className: 'card border-0 shadow-sm' }, | |
| h('div', { className: 'card-body' }, | |
| pdfPages.length === 0 | |
| ? h('div', { className: 'd-flex align-items-center justify-content-center h-100 p-4' }, | |
| h('div', { className: 'upload-zone' }, | |
| h('i', { className: 'bi bi-file-earmark-pdf display-4 text-danger d-block mb-3' }), | |
| h('h5', null, 'Upload a Revision PDF'), | |
| h('p', { className: 'text-body-secondary mb-3 small' }, 'Pages will appear on the right panel for linking.'), | |
| h('button', { className: 'btn btn-primary btn-sm', onClick: () => fileRef.current?.click(), disabled: uploading }, | |
| uploading ? 'Uploading…' : 'Choose PDF' | |
| ) | |
| ) | |
| ) | |
| : h('div', { className: 'canvas-stage' }, | |
| h(ReactFlow, { | |
| nodes, edges, nodeTypes, | |
| onNodesChange, onEdgesChange, onConnect, | |
| onNodeClick: handleNodeClick, | |
| fitView: true, | |
| fitViewOptions: { padding: 0.15 }, | |
| proOptions: { hideAttribution: true }, | |
| defaultEdgeOptions: { | |
| type: 'smoothstep', | |
| markerEnd: { type: MarkerType.ArrowClosed, color: '#0d6efd' }, | |
| style: { stroke: '#0d6efd', strokeWidth: 2.5 } | |
| } | |
| }, | |
| h(Background, { gap: 20, size: 1, color: '#dee2e6' }), | |
| h(Controls, { position: 'bottom-left', showInteractive: false }), | |
| h(MiniMap, { | |
| pannable: true, zoomable: true, position: 'bottom-right', | |
| nodeColor: n => n.id.startsWith('q-') ? '#0d6efd' : '#198754', | |
| style: { border: '1px solid #dee2e6', borderRadius: 6 } | |
| }) | |
| ) | |
| ) | |
| ) | |
| ) | |
| ) | |
| ), | |
| /* Right: PDF Pages */ | |
| h('div', { className: 'side-panel' }, | |
| h('div', { className: 'card border-0 shadow-sm' }, | |
| h('div', { className: 'card-header bg-white border-bottom d-flex align-items-center justify-content-between py-2' }, | |
| h('span', { className: 'fw-semibold small' }, | |
| h('i', { className: 'bi bi-file-earmark-pdf text-success me-1' }), | |
| `Pages (${pdfPages.length})` | |
| ), | |
| h('button', { className: 'btn btn-sm btn-outline-success py-0 px-2', onClick: () => fileRef.current?.click(), disabled: uploading }, | |
| h('i', { className: 'bi bi-arrow-repeat' }) | |
| ) | |
| ), | |
| h('div', { className: 'card-body' }, | |
| h('div', { className: 'scroll-list d-grid gap-2' }, | |
| pdfPages.length === 0 | |
| ? h('div', { className: 'text-center text-body-secondary py-4 small' }, | |
| h('i', { className: 'bi bi-cloud-upload d-block fs-3 mb-2' }), | |
| 'No PDF uploaded yet' | |
| ) | |
| : pdfPages.map(page => { | |
| const nid = `p-${page.image_id}`; | |
| const sel = selP === nid; | |
| const added = addedPageIds.has(page.image_id); | |
| return h('div', { key: nid, className: `p-item ${sel ? 'active' : ''} ${added ? 'on-canvas' : ''}` }, | |
| h('img', { src: page.image_url, className: 'item-thumb mb-2', alt: '' }), | |
| h('div', { className: 'd-flex justify-content-between align-items-center mb-2' }, | |
| h('span', { className: 'fw-semibold small' }, `Page ${page.page_number}`), | |
| added ? h('span', { className: 'badge text-bg-success rounded-pill small' }, 'canvas') : null | |
| ), | |
| h('div', { className: 'd-grid gap-1' }, | |
| h('button', { | |
| className: `btn btn-sm ${directMode ? 'btn-success' : 'btn-outline-success'}`, | |
| onClick: () => tapPage(page) | |
| }, | |
| h('i', { className: `bi ${directMode ? 'bi-link-45deg' : 'bi-plus-circle'} me-1` }), | |
| directMode ? 'Tap to link' : (added ? 'Select' : 'Add to canvas') | |
| ) | |
| ) | |
| ); | |
| }) | |
| ) | |
| ) | |
| ) | |
| ) | |
| ) | |
| ); | |
| } | |
| createRoot(document.getElementById('app')).render(h(App)); | |
| </script> | |
| </body> | |
| </html> | |