From f6a5913bc3f68cc4d566e89afec7835c6b268d7f Mon Sep 17 00:00:00 2001 From: Bas van Doren Date: Sat, 24 Jan 2026 13:15:50 +0000 Subject: [PATCH 1/9] Add Micropython project and devcontainer --- .devcontainer/Dockerfile | 9 +++ .devcontainer/devcontainer.json | 20 ++++++ rinkhals-webui/pyproject.toml | 41 +++++++++++ rinkhals-webui/src/rinkhals_webui/__init__.py | 0 rinkhals-webui/src/rinkhals_webui/__main__.py | 4 ++ rinkhals-webui/src/rinkhals_webui/main.py | 69 +++++++++++++++++++ 6 files changed, 143 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 rinkhals-webui/pyproject.toml create mode 100644 rinkhals-webui/src/rinkhals_webui/__init__.py create mode 100644 rinkhals-webui/src/rinkhals_webui/__main__.py create mode 100644 rinkhals-webui/src/rinkhals_webui/main.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..8ebea8d --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,9 @@ +FROM mcr.microsoft.com/devcontainers/base:trixie + +ENV PIP_BREAK_SYSTEM_PACKAGES=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends micropython python3-pip \ + && pip3 install --no-cache-dir hatch micropython-stdlib-stubs \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..dcded15 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,20 @@ +{ + "name": "Rinkhals WebUI", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + "remoteUser": "vscode", + "postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance" + ], + "settings": { + "python.defaultInterpreterPath": "/usr/bin/python3" + } + } + } +} diff --git a/rinkhals-webui/pyproject.toml b/rinkhals-webui/pyproject.toml new file mode 100644 index 0000000..477f509 --- /dev/null +++ b/rinkhals-webui/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "rinkhals-webui" +version = "2.0.0" +description = "Web interface for Rinkhals and Kobra-specific features." +license = { text = "MIT" } +authors = [{ name = "Bas van Doren" }] +requires-python = ">=3.11" +keywords = ["micropython", "webui", "rinkhals"] + +[project.optional-dependencies] +dev = [ + "micropython-stdlib-stubs>=1.20.0.post2", + "ruff>=0.4.10", + "pyright>=1.1.347", + "pytest>=8.3", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/rinkhals_webui"] + +[tool.hatch.envs.default] +dependencies = [ + "micropython-stdlib-stubs>=1.20.0.post2", + "ruff>=0.4.10", + "pytest>=8.3", + "pyright>=1.1.347", +] + +[tool.hatch.envs.default.env-vars] +MICROPYPATH = "src" + +[tool.hatch.envs.default.scripts] +lint = "ruff check src" +format = "ruff format src" +test = "pytest" +run = "micropython -m rinkhals_webui" +repl = "micropython" diff --git a/rinkhals-webui/src/rinkhals_webui/__init__.py b/rinkhals-webui/src/rinkhals_webui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rinkhals-webui/src/rinkhals_webui/__main__.py b/rinkhals-webui/src/rinkhals_webui/__main__.py new file mode 100644 index 0000000..40e2b01 --- /dev/null +++ b/rinkhals-webui/src/rinkhals_webui/__main__.py @@ -0,0 +1,4 @@ +from .main import main + +if __name__ == "__main__": + main() diff --git a/rinkhals-webui/src/rinkhals_webui/main.py b/rinkhals-webui/src/rinkhals_webui/main.py new file mode 100644 index 0000000..1f3a492 --- /dev/null +++ b/rinkhals-webui/src/rinkhals_webui/main.py @@ -0,0 +1,69 @@ +"""Minimal MicroPython bootstrap for the Rinkhals WebUI port.""" + +try: + import machine # type: ignore[import-not-found] +except ImportError: # pragma: no cover + machine = None + +try: + import utime as _time # type: ignore[import-not-found] +except ImportError: # pragma: no cover + import time as _time + + +def _sleep_ms(duration_ms: int) -> None: + """Sleep helper that works on both CPython and MicroPython.""" + if hasattr(_time, "sleep_ms"): + _time.sleep_ms(duration_ms) + else: + _time.sleep(duration_ms / 1000) + + +class _MockPin: + """Lightweight stand-in when `machine.Pin` is unavailable during simulation.""" + + OUT = 0 + + def __init__(self, pin_id: int, mode: int, value: int = 0) -> None: + self.pin_id = pin_id + self.mode = mode + self._value = value + + def value(self, new_value: int | None = None) -> int: + if new_value is None: + return self._value + self._value = new_value + print(f"[mock-pin] pin={self.pin_id} -> {self._value}") + return self._value + + +class StatusLED: + """Basic status indicator abstraction to verify event flow end-to-end.""" + + def __init__(self, pin_id: int = 2, active_high: bool = True) -> None: + pin_cls = machine.Pin if machine and hasattr(machine, "Pin") else _MockPin + initial = 1 if not active_high else 0 + self._pin = pin_cls(pin_id, pin_cls.OUT, value=initial) + self._active_high = active_high + + def on(self) -> None: + self._pin.value(1 if self._active_high else 0) + + def off(self) -> None: + self._pin.value(0 if self._active_high else 1) + + def blink(self, count: int = 3, period_ms: int = 200) -> None: + for _ in range(count): + self.on() + _sleep_ms(period_ms // 2) + self.off() + _sleep_ms(period_ms // 2) + + +def main() -> None: + led = StatusLED() + led.blink(count=5, period_ms=300) + + +if __name__ == "__main__": # pragma: no cover + main() From f7ab1273e27a3b2e7ffc03399fa9da35d7137b6f Mon Sep 17 00:00:00 2001 From: Bas van Doren Date: Sat, 24 Jan 2026 15:15:28 +0000 Subject: [PATCH 2/9] Add Microdot framework and update dev environment --- .devcontainer/Dockerfile | 2 +- .devcontainer/devcontainer.json | 4 +- .devcontainer/post-create.sh | 14 + .gitignore | 2 + .../pyproject.toml => pyproject.toml | 14 +- pyrightconfig.json | 13 + rinkhals-webui/src/microdot/LICENSE | 21 + rinkhals-webui/src/microdot/README.md | 7 + rinkhals-webui/src/microdot/VENDORED.md | 7 + rinkhals-webui/src/microdot/__init__.py | 4 + rinkhals-webui/src/microdot/asgi.py | 274 +++ rinkhals-webui/src/microdot/auth.py | 162 ++ rinkhals-webui/src/microdot/cors.py | 111 ++ rinkhals-webui/src/microdot/csrf.py | 120 ++ rinkhals-webui/src/microdot/helpers.py | 8 + rinkhals-webui/src/microdot/login.py | 172 ++ rinkhals-webui/src/microdot/microdot.py | 1551 +++++++++++++++++ rinkhals-webui/src/microdot/multipart.py | 298 ++++ .../src/microdot/pyrightconfig.json | 4 + rinkhals-webui/src/microdot/session.py | 159 ++ rinkhals-webui/src/microdot/sse.py | 139 ++ rinkhals-webui/src/microdot/test_client.py | 385 ++++ rinkhals-webui/src/microdot/utemplate.py | 70 + rinkhals-webui/src/microdot/websocket.py | 231 +++ rinkhals-webui/src/microdot/wsgi.py | 161 ++ rinkhals-webui/src/rinkhals_webui/main.py | 70 +- 26 files changed, 3937 insertions(+), 66 deletions(-) create mode 100644 .devcontainer/post-create.sh rename rinkhals-webui/pyproject.toml => pyproject.toml (70%) create mode 100644 pyrightconfig.json create mode 100644 rinkhals-webui/src/microdot/LICENSE create mode 100644 rinkhals-webui/src/microdot/README.md create mode 100644 rinkhals-webui/src/microdot/VENDORED.md create mode 100644 rinkhals-webui/src/microdot/__init__.py create mode 100644 rinkhals-webui/src/microdot/asgi.py create mode 100644 rinkhals-webui/src/microdot/auth.py create mode 100644 rinkhals-webui/src/microdot/cors.py create mode 100644 rinkhals-webui/src/microdot/csrf.py create mode 100644 rinkhals-webui/src/microdot/helpers.py create mode 100644 rinkhals-webui/src/microdot/login.py create mode 100644 rinkhals-webui/src/microdot/microdot.py create mode 100644 rinkhals-webui/src/microdot/multipart.py create mode 100644 rinkhals-webui/src/microdot/pyrightconfig.json create mode 100644 rinkhals-webui/src/microdot/session.py create mode 100644 rinkhals-webui/src/microdot/sse.py create mode 100644 rinkhals-webui/src/microdot/test_client.py create mode 100644 rinkhals-webui/src/microdot/utemplate.py create mode 100644 rinkhals-webui/src/microdot/websocket.py create mode 100644 rinkhals-webui/src/microdot/wsgi.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8ebea8d..b3269b0 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -4,6 +4,6 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN apt-get update \ && apt-get install -y --no-install-recommends micropython python3-pip \ - && pip3 install --no-cache-dir hatch micropython-stdlib-stubs \ + && pip3 install --no-cache-dir hatch \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index dcded15..3fb6ff6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,7 @@ "context": ".." }, "remoteUser": "vscode", - "postCreateCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "postCreateCommand": ".devcontainer/post-create.sh ${containerWorkspaceFolder}", "customizations": { "vscode": { "extensions": [ @@ -13,7 +13,7 @@ "ms-python.vscode-pylance" ], "settings": { - "python.defaultInterpreterPath": "/usr/bin/python3" + "python.defaultInterpreterPath": "${containerWorkspaceFolder}/.venv/bin/python" } } } diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100644 index 0000000..9cdadbe --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +workspace_dir="${1:-}" + +if [ -z "${workspace_dir}" ]; then + echo "post-create.sh requires the workspace path as its first argument" >&2 + exit 1 +fi + +git config --global --add safe.directory "${workspace_dir}" + +cd "${workspace_dir}" +hatch -v env create diff --git a/.gitignore b/.gitignore index de21fd8..9fecb16 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ build/dist/ +.venv/ +.ruff_cache/ diff --git a/rinkhals-webui/pyproject.toml b/pyproject.toml similarity index 70% rename from rinkhals-webui/pyproject.toml rename to pyproject.toml index 477f509..1a6533f 100644 --- a/rinkhals-webui/pyproject.toml +++ b/pyproject.toml @@ -14,28 +14,34 @@ keywords = ["micropython", "webui", "rinkhals"] [project.optional-dependencies] dev = [ "micropython-stdlib-stubs>=1.20.0.post2", + "micropython-unix-stubs>=1.20.0", "ruff>=0.4.10", "pyright>=1.1.347", "pytest>=8.3", ] [tool.hatch.build.targets.wheel] -packages = ["src/rinkhals_webui"] +packages = [ + "rinkhals-webui/src/rinkhals_webui", + "rinkhals-webui/src/microdot", +] [tool.hatch.envs.default] +path = ".venv" dependencies = [ "micropython-stdlib-stubs>=1.20.0.post2", + "micropython-unix-stubs>=1.20.0", "ruff>=0.4.10", "pytest>=8.3", "pyright>=1.1.347", ] [tool.hatch.envs.default.env-vars] -MICROPYPATH = "src" +MICROPYPATH = "rinkhals-webui/src:.frozen:/home/vscode/.micropython/lib:/usr/lib/micropython" [tool.hatch.envs.default.scripts] -lint = "ruff check src" -format = "ruff format src" +lint = "ruff check rinkhals-webui/src" +format = "ruff format rinkhals-webui/src" test = "pytest" run = "micropython -m rinkhals_webui" repl = "micropython" diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..5e92fca --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,13 @@ +{ + "venvPath": ".", + "venv": ".venv", + "include": [ + "rinkhals-webui/src", + "rinkhals-webui/scripts" + ], + "ignore": [ + "rinkhals-webui/src/microdot" + ], + // Micropython type stubs have no source + "reportMissingModuleSource": "none" +} diff --git a/rinkhals-webui/src/microdot/LICENSE b/rinkhals-webui/src/microdot/LICENSE new file mode 100644 index 0000000..ba45cea --- /dev/null +++ b/rinkhals-webui/src/microdot/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Miguel Grinberg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rinkhals-webui/src/microdot/README.md b/rinkhals-webui/src/microdot/README.md new file mode 100644 index 0000000..90f3470 --- /dev/null +++ b/rinkhals-webui/src/microdot/README.md @@ -0,0 +1,7 @@ +# Microdot manual copy + +These sources originate from https://github.com/miguelgrinberg/microdot at commit `0ba1b928797d674e10841c722ff50b310adbc5c8` (January 15, 2024). + +The files are included verbatim to provide Microdot and its core extension modules on MicroPython targets where installing from PyPI is impractical. + +To update, re-clone the upstream repository at the desired commit and replace this directory, ensuring you also copy the LICENSE file. diff --git a/rinkhals-webui/src/microdot/VENDORED.md b/rinkhals-webui/src/microdot/VENDORED.md new file mode 100644 index 0000000..2f658a4 --- /dev/null +++ b/rinkhals-webui/src/microdot/VENDORED.md @@ -0,0 +1,7 @@ +# Microdot Vendored Copy + +These sources originate from https://github.com/miguelgrinberg/microdot at commit `0ba1b928797d674e10841c722ff50b310adbc5c8` (January 15, 2024). + +The files are included verbatim to provide Microdot and its core extension modules on MicroPython targets where installing from PyPI is impractical. + +To update, re-clone the upstream repository at the desired commit and replace this directory, ensuring you also copy the LICENSE file. diff --git a/rinkhals-webui/src/microdot/__init__.py b/rinkhals-webui/src/microdot/__init__.py new file mode 100644 index 0000000..5d1e1b0 --- /dev/null +++ b/rinkhals-webui/src/microdot/__init__.py @@ -0,0 +1,4 @@ +from microdot.microdot import Microdot, Request, Response, abort, redirect, \ + send_file, URLPattern, AsyncBytesIO, iscoroutine # noqa: F401 + +__version__ = '2.5.2.dev0' diff --git a/rinkhals-webui/src/microdot/asgi.py b/rinkhals-webui/src/microdot/asgi.py new file mode 100644 index 0000000..d533de1 --- /dev/null +++ b/rinkhals-webui/src/microdot/asgi.py @@ -0,0 +1,274 @@ +import asyncio +import os +import signal +from microdot import * # noqa: F401, F403 +from microdot.microdot import Microdot as BaseMicrodot, Request, Response, \ + NoCaseDict, abort +from microdot.websocket import WebSocket as BaseWebSocket, websocket_wrapper + + +class _BodyStream: # pragma: no cover + def __init__(self, receive): + self.receive = receive + self.data = b'' + self.more = True + + async def read_more(self): + if self.more: + packet = await self.receive() + self.data += packet.get('body', b'') + self.more = packet.get('more_body', False) + + async def read(self, n=-1): + while self.more and len(self.data) < n: + await self.read_more() + if len(self.data) < n: + data = self.data + self.data = b'' + return data + + data = self.data[:n] + self.data = self.data[n:] + return data + + async def readline(self): + return await self.readuntil() + + async def readexactly(self, n): + return await self.read(n) + + async def readuntil(self, separator=b'\n'): + if self.more and separator not in self.data: + await self.read_more() + data, self.data = self.data.split(separator, 1) + return data + + +class Microdot(BaseMicrodot): + """A subclass of the core :class:`Microdot ` class that + implements the ASGI protocol. + + :param startup: An optional function to handle the `lifespan.startup` ASGI + signal. + :param shutdown: An optional function to handle the `lifespan.shutdown` + ASGI signal. + + This class must be used as the application instance when running under an + ASGI web server. + """ + def __init__(self, lifespan_startup=None, lifespan_shutdown=None): + super().__init__() + self.lifespan_startup = lifespan_startup + self.lifespan_shutdown = lifespan_shutdown + self.embedded_server = False + + async def handle_lifespan(self, scope, receive, send): + while True: + message = await receive() + if message['type'] == 'lifespan.startup': + try: + if self.lifespan_startup: + await self.lifespan_startup(scope) + except Exception as e: + await send({'type': 'lifespan.startup.failed', + 'message': repr(e)}) + else: + await send({'type': 'lifespan.startup.complete'}) + elif message['type'] == 'lifespan.shutdown': # pragma: no branch + try: + if self.lifespan_shutdown: + await self.lifespan_shutdown(scope) + except Exception as e: + await send({'type': 'lifespan.shutdown.failed', + 'message': repr(e)}) + else: + await send({'type': 'lifespan.shutdown.complete'}) + break + + async def asgi_app(self, scope, receive, send): + """An ASGI application.""" + if scope['type'] == 'lifespan': + return await self.handle_lifespan(scope, receive, send) + if scope['type'] not in ['http', 'websocket']: # pragma: no cover + return + path = scope['path'] + if 'query_string' in scope and scope['query_string']: + path += '?' + scope['query_string'].decode() + headers = NoCaseDict() + content_length = 0 + for key, value in scope.get('headers', []): + key = key.decode().title() + headers[key] = value.decode() + if key == 'Content-Length': + content_length = int(value) + + if content_length and content_length <= Request.max_body_length: + body = b'' + more = True + while more: + packet = await receive() + body += packet.get('body', b'') + more = packet.get('more_body', False) + stream = None + else: + body = b'' + stream = _BodyStream(receive) + + req = Request( + self, + (scope['client'][0], scope['client'][1]), + scope.get('method', 'GET'), + path, + 'HTTP/' + scope['http_version'], + headers, + body=body, + stream=stream, + sock=(receive, send), + scheme=scope.get('scheme')) + req.asgi_scope = scope + + res = await self.dispatch_request(req) + res.complete() + + header_list = [] + for name, value in res.headers.items(): + if not isinstance(value, list): + header_list.append((name.lower().encode(), value.encode())) + else: + for v in value: + header_list.append((name.lower().encode(), v.encode())) + + if scope['type'] != 'http': # pragma: no cover + return + + await send({'type': 'http.response.start', + 'status': res.status_code, + 'headers': header_list}) + + cancelled = False + + async def cancel_monitor(): + nonlocal cancelled + + while True: + event = await receive() + if event is None or \ + event['type'] == 'http.disconnect': # pragma: no cover + cancelled = True + break + + monitor_task = asyncio.ensure_future(cancel_monitor()) + + body_iter = res.body_iter().__aiter__() + try: + while not cancelled: # pragma: no branch + res_body = await body_iter.__anext__() + if isinstance(res_body, str): + res_body = res_body.encode() + await send({'type': 'http.response.body', + 'body': res_body, + 'more_body': True}) + except StopAsyncIteration: + pass + await send({'type': 'http.response.body', + 'body': b'', + 'more_body': False}) + if hasattr(body_iter, 'aclose'): # pragma: no branch + await body_iter.aclose() + cancelled = True + await monitor_task + + async def __call__(self, scope, receive, send): + return await self.asgi_app(scope, receive, send) + + def shutdown(self): + if self.embedded_server: # pragma: no cover + super().shutdown() + else: + pid = os.getpgrp() if hasattr(os, 'getpgrp') else os.getpid() + os.kill(pid, signal.SIGTERM) + + def run(self, host='0.0.0.0', port=5000, debug=False, + **options): # pragma: no cover + """Normally you would not start the server by invoking this method. + Instead, start your chosen ASGI web server and pass the ``Microdot`` + instance as the ASGI application. + """ + self.embedded_server = True + super().run(host=host, port=port, debug=debug, **options) + + +class WebSocket(BaseWebSocket): # pragma: no cover + async def handshake(self): + connect = await self.request.sock[0]() + if connect['type'] != 'websocket.connect': + abort(400) + await self.request.sock[1]({'type': 'websocket.accept'}) + + async def receive(self): + message = await self.request.sock[0]() + if message['type'] == 'websocket.disconnect': + raise OSError(32, 'Websocket connection closed') + elif message['type'] != 'websocket.receive': + raise OSError(32, 'Websocket message type not supported') + return message.get('bytes', message.get('text')) + + async def send(self, data): + if isinstance(data, str): + await self.request.sock[1]( + {'type': 'websocket.send', 'text': data}) + else: + await self.request.sock[1]( + {'type': 'websocket.send', 'bytes': data}) + + async def close(self): + if not self.closed: + self.closed = True + try: + await self.request.sock[1]({'type': 'websocket.close'}) + except: # noqa E722 + pass + + +async def websocket_upgrade(request): # pragma: no cover + """Upgrade a request handler to a websocket connection. + + This function can be called directly inside a route function to process a + WebSocket upgrade handshake, for example after the user's credentials are + verified. The function returns the websocket object:: + + @app.route('/echo') + async def echo(request): + if not (await authenticate_user(request)): + abort(401) + ws = await websocket_upgrade(request) + while True: + message = await ws.receive() + await ws.send(message) + """ + ws = WebSocket(request) if not request.app.embedded_server else \ + BaseWebSocket(request) + await ws.handshake() + + @request.after_request + async def after_request(request, response): + return Response.already_handled + + return ws + + +def with_websocket(f): # pragma: no cover + """Decorator to make a route a WebSocket endpoint. + + This decorator is used to define a route that accepts websocket + connections. The route then receives a websocket object as a second + argument that it can use to send and receive messages:: + + @app.route('/echo') + @with_websocket + async def echo(request, ws): + while True: + message = await ws.receive() + await ws.send(message) + """ + return websocket_wrapper(f, websocket_upgrade) diff --git a/rinkhals-webui/src/microdot/auth.py b/rinkhals-webui/src/microdot/auth.py new file mode 100644 index 0000000..71574ee --- /dev/null +++ b/rinkhals-webui/src/microdot/auth.py @@ -0,0 +1,162 @@ +from microdot import abort +from microdot.microdot import invoke_handler + + +class BaseAuth: + def __init__(self): + self.auth_callback = None + self.error_callback = None + + def __call__(self, f): + """Decorator to protect a route with authentication. + + An instance of this class must be used as a decorator on the routes + that need to be protected. Example:: + + auth = BasicAuth() # or TokenAuth() + + @app.route('/protected') + @auth + def protected(request): + # ... + + Routes that are decorated in this way will only be invoked if the + authentication callback returned a valid user object, otherwise the + error callback will be executed. + """ + async def wrapper(request, *args, **kwargs): + auth = self._get_auth(request) + if not auth: + return await invoke_handler(self.error_callback, request) + request.g.current_user = await invoke_handler( + self.auth_callback, request, *auth) + if not request.g.current_user: + return await invoke_handler(self.error_callback, request) + return await invoke_handler(f, request, *args, **kwargs) + + return wrapper + + def optional(self, f): + """Decorator to protect a route with optional authentication. + + This decorator makes authentication for the decorated route optional, + meaning that the route is allowed to run with or with + authentication given in the request. + """ + async def wrapper(request, *args, **kwargs): + auth = self._get_auth(request) + if not auth: + request.g.current_user = None + else: + request.g.current_user = await invoke_handler( + self.auth_callback, request, *auth) + return await invoke_handler(f, request, *args, **kwargs) + + return wrapper + + +class BasicAuth(BaseAuth): + """Basic Authentication. + + :param realm: The realm that is displayed when the user is prompted to + authenticate in the browser. + :param charset: The charset that is used to encode the realm. + :param scheme: The authentication scheme. Defaults to 'Basic'. + :param error_status: The error status code to return when authentication + fails. Defaults to 401. + """ + def __init__(self, realm='Please login', charset='UTF-8', scheme='Basic', + error_status=401): + super().__init__() + self.realm = realm + self.charset = charset + self.scheme = scheme + self.error_status = error_status + self.error_callback = self.authentication_error + + def _get_auth(self, request): + auth = request.headers.get('Authorization') + if auth and auth.startswith('Basic '): + import binascii + try: + username, password = binascii.a2b_base64( + auth[6:]).decode().split(':', 1) + except Exception: # pragma: no cover + return None + return username, password + + async def authentication_error(self, request): + return '', self.error_status, { + 'WWW-Authenticate': '{} realm="{}", charset="{}"'.format( + self.scheme, self.realm, self.charset)} + + def authenticate(self, f): + """Decorator to configure the authentication callback. + + This decorator must be used with a function that accepts the request + object, a username and a password and returns a user object if the + credentials are valid, or ``None`` if they are not. Example:: + + @auth.authenticate + async def check_credentials(request, username, password): + user = get_user(username) + if user and user.check_password(password): + return get_user(username) + """ + self.auth_callback = f + + +class TokenAuth(BaseAuth): + """Token based authentication. + + :param header: The name of the header that will contain the token. Defaults + to 'Authorization'. + :param scheme: The authentication scheme. Defaults to 'Bearer'. + :param error_status: The error status code to return when authentication + fails. Defaults to 401. + """ + def __init__(self, header='Authorization', scheme='Bearer', + error_status=401): + super().__init__() + self.header = header + self.scheme = scheme.lower() + self.error_status = error_status + self.error_callback = self.authentication_error + + def _get_auth(self, request): + auth = request.headers.get(self.header) + if auth: + if self.header == 'Authorization': + try: + scheme, token = auth.split(' ', 1) + except Exception: + return None + if scheme.lower() == self.scheme: + return (token.strip(),) + else: + return (auth,) + + def authenticate(self, f): + """Decorator to configure the authentication callback. + + This decorator must be used with a function that accepts the request + object, a username and a password and returns a user object if the + credentials are valid, or ``None`` if they are not. Example:: + + @auth.authenticate + async def check_credentials(request, token): + return get_user(token) + """ + self.auth_callback = f + + def errorhandler(self, f): + """Decorator to configure the error callback. + + Microdot calls the error callback to allow the application to generate + a custom error response. The default error response is to call + ``abort(401)``. + """ + self.error_callback = f + + async def authentication_error(self, request): + abort(self.error_status) diff --git a/rinkhals-webui/src/microdot/cors.py b/rinkhals-webui/src/microdot/cors.py new file mode 100644 index 0000000..3c24935 --- /dev/null +++ b/rinkhals-webui/src/microdot/cors.py @@ -0,0 +1,111 @@ +class CORS: + """Add CORS headers to HTTP responses. + + :param app: The application to add CORS headers to. + :param allowed_origins: A list of origins that are allowed to make + cross-site requests. If set to '*', all origins are + allowed. + :param allow_credentials: If set to True, the + ``Access-Control-Allow-Credentials`` header will + be set to ``true`` to indicate to the browser + that it can expose cookies and authentication + headers. + :param allowed_methods: A list of methods that are allowed to be used when + making cross-site requests. If not set, all methods + are allowed. + :param expose_headers: A list of headers that the browser is allowed to + exposed. + :param allowed_headers: A list of headers that are allowed to be used when + making cross-site requests. If not set, all headers + are allowed. + :param max_age: The maximum amount of time in seconds that the browser + should cache the results of a preflight request. + :param handle_cors: If set to False, CORS headers will not be added to + responses. This can be useful if you want to add CORS + headers manually. + """ + def __init__(self, app=None, allowed_origins=None, allow_credentials=False, + allowed_methods=None, expose_headers=None, + allowed_headers=None, max_age=None, handle_cors=True): + self.allowed_origins = allowed_origins + self.allow_credentials = allow_credentials + self.allowed_methods = allowed_methods + self.expose_headers = expose_headers + self.allowed_headers = None if allowed_headers is None \ + else [h.lower() for h in allowed_headers] + self.max_age = max_age + if app is not None: + self.initialize(app, handle_cors=handle_cors) + + def initialize(self, app, handle_cors=True): + """Initialize the CORS object for the given application. + + :param app: The application to add CORS headers to. + :param handle_cors: If set to False, CORS headers will not be added to + responses. This can be useful if you want to add + CORS headers manually. + """ + self.default_options_handler = app.options_handler + if handle_cors: + app.options_handler = self.options_handler + app.after_request(self.after_request) + app.after_error_request(self.after_request) + + def options_handler(self, request): + headers = self.default_options_handler(request) + headers.update(self.get_cors_headers(request)) + return headers + + def get_cors_headers(self, request): + """Return a dictionary of CORS headers to add to a given request. + + :param request: The request to add CORS headers to. + """ + cors_headers = {} + origin = request.headers.get('Origin') + if self.allowed_origins == '*': + cors_headers['Access-Control-Allow-Origin'] = origin or '*' + if origin: + cors_headers['Vary'] = 'Origin' + elif origin in (self.allowed_origins or []): + cors_headers['Access-Control-Allow-Origin'] = origin + cors_headers['Vary'] = 'Origin' + if self.allow_credentials and \ + 'Access-Control-Allow-Origin' in cors_headers: + cors_headers['Access-Control-Allow-Credentials'] = 'true' + if self.expose_headers: + cors_headers['Access-Control-Expose-Headers'] = \ + ', '.join(self.expose_headers) + + if request.method == 'OPTIONS': + # handle preflight request + if self.max_age: + cors_headers['Access-Control-Max-Age'] = str(self.max_age) + + method = request.headers.get('Access-Control-Request-Method') + if method: + method = method.upper() + if self.allowed_methods is None or \ + method in self.allowed_methods: + cors_headers['Access-Control-Allow-Methods'] = method + + headers = request.headers.get('Access-Control-Request-Headers') + if headers: + if self.allowed_headers is None: + cors_headers['Access-Control-Allow-Headers'] = headers + else: + headers = [h.strip() for h in headers.split(',')] + headers = [h for h in headers + if h.lower() in self.allowed_headers] + cors_headers['Access-Control-Allow-Headers'] = \ + ', '.join(headers) + + return cors_headers + + def after_request(self, request, response): + saved_vary = response.headers.get('Vary') + if request: # pragma: no branch + response.headers.update(self.get_cors_headers(request)) + if saved_vary and saved_vary != response.headers.get('Vary'): + response.headers['Vary'] = ( + saved_vary + ', ' + response.headers['Vary']) diff --git a/rinkhals-webui/src/microdot/csrf.py b/rinkhals-webui/src/microdot/csrf.py new file mode 100644 index 0000000..dcee5da --- /dev/null +++ b/rinkhals-webui/src/microdot/csrf.py @@ -0,0 +1,120 @@ +from microdot import abort + + +class CSRF: + """CSRF protection for Microdot routes. + + :param app: The application instance. + :param cors: The ``CORS`` instance that defines the origins that are + trusted by the application. This is used to validate requests + from older browsers that do not send the ``Sec-Fetch-Site`` + header. + :param protect_all: If ``True``, all state changing routes are protected by + default, with the exception of routes that are + decorated with the :meth:`exempt ` decorator. + If ``False``, only routes decorated with the + :meth:`protect ` decorator are protected. The + default is ``True``. + :param allow_subdomains: If ``True``, requests from subdomains of the + application domain are trusted. The default is + ``False``. + + CSRF protection is implemented by checking the ``Sec-Fetch-Site`` sent by + browsers. When the ``cors`` argument is provided, requests from older + browsers that do not support the ``Sec-Fetch-Site`` header are validated + by checking the ``Origin`` header. + """ + SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] + + def __init__(self, app=None, cors=None, protect_all=True, + allow_subdomains=False): + self.cors = None + self.protect_all = protect_all + self.allow_subdomains = allow_subdomains + self.exempt_routes = [] + self.protected_routes = [] + if app is not None: + self.initialize(app, cors) + + def initialize(self, app, cors=None): + """Initialize the CSRF class. + + :param app: The application instance. + :param cors: The ``CORS`` instance that defines the origins that are + trusted by the application. This is used to validate + requests from older browsers that do not send the + ``Sec-Fetch-Site`` header. + """ + self.cors = cors + + @app.before_request + async def csrf_before_request(request): + if ( + self.protect_all + and request.method not in self.SAFE_METHODS + and request.route not in self.exempt_routes + ) or request.route in self.protected_routes: + allow = False + sfs = request.headers.get('Sec-Fetch-Site') + origin = request.headers.get('Origin') + if sfs: + # if the Sec-Fetch-Site header was given, ensure it is not + # cross-site + if sfs in ['same-origin', 'none']: + allow = True + elif sfs == 'same-site' and self.allow_subdomains: + allow = True + if not allow and origin and self.cors and \ + self.cors.allowed_origins != '*': + # if we have a list of allowed origins, then we can + # validate the origin + if not self.allow_subdomains: + allow = origin in self.cors.allowed_origins + else: + origin_scheme, origin_host = origin.split('://', 1) + for allowed_origin in self.cors.allowed_origins: + allowed_scheme, allowed_host = \ + allowed_origin.split('://', 1) + if origin == allowed_origin or ( + origin_host.endswith('.' + allowed_host) + and origin_scheme == allowed_scheme + ): + allow = True + break + if not allow and not sfs and not origin: + allow = True # no headers to check + + if not allow: + abort(403, 'Forbidden') + + def exempt(self, f): + """Decorator to exempt a route from CSRF protection. + + This decorator must be added immediately after the route decorator to + disable CSRF protection on the route. Example:: + + @app.post('/submit') + @csrf.exempt + # add additional decorators here + def submit(request): + # ... + """ + self.exempt_routes.append(f) + return f + + def protect(self, f): + """Decorator to protect a route against CSRF attacks. + + This is useful when it is necessary to protect a request that uses one + of the safe methods that are not supposed to make state changes. The + decorator must be added immediately after the route decorator to + disable CSRF protection on the route. Example:: + + @app.get('/data') + @csrf.force + # add additional decorators here + def get_data(request): + # ... + """ + self.protected_routes.append(f) + return f diff --git a/rinkhals-webui/src/microdot/helpers.py b/rinkhals-webui/src/microdot/helpers.py new file mode 100644 index 0000000..664e58c --- /dev/null +++ b/rinkhals-webui/src/microdot/helpers.py @@ -0,0 +1,8 @@ +try: + from functools import wraps +except ImportError: # pragma: no cover + # MicroPython does not currently implement functools.wraps + def wraps(wrapped): + def _(wrapper): + return wrapper + return _ diff --git a/rinkhals-webui/src/microdot/login.py b/rinkhals-webui/src/microdot/login.py new file mode 100644 index 0000000..53cdb5f --- /dev/null +++ b/rinkhals-webui/src/microdot/login.py @@ -0,0 +1,172 @@ +from time import time +from microdot import redirect +from microdot.microdot import urlencode, invoke_handler + + +class Login: + """User login support for Microdot. + + :param login_url: the URL to redirect to when a login is required. The + default is '/login'. + """ + def __init__(self, login_url='/login'): + self.login_url = login_url + self.user_loader_callback = None + + def user_loader(self, f): + """Decorator to configure the user callback. + + The decorated function receives the user ID as an argument and must + return the corresponding user object, or ``None`` if the user ID is + invalid. + """ + self.user_loader_callback = f + + def _get_session(self, request): + return request.app._session.get(request) + + def _update_remember_cookie(self, request, days, user_id=None): + remember_payload = request.app._session.encode({ + 'user_id': user_id, + 'days': days, + 'exp': time() + days * 24 * 60 * 60 + }) + + @request.after_request + async def _set_remember_cookie(request, response): + response.set_cookie('_remember', remember_payload, + max_age=days * 24 * 60 * 60) + return response + + def _get_user_id_from_session(self, request): + session = self._get_session(request) + if session and '_user_id' in session: + return session['_user_id'] + if '_remember' in request.cookies: + remember_payload = request.app._session.decode( + request.cookies['_remember']) + user_id = remember_payload.get('user_id') + if user_id: # pragma: no branch + self._update_remember_cookie( + request, remember_payload.get('_days', 30), user_id) + session['_user_id'] = user_id + session['_fresh'] = False + session.save() + return user_id + + async def _redirect_to_login(self, request): + return '', 302, {'Location': self.login_url + '?next=' + urlencode( + request.url)} + + async def login_user(self, request, user, remember=False, + redirect_url='/'): + """Log a user in. + + :param request: the request object + :param user: the user object + :param remember: if the user's logged in state should be remembered + with a cookie after the session ends. Set to the + number of days the remember cookie should last, or to + ``True`` to use a default duration of 30 days. + :param redirect_url: the URL to redirect to after login + + This call marks the user as logged in by storing their user ID in the + user session. The application must call this method to log a user in + after their credentials have been validated. + + The method returns a redirect response, either to the URL the user + originally intended to visit, or if there is no original URL to the URL + specified by the `redirect_url`. + """ + session = self._get_session(request) + session['_user_id'] = user.id + session['_fresh'] = True + session.save() + request.g.current_user = user + + if remember: + days = 30 if remember is True else int(remember) + self._update_remember_cookie(request, days, session['_user_id']) + + next_url = request.args.get('next', redirect_url) + if not next_url.startswith('/'): + next_url = redirect_url + return redirect(next_url) + + async def logout_user(self, request): + """Log a user out. + + :param request: the request object + + This call removes information about the user's log in from the user + session. If a remember cookie exists, it is removed as well. + """ + session = self._get_session(request) + session.pop('_user_id', None) + session.pop('_fresh', None) + session.save() + request.g.current_user = None + if '_remember' in request.cookies: + self._update_remember_cookie(request, 0) + + async def get_current_user(self, request): + """Return the currently logged in user.""" + if not hasattr(request.g, 'current_user'): + user_id = self._get_user_id_from_session(request) + if user_id: + request.g.current_user = await invoke_handler( + self.user_loader_callback, user_id) + else: + request.g.current_user = None + return request.g.current_user + + def __call__(self, f): + """Decorator to protect a route with authentication. + + If the user is not logged in, Microdot will redirect to the login page + first. The decorated route will only run after successful login by the + user. If the user is already logged in, the route will run immediately. + Example:: + + login = Login() + + @app.route('/secret') + @login + async def secret(request): + # only accessible to authenticated users + + """ + async def wrapper(request, *args, **kwargs): + user = await self.get_current_user(request) + if not user: + return await self._redirect_to_login(request) + return await invoke_handler(f, request, *args, **kwargs) + + return wrapper + + def fresh(self, f): + """Decorator to protect a route with "fresh" authentication. + + This decorator prevents the route from running when the login session + is not fresh. A fresh session is a session that has been created from + direct user interaction with the login page, while a non-fresh session + occurs when a login is restored from a "remember me" cookie. Example:: + + login = Login() + + @app.route('/secret') + @auth.fresh + async def secret(request): + # only accessible to authenticated users + # users logged in via remember me cookie will need to + # re-authenticate + """ + base_wrapper = self.__call__(f) + + async def wrapper(request, *args, **kwargs): + session = self._get_session(request) + if session.get('_fresh'): + return await base_wrapper(request, *args, **kwargs) + return await self._redirect_to_login(request) + + return wrapper diff --git a/rinkhals-webui/src/microdot/microdot.py b/rinkhals-webui/src/microdot/microdot.py new file mode 100644 index 0000000..ccb170e --- /dev/null +++ b/rinkhals-webui/src/microdot/microdot.py @@ -0,0 +1,1551 @@ +""" +microdot +-------- + +The ``microdot`` module defines a few classes that help implement HTTP-based +servers for MicroPython and standard Python. +""" +import asyncio +import io +import re +import time + +try: + import orjson as json +except ImportError: + import json + +try: + from inspect import iscoroutinefunction, iscoroutine + from functools import partial + + async def invoke_handler(handler, *args, **kwargs): + """Invoke a handler and return the result. + + This method runs sync handlers in a thread pool executor. + """ + if iscoroutinefunction(handler): + ret = await handler(*args, **kwargs) + else: + ret = await asyncio.get_running_loop().run_in_executor( + None, partial(handler, *args, **kwargs)) + return ret +except ImportError: # pragma: no cover + def iscoroutine(coro): + return hasattr(coro, 'send') and hasattr(coro, 'throw') + + async def invoke_handler(handler, *args, **kwargs): + """Invoke a handler and return the result. + + This method runs sync handlers in the asyncio thread, which can + potentially cause blocking and performance issues. + """ + ret = handler(*args, **kwargs) + if iscoroutine(ret): + ret = await ret + return ret + +try: + from sys import print_exception +except ImportError: # pragma: no cover + import traceback + + def print_exception(exc): + traceback.print_exc() + +MUTED_SOCKET_ERRORS = [ + 32, # Broken pipe + 54, # Connection reset by peer + 104, # Connection reset by peer + 128, # Operation on closed socket +] + + +def urldecode(s): + if isinstance(s, str): + s = s.encode() + s = s.replace(b'+', b' ') + parts = s.split(b'%') + if len(parts) == 1: + return s.decode() + result = [parts[0]] + for item in parts[1:]: + if item == b'': + result.append(b'%') + else: + code = item[:2] + result.append(bytes([int(code, 16)])) + result.append(item[2:]) + return b''.join(result).decode() + + +def urlencode(s): + return s.replace('+', '%2B').replace(' ', '+').replace( + '%', '%25').replace('?', '%3F').replace('#', '%23').replace( + '&', '%26').replace('=', '%3D') + + +class NoCaseDict(dict): + """A subclass of dictionary that holds case-insensitive keys. + + :param initial_dict: an initial dictionary of key/value pairs to + initialize this object with. + + Example:: + + >>> d = NoCaseDict() + >>> d['Content-Type'] = 'text/html' + >>> print(d['Content-Type']) + text/html + >>> print(d['content-type']) + text/html + >>> print(d['CONTENT-TYPE']) + text/html + >>> del d['cOnTeNt-TyPe'] + >>> print(d) + {} + """ + def __init__(self, initial_dict=None): + super().__init__(initial_dict or {}) + self.keymap = {k.lower(): k for k in self.keys() if k.lower() != k} + + def __setitem__(self, key, value): + kl = key.lower() + key = self.keymap.get(kl, key) + if kl != key: + self.keymap[kl] = key + super().__setitem__(key, value) + + def __getitem__(self, key): + kl = key.lower() + return super().__getitem__(self.keymap.get(kl, kl)) + + def __delitem__(self, key): + kl = key.lower() + super().__delitem__(self.keymap.get(kl, kl)) + + def __contains__(self, key): + kl = key.lower() + return self.keymap.get(kl, kl) in self.keys() + + def get(self, key, default=None): + kl = key.lower() + return super().get(self.keymap.get(kl, kl), default) + + def update(self, other_dict): + for key, value in other_dict.items(): + self[key] = value + + +def mro(cls): # pragma: no cover + """Return the method resolution order of a class. + + This is a helper function that returns the method resolution order of a + class. It is used by Microdot to find the best error handler to invoke for + the raised exception. + + In CPython, this function returns the ``__mro__`` attribute of the class. + In MicroPython, this function implements a recursive depth-first scanning + of the class hierarchy. + """ + if hasattr(cls, 'mro'): + return cls.__mro__ + + def _mro(cls): + m = [cls] + for base in cls.__bases__: + m += _mro(base) + return m + + mro_list = _mro(cls) + + # If a class appears multiple times (due to multiple inheritance) remove + # all but the last occurence. This matches the method resolution order + # of MicroPython, but not CPython. + mro_pruned = [] + for i in range(len(mro_list)): + base = mro_list.pop(0) + if base not in mro_list: + mro_pruned.append(base) + return mro_pruned + + +class MultiDict(dict): + """A subclass of dictionary that can hold multiple values for the same + key. It is used to hold key/value pairs decoded from query strings and + form submissions. + + :param initial_dict: an initial dictionary of key/value pairs to + initialize this object with. + + Example:: + + >>> d = MultiDict() + >>> d['sort'] = 'name' + >>> d['sort'] = 'email' + >>> print(d['sort']) + 'name' + >>> print(d.getlist('sort')) + ['name', 'email'] + """ + def __init__(self, initial_dict=None): + super().__init__() + if initial_dict: + for key, value in initial_dict.items(): + self[key] = value + + def __setitem__(self, key, value): + if key not in self: + super().__setitem__(key, []) + super().__getitem__(key).append(value) + + def __getitem__(self, key): + return super().__getitem__(key)[0] + + def get(self, key, default=None, type=None): + """Return the value for a given key. + + :param key: The key to retrieve. + :param default: A default value to use if the key does not exist. + :param type: A type conversion callable to apply to the value. + + If the multidict contains more than one value for the requested key, + this method returns the first value only. + + Example:: + + >>> d = MultiDict() + >>> d['age'] = '42' + >>> d.get('age') + '42' + >>> d.get('age', type=int) + 42 + >>> d.get('name', default='noname') + 'noname' + """ + if key not in self: + return default + value = self[key] + if type is not None: + value = type(value) + return value + + def getlist(self, key, type=None): + """Return all the values for a given key. + + :param key: The key to retrieve. + :param type: A type conversion callable to apply to the values. + + If the requested key does not exist in the dictionary, this method + returns an empty list. + + Example:: + + >>> d = MultiDict() + >>> d.getlist('items') + [] + >>> d['items'] = '3' + >>> d.getlist('items') + ['3'] + >>> d['items'] = '56' + >>> d.getlist('items') + ['3', '56'] + >>> d.getlist('items', type=int) + [3, 56] + """ + if key not in self: + return [] + values = super().__getitem__(key) + if type is not None: + values = [type(value) for value in values] + return values + + +class AsyncBytesIO: + """An async wrapper for BytesIO.""" + def __init__(self, data): + self.stream = io.BytesIO(data) + + async def read(self, n=-1): + return self.stream.read(n) + + async def readline(self): # pragma: no cover + return self.stream.readline() + + async def readexactly(self, n): # pragma: no cover + return self.stream.read(n) + + async def readuntil(self, separator=b'\n'): # pragma: no cover + return self.stream.readuntil(separator=separator) + + async def awrite(self, data): # pragma: no cover + return self.stream.write(data) + + async def aclose(self): # pragma: no cover + pass + + +class Request: + """An HTTP request.""" + #: Specify the maximum payload size that is accepted. Requests with larger + #: payloads will be rejected with a 413 status code. Applications can + #: change this maximum as necessary. + #: + #: Example:: + #: + #: Request.max_content_length = 1 * 1024 * 1024 # 1MB requests allowed + max_content_length = 16 * 1024 + + #: Specify the maximum payload size that can be stored in ``body``. + #: Requests with payloads that are larger than this size and up to + #: ``max_content_length`` bytes will be accepted, but the application will + #: only be able to access the body of the request by reading from + #: ``stream``. Set to 0 if you always access the body as a stream. + #: + #: Example:: + #: + #: Request.max_body_length = 4 * 1024 # up to 4KB bodies read + max_body_length = 16 * 1024 + + #: Specify the maximum length allowed for a line in the request. Requests + #: with longer lines will not be correctly interpreted. Applications can + #: change this maximum as necessary. + #: + #: Example:: + #: + #: Request.max_readline = 16 * 1024 # 16KB lines allowed + max_readline = 2 * 1024 + + class G: + pass + + def __init__(self, app, client_addr, method, url, http_version, headers, + body=None, stream=None, sock=None, url_prefix='', + subapp=None, scheme=None, route=None): + #: The application instance to which this request belongs. + self.app = app + #: The address of the client, as a tuple (host, port). + self.client_addr = client_addr + #: The HTTP method of the request. + self.method = method + #: The scheme of the request, either `http` or `https`. + self.scheme = scheme or 'http' + #: The request URL, including the path and query string, but not the + #: scheme or the host, which is available in the ``Host`` header. + self.url = url + #: The URL prefix, if the endpoint comes from a mounted + #: sub-application, or else ''. + self.url_prefix = url_prefix + #: The sub-application instance, or `None` if this isn't a mounted + #: endpoint. + self.subapp = subapp + #: The route function that handles this request. + self.route = route + #: The path portion of the URL. + self.path = url + #: The query string portion of the URL. + self.query_string = None + #: The parsed query string, as a + #: :class:`MultiDict ` object. + self.args = {} + #: A dictionary with the headers included in the request. + self.headers = headers + #: A dictionary with the cookies included in the request. + self.cookies = {} + #: The parsed ``Content-Length`` header. + self.content_length = 0 + #: The parsed ``Content-Type`` header. + self.content_type = None + #: A general purpose container for applications to store data during + #: the life of the request. + self.g = Request.G() + + self.http_version = http_version + if '?' in self.path: + self.path, self.query_string = self.path.split('?', 1) + self.args = self._parse_urlencoded(self.query_string) + + if 'Content-Length' in self.headers: + self.content_length = int(self.headers['Content-Length']) + if 'Content-Type' in self.headers: + self.content_type = self.headers['Content-Type'] + if 'Cookie' in self.headers: + for cookie in self.headers['Cookie'].split(';'): + c = cookie.strip().split('=', 1) + self.cookies[c[0]] = c[1] if len(c) > 1 else '' + + self._body = body + self.body_used = False + self._stream = stream + self.sock = sock + self._json = None + self._form = None + self._files = None + self.after_request_handlers = [] + + @staticmethod + async def create(app, client_reader, client_writer, client_addr, + scheme=None): + """Create a request object. + + :param app: The Microdot application instance. + :param client_reader: An input stream from where the request data can + be read. + :param client_writer: An output stream where the response data can be + written. + :param client_addr: The address of the client, as a tuple. + :param scheme: The scheme of the request, either 'http' or 'https'. + + This method is a coroutine. It returns a newly created ``Request`` + object. + """ + # request line + line = (await Request._safe_readline(client_reader)).strip().decode() + if not line: # pragma: no cover + return None + method, url, http_version = line.split() + http_version = http_version.split('/', 1)[1] + + # headers + headers = NoCaseDict() + content_length = 0 + while True: + line = (await Request._safe_readline( + client_reader)).strip().decode() + if line == '': + break + header, value = line.split(':', 1) + value = value.strip() + headers[header] = value + if header.lower() == 'content-length': + content_length = int(value) + + # body + body = b'' + if content_length and content_length <= Request.max_body_length: + body = await client_reader.readexactly(content_length) + stream = None + else: + body = b'' + stream = client_reader + + return Request(app, client_addr, method, url, http_version, headers, + body=body, stream=stream, + sock=(client_reader, client_writer), scheme=scheme) + + def _parse_urlencoded(self, urlencoded): + data = MultiDict() + if len(urlencoded) > 0: # pragma: no branch + if isinstance(urlencoded, str): + for kv in [pair.split('=', 1) + for pair in urlencoded.split('&') if pair]: + data[urldecode(kv[0])] = urldecode(kv[1]) \ + if len(kv) > 1 else '' + elif isinstance(urlencoded, bytes): # pragma: no branch + for kv in [pair.split(b'=', 1) + for pair in urlencoded.split(b'&') if pair]: + data[urldecode(kv[0])] = urldecode(kv[1]) \ + if len(kv) > 1 else b'' + return data + + @property + def body(self): + """The body of the request, as bytes.""" + return self._body + + @property + def stream(self): + """The body of the request, as a bytes stream.""" + if self._stream is None: + self._stream = AsyncBytesIO(self._body) + return self._stream + + @property + def json(self): + """The parsed JSON body, or ``None`` if the request does not have a + JSON body.""" + if self._json is None: + if self.content_type is None: + return None + mime_type = self.content_type.split(';')[0] + if mime_type != 'application/json': + return None + self._json = json.loads(self.body.decode()) + return self._json + + @property + def form(self): + """The parsed form submission body, as a + :class:`MultiDict ` object, or ``None`` if the + request does not have a form submission. + + Forms that are URL encoded are processed by default. For multipart + forms to be processed, the + :func:`with_form_data ` + decorator must be added to the route. + """ + if self._form is None: + if self.content_type is None: + return None + mime_type = self.content_type.split(';')[0] + if mime_type != 'application/x-www-form-urlencoded': + return None + self._form = self._parse_urlencoded(self.body) + return self._form + + @property + def files(self): + """The files uploaded in the request as a dictionary, or ``None`` if + the request does not have any files. + + The :func:`with_form_data ` + decorator must be added to the route that receives file uploads for + this property to be set. + """ + return self._files + + def after_request(self, f): + """Register a request-specific function to run after the request is + handled. Request-specific after request handlers run at the very end, + after the application's own after request handlers. The function must + take two arguments, the request and response objects. The return value + of the function must be the updated response object. + + Example:: + + @app.route('/') + def index(request): + # register a request-specific after request handler + @req.after_request + def func(request, response): + # ... + return response + + return 'Hello, World!' + + Note that the function is not called if the request handler raises an + exception and an error response is returned instead. + """ + self.after_request_handlers.append(f) + return f + + @staticmethod + async def _safe_readline(stream): + line = (await stream.readline()) + if len(line) > Request.max_readline: + raise ValueError('line too long') + return line + + +class Response: + """An HTTP response class. + + :param body: The body of the response. If a dictionary or list is given, + a JSON formatter is used to generate the body. If a file-like + object or an async generator is given, a streaming response is + used. If a string is given, it is encoded from UTF-8. Else, + the body should be a byte sequence. + :param status_code: The numeric HTTP status code of the response. The + default is 200. + :param headers: A dictionary of headers to include in the response. + :param reason: A custom reason phrase to add after the status code. The + default is "OK" for responses with a 200 status code and + "N/A" for any other status codes. + """ + types_map = { + 'css': 'text/css', + 'gif': 'image/gif', + 'html': 'text/html', + 'jpg': 'image/jpeg', + 'js': 'application/javascript', + 'json': 'application/json', + 'png': 'image/png', + 'txt': 'text/plain', + 'svg': 'image/svg+xml', + } + + send_file_buffer_size = 1024 + + #: The content type to use for responses that do not explicitly define a + #: ``Content-Type`` header. + default_content_type = 'text/plain' + + #: The default cache control max age used by :meth:`send_file`. A value + #: of ``None`` means that no ``Cache-Control`` header is added. + default_send_file_max_age = None + + #: Special response used to signal that a response does not need to be + #: written to the client. Used to exit WebSocket connections cleanly. + already_handled = None + + def __init__(self, body='', status_code=200, headers=None, reason=None): + if body is None and status_code == 200: + body = '' + status_code = 204 + self.status_code = status_code + self.headers = NoCaseDict(headers or {}) + self.reason = reason + if isinstance(body, (dict, list)): + body = json.dumps(body) + self.headers['Content-Type'] = 'application/json; charset=UTF-8' + if isinstance(body, str): + self.body = body.encode() + else: + # this applies to bytes, file-like objects or generators + self.body = body + self.is_head = False + + def set_cookie(self, cookie, value, path=None, domain=None, expires=None, + max_age=None, secure=False, http_only=False, + partitioned=False): + """Add a cookie to the response. + + :param cookie: The cookie's name. + :param value: The cookie's value. + :param path: The cookie's path. + :param domain: The cookie's domain. + :param expires: The cookie expiration time, as a ``datetime`` object + or a correctly formatted string. + :param max_age: The cookie's ``Max-Age`` value. + :param secure: The cookie's ``secure`` flag. + :param http_only: The cookie's ``HttpOnly`` flag. + :param partitioned: Whether the cookie is partitioned. + """ + http_cookie = '{cookie}={value}'.format(cookie=cookie, value=value) + if path: + http_cookie += '; Path=' + path + if domain: + http_cookie += '; Domain=' + domain + if expires: + if isinstance(expires, str): + http_cookie += '; Expires=' + expires + else: # pragma: no cover + http_cookie += '; Expires=' + time.strftime( + '%a, %d %b %Y %H:%M:%S GMT', expires.timetuple()) + if max_age is not None: + http_cookie += '; Max-Age=' + str(max_age) + if secure: + http_cookie += '; Secure' + if http_only: + http_cookie += '; HttpOnly' + if partitioned: + http_cookie += '; Partitioned' + if 'Set-Cookie' in self.headers: + self.headers['Set-Cookie'].append(http_cookie) + else: + self.headers['Set-Cookie'] = [http_cookie] + + def delete_cookie(self, cookie, **kwargs): + """Delete a cookie. + + :param cookie: The cookie's name. + :param kwargs: Any cookie options and flags supported by + :meth:`set_cookie() `. + Values given for ``expires`` and ``max_age`` are + ignored. + """ + kwargs.pop('expires', None) + kwargs.pop('max_age', None) + self.set_cookie(cookie, '', expires='Thu, 01 Jan 1970 00:00:01 GMT', + max_age=0, **kwargs) + + def complete(self): + if isinstance(self.body, bytes) and \ + 'Content-Length' not in self.headers: + self.headers['Content-Length'] = str(len(self.body)) + if 'Content-Type' not in self.headers: + self.headers['Content-Type'] = self.default_content_type + if 'charset=' not in self.headers['Content-Type']: + self.headers['Content-Type'] += '; charset=UTF-8' + + async def write(self, stream): + self.complete() + + try: + # status code + reason = self.reason if self.reason is not None else \ + ('OK' if self.status_code == 200 else 'N/A') + await stream.awrite('HTTP/1.0 {status_code} {reason}\r\n'.format( + status_code=self.status_code, reason=reason).encode()) + + # headers + for header, value in self.headers.items(): + values = value if isinstance(value, list) else [value] + for value in values: + await stream.awrite('{header}: {value}\r\n'.format( + header=header, value=value).encode()) + await stream.awrite(b'\r\n') + + # body + if not self.is_head: + iter = self.body_iter() + async for body in iter: + if isinstance(body, str): # pragma: no cover + body = body.encode() + try: + await stream.awrite(body) + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS or \ + exc.args[0] == 'Connection lost': + if hasattr(iter, 'aclose'): + await iter.aclose() + raise + if hasattr(iter, 'aclose'): # pragma: no branch + await iter.aclose() + + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS or \ + exc.args[0] == 'Connection lost': + pass + else: + raise + + def body_iter(self): + if hasattr(self.body, '__anext__'): + # response body is an async generator + return self.body + + response = self + + class iter: + ITER_UNKNOWN = 0 + ITER_SYNC_GEN = 1 + ITER_FILE_OBJ = 2 + ITER_NO_BODY = -1 + + def __aiter__(self): + if response.body: + self.i = self.ITER_UNKNOWN # need to determine type + else: + self.i = self.ITER_NO_BODY + return self + + async def __anext__(self): + if self.i == self.ITER_NO_BODY: + await self.aclose() + raise StopAsyncIteration + if self.i == self.ITER_UNKNOWN: + if hasattr(response.body, 'read'): + self.i = self.ITER_FILE_OBJ + elif hasattr(response.body, '__next__'): + self.i = self.ITER_SYNC_GEN + return next(response.body) + else: + self.i = self.ITER_NO_BODY + return response.body + elif self.i == self.ITER_SYNC_GEN: + try: + return next(response.body) + except StopIteration: + await self.aclose() + raise StopAsyncIteration + buf = response.body.read(response.send_file_buffer_size) + if iscoroutine(buf): # pragma: no cover + buf = await buf + if len(buf) < response.send_file_buffer_size: + self.i = self.ITER_NO_BODY + return buf + + async def aclose(self): + if hasattr(response.body, 'close'): + result = response.body.close() + if iscoroutine(result): # pragma: no cover + await result + + return iter() + + @classmethod + def redirect(cls, location, status_code=302): + """Return a redirect response. + + :param location: The URL to redirect to. + :param status_code: The 3xx status code to use for the redirect. The + default is 302. + """ + if '\x0d' in location or '\x0a' in location: + raise ValueError('invalid redirect URL') + return cls(status_code=status_code, headers={'Location': location}) + + @classmethod + def send_file(cls, filename, status_code=200, content_type=None, + stream=None, max_age=None, compressed=False, + file_extension=''): + """Send file contents in a response. + + :param filename: The filename of the file. + :param status_code: The 3xx status code to use for the redirect. The + default is 200. + :param content_type: The ``Content-Type`` header to use in the + response. If omitted, it is generated + automatically from the file extension of the + ``filename`` parameter. + :param stream: A file-like object to read the file contents from. If + a stream is given, the ``filename`` parameter is only + used when generating the ``Content-Type`` header. + :param max_age: The ``Cache-Control`` header's ``max-age`` value in + seconds. If omitted, the value of the + :attr:`Response.default_send_file_max_age` attribute is + used. + :param compressed: Whether the file is compressed. If ``True``, the + ``Content-Encoding`` header is set to ``gzip``. A + string with the header value can also be passed. + Note that when using this option the file must have + been compressed beforehand. This option only sets + the header. + :param file_extension: A file extension to append to the ``filename`` + parameter when opening the file, including the + dot. The extension given here is not considered + when generating the ``Content-Type`` header. + + Security note: The filename is assumed to be trusted. Never pass + filenames provided by the user without validating and sanitizing them + first. + """ + if content_type is None: + if compressed and filename.endswith('.gz'): + ext = filename[:-3].split('.')[-1] + else: + ext = filename.split('.')[-1] + if ext in Response.types_map: + content_type = Response.types_map[ext] + else: + content_type = 'application/octet-stream' + headers = {'Content-Type': content_type} + + if max_age is None: + max_age = cls.default_send_file_max_age + if max_age is not None: + headers['Cache-Control'] = 'max-age={}'.format(max_age) + + if compressed: + headers['Content-Encoding'] = compressed \ + if isinstance(compressed, str) else 'gzip' + + f = stream or open(filename + file_extension, 'rb') + return cls(body=f, status_code=status_code, headers=headers) + + +class URLPattern(): + """A class that represents the URL pattern for a route. + + :param url_pattern: The route URL pattern, which can include static and + dynamic path segments. Dynamic segments are enclosed in + ``<`` and ``>``. The type of the segment can be given + as a prefix, separated from the name with a colon. + Supported types are ``string`` (the default), + ``int`` and ``path``. Custom types can be registered + using the :meth:`URLPattern.register_type` method. + """ + + segment_patterns = { + 'string': '/([^/]+)', + 'int': '/(-?\\d+)', + 'path': '/(.+)', + } + segment_parsers = { + 'int': lambda value: int(value), + } + + @classmethod + def register_type(cls, type_name, pattern='[^/]+', parser=None): + """Register a new URL segment type. + + :param type_name: The name of the segment type to register. + :param pattern: The regular expression pattern to use when matching + this segment type. If not given, a default matcher for + a single path segment is used. + :param parser: A callable that will be used to parse and transform the + value of the segment. If omitted, the value is returned + as a string. + """ + cls.segment_patterns[type_name] = '/({})'.format(pattern) + cls.segment_parsers[type_name] = parser + + def __init__(self, url_pattern): + self.url_pattern = url_pattern + self.segments = [] + self.regex = None + + def compile(self): + """Generate a regular expression for the URL pattern. + + This method is automatically invoked the first time the URL pattern is + matched against a path. + """ + pattern = '' + for segment in self.url_pattern.lstrip('/').split('/'): + if segment and segment[0] == '<': + if segment[-1] != '>': + raise ValueError('invalid URL pattern') + segment = segment[1:-1] + if ':' in segment: + type_, name = segment.rsplit(':', 1) + else: + type_ = 'string' + name = segment + parser = None + if type_.startswith('re:'): + pattern += '/({pattern})'.format(pattern=type_[3:]) + else: + if type_ not in self.segment_patterns: + raise ValueError('invalid URL segment type') + pattern += self.segment_patterns[type_] + parser = self.segment_parsers.get(type_) + self.segments.append({'parser': parser, 'name': name, + 'type': type_}) + else: + pattern += '/' + segment + self.segments.append({'parser': None}) + self.regex = re.compile('^' + pattern + '$') + return self.regex + + def match(self, path): + """Match a path against the URL pattern. + + Returns a dictionary with the values of all dynamic path segments if a + matche is found, or ``None`` if the path does not match this pattern. + """ + args = {} + g = (self.regex or self.compile()).match(path) + if not g: + return + i = 1 + for segment in self.segments: + if 'name' not in segment: + continue + arg = g.group(i) + if segment['parser']: + arg = self.segment_parsers[segment['type']](arg) + if arg is None: + return + args[segment['name']] = arg + i += 1 + return args + + def __repr__(self): # pragma: no cover + return 'URLPattern: {}'.format(self.url_pattern) + + +class HTTPException(Exception): + def __init__(self, status_code, reason=None): + self.status_code = status_code + self.reason = reason or str(status_code) + ' error' + + def __repr__(self): # pragma: no cover + return 'HTTPException: {}'.format(self.status_code) + + +class Microdot: + """An HTTP application class. + + This class implements an HTTP application instance and is heavily + influenced by the ``Flask`` class of the Flask framework. It is typically + declared near the start of the main application script. + + Example:: + + from microdot import Microdot + + app = Microdot() + """ + + def __init__(self): + self.url_map = [] + self.before_request_handlers = [] + self.after_request_handlers = [] + self.after_error_request_handlers = [] + self.error_handlers = {} + self.options_handler = self.default_options_handler + self.ssl = False + self.debug = False + self.server = None + + def route(self, url_pattern, methods=None): + """Decorator that is used to register a function as a request handler + for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + :param methods: The list of HTTP methods to be handled by the + decorated function. If omitted, only ``GET`` requests + are handled. + + The URL pattern can be a static path (for example, ``/users`` or + ``/api/invoices/search``) or a path with dynamic components enclosed + in ``<`` and ``>`` (for example, ``/users/`` or + ``/invoices//products``). Dynamic path components can also + include a type prefix, separated from the name with a colon (for + example, ``/users/``). The type can be ``string`` (the + default), ``int``, ``path`` or ``re:[regular-expression]``. + + The first argument of the decorated function must be + the request object. Any path arguments that are specified in the URL + pattern are passed as keyword arguments. The return value of the + function must be a :class:`Response` instance, or the arguments to + be passed to this class. + + Example:: + + @app.route('/') + def index(request): + return 'Hello, world!' + """ + def decorated(f): + self.url_map.append( + ([m.upper() for m in (methods or ['GET'])], + URLPattern(url_pattern), f, '', None)) + return f + return decorated + + def get(self, url_pattern): + """Decorator that is used to register a function as a ``GET`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['GET']``. + + Example:: + + @app.get('/users/') + def get_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['GET']) + + def post(self, url_pattern): + """Decorator that is used to register a function as a ``POST`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the``route`` decorator with + ``methods=['POST']``. + + Example:: + + @app.post('/users') + def create_user(request): + # ... + """ + return self.route(url_pattern, methods=['POST']) + + def put(self, url_pattern): + """Decorator that is used to register a function as a ``PUT`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['PUT']``. + + Example:: + + @app.put('/users/') + def edit_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['PUT']) + + def patch(self, url_pattern): + """Decorator that is used to register a function as a ``PATCH`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['PATCH']``. + + Example:: + + @app.patch('/users/') + def edit_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['PATCH']) + + def delete(self, url_pattern): + """Decorator that is used to register a function as a ``DELETE`` + request handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['DELETE']``. + + Example:: + + @app.delete('/users/') + def delete_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['DELETE']) + + def before_request(self, f): + """Decorator to register a function to run before each request is + handled. The decorated function must take a single argument, the + request object. + + Example:: + + @app.before_request + def func(request): + # ... + """ + self.before_request_handlers.append(f) + return f + + def after_request(self, f): + """Decorator to register a function to run after each request is + handled. The decorated function must take two arguments, the request + and response objects. The return value of the function must be an + updated response object. + + Example:: + + @app.after_request + def func(request, response): + # ... + return response + """ + self.after_request_handlers.append(f) + return f + + def after_error_request(self, f): + """Decorator to register a function to run after an error response is + generated. The decorated function must take two arguments, the request + and response objects. The return value of the function must be an + updated response object. The handler is invoked for error responses + generated by Microdot, as well as those returned by application-defined + error handlers. + + Example:: + + @app.after_error_request + def func(request, response): + # ... + return response + """ + self.after_error_request_handlers.append(f) + return f + + def errorhandler(self, status_code_or_exception_class): + """Decorator to register a function as an error handler. Error handler + functions for numeric HTTP status codes must accept a single argument, + the request object. Error handler functions for Python exceptions + must accept two arguments, the request object and the exception + object. + + :param status_code_or_exception_class: The numeric HTTP status code or + Python exception class to + handle. + + Examples:: + + @app.errorhandler(404) + def not_found(request): + return 'Not found' + + @app.errorhandler(RuntimeError) + def runtime_error(request, exception): + return 'Runtime error' + """ + def decorated(f): + self.error_handlers[status_code_or_exception_class] = f + return f + return decorated + + def mount(self, subapp, url_prefix='', local=False): + """Mount a sub-application, optionally under the given URL prefix. + + :param subapp: The sub-application to mount. + :param url_prefix: The URL prefix to mount the application under. + :param local: When set to ``True``, the before, after and error request + handlers only apply to endpoints defined in the + sub-application. When ``False``, they apply to the entire + application. The default is ``False``. + """ + for methods, pattern, handler, _prefix, _subapp in subapp.url_map: + self.url_map.append( + (methods, URLPattern(url_prefix + pattern.url_pattern), + handler, url_prefix + _prefix, _subapp or subapp)) + if not local: + for handler in subapp.before_request_handlers: + self.before_request_handlers.append(handler) + subapp.before_request_handlers = [] + for handler in subapp.after_request_handlers: + self.after_request_handlers.append(handler) + subapp.after_request_handlers = [] + for handler in subapp.after_error_request_handlers: + self.after_error_request_handlers.append(handler) + subapp.after_error_request_handlers = [] + for status_code, handler in subapp.error_handlers.items(): + self.error_handlers[status_code] = handler + subapp.error_handlers = {} + + @staticmethod + def abort(status_code, reason=None): + """Abort the current request and return an error response with the + given status code. + + :param status_code: The numeric status code of the response. + :param reason: The reason for the response, which is included in the + response body. + + Example:: + + from microdot import abort + + @app.route('/users/') + def get_user(id): + user = get_user_by_id(id) + if user is None: + abort(404) + return user.to_dict() + """ + raise HTTPException(status_code, reason) + + async def start_server(self, host='0.0.0.0', port=5000, debug=False, + ssl=None): + """Start the Microdot web server as a coroutine. This coroutine does + not normally return, as the server enters an endless listening loop. + The :func:`shutdown` function provides a method for terminating the + server gracefully. + + :param host: The hostname or IP address of the network interface that + will be listening for requests. A value of ``'0.0.0.0'`` + (the default) indicates that the server should listen for + requests on all the available interfaces, and a value of + ``127.0.0.1`` indicates that the server should listen + for requests only on the internal networking interface of + the host. + :param port: The port number to listen for requests. The default is + port 5000. + :param debug: If ``True``, the server logs debugging information. The + default is ``False``. + :param ssl: An ``SSLContext`` instance or ``None`` if the server should + not use TLS. The default is ``None``. + + This method is a coroutine. + + Example:: + + import asyncio + from microdot import Microdot + + app = Microdot() + + @app.route('/') + async def index(request): + return 'Hello, world!' + + async def main(): + await app.start_server(debug=True) + + asyncio.run(main()) + """ + self.ssl = ssl + self.debug = debug + + async def serve(reader, writer): + if not hasattr(writer, 'awrite'): # pragma: no cover + # CPython provides the awrite and aclose methods in 3.8+ + async def awrite(self, data): + self.write(data) + await self.drain() + + async def aclose(self): + self.close() + await self.wait_closed() + + from types import MethodType + writer.awrite = MethodType(awrite, writer) + writer.aclose = MethodType(aclose, writer) + + await self.handle_request(reader, writer) + + if self.debug: # pragma: no cover + print('Starting async server on {host}:{port}...'.format( + host=host, port=port)) + + try: + self.server = await asyncio.start_server(serve, host, port, + ssl=ssl) + except TypeError: # pragma: no cover + self.server = await asyncio.start_server(serve, host, port) + + while True: + try: + if hasattr(self.server, 'serve_forever'): # pragma: no cover + try: + await self.server.serve_forever() + except asyncio.CancelledError: + pass + await self.server.wait_closed() + break + except AttributeError: # pragma: no cover + # the task hasn't been initialized in the server object yet + # wait a bit and try again + await asyncio.sleep(0.1) + + def run(self, host='0.0.0.0', port=5000, debug=False, ssl=None): + """Start the web server. This function does not normally return, as + the server enters an endless listening loop. The :func:`shutdown` + function provides a method for terminating the server gracefully. + + :param host: The hostname or IP address of the network interface that + will be listening for requests. A value of ``'0.0.0.0'`` + (the default) indicates that the server should listen for + requests on all the available interfaces, and a value of + ``127.0.0.1`` indicates that the server should listen + for requests only on the internal networking interface of + the host. + :param port: The port number to listen for requests. The default is + port 5000. + :param debug: If ``True``, the server logs debugging information. The + default is ``False``. + :param ssl: An ``SSLContext`` instance or ``None`` if the server should + not use TLS. The default is ``None``. + + Example:: + + from microdot import Microdot + + app = Microdot() + + @app.route('/') + async def index(request): + return 'Hello, world!' + + app.run(debug=True) + """ + asyncio.run(self.start_server(host=host, port=port, debug=debug, + ssl=ssl)) # pragma: no cover + + def shutdown(self): + """Request a server shutdown. The server will then exit its request + listening loop and the :func:`run` function will return. This function + can be safely called from a route handler, as it only schedules the + server to terminate as soon as the request completes. + + Example:: + + @app.route('/shutdown') + def shutdown(request): + request.app.shutdown() + return 'The server is shutting down...' + """ + self.server.close() + + def find_route(self, req): + method = req.method.upper() + if method == 'OPTIONS' and self.options_handler: + return self.options_handler(req), '', None + if method == 'HEAD': + method = 'GET' + f = 404 + p = '' + s = None + for route_methods, route_pattern, route_handler, url_prefix, subapp \ + in self.url_map: + req.url_args = route_pattern.match(req.path) + if req.url_args is not None: + p = url_prefix + s = subapp + if method in route_methods: + f = route_handler + break + else: + f = 405 + return f, p, s + + def default_options_handler(self, req): + allow = [] + for route_methods, route_pattern, _, _, _ in self.url_map: + if route_pattern.match(req.path) is not None: + allow.extend(route_methods) + if 'GET' in allow: + allow.append('HEAD') + allow.append('OPTIONS') + return {'Allow': ', '.join(allow)} + + async def handle_request(self, reader, writer): + req = None + try: + req = await Request.create(self, reader, writer, + writer.get_extra_info('peername')) + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS: + pass + else: + raise + except Exception as exc: # pragma: no cover + print_exception(exc) + + res = await self.dispatch_request(req) + try: + if res != Response.already_handled: # pragma: no branch + await res.write(writer) + await writer.aclose() + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS: + pass + else: + raise + if self.debug and req: # pragma: no cover + print('{method} {path} {status_code}'.format( + method=req.method, path=req.path, + status_code=res.status_code)) + + def get_request_handlers(self, req, attr, local_first=True): + handlers = getattr(self, attr + '_handlers') + local_handlers = getattr(req.subapp, attr + '_handlers') \ + if req and req.subapp else [] + return local_handlers + handlers if local_first \ + else handlers + local_handlers + + async def error_response(self, req, status_code, reason=None): + if req and req.subapp and status_code in req.subapp.error_handlers: + return await invoke_handler( + req.subapp.error_handlers[status_code], req) + elif status_code in self.error_handlers: + return await invoke_handler(self.error_handlers[status_code], req) + return reason or 'N/A', status_code + + async def dispatch_request(self, req): + after_request_handled = False + if req: + if req.content_length > req.max_content_length: + # the request body is larger than allowed + res = await self.error_response(req, 413, 'Payload too large') + else: + # find the route in the app's URL map + f, req.url_prefix, req.subapp = self.find_route(req) + + try: + res = None + if callable(f): + req.route = f + + # invoke the before request handlers + for handler in self.get_request_handlers( + req, 'before_request', False): + res = await invoke_handler(handler, req) + if res: + break + + # invoke the endpoint handler + if res is None: + res = await invoke_handler(f, req, **req.url_args) + + # process the response + if isinstance(res, int): + # an integer response is taken as a status code + # with an empty body + res = '', res + if isinstance(res, tuple): + # handle a tuple response + if isinstance(res[0], int): + # a tuple that starts with an int has an empty + # body + res = ('', res[0], + res[1] if len(res) > 1 else {}) + body = res[0] + if isinstance(res[1], int): + # extract the status code and headers (if + # available) + status_code = res[1] + headers = res[2] if len(res) > 2 else {} + else: + # if the status code is missing, assume 200 + status_code = 200 + headers = res[1] + res = Response(body, status_code, headers) + elif not isinstance(res, Response): + # any other response types are wrapped in a + # Response object + res = Response(res) + + # invoke the after request handlers + for handler in self.get_request_handlers( + req, 'after_request', True): + res = await invoke_handler( + handler, req, res) or res + for handler in req.after_request_handlers: + res = await invoke_handler( + handler, req, res) or res + after_request_handled = True + elif isinstance(f, dict): + # the response from an OPTIONS request is a dict with + # headers + res = Response(headers=f) + else: + # if the route is not found, return a 404 or 405 + # response as appropriate + res = await self.error_response(req, f, 'Not found') + except HTTPException as exc: + # an HTTP exception was raised while handling this request + res = await self.error_response(req, exc.status_code, + exc.reason) + except Exception as exc: + # an unexpected exception was raised while handling this + # request + print_exception(exc) + + # invoke the error handler for the exception class if one + # exists + handler = None + res = None + if req.subapp and exc.__class__ in \ + req.subapp.error_handlers: + handler = req.subapp.error_handlers[exc.__class__] + elif exc.__class__ in self.error_handlers: + handler = self.error_handlers[exc.__class__] + else: + # walk up the exception class hierarchy to try to find + # a handler + for c in mro(exc.__class__)[1:]: + if req.subapp and c in req.subapp.error_handlers: + handler = req.subapp.error_handlers[c] + break + elif c in self.error_handlers: + handler = self.error_handlers[c] + break + if handler: + try: + res = await invoke_handler(handler, req, exc) + except Exception as exc2: # pragma: no cover + print_exception(exc2) + if res is None: + # if there is still no response, issue a 500 error + res = await self.error_response( + req, 500, 'Internal server error') + else: + # if the request could not be parsed, issue a 400 error + res = await self.error_response(req, 400, 'Bad request') + if isinstance(res, tuple): + res = Response(*res) + elif not isinstance(res, Response): + res = Response(res) + if not after_request_handled: + # if the request did not finish due to an error, invoke the after + # error request handler + for handler in self.get_request_handlers( + req, 'after_error_request', True): + res = await invoke_handler( + handler, req, res) or res + res.is_head = (req and req.method == 'HEAD') + return res + + +Response.already_handled = Response() + +abort = Microdot.abort +redirect = Response.redirect +send_file = Response.send_file diff --git a/rinkhals-webui/src/microdot/multipart.py b/rinkhals-webui/src/microdot/multipart.py new file mode 100644 index 0000000..9d7a720 --- /dev/null +++ b/rinkhals-webui/src/microdot/multipart.py @@ -0,0 +1,298 @@ +import os +from random import choice +from microdot import abort, iscoroutine, AsyncBytesIO +from microdot.helpers import wraps + + +class FormDataIter: + """Asynchronous iterator that parses a ``multipart/form-data`` body and + returns form fields and files as they are parsed. + + :param request: the request object to parse. + + Example usage:: + + from microdot.multipart import FormDataIter + + @app.post('/upload') + async def upload(request): + async for name, value in FormDataIter(request): + print(name, value) + + The iterator returns no values when the request has a content type other + than ``multipart/form-data``. For a file field, the returned value is of + type :class:`FileUpload`, which supports the + :meth:`read() ` and :meth:`save() ` + methods. Values for regular fields are provided as strings. + + The request body is read efficiently in chunks of size + :attr:`buffer_size `. On iterations in which a + file field is encountered, the file must be consumed before moving on to + the next iteration, as the internal stream stored in ``FileUpload`` + instances is invalidated at the end of the iteration. + """ + #: The size of the buffer used to read chunks of the request body. This + #: size must be large enough to hold at least one complete header or + #: boundary line, so it is not recommended to lower it, but it can be made + #: higher to improve performance at the expense of RAM. + buffer_size = 256 + + def __init__(self, request): + self.request = request + self.buffer = None + try: + mimetype, boundary = request.content_type.rsplit('; boundary=', 1) + except ValueError: + return # not a multipart request + if mimetype.split(';', 1)[0] == \ + 'multipart/form-data': # pragma: no branch + self.boundary = b'--' + boundary.encode() + self.extra_size = len(boundary) + 4 + self.buffer = b'' + + def __aiter__(self): + return self + + async def __anext__(self): + if self.buffer is None: + raise StopAsyncIteration + + # make sure we have consumed the previous entry + while await self._read_buffer(self.buffer_size) != b'': + pass + + # make sure we are at a boundary + await self._fill_buffer() + s = self.buffer.split(self.boundary, 1) + if len(s) != 2 or s[0] != b'': + abort(400) # pragma: no cover + self.buffer = s[1] + if self.buffer[:2] == b'--': + # we have reached the end + raise StopAsyncIteration + elif self.buffer[:2] != b'\r\n': + abort(400) # pragma: no cover + self.buffer = self.buffer[2:] + + # parse the headers of this part + name = '' + filename = None + content_type = None + while True: + await self._fill_buffer() + lines = self.buffer.split(b'\r\n', 1) + if len(lines) != 2: + abort(400) # pragma: no cover + line, self.buffer = lines + if line == b'': + # we reached the end of the headers + break + header, value = line.decode().split(':', 1) + header = header.lower() + value = value.strip() + if header == 'content-disposition': + parts = value.split(';') + if len(parts) < 2 or parts[0] != 'form-data': + abort(400) # pragma: no cover + for part in parts[1:]: + part = part.strip() + if part.startswith('name="'): + name = part[6:-1] + elif part.startswith('filename="'): # pragma: no branch + filename = part[10:-1] + elif header == 'content-type': # pragma: no branch + content_type = value + + if filename is None: + # this is a regular form field, so we read the value + value = b'' + while True: + v = await self._read_buffer(self.buffer_size) + value += v + if len(v) < self.buffer_size: # pragma: no branch + break + return name, value.decode() + return name, FileUpload(filename, content_type, self._read_buffer) + + async def _fill_buffer(self): + if self.buffer[-len(self.boundary) - 4:] == self.boundary + b'--\r\n': + # we have reached the end of the body + return + self.buffer += await self.request.stream.read( + self.buffer_size + self.extra_size - len(self.buffer)) + + async def _read_buffer(self, n=-1): + data = b'' + while n == -1 or len(data) < n: + await self._fill_buffer() + s = self.buffer.split(self.boundary, 1) + data += s[0][:n] if n != -1 else s[0] + self.buffer = s[0][n:] if n != -1 else b'' + if len(s) == 2: # pragma: no branch + # the end of this part is in the buffer + if len(self.buffer) < 2: + # we have read all the way to the end of this part + data = data[:-(2 - len(self.buffer))] # remove last "\r\n" + self.buffer += self.boundary + s[1] + return data + return data + + +class FileUpload: + """Class that represents an uploaded file. + + :param filename: the name of the uploaded file. + :param content_type: the content type of the uploaded file. + :param read: a coroutine that reads from the uploaded file's stream. + + An uploaded file can be read from the stream using the :meth:`read()` + method or saved to a file using the :meth:`save()` method. + + Instances of this class do not normally need to be created directly. + """ + #: The size at which the file is copied to a temporary file. + max_memory_size = 1024 + + def __init__(self, filename, content_type, read): + self.filename = filename + self.content_type = content_type + self._read = read + self._close = None + + async def read(self, n=-1): + """Read up to ``n`` bytes from the uploaded file's stream. + + :param n: the maximum number of bytes to read. If ``n`` is -1 or not + given, the entire file is read. + """ + return await self._read(n) + + async def save(self, path_or_file): + """Save the uploaded file to the given path or file object. + + :param path_or_file: the path to save the file to, or a file object + to which the file is to be written. + + The file is read and written in chunks of size + :attr:`FormDataIter.buffer_size`. + """ + if isinstance(path_or_file, str): + f = open(path_or_file, 'wb') + else: + f = path_or_file + while True: + data = await self.read(FormDataIter.buffer_size) + if not data: + break + f.write(data) + if f != path_or_file: + f.close() + + async def copy(self, max_memory_size=None): + """Copy the uploaded file to a temporary file, to allow the parsing of + the multipart form to continue. + + :param max_memory_size: the maximum size of the file to keep in memory. + If not given, then the class attribute of the + same name is used. + """ + max_memory_size = max_memory_size or FileUpload.max_memory_size + buffer = await self.read(max_memory_size) + if len(buffer) < max_memory_size: + f = AsyncBytesIO(buffer) + self._read = f.read + return self + + # create a temporary file + while True: + tmpname = "".join([ + choice('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ') + for _ in range(12) + ]) + try: + f = open(tmpname, 'x+b') + except OSError as e: # pragma: no cover + if e.errno == 17: + # EEXIST + continue + elif e.errno == 2: + # ENOENT + # some MicroPython platforms do not support mode "x" + f = open(tmpname, 'w+b') + if f.read(1) != b'': + f.close() + continue + else: + raise + break + f.write(buffer) + await self.save(f) + f.seek(0) + + async def read(n=-1): + return f.read(n) + + async def close(): + f.close() + os.remove(tmpname) + + self._read = read + self._close = close + return self + + async def close(self): + """Close an open file. + + This method must be called to free memory or temporary files created by + the ``copy()`` method. + + Note that when using the ``@with_form_data`` decorator this method is + called automatically when the request ends. + """ + if self._close: + await self._close() + self._close = None + + +def with_form_data(f): + """Decorator that parses a ``multipart/form-data`` body and updates the + request object with the parsed form fields and files. + + Example usage:: + + from microdot.multipart import with_form_data + + @app.post('/upload') + @with_form_data + async def upload(request): + print('form fields:', request.form) + print('files:', request.files) + + Note: this decorator calls the :meth:`FileUpload.copy() + ` method on all uploaded files, so that + the request can be parsed in its entirety. The files are either copied to + memory or a temporary file, depending on their size. The temporary files + are automatically deleted when the request ends. + """ + @wraps(f) + async def wrapper(request, *args, **kwargs): + form = {} + files = {} + async for name, value in FormDataIter(request): + if isinstance(value, FileUpload): + files[name] = await value.copy() + else: + form[name] = value + if form or files: + request._form = form + request._files = files + try: + ret = f(request, *args, **kwargs) + if iscoroutine(ret): + ret = await ret + finally: + if request.files: + for file in request.files.values(): + await file.close() + return ret + return wrapper diff --git a/rinkhals-webui/src/microdot/pyrightconfig.json b/rinkhals-webui/src/microdot/pyrightconfig.json new file mode 100644 index 0000000..1a1a513 --- /dev/null +++ b/rinkhals-webui/src/microdot/pyrightconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../pyrightconfig.json", + "typeCheckingMode": "off" +} diff --git a/rinkhals-webui/src/microdot/session.py b/rinkhals-webui/src/microdot/session.py new file mode 100644 index 0000000..041b290 --- /dev/null +++ b/rinkhals-webui/src/microdot/session.py @@ -0,0 +1,159 @@ +import jwt +from microdot.microdot import invoke_handler +from microdot.helpers import wraps + + +class SessionDict(dict): + """A session dictionary. + + The session dictionary is a standard Python dictionary that has been + extended with convenience ``save()`` and ``delete()`` methods. + """ + def __init__(self, request, session_dict): + super().__init__(session_dict) + self.request = request + + def save(self): + """Update the session cookie.""" + self.request.app._session.update(self.request, self) + + def delete(self): + """Delete the session cookie.""" + self.request.app._session.delete(self.request) + + +class Session: + """Session handling + + :param app: The application instance. + :param secret_key: The secret key, as a string or bytes object. + :param cookie_options: A dictionary with cookie options to pass as + arguments to :meth:`Response.set_cookie() + `. + """ + secret_key = None + + def __init__(self, app=None, secret_key=None, cookie_options=None): + self.secret_key = secret_key + self.cookie_options = cookie_options or {} + if app is not None: + self.initialize(app) + + def initialize(self, app, secret_key=None, cookie_options=None): + if secret_key is not None: + self.secret_key = secret_key + if cookie_options is not None: + self.cookie_options = cookie_options + if 'path' not in self.cookie_options: + self.cookie_options['path'] = '/' + if 'http_only' not in self.cookie_options: + self.cookie_options['http_only'] = True + app._session = self + + def get(self, request): + """Retrieve the user session. + + :param request: The client request. + + The return value is a session dictionary with the data stored in the + user's session, or ``{}`` if the session data is not available or + invalid. + """ + if not self.secret_key: + raise ValueError('The session secret key is not configured') + if hasattr(request.g, '_session'): + return request.g._session + session = request.cookies.get('session') + if session is None: + request.g._session = SessionDict(request, {}) + return request.g._session + request.g._session = SessionDict(request, self.decode(session)) + return request.g._session + + def update(self, request, session): + """Update the user session. + + :param request: The client request. + :param session: A dictionary with the update session data for the user. + + Applications would normally not call this method directly, instead they + would use the :meth:`SessionDict.save` method on the session + dictionary, which calls this method. For example:: + + @app.route('/') + @with_session + def index(request, session): + session['foo'] = 'bar' + session.save() + return 'Hello, World!' + + Calling this method adds a cookie with the updated session to the + request currently being processed. + """ + if not self.secret_key: + raise ValueError('The session secret key is not configured') + + encoded_session = self.encode(session) + + @request.after_request + def _update_session(request, response): + response.set_cookie('session', encoded_session, + **self.cookie_options) + return response + + def delete(self, request): + """Remove the user session. + + :param request: The client request. + + Applications would normally not call this method directly, instead they + would use the :meth:`SessionDict.delete` method on the session + dictionary, which calls this method. For example:: + + @app.route('/') + @with_session + def index(request, session): + session.delete() + return 'Hello, World!' + + Calling this method adds a cookie removal header to the request + currently being processed. + """ + @request.after_request + def _delete_session(request, response): + response.delete_cookie('session', **self.cookie_options) + return response + + def encode(self, payload, secret_key=None): + return jwt.encode(payload, secret_key or self.secret_key, + algorithm='HS256') + + def decode(self, session, secret_key=None): + try: + payload = jwt.decode(session, secret_key or self.secret_key, + algorithms=['HS256']) + except jwt.exceptions.PyJWTError: # pragma: no cover + return {} + return payload + + +def with_session(f): + """Decorator that passes the user session to the route handler. + + The session dictionary is passed to the decorated function as an argument + after the request object. Example:: + + @app.route('/') + @with_session + def index(request, session): + return 'Hello, World!' + + Note that the decorator does not save the session. To update the session, + call the :func:`session.save() ` method. + """ + @wraps(f) + async def wrapper(request, *args, **kwargs): + return await invoke_handler( + f, request, request.app._session.get(request), *args, **kwargs) + + return wrapper diff --git a/rinkhals-webui/src/microdot/sse.py b/rinkhals-webui/src/microdot/sse.py new file mode 100644 index 0000000..a27b1ca --- /dev/null +++ b/rinkhals-webui/src/microdot/sse.py @@ -0,0 +1,139 @@ +import asyncio +from microdot.helpers import wraps + +try: + import orjson as json +except ImportError: + import json + + +class SSE: + """Server-Sent Events object. + + An object of this class is sent to handler functions to manage the SSE + connection. + """ + def __init__(self): + self.event = asyncio.Event() + self.queue = [] + + async def send(self, data, event=None, event_id=None, retry=None, + comment=False): + """Send an event to the client. + + :param data: the data to send. It can be given as a string, bytes, dict + or list. Dictionaries and lists are serialized to JSON. + Any other types are converted to string before sending. + :param event: an optional event name, to send along with the data. If + given, it must be a string. + :param event_id: an optional event id, to send along with the data. If + given, it must be a string. + :param retry: an optional reconnection time (in seconds) that the + client should use when the connection is lost. + :param comment: when set to ``True``, the data is sent as a comment + line, and all other parameters are ignored. This is + useful as a heartbeat mechanism that keeps the + connection alive. + """ + if isinstance(data, (dict, list)): + data = json.dumps(data) + if isinstance(data, str): + data = data.encode() + elif not isinstance(data, bytes): + data = str(data).encode() + if comment: + data = b': ' + data + b'\n\n' + else: + data = b'data: ' + data + b'\n\n' + if event_id: + data = b'id: ' + event_id.encode() + b'\n' + data + if event: + data = b'event: ' + event.encode() + b'\n' + data + if retry: + data = b'retry: ' + str(int(retry * 1000)).encode() + b'\n' + \ + data + self.queue.append(data) + self.event.set() + + +def sse_response(request, event_function, *args, **kwargs): + """Return a response object that initiates an event stream. + + :param request: the request object. + :param event_function: an asynchronous function that will send events to + the client. The function is invoked with ``request`` + and an ``sse`` object. The function should use + ``sse.send()`` to send events to the client. + :param args: additional positional arguments to be passed to the response. + :param kwargs: additional keyword arguments to be passed to the response. + + This is a low-level function that can be used to implement a custom SSE + endpoint. In general the :func:`microdot.sse.with_sse` decorator should be + used instead. + """ + sse = SSE() + + async def sse_task_wrapper(): + try: + await event_function(request, sse, *args, **kwargs) + except asyncio.CancelledError: # pragma: no cover + pass + except Exception as exc: + # the SSE task raised an exception so we need to pass it to the + # main route so that it is re-raised there + sse.queue.append(exc) + sse.event.set() + + task = asyncio.create_task(sse_task_wrapper()) + + class sse_loop: + def __aiter__(self): + return self + + async def __anext__(self): + event = None + while sse.queue or not task.done(): + try: + event = sse.queue.pop(0) + break + except IndexError: + await sse.event.wait() + sse.event.clear() + if isinstance(event, Exception): + # if the event is an exception we re-raise it here so that it + # can be handled appropriately + raise event + elif event is None: + raise StopAsyncIteration + return event + + async def aclose(self): + task.cancel() + + return sse_loop(), 200, {'Content-Type': 'text/event-stream'} + + +def with_sse(f): + """Decorator to make a route a Server-Sent Events endpoint. + + This decorator is used to define a route that accepts SSE connections. The + route then receives a sse object as a second argument that it can use to + send events to the client:: + + @app.route('/events') + @with_sse + async def events(request, sse): + # send an unnamed event with string data + await sse.send('hello') + + # send an unnamed event with JSON data + await sse.send({'foo': 'bar'}) + + # send a named event + await sse.send('hello', event='greeting') + """ + @wraps(f) + async def sse_handler(request, *args, **kwargs): + return sse_response(request, f, *args, **kwargs) + + return sse_handler diff --git a/rinkhals-webui/src/microdot/test_client.py b/rinkhals-webui/src/microdot/test_client.py new file mode 100644 index 0000000..dcf5d65 --- /dev/null +++ b/rinkhals-webui/src/microdot/test_client.py @@ -0,0 +1,385 @@ +import asyncio +from microdot.microdot import Request, Response, AsyncBytesIO + +try: + from microdot.websocket import WebSocket +except: # pragma: no cover # noqa: E722 + WebSocket = None + +try: + import orjson as json +except ImportError: + import json + +__all__ = ['TestClient', 'TestResponse'] + + +class TestResponse: + """A response object issued by the Microdot test client.""" + def __init__(self): + #: The numeric status code returned by the server. + self.status_code = None + #: The text reason associated with the status response, such as + #: ``'OK'`` or ``'NOT FOUND'``. Set to ``None`` unless the application + #: explicitly sets it on the response object. + self.reason = None + #: A dictionary with the response headers. + self.headers = {} + #: The body of the response, as a bytes object. + self.body = None + #: The body of the response, decoded to a UTF-8 string. Set to + #: ``None`` if the response cannot be represented as UTF-8 text. + self.text = None + #: The body of the JSON response, decoded to a dictionary or list. Set + #: ``Note`` if the response does not have a JSON payload. + self.json = None + #: The body of the SSE response, decoded to a list of events, each + #: given as a dictionary with a ``data`` key and optionally also + #: ``event`` and ``id`` keys. Set to ``None`` if the response does not + #: have an SSE payload. + self.events = None + + def _initialize_response(self, res): + self.status_code = res.status_code + self.reason = res.reason + self.headers = res.headers + + async def _initialize_body(self, res): + self.body = b'' + iter = res.body_iter() + try: + async for body in iter: # pragma: no branch + if isinstance(body, str): + body = body.encode() + self.body += body + except asyncio.CancelledError: # pragma: no cover + pass + if hasattr(iter, 'aclose'): # pragma: no branch + await iter.aclose() + + def _process_text_body(self): + try: + self.text = self.body.decode() + except ValueError: + pass + + def _process_json_body(self): + if 'Content-Type' in self.headers: # pragma: no branch + content_type = self.headers['Content-Type'] + if content_type.split(';')[0] == 'application/json': + self.json = json.loads(self.text) + + def _process_sse_body(self): + if 'Content-Type' in self.headers: # pragma: no branch + content_type = self.headers['Content-Type'] + if content_type.split(';')[0] == 'text/event-stream': + self.events = [] + for sse_event in self.body.split(b'\n\n'): + data = None + event = None + event_id = None + retry = None + for line in sse_event.split(b'\n'): + if line.startswith(b'data:'): + data = line[5:].strip() + elif line.startswith(b'event:'): + event = line[6:].strip().decode() + elif line.startswith(b'id:'): + event_id = line[3:].strip().decode() + elif line.startswith(b'retry:'): + retry = int(line[7:].strip()) / 1000 + if data: + data_json = None + try: + data_json = json.loads(data) + except ValueError: + pass + self.events.append({ + 'data': data, 'data_json': data_json, + 'event': event, 'event_id': event_id, + 'retry': retry}) + + @classmethod + async def create(cls, res): + test_res = cls() + test_res._initialize_response(res) + if not res.is_head: + await test_res._initialize_body(res) + test_res._process_text_body() + test_res._process_json_body() + test_res._process_sse_body() + return test_res + + +class TestClient: + """A test client for Microdot. + + :param app: The Microdot application instance. + :param cookies: A dictionary of cookies to use when sending requests to the + application. + :param scheme: The scheme to use for requests, either 'http' or 'https'. + :param host: The host to use for requests. + + The following example shows how to create a test client for an application + and send a test request:: + + from microdot import Microdot + + app = Microdot() + + @app.get('/') + async def index(): + return 'Hello, World!' + + async def test_hello_world(self): + client = TestClient(app) + res = await client.get('/') + assert res.status_code == 200 + assert res.text == 'Hello, World!' + """ + __test__ = False # remove this class from pytest's test collection + + def __init__(self, app, cookies=None, scheme=None, host=None): + self.app = app + self.cookies = cookies or {} + self.scheme = scheme + self.host = host or 'example.com:1234' + + def _process_body(self, body, headers): + if body is None: + body = b'' + elif isinstance(body, (dict, list)): + body = json.dumps(body) + if 'Content-Type' not in headers: # pragma: no cover + headers['Content-Type'] = 'application/json' + if isinstance(body, str): + body = body.encode() + if body and 'Content-Length' not in headers: + headers['Content-Length'] = str(len(body)) + return body, headers + + def _process_cookies(self, path, headers): + cookies = '' + for name, value in self.cookies.items(): + if isinstance(value, tuple): + value, cookie_path = value + if not path.startswith(cookie_path): + continue + if cookies: + cookies += '; ' + cookies += name + '=' + value + if cookies: + if 'Cookie' in headers: + headers['Cookie'] += '; ' + cookies + else: + headers['Cookie'] = cookies + return headers + + def _render_request(self, method, path, headers, body): + request_bytes = '{method} {path} HTTP/1.0\n'.format( + method=method, path=path) + if 'Host' not in headers: # pragma: no branch + request_bytes += 'Host: {host}\n'.format(host=self.host) + for header, value in headers.items(): + request_bytes += '{header}: {value}\n'.format( + header=header, value=value) + request_bytes = request_bytes.encode() + b'\n' + body + return request_bytes + + def _update_cookies(self, res): + cookies = res.headers.get('Set-Cookie', []) + for cookie in cookies: + cookie_name, cookie_value = cookie.split('=', 1) + cookie_options = cookie_value.split(';') + path = '/' + delete = False + for option in cookie_options[1:]: + option = option.strip().lower() + if option.startswith( + 'max-age='): # pragma: no cover + _, age = option.split('=', 1) + try: + age = int(age) + except ValueError: # pragma: no cover + age = 0 + if age <= 0: + delete = True + elif option.startswith('expires='): + _, e = option.split('=', 1) + # this is a very limited parser for cookie expiry + # that only detects a cookie deletion request when + # the date is 1/1/1970 + if '1 jan 1970' in e.lower(): # pragma: no branch + delete = True + elif option.startswith('path='): + _, path = option.split('=', 1) + if delete: + if cookie_name in self.cookies: # pragma: no branch + cookie_path = self.cookies[cookie_name][1] \ + if isinstance(self.cookies[cookie_name], tuple) \ + else '/' + if path == cookie_path: + del self.cookies[cookie_name] + else: + if path == '/': + self.cookies[cookie_name] = cookie_options[0] + else: + self.cookies[cookie_name] = (cookie_options[0], path) + + async def request(self, method, path, headers=None, body=None, sock=None): + headers = headers or {} + body, headers = self._process_body(body, headers) + headers = self._process_cookies(path, headers) + request_bytes = self._render_request(method, path, headers, body) + if sock: + reader = sock[0] + reader.buffer = request_bytes + writer = sock[1] + else: + reader = AsyncBytesIO(request_bytes) + writer = AsyncBytesIO(b'') + + req = await Request.create(self.app, reader, writer, + ('127.0.0.1', 1234), scheme=self.scheme) + res = await self.app.dispatch_request(req) + if res == Response.already_handled: + return TestResponse() + res.complete() + + self._update_cookies(res) + return await TestResponse.create(res) + + async def get(self, path, headers=None): + """Send a GET request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + + This method returns a + :class:`TestResponse ` object. + """ + return await self.request('GET', path, headers=headers) + + async def post(self, path, headers=None, body=None): + """Send a POST request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + :param body: The request body. If a dictionary or list is provided, + a JSON-encoded body will be sent. A string body is encoded + to bytes as UTF-8. A bytes body is sent as-is. + + This method returns a + :class:`TestResponse ` object. + """ + return await self.request('POST', path, headers=headers, body=body) + + async def put(self, path, headers=None, body=None): + """Send a PUT request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + :param body: The request body. If a dictionary or list is provided, + a JSON-encoded body will be sent. A string body is encoded + to bytes as UTF-8. A bytes body is sent as-is. + + This method returns a + :class:`TestResponse ` object. + """ + return await self.request('PUT', path, headers=headers, body=body) + + async def patch(self, path, headers=None, body=None): + """Send a PATCH request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + :param body: The request body. If a dictionary or list is provided, + a JSON-encoded body will be sent. A string body is encoded + to bytes as UTF-8. A bytes body is sent as-is. + + This method returns a + :class:`TestResponse ` object. + """ + return await self.request('PATCH', path, headers=headers, body=body) + + async def delete(self, path, headers=None): + """Send a DELETE request to the application. + + :param path: The request URL. + :param headers: A dictionary of headers to send with the request. + + This method returns a + :class:`TestResponse ` object. + """ + return await self.request('DELETE', path, headers=headers) + + async def websocket(self, path, client, headers=None): + """Send a websocket connection request to the application. + + :param path: The request URL. + :param client: A generator function that yields client messages. + :param headers: A dictionary of headers to send with the request. + """ + gen = client() + + class FakeWebSocket: + def __init__(self): + self.started = False + self.closed = False + self.buffer = b'' + + async def _next(self, data=None): + try: + data = (await gen.asend(data)) if hasattr(gen, 'asend') \ + else gen.send(data) + except (StopIteration, StopAsyncIteration): + if not self.closed: + self.closed = True + raise OSError(32, 'Websocket connection closed') + return # pragma: no cover + opcode = WebSocket.TEXT if isinstance(data, str) \ + else WebSocket.BINARY + return WebSocket._encode_websocket_frame(opcode, data) + + async def read(self, n): + if not self.buffer: + self.started = True + self.buffer = await self._next() + data = self.buffer[:n] + self.buffer = self.buffer[n:] + return data + + async def readexactly(self, n): # pragma: no cover + return await self.read(n) + + async def readline(self): + line = b'' + while True: + line += await self.read(1) + if line[-1] in [b'\n', 10]: + break + return line + + async def awrite(self, data): + if self.started: + h = WebSocket._parse_frame_header(data[0:2]) + if h[1] not in [WebSocket.TEXT, WebSocket.BINARY]: + return + if h[3] < 0: + data = data[2 - h[3]:] + else: + data = data[2:] + if h[1] == WebSocket.TEXT: + data = data.decode() + self.buffer = await self._next(data) + + ws_headers = { + 'Upgrade': 'websocket', + 'Connection': 'Upgrade', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', + } + ws_headers.update(headers or {}) + sock = FakeWebSocket() + return await self.request('GET', path, headers=ws_headers, + sock=(sock, sock)) diff --git a/rinkhals-webui/src/microdot/utemplate.py b/rinkhals-webui/src/microdot/utemplate.py new file mode 100644 index 0000000..16d0398 --- /dev/null +++ b/rinkhals-webui/src/microdot/utemplate.py @@ -0,0 +1,70 @@ +from utemplate import recompile + +_loader = None + + +class Template: + """A template object. + + :param template: The filename of the template to render, relative to the + configured template directory. + """ + @classmethod + def initialize(cls, template_dir='templates', + loader_class=recompile.Loader): + """Initialize the templating subsystem. + + :param template_dir: the directory where templates are stored. This + argument is optional. The default is to load + templates from a *templates* subdirectory. + :param loader_class: the ``utemplate.Loader`` class to use when loading + templates. This argument is optional. The default + is the ``recompile.Loader`` class, which + automatically recompiles templates when they + change. + """ + global _loader + _loader = loader_class(None, template_dir) + + def __init__(self, template): + if _loader is None: # pragma: no cover + self.initialize() + #: The name of the template + self.name = template + self.template = _loader.load(template) + + def generate(self, *args, **kwargs): + """Return a generator that renders the template in chunks, with the + given arguments.""" + return self.template(*args, **kwargs) + + def render(self, *args, **kwargs): + """Render the template with the given arguments and return it as a + string.""" + return ''.join(self.generate(*args, **kwargs)) + + def generate_async(self, *args, **kwargs): + """Return an asynchronous generator that renders the template in + chunks, using the given arguments.""" + class sync_to_async_iter(): + def __init__(self, iter): + self.iter = iter + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.iter) + except StopIteration: + raise StopAsyncIteration + + return sync_to_async_iter(self.generate(*args, **kwargs)) + + async def render_async(self, *args, **kwargs): + """Render the template with the given arguments asynchronously and + return it as a string.""" + response = '' + async for chunk in self.generate_async(*args, **kwargs): + response += chunk + return response diff --git a/rinkhals-webui/src/microdot/websocket.py b/rinkhals-webui/src/microdot/websocket.py new file mode 100644 index 0000000..63087e1 --- /dev/null +++ b/rinkhals-webui/src/microdot/websocket.py @@ -0,0 +1,231 @@ +import binascii +import hashlib +from microdot import Request, Response +from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception +from microdot.helpers import wraps + + +class WebSocketError(Exception): + """Exception raised when an error occurs in a WebSocket connection.""" + pass + + +class WebSocket: + """A WebSocket connection object. + + An instance of this class is sent to handler functions to manage the + WebSocket connection. + """ + CONT = 0 + TEXT = 1 + BINARY = 2 + CLOSE = 8 + PING = 9 + PONG = 10 + + #: Specify the maximum message size that can be received when calling the + #: ``receive()`` method. Messages with payloads that are larger than this + #: size will be rejected and the connection closed. Set to 0 to disable + #: the size check (be aware of potential security issues if you do this), + #: or to -1 to use the value set in + #: ``Request.max_body_length``. The default is -1. + #: + #: Example:: + #: + #: WebSocket.max_message_length = 4 * 1024 # up to 4KB messages + max_message_length = -1 + + def __init__(self, request): + self.request = request + self.closed = False + + async def handshake(self): + response = self._handshake_response() + await self.request.sock[1].awrite( + b'HTTP/1.1 101 Switching Protocols\r\n') + await self.request.sock[1].awrite(b'Upgrade: websocket\r\n') + await self.request.sock[1].awrite(b'Connection: Upgrade\r\n') + await self.request.sock[1].awrite( + b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n') + + async def receive(self): + """Receive a message from the client.""" + while True: + opcode, payload = await self._read_frame() + send_opcode, data = self._process_websocket_frame(opcode, payload) + if send_opcode: # pragma: no cover + await self.send(data, send_opcode) + elif data: # pragma: no branch + return data + + async def send(self, data, opcode=None): + """Send a message to the client. + + :param data: the data to send, given as a string or bytes. + :param opcode: a custom frame opcode to use. If not given, the opcode + is ``TEXT`` or ``BINARY`` depending on the type of the + data. + """ + frame = self._encode_websocket_frame( + opcode or (self.TEXT if isinstance(data, str) else self.BINARY), + data) + await self.request.sock[1].awrite(frame) + + async def close(self): + """Close the websocket connection.""" + if not self.closed: # pragma: no cover + self.closed = True + await self.send(b'', self.CLOSE) + + def _handshake_response(self): + connection = False + upgrade = False + websocket_key = None + for header, value in self.request.headers.items(): + h = header.lower() + if h == 'connection': + connection = True + if 'upgrade' not in value.lower(): + return self.request.app.abort(400) + elif h == 'upgrade': + upgrade = True + if not value.lower() == 'websocket': + return self.request.app.abort(400) + elif h == 'sec-websocket-key': + websocket_key = value + if not connection or not upgrade or not websocket_key: + return self.request.app.abort(400) + d = hashlib.sha1(websocket_key.encode()) + d.update(b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11') + return binascii.b2a_base64(d.digest())[:-1] + + @classmethod + def _parse_frame_header(cls, header): + fin = header[0] & 0x80 + opcode = header[0] & 0x0f + if fin == 0 or opcode == cls.CONT: # pragma: no cover + raise WebSocketError('Continuation frames not supported') + has_mask = header[1] & 0x80 + length = header[1] & 0x7f + if length == 126: + length = -2 + elif length == 127: + length = -8 + return fin, opcode, has_mask, length + + def _process_websocket_frame(self, opcode, payload): + if opcode == self.TEXT: + payload = payload.decode() + elif opcode == self.BINARY: + pass + elif opcode == self.CLOSE: + raise WebSocketError('Websocket connection closed') + elif opcode == self.PING: + return self.PONG, payload + elif opcode == self.PONG: # pragma: no branch + return None, None + return None, payload + + @classmethod + def _encode_websocket_frame(cls, opcode, payload): + frame = bytearray() + frame.append(0x80 | opcode) + if opcode == cls.TEXT: + payload = payload.encode() + if len(payload) < 126: + frame.append(len(payload)) + elif len(payload) < (1 << 16): + frame.append(126) + frame.extend(len(payload).to_bytes(2, 'big')) + else: + frame.append(127) + frame.extend(len(payload).to_bytes(8, 'big')) + frame.extend(payload) + return frame + + async def _read_frame(self): + header = await self.request.sock[0].read(2) + if len(header) != 2: # pragma: no cover + raise WebSocketError('Websocket connection closed') + fin, opcode, has_mask, length = self._parse_frame_header(header) + if length == -2: + length = await self.request.sock[0].readexactly(2) + length = int.from_bytes(length, 'big') + elif length == -8: + length = await self.request.sock[0].readexactly(8) + length = int.from_bytes(length, 'big') + max_allowed_length = Request.max_body_length \ + if self.max_message_length == -1 else self.max_message_length + if length > max_allowed_length: + raise WebSocketError('Message too large') + if has_mask: # pragma: no cover + mask = await self.request.sock[0].readexactly(4) + payload = await self.request.sock[0].readexactly(length) + if has_mask: # pragma: no cover + payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload)) + return opcode, payload + + +async def websocket_upgrade(request): + """Upgrade a request handler to a websocket connection. + + This function can be called directly inside a route function to process a + WebSocket upgrade handshake, for example after the user's credentials are + verified. The function returns the websocket object:: + + @app.route('/echo') + async def echo(request): + if not authenticate_user(request): + abort(401) + ws = await websocket_upgrade(request) + while True: + message = await ws.receive() + await ws.send(message) + """ + ws = WebSocket(request) + await ws.handshake() + + @request.after_request + async def after_request(request, response): + return Response.already_handled + + return ws + + +def websocket_wrapper(f, upgrade_function): + @wraps(f) + async def wrapper(request, *args, **kwargs): + ws = await upgrade_function(request) + try: + await f(request, ws, *args, **kwargs) + except OSError as exc: + if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover + raise + except WebSocketError: + pass + except Exception as exc: + print_exception(exc) + finally: # pragma: no cover + try: + await ws.close() + except Exception: + pass + return Response.already_handled + return wrapper + + +def with_websocket(f): + """Decorator to make a route a WebSocket endpoint. + + This decorator is used to define a route that accepts websocket + connections. The route then receives a websocket object as a second + argument that it can use to send and receive messages:: + + @app.route('/echo') + @with_websocket + async def echo(request, ws): + while True: + message = await ws.receive() + await ws.send(message) + """ + return websocket_wrapper(f, websocket_upgrade) diff --git a/rinkhals-webui/src/microdot/wsgi.py b/rinkhals-webui/src/microdot/wsgi.py new file mode 100644 index 0000000..67b9e1c --- /dev/null +++ b/rinkhals-webui/src/microdot/wsgi.py @@ -0,0 +1,161 @@ +import asyncio +import os +import signal +from microdot import * # noqa: F401, F403 +from microdot.microdot import Microdot as BaseMicrodot, Request, NoCaseDict, \ + MUTED_SOCKET_ERRORS +from microdot.websocket import WebSocket, websocket_upgrade, \ + with_websocket # noqa: F401 + + +class Microdot(BaseMicrodot): + """A subclass of the core :class:`Microdot ` class that + implements the WSGI protocol. + + This class must be used as the application instance when running under a + WSGI web server. + """ + def __init__(self): + super().__init__() + self.loop = asyncio.new_event_loop() + self.embedded_server = False + + def wsgi_app(self, environ, start_response): + """A WSGI application callable.""" + path = environ.get('SCRIPT_NAME', '') + environ.get('PATH_INFO', '') + if 'QUERY_STRING' in environ and environ['QUERY_STRING']: + path += '?' + environ['QUERY_STRING'] + headers = NoCaseDict() + content_length = 0 + for k, value in environ.items(): + if k.startswith('HTTP_'): + key = '-'.join([p.title() for p in k[5:].split('_')]) + headers[key] = value + elif k == 'CONTENT_TYPE': + headers['Content-Type'] = value + elif k == 'CONTENT_LENGTH': + headers['Content-Length'] = value + content_length = int(value) + + class sync_to_async_body_stream(): # pragma: no cover + def __init__(self, wsgi_input=None): + self.wsgi_input = wsgi_input + + async def read(self, n=-1): + return self.wsgi_input.read(n) + + async def readline(self): + return self.wsgi_input.readline() + + async def readexactly(self, n): + return self.wsgi_input.read(n) + + wsgi_input = environ.get('wsgi.input') + if content_length and content_length <= Request.max_body_length: + # the request came with a body that is within the allowed size + body = wsgi_input.read(content_length) + stream = None + sock = (None, None) + else: + body = b'' + if content_length: + # the request came with a body that is too large to fit in + # memory, so we stream it + stream = sync_to_async_body_stream(wsgi_input) + sock = (None, None) + else: + # the request did not declare a body size, so we connect the + # raw socket if available + stream = None + if 'gunicorn.socket' in environ: # pragma: no cover + reader, writer = self.loop.run_until_complete( + asyncio.open_connection( + sock=environ['gunicorn.socket'].dup())) + if not hasattr(writer, 'awrite'): # pragma: no cover + async def awrite(self, data): + self.write(data) + await self.drain() + + async def aclose(self): + self.close() + await self.wait_closed() + + from types import MethodType + writer.awrite = MethodType(awrite, writer) + writer.aclose = MethodType(aclose, writer) + sock = (reader, writer) + else: + sock = (None, None) + + req = Request( + self, + (environ['REMOTE_ADDR'], int(environ.get('REMOTE_PORT', '0'))), + environ['REQUEST_METHOD'], + path, + environ['SERVER_PROTOCOL'], + headers, + body=body, + stream=stream, + sock=sock, + scheme=environ.get('wsgi.url_scheme')) + req.environ = environ + + res = self.loop.run_until_complete(self.dispatch_request(req)) + res.complete() + if sock[1]: # pragma: no cover + try: + self.loop.run_until_complete(sock[1].aclose()) + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS: + pass + else: + raise + + reason = res.reason or ('OK' if res.status_code == 200 else 'N/A') + header_list = [] + for name, value in res.headers.items(): + if not isinstance(value, list): + header_list.append((name, value)) + else: + for v in value: + header_list.append((name, v)) + start_response(str(res.status_code) + ' ' + reason, header_list) + + class async_to_sync_iter(): + def __init__(self, iter, loop): + self.iter = iter.__aiter__() + self.loop = loop + + def __iter__(self): + return self + + def __next__(self): + try: + return self.loop.run_until_complete(self.iter.__anext__()) + except StopAsyncIteration: + raise StopIteration + + def close(self): # pragma: no cover + if hasattr(self.iter, 'aclose'): + self.loop.run_until_complete(self.iter.aclose()) + + return async_to_sync_iter(res.body_iter(), self.loop) + + def __call__(self, environ, start_response): + return self.wsgi_app(environ, start_response) + + def shutdown(self): + if self.embedded_server: # pragma: no cover + super().shutdown() + else: + pid = os.getpgrp() if hasattr(os, 'getpgrp') else os.getpid() + os.kill(pid, signal.SIGTERM) + + def run(self, host='0.0.0.0', port=5000, debug=False, + **options): # pragma: no cover + """Normally you would not start the server by invoking this method. + Instead, start your chosen WSGI web server and pass the ``Microdot`` + instance as the WSGI callable. + """ + self.embedded_server = True + super().run(host=host, port=port, debug=debug, **options) diff --git a/rinkhals-webui/src/rinkhals_webui/main.py b/rinkhals-webui/src/rinkhals_webui/main.py index 1f3a492..43c57c4 100644 --- a/rinkhals-webui/src/rinkhals_webui/main.py +++ b/rinkhals-webui/src/rinkhals_webui/main.py @@ -1,69 +1,21 @@ -"""Minimal MicroPython bootstrap for the Rinkhals WebUI port.""" +from microdot import Microdot, Response -try: - import machine # type: ignore[import-not-found] -except ImportError: # pragma: no cover - machine = None +app = Microdot() +Response.default_content_type = "text/plain" -try: - import utime as _time # type: ignore[import-not-found] -except ImportError: # pragma: no cover - import time as _time +@app.route("/") +def index(_request): + """Return a static greeting so we can verify the Microdot stack.""" -def _sleep_ms(duration_ms: int) -> None: - """Sleep helper that works on both CPython and MicroPython.""" - if hasattr(_time, "sleep_ms"): - _time.sleep_ms(duration_ms) - else: - _time.sleep(duration_ms / 1000) - - -class _MockPin: - """Lightweight stand-in when `machine.Pin` is unavailable during simulation.""" - - OUT = 0 - - def __init__(self, pin_id: int, mode: int, value: int = 0) -> None: - self.pin_id = pin_id - self.mode = mode - self._value = value - - def value(self, new_value: int | None = None) -> int: - if new_value is None: - return self._value - self._value = new_value - print(f"[mock-pin] pin={self.pin_id} -> {self._value}") - return self._value - - -class StatusLED: - """Basic status indicator abstraction to verify event flow end-to-end.""" - - def __init__(self, pin_id: int = 2, active_high: bool = True) -> None: - pin_cls = machine.Pin if machine and hasattr(machine, "Pin") else _MockPin - initial = 1 if not active_high else 0 - self._pin = pin_cls(pin_id, pin_cls.OUT, value=initial) - self._active_high = active_high - - def on(self) -> None: - self._pin.value(1 if self._active_high else 0) - - def off(self) -> None: - self._pin.value(0 if self._active_high else 1) - - def blink(self, count: int = 3, period_ms: int = 200) -> None: - for _ in range(count): - self.on() - _sleep_ms(period_ms // 2) - self.off() - _sleep_ms(period_ms // 2) + return "Hello from Rinkhals WebUI!" def main() -> None: - led = StatusLED() - led.blink(count=5, period_ms=300) + """Start the Microdot development server.""" + + app.run(host="0.0.0.0", port=1414, debug=False) -if __name__ == "__main__": # pragma: no cover +if __name__ == "__main__": main() From 031f67742d74733780783910729ffc5df1f2eff1 Mon Sep 17 00:00:00 2001 From: Bas van Doren Date: Sat, 24 Jan 2026 15:37:47 +0000 Subject: [PATCH 3/9] Remove redundant dependency list in pyproject.toml --- .devcontainer/devcontainer.json | 3 ++- pyproject.toml | 21 ++++++++------------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3fb6ff6..b3a41f5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,7 +10,8 @@ "vscode": { "extensions": [ "ms-python.python", - "ms-python.vscode-pylance" + "ms-python.vscode-pylance", + "tamasfe.even-better-toml" ], "settings": { "python.defaultInterpreterPath": "${containerWorkspaceFolder}/.venv/bin/python" diff --git a/pyproject.toml b/pyproject.toml index 1a6533f..f548790 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,29 +11,24 @@ authors = [{ name = "Bas van Doren" }] requires-python = ">=3.11" keywords = ["micropython", "webui", "rinkhals"] -[project.optional-dependencies] +[dependency-groups] dev = [ - "micropython-stdlib-stubs>=1.20.0.post2", - "micropython-unix-stubs>=1.20.0", - "ruff>=0.4.10", - "pyright>=1.1.347", - "pytest>=8.3", + "micropython-unix-stubs>=1.26.0.post1", + "ruff>=0.14.14", + "pyright>=1.1.408", + "pytest>=9.0.2", ] [tool.hatch.build.targets.wheel] packages = [ "rinkhals-webui/src/rinkhals_webui", - "rinkhals-webui/src/microdot", + "rinkhals-webui/src/microdot" ] [tool.hatch.envs.default] path = ".venv" -dependencies = [ - "micropython-stdlib-stubs>=1.20.0.post2", - "micropython-unix-stubs>=1.20.0", - "ruff>=0.4.10", - "pytest>=8.3", - "pyright>=1.1.347", +dependency-groups = [ + "dev", ] [tool.hatch.envs.default.env-vars] From 4dbe2eaf1da6889c39aed4ad7f3e228cf7d08621 Mon Sep 17 00:00:00 2001 From: Bas van Doren Date: Sun, 25 Jan 2026 01:38:22 +0000 Subject: [PATCH 4/9] Add Svelte placeholder, micropython-lib, and static file serving --- .devcontainer/Dockerfile | 12 +- .devcontainer/devcontainer.json | 3 +- .devcontainer/install_micropython_lib.py | 89 ++++ .devcontainer/post-create.sh | 1 + .gitignore | 4 +- frontend/index.html | 16 + frontend/package.json | 16 + frontend/public/favicon-120.png | Bin 0 -> 7305 bytes frontend/public/favicon-180.png | Bin 0 -> 11319 bytes frontend/public/favicon-57.png | Bin 0 -> 3287 bytes frontend/public/favicon.png | Bin 0 -> 22989 bytes frontend/src/App.svelte | 34 ++ frontend/src/app.css | 118 +++++ frontend/src/main.js | 12 + frontend/svelte.config.js | 5 + frontend/vite.config.js | 23 + frontend/yarn.lock | 518 ++++++++++++++++++++ pyrightconfig.json | 8 + rinkhals-webui/src/rinkhals_webui/main.py | 33 +- rinkhals-webui/src/rinkhals_webui/static.py | 39 ++ rinkhals-webui/static/.gitkeep | 0 21 files changed, 922 insertions(+), 9 deletions(-) create mode 100644 .devcontainer/install_micropython_lib.py create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon-120.png create mode 100644 frontend/public/favicon-180.png create mode 100644 frontend/public/favicon-57.png create mode 100644 frontend/public/favicon.png create mode 100644 frontend/src/App.svelte create mode 100644 frontend/src/app.css create mode 100644 frontend/src/main.js create mode 100644 frontend/svelte.config.js create mode 100644 frontend/vite.config.js create mode 100644 frontend/yarn.lock create mode 100644 rinkhals-webui/src/rinkhals_webui/static.py create mode 100644 rinkhals-webui/static/.gitkeep diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b3269b0..de3660d 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -3,7 +3,17 @@ FROM mcr.microsoft.com/devcontainers/base:trixie ENV PIP_BREAK_SYSTEM_PACKAGES=1 RUN apt-get update \ - && apt-get install -y --no-install-recommends micropython python3-pip \ + && apt-get install -y --no-install-recommends \ + micropython \ + python3-pip \ + curl \ + ca-certificates \ && pip3 install --no-cache-dir hatch \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* + +# Install Node.js 24 LTS via nvm and enable Yarn globally through Corepack. +RUN su - vscode -c 'curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash \ + && . "$HOME/.nvm/nvm.sh" \ + && nvm install 24 \ + && corepack enable yarn' diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b3a41f5..ede1e08 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,7 +11,8 @@ "extensions": [ "ms-python.python", "ms-python.vscode-pylance", - "tamasfe.even-better-toml" + "tamasfe.even-better-toml", + "svelte.svelte-vscode" ], "settings": { "python.defaultInterpreterPath": "${containerWorkspaceFolder}/.venv/bin/python" diff --git a/.devcontainer/install_micropython_lib.py b/.devcontainer/install_micropython_lib.py new file mode 100644 index 0000000..dab9421 --- /dev/null +++ b/.devcontainer/install_micropython_lib.py @@ -0,0 +1,89 @@ +#!/usr/bin/env micropython +"""Install MicroPython packages via mip.""" + +import mip + +PACKAGES = [ + "__future__", + "abc", + "argparse", + "base64", + "binascii", + "bisect", + "cmd", + "collections", + "collections-defaultdict", + "contextlib", + "copy", + "curses.ascii", + "datetime", + "errno", + "fnmatch", + "functools", + "gzip", + "hashlib", + "hashlib-core", + "hashlib-sha224", + "hashlib-sha256", + "hashlib-sha384", + "hashlib-sha512", + "heapq", + "hmac", + "html", + "inspect", + "io", + "itertools", + "keyword", + "locale", + "logging", + "operator", + "os", + "os-path", + "pathlib", + "pickle", + "pkg_resources", + "pkgutil", + "pprint", + "quopri", + "random", + "shutil", + "ssl", + "stat", + "string", + "struct", + "tarfile", + "tarfile-write", + "tempfile", + "textwrap", + "threading", + "time", + "traceback", + "types", + "unittest", + "unittest-discover", + "uu", + "venv", + "warnings", + "zlib", +] + + +def install_all(): + failures = [] + for package in PACKAGES: + print("Installing {}".format(package)) + try: + mip.install(package) + except Exception as exc: + failures.append((package, exc)) + print("Failed installing {}: {}".format(package, exc)) + if failures: + print("Completed with failures:") + for package, exc in failures: + print(" {}: {}".format(package, exc)) + raise SystemExit(1) + print("All packages installed successfully.") + + +if __name__ == "__main__": + install_all() diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 9cdadbe..45c5762 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -12,3 +12,4 @@ git config --global --add safe.directory "${workspace_dir}" cd "${workspace_dir}" hatch -v env create +micropython "${workspace_dir}/.devcontainer/install_micropython_lib.py" diff --git a/.gitignore b/.gitignore index 9fecb16..b6d072f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ -build/dist/ .venv/ .ruff_cache/ +__pycache__/ +dist/ +node_modules/ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8b074d1 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Rinkhals WebUI + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..48103f7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,16 @@ +{ + "name": "rinkhals-webui-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "svelte": "^5.48.2", + "vite": "^5.0.12" + } +} diff --git a/frontend/public/favicon-120.png b/frontend/public/favicon-120.png new file mode 100644 index 0000000000000000000000000000000000000000..770627d71b1e74ea7358450cc4189513dc5cb772 GIT binary patch literal 7305 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 literal 0 HcmV?d00001 diff --git a/frontend/public/favicon-180.png b/frontend/public/favicon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..883a27533b32db12c312a0c6afb75b80f63e290c GIT binary patch literal 11319 zcmV-7EXdP|P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGh*8l(w*8xH(n|J^KE6hnmK~#8N?VSgh z9L2ToPj&Z%%}Lsz1QJRhBt*u*MPRT=0;JuQ1cGaPZC~T-1paV(_A^-5*ZKNAUt?c~ zhq1vnLE2S92!REHOEggk1waW&NSpJ_PU`M@=k(UDHcaTA?FqZvzwhgvuA14I?&^P? zI;X0-8XO!P92~L~CSEg2=a$ta{$Q|zG3iu^!933SJUp}t&UmrJr6LZzm@)8(kEaZV zKO*5KOUAw|OU#WWB;#AOWxA}e7(l~UP1AW8%LrcqbdK4$r5gN&ApTGn7DFc%Q76}V z6Bg2YSSatQDjdyoN195OZ|W2u&lnlGNQXDvRP5!hxmZ9~VDcsg>`WAIF20g*(uyHq znSl#cEMaXLi|9Is2&!AtxFPVg!$HMg4WOEKVQQ1Y*t5*z-aqNOzjgP0Abq6m3LgH` z?cUsC-#J+JcK~PC<1d%uk7rClXnn!$l8x8#1X^zhM>OL#Q&6LUcA^DeH{lh&zryQ# zN4joXNW7g=Qhqg$pgZ9Tg}#i|_jWwV(?oX0AS>({he93>2|9$rChMI^NZk1MAd{p& zx%1&SrJq=bZcFM&*;Q@abaMr$>Ros?UqB%ji|mX+CrTFCrJ#d6fe5R*VX?CU4sEE1 z-{dm&S4Gtu+eEId*e>XnjW-tPn(J=P;G6jMIeodAg{-pIEgSywM1=h6U^w&Ex*t58 z9to+`34j)K9S2gN)wrF;^SatKAO zQrAe9fWkh=8UISv^7?IPYiy})wp{0i`sL-o-7#ZTU3*_{@{CH*Si@Lz{mP4VNq-c-Xqw2*I1%(4iIRepcU_M;NXi{O zA>+blQ#^rMI;2Q!<>ck--V|9$k+h&WM;Bwmp1T=`AKtY^pu0DM9uGiOX|D0= zFcR{q8``Mb7@#26l|ALep%)M38|Awe{En?zlaz0i)TyjpyF$KH=D);${t^~ORul(W z?msA;!9Vp#1g;(BkP z{{g;ZiRoHqO3?I$_{G0MH)FZT$~ZJo&@X196fcb9H(Q6wY;bQ-4~jaNEhCErgPWT= zCjWxQIYdre5`GB%8P?Q3>3`ww=lXIo4-F3VH7JA`XWYj-cMn!d%_ND+X0E0g2H~N zY)RDFA!!mdlO&1%pkZUpg(52^aRuG9Wkn@^+h0*DSraMv0+AuZx1{3&3}zfgM}E%G zOHA{t{ef)qr&$iu;gOHlFDnxnQHd#N@>zI@KY)6v5?L9Cn!#QS1P4P?L9?X~bu2^W z=pHt5=nqCRIW4_U_XwPkEov#(itbl^R!LA08W!+3;n!KE$EghBh z;J`Z@z(ZQvxMd@rf# z7Pi1S|F5c=`nyDOfDwXj*;qSI)%h;02q(kdbbwV)@)m-pd^RX$GeGth7{VrKVtPw! z=ZFdFvg(J@<`;0!5wsm}EbsP;D?pcqUV0g0l*yBdjCnE!I$G^e4@6pzK)Cq;=s_Q5 zruiCh9#kb|?zH8P9TPbQBU}zuga4gwF)PJWAb-KtPa!%2Jn2B&C67_Bi zS)&or(HG=fM*;r$BEY7d7zm~1R!)293IMEsi^Bsiao9QJvL**Ziz^vrIuu$jAsiVL zj7u_Jqhjz*KMSu}HD0?t zUYM{jy1M<)+dEQeoT=(DQ;k* z3wg_h0LvDSXml;nRdybPJ(ET1B)PdBC@RbospzPk4G=i|8fd*8A}6(=+tH29#hj{| z4b38Jn1$xr*V7?phBe$f;~XeB>jqObI~a@}X)`{$2t152=7zUKLeSg;@b|qU5r=j^ zzrxSzZJzxq8`rC_V{khQ+qgktO08TfC;;8D|@A2*bcW<#1SeeDl&=kvb`8 zSy!$?pC23C)FI^*LjI!bKq)L2If>~Lv`CD=L;=Sk)_=$Q*2XS?Kfl~39)c#PJ@^WT zrYLI!k#9?unU0^f18998!p{!pXgk2){z0oB^`*%e(*H*L&VFK*NxWkp4Yb^tuA4Xi zN)cM?h1Y`Q$rm}P0j=~DrsZBPl7n0hDYcW2ByT}dC#Y$$+i9NumBS2|0PENgNPv_- z7|Pq%AQFSv-!$_754McVqcnXs?MSiv_0>c^(=a3q?(@?iYKiLuUCf4{+cvD4j`CPy z|YQ zm_d%wArLE{m|7xshVjCFfG5xoZlhokE$gqJy<)m|iLp2%W?27t{LIA|nZOZ1Lh*=y=z{3^Zs)qroZSO42fGtgD{I znREaPadbyJ9CS7E7hVHOf%zVdXwaZaLsJ)Y`-0HfO>1rg6c>7-FyCw36g#PTN6fxc zvmEOH*m;zrbz^YuDMJMw4Z4}*$6Gn<`bcEh%sDdvzIjbzaZ(S<8y`|%ILH5Caa|NM zXlR|=CS3}hz7X`_M&(VD|BDN-ym6~6FM^`NfgZA?P&F z!^*2U*SCpW+aNb1|IAO~UUKz>X4bv(=23Wj`))XRq{Ud5Z7{Tw=#+{=_{!xv%qdI% z6i4b${_&IC=!#23fBi7iM=-w<;9Dr>ybRp$ySK#+|NR_5Lvs&AhV^K8FhZR(vm9=? zY5`ny_H=M1s`arzA@}Th0yJM7pDYRRYq>7*CGy=XPrnYWv&}9CgIo*(lI^b9^*#u>p#C@ zh*jjq?TZefalUquV)dtNLBV=-Ho6D@G>1W%7a-WGfftXX6h%DCt;5ARDlExWp-AFT zh}&YX29=6#e8z+RdjaNAdg-|meD*R17tQK>zDp*c9+}_m2xA2m8BtIaS~iS9cTWgj zeB+4W_MJAP%yQXFo?M7D9{{e|+_Ew_EPs8~>35;HZCzR4Q#H?+L1&dGo_!zwT=(V> z3qh_{JV6H|94?=)Kt8D?Ly&uLD#kj+N5ZfWE%5@}X1R1IL17O*D%$TCJALL%+~(o_ zr57tuke9jRCq8V|Ve{K++@Q8!&;(thw|1X^vXWewHDge8Y66Dw6<33xcAN0mEP!4| zH`)VMA=0+to;*wJnTOnr2kYM&5i1PL!HogA)=%#l>5@2DL((!?XnO^nS3+kmQs$uEjN^^5 zXX40G{GhMZ$KlOu z%BN_HP#vpH?bv}S)7Y}kb z;#p{7&j-yAh}T6D1-kj!PDsmml#K3hv<;?M8xd$}^MRV+HVZ0HIYEIMR{GUQ+`z_R zDCG81nI$5Tm6#53y-H0iKqr_4B_-ncjeh{}F-K9>+{4;&=y# zN3g6CVU)WG3vGC?8He|fN^BLh)zP0K3%6HO$9^JuLmcjVCTujP4$+}*y8E_=;*JXT zBb%_!1PQ+&-9C zNL=G3492O<%|MgIN{g+{#G}=(>;&709tm9^TOTH_2jkq?8HK!DH!PfIez4Mvl_p+0 zo(mzb?Sr6Wo`AOsUO8f}3>6b`OFyU2E{9yNHJ@1P?G$_VfTpgKy6p~%v@OuuXI8oF zf|+73F%ns66F%MfXU%|~l0^`4+uJo{6`C(!Fax*gxNlw`DIwx113#9@rROCsZ|ZlZ zvNi)Xnpa+8eL9ftw|7Arw9p?&oU`{t+7^sjXK{q%lJic1DYRsfNnEb@^DAk>%*QW2 z&A5q{Rsza|sHy8bA4NQM2_DaxxGn74&znkb_I)OuMBJs&ReG+me%<9Cn*{}NCR|hS z?Q)s7wx>=jK?7p#LBixeYZhDcgm8FQ(sgcqh$jwu$Zz>TX|nj|Zn?m;FCDMI=?;q( z*Df@CI-*b1=Nr~3l^A0`0uR=os*eFoLJ`Y@>Yj`mntT?%cM*Q3NMs^||2Gwc@!Y9m z_usCTk8a+|>lYej6^+5O>Js60FMsD#3ut!c z*YNmlvm3M&uGPi+Dkm2htH>uBHSCAn4jOW$Z7|neJ`b+CbhbzdP%*)rDvc3I}PG zn?X>YvsvGnD#T}2oChDHd&VphZg>yWZu=Gmlc1XC zsho?yVy%wMojD0Ds|0)ZH^;r(yC1UXbZb3{nx3^sVqW&i1<|bw=7qhJjhjGS;SMYS zYi^Ml$m+Vub24$-bcpW4p_bZCsAcjOoesSP=-WnV0;r5AkfWK7hmN%yULqL@m)o2S zT#5|0*PH{_UNO(=Wd^RR5IFFXt=)F0-}Z`SYyVw-{`u$mH&&jp43CPuJ6qt95GWBrZd7p$9&Ng?r^CvyJbF4FlC(jKVGlSELgp zeGILgYXQ|=)VGQ#R$w~kSWp+}f;kn~I_AUSqaC0dt6C&hXBEafcYXE}Bbc`;Wsodsv?qh|sEG_J4tDW1qTjdyIKZ_Q`ANj5oXZoqS|9D-+GJ_!3iY#ZTP zkZ|cvi6s}#gex$}@%wwD&ZmwAt%aQ9KMc7&2XTLVOll&lOcuE}?^HZN^FA%Lbh)`X zu>Hk@#>~ef$2$y*Js5H?=FOUHlr@!k(l^Os%h|(*411q z$!x3fj$L4Z(pq+M!N|3StwQ$~9&hY|_O1XnSKR2puc|13vOyjBBmfxiXZ zCtfz5b(oB`$p*+h>+V${GW8OGTx-Fvg04epNx_hSytShj8k)PIqcdO#naX==Wuc`} z-B{2X(!RY_rbyc{_{p~LQ#5Ao(uAG(8nJ3c(ZIJqUz8Sh!{`R)iiDd;(2q;FHz`r zyP%{v-w`$mvn1h`Lj#dE-9psX9fG6HJ~+|VYdp@31d~mWO*2G{$9AH%7mhahpdF8y z#7wB@n}%BxFAqR1Q9hOtS>|+!nFt-VJE9pK4h;YWkM0zylTb@Y5BF^92pHl%f}-(xr$IP>256oV;Ih}~DKQp`H2hQ& zgpC`1Cw({lhK|2QzZou=0X?26hJfjq=-7Hg(eKa>9(^11VC?BRR3;@@HU(Hl>2$Hw zt_BDmdqBRclC5*!keAD*RWbPdFr>{%FJ!J7A7o^A63xPmrh|8u& z0^r~GEa)L~Q^AWDTnVmX(XWodq!C0KuVlfa$!av9ph;;YZ0b7}!CkohBk0=qys@7M zV_oL?9|xwK^dRXgb922=ke`G5X;d;o6|861R^V9fc0&s$F%8bHTv5M^(gP+;R@s|) zo^2v&D?A)9)-$tnWXKHEoYI4C%F2qs>+QR^Tnj_*2QQcw^9T$JiWXY-fUe}=mTU8d zEdYMEv1py5wih@!`&zR?~Bw)L07C? z=RKSDZ5eeG1il}2eQy)sY56BU?L3z8tCVSoAzVjZo$Iso5w*K&hentE^x!-5{&i> zJ!4wfH0)33K<7f|MCaCjI!4R*&f~@tUxx$|DT3oQ4Suu>-HfMF?qcF8zFBeo!Vg8# zV2Gg4T)8fcpSoWpQwN@O;PC544OWd2qf`_$2hG<8J@0OV-UBbitUPqx-HL?%c3*ld zLY0xuAy$5e*4h>r}a0XfC^(!H0$;c;qciroQsvi9N#Dnjw9wBQ9e^*B--9 zrpSXI3iVU_P;h<>7>)IUjb4LMg5N>k>)*G-s4%t+v1Wtuw&57mZgd0Q*qwl!4b;^pS7Gj4Vfg6v=nq;*O3;HL zW8y6YIll}fG%B>58EMtRpv$>oQuSl|L~;aiqX3K%zK=fC=$@J8;9wKfv%wVju1H2DVP{a| z`dTvY-|9<^U*h@DlNVnNo>J@2jIono>rrUk^^8ajWuo}djPo@WHTB;Y$*3e0^pU@< zE^&K9dl_TaZsQ(f#1nMhyvso;%%$!wA$|pSodD4xBcQ9H=^DQ?k(h~uf+pVI)v}QRkKW!nIHaD)a^0r7x~oGjz^IEKCWSsH|OohynW?nSYVx;E+-{O%LVl za}`V1nOjZ^Nf;CkhDHz@*Vq1E#^6qoHExI(6jq#j4dj&Bwhm8H^fn)X=AGL`YAEA2 zDCF?-%9=V$>&2M1&;*Sj_T>1!h01RJpV4T92`u*b#z(3S6Nf{ zb>pTI8?Z&t1Y?X>)zp6(LiDCWQO2%JNYju2V4lQKaQGgj3duh67Va?bw)t+n$s`3Z|L zqWt~JmeT8|tg1`tN}VYcG{IJ`3uCbOJ&DOTU=?i@*$xu~!|h~n9a`lz?2bRA+^D5o z(EU^_U;C7z!eX7nT0BkXcCK*_^>lF#4>H-cxN>=Y+U|Bro1lqF=z>hCsawSWuEnJ7 z4=HlU4$xPAhXcE^s;2JKl{Y<_z6b0eX%%!o71i}mx2o(MwA0_gTAcpXxE)di??7Yq z%}SM>JEgiVBb@;m5i~(-QCHR0{aBHe1=u{@k6UV( zcq{E>Owj#IUiRpTs_OcGRV3HxnEaJvvBwZJ5g+)^0JM2k%j>^bvC{m#f2ktlg6@Ze zjgj!3Oi|{esDDU>g|D^^i8|N=T@&2sX=&frT(0?*)%D-1s@Y)ggS$rox;)v*zKu5) z6zlG5P|}|R#;;^R5A91G&T_i(p;bQ_gQrn24}dB?O;1e|Z)Fu(E9k+rY`l4js;akv z#Hoj5F&?(KI@Y$fc1pwz*KnPGf-!bJ z1AeL<0(S&W&@4%I2(9t!sElVYuGu!}rbip>n%bET#+0Ck!*?uE8rsUHqOh0Zsa%8w zKL>NGfU`+>X1U^HrkH?&_M>py(3NXIOZ)-8z7zrNiD{+njqHl&$LxKgV_MMB(6?UW zIi{E9dIF*8Ok#_5fVp^*Mfg?$XS@(kxd7Aj(D8iS2K@4t&LGob2ztgPH%-eeo02Dz z8NuIt%y;b7EyqQ22#UgBQjvG?!D~7PpUj{eTQyp40yo}+AL!%^-e(%T?N!v{MV?SV zN@A&iY-ZdEIu$g1@Yvt6q^=Ul%)nuD#oSdliIhVO96_7l@Xm*}V@X{pk{NG zZxjiK7-UU9w1Wfj(fVa&%^Rw(Yp7fC>BjonPc%Gqna7L1y;@GuNvz1P*&`do;3xgB>&}{LA=qeqYKmFNxQ< zID&Ra3k~(l&*Yr{P$VrQN!-3?eLFaehNF+w=AhL6!jf?{40sn5OS?oOE{>obQVPwI zbG!IYQOpTP`C`F=9V~zDjD-F(;RxCxr8F$sa~lWvl1L^8hu7%gR3a4@N6-!_qhVdm zg($G!veDm_Z+e|PN6-!_p|P%d7RYP^SiJjwB($`GmT-iyA4kv*w$r%j<_gZ(lMJ9r zWF-yf?88b;p{2)6;RxEnR=UuYZXP2Y}Ut`OO_f#3O-)u#bCf_6xX!#i&GHZR)q z8xHUnOr~VrKd!?IV%Ic|pdBo8tbXNLIUW8NID;>s&zzE#1F)iSNn%fnq-h*MJD7%6 zH)>p8dyfLz>zK55$B9+Ye?Hby@(+gzUI?iPEsXW~mhM(2Tb==-ayb-bo#3 z_@6m1i6_rGf_8`r1$`|WR-8M3(PUa_eG>}#w7z#zkIwjJv1qGOkE9B!$U!=(F2b{yz=qD@Ut+c>_`!FIrEt#ilIf6F9L|Gr2o_^36 z$iD$a@ik!lLTvUkvRo4idUI8EeNvUzaXBV9^dP1a^~=w1sIUEHb1?sa#9$p-hoh?6)Fm8As3)kmH+gnRKFV<#mngYyYchea#_>$*-Y^@5HyJ^?f39Fvd?Xzt?jA zb3cyDVS=&ha?rheimaDQj8`#`=W>AaaijB?lWxD*EO>nXSyf#}>qI9-(t_5ve#PTw zEwgo<&y;0W1v*T@>QyY>9#^ziTvnsWc^Q4Kn;h;s>^k~f)X1M9*i`4B-9=_D!o?JZQ!#lpJ^T!H@(sia9LX#V@pyL{Vj`5FG;+gPU8P81OjhRJTR z=a53SRNh$kq1aEFxPtE7vbw}Ky?Z^1`0MEy?#xPLJZ&i}2XOgK7X z3c7P!ZK)*j|3M)y6HiRQ&t-WTuBEVu+v_@Y@-4Li&DK$*E#5^JxBdu{ZW%jQ^o>dHoY2X)6Qb z)t|a84+FupOduZ3}YZ?DTb(49Ttb-QcNT)8e~0l|QvWzOeft+CYMdbkzfzGpf7Xe);o z_G7b{tb32bk*XGfJm)Hqrk)L;c*GmlK;I1BI0Ep~Z5)2Nox=yo&e=k*OR6|SBr=cM z)hPDHE}R}K0rK+Vr4M(AtdwxlUuG~x?3v<78^ArA`h>jmSf8*rTUxY9fVD`TwFCvd z5LiKlAtVxZGQrsM9{~LPDGqOW?|WF=FUUwe3nRM z9v%rmsPTP~8e5>Ada$)yJ+*fIMv;{k1{~Gzu0C@vD&Pi@Oh_fVm?gOk8n89=pfSC0 zCctMdWuRDfeYp<2yP?m`z+j9AyFmZ&Wi+?l;sfS6y$UTdwi2Da07W?f%g$!-$umY) zyr|%B%QG*<lfyGW;vflJ-< z8Vm3+%6U!2DP=!r=UHE1(N2a6`iWJmm?YUcoiCfwWiND2x(GBG9quU5jj0|C5IiVw z5;=)I)o8vp@V)yaTIXSXqS*@PA8HbOYU=X(q!)*=lOYXrx8{GGb3Qi1YKv$}9(0yn z7*osymIVZZ5$Njfg_hPXXm07meLv=fF?O)uJ<&rx@b7zOjEI?&CH|w5<2@@|#7vB6 zP<`80FXcMljK(Pgjcs~R-Nn#VdM@bF=rjx4hlu-?0q zZm^>7lyvw~#fth@DA|{r9gGN_@;r04p_FMo;Bg4n(vnkv&Hgxe^9u|YVl*O*L^SB> z2|!z0H#9YOLPtjr^aetpX*vZ-rXkDh+r2I8#f<0FgzeJZZJKl;ika4D$+{7rs8^Gg zFOVhvDqd?_o(7r~&~(tLXM;3(4kk{XHi<73ju>u4B%*>&eULUF%M$wEZVWEbdSh@Y z(a28}^xh8e?cI)|_KTeC11)kZXY9U6zK%TM~Sm{hsmB=&?^-G7t zsv+<&T5+=GcA)SOcsuri)^SwiWDi(@IhNFGTw=fL@wwN}{pY`Bzf(Vmgjd>4ydAs@s# zzfD!y{nJ*i+bOcg9Hz_`-?2pT2d8`*TgmU>f1tU`Lg)_d{BjiWX~2r7CuNbXK(0t6 zg1)z~-l|H0V03{?Y5-*4N1${bK&L%B?mWR`4WcWw7ms)w)A(Z*wP)_O`wr|(VCkyU z0@`Io${lE!Rx@A=Fe|t3oeV(t^15=3JF{u+45Px#1ZP1_K>z@;j|==^1poj532;bRa{vGh*8l(w*8xH(n|J^K3~Wh6K~!i%)th;c z6xA8PzxTT5*kkrSiGm9XA}#_(jg&$(MKmZ1yDMmdF`8&4`bUyV3Zpp?D=}(PDXUVJ zxymYy8Wwg}ITF0$3L*w10^*4X2*~d2y*s-*M^E?5_q~2IJF|1TyBPOZHP!F?y8FHF zd;N~@_3L4fB}ZFtap&|6odXtB0Ta2VB~sq&)O2vUIGxvQO;(t}pd~_PSc!>foCy$Y z7&x^1`|>`jx%G*FI+d2RPI@=okS}y=HD0y~b6?1SIn}A8=o$xy&dC_1`Rrijff2SK zY+6c_k{}3p11r3dh51vJD>n`*eF90XXMV6sv zwy4X=e)2=9BvI>*H#U?A#$LcH&r+(i3%! zmSxXjOeBofaL9{+Ll*#cBgvn(3;0o^0BmjW`eA?{V|phZhe7~9TrcE#B8Xk?Shs!u zXc$Hy1eF^pn_87;?ow%ADsUj0PyS&HA(ks$0$N@PP!Ew5N9 z5+VOsdrQN1OfRxk@#NQj1N4GQCD{(Xvg4VTQD3TwW&;AoK<4ESO9WrH8%mg`7Td2bLTbx@%Ba_|9Ik9X2V^o4Ji)1KN2Zvn%zhH{Q@@c z%WewcXrLDJBRLzk957%YU|Sg}mx#587;SF^x)TQ;=Cj37jEan%G%_eOTuMb~++)DW z#8xsPoWOk7?;fed*m7g-k)(*^$v=FO;*^r(F#R|R#Bk|_(^SfCHo9&X7`7Pnms|*z z=9Y3gTtxt7bM0d$EG-8HFki}_pz>)|QQ_81W0xRIdkg5aIcF;`FSmo`to6fY$>cCx8 zA$6%B2LpW_@X5{=DOVO#^OQ*&j8An8#;N%d^z=+Dle$EDgda3VP1W`5y>fJ*1kNXq z$#ZF-jKh7WAk=;cLf(%c+}|GO;Ap77rgM;>^K@NGEK`;f@=}?HWcisL#L7c|$S+Dq zhrmIT-H%1ZhY{nXr*{y%9laTUblpTkerP$a=o&fdgM zCkb}N_HgBj=I>zgKD;^cMa~xpvJc1i`>N1hvRkR+Na`d(HBwdAv`**R5^M$^EA8(Nh%B^jM0 zXdiG&UCVDoeh$XB;s^L--@xPN+>B*;yDTbyDCexI`sN2}Zrwiq{mFEkYHTRi9pVa% z#ph!-GqG<}U>o;KIA+A&I~<+Idlqs7 z4wtQJimsxwYOZ62mQ9@VEPms!Yq54M_a5)rf+Ov#hC_UCvSs;9b?WpwSy@peD$0u> z(~quGw~U$g+IR27!&Q{VC04%1^ynVzX?61Rf;L?l6AvoUhKYAnYVtcN#9A&GdmIO= zm{%9V-yP!nlrZ_7Y=8E>HH@*vN|Xq}_MX3TWAv56q;|5srT%&b)~}Tq1*e={Dk+(? zPLhM@O(S-`gvBUh*6S)MnRHHW*l>f^*3xhf&aEvtj3(@6Vl-x%!kWBKvg5h>`HOV1 z9cR@CupcJ19jA}C&Zv8?TZxj>8u5H`@b4J$uq+}Tv1eNC%}FC3(Yjdo_LN_N6X{(T zJuO33+6Tjg_*rFrbKH(Vf|J$@tMmNf@NA46)L!w}>6xP-~#GOv@-J>Y6Rowkj@=7<;&N{ZDK{8w07R+8TJi7h;98gto_Ve zR9T?Sz6MxM<}EC9uoHX-;$F*)30y2ZKBd0tyGo2AAARR@w|RyJhIgRPcTSXpRDaBs zuhJQqi1&j+3^|?fD<#Dtopd?Dxqhv4 zq6QqpT?W>)Mc{HfL67-bBNam*w1Uz7iITDho9NS(2D_cFYD&KQ8iRa_zJJ~LS9J1w zQZo(=S1I(D;MB(KCr(@LE|&ubHU~KIIQp}e;HU3^|JYt7We4Wq#cA#*RrO6zD^*5h zseN0&S*&x6_ZZ7OaVG;KEM7Sqc=cTQk$f;{KoHweXw;WXw7EkLyPO)BZ3iK6@}N4C z4WiSs*rRTXJyIe!r?MwnwSCVwlxUXz2&!(v3eRrr$9~6)rUI)tH|d;+;ab#TI$B`^ z_)r@-dOrddO52ST;yA7ici@P!X)tGS%gm+E$A8F|CGqVCvC!EeQ=i8<_b62y^8X-i z57)}3b0|57+N3&@1`}-?@P>UK@4*OeSSb^s|9xQo0mk$m5g0!DrEhJWfS1_-{{sbD V-kM20l<5Ef002ovPDHLkV1nc6GW7re literal 0 HcmV?d00001 diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..f9498264e8514b710a51b2ea48c5b44182c828fe GIT binary patch literal 22989 zcmY&<1yoeu7w(;5Xekv5X#_+{x?unTky25*q#LARfEffakWf^*1VN;wb4FA`8l-Db z1f(T~o_Tlh_h0Wl*W!ZroU`}0_x?`Yea?Ay+d!L!ij@ig02-Z}H|_!e6#NqkP>_MY zc7uN$fxlpW>N@u*z<*&B4o|`VQ~KOA^8@RT5q}`+SJQ*Rmn{C8rv66WPX0j;d>w(H zpde9KFLyut2R@FX-oDQ18%nGIV0KIAhT6U0$@P|y494H7`^s)o23Zb29VAlq7E^n! zY&xX=?p+9x(B+p*GF%*Ck-gZMJI`hek$&_P3VlTJ0sEZi!-uuwR;gZEp5m*|*N#UA z;KLp?`ol914ew%6CZE@qkqe3Dct0MXBt>$EjSa z))z7gnhg6rL$~zQs`8wdq=gs^veG%_c{4;T=mjr)?-zS|FT;kp2ukJFm+eo(kYB-)K|l8|Io{f@h_0bvZZ`!8fGccq?2lkQ8wu7l046<1^xQEH-VXw#qYtLi%?XSu5w|h*B;wygHunF zfmy)j^Sz3~pUV1(DU&~Y6!7sVd}FY<|HT+8-F#cB$NjEs9HbEuS@K2$KQhwidR||^ z_2F39CPiD-D{2iYaPr!fdXLiyU6pLYm)B)gCUCMrPorSEmXob zH-WY{>hy7OaYmi+S~;_s%l69K0uE$Q>^_Ddl-qJ$hiv~2RmK20XBQ^yWn@Dq{F1!+ zjH12rZh7ZL7zT$yi>oj=RiB%Uy6A(G6Bp=zef|iG&4et{IAlhRv`CNIdtdRR2P_^D z=A!EyQCYb+tB^J2{Q;DsJ|yfGN<0;hBuA8GH|ILJU|g4T2_(7bb6-y{+T#XOXO@_* zzob(NErz$J#ml?(Q{hbZe>QU_>QkW7bCI!Ii513VGfWq=_&)cai|G8h`uHhII_%C& zy1nwzB?)SX&$R>ZQfnYjhf&k1UntDc@^2L1vgyKo_2LiD#mxPMSYm+u%P0F3oVWD? z^*4U8sE#XXV_`|8A{vO`S!D-SXyO`FIi!uBszdY^b=r9eEVX~h-Kx=Nn1Wz$=g-_Q z>_;qG&4q96-R(7tcga(y*zOMKG)=`FSl~CrDUE|oCn*sQFJUT_z|kA07PW*I2#4+yu$doXqTBdxybB#&y}k+g460P*@zoS!`Y)|bY_tLm2P^!u6?EC_MCCMshgpMlh zIio`g)r{$@v-&?)T}kPtCKo^9^&K*}?n8+Lt^jO5qn)Usw%%KF!Y^!dj!0zwLJoY5 zxI-U#b5Oq+C{Y8}s0i6q7AzToejZNtMsm_mw#aXCLCi0k^X^iu^4S(U(L!yt59(eg z`9`X_igh?kiy!k#OCbcLuAD$tUIL{IrHrL4p-)CE2iDYpPl#^hMUp`;S_3bK+ITP9 z!1$IY6L>(UnLVfGopfG}23;lI3v*Rf_57Cm0im=a?z6MllfvI=>-HPs>DsfNwK|fD z-3YKj?pgIb>HL}-hLz^fT33Eb5C1a~hC6mj)@D2rk8rkq9r za+mSQtHQQEhrd3eWAY?fhdUt>aQEf!jX%9md*>_Qk{~1bPtbfN_O~axUb2|j>U-6j^6?>U{L}1Q5U6{{9gfY#N0)9F_r$zf?lSMc%hEO{g@C5A} zvwsS)d|#d8+9Lfy`Z>(BdsF%sJ?iWHoHgwL)4B7<112xe_wJVWeiocQBHwz{CMZ9a zh$5>@dmm)h#grC6f%!c@cbm4S@Ek2NMzsnLX`T_|N=>@~E%|NKs|3qYI3Z}z-sFpF zYv1aR^SpJ}ChyMfTi!SpjN)@=nN}w&uQ(mRoz(Q!dncO@q;Oh{YG0N&12Y zH?XaN<}X>wpVE1!A6^gIxo?F^(~n+$I4XL%*}6)60LrDhCDdkTP@B1y{p0GPlSGy? z0gy?2UhN(?yjt=G^)2nC+9lNA3O!^RGG@d<8 z6s4Szk~wJwl!k<6*W7b;B2xzzo`h*c45%1<%w$WMzWTmBSLnr?VIN2d?}SN^`@m!& zxp}urZyy&0M#`WdlP&e7d+iIi#Jj_CUjxyAv2U=s*R7h88f5evsAt5*>%C*3d01|P zcP*R8g`wZ*r~a0sObEc*v|xqV_q`Hj)E=bdE$T^_Ah7)n+2=>VP9HgMbHcB`C11RQ z+FT?#nKpsaBR9~=NzR)JgreCa6aOIbG+|VWOI(5Z*d+D17{(3PD9woxfuywQRE5-_ zf0UoErSLeUwv{!{v$$KbzH%QLB81u2h8Tr`vC!mPaG1~?+?{@%9`z{*x{4<`h-?uo zIcIu#oo4rDF&|c~m=xZM-Cu~!TDmdO9bdVhKf+djN=HB40X)Z%h31cBjJ>Ea0}6a! zt-bfTWndl`IRS=4&0Ts%VT?nqy!uEKb595qF2K5X&|GmhiidDf&Ue9x~1SdwKeTE zqcw{)xEgX7u@4)R8g|O+h{w2d>+F*n)yXb~gR}3HW31iZz@$2#1D)h^!Zuhg0WQT1 z^Dwmhu^OjQ?QcfE2b(vILL6cmg6;74Gp1E%E_$(KbEJ2q{k(5Dqz4J>{CmH}UOL0; z)P3{j52NZam;S-oJms4R24#77wjaWMF~IH;7SYkAxvux8ake0=W3^z#mVMMjF>*f%LmRL}xM6ZJw zX^t5A%zL5J!_wY~y;FV7`RHn@a+!{Oz217rYcW@SV&mVs=8l$xNezj0jmhrnLRas+ zj;27SoN@su7w2xkA!eA($63DWQ*1+pbN$7h<`>kWPS}p6)V9)8M#6mD7uS41Max}| zmoky#UCY;JS@|vchzy)UXzQ?U%)4T%b!ViB4re9wmMeSxe8KmFuP(H87U9#*yJ&0r&M(cjv4U^-s z=jE}#Vi(^_HP8;f`y~|zS?4p@7g!IOJgz2xMH2s8C&+{pu7WMzR_l--V38;+611A? zzQ>Wa7W`o3b~EBu`(xx{;JmZ;%?>a_jg5QjQ41s)Eaa)Z>fe2 z&Q;}5zbi0YYK%h5(D?E=l~~f8PyFn(hFbB<>`4jUetFa5+~HB**b^~_Y4Ymi`kC@- zfw%1&6p0KhV8j-UN>1-4m-JQ+{ocJ z@8YyTe`+irY1YL=-y_U9?dSNf$ERUa2crouQkwkkGND|g?Be^mg`?V^oqTW|RNDKS zvup{O!*A4_vi%{QG-_V4nG~vVcH6w7G1N#s(02!th};T2e6sIrYm_~)`E4C9Bwy?P ziB}Wp8;3N3tex0~_F4~i*+x56FrVs4t^Xa!Z9#T?DUxnsKNGiMKdBgE&n1F$b5axp z?$ElqC7t*)oGYj1ujMh(Su7`5eZt>YK4}2HpL}SLl?{h1kAD8OU&G|Mq#x}^{jsb4 z+=*|3YL!XhZbwuf%hqZ-PoYUb5YYvLv#;W^3R+(4JQAEEeX1Y`aQeWLEIXa&!wY_5 z#!D{VDlv`kd02qm+sl4q6+W>mlVC`2D-XRC{B;jCDZ}j+gL{_N!SNBVM3YKBrPaGL z!|T+wx)HuX&dam-#+qSg>-1&H6~UwIJ@hc4x3qs`E+4Ir%)=X>_8v{`F)vs8AoQf- zZb-0RhzQqR=y~43wn{!}@o8kNy3oy=&;HdV6dfA_aQGym=d<4STf#@Lf|c>L62@mn zn}o63N3*=G9-*Y=j{Ly)$UhrlbrKCfN9$@~!Q>0?*Y2 zRTDx9K2uNC1l@#>aZuf_B%gVSiW-*Wq*Ew3F{NIb4IikC$Z7c&!(noI6Ga;RiXH`F z0apbiV_mBEIwMP-oH!MPEu|Y@jPdnfa#(`dPLNQbk~VYb^iC1}5nOhCVm)hq|p{432w@tpNmG`$Ll`cRIq{%C@OimWH z?2qlH?p`zy0lu;WOQi5@zd}XvJITTZ`+mvjRD9(CI(=hlCCFe&a_=E(*bk~6PHvm( z7X&n$(qDZqO_IOwM#mq!(n7tpst2>ZNL~@Yl|43ekB!ayzS_NuOmfC+BNhq5gUVzrD?ZJUcV*+d0Kvq zj3-G6EQVYoTMuUBpQ`3JI=Gl{+!Z6a^089-ZPwf`cY z2^LJ6``AGR@wo}it}eJ34sh68%ne)jT!setmbIQjRxY3@$i>tS`n@yXd<%aSN`l4e zX~!zpYKlX#oeOBKILTv`;#mXbi~ELg4qZFH&lW11AtYJeY>C3JutQ8cBb$EKBGvqi?#K;(hu$_|PuXy(%(mW>gJQqNdsImh zRP4#}@eR6johN?DtuGq^y)YHOzj88{AwCZ5s5e&Z>y(rB9_z@-^cwA?`zDl_w%0H? zzAb1$EaH1)Q zQsuvOKM2y8&jSd_+uHa>9Mi9ow8HO??e?ayFac+carSrJYsRR%0RM zY!mWbgLYye1XnQnN6K#xgNC{uc7e-72?5q7d+cjqS*DRs$Kz$moXBuj{BsT=7^Z9; z>prOZvH{ubRC$2N+g{)>yjmt;)rz^hq(2R+r-&&ffds*9VgI#Rwxk8ZLA9 zoYBKSm@+Bt!==6F6GLT=Sy-e?E##T-(#z-~LZDT4Q?SdA1!=#M-pqwE~B1(zb>7oEE2voln zpB%d7n7ru;svdar<+Ap{PQ%i)ii0{UOb{B!^t%D#^>s}h)=NV9&9rSAeWJG(mhJ=SFxd6}WaYaEMQGx_TR5~ryoY?L z=VMtb+qiW7owW!n>KrGL6BnsNr@7K7OKB0GeZ@ao@(m$MRQ0c>o1eCpKDN~p zCB6Aol@NTYkA7g^(tBBai79j^(;ugjn|)|a{wHV2bpM)5a9kaf%s)2em*2Zqj^l5f ztth~<#&w{&LX7dG!6}>v;=<-6sj%O*)5$0VM_Ab)aG3URX2{-?16H%1kFET-eAVmPkGI&h06~5(x(|*k0v$^9LC4Tzet?w3} zC-AgSoVE0KuFjX&O$6j}v`6o_tn+_#Ee$9QDGgtTOj1`1xOfk62@Q%JC&(Zvy)-g; zeZ9Ieb~}A|EV^L1`)w_qrC)Vq+^@25A%)K6k`o$xX7VzMxE~(+r*0zd`tF|YafL${ zs-L)s4Q9P-`RzjwgYQ!%W^vjD{k0D7;Tk#B1Fj=no zGbW#0u)?#dt)(}7Gq}xWUvfa6B?GZPkQR`~B2CO`Ca#ISysp`+Tlfk}6# z#;C^lE)trJTP=5&^6vBY@+Jf}f2^DhG>I3|%uX8atlLySCY=$2Cx6}(j`mASeOc=J zxV=EfOR`_3F8uP6toftjF$}0d6Ugtd3^Ax-ay8uk^%I^3;SN8yl~6;PV|KM1FWZVO zhBUcRyHHcaa_w*~y6>HfPJNnTg9FEYlS~5i>;jY@ukS3m^C<3I4MSyUPpgpm; z4hm@IRB* zt;{)0@r)@Z))5j#T8LxikTOR6FSH{ETxOAMH(ct~U*p<+Sywl$>hIFss}y*lL2Z(w zAc;f0)4prstHGn2^5(@}dysW;9nTMZ%DRYT&d?GkH40F?C*(lRgB;steVlEUIj%bF zzmB^(q`x1JNN+Sg2$FGe>FX_QaB|e&54p25Tk(Nq>!xH<@|19(V#};!02rY%{hgk~ zOL6*Oj!frq1kdi~3VN`{)oA$2(q!F1`uCvW&4byBr}9>qknZnf@Vj~c;poEgu3Jrh zDM5F>5~j!n!Yp-xwAYVUN6^P5oUfZyQ*F)b+_Q)7k7d%#AaI3Y*WU^1%cScz#k*dj zUUdBHll4PktaxpG^eWjo~`ht{KyUS zTAkI=;`DEXK$}I#I2Rxj1voI3-jPMX6_j-lsW_?PWb(Iq6!TW7&5yu|$#_KY@7yjN z?bml~@JOTaM_RYT%4-MJ(Y@%wwP3FOUElXSrZ4?ux>?BdyIc03kJdYz@ZW8#?E7CsKk zDW-y}IuV?+@9WM=1SwVkJ?x^bR3E0py?LF2dBH}GF8Xf7WMwD8v*@*x;wZUb62)&fhCtW&O>*p}YST_f|O=aDi_lKxe(?!40YQLxza+_&# zwR*`+0MaN`;c(+A%r=nW zzotc2Al;(dSNA+;pWUfCWoVM`eTB}-s|hadHX-b-LlS8xcdGZzWa;1%JU5`%%}8yn zU9t$9oSri%#Bp39yA9$LN2YIyW*7Y7_R}@(RPP(kJn48dLNYU2YDXfuk)t7rW^A~>N*&1hYO`@ zb*m?%QYL^N*?Q!t2(_>o!+FTTjeW@Yx*Y0Bz%eFxT z4zDf4Kdu$=wJi5r=R3=Q)b2U73JfVD>eqvVN*R{LlZo<>yZ5Yxse86D z6aW+}LT%%|VrJD8evBqteHHZ*wFPig-Sxg zb#>%ry=%!td_Gyz4zpOL{*{Sm>&PTc#Jl_Mkeg1?w(KbVYd|rH7!bQ=4*TFe=at3y z*2J6SYMd`?>d%Qa1{8q0{CYuK@7J_e){pH{j#<1(wSYMv5ZhM4e0$c75v8d{$Sr(} zAN}}wq{vUZpQ^i9Gtw=1v9evt9R^rp8_H&9dj0(VW)uBVr0wn$4F@h20eWiqW8pNYx%7eTD88I-{aK(V^|atQ=yfYn+k4n#i1ptT zq*9(>V{A|J2hg8o5j7^kGuhPwZr;^S^0r+s z=qWH)8Bt?UBssRF&v?SoVEAnoQ;LZ@^v9N)AqOcJfQ4y^nTSE8IGLsid8%elQ z8K970eNtOEmee-Ysy}&B^#HX*8fe11$xqVG&oDDF5GGN;Lc<0@og2RjZqzN9&9n#N z-4{cULs>3w0MNZ~fb0XXd+y{4=35|5Sw~Y)-y`5g4><9!zlq+s6zrv<4>tB62#{_b zU*G)Hg$$DIyX6fw9=QT1LWUbMqT(ZwG(MlhjcLjvNkX08cW!truixNtyg}fX@`C~3 zc3#J7=m^KlxY>N=!H;7ApQJh$zcpSxh!oO)2JBI?!y%`~e45 zBQLCi(?fwS;#TG_$GFgzt^PM)Slz^cGqmwkn9|(wb06|CX`gGH{!Ez(RgZ6DIQmI% zZ+O44zqK?t%>aC$Bkq@$ukIi}?XD5IKT3ge(3F!Q`QCJ&6l?Qk%U+01|C1By4SSbt z+_ri2iYRJ(H?NuXDF6?5i3_$$o&eXD)*qAy-{MQoIwlh%3QhebL1a$4=c9$5;r?dU zOcOxU>@D@ZuOZWQmjbu~cjvt<{pWXq>aziB7bEY{5{hTQNhTG>CVIZ(t!LQhk7~77 zkuJuu8)f5GfOod!Y=F0YRnRXNqa$kpRuw2v<9({D@8St+N9@gA9i{#>5nIhP`6FFg zNSy~j=0bgB&mOs`AB=#?EVGN8(cC$E?IeRy(fP{UD6s5%p(g_;aq#N5I-TyR)!(PG?_N1=s($NfD#3gWk&&bVI+F=)_~2iQT4tnUG+x+U9ISaLjpCp zGWNVW3mgC27*N~b)@5NPcH_4(6DR4yqO@d_>#`XR%H2HF!!OxoRTJTYkaBK_C?wN5$svt=840ijE4cOb`EU5`%?%+hKsv9S zSbS{{yjGJ_^J}-1pjdU%hxC}wE^M-xh?2hC#(>v1_^}Wn0_n71kKsguX(+{BKZ4$H zK{$e%gLFZ@R&T4yFi59p-YQT4WdbXYP*E+#Z_Ui`x*5bj+}zol8}0wFskj`pU}<&w zac=P97c^NZcj4RPk;%S5_8{`y=hF zb&CVZnqM)-#-uj+38&Ecm>lguctp%o);VA3HT@a!aY+)rd_tji_gEaW@L^)&clX#4 zE$!1Zzp7>e$5HChgmgL0O3=_|yZpzN=1pFS7;Q&Oa&ujCBUjaZw9s+?X$`oGRaLX; z#_u8%eFJC|IFCZ8Z=Za5y&2j)8+r8wi}=8!X9~ST=6tuA#6yr1msw<6#8{jSN;fof zeza007q3YgJ(;n1-W9%2&%1hwnsxUdW=cAV~UHivZhfY{j)+IeEkF=pM)YN95H0iL7|Me5}!T~u+@8fU1 zluQ*Fiv?`iL2OfmXlxqh#mz|;!7af&M1f3FD>n(eU)%B zD#U4Fs^6(4*c&Y%R^*g4oL>|YG;3?ZYO`Jg?Gtm==!9eWkNILpQ%242sz(^&1Wx0E zS0_xxsv5^`U2@?EyZg(abY!ng+FIy|=%A@)2BdUaH}*k*OMCsupnd z-nLMs8CyW70M%uN0A9j@{L4MIw1pP6H+7I56=55F~r+nvF* z>~5@7HaIOVs4dQGD>NEo!AUd)f7+NH=dNwT&!rt?@TrV4B19HN^B8j&7v}FV;X8ty zm=eDmTSH>%e&4=2KJgwtlkJmJ>1UgGkOzAq`$RVWdZFvi4D z??~SgvZUgXkjlwX)-m(3w6V!Cj+~a90x_3_0qb044a5^1{W{;I(W4){V3JK_Nop#I z0kzr5iRbYTf`rL2ak2L?{!V`tF8PY~&|#7H;%2IyXVP>_#ghr?4QoF;1CCrWom_j( zju-Ll799b9Hw5FAgT}0Ll+_R#%%M5Bqb;jb_kyMHr8FpgGHU%nRo?Js0PQzyQ0#@{ z0<*CzbS21P503Ei38n1~=BAE0cY}Vg3$Hud>Yt+Vp-}0dbWSghD z<#h8S1l$cXn`EGwqm|^x*VmiDnb5BM3fjJkk9WMX2l3rb*0C)YE~h0;Goa)YF8S+S zVHHw{@n9V;r0dBUbC7sw9va46r7x+>2RscY$5kj1E~;G^A0rES{=V_IkEV zStTEunDiW&=0X0cy3X;TiW8S-DzR|#Qhsr(meS1qEx6At0cOS;ob1utWte9ZAq5#e z=QfJN{w^H{Sgnsej^|@HnqcjJlK=Mm^ZM&u2S0CwBILf0vXMJ2lyQ+P=#znpT;qcB$*cGj~-x^+NP1-w+FKHvNAB5oabozld9J~NFf{xn%{lG& z9|}z7yt1vn-k&s%iwX7#u&N^uQXNh*iyNr- zCWrry@cP=HTOJ=0Up!JbzAJRZ95*{Z@EkeR{UyWkOzzl%7(uj$eB)%)M3Ad+wncek zI}`$*H(4K^tRFhKP-(xWT;OvnUcRNiMV_#6dS1OmVVS+R_t0^_yV9FUkvvqiSz%M5 z-M^&aFamAl?vFV!rK1ZvaF1CE7jc9<)x}I+=WM=k*sG^G52NbpnyI65PSX%oIzQf$ z)8x#3J3U-Cv^{H>V@iJTwMWQQ#`M-F`Kq#V`KFK5cGw#O-Qqv&i#qNlcy_(5)BD>; zSqUCcR=K=n*^&0^!f8H#wahXDt;&A#Y^k!+f(gNWwfn%G5>v%pC*bFM2eC*ZXO;4e zbnzgoC|UE+Dh5t^!L7k zwU+pTwHfl?-)l1kZ9;9h(MbafQukF`$$Ven7;KFx&7|L_-5u!)uCMw4MA&aLDUlJy??L z%K?2)Ol96i^L^3VY55sHb7%NH!$}Sd16HCWoIDLItMNXZbT|5R5~XL>i7!w zzjJMH>OVkOO;M16aQr>~Fb`M0y=RkHS!|zT9=PR|vunCu<6w{(_R(y;o@fy_d3{z3 z$)Z-zp{K^xd*pBb_BedHMkFLv;P{4gi;T`eu-0Zpv1rlaAUi|*boS4=O2zsRBo76p*0}b!EYS{=2xKzJ%KtUHSx$nJL8S0z9j% z2Kx8NW+_Sh9XOxq^-6|o<1W4wFSSo+HT?Am9$JLE_lZvs^0ZzMknMhw@rz5~@Mew= z4{64C5rRnhmGEv=tH8J>CeX6;@9e*fwB{th99|f$7U=<5og#z<`9hZUG@?-#$rW_K zVSGs?RO{T^{mAnB3x&x++Y1@Qq~a(n$7!hIMSv)6S-^;@?5JYRlUIB?Hm3==jfdld<(B~(@X;Rxn4^RU+;jLU(Rj5aJA(&;wNSD z5&?Fyf`luWWt9OZDI^;xim-g%De?fSI6dI!`HJYMjh-sZ&^{vd`xiA!)vzqVxx)hanguoYcv+BsjvNwM8~B$MrKI_}>$tBz{wT)~vQ zc0ZU5#}E4vyS5u`>t!J-E&G_Y!kVwe&z;kEENr9;=fcNV>{qJ+HnWK(-$Qv7v0B1$ z!6W((eDc9B{)NmZ1%3$L{rXa$g!7d(1J0BO2Ba5HJ&!K$)vb7s&Xsk*6CQJ1P?iaOv1`ibr}u>)s9BBx5dIn6tlqQ0zk zi_|kL{q{V!!LwidOK5;kk0uV4u%v4 zqHf;c{yZxpZt-OF^I{u*5Io6M@`8Wk&wb6qjUc^=f@JXUr+|I=)y#~!YxEuGKyEzZ z2TaOE2Cf-bnKzk#G4CD;q8iG+{Xtn15sPD9m!9;i-lsn>V??oChG7*RC<=V34T1IA zn~*ie;{?V1W>k;C4SnDD1H-Rd=cZnlZE#rlYh3-8-nB`Cn^yI_SQ9X zq|0i`q|0l{mEj1r5`mll@7D9A?sC!SV*<|q68@nj$jgC87oZd%>47W?IJLm1DM3iR zb?c7^11c^61pxKmBYG{}Vc;5#&c_bC`ve0pBgF*rjPITTq*$#(U(F0QIPp%D&tslm z^HpNK1m8#h>b)r@XlM3#6p~_{4qpk#>44Ip0QRbYlzk~9svf5F`56>OyhEj3j?eZ{ zD4(H6d3Pp@{C0qV7s&t=?`yYOb1`tvT<)MG`B$$Df86kL#ETNuM|yQ3|EwBZ>!QNx z+8j6#>SB~LU<~9B5!HHab`z8Bi?VO$EA)Gd%VqiP3{@}J;+t_zYZRB zh%F9f90dSNq3XRoM+LWphCbxs{s65&+@Uk7cT{qKoRuD|pp&N#*B%4YSJ!?zK#2(9 zcjwXFU3|zYeh}n=n8DREJy-Yu!f#C(1P^hx@6jP8B1eUOL4Go6SbGnInM7E!2r$tT zuacd=TVTc_2LgvX8(ja_w)tpt&5<7=0*q0o%XOXk%g5}%d+5cv5^#9MCU4uTE;*2# zDSy!~eYvI2zMr^y=9Qa2t4rT>#Q8E#xbH0xrW>&k z=cI_N5(VG8d;xC^5u107ATMA3G)6@Xgd7=|pa(T$z92J18o+brVfy1;SU{B$^En8S zD*0HFn|irY8^Acvqm8O)Avc8~1WXvPl|&q8IxLmMIX|H|5HKJNH%;L5_-R0PZHCvb!UT z?Y2PfQ2+<8uk}h;_>Gzq-9b)9PlSmw^ga!$khd?r1d>wEnf{Dd2f`jpLQJ*2W8}DX( z$Y&DH3)C1>2|Fg9NjB9g@)SBHy#@BXEupv6WJK>(c%;tR9Euo*3lLq(MSfyL#tzPz zAY`*equwj3PDNtGIPZ5w5*~df2j7FveS4r@)mbjTVnH+|h>YvM`k^!BP1%w^#)ufz z&Z)ye=`cWvmY^2VZ#YK{0&JILhJfP)J+O84q=TGWF^UJUVYqbt>c5vh@+V9#i(EjU z4u+Fzl{o_i4h$4P{YCt$l>4Js>5&YekHU1vzjJ}a2Bl!;x8^>wT*&V^_nJu1gvT}K zjQ^=*qaxI#iKumVV<E? zNQ8iz8`uys@qZ=>#t?SY`a3X0@ErCNw*Tb6vYg$oyzXFsl}L`>-(T_ZpX1K3LVi3 zfCx8&N1!=Tjkpp3%jiR?q%X9kJkGsHwB2yL9s8d&igi7LXBz2)-p>PPrZ9}&hMma* z^ZnEW=`_fLKFn=ucngBa<0I=$PSjSaG4_BcH(^sAmL05 zJ>Ydn0>wV3xZ!}!%-Ny`+L$4zl85RD@ziL*!Cfr!fdqwW9z6rGi%F^!Z?w!?cXL82 zG++_jVK?sZ5IfXr-LdLWmM#uF?x2GSXw#5@>k8f`^4uq5XoVv^h&?q57{3NZUVHKK z^ARa&EOB07+0hIz>^(M;AcJhhQk*E3pCgKbx0mBi6;mGp2X{cKG=7Jx7;^Ix-w$P! zZ)AqZCh}3B<}|U0wtLT6iE~p~$cXBF1ph=N0Mmzgy@9kofog-Z6RLPTPx(~|I6DeM zWT_ImZB-3E6UCr#>g&%!WM45*z)f#pi`4~KPM)}cG3Nrf7j6g8^&DSI1;5Jf!&-T& zlkCQIHNa6DLIFV(BdljlIze?GHL*u4r=-CF^q_LCit=68gr=;l1OPrGF>u?|@XJ|! z_*VH?mX^0Rz$ML4uWzbGq6SLYdazYK0AAJr-%2tZys1cxc?5a^8-=y`pd33J2cW8W zwvf?g9^lbq0BSprYp$x+9xP@6d`w_?%jLdkwX;|Wr;Hc2EcgHEr2vA(NK~1u9UpL? z6)0KOFWp~P-nE4RYZJlCWd~U#j8cMUMQr&zty%;tkAWxvH6yX5t&Vu$xMw{nb^WaL z?qci;$N5!0%*ZLII~r-&T6x62dPn1=efa71iJ^Q6qogy#<`V7#Lbd|Ll!rzj)LZ=5GwAX@v$Q}4^a&Y zU@I?cFkKHw82c%*%MS;*((ShN=WU`#+u#ohD!vuSgAJkB3y=>V$}&x9XkLuH9vzka zBMlN|ZdT!-)u(B|EZE4DUw=WMJ3_T~b4vN^^G z;S(Me*FV;|j|bG28M1qJ)2`o25fzR%^c_+>NB;Qy{MVX9exz@ACs|>}&*hZ}CW>Ec zIUTcIens}Ysp1zo-N}EMEW7`*e;69((RzuqSox=Yoo1LYfnTK-$;P>=ln2h*nEspe z%4*EcaRSUyAZy{qSCyq!yN24#$UdRi5ba!Gw@PHB^~@Zo^XU#Ina%6S5(j-ij3x0w z9NUNw;_w1K!0vGNjvPW9On?mQxrKs*MmqF)y!inYu@tQG8N4&AGC%WbLO-4|+op0m z4^Q~^S2K^5N|uKx3EY*R!xFkz?sKJQ7(NtSmi&94*IOL`J3cs8*vxaj;ZjYX&!TVo zJ$GmbBQ6?2cGBU~AM4P1o!fhSf=J;0C&kwLV0ELQYI=fOV)McODqch-TTSzrZ^Ei? z{3o$&3qAL3t9(8qtZyG&MxITiyeG)3^Op0Gz;Yf+o@g5gYy9st^1bXlQU~g#@vkXY z6Ljlo-8|<7R1vt+{AMX9QUa5W$ zeT{nF#*Zn7@jrLWQp1xrVk;gFG>;IwMm^rfXv^nU2anbz~iyD|dLSF&qH2-r7 zh|ivF_b2mp`m@=xe$RVsgmC45wgjxI@&oMuX`|>bC9pycwEtJ=x2md@3SM;kUvY0K zVSI#ew>+La3QD9C%7TESfFu8_-sZQ8)&l+)3=9h-xQBCoZb9;}%my5-iEJVI%HD*K z-mD~Z?%&Lv6GPZ*W6ib%^LfqB{`0{ibYM!A7{yEfxs7<@110AS4rJwD z_WtV1fx*TM<+G|I^Nn=Y&at93-52aN3ixo}u=fCx|@I@FChQ_MhEbjt5BX zan>~uIh^)Cc8<&wlpmOE5vw7e|8qAzzG|mc>~p;;8@Q)cPScSQNsa1X%{N)2t`LMkf4|7%hE*xXB#B(Yob*_Zs?C z;h&4RJU$Z`AKaseLNBf$b48XOK`4oOu=fuLl@&bAM4>AGEQF`D5wyWA}2?KXr52h9Btv z&%J82gr~6C5Gdgq?HGdP8A;aLy-Ysv za!?CU`1=Zy%PAn~Q1h(nSU#2vU9|t-D4rsZAmcCn`>5b|Lx0cW%K$N<8rZ4xBJ(Y^ z)CSyIxw!unpR<=ks+Ji>E0*Nrq?mS487dP+7xSoKXF0h{tPurK&1Y!WPl*!XrYX)8W$A*Ml#l)U~ zkuFe|ZVewfVsRswm;B$!jaGoN%9`yi#)+qwsq__nt3c@Rkd_VUU>p4t_E0zFaTWai z=D%9=@&Fq=RHzxaWJ({3(fm0?QusU1UN}L$)Nm2~Zt62=gU^=B5)%r*Q{XZl`ajZ* zY@zfMDfO~&HvX?pkfClOM=CDr`M|5PXpLE_r~T{xd&Ej5`1ns3kI~PdieeD8jZh4n zd(82}3z*Pxct@l9bBqElll%m_NKXQi#!&wICfrAK&ULOfL0(xFqxl!D2WouA<_-<8f6pwI%F%(k zYnxx1QrI1mS{WU5(JmmN>s73aOpo_>&Xfg0dg$~rekfP&O$v%%CdtomqHG*`@jF+n z`1Y)fvSg{(;-)SUL$)b~Otl0`C3)L3d@?HNfoWnV@15=qpZI=pEl>2dk=cF@d4ney z%;PqXJ!4Y+3e`&0eQDC}vnly{2Oz&PNe9k$Lnu9Af*LGyxM-3FkjPNZ#sEktU@nh$ zDeRA9gLt|-OOlR&`T~d_km-TW#}g39!tBgBpnHNJ$6OiSxtY9Q1{sV1=vxI^Ji+Vs z!7h+WsiG`W#1Ym&RwY)r$PJjY1g=VhR7@OZ8(K|AvBZ9hDuC`}GqzA{7uzLB$@l6D zXk_T`*B%y_4OnuZ$}M5Xi+Q!ALtd85geG$<;Vd1G-}z>ut)sAi6d{VZmeuHo41x%E z+kPF!z^d^)(8kNk&a&JSHunC85+tE7q(z9ZA7(~$u!;_Y!{NHXs2Ju2#874shFq@- z#Abr=UTrw*MG0mn3!x7OSS{R0nY(N#)O%O?o7zL=ElLLPsE}c(_ZN0b8GwF$^p z+oWW$J8YM-L9UPrCyBMHPV$ol!Y??0{qD9Rs%7RQ9Q%D6IjB-#R}tO2YfZ4m*ZT)kJ@*Nxo)gqA$f!l~b-X6tgB4Nn+MbL0J5Q5T} z+^f+*`~3jSe2x|Dk|Z0@2r_W=+$xxe?lg2YD`|jNJtsvM((wu~P9B;ZH4ktkSmCg1 z;=NcerW!G=o$1m8SL&dp5qPQVBTlJo({zXR=k3yj=qQ)soXpN~^# zyUCF)3=PNcJh>D4=Y93rANMc(=DNOEN>K|sfZx7Xi;+|Oam_-ixLez)dp)=0bzgr` zO~-Sm&#C&Ar$?3C9(Bf_gcsw|V}1-(7p(*hNF|vC?Zcz>p`x5Z@ATWDy*4;Metin% zedFvJICYA$_3k!-Dsp`Z5l|T6)WU464jVHFnmbWY7#E=u`eO?&<86&|T=(_$&S-ss19Ez4{jYZ4RV&_FsDfKrj$A-DB)Q{atOZVumpI7ewmVd4xSh3 zu*X8nb0vA-Aa3{GG41TF%=F>$5-~OYIq%N`%mn?ahX;6TSyi>tTdgJL4B^2As9ZEi z-)A_L))*m9GoMEv&y!eJ=TV*BSLp({;g5NY-(|iH1EI^N&s=mIX1^}Ho~`#Nqp7!Tv-Hf=}Q%8Cz5bqo> zq=31|)wiO|wr2)E+e3d#lAZ0II0JFiD_u6#Efe(bn?3lqME8Hh<6n!bYC}H7A0Lw* zQA*vh0v%FeRC@TobZh)O6b*!E;tDtS?esm`*x&-xWL1U^NX4cVJ5hZlW46-Ri9lB;(%9$Zwbp z&IEq7N+Rl#3rPQv$?05URTh|wXng`_$9ufhDa#-JeD9q>)7(Z{S9FoNbaGS25h`(D zPZ7#e5FnY-`_XXX`-dV#U4(qun-S0zM^xvLtO`wafu

-fE*W0q&ndr>}xd(#}+6 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 literal 0 HcmV?d00001 diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..054371d --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,34 @@ + + +

+
+

Rinkhals Control

+

Manage your {printerName} from the browser.

+

+ This Svelte-driven interface is just a placeholder so you can iterate on the UI. +

+ +
+ +
+

Next Steps

+
    + {#each actions as action} +
  • +

    {action.title}

    +

    {action.description}

    +
  • + {/each} +
+
+
diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..8331592 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,118 @@ +:root { + font-family: 'Space Grotesk', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + color: #f5f5f5; + background: radial-gradient(circle at 10% 20%, #222 0%, #0a0a0a 60%); + min-height: 100vh; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: transparent; +} + +main { + padding: 4rem clamp(1rem, 4vw, 5rem) 6rem; + display: flex; + flex-direction: column; + gap: 3rem; +} + +.hero { + max-width: 720px; +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.2em; + font-size: 0.75rem; + margin-bottom: 0.5rem; + color: #7fd1ff; +} + +h1 { + font-size: clamp(2.5rem, 5vw, 4.5rem); + margin: 0 0 1rem; +} + +.lede { + line-height: 1.6; + color: #c8c8c8; +} + +.cta { + margin-top: 2rem; + display: flex; + gap: 1rem; +} + +.button, +.ghost { + padding: 0.85rem 1.5rem; + border-radius: 999px; + text-decoration: none; + font-weight: 600; + transition: transform 120ms ease, background 150ms ease; +} + +.button { + background: linear-gradient(120deg, #7fd1ff, #5d87ff); + color: #0b0b0b; +} + +.ghost { + border: 1px solid rgba(255, 255, 255, 0.2); + color: #f5f5f5; +} + +.button:hover, +.ghost:hover { + transform: translateY(-2px); +} + +.panel { + backdrop-filter: blur(18px); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 24px; + padding: 2rem; +} + +.panel ul { + list-style: none; + padding: 0; + margin: 1.5rem 0 0; + display: grid; + gap: 1.5rem; +} + +.panel li { + padding-bottom: 1.25rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.panel li:last-child { + border-bottom: none; +} + +h2 { + margin: 0; +} + +h3 { + margin: 0 0 0.5rem; +} + +@media (max-width: 640px) { + main { + padding: 2.5rem 1.25rem 4rem; + } + + .cta { + flex-direction: column; + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..6f0061c --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,12 @@ +import './app.css'; +import { mount } from 'svelte'; +import App from './App.svelte'; + +const app = mount(App, { + target: document.getElementById('app'), + props: { + printerName: 'Kobra' + } +}); + +export default app; diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..21b9399 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { + preprocess: vitePreprocess() +}; diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..924cd29 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import { resolve } from 'path'; + +// Export a Vite config that drops the build artifacts directly in the +// Microdot static directory so the Python dev server can serve them. +export default defineConfig({ + plugins: [svelte()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + build: { + outDir: '../rinkhals-webui/static/dist', + emptyOutDir: true, + sourcemap: true + }, + server: { + port: 5173, + strictPort: true + } +}); diff --git a/frontend/yarn.lock b/frontend/yarn.lock new file mode 100644 index 0000000..5cea51b --- /dev/null +++ b/frontend/yarn.lock @@ -0,0 +1,518 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@esbuild/aix-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" + integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== + +"@esbuild/android-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" + integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== + +"@esbuild/android-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" + integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== + +"@esbuild/android-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" + integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== + +"@esbuild/darwin-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" + integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== + +"@esbuild/darwin-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" + integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== + +"@esbuild/freebsd-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" + integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== + +"@esbuild/freebsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" + integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== + +"@esbuild/linux-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" + integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== + +"@esbuild/linux-arm@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" + integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== + +"@esbuild/linux-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" + integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== + +"@esbuild/linux-loong64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" + integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== + +"@esbuild/linux-mips64el@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" + integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== + +"@esbuild/linux-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" + integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== + +"@esbuild/linux-riscv64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" + integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== + +"@esbuild/linux-s390x@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" + integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== + +"@esbuild/linux-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" + integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== + +"@esbuild/netbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" + integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== + +"@esbuild/openbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" + integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== + +"@esbuild/sunos-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" + integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== + +"@esbuild/win32-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" + integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== + +"@esbuild/win32-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" + integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== + +"@esbuild/win32-x64@0.21.5": + version "0.21.5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" + integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/remapping@^2.3.4": + version "2.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.4.15", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.24": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@rollup/rollup-android-arm-eabi@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz#067cfcd81f1c1bfd92aefe3ad5ef1523549d5052" + integrity sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw== + +"@rollup/rollup-android-arm64@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz#85e39a44034d7d4e4fee2a1616f0bddb85a80517" + integrity sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q== + +"@rollup/rollup-darwin-arm64@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz#17d92fe98f2cc277b91101eb1528b7c0b6c00c54" + integrity sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w== + +"@rollup/rollup-darwin-x64@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz#89ae6c66b1451609bd1f297da9384463f628437d" + integrity sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g== + +"@rollup/rollup-freebsd-arm64@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz#cdbdb9947b26e76c188a31238c10639347413628" + integrity sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ== + +"@rollup/rollup-freebsd-x64@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz#9b1458d07b6e040be16ee36d308a2c9520f7f7cc" + integrity sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg== + +"@rollup/rollup-linux-arm-gnueabihf@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz#1d50ded7c965d5f125f5832c971ad5b287befef7" + integrity sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A== + +"@rollup/rollup-linux-arm-musleabihf@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz#53597e319b7e65990d3bc2a5048097384814c179" + integrity sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw== + +"@rollup/rollup-linux-arm64-gnu@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz#597002909dec198ca4bdccb25f043d32db3d6283" + integrity sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ== + +"@rollup/rollup-linux-arm64-musl@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz#286f0e0f799545ce288bdc5a7c777261fcba3d54" + integrity sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA== + +"@rollup/rollup-linux-loong64-gnu@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz#1fab07fa1a4f8d3697735b996517f1bae0ba101b" + integrity sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg== + +"@rollup/rollup-linux-loong64-musl@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz#efc2cb143d6c067f95205482afb177f78ed9ea3d" + integrity sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA== + +"@rollup/rollup-linux-ppc64-gnu@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz#e8de8bd3463f96b92b7dfb7f151fd80ffe8a937c" + integrity sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw== + +"@rollup/rollup-linux-ppc64-musl@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz#8c508fe28a239da83b3a9da75bcf093186e064b4" + integrity sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg== + +"@rollup/rollup-linux-riscv64-gnu@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz#ff6d51976e0830732880770a9e18553136b8d92b" + integrity sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew== + +"@rollup/rollup-linux-riscv64-musl@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz#325fb35eefc7e81d75478318f0deee1e4a111493" + integrity sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ== + +"@rollup/rollup-linux-s390x-gnu@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz#37410fabb5d3ba4ad34abcfbe9ba9b6288413f30" + integrity sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ== + +"@rollup/rollup-linux-x64-gnu@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz#8ef907a53b2042068fc03fcc6a641e2b02276eca" + integrity sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw== + +"@rollup/rollup-linux-x64-musl@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz#61b9ba09ea219e0174b3f35a6ad2afc94bdd5662" + integrity sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA== + +"@rollup/rollup-openbsd-x64@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz#fc4e54133134c1787d0b016ffdd5aeb22a5effd3" + integrity sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA== + +"@rollup/rollup-openharmony-arm64@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz#959ae225b1eeea0cc5b7c9f88e4834330fb6cd09" + integrity sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ== + +"@rollup/rollup-win32-arm64-msvc@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz#842acd38869fa1cbdbc240c76c67a86f93444c27" + integrity sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing== + +"@rollup/rollup-win32-ia32-msvc@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz#7ab654def4042df44cb29f8ed9d5044e850c66d5" + integrity sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg== + +"@rollup/rollup-win32-x64-gnu@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz#7426cdec1b01d2382ffd5cda83cbdd1c8efb3ca6" + integrity sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ== + +"@rollup/rollup-win32-x64-msvc@4.56.0": + version "4.56.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz#9eec0212732a432c71bde0350bc40b673d15b2db" + integrity sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g== + +"@sveltejs/acorn-typescript@^1.0.5": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz#69c746a7c232094c117c50dedbd1279fc64887b7" + integrity sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA== + +"@sveltejs/vite-plugin-svelte-inspector@^3.0.0-next.0||^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz#006bcab6ea90e09c65459133d4e3eaa6b1e83e28" + integrity sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ== + dependencies: + debug "^4.3.7" + +"@sveltejs/vite-plugin-svelte@^4.0.0": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz#79dfc00377f5456f4c3d95f56817d6486cc0df6c" + integrity sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA== + dependencies: + "@sveltejs/vite-plugin-svelte-inspector" "^3.0.0-next.0||^3.0.0" + debug "^4.3.7" + deepmerge "^4.3.1" + kleur "^4.1.5" + magic-string "^0.30.12" + vitefu "^1.0.3" + +"@types/estree@1.0.8", "@types/estree@^1.0.5", "@types/estree@^1.0.6": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +acorn@^8.12.1: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +aria-query@^5.3.1: + version "5.3.2" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" + integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw== + +axobject-query@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee" + integrity sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ== + +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + +debug@^4.3.7: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +deepmerge@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +devalue@^5.6.2: + version "5.6.2" + resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.6.2.tgz#931e2bb1cc2b299e0f0fb9e4e5be8ebf521a25b8" + integrity sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg== + +esbuild@^0.21.3: + version "0.21.5" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" + integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.21.5" + "@esbuild/android-arm" "0.21.5" + "@esbuild/android-arm64" "0.21.5" + "@esbuild/android-x64" "0.21.5" + "@esbuild/darwin-arm64" "0.21.5" + "@esbuild/darwin-x64" "0.21.5" + "@esbuild/freebsd-arm64" "0.21.5" + "@esbuild/freebsd-x64" "0.21.5" + "@esbuild/linux-arm" "0.21.5" + "@esbuild/linux-arm64" "0.21.5" + "@esbuild/linux-ia32" "0.21.5" + "@esbuild/linux-loong64" "0.21.5" + "@esbuild/linux-mips64el" "0.21.5" + "@esbuild/linux-ppc64" "0.21.5" + "@esbuild/linux-riscv64" "0.21.5" + "@esbuild/linux-s390x" "0.21.5" + "@esbuild/linux-x64" "0.21.5" + "@esbuild/netbsd-x64" "0.21.5" + "@esbuild/openbsd-x64" "0.21.5" + "@esbuild/sunos-x64" "0.21.5" + "@esbuild/win32-arm64" "0.21.5" + "@esbuild/win32-ia32" "0.21.5" + "@esbuild/win32-x64" "0.21.5" + +esm-env@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/esm-env/-/esm-env-1.2.2.tgz#263c9455c55861f41618df31b20cb571fc20b75e" + integrity sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA== + +esrap@^2.2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/esrap/-/esrap-2.2.2.tgz#437163734f470f6191580652388a4dc5e09643f6" + integrity sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.4.15" + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +is-reference@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-3.0.3.tgz#9ef7bf9029c70a67b2152da4adf57c23d718910f" + integrity sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw== + dependencies: + "@types/estree" "^1.0.6" + +kleur@^4.1.5: + version "4.1.5" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780" + integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== + +locate-character@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/locate-character/-/locate-character-3.0.0.tgz#0305c5b8744f61028ef5d01f444009e00779f974" + integrity sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA== + +magic-string@^0.30.11, magic-string@^0.30.12: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +postcss@^8.4.43: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +rollup@^4.20.0: + version "4.56.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.56.0.tgz#65959d13cfbd7e48b8868c05165b1738f0143862" + integrity sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.56.0" + "@rollup/rollup-android-arm64" "4.56.0" + "@rollup/rollup-darwin-arm64" "4.56.0" + "@rollup/rollup-darwin-x64" "4.56.0" + "@rollup/rollup-freebsd-arm64" "4.56.0" + "@rollup/rollup-freebsd-x64" "4.56.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.56.0" + "@rollup/rollup-linux-arm-musleabihf" "4.56.0" + "@rollup/rollup-linux-arm64-gnu" "4.56.0" + "@rollup/rollup-linux-arm64-musl" "4.56.0" + "@rollup/rollup-linux-loong64-gnu" "4.56.0" + "@rollup/rollup-linux-loong64-musl" "4.56.0" + "@rollup/rollup-linux-ppc64-gnu" "4.56.0" + "@rollup/rollup-linux-ppc64-musl" "4.56.0" + "@rollup/rollup-linux-riscv64-gnu" "4.56.0" + "@rollup/rollup-linux-riscv64-musl" "4.56.0" + "@rollup/rollup-linux-s390x-gnu" "4.56.0" + "@rollup/rollup-linux-x64-gnu" "4.56.0" + "@rollup/rollup-linux-x64-musl" "4.56.0" + "@rollup/rollup-openbsd-x64" "4.56.0" + "@rollup/rollup-openharmony-arm64" "4.56.0" + "@rollup/rollup-win32-arm64-msvc" "4.56.0" + "@rollup/rollup-win32-ia32-msvc" "4.56.0" + "@rollup/rollup-win32-x64-gnu" "4.56.0" + "@rollup/rollup-win32-x64-msvc" "4.56.0" + fsevents "~2.3.2" + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +svelte@^5.48.2: + version "5.48.2" + resolved "https://registry.yarnpkg.com/svelte/-/svelte-5.48.2.tgz#18bb2513db79425acb2aa16043eec0e9cd3619ce" + integrity sha512-VPWD+UyoSFZ7Nxix5K/F8yWiKWOiROkLlWYXOZReE0TUycw+58YWB3D6lAKT+57xmN99wRX4H3oZmw0NPy7y3Q== + dependencies: + "@jridgewell/remapping" "^2.3.4" + "@jridgewell/sourcemap-codec" "^1.5.0" + "@sveltejs/acorn-typescript" "^1.0.5" + "@types/estree" "^1.0.5" + acorn "^8.12.1" + aria-query "^5.3.1" + axobject-query "^4.1.0" + clsx "^2.1.1" + devalue "^5.6.2" + esm-env "^1.2.1" + esrap "^2.2.1" + is-reference "^3.0.3" + locate-character "^3.0.0" + magic-string "^0.30.11" + zimmerframe "^1.1.2" + +vite@^5.0.12: + version "5.4.21" + resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.21.tgz#84a4f7c5d860b071676d39ba513c0d598fdc7027" + integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.43" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + +vitefu@^1.0.3: + version "1.1.1" + resolved "https://registry.yarnpkg.com/vitefu/-/vitefu-1.1.1.tgz#c39b7e4c91bf2f6c590fb96e0758f394dff5795b" + integrity sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ== + +zimmerframe@^1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/zimmerframe/-/zimmerframe-1.1.4.tgz#0352b5cafad3ad4526b0a526a9a52d9c040d865b" + integrity sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ== diff --git a/pyrightconfig.json b/pyrightconfig.json index 5e92fca..0dba5f9 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -8,6 +8,14 @@ "ignore": [ "rinkhals-webui/src/microdot" ], + "executionEnvironments": [ + { + "root": ".", + "extraPaths": [ + "rinkhals-webui/src" + ] + } + ], // Micropython type stubs have no source "reportMissingModuleSource": "none" } diff --git a/rinkhals-webui/src/rinkhals_webui/main.py b/rinkhals-webui/src/rinkhals_webui/main.py index 43c57c4..ba7fa70 100644 --- a/rinkhals-webui/src/rinkhals_webui/main.py +++ b/rinkhals-webui/src/rinkhals_webui/main.py @@ -1,20 +1,41 @@ -from microdot import Microdot, Response +import json + +from microdot import Microdot, Response, send_file +from .static import serve_static, frontend_missing_response app = Microdot() -Response.default_content_type = "text/plain" +Response.default_content_type = "text/html" + + +JSON_HEADERS = {"Content-Type": "application/json; charset=UTF-8"} +TEXT_HEADERS = {"Content-Type": "text/plain; charset=UTF-8"}, + + +@app.route("/api/health") +async def health(request): + return Response(json.dumps({"status": "ok"}), headers=JSON_HEADERS) @app.route("/") -def index(_request): - """Return a static greeting so we can verify the Microdot stack.""" +async def index(request): + index = serve_static("index.html") + if index is None: + return frontend_missing_response() + return index + - return "Hello from Rinkhals WebUI!" +@app.route("/") +async def static(request, path): + asset = serve_static(path) + if asset is not None: + return asset + return Response("Not Found", status_code=404, headers=TEXT_HEADERS) def main() -> None: """Start the Microdot development server.""" - app.run(host="0.0.0.0", port=1414, debug=False) + app.run(host="0.0.0.0", port=1414, debug=True) if __name__ == "__main__": diff --git a/rinkhals-webui/src/rinkhals_webui/static.py b/rinkhals-webui/src/rinkhals_webui/static.py new file mode 100644 index 0000000..b8ed067 --- /dev/null +++ b/rinkhals-webui/src/rinkhals_webui/static.py @@ -0,0 +1,39 @@ +from os import path +from pathlib import Path + +from microdot import Response, send_file + + +STATIC_DIR = str((Path(__file__).parent / "../../static/dist").resolve()) + + +def frontend_missing_response() -> Response: + """Return a helpful message when the static bundle is unavailable.""" + + message = ( + "Static frontend bundle not found. Run `yarn install && yarn build` inside " + "the `frontend/` directory before starting Microdot." + ) + + return Response(message, status_code=503, headers={"Content-Type": "text/plain; charset=UTF-8"}) + + +def serve_static(asset_path: str): + """Return a file under the static directory if it exists.""" + + if not STATIC_DIR or not path.isdir(STATIC_DIR): + return None + + relative_path = asset_path.lstrip("/") + candidate = path.normpath(path.join(STATIC_DIR, relative_path)) + dist_prefix = STATIC_DIR if STATIC_DIR.endswith("/") else STATIC_DIR + "/" + + # Ensure requested file stays within dist_root (prefix match on normalized + # paths works because both are absolute and canonicalized). + if candidate != STATIC_DIR and not candidate.startswith(dist_prefix): + return None + + if path.isfile(candidate): + return send_file(candidate) + + return None diff --git a/rinkhals-webui/static/.gitkeep b/rinkhals-webui/static/.gitkeep new file mode 100644 index 0000000..e69de29 From b61cfab2dbc8e39f2f07295cc8bda9421202ec81 Mon Sep 17 00:00:00 2001 From: Bas van Doren Date: Sat, 9 May 2026 18:19:55 +0000 Subject: [PATCH 5/9] Add WIP filament hub frontend --- frontend/index.html | 16 + frontend/package-lock.json | 2057 +++++++++++++++++ frontend/package.json | 14 +- frontend/src/App.svelte | 42 +- frontend/src/app.css | 120 +- .../src/lib/components/ColorPicker.svelte | 137 ++ .../src/lib/components/FilamentHub.svelte | 291 +++ .../lib/components/FilamentSlotDialog.svelte | 124 + .../src/lib/components/ThemeToggle.svelte | 50 + frontend/vite.config.js | 10 +- frontend/yarn.lock | 946 +++++--- 11 files changed, 3283 insertions(+), 524 deletions(-) create mode 100644 frontend/package-lock.json create mode 100644 frontend/src/lib/components/ColorPicker.svelte create mode 100644 frontend/src/lib/components/FilamentHub.svelte create mode 100644 frontend/src/lib/components/FilamentSlotDialog.svelte create mode 100644 frontend/src/lib/components/ThemeToggle.svelte diff --git a/frontend/index.html b/frontend/index.html index 8b074d1..952b872 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,22 @@ Rinkhals WebUI +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a91a096 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2057 @@ +{ + "name": "rinkhals-webui-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rinkhals-webui-frontend", + "version": "0.1.0", + "dependencies": { + "@material-symbols/svg-200": "^0.44.6", + "bits-ui": "^2.18.1" + }, + "devDependencies": { + "@poppanator/sveltekit-svg": "^7.0.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "@zag-js/color-picker": "^1.40.0", + "@zag-js/svelte": "^1.40.0", + "daisyui": "^5.5.19", + "svelte": "^5.48.2", + "tailwindcss": "^4.2.2", + "vite": "^8.0.8" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@internationalized/date": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz", + "integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@material-symbols/svg-200": { + "version": "0.44.6", + "resolved": "https://registry.npmjs.org/@material-symbols/svg-200/-/svg-200-0.44.6.tgz", + "integrity": "sha512-63QzYakNqqFSXbu3Nmd/57ww9xokPXwrgqBhZ9sZGWi2lWbxIFCMr2Fae7msGJz0xXhdiR8rcqTsYB/aNbQD+w==", + "license": "Apache-2.0" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@poppanator/sveltekit-svg": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@poppanator/sveltekit-svg/-/sveltekit-svg-7.0.0.tgz", + "integrity": "sha512-3SD8TLEqpieEW++ebWVm2qYTrlMJJO552uFJALHDNkZP5DFM5Xnq55SX53/d2aDCtjCemvrXhPT9YsduZkcH2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "5.3.0", + "svgo": "^4.0.1" + }, + "peerDependencies": { + "svelte": ">=5.x", + "vite": ">= 6 || >= 7 || >= 8" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", + "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@zag-js/color-picker": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/color-picker/-/color-picker-1.40.0.tgz", + "integrity": "sha512-lT93xd1BlNBbitl2RxST8ARYE6q/HZD5a0QhMIT1RbndB8F4e9j/NxkStgE9f0QqgpC/rO+nKHLoR+H1xs/EkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/anatomy": "1.40.0", + "@zag-js/color-utils": "1.40.0", + "@zag-js/core": "1.40.0", + "@zag-js/dismissable": "1.40.0", + "@zag-js/dom-query": "1.40.0", + "@zag-js/popper": "1.40.0", + "@zag-js/types": "1.40.0", + "@zag-js/utils": "1.40.0" + } + }, + "node_modules/@zag-js/color-picker/node_modules/@zag-js/anatomy": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/anatomy/-/anatomy-1.40.0.tgz", + "integrity": "sha512-oiB4uAaV//L38JluLVPtOHO3xvqambrfrXVOoq4kmNrBv1LLlCmFvrXA2HOR9lakn4ExK27XSUrKhUN7YlKjfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@zag-js/color-picker/node_modules/@zag-js/core": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/core/-/core-1.40.0.tgz", + "integrity": "sha512-0YcqCh7TmhSonkbKM/7NWolxlaQgvvXgqedocW9oeRYiDJIpBZyRqnHPoGAS2XwbBPkCnrqSosxSF5yBjhZpgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.40.0", + "@zag-js/utils": "1.40.0" + } + }, + "node_modules/@zag-js/color-picker/node_modules/@zag-js/dismissable": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/dismissable/-/dismissable-1.40.0.tgz", + "integrity": "sha512-bBkFvPg/zbYn31ZgEfx8not6s2Ekx7zU2sO8tGXb8rYPnHBfGDYEzVQansUStJn0Atzw+y7XR7B3G3u5AFQJKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.40.0", + "@zag-js/interact-outside": "1.40.0", + "@zag-js/utils": "1.40.0" + } + }, + "node_modules/@zag-js/color-picker/node_modules/@zag-js/dom-query": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.40.0.tgz", + "integrity": "sha512-4J3EO2gHpZ1VZiGLuMlH6G1Tsp4gKB8PPt2yKeNQWYGEXyrHUXrvMhRUzv7Z4/2I1s1tnxlFG4F8ovB3kTpz/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.40.0" + } + }, + "node_modules/@zag-js/color-picker/node_modules/@zag-js/interact-outside": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/interact-outside/-/interact-outside-1.40.0.tgz", + "integrity": "sha512-Fws+O4uD9vS0I5KVcf3U2tNjLKvqlv+RExFbTywckDLOCJ145M/pMQWTr1FHil04jk5PFyM1iGfsbom8tozHpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.40.0", + "@zag-js/utils": "1.40.0" + } + }, + "node_modules/@zag-js/color-picker/node_modules/@zag-js/popper": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/popper/-/popper-1.40.0.tgz", + "integrity": "sha512-rCkgqgwlpgMwcnuSVrZK2xXl1Mvptpuw3cZy6rC2C5F3yE1GmWohdts5VkeQNro+sd/xHTdVovOqY6cU9Htj1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6", + "@zag-js/dom-query": "1.40.0", + "@zag-js/utils": "1.40.0" + } + }, + "node_modules/@zag-js/color-picker/node_modules/@zag-js/types": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-1.40.0.tgz", + "integrity": "sha512-LVvxEyqFv/u9SEe5xdivvG2vYb9cCmbkD+5r6s+IGljpDLaRgv4BYyxEh40ri1ai070tL08ZKmoLfx2/xfvY/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "3.2.3" + } + }, + "node_modules/@zag-js/color-picker/node_modules/@zag-js/utils": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/utils/-/utils-1.40.0.tgz", + "integrity": "sha512-XUpqDtXfHe7CySjOhLPLj9H8rxbiFUJAGgmBzNdpsGPP4wx12cpOXrpSjRXZ2kMwooMPz/P7RPDBteto8sqhAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@zag-js/color-utils": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/color-utils/-/color-utils-1.40.0.tgz", + "integrity": "sha512-PZihcGheb5bn0/cEUwozjJjPoKkEwlJNpTA5mUxj/+sOElLaZM+zY2AnGYeMl6w5zIyZZUDoJMIT5rcb5sN87g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/utils": "1.40.0" + } + }, + "node_modules/@zag-js/color-utils/node_modules/@zag-js/utils": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/utils/-/utils-1.40.0.tgz", + "integrity": "sha512-XUpqDtXfHe7CySjOhLPLj9H8rxbiFUJAGgmBzNdpsGPP4wx12cpOXrpSjRXZ2kMwooMPz/P7RPDBteto8sqhAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@zag-js/svelte": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/svelte/-/svelte-1.40.0.tgz", + "integrity": "sha512-OfLiqz8shz2vNrjmx4G5+CM0boSnTLwwemZaQZNRqQ/WHYgE+i1/Ahy8m+wN/59f6ef4orI1J0vmyGr1plNMxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/core": "1.40.0", + "@zag-js/types": "1.40.0", + "@zag-js/utils": "1.40.0" + }, + "peerDependencies": { + "svelte": ">=5" + } + }, + "node_modules/@zag-js/svelte/node_modules/@zag-js/core": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/core/-/core-1.40.0.tgz", + "integrity": "sha512-0YcqCh7TmhSonkbKM/7NWolxlaQgvvXgqedocW9oeRYiDJIpBZyRqnHPoGAS2XwbBPkCnrqSosxSF5yBjhZpgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.40.0", + "@zag-js/utils": "1.40.0" + } + }, + "node_modules/@zag-js/svelte/node_modules/@zag-js/dom-query": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.40.0.tgz", + "integrity": "sha512-4J3EO2gHpZ1VZiGLuMlH6G1Tsp4gKB8PPt2yKeNQWYGEXyrHUXrvMhRUzv7Z4/2I1s1tnxlFG4F8ovB3kTpz/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.40.0" + } + }, + "node_modules/@zag-js/svelte/node_modules/@zag-js/types": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-1.40.0.tgz", + "integrity": "sha512-LVvxEyqFv/u9SEe5xdivvG2vYb9cCmbkD+5r6s+IGljpDLaRgv4BYyxEh40ri1ai070tL08ZKmoLfx2/xfvY/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "3.2.3" + } + }, + "node_modules/@zag-js/svelte/node_modules/@zag-js/utils": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@zag-js/utils/-/utils-1.40.0.tgz", + "integrity": "sha512-XUpqDtXfHe7CySjOhLPLj9H8rxbiFUJAGgmBzNdpsGPP4wx12cpOXrpSjRXZ2kMwooMPz/P7RPDBteto8sqhAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bits-ui": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz", + "integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/daisyui": { + "version": "5.5.19", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", + "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", + "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/svelte": { + "version": "5.48.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.2.tgz", + "integrity": "sha512-VPWD+UyoSFZ7Nxix5K/F8yWiKWOiROkLlWYXOZReE0TUycw+58YWB3D6lAKT+57xmN99wRX4H3oZmw0NPy7y3Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.2", + "esm-env": "^1.2.1", + "esrap": "^2.2.1", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json index 48103f7..c365c57 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,8 +9,18 @@ "preview": "vite preview" }, "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@poppanator/sveltekit-svg": "^7.0.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "@zag-js/color-picker": "^1.40.0", + "@zag-js/svelte": "^1.40.0", + "daisyui": "^5.5.19", "svelte": "^5.48.2", - "vite": "^5.0.12" + "tailwindcss": "^4.2.2", + "vite": "^8.0.8" + }, + "dependencies": { + "@material-symbols/svg-200": "^0.44.6", + "bits-ui": "^2.18.1" } } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 054371d..765d884 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -1,34 +1,18 @@ -
-
-

Rinkhals Control

-

Manage your {printerName} from the browser.

-

- This Svelte-driven interface is just a placeholder so you can iterate on the UI. -

- -
- -
-

Next Steps

-
    - {#each actions as action} -
  • -

    {action.title}

    -

    {action.description}

    -
  • - {/each} -
+ +
+
+
diff --git a/frontend/src/app.css b/frontend/src/app.css index 8331592..af1ce25 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,118 +1,8 @@ -:root { - font-family: 'Space Grotesk', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; - color: #f5f5f5; - background: radial-gradient(circle at 10% 20%, #222 0%, #0a0a0a 60%); - min-height: 100vh; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-height: 100vh; - background: transparent; -} - -main { - padding: 4rem clamp(1rem, 4vw, 5rem) 6rem; - display: flex; - flex-direction: column; - gap: 3rem; -} - -.hero { - max-width: 720px; -} - -.eyebrow { - text-transform: uppercase; - letter-spacing: 0.2em; - font-size: 0.75rem; - margin-bottom: 0.5rem; - color: #7fd1ff; -} - -h1 { - font-size: clamp(2.5rem, 5vw, 4.5rem); - margin: 0 0 1rem; -} - -.lede { - line-height: 1.6; - color: #c8c8c8; +@import 'tailwindcss'; +@plugin "daisyui" { + themes: light --default, dark --prefersdark; } -.cta { - margin-top: 2rem; - display: flex; - gap: 1rem; -} - -.button, -.ghost { - padding: 0.85rem 1.5rem; - border-radius: 999px; - text-decoration: none; - font-weight: 600; - transition: transform 120ms ease, background 150ms ease; -} - -.button { - background: linear-gradient(120deg, #7fd1ff, #5d87ff); - color: #0b0b0b; -} - -.ghost { - border: 1px solid rgba(255, 255, 255, 0.2); - color: #f5f5f5; -} - -.button:hover, -.ghost:hover { - transform: translateY(-2px); -} - -.panel { - backdrop-filter: blur(18px); - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 24px; - padding: 2rem; -} - -.panel ul { - list-style: none; - padding: 0; - margin: 1.5rem 0 0; - display: grid; - gap: 1.5rem; -} - -.panel li { - padding-bottom: 1.25rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); -} - -.panel li:last-child { - border-bottom: none; -} - -h2 { - margin: 0; -} - -h3 { - margin: 0 0 0.5rem; -} - -@media (max-width: 640px) { - main { - padding: 2.5rem 1.25rem 4rem; - } - - .cta { - flex-direction: column; - } +:root { + font-size: 18px; } diff --git a/frontend/src/lib/components/ColorPicker.svelte b/frontend/src/lib/components/ColorPicker.svelte new file mode 100644 index 0000000..c782bd6 --- /dev/null +++ b/frontend/src/lib/components/ColorPicker.svelte @@ -0,0 +1,137 @@ + + +
+ +
+ +
+
+
+
+ + +
+
+
+
+ + +
+ +
+ + + R + + G + + B + + # + +
+
+ + +
+ {#each PRESETS as preset} + + {/each} +
+
+ + diff --git a/frontend/src/lib/components/FilamentHub.svelte b/frontend/src/lib/components/FilamentHub.svelte new file mode 100644 index 0000000..7cf23fe --- /dev/null +++ b/frontend/src/lib/components/FilamentHub.svelte @@ -0,0 +1,291 @@ + + +
+
+ +
+

Filament Hub

+ {#if hub} +
+ {#if hub.temperature != null} + + + {hub.temperature}°C + + {/if} + + {hub.status ?? 'unknown'} + +
+ {/if} +
+ + {#if loading && !hub} +

Loading…

+ {:else if error && !hub} + + {:else if hub} + +
+ {#each hub.slots as slot, i} + {@const SlotIcon = SLOT_ICONS[i] ?? SLOT_ICONS[0]} + + {/each} +
+ + +
+
+

+ + Filament Drying +

+ + {#if hub.dryer.is_active} + + + {:else} + +
+ + +
+ + {/if} +
+ + {#if error} + + {/if} + {/if} +
+
+ + + diff --git a/frontend/src/lib/components/FilamentSlotDialog.svelte b/frontend/src/lib/components/FilamentSlotDialog.svelte new file mode 100644 index 0000000..30e8b2b --- /dev/null +++ b/frontend/src/lib/components/FilamentSlotDialog.svelte @@ -0,0 +1,124 @@ + + + + + + +
+ + Edit Filament Slot {slotIndex + 1} + + +
+ Filament Type + +
+ + + {#if isCustom} +
+ Custom Type Name + +
+ {/if} + + +
+ Filament Color + +
+ + +
+ + +
+
+
+
+
diff --git a/frontend/src/lib/components/ThemeToggle.svelte b/frontend/src/lib/components/ThemeToggle.svelte new file mode 100644 index 0000000..f688fba --- /dev/null +++ b/frontend/src/lib/components/ThemeToggle.svelte @@ -0,0 +1,50 @@ + + +
+ + + +
diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 924cd29..b24a071 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -1,11 +1,13 @@ import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; import { resolve } from 'path'; +import tailwindcss from '@tailwindcss/vite'; +import svg from '@poppanator/sveltekit-svg'; // Export a Vite config that drops the build artifacts directly in the // Microdot static directory so the Python dev server can serve them. export default defineConfig({ - plugins: [svelte()], + plugins: [tailwindcss(), svelte(), svg()], resolve: { alias: { '@': resolve(__dirname, 'src') @@ -17,7 +19,11 @@ export default defineConfig({ sourcemap: true }, server: { + host: "0.0.0.0", port: 5173, - strictPort: true + strictPort: true, + watch: { + usePolling: true, + }, } }); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 5cea51b..4a47e98 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2,132 +2,44 @@ # yarn lockfile v1 -"@esbuild/aix-ppc64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" - integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== - -"@esbuild/android-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" - integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== - -"@esbuild/android-arm@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" - integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== - -"@esbuild/android-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" - integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== - -"@esbuild/darwin-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" - integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== - -"@esbuild/darwin-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" - integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== - -"@esbuild/freebsd-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" - integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== - -"@esbuild/freebsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" - integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== - -"@esbuild/linux-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" - integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== - -"@esbuild/linux-arm@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" - integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== - -"@esbuild/linux-ia32@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" - integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== - -"@esbuild/linux-loong64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" - integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== - -"@esbuild/linux-mips64el@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" - integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== - -"@esbuild/linux-ppc64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" - integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== - -"@esbuild/linux-riscv64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" - integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== - -"@esbuild/linux-s390x@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" - integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== - -"@esbuild/linux-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" - integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== - -"@esbuild/netbsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" - integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== - -"@esbuild/openbsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" - integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== - -"@esbuild/sunos-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" - integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== - -"@esbuild/win32-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" - integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== - -"@esbuild/win32-ia32@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" - integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== - -"@esbuild/win32-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" - integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== +"@floating-ui/core@^1.7.1", "@floating-ui/core@^1.7.5": + version "1.7.5" + resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz" + integrity sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ== + dependencies: + "@floating-ui/utils" "^0.2.11" + +"@floating-ui/dom@^1.7.1", "@floating-ui/dom@^1.7.6": + version "1.7.6" + resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz" + integrity sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ== + dependencies: + "@floating-ui/core" "^1.7.5" + "@floating-ui/utils" "^0.2.11" + +"@floating-ui/utils@^0.2.11": + version "0.2.11" + resolved "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz" + integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg== + +"@internationalized/date@^3.8.1": + version "3.12.0" + resolved "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz" + integrity sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ== + dependencies: + "@swc/helpers" "^0.5.0" "@jridgewell/gen-mapping@^0.3.5": version "0.3.13" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz" integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== dependencies: "@jridgewell/sourcemap-codec" "^1.5.0" "@jridgewell/trace-mapping" "^0.3.24" -"@jridgewell/remapping@^2.3.4": +"@jridgewell/remapping@^2.3.4", "@jridgewell/remapping@^2.3.5": version "2.3.5" - resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" + resolved "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz" integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== dependencies: "@jridgewell/gen-mapping" "^0.3.5" @@ -135,349 +47,588 @@ "@jridgewell/resolve-uri@^3.1.0": version "3.1.2" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.4.15", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": version "1.5.5" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== "@jridgewell/trace-mapping@^0.3.24": version "0.3.31" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== dependencies: "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@rollup/rollup-android-arm-eabi@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz#067cfcd81f1c1bfd92aefe3ad5ef1523549d5052" - integrity sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw== - -"@rollup/rollup-android-arm64@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz#85e39a44034d7d4e4fee2a1616f0bddb85a80517" - integrity sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q== - -"@rollup/rollup-darwin-arm64@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz#17d92fe98f2cc277b91101eb1528b7c0b6c00c54" - integrity sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w== - -"@rollup/rollup-darwin-x64@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz#89ae6c66b1451609bd1f297da9384463f628437d" - integrity sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g== - -"@rollup/rollup-freebsd-arm64@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz#cdbdb9947b26e76c188a31238c10639347413628" - integrity sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ== - -"@rollup/rollup-freebsd-x64@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz#9b1458d07b6e040be16ee36d308a2c9520f7f7cc" - integrity sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg== - -"@rollup/rollup-linux-arm-gnueabihf@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz#1d50ded7c965d5f125f5832c971ad5b287befef7" - integrity sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A== - -"@rollup/rollup-linux-arm-musleabihf@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz#53597e319b7e65990d3bc2a5048097384814c179" - integrity sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw== - -"@rollup/rollup-linux-arm64-gnu@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz#597002909dec198ca4bdccb25f043d32db3d6283" - integrity sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ== - -"@rollup/rollup-linux-arm64-musl@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz#286f0e0f799545ce288bdc5a7c777261fcba3d54" - integrity sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA== - -"@rollup/rollup-linux-loong64-gnu@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz#1fab07fa1a4f8d3697735b996517f1bae0ba101b" - integrity sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg== - -"@rollup/rollup-linux-loong64-musl@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz#efc2cb143d6c067f95205482afb177f78ed9ea3d" - integrity sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA== - -"@rollup/rollup-linux-ppc64-gnu@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz#e8de8bd3463f96b92b7dfb7f151fd80ffe8a937c" - integrity sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw== - -"@rollup/rollup-linux-ppc64-musl@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz#8c508fe28a239da83b3a9da75bcf093186e064b4" - integrity sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg== - -"@rollup/rollup-linux-riscv64-gnu@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz#ff6d51976e0830732880770a9e18553136b8d92b" - integrity sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew== - -"@rollup/rollup-linux-riscv64-musl@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz#325fb35eefc7e81d75478318f0deee1e4a111493" - integrity sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ== - -"@rollup/rollup-linux-s390x-gnu@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz#37410fabb5d3ba4ad34abcfbe9ba9b6288413f30" - integrity sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ== - -"@rollup/rollup-linux-x64-gnu@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz#8ef907a53b2042068fc03fcc6a641e2b02276eca" - integrity sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw== - -"@rollup/rollup-linux-x64-musl@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz#61b9ba09ea219e0174b3f35a6ad2afc94bdd5662" - integrity sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA== - -"@rollup/rollup-openbsd-x64@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz#fc4e54133134c1787d0b016ffdd5aeb22a5effd3" - integrity sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA== - -"@rollup/rollup-openharmony-arm64@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz#959ae225b1eeea0cc5b7c9f88e4834330fb6cd09" - integrity sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ== - -"@rollup/rollup-win32-arm64-msvc@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz#842acd38869fa1cbdbc240c76c67a86f93444c27" - integrity sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing== - -"@rollup/rollup-win32-ia32-msvc@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz#7ab654def4042df44cb29f8ed9d5044e850c66d5" - integrity sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg== - -"@rollup/rollup-win32-x64-gnu@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz#7426cdec1b01d2382ffd5cda83cbdd1c8efb3ca6" - integrity sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ== - -"@rollup/rollup-win32-x64-msvc@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz#9eec0212732a432c71bde0350bc40b673d15b2db" - integrity sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g== +"@material-symbols/svg-200@^0.44.6": + version "0.44.6" + resolved "https://registry.npmjs.org/@material-symbols/svg-200/-/svg-200-0.44.6.tgz" + integrity sha512-63QzYakNqqFSXbu3Nmd/57ww9xokPXwrgqBhZ9sZGWi2lWbxIFCMr2Fae7msGJz0xXhdiR8rcqTsYB/aNbQD+w== + +"@oxc-project/types@=0.124.0": + version "0.124.0" + resolved "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz" + integrity sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg== + +"@poppanator/sveltekit-svg@^7.0.0": + version "7.0.0" + resolved "https://registry.npmjs.org/@poppanator/sveltekit-svg/-/sveltekit-svg-7.0.0.tgz" + integrity sha512-3SD8TLEqpieEW++ebWVm2qYTrlMJJO552uFJALHDNkZP5DFM5Xnq55SX53/d2aDCtjCemvrXhPT9YsduZkcH2w== + dependencies: + "@rollup/pluginutils" "5.3.0" + svgo "^4.0.1" + +"@rolldown/binding-linux-x64-gnu@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz" + integrity sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA== + +"@rolldown/binding-linux-x64-musl@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz" + integrity sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw== + +"@rolldown/pluginutils@1.0.0-rc.15": + version "1.0.0-rc.15" + resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz" + integrity sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g== + +"@rollup/pluginutils@5.3.0": + version "5.3.0" + resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz" + integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^4.0.2" "@sveltejs/acorn-typescript@^1.0.5": version "1.0.8" - resolved "https://registry.yarnpkg.com/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz#69c746a7c232094c117c50dedbd1279fc64887b7" + resolved "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz" integrity sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA== -"@sveltejs/vite-plugin-svelte-inspector@^3.0.0-next.0||^3.0.0": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz#006bcab6ea90e09c65459133d4e3eaa6b1e83e28" - integrity sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ== +"@sveltejs/vite-plugin-svelte@^7.0.0": + version "7.0.0" + resolved "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz" + integrity sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g== + dependencies: + deepmerge "^4.3.1" + magic-string "^0.30.21" + obug "^2.1.0" + vitefu "^1.1.2" + +"@swc/helpers@^0.5.0": + version "0.5.21" + resolved "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz" + integrity sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg== dependencies: - debug "^4.3.7" + tslib "^2.8.0" -"@sveltejs/vite-plugin-svelte@^4.0.0": - version "4.0.4" - resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz#79dfc00377f5456f4c3d95f56817d6486cc0df6c" - integrity sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA== +"@tailwindcss/node@4.2.2": + version "4.2.2" + resolved "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz" + integrity sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA== dependencies: - "@sveltejs/vite-plugin-svelte-inspector" "^3.0.0-next.0||^3.0.0" - debug "^4.3.7" - deepmerge "^4.3.1" - kleur "^4.1.5" - magic-string "^0.30.12" - vitefu "^1.0.3" + "@jridgewell/remapping" "^2.3.5" + enhanced-resolve "^5.19.0" + jiti "^2.6.1" + lightningcss "1.32.0" + magic-string "^0.30.21" + source-map-js "^1.2.1" + tailwindcss "4.2.2" + +"@tailwindcss/oxide-linux-x64-gnu@4.2.2": + version "4.2.2" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz" + integrity sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg== + +"@tailwindcss/oxide-linux-x64-musl@4.2.2": + version "4.2.2" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz" + integrity sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ== + +"@tailwindcss/oxide@4.2.2": + version "4.2.2" + resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz" + integrity sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg== + optionalDependencies: + "@tailwindcss/oxide-android-arm64" "4.2.2" + "@tailwindcss/oxide-darwin-arm64" "4.2.2" + "@tailwindcss/oxide-darwin-x64" "4.2.2" + "@tailwindcss/oxide-freebsd-x64" "4.2.2" + "@tailwindcss/oxide-linux-arm-gnueabihf" "4.2.2" + "@tailwindcss/oxide-linux-arm64-gnu" "4.2.2" + "@tailwindcss/oxide-linux-arm64-musl" "4.2.2" + "@tailwindcss/oxide-linux-x64-gnu" "4.2.2" + "@tailwindcss/oxide-linux-x64-musl" "4.2.2" + "@tailwindcss/oxide-wasm32-wasi" "4.2.2" + "@tailwindcss/oxide-win32-arm64-msvc" "4.2.2" + "@tailwindcss/oxide-win32-x64-msvc" "4.2.2" + +"@tailwindcss/vite@^4.2.2": + version "4.2.2" + resolved "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz" + integrity sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w== + dependencies: + "@tailwindcss/node" "4.2.2" + "@tailwindcss/oxide" "4.2.2" + tailwindcss "4.2.2" + +"@tybys/wasm-util@^0.10.1": + version "0.10.1" + resolved "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + dependencies: + tslib "^2.4.0" -"@types/estree@1.0.8", "@types/estree@^1.0.5", "@types/estree@^1.0.6": +"@types/estree@^1.0.0", "@types/estree@^1.0.5", "@types/estree@^1.0.6": version "1.0.8" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== -acorn@^8.12.1: +"@zag-js/anatomy@1.40.0": + version "1.40.0" + resolved "https://registry.npmjs.org/@zag-js/anatomy/-/anatomy-1.40.0.tgz" + integrity sha512-oiB4uAaV//L38JluLVPtOHO3xvqambrfrXVOoq4kmNrBv1LLlCmFvrXA2HOR9lakn4ExK27XSUrKhUN7YlKjfQ== + +"@zag-js/color-picker@^1.40.0": + version "1.40.0" + resolved "https://registry.npmjs.org/@zag-js/color-picker/-/color-picker-1.40.0.tgz" + integrity sha512-lT93xd1BlNBbitl2RxST8ARYE6q/HZD5a0QhMIT1RbndB8F4e9j/NxkStgE9f0QqgpC/rO+nKHLoR+H1xs/EkA== + dependencies: + "@zag-js/anatomy" "1.40.0" + "@zag-js/color-utils" "1.40.0" + "@zag-js/core" "1.40.0" + "@zag-js/dismissable" "1.40.0" + "@zag-js/dom-query" "1.40.0" + "@zag-js/popper" "1.40.0" + "@zag-js/types" "1.40.0" + "@zag-js/utils" "1.40.0" + +"@zag-js/color-utils@1.40.0": + version "1.40.0" + resolved "https://registry.npmjs.org/@zag-js/color-utils/-/color-utils-1.40.0.tgz" + integrity sha512-PZihcGheb5bn0/cEUwozjJjPoKkEwlJNpTA5mUxj/+sOElLaZM+zY2AnGYeMl6w5zIyZZUDoJMIT5rcb5sN87g== + dependencies: + "@zag-js/utils" "1.40.0" + +"@zag-js/core@1.40.0": + version "1.40.0" + resolved "https://registry.npmjs.org/@zag-js/core/-/core-1.40.0.tgz" + integrity sha512-0YcqCh7TmhSonkbKM/7NWolxlaQgvvXgqedocW9oeRYiDJIpBZyRqnHPoGAS2XwbBPkCnrqSosxSF5yBjhZpgw== + dependencies: + "@zag-js/dom-query" "1.40.0" + "@zag-js/utils" "1.40.0" + +"@zag-js/dismissable@1.40.0": + version "1.40.0" + resolved "https://registry.npmjs.org/@zag-js/dismissable/-/dismissable-1.40.0.tgz" + integrity sha512-bBkFvPg/zbYn31ZgEfx8not6s2Ekx7zU2sO8tGXb8rYPnHBfGDYEzVQansUStJn0Atzw+y7XR7B3G3u5AFQJKw== + dependencies: + "@zag-js/dom-query" "1.40.0" + "@zag-js/interact-outside" "1.40.0" + "@zag-js/utils" "1.40.0" + +"@zag-js/dom-query@1.40.0": + version "1.40.0" + resolved "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.40.0.tgz" + integrity sha512-4J3EO2gHpZ1VZiGLuMlH6G1Tsp4gKB8PPt2yKeNQWYGEXyrHUXrvMhRUzv7Z4/2I1s1tnxlFG4F8ovB3kTpz/Q== + dependencies: + "@zag-js/types" "1.40.0" + +"@zag-js/interact-outside@1.40.0": + version "1.40.0" + resolved "https://registry.npmjs.org/@zag-js/interact-outside/-/interact-outside-1.40.0.tgz" + integrity sha512-Fws+O4uD9vS0I5KVcf3U2tNjLKvqlv+RExFbTywckDLOCJ145M/pMQWTr1FHil04jk5PFyM1iGfsbom8tozHpQ== + dependencies: + "@zag-js/dom-query" "1.40.0" + "@zag-js/utils" "1.40.0" + +"@zag-js/popper@1.40.0": + version "1.40.0" + resolved "https://registry.npmjs.org/@zag-js/popper/-/popper-1.40.0.tgz" + integrity sha512-rCkgqgwlpgMwcnuSVrZK2xXl1Mvptpuw3cZy6rC2C5F3yE1GmWohdts5VkeQNro+sd/xHTdVovOqY6cU9Htj1w== + dependencies: + "@floating-ui/dom" "^1.7.6" + "@zag-js/dom-query" "1.40.0" + "@zag-js/utils" "1.40.0" + +"@zag-js/svelte@^1.40.0": + version "1.40.0" + resolved "https://registry.npmjs.org/@zag-js/svelte/-/svelte-1.40.0.tgz" + integrity sha512-OfLiqz8shz2vNrjmx4G5+CM0boSnTLwwemZaQZNRqQ/WHYgE+i1/Ahy8m+wN/59f6ef4orI1J0vmyGr1plNMxQ== + dependencies: + "@zag-js/core" "1.40.0" + "@zag-js/types" "1.40.0" + "@zag-js/utils" "1.40.0" + +"@zag-js/types@1.40.0": + version "1.40.0" + resolved "https://registry.npmjs.org/@zag-js/types/-/types-1.40.0.tgz" + integrity sha512-LVvxEyqFv/u9SEe5xdivvG2vYb9cCmbkD+5r6s+IGljpDLaRgv4BYyxEh40ri1ai070tL08ZKmoLfx2/xfvY/A== + dependencies: + csstype "3.2.3" + +"@zag-js/utils@1.40.0": + version "1.40.0" + resolved "https://registry.npmjs.org/@zag-js/utils/-/utils-1.40.0.tgz" + integrity sha512-XUpqDtXfHe7CySjOhLPLj9H8rxbiFUJAGgmBzNdpsGPP4wx12cpOXrpSjRXZ2kMwooMPz/P7RPDBteto8sqhAQ== + +acorn@^8.12.1, acorn@^8.9.0: version "8.15.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== aria-query@^5.3.1: version "5.3.2" - resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.2.tgz#93f81a43480e33a338f19163a3d10a50c01dcd59" + resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz" integrity sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw== axobject-query@^4.1.0: version "4.1.0" - resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee" + resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz" integrity sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ== +bits-ui@^2.18.1: + version "2.18.1" + resolved "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz" + integrity sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA== + dependencies: + "@floating-ui/core" "^1.7.1" + "@floating-ui/dom" "^1.7.1" + esm-env "^1.1.2" + runed "^0.35.1" + svelte-toolbelt "^0.10.6" + tabbable "^6.2.0" + +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + clsx@^2.1.1: version "2.1.1" - resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== -debug@^4.3.7: - version "4.4.3" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" - integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== +commander@^11.1.0: + version "11.1.0" + resolved "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz" + integrity sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ== + +css-select@^5.1.0: + version "5.2.2" + resolved "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz" + integrity sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw== + dependencies: + boolbase "^1.0.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" + nth-check "^2.0.1" + +css-tree@^3.0.1: + version "3.2.1" + resolved "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz" + integrity sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA== + dependencies: + mdn-data "2.27.1" + source-map-js "^1.2.1" + +css-tree@~2.2.0: + version "2.2.1" + resolved "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz" + integrity sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA== dependencies: - ms "^2.1.3" + mdn-data "2.0.28" + source-map-js "^1.0.1" + +css-what@^6.1.0: + version "6.2.2" + resolved "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz" + integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== + +csso@^5.0.5: + version "5.0.5" + resolved "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz" + integrity sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ== + dependencies: + css-tree "~2.2.0" + +csstype@3.2.3: + version "3.2.3" + resolved "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + +daisyui@^5.5.19: + version "5.5.19" + resolved "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz" + integrity sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA== deepmerge@^4.3.1: version "4.3.1" - resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== +dequal@^2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + +detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + devalue@^5.6.2: version "5.6.2" - resolved "https://registry.yarnpkg.com/devalue/-/devalue-5.6.2.tgz#931e2bb1cc2b299e0f0fb9e4e5be8ebf521a25b8" + resolved "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz" integrity sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg== -esbuild@^0.21.3: - version "0.21.5" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" - integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== - optionalDependencies: - "@esbuild/aix-ppc64" "0.21.5" - "@esbuild/android-arm" "0.21.5" - "@esbuild/android-arm64" "0.21.5" - "@esbuild/android-x64" "0.21.5" - "@esbuild/darwin-arm64" "0.21.5" - "@esbuild/darwin-x64" "0.21.5" - "@esbuild/freebsd-arm64" "0.21.5" - "@esbuild/freebsd-x64" "0.21.5" - "@esbuild/linux-arm" "0.21.5" - "@esbuild/linux-arm64" "0.21.5" - "@esbuild/linux-ia32" "0.21.5" - "@esbuild/linux-loong64" "0.21.5" - "@esbuild/linux-mips64el" "0.21.5" - "@esbuild/linux-ppc64" "0.21.5" - "@esbuild/linux-riscv64" "0.21.5" - "@esbuild/linux-s390x" "0.21.5" - "@esbuild/linux-x64" "0.21.5" - "@esbuild/netbsd-x64" "0.21.5" - "@esbuild/openbsd-x64" "0.21.5" - "@esbuild/sunos-x64" "0.21.5" - "@esbuild/win32-arm64" "0.21.5" - "@esbuild/win32-ia32" "0.21.5" - "@esbuild/win32-x64" "0.21.5" - -esm-env@^1.2.1: +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.0.1: + version "3.2.2" + resolved "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz" + integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + +enhanced-resolve@^5.19.0: + version "5.20.1" + resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz" + integrity sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.3.0" + +entities@^4.2.0: + version "4.5.0" + resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +esm-env@^1.0.0, esm-env@^1.1.2, esm-env@^1.2.1: version "1.2.2" - resolved "https://registry.yarnpkg.com/esm-env/-/esm-env-1.2.2.tgz#263c9455c55861f41618df31b20cb571fc20b75e" + resolved "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz" integrity sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA== esrap@^2.2.1: version "2.2.2" - resolved "https://registry.yarnpkg.com/esrap/-/esrap-2.2.2.tgz#437163734f470f6191580652388a4dc5e09643f6" + resolved "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz" integrity sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ== dependencies: "@jridgewell/sourcemap-codec" "^1.4.15" -fsevents@~2.3.2, fsevents@~2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +graceful-fs@^4.2.4: + version "4.2.11" + resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +inline-style-parser@0.2.7: + version "0.2.7" + resolved "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz" + integrity sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA== is-reference@^3.0.3: version "3.0.3" - resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-3.0.3.tgz#9ef7bf9029c70a67b2152da4adf57c23d718910f" + resolved "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz" integrity sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw== dependencies: "@types/estree" "^1.0.6" -kleur@^4.1.5: - version "4.1.5" - resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780" - integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== +jiti@^2.6.1, jiti@>=1.21.0: + version "2.6.1" + resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz" + integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== + +lightningcss-linux-x64-gnu@1.32.0: + version "1.32.0" + resolved "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz" + integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA== + +lightningcss-linux-x64-musl@1.32.0: + version "1.32.0" + resolved "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz" + integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg== + +lightningcss@^1.32.0, lightningcss@1.32.0: + version "1.32.0" + resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz" + integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-android-arm64 "1.32.0" + lightningcss-darwin-arm64 "1.32.0" + lightningcss-darwin-x64 "1.32.0" + lightningcss-freebsd-x64 "1.32.0" + lightningcss-linux-arm-gnueabihf "1.32.0" + lightningcss-linux-arm64-gnu "1.32.0" + lightningcss-linux-arm64-musl "1.32.0" + lightningcss-linux-x64-gnu "1.32.0" + lightningcss-linux-x64-musl "1.32.0" + lightningcss-win32-arm64-msvc "1.32.0" + lightningcss-win32-x64-msvc "1.32.0" locate-character@^3.0.0: version "3.0.0" - resolved "https://registry.yarnpkg.com/locate-character/-/locate-character-3.0.0.tgz#0305c5b8744f61028ef5d01f444009e00779f974" + resolved "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz" integrity sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA== -magic-string@^0.30.11, magic-string@^0.30.12: +lz-string@^1.5.0: + version "1.5.0" + resolved "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz" + integrity sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ== + +magic-string@^0.30.11, magic-string@^0.30.21: version "0.30.21" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz" integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== dependencies: "@jridgewell/sourcemap-codec" "^1.5.5" -ms@^2.1.3: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +mdn-data@2.0.28: + version "2.0.28" + resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz" + integrity sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g== + +mdn-data@2.27.1: + version "2.27.1" + resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz" + integrity sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ== nanoid@^3.3.11: version "3.3.11" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== +nth-check@^2.0.1: + version "2.1.1" + resolved "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + +obug@^2.1.0: + version "2.1.1" + resolved "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz" + integrity sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ== + picocolors@^1.1.1: version "1.1.1" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -postcss@^8.4.43: - version "8.5.6" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" - integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== +"picomatch@^3 || ^4", picomatch@^4.0.2, picomatch@^4.0.4: + version "4.0.4" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + +postcss@^8.5.8: + version "8.5.9" + resolved "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz" + integrity sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw== dependencies: nanoid "^3.3.11" picocolors "^1.1.1" source-map-js "^1.2.1" -rollup@^4.20.0: - version "4.56.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.56.0.tgz#65959d13cfbd7e48b8868c05165b1738f0143862" - integrity sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg== +rolldown@1.0.0-rc.15: + version "1.0.0-rc.15" + resolved "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz" + integrity sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g== dependencies: - "@types/estree" "1.0.8" + "@oxc-project/types" "=0.124.0" + "@rolldown/pluginutils" "1.0.0-rc.15" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.56.0" - "@rollup/rollup-android-arm64" "4.56.0" - "@rollup/rollup-darwin-arm64" "4.56.0" - "@rollup/rollup-darwin-x64" "4.56.0" - "@rollup/rollup-freebsd-arm64" "4.56.0" - "@rollup/rollup-freebsd-x64" "4.56.0" - "@rollup/rollup-linux-arm-gnueabihf" "4.56.0" - "@rollup/rollup-linux-arm-musleabihf" "4.56.0" - "@rollup/rollup-linux-arm64-gnu" "4.56.0" - "@rollup/rollup-linux-arm64-musl" "4.56.0" - "@rollup/rollup-linux-loong64-gnu" "4.56.0" - "@rollup/rollup-linux-loong64-musl" "4.56.0" - "@rollup/rollup-linux-ppc64-gnu" "4.56.0" - "@rollup/rollup-linux-ppc64-musl" "4.56.0" - "@rollup/rollup-linux-riscv64-gnu" "4.56.0" - "@rollup/rollup-linux-riscv64-musl" "4.56.0" - "@rollup/rollup-linux-s390x-gnu" "4.56.0" - "@rollup/rollup-linux-x64-gnu" "4.56.0" - "@rollup/rollup-linux-x64-musl" "4.56.0" - "@rollup/rollup-openbsd-x64" "4.56.0" - "@rollup/rollup-openharmony-arm64" "4.56.0" - "@rollup/rollup-win32-arm64-msvc" "4.56.0" - "@rollup/rollup-win32-ia32-msvc" "4.56.0" - "@rollup/rollup-win32-x64-gnu" "4.56.0" - "@rollup/rollup-win32-x64-msvc" "4.56.0" - fsevents "~2.3.2" - -source-map-js@^1.2.1: + "@rolldown/binding-android-arm64" "1.0.0-rc.15" + "@rolldown/binding-darwin-arm64" "1.0.0-rc.15" + "@rolldown/binding-darwin-x64" "1.0.0-rc.15" + "@rolldown/binding-freebsd-x64" "1.0.0-rc.15" + "@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.15" + "@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-arm64-musl" "1.0.0-rc.15" + "@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-x64-gnu" "1.0.0-rc.15" + "@rolldown/binding-linux-x64-musl" "1.0.0-rc.15" + "@rolldown/binding-openharmony-arm64" "1.0.0-rc.15" + "@rolldown/binding-wasm32-wasi" "1.0.0-rc.15" + "@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.15" + "@rolldown/binding-win32-x64-msvc" "1.0.0-rc.15" + +runed@^0.35.1: + version "0.35.1" + resolved "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz" + integrity sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q== + dependencies: + dequal "^2.0.3" + esm-env "^1.0.0" + lz-string "^1.5.0" + +sax@^1.5.0: + version "1.6.0" + resolved "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz" + integrity sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA== + +source-map-js@^1.0.1, source-map-js@^1.2.1: version "1.2.1" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== -svelte@^5.48.2: +style-to-object@^1.0.8: + version "1.0.14" + resolved "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz" + integrity sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw== + dependencies: + inline-style-parser "0.2.7" + +svelte-toolbelt@^0.10.6: + version "0.10.6" + resolved "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz" + integrity sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ== + dependencies: + clsx "^2.1.1" + runed "^0.35.1" + style-to-object "^1.0.8" + +svelte@^5.30.2, svelte@^5.33.0, svelte@^5.46.4, svelte@^5.48.2, svelte@^5.7.0, svelte@>=5, svelte@>=5.x: version "5.48.2" - resolved "https://registry.yarnpkg.com/svelte/-/svelte-5.48.2.tgz#18bb2513db79425acb2aa16043eec0e9cd3619ce" + resolved "https://registry.npmjs.org/svelte/-/svelte-5.48.2.tgz" integrity sha512-VPWD+UyoSFZ7Nxix5K/F8yWiKWOiROkLlWYXOZReE0TUycw+58YWB3D6lAKT+57xmN99wRX4H3oZmw0NPy7y3Q== dependencies: "@jridgewell/remapping" "^2.3.4" @@ -496,23 +647,66 @@ svelte@^5.48.2: magic-string "^0.30.11" zimmerframe "^1.1.2" -vite@^5.0.12: - version "5.4.21" - resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.21.tgz#84a4f7c5d860b071676d39ba513c0d598fdc7027" - integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw== +svgo@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz" + integrity sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w== dependencies: - esbuild "^0.21.3" - postcss "^8.4.43" - rollup "^4.20.0" + commander "^11.1.0" + css-select "^5.1.0" + css-tree "^3.0.1" + css-what "^6.1.0" + csso "^5.0.5" + picocolors "^1.1.1" + sax "^1.5.0" + +tabbable@^6.2.0: + version "6.4.0" + resolved "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz" + integrity sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg== + +tailwindcss@^4.2.2, tailwindcss@4.2.2: + version "4.2.2" + resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz" + integrity sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q== + +tapable@^2.3.0: + version "2.3.2" + resolved "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz" + integrity sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA== + +tinyglobby@^0.2.15: + version "0.2.16" + resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz" + integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + +tslib@^2.4.0, tslib@^2.8.0: + version "2.8.1" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +"vite@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vite@^5.2.0 || ^6 || ^7 || ^8", "vite@^8.0.0-beta.7 || ^8.0.0", vite@^8.0.8, "vite@>= 6 || >= 7 || >= 8": + version "8.0.8" + resolved "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz" + integrity sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw== + dependencies: + lightningcss "^1.32.0" + picomatch "^4.0.4" + postcss "^8.5.8" + rolldown "1.0.0-rc.15" + tinyglobby "^0.2.15" optionalDependencies: fsevents "~2.3.3" -vitefu@^1.0.3: - version "1.1.1" - resolved "https://registry.yarnpkg.com/vitefu/-/vitefu-1.1.1.tgz#c39b7e4c91bf2f6c590fb96e0758f394dff5795b" - integrity sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ== +vitefu@^1.1.2: + version "1.1.3" + resolved "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz" + integrity sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg== zimmerframe@^1.1.2: version "1.1.4" - resolved "https://registry.yarnpkg.com/zimmerframe/-/zimmerframe-1.1.4.tgz#0352b5cafad3ad4526b0a526a9a52d9c040d865b" + resolved "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz" integrity sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ== From 5b5bc6062ecf24080f18950c33e59e20ca4602a4 Mon Sep 17 00:00:00 2001 From: Bas van Doren Date: Sat, 9 May 2026 19:47:09 +0000 Subject: [PATCH 6/9] Add WIP Microdot backend --- .devcontainer/install_micropython_lib.py | 1 + rinkhals-webui/src/rinkhals_webui/ace.py | 137 ++++++++++++ rinkhals-webui/src/rinkhals_webui/apps.py | 200 +++++++++++++++++ rinkhals-webui/src/rinkhals_webui/klippy.py | 69 ++++++ rinkhals-webui/src/rinkhals_webui/main.py | 222 ++++++++++++++++++- rinkhals-webui/src/rinkhals_webui/printer.py | 37 ++++ 6 files changed, 663 insertions(+), 3 deletions(-) create mode 100644 rinkhals-webui/src/rinkhals_webui/ace.py create mode 100644 rinkhals-webui/src/rinkhals_webui/apps.py create mode 100644 rinkhals-webui/src/rinkhals_webui/klippy.py create mode 100644 rinkhals-webui/src/rinkhals_webui/printer.py diff --git a/.devcontainer/install_micropython_lib.py b/.devcontainer/install_micropython_lib.py index dab9421..dbc7a68 100644 --- a/.devcontainer/install_micropython_lib.py +++ b/.devcontainer/install_micropython_lib.py @@ -65,6 +65,7 @@ "venv", "warnings", "zlib", + "github:josverl/micropython-stubs/mip/typing.json", ] diff --git a/rinkhals-webui/src/rinkhals_webui/ace.py b/rinkhals-webui/src/rinkhals_webui/ace.py new file mode 100644 index 0000000..6c6f6fb --- /dev/null +++ b/rinkhals-webui/src/rinkhals_webui/ace.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any, Dict, List, Optional + +from .klippy import KlippyClient, KlippyError + + +_CONFIG_KEYS = ( + "auto_refill", + "runout_detect", +) + +class ACEController: + """Controller for interacting with the ACE filament hub.""" + + def __init__(self, client: Optional[KlippyClient] = None) -> None: + self._client = client or KlippyClient() + + def list_hubs(self) -> List[Dict[str, Any]]: + response = self._client.call("objects/query", params={"objects": {"filament_hub": None}}) + try: + hubs = response["result"]["status"]["filament_hub"]["filament_hubs"] + except KeyError as e: + raise KlippyError("Klippy returned an incomplete ACE status payload") from e + + if not isinstance(hubs, Iterable): + raise KlippyError("Klippy returned an invalid ACE hub list") + + normalized: List[Dict[str, Any]] = [] + for hub in hubs: + if not isinstance(hub, Mapping): + continue + normalized.append(_normalize_hub(hub)) + return normalized + + def fetch_config(self) -> Dict[str, Any]: + response = self._client.call("filament_hub/get_config") + result = response.get("result") + if not isinstance(result, Mapping): + raise KlippyError("Klippy returned an invalid ACE config") + return {key: result.get(key) for key in _CONFIG_KEYS if key in result} + + def update_config(self, updates: Mapping[str, Any]) -> None: + if not updates: + raise ValueError("No configuration values supplied") + + payload = {key: updates[key] for key in _CONFIG_KEYS if key in updates} + if not payload: + raise ValueError("No supported configuration values supplied") + + self._client.call("filament_hub/set_config", params=payload) + + def set_filament(self, ace_id: int, slot: int, material: str, color_hex: str) -> None: + payload = { + "id": ace_id, + "index": slot, + "type": material, + "color": _parse_color_hex(color_hex), + } + self._client.call("filament_hub/set_filament_info", params=payload) + + def start_drying(self, ace_id: int, duration: int, temp: int, fan_speed: Optional[int]) -> None: + if duration <= 0: + raise ValueError("Duration must be greater than zero") + if temp <= 0: + raise ValueError("Temperature must be greater than zero") + if fan_speed is not None and fan_speed < 0: + raise ValueError("Fan speed must be non-negative") + + payload = { + "id": ace_id, + "duration": duration, + "temp": temp, + "fan_speed": 0 if fan_speed is None else max(0, fan_speed), + } + self._client.call("filament_hub/start_drying", params=payload) + + def stop_drying(self, ace_id: int) -> None: + self._client.call("filament_hub/stop_drying", params={"id": ace_id}) + + +def _normalize_hub(hub: Mapping[str, Any]) -> Dict[str, Any]: + slots = [] + for slot in hub.get("slots", []): + slots.append( + { + "index": slot.get("index"), + "status": slot.get("status"), + "type": slot.get("type"), + "color": _normalize_slot_color(slot.get("color")), + } + ) + + dryer_status = hub.get("dryer_status") or {} + dryer = { + "status": dryer_status.get("status"), + "target_temp": dryer_status.get("target_temp"), + "remaining_seconds": dryer_status.get("remain_time"), + "is_active": dryer_status.get("status") not in (None, "stop"), + } + + return { + "id": hub.get("id"), + "status": hub.get("status"), + "temperature": hub.get("temp"), + "slots": slots, + "dryer": dryer, + } + + +def _normalize_slot_color(color: Any) -> Optional[Dict[str, Any]]: + triplet = list(color) + if len(triplet) != 3: + return None + try: + r, g, b = (int(component) for component in triplet) + except (TypeError, ValueError): + return None + return {"rgb": {"r": r, "g": g, "b": b}, "hex": f"#{r:02X}{g:02X}{b:02X}"} + + +def _parse_color_hex(color_hex: str) -> Dict[str, int]: + value = color_hex.strip() + if value.startswith("#"): + value = value[1:] + if len(value) != 6: + raise ValueError("Color must use the #RRGGBB format") + + try: + r = int(value[0:2], 16) + g = int(value[2:4], 16) + b = int(value[4:6], 16) + except ValueError as e: + raise ValueError("Color must be a valid hex value") from e + return {"R": r, "G": g, "B": b} + diff --git a/rinkhals-webui/src/rinkhals_webui/apps.py b/rinkhals-webui/src/rinkhals_webui/apps.py new file mode 100644 index 0000000..eabee6e --- /dev/null +++ b/rinkhals-webui/src/rinkhals_webui/apps.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +import json +import os +import re +import subprocess +from typing import Any, Dict, List + +# Paths mirror the variables in tools.sh and get-entities-apps.sh +_RINKHALS_ROOT = "/useremain/rinkhals/.current" +_RINKHALS_HOME = "/useremain/rinkhals" +_BUILTIN_APPS_DIR = os.path.join(_RINKHALS_ROOT, "home", "rinkhals", "apps") +_USER_APPS_DIR = os.path.join(_RINKHALS_HOME, "apps") + +_SKIP_APPS: frozenset[str] = frozenset({"example"}) + + +class AppsController: + """Mirrors the logic of get-entities-apps.sh and toggle-app.sh.""" + + # ------------------------------------------------------------------ + # Discovery helpers + # ------------------------------------------------------------------ + + def _discover_apps(self) -> List[tuple[str, str, bool]]: + """Return a deduplicated list of (app_name, app_root, is_builtin) tuples.""" + seen: set[str] = set() + apps: List[tuple[str, str, bool]] = [] + + for base_dir, is_builtin in [ + (_BUILTIN_APPS_DIR, True), + (_USER_APPS_DIR, False), + ]: + if not os.path.isdir(base_dir): + continue + try: + entries = sorted(os.listdir(base_dir)) + except OSError: + continue + + for entry in entries: + if entry in _SKIP_APPS or entry.startswith("."): + continue + app_root = os.path.join(base_dir, entry) + if not os.path.isdir(app_root): + continue + if not ( + os.path.isfile(os.path.join(app_root, "app.json")) + and os.path.isfile(os.path.join(app_root, "app.sh")) + ): + continue + if entry not in seen: + seen.add(entry) + apps.append((entry, app_root, is_builtin)) + + return apps + + def _read_metadata(self, app_root: str) -> Dict[str, Any]: + path = os.path.join(app_root, "app.json") + try: + with open(path) as f: + raw = f.read() + # Strip JS-style single-line comments before JSON parsing + raw = re.sub(r"//[^\n]*", "", raw) + data = json.loads(raw) + # Validate schema version (matches get-entities-apps.sh guard) + if str(data.get("$version", "")) != "1": + return {} + return data + except Exception: + return {} + + def _is_enabled(self, app_name: str, app_root: str) -> bool: + """Matches the enabled-check from get-entities-apps.sh: + enabled = (.enabled in app_root OR $RINKHALS_HOME/apps/$app.enabled) AND + NOT (.disabled in app_root OR $RINKHALS_HOME/apps/$app.disabled) + """ + has_enabled = os.path.isfile(os.path.join(app_root, ".enabled")) or os.path.isfile( + os.path.join(_USER_APPS_DIR, f"{app_name}.enabled") + ) + has_disabled = os.path.isfile(os.path.join(app_root, ".disabled")) or os.path.isfile( + os.path.join(_USER_APPS_DIR, f"{app_name}.disabled") + ) + return has_enabled and not has_disabled + + def _get_status(self, app_root: str) -> str: + """Run app.sh status and extract the status word.""" + app_sh = os.path.join(app_root, "app.sh") + try: + result = subprocess.run( + [app_sh, "status"], + capture_output=True, + text=True, + timeout=5, + cwd=app_root, + ) + for line in result.stdout.splitlines(): + if "Status" in line: + parts = line.split() + return parts[1] if len(parts) >= 2 else "unknown" + except Exception: + pass + return "unknown" + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def list_apps(self) -> List[Dict[str, Any]]: + result = [] + for app_name, app_root, _is_builtin in self._discover_apps(): + meta = self._read_metadata(app_root) + if not meta: + continue + result.append( + { + "app": app_name, + "name": meta.get("name", app_name), + "description": meta.get("description", ""), + "version": meta.get("version", ""), + "enabled": self._is_enabled(app_name, app_root), + "status": self._get_status(app_root), + } + ) + return result + + def toggle_app(self, app_name: str) -> bool: + """Toggle an app's enabled state; returns the new enabled state.""" + for name, app_root, is_builtin in self._discover_apps(): + if name == app_name: + return self._do_toggle(app_name, app_root, is_builtin) + raise ValueError(f"App not found: {app_name!r}") + + # ------------------------------------------------------------------ + # Toggle implementation (mirrors toggle-app.sh) + # ------------------------------------------------------------------ + + def _do_toggle(self, app_name: str, app_root: str, is_builtin: bool) -> bool: + currently_enabled = self._is_enabled(app_name, app_root) + os.makedirs(_USER_APPS_DIR, exist_ok=True) + + home_enabled = os.path.join(_USER_APPS_DIR, f"{app_name}.enabled") + home_disabled = os.path.join(_USER_APPS_DIR, f"{app_name}.disabled") + + if is_builtin: + # Builtin: use $RINKHALS_HOME/apps/$app.enabled / $app.disabled + if currently_enabled: + _rm(home_enabled) + _touch(home_disabled) + else: + _rm(home_disabled) + # Only create .enabled flag if the app root has no .enabled already + if not os.path.isfile(os.path.join(app_root, ".enabled")): + _touch(home_enabled) + else: + # User app: use $RINKHALS_HOME/apps/$app/.enabled / .disabled + app_enabled = os.path.join(_USER_APPS_DIR, app_name, ".enabled") + app_disabled = os.path.join(_USER_APPS_DIR, app_name, ".disabled") + _rm(home_enabled) + _rm(home_disabled) + if currently_enabled: + _rm(app_enabled) + _touch(app_disabled) + else: + _rm(app_disabled) + _touch(app_enabled) + + self._run_lifecycle(app_root, "stop" if currently_enabled else "start") + return not currently_enabled + + def _run_lifecycle(self, app_root: str, command: str) -> None: + app_sh = os.path.join(app_root, "app.sh") + if not os.path.isfile(app_sh): + return + try: + subprocess.run( + [app_sh, command], + capture_output=True, + timeout=10, + cwd=app_root, + ) + except Exception: + pass + + +# --------------------------------------------------------------------------- +# File helpers +# --------------------------------------------------------------------------- + + +def _rm(path: str) -> None: + try: + os.remove(path) + except FileNotFoundError: + pass + + +def _touch(path: str) -> None: + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + open(path, "a").close() diff --git a/rinkhals-webui/src/rinkhals_webui/klippy.py b/rinkhals-webui/src/rinkhals_webui/klippy.py new file mode 100644 index 0000000..deb8c66 --- /dev/null +++ b/rinkhals-webui/src/rinkhals_webui/klippy.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import json +import os +import random +import socket +from typing import Any, Dict, Optional + + +_DEFAULT_SOCKET = "/tmp/unix_uds1" +_SOCKET_ENV = "KLIPPY_SOCKET_PATH" +_MESSAGE_TERMINATOR = "\x03" + + +class KlippyError(RuntimeError): + """Raised when Klippy connection fails or returns an error payload.""" + + +class KlippyClient: + """Client to interact with the Klippy API socket.""" + + def __init__(self, socket_path: Optional[str] = None, timeout: float = 3.0) -> None: + self.socket_path = socket_path or os.getenv(_SOCKET_ENV, _DEFAULT_SOCKET) + self.timeout = timeout + + def call(self, method: str, params: Optional[Dict[str, Any]] = None, request_id: Optional[int] = None) -> Dict[str, Any]: + """Send a JSON-RPC command and return the decoded response.""" + + payload = { + "method": method, + "params": params or {}, + "id": request_id or random.randint(1, 1_000_000), + } + response = self._send_request(payload) + if "error" in response: + raise KlippyError(response["error"]) + return response + + def _send_request(self, payload: Dict[str, Any]) -> Dict[str, Any]: + message = json.dumps(payload) + _MESSAGE_TERMINATOR + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.timeout) + + try: + sock.connect(self.socket_path) + sock.sendall(message.encode("utf-8")) + + chunks = [] + while True: + chunk = sock.recv(4096) + if not chunk: + break + chunks.append(chunk) + except OSError as e: + raise KlippyError(f"Unable to reach Klippy on {self.socket_path}: {e}") from e + finally: + try: + sock.close() + except OSError: + pass + + if not chunks: + raise KlippyError("Klippy returned an empty response") + + raw = b"".join(chunks).decode("utf-8").strip().rstrip(_MESSAGE_TERMINATOR) + try: + return json.loads(raw) + except ValueError as exc: + raise KlippyError(f"Klippy returned invalid JSON: {raw}") from exc diff --git a/rinkhals-webui/src/rinkhals_webui/main.py b/rinkhals-webui/src/rinkhals_webui/main.py index ba7fa70..66c2780 100644 --- a/rinkhals-webui/src/rinkhals_webui/main.py +++ b/rinkhals-webui/src/rinkhals_webui/main.py @@ -1,6 +1,14 @@ +import glob import json +import os +import subprocess -from microdot import Microdot, Response, send_file +from microdot import Microdot, Response + +from .ace import ACEController +from .apps import AppsController +from .klippy import KlippyError +from .printer import PrinterController from .static import serve_static, frontend_missing_response app = Microdot() @@ -8,12 +16,220 @@ JSON_HEADERS = {"Content-Type": "application/json; charset=UTF-8"} -TEXT_HEADERS = {"Content-Type": "text/plain; charset=UTF-8"}, +TEXT_HEADERS = {"Content-Type": "text/plain; charset=UTF-8"} + +ace_controller = ACEController() +apps_controller = AppsController() +printer_controller = PrinterController() + +_SCRIPTS_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "scripts")) +_LOG_DIR = "/useremain/rinkhals" + + +def json_response(payload, status_code=200): + return Response(json.dumps(payload), status_code=status_code, headers=JSON_HEADERS) + + +def error_response(message, status_code=500): + return json_response({"error": message}, status_code=status_code) + + +def get_json_body(request): + data = request.json + if data is None or not isinstance(data, dict): + raise ValueError("Expected JSON object body") + return data @app.route("/api/health") async def health(request): - return Response(json.dumps({"status": "ok"}), headers=JSON_HEADERS) + return json_response({"status": "ok"}) + + +@app.route("/api/ace", methods=["GET"]) +async def ace_status(request): + try: + hubs = ace_controller.list_hubs() + except KlippyError as exc: + return error_response(str(exc), status_code=502) + return json_response({"filament_hubs": hubs}) + + +@app.route("/api/ace/config", methods=["GET"]) +async def ace_config(request): + try: + config = ace_controller.fetch_config() + except KlippyError as exc: + return error_response(str(exc), status_code=502) + return json_response(config) + + +@app.route("/api/ace/config", methods=["POST"]) +async def update_ace_config(request): + try: + body = get_json_body(request) + ace_controller.update_config(body) + except ValueError as exc: + return error_response(str(exc), status_code=400) + except KlippyError as exc: + return error_response(str(exc), status_code=502) + return json_response({"status": "ok"}) + + +@app.route("/api/ace//filament", methods=["POST"]) +async def set_filament(request, ace_id): + try: + body = get_json_body(request) + except ValueError as exc: + return error_response(str(exc), status_code=400) + + missing = [field for field in ("slot", "material", "color") if field not in body] + if missing: + return error_response(f"Missing fields: {', '.join(missing)}", status_code=400) + + try: + slot = int(body["slot"]) + material = str(body["material"]) + color = str(body["color"]) + ace_controller.set_filament(ace_id, slot, material, color) + except (ValueError, KlippyError) as exc: + return error_response(str(exc), status_code=400 if isinstance(exc, ValueError) else 502) + + return json_response({"status": "ok"}) + + +@app.route("/api/ace//drying", methods=["POST"]) +async def start_drying(request, ace_id): + try: + body = get_json_body(request) + except ValueError as exc: + return error_response(str(exc), status_code=400) + + missing = [field for field in ("duration", "temp") if field not in body] + if missing: + return error_response(f"Missing fields: {', '.join(missing)}", status_code=400) + + try: + duration = int(body["duration"]) + temp = int(body["temp"]) + fan_speed = body.get("fan_speed") + ace_controller.start_drying(ace_id, duration, temp, None if fan_speed is None else int(fan_speed)) + except (ValueError, KlippyError) as exc: + return error_response(str(exc), status_code=400 if isinstance(exc, ValueError) else 502) + + return json_response({"status": "ok"}) + + +@app.route("/api/ace//drying", methods=["DELETE"]) +async def stop_drying(request, ace_id): + try: + ace_controller.stop_drying(ace_id) + except KlippyError as exc: + return error_response(str(exc), status_code=502) + return json_response({"status": "ok"}) + + +# --------------------------------------------------------------------------- +# LED API +# --------------------------------------------------------------------------- + + +@app.route("/api/led", methods=["POST"]) +async def set_led(request): + try: + body = get_json_body(request) + except ValueError as exc: + return error_response(str(exc), status_code=400) + if "on" not in body: + return error_response("Missing field: on", status_code=400) + try: + printer_controller.set_led(bool(body["on"])) + except KlippyError as exc: + return error_response(str(exc), status_code=502) + return json_response({"on": bool(body["on"])}) + + +# --------------------------------------------------------------------------- +# Apps API +# --------------------------------------------------------------------------- + + +@app.route("/api/apps", methods=["GET"]) +async def list_apps(request): + try: + apps = apps_controller.list_apps() + except Exception as exc: + return error_response(str(exc)) + return json_response({"apps": apps}) + + +@app.route("/api/apps//toggle", methods=["POST"]) +async def toggle_app(request, name): + try: + enabled = apps_controller.toggle_app(name) + except ValueError as exc: + return error_response(str(exc), status_code=404) + except Exception as exc: + return error_response(str(exc)) + return json_response({"app": name, "enabled": enabled}) + + +# --------------------------------------------------------------------------- +# System API +# --------------------------------------------------------------------------- + + +@app.route("/api/system/sync", methods=["POST"]) +async def system_sync(request): + try: + subprocess.run(["sync"], timeout=10, check=True) + except Exception as exc: + return error_response(str(exc)) + return json_response({"status": "ok"}) + + +@app.route("/api/system/reboot", methods=["POST"]) +async def system_reboot(request): + # Popen is non-blocking; the response is delivered before the OS reboots. + subprocess.Popen(["reboot"]) + return json_response({"status": "rebooting"}) + + +@app.route("/api/system/logs", methods=["GET"]) +async def system_logs(request): + parts = [] + for path in sorted(glob.glob(os.path.join(_LOG_DIR, "*.log"))): + try: + with open(path) as f: + content = f.read() + parts.append(f"=== {path} ===\n{content}") + except OSError: + pass + body = "\n\n".join(parts) if parts else "(no log files found)" + return Response(body, status_code=200, headers=TEXT_HEADERS) + + +@app.route("/api/system/update", methods=["GET"]) +async def system_update(request): + script = os.path.join(_SCRIPTS_DIR, "ota-update-info.py") + try: + result = subprocess.run( + ["python3", script], + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "Script exited with non-zero status") + data = json.loads(result.stdout) + return json_response(data) + except Exception as exc: + return json_response({"status": "error", "message": str(exc)}) + + +# --------------------------------------------------------------------------- +# Static / index +# --------------------------------------------------------------------------- @app.route("/") diff --git a/rinkhals-webui/src/rinkhals_webui/printer.py b/rinkhals-webui/src/rinkhals_webui/printer.py new file mode 100644 index 0000000..b8d85a7 --- /dev/null +++ b/rinkhals-webui/src/rinkhals_webui/printer.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import fcntl +import struct +from typing import Optional + +from .klippy import KlippyClient + +_VIDEO_DEVICE = "/dev/video10" +_VIDIOC_G_CTRL = 0xC008561B +_VIDIOC_S_CTRL = 0xC008561C +_V4L2_CID_GAIN = 0x00980913 + + +class PrinterController: + """Controller for printer-level commands.""" + + def __init__(self, client: Optional[KlippyClient] = None) -> None: + self._client = client or KlippyClient() + + def set_led(self, on: bool) -> None: + """Turn the print head LED on (True) or off (False).""" + self._client.call("led/set_led", params={"S": 1 if on else 0}) + + def get_camera_led(self) -> bool: + """Read the camera LED state directly from the V4L2 device.""" + buf = struct.pack("Ii", _V4L2_CID_GAIN, 0) + with open(_VIDEO_DEVICE, "rb") as fd: + result = fcntl.ioctl(fd, _VIDIOC_G_CTRL, buf) + _, value = struct.unpack("Ii", result) + return value != 0 + + def set_camera_led(self, on: bool) -> None: + """Set the camera LED state directly via the V4L2 device.""" + buf = struct.pack("Ii", _V4L2_CID_GAIN, 1 if on else 0) + with open(_VIDEO_DEVICE, "rb") as fd: + fcntl.ioctl(fd, _VIDIOC_S_CTRL, buf) From afb157b164eab1b49f65498687e03ab11a26327f Mon Sep 17 00:00:00 2001 From: Bas van Doren Date: Sun, 10 May 2026 11:34:09 +0000 Subject: [PATCH 7/9] Add full frontend outline --- frontend/index.html | 1 + frontend/package-lock.json | 271 ++++++------------ frontend/package.json | 4 +- frontend/src/App.svelte | 39 ++- frontend/src/app.css | 12 +- .../src/lib/components/ConfirmDialog.svelte | 80 ++++++ .../lib/components/FilamentSlotDialog.svelte | 124 -------- frontend/src/lib/components/Navbar.svelte | 93 ++++++ .../src/lib/components/ThemeToggle.svelte | 26 +- frontend/src/lib/components/ToastItem.svelte | 40 +++ frontend/src/lib/components/Toaster.svelte | 43 +++ .../src/lib/components/apps/AppsTab.svelte | 111 +++++++ .../components/controls/ControlsTab.svelte | 14 + .../{ => controls}/FilamentHub.svelte | 170 ++++++----- .../controls/FilamentSlotDialog.svelte | 112 ++++++++ .../lib/components/controls/Settings.svelte | 164 +++++++++++ .../lib/components/system/SystemTab.svelte | 225 +++++++++++++++ frontend/src/lib/toast.js | 8 + frontend/vite.config.js | 10 +- frontend/yarn.lock | 125 ++++---- rinkhals-webui/src/rinkhals_webui/main.py | 24 ++ 21 files changed, 1203 insertions(+), 493 deletions(-) create mode 100644 frontend/src/lib/components/ConfirmDialog.svelte delete mode 100644 frontend/src/lib/components/FilamentSlotDialog.svelte create mode 100644 frontend/src/lib/components/Navbar.svelte create mode 100644 frontend/src/lib/components/ToastItem.svelte create mode 100644 frontend/src/lib/components/Toaster.svelte create mode 100644 frontend/src/lib/components/apps/AppsTab.svelte create mode 100644 frontend/src/lib/components/controls/ControlsTab.svelte rename frontend/src/lib/components/{ => controls}/FilamentHub.svelte (66%) create mode 100644 frontend/src/lib/components/controls/FilamentSlotDialog.svelte create mode 100644 frontend/src/lib/components/controls/Settings.svelte create mode 100644 frontend/src/lib/components/system/SystemTab.svelte create mode 100644 frontend/src/lib/toast.js diff --git a/frontend/index.html b/frontend/index.html index 952b872..8358edc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,6 +6,7 @@ + Rinkhals WebUI -