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:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
419
src/static/bundled-lib/machine.py
Normal file
419
src/static/bundled-lib/machine.py
Normal 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
|
||||
56
src/static/bundled-lib/neopixel.py
Normal file
56
src/static/bundled-lib/neopixel.py
Normal 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))
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (0–65535)</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>
|
||||
|
||||
1032
src/static/local-workspace.js
Normal file
1032
src/static/local-workspace.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
1279
src/static/script.js
1279
src/static/script.js
File diff suppressed because it is too large
Load Diff
@@ -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
484
src/static/users.html
Normal 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
204
src/static/zip-utils.js
Normal 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user