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:
@@ -13,6 +13,7 @@ RUN pipenv install --system --deploy
|
||||
|
||||
COPY src ./src
|
||||
COPY lib ./lib
|
||||
RUN mkdir -p src/static/bundled-lib && cp -f lib/*.py src/static/bundled-lib/
|
||||
COPY workspace ./workspace
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
2
Pipfile
2
Pipfile
@@ -20,7 +20,7 @@ bcrypt = "*"
|
||||
python_version = "3.12"
|
||||
|
||||
[scripts]
|
||||
dev = "uvicorn app:app --app-dir src --reload --port 8080"
|
||||
dev = "uvicorn app:app --app-dir src --reload --host 0.0.0.0 --port 8080"
|
||||
test = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests'"
|
||||
test-integration = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests -m integration'"
|
||||
test-selenium = "bash -lc 'cd src && PYTHONPATH=. pytest ../tests -m selenium -v'"
|
||||
|
||||
11
README.md
11
README.md
@@ -90,17 +90,22 @@ Admins can open another user's workspace from the home page user management pane
|
||||
|
||||
The home page can store the API key in `sessionStorage` when you are not using cookie login, or use `?api_key=` on `/editor`.
|
||||
|
||||
**Local mode (no login)** — Click *Use locally* on the home page (or open `/editor?local=1`) to run the editor without any FastAPI auth. The boot-time auth probe is skipped when local mode is active, so this works even on a host that has `AUTH_ENABLED=true`. Files default to the browser's **IndexedDB**; inside the editor the workspace badge has a **Folder…** button that opens `window.showDirectoryPicker()` so you can save straight to any folder on disk (Chromium-only — Firefox/Safari stay on IndexedDB). The picked directory handle is persisted across reloads in IndexedDB; if browser permission lapses a *Reconnect* button reappears in the badge. Nothing is sent to the server for file reads/writes. The MicroPython stubs are loaded from **`/static/bundled-lib/*.py`** (files under `src/static/bundled-lib/` in the repo) so a plain static file server is enough; if those requests fail, the app falls back to `GET /api/public/lib-bundle` when FastAPI is available. For static-only hosting, run `python scripts/serve_static_editor.py` from the repo root — it serves `src/static/` with the same `/static/…` URLs the HTML expects (it strips the `/static` prefix when resolving files), rewrites `/editor` → `index.html`, and sends the same COOP/COEP headers as the full app so **ADC sliders, pin toggles, and serial I/O** keep using `SharedArrayBuffer` on mobile Safari and Chrome where supported. An *Exit* button in the editor's workspace badge clears the local-mode flag (your IndexedDB files stay until you wipe browser storage).
|
||||
|
||||
## Layout
|
||||
|
||||
- `src/` — FastAPI app and static UI (`src/static/`)
|
||||
- `lib/` — bundled MicroPython stubs (copied into `WORKSPACE_ROOT/lib` when missing; read-only via API)
|
||||
- `workspace/` — default `WORKSPACE_ROOT`: `code/` samples (editable); runtime `lib/` is filled from `lib/` above
|
||||
- `lib/` — bundled MicroPython stubs, served read-only as `lib/` in the editor and merged into Pyodide at run time (single source of truth)
|
||||
- `workspace/` — default `WORKSPACE_ROOT`: `code/` samples and per-user folders (editable); the editor surfaces the repo `lib/` alongside it without copying anything to disk
|
||||
|
||||
## ESP32 / NeoPixel mock
|
||||
|
||||
The browser runtime ships MicroPython-style stubs in repo `lib/` (they appear as `lib/` in the editor and are read-only via the APIs):
|
||||
|
||||
- `machine.Pin`, `machine.freq()`, `machine.unique_id()`, `machine.reset()` (no-op here)
|
||||
- `machine.Pin` — `value/on/off/toggle/high/low/init/__call__/irq` plus a live "Pins" panel: OUT pins show an indicator, IN pins expose a clickable toggle button (its value is what `Pin.value()` returns), `irq()` fires on rising / falling edges as you click
|
||||
- `machine.PWM` — `freq()` / `duty()` / `duty_u16()` / `duty_ns()` with a duty-cycle bar in the Pins panel
|
||||
- `machine.ADC` — backed by a live slider in the editor UI (one slider per pin, `read_u16()` returns 0..65535)
|
||||
- `machine.UART` — opens a Serial Monitor pane; `write()` text appears there, what you type is delivered via `read()` / `readline()`
|
||||
- `neopixel.NeoPixel`
|
||||
- `utime` — `ticks_ms`, `ticks_diff`, `ticks_add`, `sleep_ms`, `sleep_us`, `sleep`
|
||||
- `micropython.const` — no-op helper for ported constant declarations
|
||||
|
||||
432
lib/machine.py
432
lib/machine.py
@@ -1,19 +1,445 @@
|
||||
"""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 _sab_isolated():
|
||||
"""True when the worker has a real SharedArrayBuffer for pin/ADC views.
|
||||
|
||||
Set by `pyodide-worker.js` at init time. When false, output updates
|
||||
must travel through `print("[pin-out]…")` markers because the main
|
||||
thread can't read worker-local arrays."""
|
||||
try:
|
||||
import js
|
||||
|
||||
return bool(getattr(js, "__sab_isolated", False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
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 not None:
|
||||
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
|
||||
if not _sab_isolated():
|
||||
# Fallback for non-isolated contexts (e.g. mobile over LAN IP):
|
||||
# the main thread reads `[pin-out]` lines from stdout and updates
|
||||
# the Pins panel from there. Slower than SAB but works everywhere.
|
||||
try:
|
||||
print(
|
||||
"[pin-out]"
|
||||
+ json.dumps(
|
||||
{"pin": int(pin_id), "mode": int(ui_mode), "value": int(value)}
|
||||
)
|
||||
)
|
||||
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
|
||||
|
||||
def __init__(self, pin_id: int, mode: int = OUT, value: int = 0):
|
||||
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
|
||||
self._value = 1 if int(new_value) else 0
|
||||
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
|
||||
|
||||
@@ -29,6 +29,17 @@ class NeoPixel:
|
||||
self.bpp = int(bpp)
|
||||
self.timing = int(timing)
|
||||
self._buf = [tuple([0] * self.bpp) for _ in range(self.n)]
|
||||
# Tell the editor UI that this pin is the NeoPixel data line so its
|
||||
# row in the Pins panel goes away (it would just look noisy — every
|
||||
# `pixels.write()` flips it).
|
||||
try:
|
||||
pin_id = getattr(self.pin, "id", self.pin)
|
||||
print(
|
||||
"[pin-claim]"
|
||||
+ json.dumps({"pin": int(pin_id), "by": "neopixel"})
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __len__(self):
|
||||
return self.n
|
||||
|
||||
74
scripts/serve_static_editor.py
Normal file
74
scripts/serve_static_editor.py
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Serve only the contents of `src/static/` (HTML, CSS, JS, bundled stubs).
|
||||
|
||||
Use this with **local mode** in the editor (`?local=1`): files live in IndexedDB,
|
||||
so no FastAPI file API is required. Maps `/` and `/editor` to `home.html` /
|
||||
`index.html` so links from the home page keep working.
|
||||
|
||||
Sends ``Cross-Origin-Opener-Policy: same-origin`` and
|
||||
``Cross-Origin-Embedder-Policy: credentialless`` on every response so the page
|
||||
can become cross-origin isolated (SharedArrayBuffer for live ADC / pins /
|
||||
serial), matching the full FastAPI app.
|
||||
|
||||
Example:
|
||||
|
||||
python scripts/serve_static_editor.py
|
||||
# open http://127.0.0.1:8765/ then "Use locally" → /editor?local=1
|
||||
|
||||
Note: Pyodide and CodeMirror still load from CDNs; you need network access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from functools import partial
|
||||
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
||||
parser.add_argument("--host", default="127.0.0.1", help="Bind address (default 127.0.0.1)")
|
||||
parser.add_argument("--port", type=int, default=8765, help="Port (default 8765)")
|
||||
args = parser.parse_args()
|
||||
|
||||
static_root = (Path(__file__).resolve().parent.parent / "src" / "static").resolve()
|
||||
if not static_root.is_dir():
|
||||
raise SystemExit(f"Static directory not found: {static_root}")
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *a, **kw):
|
||||
super().__init__(*a, directory=str(static_root), **kw)
|
||||
|
||||
def end_headers(self):
|
||||
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
|
||||
self.send_header("Cross-Origin-Embedder-Policy", "credentialless")
|
||||
super().end_headers()
|
||||
|
||||
def translate_path(self, path: str) -> str:
|
||||
clean = path.split("?", 1)[0].split("#", 1)[0]
|
||||
if clean.startswith("/static/"):
|
||||
clean = clean[len("/static") :] # e.g. /styles.css, /bundled-lib/machine.py
|
||||
if clean in ("/", ""):
|
||||
clean = "/home.html"
|
||||
elif clean == "/editor" or clean.startswith("/editor/"):
|
||||
clean = "/index.html"
|
||||
elif clean == "/tutorial" or clean.startswith("/tutorial/"):
|
||||
clean = "/tutorial.html"
|
||||
elif clean == "/login" or clean.startswith("/login/"):
|
||||
clean = "/login.html"
|
||||
elif clean == "/register" or clean.startswith("/register/"):
|
||||
clean = "/register.html"
|
||||
return super().translate_path(clean)
|
||||
|
||||
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
|
||||
print(f"Serving {static_root} at http://{args.host}:{args.port}/")
|
||||
print("Open / then use “Use locally” for IndexedDB workspace.")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import importlib
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -10,33 +9,24 @@ def test_root_serves_html(client):
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
assert "Python Editor" in response.text
|
||||
assert "LED Editor" in response.text
|
||||
|
||||
|
||||
def test_editor_route_serves_editor_html(client):
|
||||
response = client.get("/editor")
|
||||
assert response.status_code == 200
|
||||
assert "text/html" in response.headers["content-type"]
|
||||
assert "Python Editor" in response.text
|
||||
assert "LED Editor" in response.text
|
||||
|
||||
|
||||
def test_workspace_lib_seeded_from_bundle_on_startup(client, tmp_path):
|
||||
"""Populate WORKSPACE_ROOT/lib with shipped stubs when absent (empty volume / fresh tmp)."""
|
||||
lib_dir = tmp_path / "lib"
|
||||
assert (lib_dir / "machine.py").is_file()
|
||||
assert (lib_dir / "neopixel.py").is_file()
|
||||
assert len((lib_dir / "machine.py").read_text(encoding="utf-8")) > 0
|
||||
|
||||
|
||||
def test_workspace_lib_repops_when_removed_after_startup(client, tmp_path):
|
||||
"""Touching lib via the API restores bundled stubs after the workspace lib dir is wiped."""
|
||||
lib_dir = tmp_path / "lib"
|
||||
assert (lib_dir / "machine.py").is_file()
|
||||
shutil.rmtree(lib_dir)
|
||||
|
||||
def test_lib_served_directly_from_repo_bundle(client, tmp_path):
|
||||
"""`lib/` reads come straight from repo `lib/`; no workspace cache is created."""
|
||||
resp = client.get("/api/file/lib/machine.py")
|
||||
assert resp.status_code == 200
|
||||
assert (lib_dir / "machine.py").is_file()
|
||||
body = resp.json().get("content", "")
|
||||
assert "class Pin" in body and len(body) > 0
|
||||
assert not (tmp_path / "lib").exists()
|
||||
|
||||
|
||||
def test_list_files_hides_dotfiles_and_reports_sizes(client, tmp_path):
|
||||
(tmp_path / ".hidden.txt").write_text("secret", encoding="utf-8")
|
||||
@@ -76,9 +66,6 @@ def test_save_file_collapses_duplicate_scoped_prefix(client, tmp_path):
|
||||
|
||||
|
||||
def test_lib_folder_is_read_only_for_mutations(client, tmp_path):
|
||||
lib_dir = tmp_path / "lib"
|
||||
lib_dir.mkdir(exist_ok=True)
|
||||
(lib_dir / "helper.py").write_text("x = 1\n", encoding="utf-8")
|
||||
code_dir = tmp_path / "code"
|
||||
code_dir.mkdir()
|
||||
(code_dir / "main.py").write_text("print('ok')\n", encoding="utf-8")
|
||||
@@ -86,7 +73,7 @@ def test_lib_folder_is_read_only_for_mutations(client, tmp_path):
|
||||
save_blocked = client.post("/api/file/lib/new.txt", json={"content": "nope"})
|
||||
assert save_blocked.status_code == 403
|
||||
|
||||
delete_blocked = client.delete("/api/file/lib/helper.py")
|
||||
delete_blocked = client.delete("/api/file/lib/machine.py")
|
||||
assert delete_blocked.status_code == 403
|
||||
|
||||
move_blocked = client.post(
|
||||
@@ -252,14 +239,14 @@ def test_folder_delete_errors(client, tmp_path):
|
||||
def test_workspace_py_sources_returns_python_files(client, tmp_path):
|
||||
(tmp_path / "code").mkdir()
|
||||
(tmp_path / "code" / "app.py").write_text("x = 1\n", encoding="utf-8")
|
||||
(tmp_path / "lib").mkdir(exist_ok=True)
|
||||
(tmp_path / "lib" / "util.py").write_text("def f():\n pass\n", encoding="utf-8")
|
||||
|
||||
response = client.get("/api/workspace/py-sources")
|
||||
assert response.status_code == 200
|
||||
files = response.json()["files"]
|
||||
assert files["code/app.py"] == "x = 1\n"
|
||||
assert "lib/util.py" in files
|
||||
# Bundled stubs from repo `lib/` are merged in automatically.
|
||||
assert "lib/machine.py" in files
|
||||
assert "lib/neopixel.py" in files
|
||||
|
||||
|
||||
def test_api_requires_bearer_when_editor_api_key_set(tmp_path, monkeypatch):
|
||||
@@ -284,7 +271,9 @@ def test_api_requires_bearer_when_editor_api_key_set(tmp_path, monkeypatch):
|
||||
assert ok.status_code == 200
|
||||
|
||||
|
||||
def test_create_app_startup_creates_lib(tmp_path, monkeypatch):
|
||||
def test_create_app_startup_does_not_seed_workspace_lib(tmp_path, monkeypatch):
|
||||
"""Bundle stubs are read directly from repo `lib/`, so startup must not create
|
||||
a `WORKSPACE_ROOT/lib` cache."""
|
||||
import editor_app.config as config
|
||||
import editor_app.db.session as db_sess
|
||||
import editor_app.main as main
|
||||
@@ -302,4 +291,4 @@ def test_create_app_startup_creates_lib(tmp_path, monkeypatch):
|
||||
assert not (tmp_path / "lib").exists()
|
||||
with TestClient(main.app) as _client:
|
||||
_client.get("/api/auth/status")
|
||||
assert (tmp_path / "lib").is_dir()
|
||||
assert not (tmp_path / "lib").exists()
|
||||
|
||||
@@ -18,7 +18,7 @@ def _reload_app(tmp_path, monkeypatch, **env):
|
||||
monkeypatch.delenv("BOOTSTRAP_ADMIN_PASSWORD", raising=False)
|
||||
for k, v in env.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
config.WORKSPACE_ROOT = tmp_path
|
||||
monkeypatch.setattr(config, "WORKSPACE_ROOT", tmp_path)
|
||||
db_sess.reset_engine()
|
||||
importlib.reload(main)
|
||||
return main.app
|
||||
@@ -304,9 +304,23 @@ def test_users_have_isolated_workspaces(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def test_lib_is_shared_read_only_across_users(tmp_path, monkeypatch):
|
||||
"""`lib/` lives at the repo root (single source of truth), not under WORKSPACE_ROOT,
|
||||
so we patch `config.PROJECT_ROOT` to give the test its own isolated lib bundle."""
|
||||
import editor_app.config as config
|
||||
|
||||
shared_lib = tmp_path / "lib"
|
||||
shared_lib.mkdir(parents=True, exist_ok=True)
|
||||
(shared_lib / "shared.py").write_text("VALUE = 42\n", encoding="utf-8")
|
||||
# Mirror canonical demo files so `_seed_canonical_demos_into_code` still works.
|
||||
real_demos = config.PROJECT_ROOT / "workspace" / "code"
|
||||
fake_demos = tmp_path / "workspace" / "code"
|
||||
fake_demos.mkdir(parents=True, exist_ok=True)
|
||||
for fname in ("pattern_rainbow_demo.py", "pattern_twinkle_demo.py", "pattern_chase_demo.py"):
|
||||
src = real_demos / fname
|
||||
if src.is_file():
|
||||
(fake_demos / fname).write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(config, "PROJECT_ROOT", tmp_path)
|
||||
|
||||
with TestClient(
|
||||
_reload_app(tmp_path, monkeypatch, AUTH_ENABLED="true", AUTH_REGISTER_OPEN="true")
|
||||
|
||||
55
workspace/code/adc_slider_demo.py
Normal file
55
workspace/code/adc_slider_demo.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""ADC slider demo — drag the sliders that appear under the editor.
|
||||
|
||||
Two simulated ADCs:
|
||||
* pin 34 — sets the base hue of a rainbow
|
||||
* pin 35 — sets overall brightness
|
||||
|
||||
The strip lights up while the script runs; the values update live (no need to
|
||||
restart the script when you move the slider).
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from machine import ADC, Pin
|
||||
from neopixel import NeoPixel
|
||||
|
||||
|
||||
NUM_LEDS = 16
|
||||
strip = NeoPixel(Pin(5, Pin.OUT), NUM_LEDS)
|
||||
|
||||
hue_pot = ADC(Pin(34))
|
||||
bri_pot = ADC(Pin(35))
|
||||
|
||||
|
||||
def hsv_to_rgb(h, s, v):
|
||||
h = h - int(h)
|
||||
i = int(h * 6)
|
||||
f = h * 6 - i
|
||||
p = v * (1 - s)
|
||||
q = v * (1 - f * s)
|
||||
t = v * (1 - (1 - f) * s)
|
||||
if i == 0:
|
||||
r, g, b = v, t, p
|
||||
elif i == 1:
|
||||
r, g, b = q, v, p
|
||||
elif i == 2:
|
||||
r, g, b = p, v, t
|
||||
elif i == 3:
|
||||
r, g, b = p, q, v
|
||||
elif i == 4:
|
||||
r, g, b = t, p, v
|
||||
else:
|
||||
r, g, b = v, p, q
|
||||
return int(r * 255), int(g * 255), int(b * 255)
|
||||
|
||||
|
||||
print("Move the ADC sliders below the editor while this runs.")
|
||||
|
||||
while True:
|
||||
base_hue = hue_pot.read_u16() / 65535
|
||||
brightness = bri_pot.read_u16() / 65535
|
||||
for i in range(NUM_LEDS):
|
||||
h = (base_hue + i / NUM_LEDS) % 1.0
|
||||
strip[i] = hsv_to_rgb(h, 1.0, brightness)
|
||||
strip.write()
|
||||
time.sleep(0.04)
|
||||
54
workspace/code/pin_demo.py
Normal file
54
workspace/code/pin_demo.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Pin features demo.
|
||||
|
||||
A "Pins" panel appears below the editor while this script runs:
|
||||
|
||||
* Pin 2 (OUT) — blinks every 200 ms; the indicator follows along.
|
||||
* Pin 4 (OUT) — chases through .on() / .off() / .toggle().
|
||||
* Pin 0 (IN) — click the toggle button in the panel to flip its value.
|
||||
When it goes 0 -> 1 we register an IRQ that toggles pin 2.
|
||||
* Pin 13 (PWM) — duty sweeps up and down; the bar shows the live duty cycle.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from machine import Pin, PWM
|
||||
|
||||
|
||||
led_a = Pin(2, Pin.OUT)
|
||||
led_b = Pin(4, Pin.OUT)
|
||||
button = Pin(0, Pin.IN, Pin.PULL_UP)
|
||||
fader = PWM(Pin(13), freq=1000, duty_u16=0)
|
||||
|
||||
|
||||
def on_button(pin):
|
||||
print("[irq] button rising edge -> toggling pin 2")
|
||||
led_a.toggle()
|
||||
|
||||
|
||||
button.irq(handler=on_button, trigger=Pin.IRQ_RISING)
|
||||
|
||||
|
||||
tick = 0
|
||||
duty = 0
|
||||
direction = 1024
|
||||
|
||||
while True:
|
||||
led_a.value(tick % 2)
|
||||
if tick % 4 == 0:
|
||||
led_b.on()
|
||||
elif tick % 4 == 2:
|
||||
led_b.off()
|
||||
|
||||
duty += direction
|
||||
if duty >= 65535:
|
||||
duty = 65535
|
||||
direction = -1024
|
||||
elif duty <= 0:
|
||||
duty = 0
|
||||
direction = 1024
|
||||
fader.duty_u16(duty)
|
||||
|
||||
button.value()
|
||||
|
||||
tick += 1
|
||||
time.sleep(0.1)
|
||||
90
workspace/code/serial_demo.py
Normal file
90
workspace/code/serial_demo.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Serial in/out demo.
|
||||
|
||||
When this script runs, a "Serial monitor" pane appears below the editor.
|
||||
|
||||
Try this:
|
||||
* type hello and press Enter -> Python echoes "echo: hello"
|
||||
* type color red -> the strip turns red
|
||||
* try color 0,128,255 -> any (r,g,b) tuple works
|
||||
* type off -> strip blanks
|
||||
* type bye -> script exits cleanly
|
||||
|
||||
Anything Python `write()`s to the UART shows up in green; what you type back
|
||||
is shown in white.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from machine import Pin, UART
|
||||
from neopixel import NeoPixel
|
||||
|
||||
|
||||
NUM_LEDS = 16
|
||||
strip = NeoPixel(Pin(5, Pin.OUT), NUM_LEDS)
|
||||
uart = UART(0, baudrate=115200)
|
||||
|
||||
PALETTE = {
|
||||
"red": (255, 0, 0),
|
||||
"green": (0, 255, 0),
|
||||
"blue": (0, 0, 255),
|
||||
"white": (200, 200, 200),
|
||||
"purple": (160, 0, 200),
|
||||
"orange": (255, 110, 0),
|
||||
}
|
||||
|
||||
|
||||
def fill(color):
|
||||
strip.fill(color)
|
||||
strip.write()
|
||||
|
||||
|
||||
def parse_color(arg):
|
||||
arg = arg.strip().lower()
|
||||
if arg in PALETTE:
|
||||
return PALETTE[arg]
|
||||
parts = [p for p in arg.replace(",", " ").split() if p]
|
||||
if len(parts) == 3:
|
||||
try:
|
||||
return tuple(max(0, min(255, int(p))) for p in parts)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
uart.write("ready. commands: color <name|r,g,b> | off | bye\n")
|
||||
fill((0, 0, 0))
|
||||
|
||||
running = True
|
||||
while running:
|
||||
line = uart.readline()
|
||||
if line is None:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
|
||||
text = line.decode("utf-8", errors="replace").strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if text == "bye":
|
||||
uart.write("goodbye!\n")
|
||||
running = False
|
||||
break
|
||||
|
||||
if text == "off":
|
||||
fill((0, 0, 0))
|
||||
uart.write("strip off\n")
|
||||
continue
|
||||
|
||||
if text.startswith("color"):
|
||||
rest = text[len("color"):].strip()
|
||||
color = parse_color(rest) if rest else None
|
||||
if color is None:
|
||||
uart.write("usage: color <name> | color r,g,b\n")
|
||||
else:
|
||||
fill(color)
|
||||
uart.write(f"strip = {color}\n")
|
||||
continue
|
||||
|
||||
uart.write(f"echo: {text}\n")
|
||||
|
||||
fill((0, 0, 0))
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Minimal MicroPython-style machine module mock for browser simulation."""
|
||||
|
||||
|
||||
class Pin:
|
||||
IN = 0
|
||||
OUT = 1
|
||||
PULL_UP = 2
|
||||
PULL_DOWN = 3
|
||||
|
||||
def __init__(self, pin_id: int, mode: int = OUT, value: int = 0):
|
||||
self.id = int(pin_id)
|
||||
self.mode = int(mode)
|
||||
self._value = 1 if value else 0
|
||||
|
||||
def value(self, new_value=None):
|
||||
if new_value is None:
|
||||
return self._value
|
||||
self._value = 1 if int(new_value) else 0
|
||||
return self._value
|
||||
Reference in New Issue
Block a user