Add tutorial route; gate lib workspace for superusers; Py worker v4
- Serve /tutorial and add tutorial.html/tutorial.js assets - Fetch auth role; hide lib from non-superusers in tree and restored tabs - Cache workspace Python sources briefly for Py worker - Pyodide worker and home/index links/styling tweaks Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,6 +16,11 @@ async def serve_frontend():
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
@router.get("/tutorial")
|
||||
async def serve_tutorial():
|
||||
return FileResponse(STATIC_DIR / "tutorial.html")
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def serve_login():
|
||||
return FileResponse(STATIC_DIR / "login.html")
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
<a class="btn btn-ghost hidden" id="link-register" href="/register">Register</a>
|
||||
<button type="button" class="btn btn-ghost hidden" id="btn-logout">Sign out</button>
|
||||
</div>
|
||||
<p>Edit and store files on the server. Python runs in your browser with <a href="https://pyodide.org/" style="color:#93c5fd">Pyodide</a>.</p>
|
||||
<p>Edit and store files on the server. Python runs in your browser with <a href="https://pyodide.org/" style="color:#93c5fd">Pyodide</a>. Choose Editor or the interactive Tutorial below.</p>
|
||||
<div id="optional-api-key">
|
||||
<p>If you use <code style="color:#fcd34d">EDITOR_API_KEY</code> (without user login), store it here for API calls from this browser tab:</p>
|
||||
<label for="api-key">API key (optional)</label>
|
||||
@@ -137,7 +137,10 @@
|
||||
<strong>User management</strong>
|
||||
<div id="users-list" class="users-list"></div>
|
||||
</section>
|
||||
<a class="btn btn-primary" href="/editor" id="open-editor">Open Editor</a>
|
||||
<div class="nav">
|
||||
<a class="btn btn-primary" href="/editor" id="open-editor">Open Editor</a>
|
||||
<a class="btn btn-ghost" href="/tutorial" id="open-tutorial">Open Tutorial</a>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const storageKey = 'python-editor.api_key';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Python Editor</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/static/styles.css?v=10">
|
||||
<link rel="stylesheet" href="/static/styles.css?v=12">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -87,6 +87,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/static/script.js?v=23"></script>
|
||||
<script type="module" src="/static/script.js?v=25"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -52,7 +52,7 @@ self.onmessage = async (event) => {
|
||||
p.globals.set('__cm_max', Math.min(100, Math.max(1, Number(payload.max_results) || 20)));
|
||||
p.globals.set('__cm_extra_json', JSON.stringify(payload.extra_files || {}));
|
||||
const raw = p.runPython(`
|
||||
import json, os
|
||||
import json, os, sys
|
||||
import jedi
|
||||
|
||||
extra = json.loads(__cm_extra_json)
|
||||
@@ -66,7 +66,13 @@ for rel_path, body in extra.items():
|
||||
os.makedirs(os.path.dirname(__cm_path), exist_ok=True)
|
||||
with open(__cm_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(__cm_code)
|
||||
proj = jedi.Project("/workspace")
|
||||
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
||||
if entry not in sys.path:
|
||||
sys.path.insert(0, entry)
|
||||
proj = jedi.Project(
|
||||
"/workspace",
|
||||
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
|
||||
)
|
||||
s = jedi.Script(code=__cm_code, path=__cm_path, project=proj)
|
||||
items = s.complete(line=__cm_line, column=__cm_col)
|
||||
out = [{"name": i.name, "type": i.type, "complete": i.complete} for i in items[:__cm_max]]
|
||||
@@ -84,7 +90,7 @@ json.dumps(out)
|
||||
p.globals.set('__diag_path', vpath);
|
||||
p.globals.set('__diag_extra_json', JSON.stringify(payload.extra_files || {}));
|
||||
const raw = p.runPython(`
|
||||
import json, os
|
||||
import json, os, sys
|
||||
import jedi
|
||||
|
||||
extra = json.loads(__diag_extra_json)
|
||||
@@ -98,7 +104,13 @@ for rel_path, body in extra.items():
|
||||
os.makedirs(os.path.dirname(__diag_path), exist_ok=True)
|
||||
with open(__diag_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(__diag_code)
|
||||
proj = jedi.Project("/workspace")
|
||||
for entry in ("/workspace/code", "/workspace/lib", "/workspace"):
|
||||
if entry not in sys.path:
|
||||
sys.path.insert(0, entry)
|
||||
proj = jedi.Project(
|
||||
"/workspace",
|
||||
added_sys_path=["/workspace/code", "/workspace/lib", "/workspace"],
|
||||
)
|
||||
s = jedi.Script(code=__diag_code, path=__diag_path, project=proj)
|
||||
errs = s.get_syntax_errors()
|
||||
out = [{"line": e.line, "column": e.column, "message": str(e.get_message())} for e in errs]
|
||||
|
||||
@@ -31,6 +31,8 @@ class TextEditor {
|
||||
this.completionRequestId = 0;
|
||||
this.diagnosticsRequestId = 0;
|
||||
this.diagnosticsTimer = null;
|
||||
this.workspaceSourcesCache = null;
|
||||
this.workspaceSourcesCacheAt = 0;
|
||||
this.draggedItemPath = null;
|
||||
this.draggedItemIsDirectory = false;
|
||||
this.dragHoverExpandTimer = null;
|
||||
@@ -43,10 +45,27 @@ class TextEditor {
|
||||
this.lastLedFrame = null;
|
||||
this.ledPanelWindow = null;
|
||||
this.workspaceUserId = null;
|
||||
this.isSuperuser = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async getWorkspacePythonSources() {
|
||||
const now = Date.now();
|
||||
if (this.workspaceSourcesCache && (now - this.workspaceSourcesCacheAt) < 1200) {
|
||||
return { ...this.workspaceSourcesCache };
|
||||
}
|
||||
const response = await this.apiFetch('/api/workspace/py-sources');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load workspace sources');
|
||||
}
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const files = payload && typeof payload.files === 'object' ? payload.files : {};
|
||||
this.workspaceSourcesCache = files;
|
||||
this.workspaceSourcesCacheAt = now;
|
||||
return { ...files };
|
||||
}
|
||||
|
||||
init() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
@@ -69,7 +88,22 @@ class TextEditor {
|
||||
this.setLspStatus('LSP: n/a', 'Open a Python file for diagnostics');
|
||||
this.updateWorkspaceBanner();
|
||||
this.prewarmPyWorker();
|
||||
this.loadInitialDirectoryState().then(() => this.restoreSessionTabs());
|
||||
this.fetchViewerRole()
|
||||
.finally(() => this.loadInitialDirectoryState().then(() => this.restoreSessionTabs()));
|
||||
}
|
||||
|
||||
async fetchViewerRole() {
|
||||
try {
|
||||
const me = await fetch('/api/auth/me', { credentials: 'include' });
|
||||
if (!me.ok) {
|
||||
this.isSuperuser = false;
|
||||
return;
|
||||
}
|
||||
const data = await me.json().catch(() => ({}));
|
||||
this.isSuperuser = Boolean(data && data.user && data.user.is_superuser);
|
||||
} catch (_error) {
|
||||
this.isSuperuser = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateWorkspaceBanner() {
|
||||
@@ -152,7 +186,7 @@ class TextEditor {
|
||||
|
||||
ensurePyWorker() {
|
||||
if (!this.pyWorker) {
|
||||
const worker = new Worker('/static/pyodide-worker.js?v=3');
|
||||
const worker = new Worker('/static/pyodide-worker.js?v=4');
|
||||
this.pyWorker = worker;
|
||||
worker.onmessage = (event) => this.handlePyWorkerMessage(event);
|
||||
}
|
||||
@@ -255,6 +289,9 @@ class TextEditor {
|
||||
return;
|
||||
}
|
||||
for (const path of session.openTabPaths) {
|
||||
if (!this.isSuperuser && typeof path === 'string' && path.startsWith('lib/')) {
|
||||
continue;
|
||||
}
|
||||
await this.openFile(path);
|
||||
}
|
||||
if (session.activeTabPath && this.findTab(session.activeTabPath)) {
|
||||
@@ -290,7 +327,7 @@ class TextEditor {
|
||||
}
|
||||
|
||||
getVisibleTopLevelNames() {
|
||||
return new Set(['code', 'lib']);
|
||||
return this.isSuperuser ? new Set(['code', 'lib']) : new Set(['code']);
|
||||
}
|
||||
|
||||
getDefaultEditableRoot() {
|
||||
@@ -300,12 +337,14 @@ class TextEditor {
|
||||
async loadInitialDirectoryState() {
|
||||
await this.loadDirectory('');
|
||||
await this.ensureFolderExists('code');
|
||||
await this.ensureFolderExists('lib');
|
||||
this.selectedPath = 'code';
|
||||
this.selectedIsDirectory = true;
|
||||
this.expandedDirs.add('code');
|
||||
await this.loadDirectory('code', { suppressError: true });
|
||||
await this.loadDirectory('lib', { suppressError: true });
|
||||
if (this.isSuperuser) {
|
||||
await this.ensureFolderExists('lib');
|
||||
await this.loadDirectory('lib', { suppressError: true });
|
||||
}
|
||||
this.renderFileTree();
|
||||
}
|
||||
|
||||
@@ -448,7 +487,7 @@ class TextEditor {
|
||||
this.setLspStatus('LSP: checking...', 'Running Jedi syntax diagnostics');
|
||||
try {
|
||||
await this.ensurePyodideReady();
|
||||
const extraFiles = {};
|
||||
const extraFiles = await this.getWorkspacePythonSources();
|
||||
for (const tab of this.openTabs) {
|
||||
if (tab.path && tab.path.toLowerCase().endsWith('.py')) {
|
||||
extraFiles[tab.path] = tab.content;
|
||||
@@ -530,7 +569,7 @@ class TextEditor {
|
||||
|
||||
try {
|
||||
await this.ensurePyodideReady();
|
||||
const extraFiles = {};
|
||||
const extraFiles = await this.getWorkspacePythonSources();
|
||||
for (const tab of this.openTabs) {
|
||||
if (tab.path && tab.path.toLowerCase().endsWith('.py')) {
|
||||
extraFiles[tab.path] = tab.content;
|
||||
@@ -667,9 +706,18 @@ class TextEditor {
|
||||
window.addEventListener('beforeunload', persistSession);
|
||||
window.addEventListener('pagehide', persistSession);
|
||||
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (!this.completionOpen) return;
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.applySelectedCompletion();
|
||||
}
|
||||
}, true);
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (this.completionOpen) {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
this.applySelectedCompletion();
|
||||
return;
|
||||
|
||||
@@ -245,6 +245,7 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
250
src/static/tutorial.html
Normal file
250
src/static/tutorial.html
Normal file
@@ -0,0 +1,250 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Python Tutorial</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid #334155;
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
padding: 0.45rem 0.7rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #2563eb;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.9rem;
|
||||
min-height: 70dvh;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
background: #111827;
|
||||
padding: 0.8rem;
|
||||
min-height: 0;
|
||||
}
|
||||
.hint { color: #94a3b8; font-size: 0.9rem; margin: 0.4rem 0 0.7rem; white-space: pre-wrap; }
|
||||
.guided-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 0.8rem;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.demo-code {
|
||||
margin: 0.55rem 0 0;
|
||||
border: 1px solid #334155;
|
||||
background: #020617;
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem;
|
||||
min-height: 280px;
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre;
|
||||
}
|
||||
.demo-line {
|
||||
display: block;
|
||||
padding: 0 0.2rem;
|
||||
border-radius: 4px;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
.demo-line.active {
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
color: #dbeafe;
|
||||
outline: 1px solid rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
.demo-controls {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.65rem;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
background: #0b1220;
|
||||
}
|
||||
.demo-controls label {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.demo-controls .control-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.demo-controls .value-badge {
|
||||
min-width: 52px;
|
||||
text-align: center;
|
||||
background: #1d4ed8;
|
||||
color: white;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.demo-controls input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: #3b82f6;
|
||||
height: 1.1rem;
|
||||
}
|
||||
.demo-controls select {
|
||||
width: 100%;
|
||||
border: 1px solid #475569;
|
||||
border-radius: 8px;
|
||||
background: #020617;
|
||||
color: #e2e8f0;
|
||||
padding: 0.5rem 0.55rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.guide-explain {
|
||||
border: 1px solid #334155;
|
||||
background: #020617;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem;
|
||||
color: #cbd5e1;
|
||||
min-height: 120px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.guide-step {
|
||||
color: #93c5fd;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.45rem 0;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
.led-demo-panel {
|
||||
border: 1px solid #334155;
|
||||
background: #020617;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem;
|
||||
width: fit-content;
|
||||
display: inline-block;
|
||||
}
|
||||
.led-meta {
|
||||
color: #9ca3af;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 0.45rem;
|
||||
display: none;
|
||||
}
|
||||
.led-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(16, 24px);
|
||||
grid-auto-rows: 24px;
|
||||
gap: 6px;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
align-content: start;
|
||||
justify-content: start;
|
||||
width: fit-content;
|
||||
}
|
||||
.led {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.guided-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<div class="topbar">
|
||||
<a class="btn" href="/">Home</a>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<section class="card">
|
||||
<h3 style="margin:0">Guided LED Demo</h3>
|
||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
|
||||
<button id="begin-demo-btn" class="btn primary" type="button">Let's Get Started</button>
|
||||
<button id="toggle-breakdown-btn" class="btn hidden" type="button">Show Breakdown</button>
|
||||
</div>
|
||||
<div class="guided-grid">
|
||||
<div>
|
||||
<div class="led-demo-panel">
|
||||
<div id="led-meta" class="led-meta">Press Begin Demo to render the panel animation.</div>
|
||||
<div id="led-grid" class="led-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="demo-controls">
|
||||
<label>Pattern
|
||||
<select id="pattern-select">
|
||||
<option value="plasma">Plasma Swirl</option>
|
||||
<option value="rainbow">Rainbow Orbit</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span class="control-head">Speed <span id="speed-value" class="value-badge"></span></span>
|
||||
<input id="speed-slider" type="range" min="0.8" max="3.2" step="0.1" value="1.6" />
|
||||
</label>
|
||||
<label>
|
||||
<span class="control-head">Wave Width <span id="width-value" class="value-badge"></span></span>
|
||||
<input id="width-slider" type="range" min="0.2" max="0.8" step="0.02" value="0.45" />
|
||||
</label>
|
||||
<label>
|
||||
<span class="control-head">Brightness <span id="brightness-value" class="value-badge"></span></span>
|
||||
<input id="brightness-slider" type="range" min="80" max="230" step="5" value="170" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="learning-content" class="hidden">
|
||||
<div id="breakdown-panel" class="hidden">
|
||||
<div style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-top:0.2rem;">
|
||||
<button id="prev-part-btn" class="btn" type="button">Prev Part</button>
|
||||
<button id="next-part-btn" class="btn" type="button">Next Part</button>
|
||||
</div>
|
||||
<div class="guided-grid" style="margin-top:0.55rem;">
|
||||
<div>
|
||||
<div id="guide-step" class="guide-step"></div>
|
||||
<div id="demo-code" class="demo-code"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div id="guide-explain" class="guide-explain"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="/static/tutorial.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
435
src/static/tutorial.js
Normal file
435
src/static/tutorial.js
Normal file
@@ -0,0 +1,435 @@
|
||||
const DEMO_PARTS = [
|
||||
{
|
||||
title: "Setup objects",
|
||||
start: 1,
|
||||
end: 6,
|
||||
explain:
|
||||
"We import timing/math and create a 16x16 NeoPixel object.\nThis gives us 256 LEDs to control every frame.",
|
||||
},
|
||||
{
|
||||
title: "Serpentine mapping",
|
||||
start: 8,
|
||||
end: 10,
|
||||
explain:
|
||||
"The panel wiring zig-zags by row.\nEven rows run right-to-left, odd rows run left-to-right.",
|
||||
},
|
||||
{
|
||||
title: "Frame loop",
|
||||
start: 12,
|
||||
end: 12,
|
||||
explain:
|
||||
"`for t in range(...)` is time.\nEach pass draws one frame of animation.",
|
||||
},
|
||||
{
|
||||
title: "Pixel color math",
|
||||
start: 13,
|
||||
end: 17,
|
||||
explain:
|
||||
"For each (x,y), we compute a sine wave value.\nThat value controls brightness and color to create flowing motion.",
|
||||
},
|
||||
{
|
||||
title: "Render + delay",
|
||||
start: 18,
|
||||
end: 19,
|
||||
explain:
|
||||
"`np.write()` pushes the full frame to LEDs.\nA tiny sleep controls animation speed.",
|
||||
},
|
||||
];
|
||||
|
||||
class TutorialApp {
|
||||
constructor() {
|
||||
this.pyWorker = null;
|
||||
this.pyWorkerMsgId = 0;
|
||||
this.pyWorkerHandlers = new Map();
|
||||
this.pyodideInited = false;
|
||||
this.outputBuffer = "";
|
||||
this.introMode = true;
|
||||
this.previewTimer = null;
|
||||
this.previewTick = 0;
|
||||
this.breakdownVisible = false;
|
||||
this.demoPartIndex = 0;
|
||||
this.demoVars = {
|
||||
pattern: "plasma",
|
||||
speed: 1.6,
|
||||
width: 0.45,
|
||||
brightness: 170,
|
||||
};
|
||||
this.bind();
|
||||
this.renderDemoControls();
|
||||
this.renderDemoCode();
|
||||
this.renderDemoPart();
|
||||
this.prewarm();
|
||||
this.startIdlePreview();
|
||||
}
|
||||
|
||||
bind() {
|
||||
document.getElementById("toggle-breakdown-btn").addEventListener("click", () => {
|
||||
this.breakdownVisible = !this.breakdownVisible;
|
||||
const panel = document.getElementById("breakdown-panel");
|
||||
const button = document.getElementById("toggle-breakdown-btn");
|
||||
if (panel) panel.classList.toggle("hidden", !this.breakdownVisible);
|
||||
if (button) button.textContent = this.breakdownVisible ? "Hide Breakdown" : "Show Breakdown";
|
||||
});
|
||||
document.getElementById("begin-demo-btn").addEventListener("click", async () => {
|
||||
if (this.introMode) {
|
||||
this.introMode = false;
|
||||
this.stopIdlePreview();
|
||||
const learning = document.getElementById("learning-content");
|
||||
const breakdownToggle = document.getElementById("toggle-breakdown-btn");
|
||||
if (learning) learning.classList.remove("hidden");
|
||||
if (breakdownToggle) breakdownToggle.classList.remove("hidden");
|
||||
}
|
||||
const src = this.buildDemoCode();
|
||||
await this.runSource(src, "code/tutorial_demo.py");
|
||||
this.setStatus("Demo ran. Use Show Breakdown to step through code.", "info");
|
||||
});
|
||||
document.getElementById("prev-part-btn").addEventListener("click", () => {
|
||||
this.demoPartIndex = (this.demoPartIndex - 1 + DEMO_PARTS.length) % DEMO_PARTS.length;
|
||||
this.renderDemoPart();
|
||||
});
|
||||
document.getElementById("next-part-btn").addEventListener("click", () => {
|
||||
this.demoPartIndex = (this.demoPartIndex + 1) % DEMO_PARTS.length;
|
||||
this.renderDemoPart();
|
||||
});
|
||||
document.getElementById("pattern-select").addEventListener("change", (event) => {
|
||||
this.demoVars.pattern = String(event.target.value || "plasma");
|
||||
this.renderDemoCode();
|
||||
this.renderDemoPart();
|
||||
});
|
||||
document.getElementById("speed-slider").addEventListener("input", (event) => {
|
||||
this.demoVars.speed = Number(event.target.value);
|
||||
this.renderDemoControls();
|
||||
this.renderDemoCode();
|
||||
this.renderDemoPart();
|
||||
});
|
||||
document.getElementById("width-slider").addEventListener("input", (event) => {
|
||||
this.demoVars.width = Number(event.target.value);
|
||||
this.renderDemoControls();
|
||||
this.renderDemoCode();
|
||||
this.renderDemoPart();
|
||||
});
|
||||
document.getElementById("brightness-slider").addEventListener("input", (event) => {
|
||||
this.demoVars.brightness = Number(event.target.value);
|
||||
this.renderDemoControls();
|
||||
this.renderDemoCode();
|
||||
this.renderDemoPart();
|
||||
});
|
||||
}
|
||||
|
||||
buildDemoCode() {
|
||||
const pattern = this.demoVars.pattern;
|
||||
const speed = this.demoVars.speed.toFixed(2);
|
||||
const width = this.demoVars.width.toFixed(2);
|
||||
const brightness = Math.round(this.demoVars.brightness);
|
||||
const bodyLines =
|
||||
pattern === "rainbow"
|
||||
? [
|
||||
" dx = x - 7.5",
|
||||
" dy = y - 7.5",
|
||||
" angle = math.atan2(dy, dx)",
|
||||
" radius = math.sqrt(dx * dx + dy * dy)",
|
||||
" hue = angle + (radius * WAVE_WIDTH) + (t * SPEED * 0.15)",
|
||||
" pulse = (math.sin((radius * 0.65) - (t * 0.10)) + 1.0) * 0.5",
|
||||
" scale = int((0.45 + pulse * 0.55) * BRIGHTNESS)",
|
||||
" r = int((math.sin(hue + 0.0) * 0.5 + 0.5) * scale)",
|
||||
" g = int((math.sin(hue + 2.09) * 0.5 + 0.5) * scale)",
|
||||
" u = int((math.sin(hue + 4.18) * 0.5 + 0.5) * scale)",
|
||||
" np[xy_to_index(x, y)] = (max(0,min(255,r)), max(0,min(255,g)), max(0,min(255,u)))",
|
||||
]
|
||||
: [
|
||||
" dx = x - 7.5",
|
||||
" dy = y - 7.5",
|
||||
" dist = math.sqrt(dx * dx + dy * dy)",
|
||||
" ring = math.sin(dist * (WAVE_WIDTH * 2.6) - t * (SPEED * 0.9))",
|
||||
` swirl = math.sin((x * WAVE_WIDTH) + (y * ${Math.max(0.1, width * 0.8).toFixed(2)}) + (t * SPEED * 0.55))`,
|
||||
" pulse = math.sin(t * SPEED * 0.12)",
|
||||
" b = int((ring * 0.45 + swirl * 0.35 + pulse * 0.2 + 1.0) * (BRIGHTNESS / 2))",
|
||||
" b = max(0, min(255, b))",
|
||||
" r_wave = math.sin((x * 0.38) + (t * 0.12))",
|
||||
" g_wave = math.sin((y * 0.41) - (t * 0.10))",
|
||||
" u_wave = math.sin(((x + y) * 0.27) + (t * 0.08))",
|
||||
" r = max(0, min(255, int(b * 0.45 + (r_wave + 1) * 80)))",
|
||||
" g = max(0, min(255, int(b * 0.30 + (g_wave + 1) * 95)))",
|
||||
" u = max(0, min(255, int(b * 0.55 + (u_wave + 1) * 85)))",
|
||||
" np[xy_to_index(x, y)] = (r, g, u)",
|
||||
];
|
||||
return [
|
||||
"from machine import Pin",
|
||||
"import neopixel",
|
||||
"import time",
|
||||
"import math",
|
||||
"",
|
||||
"np = neopixel.NeoPixel(Pin(4), 16 * 16)",
|
||||
`PATTERN = "${pattern}"`,
|
||||
`SPEED = ${speed}`,
|
||||
`WAVE_WIDTH = ${width}`,
|
||||
`BRIGHTNESS = ${brightness}`,
|
||||
"",
|
||||
"def xy_to_index(x, y):",
|
||||
" return y * 16 + (15 - x if y % 2 == 0 else x)",
|
||||
"",
|
||||
"frames = 130",
|
||||
"for t in range(frames):",
|
||||
" for y in range(16):",
|
||||
" for x in range(16):",
|
||||
...bodyLines,
|
||||
" np.write()",
|
||||
" time.sleep(0.03)",
|
||||
"",
|
||||
"print('DEMO_DONE')",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
renderDemoControls() {
|
||||
const patternSelect = document.getElementById("pattern-select");
|
||||
if (patternSelect) patternSelect.value = this.demoVars.pattern;
|
||||
document.getElementById("speed-value").textContent = this.demoVars.speed.toFixed(1);
|
||||
document.getElementById("width-value").textContent = this.demoVars.width.toFixed(2);
|
||||
document.getElementById("brightness-value").textContent = String(Math.round(this.demoVars.brightness));
|
||||
}
|
||||
|
||||
renderDemoCode() {
|
||||
const code = this.buildDemoCode();
|
||||
const lines = code.split("\n");
|
||||
const active = DEMO_PARTS[this.demoPartIndex];
|
||||
const html = lines
|
||||
.map((line, idx) => {
|
||||
const n = idx + 1;
|
||||
const activeClass = n >= active.start && n <= active.end ? " active" : "";
|
||||
return `<span class="demo-line${activeClass}">${String(n).padStart(2, " ")} ${this.escapeHtml(line)}</span>`;
|
||||
})
|
||||
.join("");
|
||||
document.getElementById("demo-code").innerHTML = html;
|
||||
}
|
||||
|
||||
renderDemoPart() {
|
||||
const part = DEMO_PARTS[this.demoPartIndex];
|
||||
document.getElementById("guide-step").textContent = `Part ${this.demoPartIndex + 1}/${DEMO_PARTS.length}: ${part.title}`;
|
||||
document.getElementById("guide-explain").textContent = part.explain;
|
||||
this.renderDemoCode();
|
||||
}
|
||||
|
||||
buildPreviewFrame(tick) {
|
||||
const pixels = new Array(16 * 16).fill(0).map(() => [0, 0, 0]);
|
||||
const pattern = this.demoVars.pattern;
|
||||
const speed = this.demoVars.speed;
|
||||
const width = this.demoVars.width;
|
||||
const brightness = this.demoVars.brightness;
|
||||
for (let y = 0; y < 16; y += 1) {
|
||||
for (let x = 0; x < 16; x += 1) {
|
||||
const ledIndex = y % 2 === 0 ? y * 16 + (15 - x) : y * 16 + x;
|
||||
if (pattern === "rainbow") {
|
||||
const dx = x - 7.5;
|
||||
const dy = y - 7.5;
|
||||
const angle = Math.atan2(dy, dx);
|
||||
const radius = Math.sqrt(dx * dx + dy * dy);
|
||||
const hue = angle + (radius * width) + (tick * speed * 0.012);
|
||||
const pulse = (Math.sin((radius * 0.65) - (tick * 0.012)) + 1) * 0.5;
|
||||
const scale = (0.45 + pulse * 0.55) * brightness;
|
||||
const r = Math.max(0, Math.min(255, Math.floor((Math.sin(hue + 0.0) * 0.5 + 0.5) * scale)));
|
||||
const g = Math.max(0, Math.min(255, Math.floor((Math.sin(hue + 2.09) * 0.5 + 0.5) * scale)));
|
||||
const u = Math.max(0, Math.min(255, Math.floor((Math.sin(hue + 4.18) * 0.5 + 0.5) * scale)));
|
||||
pixels[ledIndex] = [r, g, u];
|
||||
} else {
|
||||
const dx = x - 7.5;
|
||||
const dy = y - 7.5;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const ring = Math.sin(dist * (width * 2.6) - tick * speed * 0.072);
|
||||
const swirl = Math.sin((x * width) + (y * Math.max(0.1, width * 0.8)) + (tick * speed * 0.044));
|
||||
const pulse = Math.sin(tick * speed * 0.01);
|
||||
const b = Math.max(0, Math.min(255, Math.floor((ring * 0.45 + swirl * 0.35 + pulse * 0.2 + 1) * (brightness / 2))));
|
||||
const rWave = Math.sin((x * 0.38) + (tick * 0.12));
|
||||
const gWave = Math.sin((y * 0.41) - (tick * 0.10));
|
||||
const uWave = Math.sin(((x + y) * 0.27) + (tick * 0.08));
|
||||
const r = Math.max(0, Math.min(255, Math.floor(b * 0.45 + (rWave + 1) * 80)));
|
||||
const g = Math.max(0, Math.min(255, Math.floor(b * 0.30 + (gWave + 1) * 95)));
|
||||
const u = Math.max(0, Math.min(255, Math.floor(b * 0.55 + (uWave + 1) * 85)));
|
||||
pixels[ledIndex] = [r, g, u];
|
||||
}
|
||||
}
|
||||
}
|
||||
return { type: "neopixel", pin: 4, bpp: 3, pixels };
|
||||
}
|
||||
|
||||
startIdlePreview() {
|
||||
this.stopIdlePreview();
|
||||
this.previewTick = 0;
|
||||
this.previewTimer = setInterval(() => {
|
||||
this.previewTick += 1;
|
||||
this.renderLedSimulation(this.buildPreviewFrame(this.previewTick));
|
||||
}, 50);
|
||||
}
|
||||
|
||||
stopIdlePreview() {
|
||||
if (this.previewTimer) {
|
||||
clearInterval(this.previewTimer);
|
||||
this.previewTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(msg, kind = "error") {
|
||||
const status = document.getElementById("status");
|
||||
if (!status) return;
|
||||
status.textContent = msg;
|
||||
if (kind === "success") {
|
||||
status.style.color = "#86efac";
|
||||
} else if (kind === "info") {
|
||||
status.style.color = "#93c5fd";
|
||||
} else {
|
||||
status.style.color = "#fca5a5";
|
||||
}
|
||||
}
|
||||
|
||||
ensurePyWorker() {
|
||||
if (!this.pyWorker) {
|
||||
this.pyWorker = new Worker("/static/pyodide-worker.js?v=4");
|
||||
this.pyWorker.onmessage = (event) => this.onWorkerMessage(event);
|
||||
}
|
||||
return this.pyWorker;
|
||||
}
|
||||
|
||||
onWorkerMessage(event) {
|
||||
const data = event.data;
|
||||
if (!data || typeof data !== "object") return;
|
||||
if (data.type === "io") {
|
||||
const text = data.text || "";
|
||||
this.outputBuffer += text;
|
||||
this.processSimulationLines(text.split(/\r?\n/));
|
||||
return;
|
||||
}
|
||||
const pending = this.pyWorkerHandlers.get(data.id);
|
||||
if (!pending) return;
|
||||
this.pyWorkerHandlers.delete(data.id);
|
||||
if (data.ok) pending.resolve(data);
|
||||
else pending.reject(new Error(data.error || "Worker error"));
|
||||
}
|
||||
|
||||
callWorker(type, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++this.pyWorkerMsgId;
|
||||
this.pyWorkerHandlers.set(id, { resolve, reject });
|
||||
this.ensurePyWorker().postMessage({ id, type, payload });
|
||||
});
|
||||
}
|
||||
|
||||
async ensurePyodideReady() {
|
||||
if (this.pyodideInited) return;
|
||||
await this.callWorker("init", {});
|
||||
this.pyodideInited = true;
|
||||
}
|
||||
|
||||
async prewarm() {
|
||||
try {
|
||||
await this.ensurePyodideReady();
|
||||
} catch (_err) {
|
||||
// ignore warm-up errors
|
||||
}
|
||||
}
|
||||
|
||||
renderLedSimulation(frame) {
|
||||
if (!frame) return;
|
||||
const meta = document.getElementById("led-meta");
|
||||
const grid = document.getElementById("led-grid");
|
||||
if (!meta || !grid) return;
|
||||
const pixels = Array.isArray(frame.pixels) ? frame.pixels : [];
|
||||
meta.textContent = `pin=${frame.pin ?? "?"} | leds=${pixels.length} | bpp=${frame.bpp ?? 3} | mode=16x16`;
|
||||
grid.innerHTML = "";
|
||||
const panelSize = 16 * 16;
|
||||
for (let panelIndex = 0; panelIndex < panelSize; panelIndex += 1) {
|
||||
const row = Math.floor(panelIndex / 16);
|
||||
const col = panelIndex % 16;
|
||||
const ledIndex = row % 2 === 0 ? row * 16 + (15 - col) : row * 16 + col;
|
||||
const px = pixels[ledIndex] || [0, 0, 0];
|
||||
const r = Number(px?.[0] ?? 0);
|
||||
const g = Number(px?.[1] ?? 0);
|
||||
const b = Number(px?.[2] ?? 0);
|
||||
const div = document.createElement("div");
|
||||
div.className = "led";
|
||||
div.title = `panel(${row},${col}) -> #${ledIndex} (${r}, ${g}, ${b})`;
|
||||
div.style.backgroundColor = `rgb(${r}, ${g}, ${b})`;
|
||||
div.style.boxShadow = `0 0 10px rgba(${r}, ${g}, ${b}, 0.55), inset 0 0 8px rgba(0,0,0,0.45)`;
|
||||
grid.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
processSimulationLines(lines) {
|
||||
for (const line of lines || []) {
|
||||
if (typeof line !== "string") continue;
|
||||
const marker = "[neopixel-json]";
|
||||
const idx = line.indexOf(marker);
|
||||
if (idx === -1) continue;
|
||||
const jsonPart = line.slice(idx + marker.length).trim();
|
||||
if (!jsonPart) continue;
|
||||
try {
|
||||
const payload = JSON.parse(jsonPart);
|
||||
if (payload && payload.type === "neopixel") {
|
||||
this.renderLedSimulation(payload);
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore malformed simulation payloads.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async runSource(source, mainPath = "code/tutorial_challenge.py") {
|
||||
this.outputBuffer = "";
|
||||
const outputEl = document.getElementById("output");
|
||||
if (outputEl) outputEl.textContent = "Running...\n";
|
||||
try {
|
||||
const bundleResp = await fetch("/api/workspace/py-sources", { credentials: "include" });
|
||||
if (!bundleResp.ok) {
|
||||
throw new Error("Failed to load workspace sources for tutorial runtime.");
|
||||
}
|
||||
const bundle = await bundleResp.json();
|
||||
const files = { ...(bundle.files || {}) };
|
||||
files[mainPath] = source;
|
||||
await this.ensurePyodideReady();
|
||||
await this.callWorker("run", {
|
||||
files,
|
||||
mainPath,
|
||||
args: [],
|
||||
});
|
||||
const visible = this.outputBuffer
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line && !line.includes("[neopixel-json]"))
|
||||
.join("\n");
|
||||
if (outputEl) outputEl.textContent = visible || "[no output]";
|
||||
return { ok: true, output: this.outputBuffer };
|
||||
} catch (error) {
|
||||
const out = `${this.outputBuffer}\n${error.message}`.trim();
|
||||
if (outputEl) outputEl.textContent = out;
|
||||
return { ok: false, output: out };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bootTutorial() {
|
||||
try {
|
||||
const st = await fetch("/api/auth/status");
|
||||
if (st.ok) {
|
||||
const status = await st.json();
|
||||
if (status.auth_enabled) {
|
||||
const me = await fetch("/api/auth/me", { credentials: "include" });
|
||||
if (!me.ok) {
|
||||
const next = encodeURIComponent(`${window.location.pathname}${window.location.search}`);
|
||||
window.location.replace(`/login?next=${next}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_err) {
|
||||
// Continue even if auth check fails.
|
||||
}
|
||||
new TutorialApp();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
bootTutorial();
|
||||
});
|
||||
Reference in New Issue
Block a user