Add local-mode workspace, ZIP import/export, and richer pin/ADC/serial sims

Boot:
- Editor now picks local vs server mode based on URL flag, sign-in
  state, and a stale local-mode flag. Signed-in users are no longer
  bounced to IndexedDB if they had previously clicked "Use locally".

Local mode:
- New LocalWorkspaceClient (src/static/local-workspace.js) with
  pluggable IndexedDB and File System Access backends. Picked folder
  handles persist across reloads with a Reconnect button when the
  permission lapses.
- Static-only host: scripts/serve_static_editor.py serves src/static/
  with COOP/COEP so SharedArrayBuffer-backed sims keep working.
- Bundled MicroPython stubs ship under src/static/bundled-lib/ for
  static hosting; FastAPI also exposes them at /api/public/lib-bundle.

Workspace import / export:
- Zero-dep ZIP encoder + reader (STORE + DEFLATE via
  DecompressionStream). Export/Import buttons in the workspace badge
  work in both local and server modes; imports are confined to code/.

Pin / ADC / Serial simulation:
- machine.py grows ADC, UART, expanded Pin, and PWM mocks, all driven
  by SharedArrayBuffer when cross-origin isolated and falling back to
  postMessage + [pin-out] stdout markers otherwise — pins, ADC slider,
  and serial input now keep working over plain HTTP / LAN-IP origins.
- NeoPixel pins are claimed via a [pin-claim] marker and dropped from
  the Pins panel so the data line doesn't flicker per write().
- New demos: adc_slider_demo.py, pin_demo.py, serial_demo.py.

Lib layout:
- Single source of truth at repo lib/; workspace/lib/ caching layer
  removed and the directory deleted. Filesystem service reads stubs
  directly from PROJECT_ROOT/lib.

UI:
- Home page slimmed to "Sign in" + "Use locally" with optional editor
  / manage-users links. Admin user/invite UI moved to /users.
- Workspace badge gains storage indicator, Folder…/Reconnect, Export,
  Import, and Exit controls.
- Mobile-friendly tweaks: safer-area padding, larger touch targets,
  iOS-zoom-proof serial input, file-tree highlight fix.

Tests:
- test_auth.py patches PROJECT_ROOT for the lib-shared test so the
  repo-root lib refactor stays green. test_api.py asserts the new
  "LED Editor" branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-10 06:16:02 +12:00
parent 9f28eabd2d
commit ca0ca6fe7e
26 changed files with 5080 additions and 793 deletions

View File

@@ -1,12 +1,12 @@
import os
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from fastapi import Depends, FastAPI, Request
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from sqlalchemy.orm import sessionmaker
from editor_app.config import STATIC_DIR, WORKSPACE_ROOT
from editor_app.config import STATIC_DIR
from editor_app.db.models import Base
from editor_app.db.session import get_engine
from editor_app.deps import require_api_access
@@ -14,13 +14,11 @@ from editor_app.routers.auth_routes import router as auth_router
from editor_app.routers.files import router as files_router
from editor_app.routers.frontend import router as frontend_router
from editor_app.routers.users_admin import router as users_admin_router
from editor_app.services import accounts, user_workspace
from editor_app.services import accounts
@asynccontextmanager
async def lifespan(_app: FastAPI):
(WORKSPACE_ROOT / "lib").mkdir(parents=True, exist_ok=True)
user_workspace.ensure_workspace_lib()
engine = get_engine()
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
@@ -47,6 +45,18 @@ async def lifespan(_app: FastAPI):
def create_app() -> FastAPI:
app = FastAPI(lifespan=lifespan)
@app.middleware("http")
async def cross_origin_isolation(request: Request, call_next):
"""Turn on `crossOriginIsolated` so the editor worker can use SharedArrayBuffer
(powers the live ADC slider). `credentialless` keeps no-credential cross-origin
imports (esm.sh, jsdelivr) loading without requiring CORP everywhere.
"""
response = await call_next(request)
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
response.headers.setdefault("Cross-Origin-Embedder-Policy", "credentialless")
return response
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(frontend_router)
app.include_router(auth_router)

View File

@@ -1,11 +1,38 @@
from fastapi import APIRouter
from fastapi.responses import FileResponse
from editor_app.config import STATIC_DIR
from editor_app.config import PROJECT_ROOT, STATIC_DIR
router = APIRouter()
@router.get("/api/public/lib-bundle")
async def serve_lib_bundle():
"""Public, unauthenticated dump of the read-only `lib/` stubs.
Local-mode browsers (no login, files in IndexedDB) need access to the
MicroPython mocks so completion, diagnostics, and `runpy` can resolve
`from machine import …`. Reading these is read-only and contains no
user data, so it is safe to expose without auth."""
bundle_root = (PROJECT_ROOT / "lib").resolve()
files: dict[str, str] = {}
if bundle_root.is_dir():
for path in sorted(bundle_root.rglob("*.py")):
if not path.is_file():
continue
try:
rel = path.relative_to(bundle_root)
except ValueError:
continue
if any(part.startswith(".") for part in rel.parts):
continue
try:
files[str(rel).replace("\\", "/")] = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
return {"files": files}
@router.get("/")
async def serve_home():
return FileResponse(STATIC_DIR / "home.html")
@@ -29,3 +56,10 @@ async def serve_login():
@router.get("/register")
async def serve_register():
return FileResponse(STATIC_DIR / "register.html")
@router.get("/users")
async def serve_users():
"""Admin panel for managing accounts and invites. Auth is enforced by
the underlying `/api/users*` endpoints, not the static page itself."""
return FileResponse(STATIC_DIR / "users.html")

View File

@@ -15,12 +15,13 @@ def _workspace_root(workspace_root: Path | None = None) -> Path:
def _shared_lib_root() -> Path:
"""Shared MicroPython stubs live under WORKSPACE_ROOT/lib; seed from bundle if missing (e.g. volume wiped)."""
lib = (config.WORKSPACE_ROOT.resolve() / LIB_DIR_NAME).resolve()
from editor_app.services.user_workspace import ensure_workspace_lib
"""Shared MicroPython stubs ship in the repo `lib/` directory and are read directly.
ensure_workspace_lib()
return lib
Treating the bundle as the single source of truth means there is no on-disk
`workspace/lib/` cache to keep in sync — updates to e.g. `machine.py` flow
straight through to Pyodide and the file tree.
"""
return (config.PROJECT_ROOT / LIB_DIR_NAME).resolve()
def normalize_relative_path(relative_path: str) -> str:
@@ -55,16 +56,8 @@ def resolve_workspace_path(relative_path: str, workspace_root: Path | None = Non
def _is_path_in_lib(target_path: Path, workspace_root: Path | None = None) -> bool:
workspace = _workspace_root(workspace_root)
lib_root = (workspace / LIB_DIR_NAME).resolve()
shared_lib_root = _shared_lib_root()
try:
target_path.resolve().relative_to(lib_root)
return True
except ValueError:
pass
try:
target_path.resolve().relative_to(shared_lib_root)
target_path.resolve().relative_to(_shared_lib_root())
return True
except ValueError:
return False

View File

@@ -16,22 +16,6 @@ _CANONICAL_DEMO_FILENAMES = (
)
def ensure_workspace_lib(workspace_root: Path | None = None) -> None:
"""Copy shipped MicroPython stubs from the repo into WORKSPACE_ROOT/lib when each file is absent."""
dst_root = (workspace_root or config.WORKSPACE_ROOT).resolve() / "lib"
dst_root.mkdir(parents=True, exist_ok=True)
src_root = config.PROJECT_ROOT.resolve() / "lib"
if not src_root.is_dir():
return
for src in sorted(src_root.glob("*.py")):
if not src.is_file():
continue
dst = dst_root / src.name
if dst.exists():
continue
shutil.copy2(src, dst)
def safe_workspace_leaf(username: str, user_id: int) -> str:
base = re.sub(r"[^a-zA-Z0-9._-]+", "-", username.strip()).strip("-").lower() or "user"
return f"{base}-{user_id}"

View File

@@ -0,0 +1,419 @@
"""Minimal MicroPython-style machine module mock for browser simulation."""
import json
_PIN_MODE_OUT = 1
_PIN_MODE_IN = 2
_PIN_MODE_PWM = 4
def _pin_view_get(name):
try:
import js
return getattr(js, name, None)
except Exception:
return None
def _pin_out_publish(pin_id, ui_mode, value):
"""Pack [ui_mode (high byte) | value (low 24 bits)] for the UI panel."""
view = _pin_view_get("__pin_out_view")
if view is None:
return
packed = ((int(ui_mode) & 0xFF) << 24) | (int(value) & 0x00FFFFFF)
try:
import js
js.Atomics.store(view, int(pin_id), packed)
except Exception:
try:
view[int(pin_id)] = packed
except Exception:
pass
def _pin_in_read(pin_id):
view = _pin_view_get("__pin_in_view")
if view is None:
return 0
try:
import js
return int(js.Atomics.load(view, int(pin_id))) & 1
except Exception:
try:
return int(view[int(pin_id)]) & 1
except Exception:
return 0
def _pin_register(pin_id, ui_mode, **extra):
payload = {"pin": int(pin_id), "mode": int(ui_mode)}
payload.update({k: v for k, v in extra.items() if v is not None})
try:
print("[pin-register]" + json.dumps(payload))
except Exception:
pass
class Pin:
"""Browser-simulated `machine.Pin`.
A control row appears in the editor UI when a pin is constructed:
* `Pin.OUT` pins show a live indicator that mirrors `value()`.
* `Pin.IN` pins show a toggle button you can click — the next call
to `value()` returns 0 or 1 accordingly.
"""
IN = 0
OUT = 1
PULL_UP = 2
PULL_DOWN = 3
OPEN_DRAIN = 7
IRQ_FALLING = 1
IRQ_RISING = 2
def __init__(self, pin_id, mode=OUT, pull=-1, value=None, **_kwargs):
self.id = int(pin_id)
self.mode = int(mode)
self.pull = int(pull) if pull != -1 else -1
self._value = 1 if value else 0
self._irq_handler = None
self._irq_trigger = 0
self._last_input = 0
self._publish()
def _ui_mode(self):
if self.mode == self.IN:
return _PIN_MODE_IN
return _PIN_MODE_OUT
def _publish(self):
ui_mode = self._ui_mode()
_pin_register(self.id, ui_mode, pull=self.pull if self.pull != -1 else None)
if ui_mode == _PIN_MODE_OUT:
_pin_out_publish(self.id, ui_mode, self._value)
def init(self, mode=-1, pull=-1, value=None):
if mode != -1:
self.mode = int(mode)
if pull != -1:
self.pull = int(pull)
if value is not None:
self._value = 1 if int(value) else 0
self._publish()
def value(self, new_value=None):
if new_value is None:
if self.mode == self.IN:
v = _pin_in_read(self.id)
if self._irq_handler is not None:
if v and not self._last_input and (self._irq_trigger & self.IRQ_RISING):
self._fire_irq()
elif not v and self._last_input and (self._irq_trigger & self.IRQ_FALLING):
self._fire_irq()
self._last_input = v
return v
return self._value
v = 1 if int(new_value) else 0
self._value = v
if self._ui_mode() == _PIN_MODE_OUT:
_pin_out_publish(self.id, _PIN_MODE_OUT, v)
return v
def on(self):
return self.value(1)
def off(self):
return self.value(0)
def high(self):
return self.value(1)
def low(self):
return self.value(0)
def toggle(self):
return self.value(0 if (self._value if self.mode == self.OUT else _pin_in_read(self.id)) else 1)
def irq(self, handler=None, trigger=IRQ_RISING | IRQ_FALLING, **_kwargs):
self._irq_handler = handler
self._irq_trigger = int(trigger)
return self
def _fire_irq(self):
if self._irq_handler is None:
return
try:
self._irq_handler(self)
except Exception as exc:
try:
print(f"[pin] irq handler raised: {exc!r}")
except Exception:
pass
def __call__(self, *args):
return self.value(*args) if args else self.value()
class PWM:
"""Browser-simulated `machine.PWM`.
Visualises the configured duty cycle as a bar in the Pins panel.
`freq()` and `duty()`/`duty_u16()`/`duty_ns()` mirror MicroPython's API.
"""
def __init__(self, pin, freq=1000, duty=None, duty_u16=None, duty_ns=None):
self._pin_id = _adc_pin_id(pin)
self._freq = int(freq) if freq else 1000
self._duty_u16 = 0
if duty_u16 is not None:
self._duty_u16 = max(0, min(65535, int(duty_u16)))
elif duty is not None:
self._duty_u16 = max(0, min(65535, int(duty) * 64))
elif duty_ns is not None:
self._set_duty_ns(int(duty_ns))
_pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def _set_duty_ns(self, ns):
period_ns = 1_000_000_000 // max(1, self._freq)
if period_ns <= 0:
self._duty_u16 = 0
return
self._duty_u16 = max(0, min(65535, int(ns) * 65535 // period_ns))
def freq(self, value=None):
if value is None:
return self._freq
self._freq = max(1, int(value))
_pin_register(self._pin_id, _PIN_MODE_PWM, freq=self._freq)
def duty(self, value=None):
if value is None:
return self._duty_u16 // 64
self._duty_u16 = max(0, min(65535, int(value) * 64))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def duty_u16(self, value=None):
if value is None:
return self._duty_u16
self._duty_u16 = max(0, min(65535, int(value)))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def duty_ns(self, value=None):
period_ns = 1_000_000_000 // max(1, self._freq)
if value is None:
return (self._duty_u16 * period_ns) // 65535
self._set_duty_ns(int(value))
_pin_out_publish(self._pin_id, _PIN_MODE_PWM, self._duty_u16)
def deinit(self):
_pin_out_publish(self._pin_id, 0, 0)
def _adc_pin_id(pin):
if isinstance(pin, int):
return pin
return int(getattr(pin, "id", 0))
def _adc_read_raw(pin_id: int) -> int:
"""Read the live slider value (0..65535) shared from the editor UI.
Backed by a SharedArrayBuffer so updates from the browser slider propagate
instantly without the script having to yield. Falls back to 0 when the
runtime is not cross-origin isolated (e.g. older browsers).
"""
try:
import js # only available inside Pyodide
view = getattr(js, "__adc_view", None)
if view is None:
return 0
try:
return int(js.Atomics.load(view, pin_id)) & 0xFFFF
except Exception:
return int(view[pin_id]) & 0xFFFF
except Exception:
return 0
class ADC:
"""Browser-simulated `machine.ADC`.
Exposes a slider in the editor UI for the given pin. `read_u16()` returns
the slider value in the 0..65535 range (matching MicroPython's API);
`read()` scales to the configured `width` (defaults to 12-bit, 0..4095).
"""
WIDTH_9BIT = 9
WIDTH_10BIT = 10
WIDTH_11BIT = 11
WIDTH_12BIT = 12
ATTN_0DB = 0
ATTN_2_5DB = 1
ATTN_6DB = 2
ATTN_11DB = 3
def __init__(self, pin):
self._pin = _adc_pin_id(pin)
self._atten = self.ATTN_11DB
self._width = self.WIDTH_12BIT
try:
print("[adc-register]" + json.dumps({"pin": self._pin}))
except Exception:
pass
def atten(self, attn):
self._atten = int(attn)
def width(self, width):
self._width = int(width)
def read_u16(self):
return _adc_read_raw(self._pin)
def read(self):
bits = self._width if self._width in (9, 10, 11, 12) else 12
max_val = (1 << bits) - 1
return (_adc_read_raw(self._pin) * max_val) // 65535
def read_uv(self):
"""Return microvolts assuming a 0..3.3V range (rough simulation)."""
return (_adc_read_raw(self._pin) * 3_300_000) // 65535
def _serial_drain_pending(buf):
"""Pull any bytes the editor's serial-monitor has pushed into the SAB ring."""
try:
import js
import base64 # noqa: F401 (kept for symmetry with write path)
indices = getattr(js, "__serial_in_indices", None)
data = getattr(js, "__serial_in_data", None)
if indices is None or data is None:
return
cap = int(getattr(js, "__serial_in_capacity", 0))
if cap <= 0:
return
r = int(js.Atomics.load(indices, 0))
w = int(js.Atomics.load(indices, 1))
while r != w:
buf.append(int(data[r]) & 0xFF)
r = (r + 1) % cap
js.Atomics.store(indices, 0, r)
except Exception:
pass
class UART:
"""Browser-simulated `machine.UART`.
A serial monitor pane appears in the editor when this is constructed.
`write()` text shows up there; characters typed into the input box arrive
via `read()` / `readline()` / `any()`.
"""
INV_TX = 1
INV_RX = 2
INV_RTS = 4
INV_CTS = 8
def __init__(self, id=0, baudrate=115200, bits=8, parity=None, stop=1,
tx=None, rx=None, timeout=0, **_kwargs):
self.id = int(id) if id is not None else 0
self.baudrate = int(baudrate)
self.bits = int(bits)
self.parity = parity
self.stop = int(stop)
self.tx = tx
self.rx = rx
self.timeout = int(timeout) if timeout is not None else 0
self._buf = bytearray()
try:
print("[serial-register]" + json.dumps({
"id": self.id,
"baudrate": self.baudrate,
}))
except Exception:
pass
def init(self, *args, **kwargs):
if args:
try:
self.baudrate = int(args[0])
except Exception:
pass
if "baudrate" in kwargs:
self.baudrate = int(kwargs["baudrate"])
def deinit(self):
self._buf = bytearray()
def any(self):
_serial_drain_pending(self._buf)
return len(self._buf)
def read(self, nbytes=None):
_serial_drain_pending(self._buf)
if not self._buf:
return None
if nbytes is None:
out = bytes(self._buf)
self._buf = bytearray()
return out
n = min(int(nbytes), len(self._buf))
if n <= 0:
return None
out = bytes(self._buf[:n])
del self._buf[:n]
return out
def readinto(self, buf, nbytes=None):
n = int(nbytes) if nbytes is not None else len(buf)
data = self.read(n)
if not data:
return None
for i, b in enumerate(data):
buf[i] = b
return len(data)
def readline(self):
_serial_drain_pending(self._buf)
if not self._buf:
return None
nl = self._buf.find(b"\n")
if nl == -1:
return None
out = bytes(self._buf[: nl + 1])
del self._buf[: nl + 1]
return out
def write(self, data):
if isinstance(data, str):
buf = data.encode("utf-8")
elif isinstance(data, (bytes, bytearray, memoryview)):
buf = bytes(data)
else:
buf = bytes([int(data) & 0xFF])
try:
import base64
encoded = base64.b64encode(buf).decode("ascii")
print(f"[serial-out]{encoded}")
except Exception:
pass
return len(buf)
def sendbreak(self):
pass
def flush(self):
pass
def txdone(self):
return True

View File

@@ -0,0 +1,56 @@
"""NeoPixel mock for Pyodide/browser execution.
Supports a useful subset of MicroPython's neopixel.NeoPixel API:
- NeoPixel(pin, n, bpp=3, timing=1)
- __setitem__, __getitem__, __len__
- fill(color)
- write() # prints current pixel buffer snapshot
"""
import json
def _normalize_color(value, bpp: int):
if not hasattr(value, "__iter__"):
raise TypeError("Color must be an iterable, e.g. (r, g, b)")
parts = [int(v) for v in value]
if len(parts) != bpp:
raise ValueError(f"Expected {bpp} color channels, got {len(parts)}")
out = []
for channel in parts:
out.append(max(0, min(255, channel)))
return tuple(out)
class NeoPixel:
def __init__(self, pin, n: int, bpp: int = 3, timing: int = 1):
self.pin = pin
self.n = int(n)
self.bpp = int(bpp)
self.timing = int(timing)
self._buf = [tuple([0] * self.bpp) for _ in range(self.n)]
def __len__(self):
return self.n
def __getitem__(self, index):
return self._buf[int(index)]
def __setitem__(self, index, color):
idx = int(index)
self._buf[idx] = _normalize_color(color, self.bpp)
def fill(self, color):
c = _normalize_color(color, self.bpp)
for i in range(self.n):
self._buf[i] = c
def write(self):
pin_id = getattr(self.pin, "id", self.pin)
payload = {
"type": "neopixel",
"pin": pin_id,
"pixels": [list(pixel) for pixel in self._buf],
"bpp": self.bpp,
}
print("[neopixel-json]" + json.dumps(payload))

View File

@@ -15,570 +15,152 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a, #1e293b);
color: #e2e8f0;
padding: 1rem;
}
.home-card {
width: min(560px, 92vw);
width: min(420px, 100%);
background: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 14px;
padding: 2rem;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
text-align: center;
}
h1 {
margin: 0 0 0.5rem 0;
font-size: 1.9rem;
color: #f8fafc;
letter-spacing: 0.02em;
}
p.tagline {
margin: 0 0 1.75rem 0;
color: #94a3b8;
font-size: 0.95rem;
}
.actions {
display: grid;
gap: 0.65rem;
}
h1 { margin: 0 0 0.75rem 0; font-size: 1.8rem; color: #f8fafc; }
p { margin: 0 0 1rem 0; color: #cbd5e1; line-height: 1.5; }
.btn {
display: inline-block;
display: block;
width: 100%;
text-decoration: none;
border-radius: 8px;
padding: 0.65rem 1rem;
border-radius: 10px;
padding: 0.85rem 1rem;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
font-size: 1rem;
text-align: center;
}
.btn-primary { background: #3b82f6; color: #ffffff; }
.btn-ghost { background: transparent; border-color: #64748b; color: #e2e8f0; }
label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.35rem; }
input[type="password"] {
width: 100%;
padding: 0.5rem 0.65rem;
border-radius: 8px;
border: 1px solid #64748b;
background: #0f172a;
color: #e2e8f0;
margin-bottom: 0.75rem;
}
.note { font-size: 0.8rem; color: #94a3b8; margin-top: 1rem; }
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
.nav span { color: #94a3b8; font-size: 0.9rem; }
.hidden { display: none !important; }
.invite-panel {
margin: 1rem 0;
padding: 0.9rem;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 10px;
background: rgba(30, 41, 59, 0.45);
}
.invite-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.4rem;
}
.invite-row input[type="email"] {
flex: 1 1 260px;
width: auto;
margin: 0;
}
.invite-row button {
margin: 0;
}
.invite-result {
margin-top: 0.55rem;
font-size: 0.85rem;
color: #cbd5e1;
word-break: break-all;
}
.users-panel {
margin: 1rem 0;
padding: 0.9rem;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 10px;
background: rgba(30, 41, 59, 0.45);
}
.users-list {
margin-top: 0.5rem;
display: grid;
gap: 0.45rem;
}
.user-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.user-row a {
color: #93c5fd;
text-decoration: none;
border: 1px solid rgba(147, 197, 253, 0.35);
border-radius: 6px;
padding: 0.25rem 0.5rem;
}
.user-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
justify-content: flex-end;
}
.btn-danger {
.btn-primary:hover { background: #2563eb; }
.btn-ghost {
background: transparent;
border-color: #f87171;
color: #fecaca;
}
.btn-danger:hover:not(:disabled) {
background: rgba(248, 113, 113, 0.15);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
#admin-users-feedback:not(:empty) {
padding: 0.5rem 0.65rem;
border-radius: 8px;
font-size: 0.85rem;
margin: 0.5rem 0;
}
#admin-users-feedback.ok {
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(74, 222, 128, 0.35);
color: #bbf7d0;
}
#admin-users-feedback.err {
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.35);
color: #fecaca;
}
.user-edit-form {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
.user-edit-form label {
display: block;
margin-bottom: 0.65rem;
}
.user-edit-form input[type='text'],
.user-edit-form input[type='password'] {
width: 100%;
max-width: 320px;
padding: 0.45rem 0.55rem;
border-radius: 8px;
border: 1px solid #64748b;
background: #0f172a;
border-color: #475569;
color: #e2e8f0;
margin-top: 0.25rem;
box-sizing: border-box;
}
.user-edit-form .edit-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.65rem;
}
.user-edit-form .super-line {
display: flex;
align-items: center;
gap: 0.5rem;
.btn-ghost:hover { background: rgba(71, 85, 105, 0.25); }
.btn-link {
background: transparent;
border: none;
color: #93c5fd;
font-size: 0.85rem;
color: #cbd5e1;
margin-bottom: 0.65rem;
cursor: pointer;
padding: 0.4rem;
text-decoration: underline;
}
.user-edit-form .super-line input {
accent-color: #3b82f6;
.signed-in {
font-size: 0.85rem;
color: #94a3b8;
margin-bottom: 0.6rem;
}
.hidden { display: none !important; }
.footnote {
margin-top: 1.25rem;
font-size: 0.78rem;
color: #64748b;
line-height: 1.4;
}
.footnote a { color: #93c5fd; text-decoration: none; }
.footnote a:hover { text-decoration: underline; }
</style>
</head>
<body>
<main class="home-card">
<h1>LED Editor</h1>
<div id="auth-nav" class="nav">
<span id="auth-greeting" class="hidden"></span>
<a class="btn btn-ghost hidden" id="link-login" href="/login">Sign in</a>
<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>. 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>
<input id="api-key" type="password" autocomplete="off" placeholder="Leave blank if not used" />
<p class="note">The key is kept in <code>sessionStorage</code>. You can also use <code>?api_key=…</code> on the editor URL.</p>
</div>
<section id="users-panel" class="users-panel hidden">
<strong>User management</strong>
<p class="note" style="margin-top:0.35rem">
New accounts sign up via an <strong>invite link</strong> below. Remove accounts here or open their workspace.
</p>
<div id="admin-users-feedback" role="status" aria-live="polite"></div>
<p style="margin: 1rem 0 0.35rem 0; font-size: 0.9rem; color: #94a3b8">Accounts</p>
<div id="users-list" class="users-list"></div>
<div id="user-edit-form" class="user-edit-form hidden">
<strong id="user-edit-heading">Edit account</strong>
<p class="note" style="margin: 0.35rem 0 0.5rem">Change login name or admin role; set a password only when you mean to reset it.</p>
<input type="hidden" id="edit-user-id" autocomplete="off" />
<label for="edit-user-username">
Username
<input type="text" id="edit-user-username" name="username" autocomplete="username" minlength="3" maxlength="64" />
</label>
<label for="edit-user-password">
New password
<input type="password" id="edit-user-password" name="password" autocomplete="new-password" minlength="8" maxlength="128" placeholder="Leave blank to keep current" />
</label>
<label class="super-line">
<input type="checkbox" id="edit-user-super" />
Superuser (can manage accounts and invites)
</label>
<div class="edit-actions">
<button type="button" class="btn btn-primary" id="edit-user-save">Save changes</button>
<button type="button" class="btn btn-ghost" id="edit-user-cancel">Cancel</button>
</div>
</div>
</section>
<section id="invite-panel" class="invite-panel hidden">
<strong>Add users via invite link</strong>
<p class="note" style="margin-top:0.4rem">
Each link lets <strong>one</strong> person create their own account at <code>/register?invite=…</code> with a password they choose. After it is used, create a new link for the next person.
</p>
<div class="invite-row">
<input id="invite-email" type="email" placeholder="new.user@example.com" autocomplete="email" />
<button type="button" class="btn btn-primary" id="invite-create-btn">Email invite</button>
<button type="button" class="btn btn-ghost" id="invite-link-btn">Invite link only</button>
</div>
<div id="invite-result" class="invite-result"></div>
<button type="button" class="btn btn-ghost hidden" id="invite-copy-btn" aria-label="Copy invite link">Copy invite link</button>
</section>
<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>
<p class="tagline">Run MicroPython in your browser. Drive simulated NeoPixels, pins, ADC, and serial.</p>
<div id="signed-in-row" class="signed-in hidden"></div>
<div class="actions">
<a class="btn btn-primary hidden" id="btn-signin" href="/login">Sign in</a>
<a class="btn btn-primary hidden" id="btn-open-editor" href="/editor">Open editor</a>
<a class="btn btn-ghost" id="btn-use-locally" href="/editor?local=1">Use locally (no login)</a>
<a class="btn-link hidden" id="btn-manage-users" href="/users">Manage users</a>
<button type="button" class="btn-link hidden" id="btn-signout">Sign out</button>
</div>
<p class="footnote">
Local mode keeps files in this browser's <code>IndexedDB</code>; from the editor you can also pick a folder on disk
(<a href="https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API" target="_blank" rel="noopener">File System Access API</a>, Chromium-only).
</p>
</main>
<script>
const storageKey = 'python-editor.api_key';
const input = document.getElementById('api-key');
const openLink = document.getElementById('open-editor');
let currentAdminUserId = null;
async function init() {
const signedInRow = document.getElementById('signed-in-row');
const btnSignIn = document.getElementById('btn-signin');
const btnOpenEditor = document.getElementById('btn-open-editor');
const btnSignOut = document.getElementById('btn-signout');
const btnManageUsers = document.getElementById('btn-manage-users');
function formatApiDetail(body) {
if (!body || body.detail === undefined || body.detail === null) return '';
const d = body.detail;
if (typeof d === 'string') return d;
if (Array.isArray(d))
return d
.map((item) =>
typeof item === 'object' && item && item.msg ? String(item.msg) : JSON.stringify(item)
)
.join(' ');
return String(d);
}
let authEnabled = false;
try {
const st = await fetch('/api/auth/status');
if (st.ok) {
const status = await st.json();
authEnabled = Boolean(status.auth_enabled);
}
} catch (_e) {
// No backend (static-only host) → behave like auth disabled.
}
function setAdminUsersFeedback(kind, msg) {
const el = document.getElementById('admin-users-feedback');
if (!el) return;
el.textContent = msg || '';
el.classList.remove('ok', 'err');
if (kind === 'ok') el.classList.add('ok');
if (kind === 'err') el.classList.add('err');
}
async function refreshAuthNav() {
const st = await fetch('/api/auth/status');
const status = await st.json();
const loginEl = document.getElementById('link-login');
const regEl = document.getElementById('link-register');
const outEl = document.getElementById('btn-logout');
const greet = document.getElementById('auth-greeting');
const optionalKey = document.getElementById('optional-api-key');
const invitePanel = document.getElementById('invite-panel');
const usersPanel = document.getElementById('users-panel');
if (!status.auth_enabled) {
currentAdminUserId = null;
loginEl.classList.add('hidden');
regEl.classList.add('hidden');
outEl.classList.add('hidden');
greet.classList.add('hidden');
if (invitePanel) invitePanel.classList.add('hidden');
if (usersPanel) usersPanel.classList.add('hidden');
if (!authEnabled) {
// No login wall, so "Sign in" makes no sense — go straight to editor.
btnOpenEditor.classList.remove('hidden');
return;
}
loginEl.classList.remove('hidden');
if (status.register_open) {
regEl.classList.remove('hidden');
}
const me = await fetch('/api/auth/me', { credentials: 'include' });
if (me.ok) {
const j = await me.json();
greet.textContent = `Signed in as ${j.user.username}`;
greet.classList.remove('hidden');
loginEl.classList.add('hidden');
regEl.classList.add('hidden');
outEl.classList.remove('hidden');
if (optionalKey) optionalKey.classList.add('hidden');
if (invitePanel) {
if (j.user && j.user.is_superuser) {
currentAdminUserId = j.user.id;
invitePanel.classList.remove('hidden');
if (usersPanel) usersPanel.classList.remove('hidden');
await refreshUsersList(j.user.id);
} else {
currentAdminUserId = null;
invitePanel.classList.add('hidden');
if (usersPanel) usersPanel.classList.add('hidden');
}
}
} else {
currentAdminUserId = null;
outEl.classList.add('hidden');
greet.classList.add('hidden');
if (invitePanel) invitePanel.classList.add('hidden');
if (usersPanel) usersPanel.classList.add('hidden');
}
}
async function refreshUsersList(viewerId) {
const usersList = document.getElementById('users-list');
if (!usersList) return;
usersList.textContent = 'Loading users...';
let me = null;
try {
const res = await fetch('/api/users', { credentials: 'include' });
const users = await res.json().catch(() => []);
if (!res.ok) {
usersList.textContent = 'Unable to load users.';
return;
}
usersList.innerHTML = '';
for (const user of users) {
const row = document.createElement('div');
row.className = 'user-row';
const name = document.createElement('span');
name.textContent = `${user.username}${user.is_superuser ? ' (admin)' : ''}`;
const actions = document.createElement('div');
actions.className = 'user-actions';
const link = document.createElement('a');
link.href = `/editor?workspace_user_id=${encodeURIComponent(String(user.id))}`;
link.textContent = 'Open workspace';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'btn btn-ghost';
editBtn.textContent = 'Edit';
editBtn.title = `Edit ${user.username}`;
editBtn.addEventListener('click', () => openUserEdit(user.id, user.username, user.is_superuser));
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'btn btn-ghost btn-danger';
delBtn.textContent = 'Remove';
const isSelf = Number(user.id) === Number(viewerId);
delBtn.disabled = isSelf;
delBtn.title = isSelf ? 'You cannot delete your own account here' : 'Permanently remove this user';
delBtn.addEventListener('click', async () => {
const ok = confirm(
`Remove user “${user.username}”? Their workspace folder stays on disk until you delete it manually.`
);
if (!ok) return;
delBtn.disabled = true;
setAdminUsersFeedback('', '');
try {
const dres = await fetch(`/api/users/${encodeURIComponent(String(user.id))}`, {
method: 'DELETE',
credentials: 'include'
});
const body = await dres.json().catch(() => ({}));
if (!dres.ok) {
setAdminUsersFeedback('err', formatApiDetail(body) || dres.statusText || 'Could not remove user');
delBtn.disabled = false;
return;
}
setAdminUsersFeedback('ok', `Removed ${user.username}.`);
await refreshUsersList(viewerId);
} catch (err) {
setAdminUsersFeedback('err', String(err.message || err));
delBtn.disabled = false;
}
});
actions.appendChild(editBtn);
actions.appendChild(link);
actions.appendChild(delBtn);
row.appendChild(name);
row.appendChild(actions);
usersList.appendChild(row);
}
} catch (_err) {
usersList.textContent = 'Unable to load users.';
const meRes = await fetch('/api/auth/me', { credentials: 'include' });
if (meRes.ok) me = (await meRes.json()).user;
} catch (_e) {
// ignore
}
}
function closeUserEdit() {
const sheet = document.getElementById('user-edit-form');
if (sheet) sheet.classList.add('hidden');
}
function openUserEdit(userId, username, isSuperuser) {
document.getElementById('edit-user-id').value = String(userId);
document.getElementById('edit-user-username').value = username;
document.getElementById('edit-user-password').value = '';
document.getElementById('edit-user-super').checked = Boolean(isSuperuser);
document.getElementById('user-edit-heading').textContent = `Edit @${username}`;
document.getElementById('user-edit-form').classList.remove('hidden');
}
document.getElementById('edit-user-cancel').addEventListener('click', () => closeUserEdit());
document.getElementById('edit-user-save').addEventListener('click', async () => {
const id = document.getElementById('edit-user-id').value;
const u = document.getElementById('edit-user-username').value.trim();
const pw = document.getElementById('edit-user-password').value;
const superU = document.getElementById('edit-user-super').checked;
const saveBtn = document.getElementById('edit-user-save');
setAdminUsersFeedback('', '');
if (!u || u.length < 3) {
setAdminUsersFeedback('err', 'Username must be at least 3 characters.');
if (!me) {
btnSignIn.classList.remove('hidden');
return;
}
const payload = { username: u, is_superuser: superU };
const tpw = pw.trim();
if (tpw.length > 0) {
if (tpw.length < 8) {
setAdminUsersFeedback('err', 'New password must be at least 8 characters (or leave blank).');
return;
}
payload.password = tpw;
}
saveBtn.disabled = true;
try {
const res = await fetch(`/api/users/${encodeURIComponent(String(id))}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
setAdminUsersFeedback('err', formatApiDetail(body) || res.statusText || 'Update failed');
saveBtn.disabled = false;
return;
}
setAdminUsersFeedback('ok', `Updated @${body.username}.`);
closeUserEdit();
await refreshUsersList(currentAdminUserId);
} catch (err) {
setAdminUsersFeedback('err', String(err.message || err));
}
saveBtn.disabled = false;
});
document.getElementById('btn-logout').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
signedInRow.textContent = `Signed in as ${me.username}${me.is_superuser ? ' (admin)' : ''}`;
signedInRow.classList.remove('hidden');
btnOpenEditor.classList.remove('hidden');
btnSignOut.classList.remove('hidden');
if (me.is_superuser) btnManageUsers.classList.remove('hidden');
}
document.getElementById('btn-signout').addEventListener('click', async () => {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
} catch (_e) {
// ignore
}
window.location.reload();
});
try {
const params = new URLSearchParams(window.location.search);
const fromQuery = params.get('api_key');
if (fromQuery) {
sessionStorage.setItem(storageKey, fromQuery);
}
const existing = sessionStorage.getItem(storageKey);
if (existing) {
input.value = existing;
}
} catch (_e) {}
openLink.addEventListener('click', () => {
try {
const v = input.value.trim();
if (v) {
sessionStorage.setItem(storageKey, v);
} else {
sessionStorage.removeItem(storageKey);
}
} catch (_e) {}
});
function showInviteOutcome(inviteUrl, headline) {
const result = document.getElementById('invite-result');
const copyBtn = document.getElementById('invite-copy-btn');
result.textContent = '';
const line = document.createElement('p');
line.style.margin = '0 0 0.35rem 0';
line.textContent = headline;
const a = document.createElement('a');
a.href = inviteUrl;
a.textContent = inviteUrl;
a.style.color = '#93c5fd';
a.style.wordBreak = 'break-all';
result.appendChild(line);
result.appendChild(a);
copyBtn.classList.remove('hidden');
copyBtn.dataset.inviteUrl = inviteUrl;
}
function clearInviteOutcome() {
const result = document.getElementById('invite-result');
const copyBtn = document.getElementById('invite-copy-btn');
result.textContent = '';
copyBtn.classList.add('hidden');
copyBtn.textContent = 'Copy invite link';
delete copyBtn.dataset.inviteUrl;
}
document.getElementById('invite-copy-btn').addEventListener('click', async () => {
const copyBtn = document.getElementById('invite-copy-btn');
const url = copyBtn.dataset.inviteUrl;
if (!url) return;
try {
await navigator.clipboard.writeText(url);
copyBtn.textContent = 'Copied!';
setTimeout(() => {
copyBtn.textContent = 'Copy invite link';
}, 2000);
} catch (_e) {
copyBtn.textContent = 'Copy failed — select the link above';
setTimeout(() => {
copyBtn.textContent = 'Copy invite link';
}, 2500);
}
});
document.getElementById('invite-create-btn').addEventListener('click', async () => {
const emailInput = document.getElementById('invite-email');
const result = document.getElementById('invite-result');
const email = (emailInput.value || '').trim();
clearInviteOutcome();
if (!email) {
result.textContent = 'Enter an email address first.';
return;
}
try {
const res = await fetch('/api/users/invites', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, expires_days: 7 })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
result.textContent = formatApiDetail(body) || res.statusText || 'Failed to create invite';
return;
}
const headline = body.delivered
? 'Email sent. They can also use this link to register:'
: 'Email not sent (SMTP not configured). Share this registration link:';
showInviteOutcome(body.invite_url, headline);
} catch (err) {
result.textContent = String(err.message || err);
}
});
document.getElementById('invite-link-btn').addEventListener('click', async () => {
const result = document.getElementById('invite-result');
clearInviteOutcome();
try {
const res = await fetch('/api/users/invites', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: null, expires_days: 7 })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
result.textContent = formatApiDetail(body) || res.statusText || 'Failed to create invite link';
return;
}
showInviteOutcome(body.invite_url, 'Share this link so they can create an account:');
} catch (err) {
result.textContent = String(err.message || err);
}
});
refreshAuthNav();
init();
</script>
</body>
</html>

View File

@@ -6,7 +6,7 @@
<meta name="theme-color" content="#2d3748">
<title>LED Editor</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css?v=13">
<link rel="stylesheet" href="/static/styles.css?v=32">
</head>
<body>
<div class="container">
@@ -28,27 +28,28 @@
<div class="main-content">
<div class="editor-header">
<button id="sidebar-toggle" class="sidebar-toggle" type="button" aria-label="Toggle file tree" aria-controls="sidebar" aria-expanded="false"></button>
<button id="sidebar-toggle" class="sidebar-toggle" type="button" aria-label="Toggle file tree" aria-controls="sidebar" aria-expanded="true" title="Toggle file browser"><span class="sidebar-toggle-icon" aria-hidden="true"></span></button>
<div class="file-info">
<span id="save-status" class="save-status"></span>
<span class="runtime-hint" title="Python runs locally in your browser via Pyodide; completions use Jedi in the same runtime.">Browser · Pyodide</span>
<span id="lsp-status" class="runtime-hint" title="Jedi in-browser diagnostics">LSP: n/a</span>
<span id="workspace-badge" class="runtime-hint hidden"></span>
</div>
<div class="mode-toggle">
<a id="home-btn" class="mode-btn active" href="/">Home</a>
</div>
<div class="editor-actions">
<button id="run-btn" disabled>Run Python</button>
<button id="stop-btn" disabled>Stop</button>
<label for="run-main-checkbox" class="run-main-toggle">
<input type="checkbox" id="run-main-checkbox" />
Run `main.py`
</label>
<label for="panel-16x16-checkbox" class="run-main-toggle">
<input type="checkbox" id="panel-16x16-checkbox" />
16x16 panel
</label>
<button id="run-btn" class="icon-btn" disabled aria-label="Run" title="Run"></button>
<button id="stop-btn" class="icon-btn" disabled aria-label="Stop" title="Stop"></button>
<details class="header-menu" id="header-menu">
<summary class="menu-toggle" aria-label="Editor options" title="Options"></summary>
<div class="menu-content" role="menu">
<a href="/" class="menu-item" id="home-btn" role="menuitem">🏠 Home</a>
<label class="menu-item menu-checkbox">
<input type="checkbox" id="run-main-checkbox" />
Run main.py
</label>
<label class="menu-item menu-checkbox">
<input type="checkbox" id="panel-16x16-checkbox" />
16×16 panel
</label>
</div>
</details>
</div>
</div>
@@ -60,20 +61,47 @@
</div>
<section id="led-sim-panel" class="led-sim-panel hidden" aria-label="NeoPixel Simulator">
<div class="led-sim-header">
<h3>NeoPixel Simulator</h3>
<div class="led-sim-actions">
<button id="led-run-btn" type="button">Run</button>
<button id="led-stop-btn" type="button">Stop</button>
<button id="led-close-btn" type="button" aria-label="Close simulator">Close</button>
</div>
</div>
<div id="led-meta" class="led-meta">Waiting for neopixel.write()...</div>
<div id="led-grid" class="led-grid"></div>
</section>
<div class="console-container">
<div class="console-header">Console Output</div>
<section id="pin-panel" class="pin-panel hidden" aria-label="Pins">
<div class="pin-panel-header">
<span class="pin-panel-title">Pins</span>
<span class="pin-panel-hint">OUT shows live state · IN is clickable · PWM shows duty</span>
</div>
<div id="pin-rows" class="pin-rows"></div>
</section>
<section id="adc-panel" class="adc-panel hidden" aria-label="ADC inputs">
<div class="adc-panel-header">
<span class="adc-panel-title">ADC inputs</span>
<span class="adc-panel-hint">drag to set value (065535)</span>
</div>
<div id="adc-sliders" class="adc-sliders"></div>
</section>
<section id="serial-panel" class="serial-panel hidden" aria-label="Serial monitor">
<div class="serial-header">
<span class="serial-title">Serial monitor</span>
<span id="serial-meta" class="serial-meta"></span>
<button type="button" id="serial-clear" class="serial-clear" title="Clear">Clear</button>
</div>
<div id="serial-output" class="serial-output" aria-live="polite"></div>
<form id="serial-form" class="serial-form" autocomplete="off">
<input id="serial-input" type="text" class="serial-input" placeholder="Type and press Enter to send" />
<label class="serial-newline" title="Append \n on send">
<input type="checkbox" id="serial-newline-checkbox" checked />
<span>\n</span>
</label>
<button type="submit" class="serial-send">Send</button>
</form>
</section>
<div class="console-container" id="console-container">
<button type="button" class="console-header" id="console-toggle" aria-expanded="true" aria-controls="console-output">
<span class="chevron" aria-hidden="true"></span>
<span>Console Output</span>
</button>
<pre id="console-output" class="console-output"></pre>
</div>
</div>
@@ -90,6 +118,6 @@
</div>
</div>
<script type="module" src="/static/script.js?v=27"></script>
<script type="module" src="/static/script.js?v=56"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,109 @@ await micropip.install("jedi")
self.onmessage = async (event) => {
const { id, type, payload } = event.data || {};
try {
/* Fast-path: fire-and-forget messages used by the SAB-less fallback
for pin input + ADC slider values. We update the worker-local
Int32Array so Python's next read sees the new value, with no
Pyodide round-trip and no reply expected. */
if (type === 'pinIn') {
const view = self.__pin_in_view;
const pin = payload && Number(payload.pin);
if (view && Number.isFinite(pin) && pin >= 0 && pin < view.length) {
try {
view[pin] = (payload.value | 0) ? 1 : 0;
} catch (_e) {
// ignore
}
}
return;
}
if (type === 'adcSet') {
const view = self.__adc_view;
const pin = payload && Number(payload.pin);
if (view && Number.isFinite(pin) && pin >= 0 && pin < view.length) {
const value = Math.max(0, Math.min(65535, payload.value | 0));
try {
view[pin] = value;
} catch (_e) {
// ignore
}
}
return;
}
if (type === 'init') {
/* The main thread shares an Int32Array-backed SAB so Python ADC.read*()
can pick up live slider values without yielding. */
const adcSab = payload && payload.adcSab;
if (adcSab && typeof adcSab !== 'string') {
try {
self.__adc_view = new Int32Array(adcSab);
} catch (_e) {
self.__adc_view = null;
}
}
/* No SAB? Fall back to a worker-local Int32Array; the main thread
then delivers slider drags via `postMessage({type:'adcSet'})`. */
if (!self.__adc_view) {
try {
self.__adc_view = new Int32Array(64);
} catch (_e) {
self.__adc_view = null;
}
}
/* Pin output state: Int32Array[64] — Python writes packed
[ui_mode << 24 | value (low 24 bits)] entries, the editor UI reads
them every frame to drive the Pins panel indicators. */
const pinOutSab = payload && payload.pinOutSab;
if (pinOutSab && typeof pinOutSab !== 'string') {
try {
self.__pin_out_view = new Int32Array(pinOutSab);
} catch (_e) {
self.__pin_out_view = null;
}
}
if (!self.__pin_out_view) {
try {
self.__pin_out_view = new Int32Array(64);
} catch (_e) {
self.__pin_out_view = null;
}
}
/* Pin input state: Int32Array[64] — UI buttons write 0/1 per pin,
Python's `Pin.value()` for IN mode reads the matching slot. */
const pinInSab = payload && payload.pinInSab;
if (pinInSab && typeof pinInSab !== 'string') {
try {
self.__pin_in_view = new Int32Array(pinInSab);
} catch (_e) {
self.__pin_in_view = null;
}
}
if (!self.__pin_in_view) {
try {
self.__pin_in_view = new Int32Array(64);
} catch (_e) {
self.__pin_in_view = null;
}
}
/* Tell Python whether SAB was actually wired up so machine.py can
emit `[pin-out]` print markers as a fallback for the live UI. */
self.__sab_isolated = Boolean(pinOutSab && typeof pinOutSab !== 'string');
/* Serial-in ring buffer: first 8 bytes are [readIdx, writeIdx] (Int32),
followed by `capacity` bytes of payload data. */
const serialSab = payload && payload.serialSab;
if (serialSab && typeof serialSab !== 'string') {
try {
self.__serial_in_indices = new Int32Array(serialSab, 0, 2);
const capacity = serialSab.byteLength - 8;
self.__serial_in_capacity = capacity;
self.__serial_in_data = new Uint8Array(serialSab, 8, capacity);
} catch (_e) {
self.__serial_in_indices = null;
self.__serial_in_data = null;
self.__serial_in_capacity = 0;
}
}
await ensurePyodide();
self.postMessage({ id, type: 'init', ok: true });
return;

File diff suppressed because it is too large Load Diff

View File

@@ -16,7 +16,8 @@ body {
button,
.tab,
.file-item,
.mode-btn {
.menu-item,
.menu-toggle {
touch-action: manipulation;
}
@@ -28,7 +29,7 @@ button,
}
.sidebar-toggle {
display: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
@@ -37,7 +38,8 @@ button,
background: white;
color: #2d3748;
border-radius: 8px;
font-size: 1.2rem;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
flex-shrink: 0;
}
@@ -46,10 +48,99 @@ button,
background: #f7fafc;
}
.sidebar-toggle .sidebar-toggle-icon {
display: inline-block;
transition: transform 0.15s ease;
}
/* Flip the chevron when the file browser is hidden. */
.sidebar-toggle[aria-expanded="false"] .sidebar-toggle-icon {
transform: rotate(180deg);
}
.sidebar-backdrop {
display: none;
}
/* Editor-header dropdown menu (Home + run options). */
.header-menu {
position: relative;
}
.menu-toggle {
list-style: none;
cursor: pointer;
padding: 0.45rem 0.7rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: white;
color: #4a5568;
font-size: 1.1rem;
line-height: 1;
user-select: none;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
min-height: 36px;
}
.menu-toggle::-webkit-details-marker {
display: none;
}
.menu-toggle::marker {
display: none;
content: '';
}
.menu-toggle:hover {
background: #f7fafc;
border-color: #cbd5e0;
}
.header-menu[open] > .menu-toggle {
background: #edf2f7;
border-color: #cbd5e0;
}
.menu-content {
position: absolute;
right: 0;
top: calc(100% + 6px);
background: white;
border: 1px solid #cbd5e0;
border-radius: 8px;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
z-index: 60;
min-width: 220px;
padding: 0.35rem;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.menu-item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.55rem 0.7rem;
border-radius: 6px;
text-decoration: none;
color: #2d3748;
font-size: 0.9rem;
cursor: pointer;
white-space: nowrap;
}
.menu-item:hover {
background: #f7fafc;
}
.menu-checkbox input[type="checkbox"] {
margin: 0;
}
/* Sidebar */
.sidebar {
width: 300px;
@@ -108,6 +199,8 @@ button,
align-items: center;
gap: 0.5rem;
transition: background-color 0.2s;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.file-item:hover {
@@ -116,6 +209,7 @@ button,
.file-item.selected {
background-color: #3182ce;
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.22);
}
.file-item.drag-target {
@@ -131,11 +225,6 @@ button,
font-weight: 600;
}
.file-item.root-drop {
border: 1px dashed #4a5568;
margin-bottom: 0.5rem;
}
.file-icon {
font-size: 1rem;
}
@@ -179,6 +268,49 @@ button,
border-radius: 6px;
padding: 0.2rem 0.45rem;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.workspace-badge-label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 220px;
}
.workspace-badge-exit,
.workspace-badge-action {
appearance: none;
border: 1px solid #cbd5e1;
background: transparent;
color: #475569;
border-radius: 4px;
padding: 0.05rem 0.4rem;
font-size: 0.7rem;
cursor: pointer;
line-height: 1.4;
}
.workspace-badge-exit:hover,
.workspace-badge-action:hover {
background: #e2e8f0;
}
.workspace-badge-action {
border-color: #93c5fd;
color: #1d4ed8;
}
.workspace-badge-action:hover {
background: #dbeafe;
}
.workspace-badge-note {
font-size: 0.7rem;
color: #94a3b8;
font-style: italic;
}
#current-file {
@@ -199,31 +331,6 @@ button,
color: #e53e3e;
}
.mode-toggle {
display: inline-flex;
border: 1px solid #cbd5e0;
border-radius: 8px;
overflow: hidden;
}
.mode-btn {
border: none;
background: #edf2f7;
color: #4a5568;
padding: 0.45rem 0.8rem;
font-size: 0.85rem;
cursor: pointer;
}
.mode-btn + .mode-btn {
border-left: 1px solid #cbd5e0;
}
.mode-btn.active {
background: #3182ce;
color: white;
}
.editor-actions {
display: flex;
gap: 0.5rem;
@@ -240,6 +347,21 @@ button,
transition: all 0.2s;
}
.icon-btn {
font-size: 1rem;
line-height: 1;
padding: 0.5rem 0.75rem;
min-width: 40px;
}
#run-btn.icon-btn {
color: #2f855a;
}
#stop-btn.icon-btn {
color: #c53030;
}
.editor-actions button:hover:not(:disabled) {
background-color: #f7fafc;
border-color: #cbd5e0;
@@ -254,23 +376,6 @@ button,
background-color: #edf2f7;
}
.run-main-toggle {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.4rem 0.6rem;
font-size: 0.85rem;
color: #374151;
background: white;
white-space: nowrap;
}
.run-main-toggle input[type="checkbox"] {
margin: 0;
}
.editor-container {
flex: 1;
position: relative;
@@ -375,7 +480,7 @@ button,
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
padding: 1rem;
padding: 0;
resize: none;
}
@@ -386,6 +491,18 @@ button,
line-height: 1.5;
}
/* Pin line-number gutter hard to the left edge of the editor pane. */
.cm-editor .cm-gutters {
border-right: 1px solid #e2e8f0;
background: #f7fafc;
padding-left: 0;
}
.cm-editor .cm-content {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.cm-focused {
outline: none;
}
@@ -396,6 +513,15 @@ button,
display: flex;
flex-direction: column;
background: #0f172a;
flex-shrink: 0;
}
.console-container.is-collapsed {
height: auto;
}
.console-container.is-collapsed .console-output {
display: none;
}
.led-sim-panel {
@@ -481,11 +607,415 @@ button,
box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.45);
}
.pin-panel {
border-top: 1px solid #e2e8f0;
background: #0b1220;
color: #e5e7eb;
padding: 0.55rem 0.75rem 0.7rem;
}
.pin-panel.hidden {
display: none;
}
.pin-panel-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.45rem;
}
.pin-panel-title {
font-size: 0.85rem;
font-weight: 600;
color: #f3f4f6;
}
.pin-panel-hint {
font-size: 0.7rem;
color: #94a3b8;
}
.pin-rows {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 26vh;
overflow-y: auto;
}
.pin-row {
display: grid;
grid-template-columns: 70px 24px 56px 1fr 90px;
align-items: center;
gap: 0.55rem;
font-size: 0.8rem;
}
.pin-row-label {
color: #cbd5e1;
font-variant-numeric: tabular-nums;
}
.pin-led {
width: 14px;
height: 14px;
border-radius: 50%;
background: #1e293b;
border: 1px solid #334155;
box-shadow: inset 0 0 4px rgba(0, 0, 0, 0.6);
justify-self: center;
}
.pin-led.on {
background: #fde047;
border-color: #facc15;
box-shadow: 0 0 10px rgba(253, 224, 71, 0.7), inset 0 0 4px rgba(0, 0, 0, 0.25);
}
.pin-toggle {
appearance: none;
border: 1px solid #334155;
background: #1e293b;
color: #e5e7eb;
border-radius: 6px;
padding: 0.2rem 0;
font-family: 'SFMono-Regular', Menlo, monospace;
font-size: 0.85rem;
cursor: pointer;
text-align: center;
}
.pin-toggle.on {
background: #16a34a;
border-color: #15803d;
color: #f0fdf4;
}
.pin-pwm-bar {
position: relative;
height: 10px;
background: #1e293b;
border-radius: 999px;
border: 1px solid #334155;
overflow: hidden;
}
.pin-pwm-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #38bdf8, #818cf8);
transition: width 80ms linear;
}
.pin-row-detail {
text-align: right;
font-size: 0.72rem;
color: #94a3b8;
font-variant-numeric: tabular-nums;
}
@media (max-width: 600px) {
.pin-row {
grid-template-columns: 60px 22px 50px 1fr 70px;
gap: 0.4rem;
font-size: 0.75rem;
}
.pin-row-detail {
font-size: 0.68rem;
}
}
.adc-panel {
border-top: 1px solid #e2e8f0;
background: #0f172a;
color: #e5e7eb;
padding: 0.6rem 0.75rem 0.75rem;
}
.adc-panel.hidden {
display: none;
}
.adc-panel-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.45rem;
}
.adc-panel-title {
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.02em;
color: #f3f4f6;
}
.adc-panel-hint {
font-size: 0.72rem;
color: #9ca3af;
}
.adc-sliders {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 32vh;
overflow-y: auto;
}
.adc-row {
display: grid;
grid-template-columns: 90px 1fr 130px;
align-items: center;
gap: 0.6rem;
}
.adc-row-label {
font-size: 0.8rem;
color: #cbd5e1;
}
.adc-slider {
width: 100%;
appearance: none;
-webkit-appearance: none;
height: 6px;
background: #1e293b;
border-radius: 999px;
outline: none;
cursor: pointer;
}
.adc-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #38bdf8;
border: 2px solid #0f172a;
cursor: grab;
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.3);
}
.adc-slider::-webkit-slider-thumb:active {
cursor: grabbing;
}
.adc-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #38bdf8;
border: 2px solid #0f172a;
cursor: grab;
}
.adc-readout {
font-family: 'SFMono-Regular', Menlo, monospace;
font-size: 0.78rem;
color: #94a3b8;
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (max-width: 600px) {
.adc-row {
grid-template-columns: 1fr;
gap: 0.25rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
}
.adc-row-label {
font-size: 0.78rem;
}
.adc-readout {
text-align: left;
}
}
.serial-panel {
border-top: 1px solid #e2e8f0;
background: #0b1220;
color: #e5e7eb;
padding: 0.55rem 0.75rem 0.7rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.serial-panel.hidden {
display: none;
}
.serial-header {
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.serial-title {
font-size: 0.85rem;
font-weight: 600;
color: #f3f4f6;
}
.serial-meta {
font-size: 0.72rem;
color: #94a3b8;
flex: 1 1 auto;
}
.serial-clear {
appearance: none;
border: 1px solid #334155;
background: #1e293b;
color: #e5e7eb;
padding: 0.15rem 0.55rem;
border-radius: 6px;
font-size: 0.72rem;
cursor: pointer;
}
.serial-clear:hover {
background: #243244;
}
.serial-output {
background: #020617;
border: 1px solid #1e293b;
border-radius: 6px;
padding: 0.55rem 0.7rem;
font-family: 'SFMono-Regular', Menlo, Consolas, monospace;
font-size: 0.82rem;
line-height: 1.4;
height: 120px;
max-height: 28vh;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.serial-rx {
color: #e2e8f0;
}
.serial-tx {
color: #34d399;
}
.serial-form {
display: flex;
gap: 0.5rem;
align-items: center;
}
.serial-input {
flex: 1 1 auto;
min-width: 0;
background: #0f172a;
border: 1px solid #334155;
color: #f8fafc;
padding: 0.4rem 0.6rem;
border-radius: 6px;
font-family: 'SFMono-Regular', Menlo, Consolas, monospace;
font-size: 0.85rem;
}
.serial-input:focus {
outline: none;
border-color: #38bdf8;
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.25);
}
.serial-newline {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: #cbd5e1;
user-select: none;
}
.serial-send {
appearance: none;
border: 1px solid #1d4ed8;
background: #2563eb;
color: #fff;
font-weight: 600;
padding: 0.4rem 0.85rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.82rem;
}
.serial-send:hover {
background: #1d4ed8;
}
@media (max-width: 600px) {
.serial-output {
height: 110px;
font-size: 0.78rem;
}
.serial-form {
flex-wrap: wrap;
}
.serial-input {
flex: 1 1 100%;
}
.serial-newline {
flex: 0 0 auto;
}
.serial-send {
flex: 0 0 auto;
margin-left: auto;
}
}
.console-header {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
color: #cbd5e1;
border-bottom: 1px solid #1e293b;
background: transparent;
border-left: 0;
border-right: 0;
border-top: 0;
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
text-align: left;
cursor: pointer;
font-family: inherit;
}
.console-header:hover {
background: rgba(255, 255, 255, 0.04);
}
.console-header .chevron {
display: inline-block;
transition: transform 0.15s ease;
color: #94a3b8;
}
.console-container.is-collapsed .console-header .chevron {
transform: rotate(-90deg);
}
.console-container.is-collapsed .console-header {
border-bottom: 0;
}
.console-output {
@@ -574,6 +1104,13 @@ button,
border-color: #2c5aa0;
}
/* Desktop: hide the file browser when collapsed via the hamburger toggle. */
@media (min-width: 769px) {
.sidebar.is-collapsed {
display: none;
}
}
/* Responsive design */
@media (max-width: 768px) {
.container {
@@ -638,51 +1175,53 @@ button,
}
.editor-header {
padding: 0.55rem 0.65rem;
flex-wrap: wrap;
gap: 0.45rem;
padding: 0.5rem 0.6rem;
flex-wrap: nowrap;
gap: 0.4rem;
align-items: center;
padding-top: max(0.55rem, env(safe-area-inset-top));
padding-top: max(0.5rem, env(safe-area-inset-top));
}
.file-info {
flex: 1 1 auto;
min-width: 0;
gap: 0.5rem;
flex-wrap: wrap;
gap: 0.4rem;
flex-wrap: nowrap;
overflow: hidden;
}
.file-info .runtime-hint {
.save-status {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#workspace-badge {
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
}
.mode-toggle {
order: 3;
}
.mode-btn {
min-height: 36px;
padding: 0.5rem 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 8rem;
}
.editor-actions {
width: 100%;
order: 4;
flex-wrap: wrap;
gap: 0.4rem;
flex: 0 0 auto;
gap: 0.35rem;
}
.editor-actions button {
flex: 1 1 110px;
.editor-actions .icon-btn {
flex: 0 0 auto;
font-size: 1.05rem;
padding: 0.45rem 0.7rem;
min-width: 40px;
min-height: 40px;
font-size: 0.95rem;
}
.run-main-toggle {
flex: 1 1 calc(50% - 0.4rem);
.menu-toggle {
min-height: 40px;
font-size: 0.85rem;
flex: 0 0 auto;
padding: 0.45rem 0.55rem;
}
.tabs {
@@ -756,6 +1295,57 @@ button,
}
}
/* ADC / Pins / Serial: touch-friendly on phones + safe-area padding */
@media (max-width: 768px) {
.adc-panel,
.pin-panel,
.serial-panel {
padding-left: max(0.75rem, env(safe-area-inset-left));
padding-right: max(0.75rem, env(safe-area-inset-right));
padding-bottom: max(0.65rem, env(safe-area-inset-bottom));
-webkit-tap-highlight-color: transparent;
}
.adc-slider {
min-height: 2.75rem;
padding: 0.75rem 0;
box-sizing: border-box;
}
.adc-slider::-webkit-slider-thumb {
width: 28px;
height: 28px;
}
.adc-slider::-moz-range-thumb {
width: 28px;
height: 28px;
}
.pin-toggle {
min-height: 44px;
min-width: 44px;
padding: 0.35rem 0.45rem;
}
.pin-led {
width: 18px;
height: 18px;
}
.serial-send,
.serial-clear {
min-height: 44px;
padding: 0.4rem 0.85rem;
font-size: 0.9rem;
}
.serial-input {
min-height: 44px;
font-size: 16px;
}
}
/* Hide drawer affordances entirely on desktop, even with [hidden] flipped off. */
@media (min-width: 769px) {
.sidebar-backdrop {

484
src/static/users.html Normal file
View File

@@ -0,0 +1,484 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage users — LED Editor</title>
<link rel="icon" href="data:,">
<style>
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a, #1e293b);
color: #e2e8f0;
}
.home-card {
width: min(640px, 92vw);
background: rgba(15, 23, 42, 0.78);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 14px;
padding: 2rem;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.35);
}
h1 { margin: 0 0 0.5rem 0; font-size: 1.6rem; color: #f8fafc; }
p { margin: 0 0 1rem 0; color: #cbd5e1; line-height: 1.5; }
.btn {
display: inline-block;
text-decoration: none;
border-radius: 8px;
padding: 0.55rem 0.9rem;
font-weight: 600;
border: 1px solid transparent;
cursor: pointer;
font-size: 0.95rem;
}
.btn-primary { background: #3b82f6; color: #ffffff; }
.btn-ghost { background: transparent; border-color: #64748b; color: #e2e8f0; }
.btn-danger { background: transparent; border-color: #f87171; color: #fecaca; }
.btn-danger:hover:not(:disabled) { background: rgba(248, 113, 113, 0.15); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
label { display: block; font-size: 0.85rem; color: #94a3b8; margin-bottom: 0.35rem; }
.note { font-size: 0.85rem; color: #94a3b8; }
.nav { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 1rem; align-items: center; }
.hidden { display: none !important; }
.invite-panel,
.users-panel {
margin: 1rem 0;
padding: 0.9rem;
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 10px;
background: rgba(30, 41, 59, 0.45);
}
.invite-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.4rem;
}
.invite-row input[type="email"] {
flex: 1 1 260px;
padding: 0.5rem 0.65rem;
border-radius: 8px;
border: 1px solid #64748b;
background: #0f172a;
color: #e2e8f0;
}
.invite-result {
margin-top: 0.55rem;
font-size: 0.85rem;
color: #cbd5e1;
word-break: break-all;
}
.users-list { margin-top: 0.5rem; display: grid; gap: 0.45rem; }
.user-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.user-row a {
color: #93c5fd;
text-decoration: none;
border: 1px solid rgba(147, 197, 253, 0.35);
border-radius: 6px;
padding: 0.25rem 0.5rem;
}
.user-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
justify-content: flex-end;
}
#admin-users-feedback:not(:empty) {
padding: 0.5rem 0.65rem;
border-radius: 8px;
font-size: 0.85rem;
margin: 0.5rem 0;
}
#admin-users-feedback.ok {
background: rgba(34, 197, 94, 0.12);
border: 1px solid rgba(74, 222, 128, 0.35);
color: #bbf7d0;
}
#admin-users-feedback.err {
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.35);
color: #fecaca;
}
.user-edit-form {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid rgba(148, 163, 184, 0.25);
}
.user-edit-form label { display: block; margin-bottom: 0.65rem; }
.user-edit-form input[type='text'],
.user-edit-form input[type='password'] {
width: 100%;
max-width: 320px;
padding: 0.45rem 0.55rem;
border-radius: 8px;
border: 1px solid #64748b;
background: #0f172a;
color: #e2e8f0;
margin-top: 0.25rem;
}
.user-edit-form .edit-actions {
display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.65rem;
}
.user-edit-form .super-line {
display: flex; align-items: center; gap: 0.5rem;
font-size: 0.85rem; color: #cbd5e1; margin-bottom: 0.65rem;
}
.user-edit-form .super-line input { accent-color: #3b82f6; }
</style>
</head>
<body>
<main class="home-card">
<h1>Manage users</h1>
<p class="note">Superuser-only. Add accounts via invite link, edit roles, or remove users.</p>
<div class="nav">
<a class="btn btn-ghost" href="/">← Home</a>
<a class="btn btn-ghost" href="/editor">Open editor</a>
<span id="auth-greeting"></span>
<button type="button" class="btn btn-ghost hidden" id="btn-logout">Sign out</button>
</div>
<div id="not-allowed" class="hidden">
<p style="color:#fca5a5">You need to be signed in as a superuser to manage users.
<a href="/login?next=/users" style="color:#93c5fd">Sign in</a></p>
</div>
<section id="users-panel" class="users-panel hidden">
<strong>Accounts</strong>
<div id="admin-users-feedback" role="status" aria-live="polite"></div>
<div id="users-list" class="users-list"></div>
<div id="user-edit-form" class="user-edit-form hidden">
<strong id="user-edit-heading">Edit account</strong>
<p class="note" style="margin: 0.35rem 0 0.5rem">Change login name or admin role; set a password only when you mean to reset it.</p>
<input type="hidden" id="edit-user-id" autocomplete="off" />
<label for="edit-user-username">Username
<input type="text" id="edit-user-username" name="username" autocomplete="username" minlength="3" maxlength="64" />
</label>
<label for="edit-user-password">New password
<input type="password" id="edit-user-password" name="password" autocomplete="new-password" minlength="8" maxlength="128" placeholder="Leave blank to keep current" />
</label>
<label class="super-line">
<input type="checkbox" id="edit-user-super" />
Superuser (can manage accounts and invites)
</label>
<div class="edit-actions">
<button type="button" class="btn btn-primary" id="edit-user-save">Save changes</button>
<button type="button" class="btn btn-ghost" id="edit-user-cancel">Cancel</button>
</div>
</div>
</section>
<section id="invite-panel" class="invite-panel hidden">
<strong>Add users via invite link</strong>
<p class="note" style="margin-top:0.4rem">
Each link lets <strong>one</strong> person create their own account at <code>/register?invite=…</code> with a password they choose. After it is used, create a new link for the next person.
</p>
<div class="invite-row">
<input id="invite-email" type="email" placeholder="new.user@example.com" autocomplete="email" />
<button type="button" class="btn btn-primary" id="invite-create-btn">Email invite</button>
<button type="button" class="btn btn-ghost" id="invite-link-btn">Invite link only</button>
</div>
<div id="invite-result" class="invite-result"></div>
<button type="button" class="btn btn-ghost hidden" id="invite-copy-btn" aria-label="Copy invite link">Copy invite link</button>
</section>
</main>
<script>
let currentAdminUserId = null;
function formatApiDetail(body) {
if (!body || body.detail === undefined || body.detail === null) return '';
const d = body.detail;
if (typeof d === 'string') return d;
if (Array.isArray(d))
return d.map((item) => (typeof item === 'object' && item && item.msg ? String(item.msg) : JSON.stringify(item))).join(' ');
return String(d);
}
function setAdminUsersFeedback(kind, msg) {
const el = document.getElementById('admin-users-feedback');
if (!el) return;
el.textContent = msg || '';
el.classList.remove('ok', 'err');
if (kind === 'ok') el.classList.add('ok');
if (kind === 'err') el.classList.add('err');
}
async function refreshUsersList(viewerId) {
const usersList = document.getElementById('users-list');
if (!usersList) return;
usersList.textContent = 'Loading users...';
try {
const res = await fetch('/api/users', { credentials: 'include' });
const users = await res.json().catch(() => []);
if (!res.ok) {
usersList.textContent = 'Unable to load users.';
return;
}
usersList.innerHTML = '';
for (const user of users) {
const row = document.createElement('div');
row.className = 'user-row';
const name = document.createElement('span');
name.textContent = `${user.username}${user.is_superuser ? ' (admin)' : ''}`;
const actions = document.createElement('div');
actions.className = 'user-actions';
const link = document.createElement('a');
link.href = `/editor?workspace_user_id=${encodeURIComponent(String(user.id))}`;
link.textContent = 'Open workspace';
const editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'btn btn-ghost';
editBtn.textContent = 'Edit';
editBtn.title = `Edit ${user.username}`;
editBtn.addEventListener('click', () => openUserEdit(user.id, user.username, user.is_superuser));
const delBtn = document.createElement('button');
delBtn.type = 'button';
delBtn.className = 'btn btn-ghost btn-danger';
delBtn.textContent = 'Remove';
const isSelf = Number(user.id) === Number(viewerId);
delBtn.disabled = isSelf;
delBtn.title = isSelf ? 'You cannot delete your own account here' : 'Permanently remove this user';
delBtn.addEventListener('click', async () => {
const ok = confirm(`Remove user “${user.username}”? Their workspace folder stays on disk until you delete it manually.`);
if (!ok) return;
delBtn.disabled = true;
setAdminUsersFeedback('', '');
try {
const dres = await fetch(`/api/users/${encodeURIComponent(String(user.id))}`, {
method: 'DELETE', credentials: 'include',
});
const body = await dres.json().catch(() => ({}));
if (!dres.ok) {
setAdminUsersFeedback('err', formatApiDetail(body) || dres.statusText || 'Could not remove user');
delBtn.disabled = false;
return;
}
setAdminUsersFeedback('ok', `Removed ${user.username}.`);
await refreshUsersList(viewerId);
} catch (err) {
setAdminUsersFeedback('err', String(err.message || err));
delBtn.disabled = false;
}
});
actions.appendChild(editBtn);
actions.appendChild(link);
actions.appendChild(delBtn);
row.appendChild(name);
row.appendChild(actions);
usersList.appendChild(row);
}
} catch (_err) {
usersList.textContent = 'Unable to load users.';
}
}
function closeUserEdit() {
const sheet = document.getElementById('user-edit-form');
if (sheet) sheet.classList.add('hidden');
}
function openUserEdit(userId, username, isSuperuser) {
document.getElementById('edit-user-id').value = String(userId);
document.getElementById('edit-user-username').value = username;
document.getElementById('edit-user-password').value = '';
document.getElementById('edit-user-super').checked = Boolean(isSuperuser);
document.getElementById('user-edit-heading').textContent = `Edit @${username}`;
document.getElementById('user-edit-form').classList.remove('hidden');
}
document.getElementById('edit-user-cancel').addEventListener('click', () => closeUserEdit());
document.getElementById('edit-user-save').addEventListener('click', async () => {
const id = document.getElementById('edit-user-id').value;
const u = document.getElementById('edit-user-username').value.trim();
const pw = document.getElementById('edit-user-password').value;
const superU = document.getElementById('edit-user-super').checked;
const saveBtn = document.getElementById('edit-user-save');
setAdminUsersFeedback('', '');
if (!u || u.length < 3) {
setAdminUsersFeedback('err', 'Username must be at least 3 characters.');
return;
}
const payload = { username: u, is_superuser: superU };
const tpw = pw.trim();
if (tpw.length > 0) {
if (tpw.length < 8) {
setAdminUsersFeedback('err', 'New password must be at least 8 characters (or leave blank).');
return;
}
payload.password = tpw;
}
saveBtn.disabled = true;
try {
const res = await fetch(`/api/users/${encodeURIComponent(String(id))}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
setAdminUsersFeedback('err', formatApiDetail(body) || res.statusText || 'Update failed');
saveBtn.disabled = false;
return;
}
setAdminUsersFeedback('ok', `Updated @${body.username}.`);
closeUserEdit();
await refreshUsersList(currentAdminUserId);
} catch (err) {
setAdminUsersFeedback('err', String(err.message || err));
}
saveBtn.disabled = false;
});
document.getElementById('btn-logout').addEventListener('click', async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/';
});
function showInviteOutcome(inviteUrl, headline) {
const result = document.getElementById('invite-result');
const copyBtn = document.getElementById('invite-copy-btn');
result.textContent = '';
const line = document.createElement('p');
line.style.margin = '0 0 0.35rem 0';
line.textContent = headline;
const a = document.createElement('a');
a.href = inviteUrl;
a.textContent = inviteUrl;
a.style.color = '#93c5fd';
a.style.wordBreak = 'break-all';
result.appendChild(line);
result.appendChild(a);
copyBtn.classList.remove('hidden');
copyBtn.dataset.inviteUrl = inviteUrl;
}
function clearInviteOutcome() {
const result = document.getElementById('invite-result');
const copyBtn = document.getElementById('invite-copy-btn');
result.textContent = '';
copyBtn.classList.add('hidden');
copyBtn.textContent = 'Copy invite link';
delete copyBtn.dataset.inviteUrl;
}
document.getElementById('invite-copy-btn').addEventListener('click', async () => {
const copyBtn = document.getElementById('invite-copy-btn');
const url = copyBtn.dataset.inviteUrl;
if (!url) return;
try {
await navigator.clipboard.writeText(url);
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy invite link'; }, 2000);
} catch (_e) {
copyBtn.textContent = 'Copy failed — select the link above';
setTimeout(() => { copyBtn.textContent = 'Copy invite link'; }, 2500);
}
});
document.getElementById('invite-create-btn').addEventListener('click', async () => {
const emailInput = document.getElementById('invite-email');
const result = document.getElementById('invite-result');
const email = (emailInput.value || '').trim();
clearInviteOutcome();
if (!email) {
result.textContent = 'Enter an email address first.';
return;
}
try {
const res = await fetch('/api/users/invites', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, expires_days: 7 }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
result.textContent = formatApiDetail(body) || res.statusText || 'Failed to create invite';
return;
}
const headline = body.delivered
? 'Email sent. They can also use this link to register:'
: 'Email not sent (SMTP not configured). Share this registration link:';
showInviteOutcome(body.invite_url, headline);
} catch (err) {
result.textContent = String(err.message || err);
}
});
document.getElementById('invite-link-btn').addEventListener('click', async () => {
const result = document.getElementById('invite-result');
clearInviteOutcome();
try {
const res = await fetch('/api/users/invites', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: null, expires_days: 7 }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
result.textContent = formatApiDetail(body) || res.statusText || 'Failed to create invite link';
return;
}
showInviteOutcome(body.invite_url, 'Share this link so they can create an account:');
} catch (err) {
result.textContent = String(err.message || err);
}
});
async function init() {
const greet = document.getElementById('auth-greeting');
const logout = document.getElementById('btn-logout');
const usersPanel = document.getElementById('users-panel');
const invitePanel = document.getElementById('invite-panel');
const blocked = document.getElementById('not-allowed');
const st = await fetch('/api/auth/status').catch(() => null);
if (!st || !st.ok) {
blocked.classList.remove('hidden');
return;
}
const status = await st.json();
if (!status.auth_enabled) {
// Auth disabled — anyone can poke /api/users; show panels regardless.
usersPanel.classList.remove('hidden');
invitePanel.classList.remove('hidden');
await refreshUsersList(null);
return;
}
const me = await fetch('/api/auth/me', { credentials: 'include' });
if (!me.ok) {
blocked.classList.remove('hidden');
return;
}
const j = await me.json();
if (!j.user || !j.user.is_superuser) {
blocked.classList.remove('hidden');
greet.textContent = `Signed in as ${j.user.username}`;
logout.classList.remove('hidden');
return;
}
currentAdminUserId = j.user.id;
greet.textContent = `Signed in as ${j.user.username} (admin)`;
logout.classList.remove('hidden');
usersPanel.classList.remove('hidden');
invitePanel.classList.remove('hidden');
await refreshUsersList(j.user.id);
}
init();
</script>
</body>
</html>

204
src/static/zip-utils.js Normal file
View File

@@ -0,0 +1,204 @@
/**
* Tiny zero-dependency ZIP encoder. STORE-only (no compression) — fine for
* Python source which is small, gzip would only save a few KB. Kept inline
* here so local-mode export works on every browser, including the ones
* (Firefox/Safari/Brave-without-flag) that have no folder picker.
*
* Format reference: APPNOTE.TXT 6.3.x
*/
let _crcTable = null;
function crcTable() {
if (_crcTable) return _crcTable;
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[i] = c;
}
_crcTable = t;
return t;
}
function crc32(bytes) {
const tbl = crcTable();
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = tbl[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function dosTime(d) {
return ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xffff;
}
function dosDate(d) {
return (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xffff;
}
/**
* Build an uncompressed ZIP from a list of `{ name, content }` entries.
* `content` may be a string or a Uint8Array.
*
* Returns a Blob (`application/zip`).
*/
export function createZip(entries) {
const enc = new TextEncoder();
const date = new Date();
const time = dosTime(date);
const ddate = dosDate(date);
const parts = [];
const central = [];
let offset = 0;
for (const entry of entries) {
const data = typeof entry.content === 'string' ? enc.encode(entry.content) : entry.content;
const nameBytes = enc.encode(entry.name);
const crc = crc32(data);
const local = new ArrayBuffer(30 + nameBytes.length);
const lv = new DataView(local);
lv.setUint32(0, 0x04034b50, true);
lv.setUint16(4, 20, true);
lv.setUint16(6, 0x0800, true); // bit 11 = UTF-8 filename
lv.setUint16(8, 0, true);
lv.setUint16(10, time, true);
lv.setUint16(12, ddate, true);
lv.setUint32(14, crc, true);
lv.setUint32(18, data.length, true);
lv.setUint32(22, data.length, true);
lv.setUint16(26, nameBytes.length, true);
lv.setUint16(28, 0, true);
new Uint8Array(local, 30).set(nameBytes);
parts.push(local);
parts.push(data);
const cd = new ArrayBuffer(46 + nameBytes.length);
const cv = new DataView(cd);
cv.setUint32(0, 0x02014b50, true);
cv.setUint16(4, 0x031e, true); // made by: unix + zip 3.0
cv.setUint16(6, 20, true);
cv.setUint16(8, 0x0800, true);
cv.setUint16(10, 0, true);
cv.setUint16(12, time, true);
cv.setUint16(14, ddate, true);
cv.setUint32(16, crc, true);
cv.setUint32(20, data.length, true);
cv.setUint32(24, data.length, true);
cv.setUint16(28, nameBytes.length, true);
cv.setUint16(30, 0, true);
cv.setUint16(32, 0, true);
cv.setUint16(34, 0, true);
cv.setUint16(36, 0, true);
cv.setUint32(38, 0, true);
cv.setUint32(42, offset, true);
new Uint8Array(cd, 46).set(nameBytes);
central.push(cd);
offset += local.byteLength + data.length;
}
const cdOffset = offset;
let cdSize = 0;
for (const cd of central) {
parts.push(cd);
cdSize += cd.byteLength;
}
const eocd = new ArrayBuffer(22);
const ev = new DataView(eocd);
ev.setUint32(0, 0x06054b50, true);
ev.setUint16(4, 0, true);
ev.setUint16(6, 0, true);
ev.setUint16(8, entries.length, true);
ev.setUint16(10, entries.length, true);
ev.setUint32(12, cdSize, true);
ev.setUint32(16, cdOffset, true);
ev.setUint16(20, 0, true);
parts.push(eocd);
return new Blob(parts, { type: 'application/zip' });
}
/**
* Parse an uncompressed-or-DEFLATEd ZIP buffer into an array of
* `{ name, content }` entries (text). Skips directory entries (those that
* end with '/'). Throws if the buffer is not a valid ZIP archive.
*
* Compression methods supported:
* - 0 (STORE)
* - 8 (DEFLATE) — decompressed via the browser's `DecompressionStream`
* ('deflate-raw'), available in Chrome/Edge/Brave 113+, Firefox 113+,
* Safari 16.4+. If the browser is too old we surface a clear error.
*/
export async function readZip(arrayBuffer) {
const buf = arrayBuffer instanceof ArrayBuffer ? arrayBuffer : arrayBuffer.buffer;
const view = new DataView(buf);
if (buf.byteLength < 22) throw new Error('Not a ZIP file (too small)');
// EOCD signature scan from the end (max 64 KiB comment).
let eocd = -1;
const minScan = Math.max(0, buf.byteLength - 22 - 65535);
for (let i = buf.byteLength - 22; i >= minScan; i--) {
if (view.getUint32(i, true) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd === -1) throw new Error('Not a ZIP file (no end-of-central-directory)');
const totalEntries = view.getUint16(eocd + 10, true);
const cdOffset = view.getUint32(eocd + 16, true);
const dec = new TextDecoder();
const out = [];
let p = cdOffset;
for (let i = 0; i < totalEntries; i++) {
if (view.getUint32(p, true) !== 0x02014b50) {
throw new Error('Bad central-directory entry');
}
const method = view.getUint16(p + 10, true);
const compSize = view.getUint32(p + 20, true);
const uncompSize = view.getUint32(p + 24, true);
const nameLen = view.getUint16(p + 28, true);
const extraLen = view.getUint16(p + 30, true);
const commentLen = view.getUint16(p + 32, true);
const localOff = view.getUint32(p + 42, true);
const name = dec.decode(new Uint8Array(buf, p + 46, nameLen));
p += 46 + nameLen + extraLen + commentLen;
// Directory entry — skip; createFolder happens implicitly when we
// write a child file under that path.
if (name.endsWith('/')) continue;
const lview = new DataView(buf, localOff, 30);
if (lview.getUint32(0, true) !== 0x04034b50) {
throw new Error(`Bad local header for "${name}"`);
}
const lNameLen = lview.getUint16(26, true);
const lExtraLen = lview.getUint16(28, true);
const dataOff = localOff + 30 + lNameLen + lExtraLen;
let bytes;
if (method === 0) {
bytes = new Uint8Array(buf, dataOff, uncompSize);
} else if (method === 8) {
if (typeof DecompressionStream === 'undefined') {
throw new Error(
`"${name}" is DEFLATE-compressed but this browser has no DecompressionStream`
);
}
const compressed = new Uint8Array(buf, dataOff, compSize);
const stream = new ReadableStream({
start(controller) {
controller.enqueue(compressed);
controller.close();
},
}).pipeThrough(new DecompressionStream('deflate-raw'));
bytes = new Uint8Array(await new Response(stream).arrayBuffer());
} else {
throw new Error(`Unsupported compression method ${method} on "${name}"`);
}
out.push({ name, content: dec.decode(bytes) });
}
return out;
}