Report-Generator / templates /knowledge_graph.html
root
Working CHanges to revesion ; add preact support
e8a57cb
{#
Knowledge Graph Editor - React Flow based node editor
For linking PDF pages to questions with revision notes
#}
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Knowledge Graph - DocuPDF</title>
<!-- Bootstrap -->
<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">
<!-- React Flow -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reactflow@11.10.1/dist/style.css" />
<style>
:root {
--bg-dark: #212529;
--bg-card: #2b3035;
--bg-elevated: #343a40;
--border-subtle: #495057;
--text-primary: #e9ecef;
--text-muted: #adb5bd;
--accent-primary: #0d6efd;
--accent-info: #0dcaf0;
--accent-success: #198754;
--accent-warning: #ffc107;
--accent-danger: #dc3545;
}
body {
margin: 0;
padding: 0;
overflow: hidden;
background: var(--bg-dark);
}
.graph-container {
display: flex;
flex-direction: column;
height: 100vh;
}
.graph-toolbar {
background: var(--bg-card);
border-bottom: 1px solid var(--border-subtle);
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 10;
}
.graph-canvas {
flex: 1;
position: relative;
}
.react-flow__node {
font-size: 12px;
transition: all 0.2s ease;
}
.react-flow__node:hover {
transform: scale(1.02);
}
/* Custom Node Styles */
.node-question {
background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);
border: 2px solid #6ea8fe;
border-radius: 8px;
padding: 12px;
min-width: 200px;
color: white;
box-shadow: 0 4px 12px rgba(13, 110, 253, 0.3);
}
.node-note {
background: linear-gradient(135deg, #198754 0%, #146c43 100%);
border: 2px solid #75b798;
border-radius: 8px;
padding: 12px;
min-width: 180px;
color: white;
box-shadow: 0 4px 12px rgba(25, 135, 84, 0.3);
}
.node-page {
background: linear-gradient(135deg, #6f42c1 0%, #59359a 100%);
border: 2px solid #b19cd9;
border-radius: 8px;
padding: 12px;
min-width: 150px;
color: white;
box-shadow: 0 4px 12px rgba(111, 66, 193, 0.3);
}
.node-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-weight: 600;
font-size: 13px;
}
.node-content {
font-size: 11px;
opacity: 0.9;
line-height: 1.4;
}
.node-actions {
display: flex;
gap: 4px;
margin-top: 8px;
justify-content: flex-end;
}
.node-btn {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 10px;
transition: all 0.15s ease;
}
.node-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.node-btn.delete:hover {
background: rgba(220, 53, 69, 0.8);
}
/* Side Panel */
.side-panel {
position: absolute;
top: 0;
right: 0;
width: 350px;
height: 100%;
background: var(--bg-card);
border-left: 1px solid var(--border-subtle);
z-index: 5;
transform: translateX(100%);
transition: transform 0.3s ease;
overflow-y: auto;
}
.side-panel.open {
transform: translateX(0);
}
.side-panel-header {
padding: 1rem;
border-bottom: 1px solid var(--border-subtle);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-elevated);
}
.side-panel-body {
padding: 1rem;
}
/* Mini Map Customization */
.react-flow__minimap {
background: var(--bg-elevated) !important;
}
.react-flow__minimap-mask {
fill: rgba(0, 0, 0, 0.3) !important;
}
/* Controls */
.react-flow__controls {
background: var(--bg-card) !important;
border: 1px solid var(--border-subtle) !important;
}
.react-flow__controls-button {
background: var(--bg-elevated) !important;
border-color: var(--border-subtle) !important;
}
.react-flow__controls-button:hover {
background: var(--bg-hover) !important;
}
/* Upload Overlay */
.upload-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(33, 37, 41, 0.95);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.upload-card {
background: var(--bg-card);
border: 2px dashed var(--border-subtle);
border-radius: 16px;
padding: 3rem;
text-align: center;
max-width: 500px;
}
.upload-card.drag-over {
border-color: var(--accent-primary);
background: rgba(13, 110, 253, 0.1);
}
/* Connection Line */
.react-flow__edge-path {
stroke: var(--accent-info);
stroke-width: 2px;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: var(--accent-warning);
stroke-width: 3px;
}
/* Loading Spinner */
.loading-spinner {
display: inline-block;
width: 2rem;
height: 2rem;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="graph-app"></div>
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<!-- React Flow -->
<script src="https://cdn.jsdelivr.net/npm/reactflow@11.10.1/dist/index.js"></script>
<!-- Babel for JSX -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useState, useCallback, useMemo, useRef, useEffect } = React;
const ReactFlow = window.ReactFlow;
const addEdge = window.ReactFlow.addEdge;
const useNodesState = window.ReactFlow.useNodesState;
const useEdgesState = window.ReactFlow.useEdgesState;
const useReactFlow = window.ReactFlow.useReactFlow;
const Controls = window.ReactFlow.Controls;
const MiniMap = window.ReactFlow.MiniMap;
const Background = window.ReactFlow.Background;
// === Custom Node Components ===
const QuestionNode = ({ id, data }) => {
const { setNodes, setEdges } = useReactFlow();
const handleDelete = () => {
setNodes(nodes => nodes.filter(n => n.id !== id));
setEdges(edges => edges.filter(e => e.source !== id && e.target !== id));
};
return (
<div className="node-question">
<div className="node-header">
<i className="bi bi-question-circle"></i>
<span>Question {data.number}</span>
</div>
<div className="node-content">
{data.subject && <div><i className="bi bi-book"></i> {data.subject}</div>}
{data.chapter && <div><i className="bi bi-folder"></i> {data.chapter}</div>}
</div>
<div className="node-actions">
<button className="node-btn" onClick={() => alert('Edit question: ' + data.number)}>
<i className="bi bi-pencil"></i>
</button>
<button className="node-btn delete" onClick={handleDelete}>
<i className="bi bi-trash"></i>
</button>
</div>
</div>
);
};
const NoteNode = ({ id, data }) => {
const { setNodes, setEdges } = useReactFlow();
const handleDelete = () => {
setNodes(nodes => nodes.filter(n => n.id !== id));
setEdges(edges => edges.filter(e => e.source !== id && e.target !== id));
};
return (
<div className="node-note">
<div className="node-header">
<i className="bi bi-sticky"></i>
<span>Revision Note</span>
</div>
<div className="node-content">
{data.content && <div>{data.content.substring(0, 80)}...</div>}
{data.tags && <div><i className="bi bi-tags"></i> {data.tags.join(', ')}</div>}
</div>
<div className="node-actions">
<button className="node-btn" onClick={() => alert('Edit note')}>
<i className="bi bi-pencil"></i>
</button>
<button className="node-btn delete" onClick={handleDelete}>
<i className="bi bi-trash"></i>
</button>
</div>
</div>
);
};
const PageNode = ({ id, data }) => {
const { setNodes, setEdges } = useReactFlow();
const handleDelete = () => {
setNodes(nodes => nodes.filter(n => n.id !== id));
setEdges(edges => edges.filter(e => e.source !== id && e.target !== id));
};
const handleView = () => {
window.open(data.imageUrl, '_blank');
};
return (
<div className="node-page">
<div className="node-header">
<i className="bi bi-file-earmark-image"></i>
<span>Page {data.pageNumber}</span>
</div>
<div className="node-content">
{data.imageUrl && (
<img src={data.imageUrl} alt={`Page ${data.pageNumber}`} style={{width: '100%', borderRadius: '4px', marginTop: '8px'}} />
)}
</div>
<div className="node-actions">
<button className="node-btn" onClick={handleView}>
<i className="bi bi-eye"></i>
</button>
<button className="node-btn delete" onClick={handleDelete}>
<i className="bi bi-trash"></i>
</button>
</div>
</div>
);
};
// === Node Types Registry ===
const nodeTypes = {
question: QuestionNode,
note: NoteNode,
page: PageNode
};
// === Main Graph App ===
const GraphApp = () => {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [selectedNode, setSelectedNode] = useState(null);
const [showPanel, setShowPanel] = useState(false);
const [pdfFile, setPdfFile] = useState(null);
const [pdfPages, setPdfPages] = useState([]);
const [sessionId, setSessionId] = useState(null);
const reactFlowWrapper = useRef(null);
// Initial nodes
useEffect(() => {
// Load initial data if available
const initialNodes = [
{
id: 'note-1',
type: 'note',
position: { x: 100, y: 100 },
data: {
content: 'Welcome to Knowledge Graph! Start by uploading a PDF.',
tags: ['welcome']
}
}
];
setNodes(initialNodes);
}, []);
// Handle node click
const onNodeClick = useCallback((event, node) => {
setSelectedNode(node);
setShowPanel(true);
}, []);
// Handle edge connect
const onConnect = useCallback((params) => {
setEdges(eds => addEdge({
...params,
type: 'smoothstep',
animated: true,
style: { stroke: '#0dcaf0', strokeWidth: 2 }
}, eds));
}, [setEdges]);
// Add question node
const addQuestionNode = (questionData) => {
const newNode = {
id: `question-${Date.now()}`,
type: 'question',
position: {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100
},
data: {
number: questionData.number || '?',
subject: questionData.subject || '',
chapter: questionData.chapter || ''
}
};
setNodes(nds => [...nds, newNode]);
};
// Add note node
const addNoteNode = (noteData) => {
const newNode = {
id: `note-${Date.now()}`,
type: 'note',
position: {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100
},
data: {
content: noteData.content || 'New note',
tags: noteData.tags || []
}
};
setNodes(nds => [...nds, newNode]);
};
// Add page node
const addPageNode = (pageData) => {
const newNode = {
id: `page-${Date.now()}`,
type: 'page',
position: {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100
},
data: {
pageNumber: pageData.pageNumber,
imageUrl: pageData.imageUrl
}
};
setNodes(nds => [...nds, newNode]);
};
// Handle PDF upload
const handlePdfUpload = async (file) => {
const formData = new FormData();
formData.append('pdf', file);
try {
const response = await fetch('/knowledge_graph/upload_pdf', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
setSessionId(result.session_id);
setPdfFile(file);
// Create page nodes for each page
const pageNodes = result.pages.map((page, index) => ({
id: `page-${index}`,
type: 'page',
position: { x: 50 + (index % 5) * 200, y: 50 + Math.floor(index / 5) * 250 },
data: {
pageNumber: index + 1,
imageUrl: page.image_url
}
}));
setPdfPages(result.pages);
setNodes(pageNodes);
}
} catch (error) {
console.error('Error uploading PDF:', error);
alert('Failed to upload PDF');
}
};
// Save graph
const saveGraph = async () => {
try {
const response = await fetch('/knowledge_graph/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
session_id: sessionId,
nodes: nodes,
edges: edges
})
});
const result = await response.json();
if (result.success) {
alert('Graph saved successfully!');
}
} catch (error) {
console.error('Error saving graph:', error);
alert('Failed to save graph');
}
};
// Export graph as image
const exportGraph = async () => {
alert('Export functionality coming soon!');
};
// If no PDF uploaded, show upload overlay
if (!sessionId) {
return (
<div className="graph-container">
<div className="upload-overlay">
<div className="upload-card"
onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add('drag-over'); }}
onDragLeave={(e) => { e.currentTarget.classList.remove('drag-over'); }}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file && file.type === 'application/pdf') {
handlePdfUpload(file);
}
}}
>
<i className="bi bi-file-earmark-pdf display-1 text-danger mb-3"></i>
<h3>Upload PDF to Start</h3>
<p className="text-muted mb-4">
Drag & drop a PDF file here, or click to browse
</p>
<input
type="file"
accept=".pdf"
onChange={(e) => handlePdfUpload(e.target.files[0])}
style={{ display: 'none' }}
id="pdf-upload-input"
/>
<button
className="btn btn-primary btn-lg"
onClick={() => document.getElementById('pdf-upload-input').click()}
>
<i className="bi bi-folder2-open me-2"></i>Choose PDF
</button>
</div>
</div>
</div>
);
}
return (
<div className="graph-container" ref={reactFlowWrapper}>
{/* Toolbar */}
<div className="graph-toolbar">
<div className="d-flex align-items-center gap-3">
<h5 className="mb-0">
<i className="bi bi-diagram-3 me-2"></i>
Knowledge Graph
</h5>
<span className="badge bg-secondary">{pdfFile?.name}</span>
</div>
<div className="d-flex align-items-center gap-2">
<button className="btn btn-outline-primary btn-sm" onClick={() => addQuestionNode({})}>
<i className="bi bi-question-circle me-1"></i>Add Question
</button>
<button className="btn btn-outline-success btn-sm" onClick={() => addNoteNode({})}>
<i className="bi bi-sticky me-1"></i>Add Note
</button>
<button className="btn btn-outline-info btn-sm" onClick={saveGraph}>
<i className="bi bi-save me-1"></i>Save
</button>
<button className="btn btn-outline-secondary btn-sm" onClick={exportGraph}>
<i className="bi bi-download me-1"></i>Export
</button>
</div>
</div>
{/* Graph Canvas */}
<div className="graph-canvas">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
fitView
snapToGrid={true}
snapGrid={[15, 15]}
>
<Controls />
<MiniMap
nodeStrokeColor={(n) => {
if (n.type === 'question') return '#0d6efd';
if (n.type === 'note') return '#198754';
if (n.type === 'page') return '#6f42c1';
return '#666';
}}
nodeColor={(n) => {
if (n.type === 'question') return '#0d6efd';
if (n.type === 'note') return '#198754';
if (n.type === 'page') return '#6f42c1';
return '#666';
}}
bgColor="#2b3035"
/>
<Background color="#495057" gap={20} />
</ReactFlow>
</div>
{/* Side Panel */}
<div className={`side-panel ${showPanel ? 'open' : ''}`}>
<div className="side-panel-header">
<h6 className="mb-0">
<i className="bi bi-info-circle me-2"></i>
Node Details
</h6>
<button
className="btn btn-close btn-close-white"
onClick={() => setShowPanel(false)}
></button>
</div>
<div className="side-panel-body">
{selectedNode ? (
<div>
<div className="mb-3">
<strong>Type:</strong> {selectedNode.type}
</div>
<div className="mb-3">
<strong>ID:</strong> {selectedNode.id}
</div>
<div className="mb-3">
<strong>Position:</strong> X: {selectedNode.position.x}, Y: {selectedNode.position.y}
</div>
<div className="mb-3">
<strong>Data:</strong>
<pre className="bg-dark p-2 rounded mt-2">
{JSON.stringify(selectedNode.data, null, 2)}
</pre>
</div>
<hr />
<button
className="btn btn-danger btn-sm w-100"
onClick={() => {
setNodes(nodes.filter(n => n.id !== selectedNode.id));
setShowPanel(false);
}}
>
<i className="bi bi-trash me-2"></i>Delete Node
</button>
</div>
) : (
<p className="text-muted">Select a node to view details</p>
)}
</div>
</div>
</div>
);
};
// Render the app
const root = ReactDOM.createRoot(document.getElementById('graph-app'));
root.render(<GraphApp />);
</script>
</body>
</html>