| <!DOCTYPE html> |
| <html lang="en"> |
|
|
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>{{ title }}</title> |
|
|
| |
| <link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" /> |
|
|
| <style> |
| * { |
| margin: 0; |
| padding: 0; |
| box-sizing: border-box; |
| } |
| |
| body { |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| background: #0d1117; |
| color: #c9d1d9; |
| height: 100vh; |
| overflow: hidden; |
| } |
| |
| .container { |
| display: flex; |
| height: 100vh; |
| flex-direction: column; |
| } |
| |
| .header { |
| background: #161b22; |
| padding: 22px 34px; |
| border-bottom: 1px solid #30363d; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| } |
| |
| .header-left { |
| display: flex; |
| align-items: center; |
| gap: 22px; |
| } |
| |
| .logo { |
| height: 45px; |
| width: auto; |
| } |
| |
| .header-title { |
| font-size: 28px; |
| color: #f0f6fc; |
| font-weight: 600; |
| letter-spacing: 0.7px; |
| } |
| |
| .header-subtitle { |
| font-size: 19px; |
| color: #8b949e; |
| margin-top: 3px; |
| } |
| |
| .main-content { |
| display: flex; |
| flex: 1; |
| overflow: hidden; |
| } |
| |
| .sidebar { |
| width: 390px; |
| background: #0d1117; |
| border-right: 1px solid #30363d; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .sidebar-header { |
| padding: 17px 22px; |
| background: #161b22; |
| font-size: 17px; |
| text-transform: uppercase; |
| letter-spacing: 1.4px; |
| color: #8b949e; |
| font-weight: 700; |
| border-bottom: 1px solid #30363d; |
| } |
| |
| .file-tree { |
| flex: 1; |
| overflow-y: auto; |
| padding: 11px 0; |
| } |
| |
| .file-item { |
| padding: 11px 17px 11px 34px; |
| cursor: pointer; |
| font-size: 18px; |
| display: flex; |
| align-items: center; |
| gap: 11px; |
| transition: background 0.15s; |
| } |
| |
| .file-item:hover { |
| background: #2a2d2e; |
| } |
| |
| .file-item.active { |
| background: #37373d; |
| color: #58a6ff; |
| } |
| |
| .file-icon { |
| width: 22px; |
| height: 22px; |
| font-size: 20px; |
| display: flex; |
| align-items: center; |
| } |
| |
| .editor-section { |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .tabs { |
| background: #161b22; |
| border-bottom: 1px solid #30363d; |
| display: flex; |
| padding: 0 11px; |
| overflow-x: auto; |
| } |
| |
| .tab { |
| padding: 14px 22px; |
| font-size: 18px; |
| cursor: pointer; |
| border-right: 1px solid #30363d; |
| background: #161b22; |
| color: #8b949e; |
| transition: all 0.15s ease; |
| white-space: nowrap; |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .tab:hover { |
| color: #c9d1d9; |
| } |
| |
| .tab.active { |
| background: #0d1117; |
| color: #58a6ff; |
| border-bottom: 3px solid #58a6ff; |
| } |
| |
| .editor-container { |
| flex: 1; |
| overflow: hidden; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .code-editor { |
| flex: 1; |
| background: #0d1117; |
| position: relative; |
| overflow: hidden; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .code-content { |
| flex: 1; |
| overflow: auto; |
| padding: 28px; |
| font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace; |
| font-size: 21px; |
| line-height: 1.7; |
| } |
| |
| .code-content pre { |
| margin: 0; |
| padding: 0; |
| background: transparent; |
| border: none; |
| } |
| |
| .code-content code { |
| color: #c9d1d9; |
| text-shadow: none; |
| } |
| |
| .line { |
| display: flex; |
| min-height: 31px; |
| } |
| |
| .line-number { |
| width: 70px; |
| text-align: right; |
| padding-right: 22px; |
| color: #6e7681; |
| user-select: none; |
| flex-shrink: 0; |
| } |
| |
| .line-content { |
| flex: 1; |
| white-space: pre; |
| padding-right: 22px; |
| color: #c9d1d9; |
| |
| } |
| |
| |
| .line-content pre { |
| color: #c9d1d9; |
| margin: 0; |
| } |
| |
| .terminal { |
| height: 250px; |
| background: #0d1117; |
| border-top: 1px solid #30363d; |
| display: flex; |
| flex-direction: column; |
| flex-shrink: 0; |
| } |
| |
| .terminal-header { |
| padding: 11px 22px; |
| background: #161b22; |
| font-size: 17px; |
| display: flex; |
| align-items: center; |
| gap: 17px; |
| border-bottom: 1px solid #30363d; |
| } |
| |
| .terminal-tab { |
| padding: 6px 17px; |
| cursor: pointer; |
| border-radius: 6px; |
| } |
| |
| .terminal-tab.active { |
| background: #1e1e1e; |
| color: #58a6ff; |
| } |
| |
| .terminal-content { |
| flex: 1; |
| overflow-y: auto; |
| padding: 17px 28px; |
| font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace; |
| font-size: 18px; |
| line-height: 1.7; |
| } |
| |
| .terminal-line { |
| margin-bottom: 6px; |
| } |
| |
| .terminal-prompt { |
| color: #4ec9b0; |
| } |
| |
| .terminal-success { |
| color: #4ec9b0; |
| } |
| |
| .terminal-info { |
| color: #4fc3f7; |
| } |
| |
| .terminal-error { |
| color: #f48771; |
| } |
| |
| .terminal-output { |
| color: #dcdcaa; |
| padding-left: 0; |
| } |
| |
| |
| .code-content::-webkit-scrollbar, |
| .terminal-content::-webkit-scrollbar, |
| .file-tree::-webkit-scrollbar { |
| width: 14px; |
| } |
| |
| .code-content::-webkit-scrollbar-track, |
| .terminal-content::-webkit-scrollbar-track, |
| .file-tree::-webkit-scrollbar-track { |
| background: #0d1117; |
| } |
| |
| .code-content::-webkit-scrollbar-thumb, |
| .terminal-content::-webkit-scrollbar-thumb, |
| .file-tree::-webkit-scrollbar-thumb { |
| background: #30363d; |
| border-radius: 7px; |
| } |
| |
| .code-content::-webkit-scrollbar-thumb:hover, |
| .terminal-content::-webkit-scrollbar-thumb:hover, |
| .file-tree::-webkit-scrollbar-thumb:hover { |
| background: #484f58; |
| } |
| |
| .typing-cursor { |
| display: inline-block; |
| color: #58a6ff; |
| font-weight: bold; |
| animation: blink 0.7s infinite; |
| font-size: 1.2em; |
| line-height: 1; |
| margin-left: -1px; |
| vertical-align: text-bottom; |
| z-index: 10; |
| } |
| |
| @keyframes blink { |
| |
| 0%, |
| 50% { |
| opacity: 1; |
| } |
| |
| 51%, |
| 100% { |
| opacity: 0; |
| } |
| } |
| </style> |
| </head> |
|
|
| <body> |
| <div class="container"> |
| <div class="header"> |
| <div class="header-left"> |
| {% if logo_path %} |
| <img src="{{ logo_path }}" alt="Logo" class="logo"> |
| {% endif %} |
| <div> |
| <div class="header-title">{{ title }}</div> |
| <div class="header-subtitle">{{ subtitle }}</div> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="main-content"> |
| <div class="sidebar"> |
| <div class="sidebar-header">π Project Files</div> |
| <div class="file-tree" id="fileTree"></div> |
| </div> |
|
|
| <div class="editor-section"> |
| <div class="tabs" id="tabsContainer"></div> |
| <div class="editor-container"> |
| <div class="code-editor"> |
| <div class="code-content" id="codeContent"></div> |
| </div> |
| </div> |
|
|
| <div class="terminal"> |
| <div class="terminal-header"> |
| <div class="terminal-tab active">π» Terminal Output</div> |
| </div> |
| <div class="terminal-content" id="terminalContent"> |
| <div class="terminal-line terminal-prompt">$</div> |
| </div> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-java.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-csharp.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-go.min.js"></script> |
|
|
| |
| <script> |
| |
| |
| |
| |
| class TimelineAnimator { |
| constructor(manifest, codeFiles) { |
| this.manifest = manifest; |
| this.codeFiles = codeFiles; |
| this.globalAudio = null; |
| this.animationFrameId = null; |
| this.isAnimating = false; |
| this.currentFile = null; |
| } |
| |
| async start() { |
| if (!this.manifest || !this.manifest.steps || this.manifest.steps.length === 0) { |
| console.error("No manifest steps found"); |
| return; |
| } |
| |
| console.log("Starting Timeline-Based Animation..."); |
| this.isAnimating = true; |
| |
| |
| const audioFile = this.manifest.audio_file || this.manifest.steps[0]?.audio_file; |
| if (!audioFile) { |
| console.error("No audio file found in manifest"); |
| return; |
| } |
| |
| this.globalAudio = new Audio(audioFile); |
| |
| |
| await new Promise((resolve, reject) => { |
| this.globalAudio.oncanplaythrough = resolve; |
| this.globalAudio.onerror = reject; |
| this.globalAudio.load(); |
| }); |
| |
| |
| await this.globalAudio.play(); |
| console.log(`Audio playing: ${audioFile}`); |
| |
| |
| this.scheduleTimeline(this.manifest.steps); |
| } |
| |
| scheduleTimeline(steps) { |
| let currentStepIndex = 0; |
| const stepsState = steps.map(() => ({ started: false, completed: false })); |
| |
| const tick = () => { |
| if (!this.globalAudio || this.globalAudio.paused || this.globalAudio.ended) { |
| console.log("Animation Complete"); |
| this.isAnimating = false; |
| return; |
| } |
| |
| const currentTime = this.globalAudio.currentTime; |
| const step = steps[currentStepIndex]; |
| const state = stepsState[currentStepIndex]; |
| |
| if (!step) { |
| return; |
| } |
| |
| |
| if (currentTime >= step.t_start && !state.started) { |
| state.started = true; |
| console.log(`[${currentTime.toFixed(2)}s] Starting step ${currentStepIndex}: ${step.target_file}`); |
| this.executeStepVisual(step, currentStepIndex); |
| } |
| |
| |
| if (currentTime >= step.t_end && state.started && !state.completed) { |
| state.completed = true; |
| currentStepIndex++; |
| } |
| |
| this.animationFrameId = requestAnimationFrame(tick); |
| }; |
| |
| tick(); |
| } |
| |
| async executeStepVisual(step, index) { |
| |
| if (step.action !== 'show_output' && this.currentFile !== step.target_file) { |
| this.switchFile(step.target_file); |
| this.currentFile = step.target_file; |
| |
| await new Promise(r => setTimeout(r, 1500)); |
| } |
| |
| |
| if (step.action === 'show_output') { |
| console.log("Showing Output..."); |
| const terminalContent = document.getElementById('terminalContent'); |
| |
| |
| |
| |
| const outputText = executionOutput[step.target_file] || executionOutput['Main.java'] || "Execution complete."; |
| |
| |
| const cmdDiv = document.createElement('div'); |
| cmdDiv.className = 'terminal-line'; |
| cmdDiv.innerHTML = `<span class="terminal-prompt">$</span> run ${step.target_file || 'Main'}`; |
| terminalContent.appendChild(cmdDiv); |
| |
| |
| const outputLines = outputText.split('\n'); |
| |
| for (let line of outputLines) { |
| const div = document.createElement('div'); |
| div.className = 'terminal-line terminal-output'; |
| terminalContent.appendChild(div); |
| |
| |
| let buffer = ""; |
| for (let char of line) { |
| buffer += char; |
| div.textContent = buffer; |
| |
| await new Promise(r => setTimeout(r, 10)); |
| } |
| |
| |
| terminalContent.scrollTop = terminalContent.scrollHeight; |
| await new Promise(r => setTimeout(r, 100)); |
| } |
| |
| |
| const newPrompt = document.createElement('div'); |
| newPrompt.className = 'terminal-line terminal-prompt'; |
| newPrompt.textContent = "$"; |
| terminalContent.appendChild(newPrompt); |
| terminalContent.scrollTop = terminalContent.scrollHeight; |
| |
| return; |
| } |
| |
| |
| if (step.action === 'wait') return; |
| |
| const filename = step.target_file; |
| const fullCode = this.codeFiles[filename]; |
| if (!fullCode) return; |
| |
| const allLines = fullCode.split('\n'); |
| |
| |
| const startLine = (step.start_line !== undefined) ? step.start_line : 1; |
| const endLine = (step.end_line !== undefined) ? step.end_line : allLines.length; |
| |
| |
| const linesToType = allLines.slice(startLine - 1, endLine); |
| |
| |
| const availableTime = (step.t_end - step.t_start) * 1000; |
| |
| |
| let totalChars = linesToType.reduce((acc, line) => acc + line.length, 0); |
| let msPerChar = (availableTime * 0.9) / (totalChars || 1); |
| |
| |
| msPerChar = Math.max(25, Math.min(msPerChar, 90)); |
| |
| |
| const container = document.getElementById('codeContent'); |
| const language = this.getLanguage(filename); |
| |
| |
| let cursor = document.getElementById('typing-cursor'); |
| if (!cursor) { |
| cursor = document.createElement('span'); |
| cursor.id = 'typing-cursor'; |
| cursor.className = 'typing-cursor'; |
| cursor.innerHTML = 'β'; |
| } |
| |
| const startTime = Date.now(); |
| let charsTypedTotal = 0; |
| |
| for (let i = 0; i < linesToType.length; i++) { |
| const lineIdx = startLine - 1 + i; |
| const lineText = linesToType[i]; |
| |
| let lineDiv = container.children[lineIdx]; |
| if (!lineDiv) continue; |
| let lineContent = lineDiv.querySelector('.line-content'); |
| |
| let currentBuffer = ""; |
| |
| if (lineText.length === 0) { |
| lineContent.innerHTML = ''; |
| lineContent.appendChild(cursor); |
| } |
| |
| for (let char of lineText) { |
| currentBuffer += char; |
| charsTypedTotal++; |
| |
| let highlighted = ""; |
| if (Prism && Prism.highlight) { |
| highlighted = Prism.highlight( |
| currentBuffer, |
| Prism.languages[language] || Prism.languages.javascript, |
| language |
| ); |
| } else { |
| highlighted = currentBuffer |
| .replace(/&/g, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">"); |
| } |
| |
| lineContent.innerHTML = `<pre style="margin:0; display:inline;">${highlighted}</pre>`; |
| lineContent.appendChild(cursor); |
| cursor.scrollIntoView({ block: "nearest", inline: "nearest" }); |
| |
| |
| const elapsed = Date.now() - startTime; |
| const targetTime = charsTypedTotal * msPerChar; |
| const delay = targetTime - elapsed; |
| |
| if (delay > 0) { |
| await new Promise(r => setTimeout(r, delay)); |
| } else { |
| await new Promise(r => setTimeout(r, 0)); |
| } |
| } |
| } |
| } |
| |
| switchFile(filename) { |
| document.querySelectorAll('.file-item').forEach(item => { |
| item.classList.toggle('active', item.dataset.file === filename); |
| }); |
| document.querySelectorAll('.tab').forEach(tab => { |
| tab.classList.toggle('active', tab.dataset.file === filename); |
| }); |
| |
| |
| const codeContent = document.getElementById('codeContent'); |
| codeContent.innerHTML = ''; |
| |
| const fullCode = this.codeFiles[filename] || ''; |
| const lines = fullCode.split('\n'); |
| |
| lines.forEach((lineText, idx) => { |
| const lineDiv = document.createElement('div'); |
| lineDiv.className = 'line'; |
| |
| const lineNum = document.createElement('div'); |
| lineNum.className = 'line-number'; |
| lineNum.textContent = idx + 1; |
| |
| const lineContent = document.createElement('div'); |
| lineContent.className = 'line-content'; |
| lineContent.innerHTML = ''; |
| |
| lineDiv.appendChild(lineNum); |
| lineDiv.appendChild(lineContent); |
| codeContent.appendChild(lineDiv); |
| }); |
| } |
| |
| getLanguage(filename) { |
| const ext = filename.split('.').pop().toLowerCase(); |
| return { |
| 'js': 'javascript', 'py': 'python', 'java': 'java', 'cpp': 'cpp', |
| 'c': 'c', 'cs': 'csharp', 'go': 'go' |
| }[ext] || 'javascript'; |
| } |
| } |
| </script> |
|
|
| |
| <script> |
| const codeFiles = {{ code_files | tojson }}; |
| const manifest = {{ manifest | default (none) | tojson }}; |
| const autoRun = {{ auto_run | tojson }}; |
| const executionOutput = {{ execution_output | tojson }}; |
| |
| |
| let currentFile = null; |
| let isAnimating = false; |
| let currentAudio = null; |
| |
| |
| let fileLineProgress = {}; |
| |
| function initializeUI() { |
| const fileTree = document.getElementById('fileTree'); |
| const tabsContainer = document.getElementById('tabsContainer'); |
| |
| fileTree.innerHTML = ''; |
| tabsContainer.innerHTML = ''; |
| |
| const files = Object.keys(codeFiles); |
| if (files.length === 0) return; |
| |
| |
| files.forEach(f => fileLineProgress[f] = 0); |
| |
| |
| currentFile = files[0]; |
| |
| files.forEach((filename) => { |
| const ext = filename.split('.').pop().toLowerCase(); |
| const iconMap = { |
| 'py': 'π', 'java': 'β', 'cpp': 'βοΈ', 'c': 'π₯οΈ', |
| 'js': 'π', 'ts': 'π', 'cs': '#οΈβ£', 'go': 'π·' |
| }; |
| const icon = iconMap[ext] || 'π'; |
| const isActive = filename === currentFile; |
| |
| const fileItem = document.createElement('div'); |
| fileItem.className = `file-item ${isActive ? 'active' : ''}`; |
| fileItem.dataset.file = filename; |
| fileItem.innerHTML = `<span class="file-icon">${icon}</span><span>${filename}</span>`; |
| fileItem.addEventListener('click', () => switchFile(filename)); |
| fileTree.appendChild(fileItem); |
| |
| const tab = document.createElement('div'); |
| tab.className = `tab ${isActive ? 'active' : ''}`; |
| tab.dataset.file = filename; |
| tab.innerHTML = `<span>${icon}</span><span>${filename}</span>`; |
| tab.addEventListener('click', () => switchFile(filename)); |
| tabsContainer.appendChild(tab); |
| }); |
| } |
| |
| function switchFile(filename) { |
| |
| currentFile = filename; |
| |
| |
| document.querySelectorAll('.file-item').forEach(item => { |
| item.classList.toggle('active', item.dataset.file === filename); |
| }); |
| document.querySelectorAll('.tab').forEach(tab => { |
| tab.classList.toggle('active', tab.dataset.file === filename); |
| }); |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| renderStaticView(filename); |
| } |
| |
| |
| function renderStaticView(filename) { |
| const codeContent = document.getElementById('codeContent'); |
| |
| |
| |
| |
| codeContent.innerHTML = ''; |
| const fullCode = codeFiles[filename] || ''; |
| const lines = fullCode.split('\n'); |
| |
| lines.forEach((lineText, idx) => { |
| const lineDiv = document.createElement('div'); |
| lineDiv.className = 'line'; |
| |
| const lineNum = document.createElement('div'); |
| lineNum.className = 'line-number'; |
| lineNum.textContent = idx + 1; |
| |
| const lineContent = document.createElement('div'); |
| lineContent.className = 'line-content'; |
| |
| |
| lineContent.innerHTML = ''; |
| |
| lineDiv.appendChild(lineNum); |
| lineDiv.appendChild(lineContent); |
| codeContent.appendChild(lineDiv); |
| }); |
| } |
| |
| function getLanguage(filename) { |
| const ext = filename.split('.').pop().toLowerCase(); |
| return { |
| 'js': 'javascript', 'py': 'python', 'java': 'java', 'cpp': 'cpp', |
| 'c': 'c', 'cs': 'csharp', 'go': 'go' |
| }[ext] || 'javascript'; |
| } |
| |
| |
| |
| document.addEventListener('DOMContentLoaded', () => { |
| |
| initializeUI(); |
| |
| if (codeFiles) { |
| |
| const first = Object.keys(codeFiles)[0]; |
| if (first) renderStaticView(first); |
| } |
| |
| if (autoRun && manifest) { |
| |
| const animator = new TimelineAnimator(manifest, codeFiles); |
| setTimeout(() => animator.start(), 1000); |
| } |
| }); |
| </script> |
| </body> |
|
|
| </html> |