diff --git a/src/editor_app/routers/frontend.py b/src/editor_app/routers/frontend.py index ec8cfe7..af13e26 100644 --- a/src/editor_app/routers/frontend.py +++ b/src/editor_app/routers/frontend.py @@ -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") diff --git a/src/static/home.html b/src/static/home.html index 9b4f46a..4065d14 100644 --- a/src/static/home.html +++ b/src/static/home.html @@ -116,7 +116,7 @@ -

Edit and store files on the server. Python runs in your browser with Pyodide.

+

Edit and store files on the server. Python runs in your browser with Pyodide. Choose Editor or the interactive Tutorial below.

If you use EDITOR_API_KEY (without user login), store it here for API calls from this browser tab:

@@ -137,7 +137,10 @@ User management
- Open Editor + + diff --git a/src/static/pyodide-worker.js b/src/static/pyodide-worker.js index 4300ea8..325c95d 100644 --- a/src/static/pyodide-worker.js +++ b/src/static/pyodide-worker.js @@ -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] diff --git a/src/static/script.js b/src/static/script.js index c193351..bbe9afb 100644 --- a/src/static/script.js +++ b/src/static/script.js @@ -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; diff --git a/src/static/styles.css b/src/static/styles.css index 8f0c2db..5f85345 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -245,6 +245,7 @@ body { overflow: hidden; } + .hidden { display: none !important; } diff --git a/src/static/tutorial.html b/src/static/tutorial.html new file mode 100644 index 0000000..5b507d3 --- /dev/null +++ b/src/static/tutorial.html @@ -0,0 +1,250 @@ + + + + + + Python Tutorial + + + + +
+
+ Home +
+
+
+

Guided LED Demo

+
+ + +
+
+
+
+
Press Begin Demo to render the panel animation.
+
+
+
+
+
+ + + + +
+
+
+ +
+
+
+ + + diff --git a/src/static/tutorial.js b/src/static/tutorial.js new file mode 100644 index 0000000..08df2f5 --- /dev/null +++ b/src/static/tutorial.js @@ -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, ">"); + } + + 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 `${String(n).padStart(2, " ")} ${this.escapeHtml(line)}`; + }) + .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(); +});