diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10db2a5..d025e02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,53 @@ jobs: run: | python3 -m compileall -q tui tdminer.py network/twitch.py core/utils.py + web: + name: Linux Web UI + runs-on: ubuntu-latest + needs: + - validate + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{env.PYTHON_VERSION}} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: web/frontend/package-lock.json + + - name: Install web dependencies + run: | + python3 -m pip install -r requirements-web.txt + npm ci --prefix web/frontend + + - name: Build web frontend + run: | + npm run build --prefix web/frontend + git diff --exit-code -- web/static + + - name: Test web server and installer + run: | + python3 -m unittest discover -s tests -p "test_web*.py" -v + python3 -m unittest discover -s tests -p "test_installer.py" -v + python3 -m compileall -q web tdminer_web.py tui/manager.py + bash -n scripts/install.sh + + - name: Upload install scripts + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + if-no-files-found: error + name: DropForge.Install.Scripts + path: scripts/install.sh + windows: name: Windows runs-on: windows-latest @@ -114,18 +161,18 @@ jobs: - name: Create release folder run: | - $FolderName = 'Twitch Drops Miner' + $FolderName = 'DropForge' New-Item $FolderName -ItemType Directory Copy-Item dist\*.exe $FolderName Copy-Item manual.txt $FolderName - Compress-Archive -Path $FolderName -DestinationPath Twitch.Drops.Miner.Windows.zip + Compress-Archive -Path $FolderName -DestinationPath DropForge.Windows.zip - name: Upload build artifact uses: actions/upload-artifact@v4 with: if-no-files-found: error - name: Twitch.Drops.Miner.Windows - path: Twitch.Drops.Miner.Windows.zip + name: DropForge.Windows + path: DropForge.Windows.zip macos: name: macOS @@ -166,18 +213,18 @@ jobs: - name: Create release folder and Zip run: | - FOLDER="Twitch Drops Miner" + FOLDER="DropForge" mkdir -p "$FOLDER" - cp -r "dist/Twitch Drops Miner (by HimanM).app" "$FOLDER/" + cp -r "dist/DropForge.app" "$FOLDER/" cp manual.txt "$FOLDER/" - zip -r Twitch.Drops.Miner.MacOS.zip "$FOLDER" + zip -r DropForge.MacOS.zip "$FOLDER" - name: Upload build artifact uses: actions/upload-artifact@v4 with: if-no-files-found: error - name: Twitch.Drops.Miner.MacOS - path: Twitch.Drops.Miner.MacOS.zip + name: DropForge.MacOS + path: DropForge.MacOS.zip macos-tui: name: macOS TUI @@ -207,14 +254,14 @@ jobs: run: | mkdir -p tdminer cp dist/tdminer tdminer/ - zip -r Twitch.Drops.Miner.TUI.MacOS.zip tdminer + zip -r DropForge.TUI.MacOS.zip tdminer - name: Upload TUI build artifact uses: actions/upload-artifact@v4 with: if-no-files-found: error - name: Twitch.Drops.Miner.TUI.MacOS - path: Twitch.Drops.Miner.TUI.MacOS.zip + name: DropForge.TUI.MacOS + path: DropForge.TUI.MacOS.zip linux-tui: name: Linux TUI (${{matrix.arch}}) @@ -252,14 +299,14 @@ jobs: run: | mkdir -p tdminer cp dist/tdminer tdminer/ - zip -r Twitch.Drops.Miner.TUI.Linux-${{matrix.arch}}.zip tdminer + zip -r DropForge.TUI.Linux-${{matrix.arch}}.zip tdminer - name: Upload TUI build artifact uses: actions/upload-artifact@v4 with: if-no-files-found: error - name: Twitch.Drops.Miner.TUI.Linux-${{matrix.arch}} - path: Twitch.Drops.Miner.TUI.Linux-${{matrix.arch}}.zip + name: DropForge.TUI.Linux-${{matrix.arch}} + path: DropForge.TUI.Linux-${{matrix.arch}}.zip linux-pyinstaller: name: Linux (PyInstaller) (${{matrix.arch}}) @@ -331,17 +378,17 @@ jobs: - name: Create release folder run: | - folder='Twitch Drops Miner' + folder='DropForge' mkdir "${folder}" cp manual.txt dist/* "${folder}" - 7z a 'Twitch.Drops.Miner.Linux.PyInstaller-${{matrix.arch}}.zip' "${folder}" + 7z a 'DropForge.Linux.PyInstaller-${{matrix.arch}}.zip' "${folder}" - name: Upload build artifact uses: actions/upload-artifact@v4 with: if-no-files-found: error - name: Twitch.Drops.Miner.Linux.PyInstaller-${{matrix.arch}} - path: Twitch.Drops.Miner.Linux.PyInstaller-${{matrix.arch}}.zip + name: DropForge.Linux.PyInstaller-${{matrix.arch}} + path: DropForge.Linux.PyInstaller-${{matrix.arch}}.zip linux-appimage: name: Linux (AppImage) (${{matrix.arch}}) @@ -393,23 +440,24 @@ jobs: - name: Create release folder run: | - folder='Twitch Drops Miner' + folder='DropForge' mkdir "${folder}" cp *.AppImage manual.txt "${folder}" - 7z a 'Twitch.Drops.Miner.Linux.AppImage-${{matrix.arch}}.zip' "${folder}" + 7z a 'DropForge.Linux.AppImage-${{matrix.arch}}.zip' "${folder}" - name: Upload build artifact uses: actions/upload-artifact@v4 with: if-no-files-found: error - name: Twitch.Drops.Miner.Linux.AppImage-${{matrix.arch}} - path: Twitch.Drops.Miner.Linux.AppImage-${{matrix.arch}}.zip + name: DropForge.Linux.AppImage-${{matrix.arch}} + path: DropForge.Linux.AppImage-${{matrix.arch}}.zip update_releases_page: name: Upload builds to Releases if: github.event_name != 'pull_request' needs: - windows + - web - linux-pyinstaller - linux-appimage - linux-tui @@ -442,30 +490,34 @@ jobs: artifactErrorsFailBuild: true artifacts: artifacts/*/* body: | - **Twitch Drops Miner Release** + **DropForge Release** - Build date: `${{steps.vars.outputs.date_now}}` - Reference commit: ${{github.sha}} - **Terminal UI install** + **Linux server or CLI install** - Linux/macOS: + Linux: ```sh curl -fsSL https://raw.githubusercontent.com/HimanM/TwitchDropsMiner/main/scripts/install.sh | sh ``` + Choose the authenticated DropForge Web UI or terminal CLI when prompted. Web installs default to localhost on port `17473`; use Tailscale Serve for private remote access or explicitly select `0.0.0.0` for a trusted LAN. + + macOS uses the same command and installs the terminal release. + On Termux, install prerequisites first: ```sh pkg install python clang curl tar ``` - Run `tdminer tui` for the richer Linux/macOS Textual interface, or `tdminer cli` for the portable prompt-based interface used by Windows/source installs and Termux-style terminals. + Run `tdminer tui` for the richer macOS Textual interface, or `tdminer cli` for the portable prompt-based interface used by source installs and Termux-style terminals. The portable CLI includes a boxed slash-command input. Type `/` for commands, `/priority add ` or `/exclude add ` for game completion, and `/switch ` for channel completion. - The `tdminer` release binaries are self-contained and do not require Linux/macOS users to install Python first. The binary installer needs `curl` and `unzip`, installs to `~/.local/bin` by default, and can be redirected with `TDMINER_INSTALL_DIR=/path/to/bin`. + The Linux installer manages source releases, dependencies, systemd, and persistent data under `~/.local/share/tdminer`. The macOS release binary is self-contained. Launchers install to `~/.local/bin` by default and can be redirected with `TDMINER_INSTALL_DIR=/path/to/bin`. name: "Release v${{steps.vars.outputs.app_version}}" prerelease: false diff --git a/.gitignore b/.gitignore index 2c1ae27..7ea9039 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ __pycache__ /*.exe /*.zip /*.tmp -/Twitch Drops Miner +/DropForge # Dev files *.new /env @@ -29,3 +29,5 @@ settings.json /.venv-linux-build /.venv-tui /graphify-out +/web/frontend/node_modules/ +/web/frontend/*.tsbuildinfo diff --git a/DEPLOY.md b/DEPLOY.md index dc1e6d6..355e2ef 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,6 +1,6 @@ # Deployment Guide -Complete guide to running and building Twitch Drops Miner across every platform and frontend. +Complete guide to running and building DropForge across every platform and frontend. ## Table of Contents @@ -292,7 +292,7 @@ pyinstaller build.spec pack.bat ``` -Output: `dist/Twitch Drops Miner (by HimanM).exe` +Output: `dist/DropForge.exe` The `build.bat` script: 1. Creates/uses the `env/` virtual environment @@ -329,7 +329,7 @@ cd - LD_LIBRARY_PATH=/tmp/libXft/lib xvfb-run --auto-servernum pyinstaller build.spec ``` -Output: `dist/Twitch Drops Miner (by HimanM)` (one-dir) or single executable +Output: `dist/DropForge` (one-dir) or single executable #### Linux GUI Build (AppImage) @@ -362,7 +362,7 @@ pip install -r requirements.txt pyinstaller pyinstaller build.spec --noconfirm ``` -Output: `dist/Twitch Drops Miner (by HimanM).app` +Output: `dist/DropForge.app` ### Building the TUI / CLI Binary diff --git a/README.md b/README.md index 404029d..f9c4c3e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,18 @@ -# Twitch Drops Miner by HimanM +

+ DropForge icon +

-A personalized fork of Twitch Drops Miner focused on desktop use, Linux/headless use, and a portable terminal interface. +# DropForge + +DropForge is HimanM's desktop, server, and terminal Twitch drops miner. The miner advances Twitch drop progress without playing video. It logs in to Twitch, discovers campaigns, picks eligible channels, switches channels when needed, and claims drop progress in the background. ## Features -- GUI build for normal desktop use. -- Terminal UI through `tdminer`. +- DropForge desktop GUI for normal desktop use. +- DropForge Web UI for authenticated, image-rich Linux server control from desktop or mobile browsers. +- DropForge terminal UI through `tdminer`. - Portable CLI mode for headless Linux, SSH sessions, Termux source installs, and terminals where full TUIs are awkward. - Twitch device-code login for terminal sessions. - Saved login through `cookies.jar`. @@ -15,7 +20,7 @@ The miner advances Twitch drop progress without playing video. It logs in to Twi - Game priority and exclusion lists. - Automatic channel selection and manual channel switching. - Optional unlinked-drop farming for the Twitch linked-account display bug. -- Linux/macOS release installer and Termux source installer. +- One-command Linux Web UI or CLI installer with in-place updates and persistent data. ## Desktop Install @@ -27,20 +32,81 @@ Unzip it, run the app, log in, then use the Settings tab to configure priority/e Persistent files such as `cookies.jar`, `settings.json`, `lock.file`, `cache/`, and logs live next to the executable or inside the app bundle, depending on the platform. -## Terminal Install +## Linux Server Install -Linux/macOS: +Run the installer and choose the Web UI or CLI when prompted: ```sh curl -fsSL https://raw.githubusercontent.com/HimanM/TwitchDropsMiner/main/scripts/install.sh | sh ``` -The installer downloads the latest matching `tdminer` release asset and installs it to `~/.local/bin` by default. +The first Web UI install prints a generated admin password and recovery code, then asks whether to bind to localhost or `0.0.0.0`. Localhost is recommended and selected by default. Re-running the same command updates the selected interface while preserving the bind choice, Twitch cookie jar, settings, admin credentials, recovery data, and browser sessions under `~/.local/share/tdminer/data`. + +For private remote access while keeping the app on localhost, install Tailscale and run: + +```sh +tailscale serve --bg http://127.0.0.1:17473 +``` + +To expose the port directly on every network interface instead: + +```sh +curl -fsSL https://raw.githubusercontent.com/HimanM/TwitchDropsMiner/main/scripts/install.sh | TDMINER_HOST=0.0.0.0 sh +``` + +The installer remembers the selected interface. For unattended installs or to switch later: + +```sh +curl -fsSL https://raw.githubusercontent.com/HimanM/TwitchDropsMiner/main/scripts/install.sh | TDMINER_MODE=web sh +curl -fsSL https://raw.githubusercontent.com/HimanM/TwitchDropsMiner/main/scripts/install.sh | TDMINER_MODE=cli sh +``` + +Web service commands: + +```sh +tdminer-web status +tdminer-web start +tdminer-web stop +tdminer-web restart +tdminer-web logs +tdminer-web reset-password +``` + +Stopping `tdminer-web` stops both the Web UI and mining. Starting it again resumes the saved installation and Twitch session. + +To uninstall the default Web UI installation while keeping `~/.local/share/tdminer/data` for a later reinstall: + +```sh +sudo systemctl disable --now tdminer-web.service +sudo rm -f /etc/systemd/system/tdminer-web.service +sudo systemctl daemon-reload +rm -f ~/.local/bin/tdminer-web +rm -rf ~/.local/share/tdminer/releases ~/.local/share/tdminer/current +rm -f ~/.local/share/tdminer/install-mode ~/.local/share/tdminer/web-host +``` + +If the installer opened port `17473` in UFW, remove that rule too: + +```sh +sudo ufw delete allow 17473/tcp +``` + +For a complete uninstall, including the Twitch cookie jar, settings, web password, recovery data, and browser sessions, also run: + +```sh +rm -rf ~/.local/share/tdminer +``` + +The last command permanently deletes all DropForge data. If `TDMINER_INSTALL_DIR` or `TDMINER_APP_DIR` was customized during installation, use those paths instead. + +Passwords and recovery codes are salted and hashed with scrypt. Session cookies are opaque, HttpOnly, and cleared on website logout. Website logout does not stop mining or clear the Twitch cookie jar. Binding to `0.0.0.0` uses HTTP, so use it only on a trusted network; localhost plus Tailscale Serve is recommended for private HTTPS remote access. + +macOS terminal install uses the same command and installs the latest release asset. Custom install location: ```sh -TDMINER_INSTALL_DIR="$HOME/bin" curl -fsSL https://raw.githubusercontent.com/HimanM/TwitchDropsMiner/main/scripts/install.sh | sh +curl -fsSL https://raw.githubusercontent.com/HimanM/TwitchDropsMiner/main/scripts/install.sh | TDMINER_INSTALL_DIR="$HOME/bin" sh ``` Termux: @@ -173,7 +239,7 @@ python tdminer.py cli ## Build Notes -- GUI builds use the original desktop packaging paths. +- Desktop GUI builds are packaged as DropForge applications. - `tdminer` release binaries are built with PyInstaller. - Linux release binaries require compatible glibc Linux systems. - Termux uses source install instead of release binaries. diff --git a/appimage/AppImageBuilder.yml b/appimage/AppImageBuilder.yml index c5ddcbf..494fbb8 100644 --- a/appimage/AppImageBuilder.yml +++ b/appimage/AppImageBuilder.yml @@ -21,7 +21,7 @@ script: # Package the app. - mkdir -p "$TARGET_APPDIR"/usr/{src,share/icons/hicolor/128x128/apps} - cp -r "$SOURCE_DIR/../lang" "$SOURCE_DIR/../icons" "$SOURCE_DIR"/../*.py "$SOURCE_DIR/../core" "$SOURCE_DIR/../models" "$SOURCE_DIR/../network" "$SOURCE_DIR/../gui" "$TARGET_APPDIR/usr/src" - - cp "$SOURCE_DIR/pickaxe.png" "$TARGET_APPDIR/usr/share/icons/hicolor/128x128/apps/io.github.himanm.twitchdropsminer.png" + - cp "$SOURCE_DIR/../icons/dropforge.png" "$TARGET_APPDIR/usr/share/icons/hicolor/128x128/apps/io.github.himanm.dropforge.png" # Create a virtual environment and install up-to-date versions of meson and ninja. - python3 -m venv env && ./env/bin/python3 -m pip install meson ninja @@ -35,9 +35,9 @@ script: AppDir: app_info: - id: io.github.himanm.twitchdropsminer - name: Twitch Drops Miner - icon: io.github.himanm.twitchdropsminer + id: io.github.himanm.dropforge + name: DropForge + icon: io.github.himanm.dropforge version: '{{APP_VERSION}}' exec: usr/bin/python3 exec_args: '${APPDIR}/usr/src/main.py $@' @@ -158,7 +158,7 @@ AppDir: AppImage: arch: '{{ARCH}}' - file_name: Twitch.Drops.Miner-{{ARCH}}.AppImage + file_name: DropForge-{{ARCH}}.AppImage comp: zstd sign-key: None update-information: guess diff --git a/appimage/pickaxe.png b/appimage/pickaxe.png deleted file mode 100644 index 40ceefa..0000000 Binary files a/appimage/pickaxe.png and /dev/null differ diff --git a/build.spec b/build.spec index 08f3c9c..4681a88 100644 --- a/build.spec +++ b/build.spec @@ -31,17 +31,17 @@ upx: bool = False # Use UPX compression (reduces file size, may increase AV det console: bool = False # True if you'd want to add a console window (useful for debugging) one_dir: bool = False # True for one-dir, False for one-file optimize: int | None = None # -1/None/0=none, 1=remove asserts, 2=also remove docstrings -app_name: str = "Twitch Drops Miner (by HimanM)" +app_name: str = "DropForge" # (source_path, dest_path, required) to_add: list[tuple[Path, str, bool]] = [ # icon files - (Path("icons/pickaxe.ico"), "./icons", True), - (Path("icons/active.ico"), "./icons", True), - (Path("icons/idle.ico"), "./icons", True), - (Path("icons/error.ico"), "./icons", True), - (Path("icons/maint.ico"), "./icons", True), + (Path("icons/dropforge.ico"), "./icons", True), + (Path("icons/dropforge-active.ico"), "./icons", True), + (Path("icons/dropforge-idle.ico"), "./icons", True), + (Path("icons/dropforge-error.ico"), "./icons", True), + (Path("icons/dropforge-maintenance.ico"), "./icons", True), # SeleniumWire HTTPS/SSL cert file and key (Path(SITE_PACKAGES_PATH, "seleniumwire/ca.crt"), "./seleniumwire", False), (Path(SITE_PACKAGES_PATH, "seleniumwire/ca.key"), "./seleniumwire", False), @@ -128,7 +128,7 @@ try: console=console, optimize=optimize, exclude_binaries=one_dir, - icon="icons/pickaxe.ico", + icon="icons/dropforge.ico", ) except PermissionError as exc: exc_text: str = format_exc() @@ -149,6 +149,6 @@ if sys.platform == "darwin": app = BUNDLE( source, name=f'{app_name}.app', - icon="icons/pickaxe.ico", - bundle_identifier='com.twitchdrops.miner', + icon="icons/dropforge.ico", + bundle_identifier='io.github.himanm.dropforge', ) diff --git a/core/constants.py b/core/constants.py index 8138d4e..0bfbef3 100644 --- a/core/constants.py +++ b/core/constants.py @@ -129,7 +129,7 @@ def _merge_vars(base_vars: JsonType, vars: JsonType) -> None: ONLINE_DELAY = timedelta(seconds=120) WATCH_INTERVAL = timedelta(seconds=59) # Strings -WINDOW_TITLE = f"Twitch Drops Miner v{__version__} (by HimanM)" +WINDOW_TITLE = f"DropForge v{__version__}" # Logging LOGGING_LEVELS = { 0: logging.ERROR, diff --git a/gui/components.py b/gui/components.py index b97fc5a..7a5548e 100644 --- a/gui/components.py +++ b/gui/components.py @@ -1077,17 +1077,17 @@ def remove(self, channel: Channel): class TrayIcon: - TITLE = "Twitch Drops Miner" + TITLE = "DropForge" def __init__(self, manager: GUIManager, master: ttk.Widget): self._manager = manager self.icon: pystray.Icon | None = None # type: ignore[unused-ignore] self._icon_images: dict[str, Image_module.Image] = { - "pickaxe": Image_module.open(resource_path("icons/pickaxe.ico")), - "active": Image_module.open(resource_path("icons/active.ico")), - "idle": Image_module.open(resource_path("icons/idle.ico")), - "error": Image_module.open(resource_path("icons/error.ico")), - "maint": Image_module.open(resource_path("icons/maint.ico")), + "pickaxe": Image_module.open(resource_path("icons/dropforge.ico")), + "active": Image_module.open(resource_path("icons/dropforge-active.ico")), + "idle": Image_module.open(resource_path("icons/dropforge-idle.ico")), + "error": Image_module.open(resource_path("icons/dropforge-error.ico")), + "maint": Image_module.open(resource_path("icons/dropforge-maintenance.ico")), } self._icon_state: str = "pickaxe" self._button = ttk.Button(master, command=self.minimize, text=_("gui", "tray", "minimize")) @@ -1146,7 +1146,7 @@ def bridge(func): pystray.MenuItem(_("gui", "tray", "quit"), bridge(self.quit)), ) self.icon = pystray.Icon( - "twitch_miner", self._icon_images[self._icon_state], self.get_title(drop), menu + "dropforge", self._icon_images[self._icon_state], self.get_title(drop), menu ) # self.icon.run_detached() loop.run_in_executor(None, self.icon.run) @@ -1569,7 +1569,7 @@ class _SettingsVars(TypedDict): class SettingsPanel: - AUTOSTART_NAME: str = "TwitchDropsMiner" + AUTOSTART_NAME: str = "DropForge" AUTOSTART_KEY: str = "HKCU/Software/Microsoft/Windows/CurrentVersion/Run" @cached_property @@ -1963,7 +1963,7 @@ def update_autostart(self) -> None: f""" [Desktop Entry] Type=Application - Name=Twitch Drops Miner + Name=DropForge Description=Mine timed drops on Twitch Exec=sh -c '{self._get_autostart_path()}' """ @@ -2221,7 +2221,7 @@ def __init__(self, twitch: Twitch): # withdraw immediately to prevent the window from flashing self._root.withdraw() # root.resizable(False, True) - set_root_icon(root, resource_path("icons/pickaxe.ico")) + set_root_icon(root, resource_path("icons/dropforge.ico")) root.title(WINDOW_TITLE) # window title root.bind_all("", self.unfocus) # pressing ESC unfocuses selection # Image cache for displaying images diff --git a/icons/active.ico b/icons/active.ico deleted file mode 100644 index a0e3471..0000000 Binary files a/icons/active.ico and /dev/null differ diff --git a/icons/dropforge-active.ico b/icons/dropforge-active.ico new file mode 100644 index 0000000..6198605 Binary files /dev/null and b/icons/dropforge-active.ico differ diff --git a/icons/dropforge-active.png b/icons/dropforge-active.png new file mode 100644 index 0000000..fcdba91 Binary files /dev/null and b/icons/dropforge-active.png differ diff --git a/icons/dropforge-error.ico b/icons/dropforge-error.ico new file mode 100644 index 0000000..d91655d Binary files /dev/null and b/icons/dropforge-error.ico differ diff --git a/icons/dropforge-error.png b/icons/dropforge-error.png new file mode 100644 index 0000000..7f276bd Binary files /dev/null and b/icons/dropforge-error.png differ diff --git a/icons/dropforge-idle.ico b/icons/dropforge-idle.ico new file mode 100644 index 0000000..880b730 Binary files /dev/null and b/icons/dropforge-idle.ico differ diff --git a/icons/dropforge-idle.png b/icons/dropforge-idle.png new file mode 100644 index 0000000..540e2ac Binary files /dev/null and b/icons/dropforge-idle.png differ diff --git a/icons/dropforge-maintenance.ico b/icons/dropforge-maintenance.ico new file mode 100644 index 0000000..52e71ce Binary files /dev/null and b/icons/dropforge-maintenance.ico differ diff --git a/icons/dropforge-maintenance.png b/icons/dropforge-maintenance.png new file mode 100644 index 0000000..84a36a1 Binary files /dev/null and b/icons/dropforge-maintenance.png differ diff --git a/icons/dropforge.ico b/icons/dropforge.ico new file mode 100644 index 0000000..79ac8bc Binary files /dev/null and b/icons/dropforge.ico differ diff --git a/icons/dropforge.png b/icons/dropforge.png new file mode 100644 index 0000000..95c7aa7 Binary files /dev/null and b/icons/dropforge.png differ diff --git a/icons/error.ico b/icons/error.ico deleted file mode 100644 index 9cd1ccd..0000000 Binary files a/icons/error.ico and /dev/null differ diff --git a/icons/idle.ico b/icons/idle.ico deleted file mode 100644 index 7e91e10..0000000 Binary files a/icons/idle.ico and /dev/null differ diff --git a/icons/maint.ico b/icons/maint.ico deleted file mode 100644 index e306840..0000000 Binary files a/icons/maint.ico and /dev/null differ diff --git a/icons/pickaxe.ico b/icons/pickaxe.ico deleted file mode 100644 index a6ecd18..0000000 Binary files a/icons/pickaxe.ico and /dev/null differ diff --git a/main.py b/main.py index 16a1c52..1f25831 100644 --- a/main.py +++ b/main.py @@ -95,7 +95,7 @@ def debug_gql(self) -> int: root = tk.Tk() root.overrideredirect(True) root.withdraw() - set_root_icon(root, resource_path("icons/pickaxe.ico")) + set_root_icon(root, resource_path("icons/dropforge.ico")) root.update() parser = Parser( SELF_PATH.name, diff --git a/manual.txt b/manual.txt index 8c8eecd..bb470fe 100644 --- a/manual.txt +++ b/manual.txt @@ -1,6 +1,6 @@ -################################### -# Twitch Drops Miner (by HimanM) # -################################### +##################### +# DropForge by HimanM # +##################### Available command line arguments: diff --git a/requirements-web.txt b/requirements-web.txt new file mode 100644 index 0000000..55690c9 --- /dev/null +++ b/requirements-web.txt @@ -0,0 +1,2 @@ +aiohttp>=3.13,<4 +truststore diff --git a/scripts/build_icons.py b/scripts/build_icons.py new file mode 100644 index 0000000..5810689 --- /dev/null +++ b/scripts/build_icons.py @@ -0,0 +1,41 @@ +from pathlib import Path + +from PIL import Image, ImageDraw + + +ROOT = Path(__file__).resolve().parents[1] +ICON_DIR = ROOT / "icons" +SOURCE = ICON_DIR / "dropforge.png" +if not SOURCE.exists(): + SOURCE = ROOT / "web" / "frontend" / "public" / "favicon-v2.png" + +PNG_SIZE = 512 +ICO_SIZES = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)] +STATUS_COLORS = { + "active": "#22c55e", + "idle": "#facc15", + "error": "#ef4444", + "maintenance": "#3b82f6", +} + + +def save_icon(image: Image.Image, name: str) -> None: + image.save(ICON_DIR / f"{name}.png", optimize=True) + image.save(ICON_DIR / f"{name}.ico", sizes=ICO_SIZES) + + +base = Image.open(SOURCE).convert("RGBA").resize((PNG_SIZE, PNG_SIZE), Image.Resampling.LANCZOS) +save_icon(base, "dropforge") + +for status, color in STATUS_COLORS.items(): + variant = base.copy() + draw = ImageDraw.Draw(variant) + draw.ellipse((342, 342, 494, 494), fill="#090909", outline="#ffffff", width=5) + draw.ellipse((358, 358, 478, 478), fill=color) + save_icon(variant, f"dropforge-{status}") + +for favicon in ( + ROOT / "web" / "frontend" / "public" / "favicon-v2.png", + ROOT / "web" / "static" / "favicon-v2.png", +): + base.convert("RGB").save(favicon, optimize=True) diff --git a/scripts/install.sh b/scripts/install.sh index 1f19772..a0eb83e 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -83,6 +83,291 @@ EOF exit 0 fi +if [ "$os" = "Linux" ]; then + MODE_FILE="$APP_DIR/install-mode" + if [ -n "${TDMINER_MODE:-}" ]; then + INSTALL_MODE="$TDMINER_MODE" + elif [ -f "$MODE_FILE" ]; then + INSTALL_MODE="$(cat "$MODE_FILE")" + elif [ -r /dev/tty ]; then + printf '\nChoose the Linux interface:\n 1) Web UI (recommended)\n 2) CLI\nSelection [1]: ' > /dev/tty + IFS= read -r mode_choice < /dev/tty || mode_choice="" + case "$mode_choice" in + 2|cli|CLI) INSTALL_MODE="cli" ;; + *) INSTALL_MODE="web" ;; + esac + else + INSTALL_MODE="web" + echo "No interactive terminal detected; installing the Web UI. Set TDMINER_MODE=cli to choose CLI." + fi + case "$INSTALL_MODE" in + web|cli) ;; + *) echo "TDMINER_MODE must be 'web' or 'cli'." >&2; exit 1 ;; + esac + + PORT="${TDMINER_PORT:-17473}" + HOST_FILE="$APP_DIR/web-host" + HOST="${TDMINER_HOST:-}" + SERVICE_NAME="tdminer-web" + + if [ "$INSTALL_MODE" = "web" ]; then + if [ -z "$HOST" ] && [ -f "$HOST_FILE" ]; then + HOST="$(cat "$HOST_FILE")" + elif [ -z "$HOST" ] && [ -r /dev/tty ]; then + printf '\nChoose Web UI exposure:\n 1) Localhost only (recommended; use Tailscale for remote access)\n 2) All interfaces (0.0.0.0)\nSelection [1]: ' > /dev/tty + IFS= read -r host_choice < /dev/tty || host_choice="" + case "$host_choice" in + 2|0.0.0.0) HOST="0.0.0.0" ;; + *) HOST="127.0.0.1" ;; + esac + fi + [ -n "$HOST" ] || HOST="127.0.0.1" + if [ "$HOST" = "localhost" ]; then + HOST="127.0.0.1" + fi + case "$HOST" in + 127.0.0.1|0.0.0.0) ;; + *) echo "TDMINER_HOST must be '127.0.0.1', 'localhost', or '0.0.0.0'." >&2; exit 1 ;; + esac + case "$PORT" in + ''|*[!0-9]*) echo "TDMINER_PORT must be a number." >&2; exit 1 ;; + esac + if [ "$PORT" -lt 1024 ] || [ "$PORT" -gt 65535 ]; then + echo "TDMINER_PORT must be between 1024 and 65535." >&2 + exit 1 + fi + fi + + as_root() { + if [ "$(id -u)" -eq 0 ]; then + "$@" + elif command -v sudo >/dev/null 2>&1; then + sudo "$@" + else + echo "Root access is required to install the system service. Install sudo or run as root." >&2 + exit 1 + fi + } + + install_dependencies() { + if command -v apt-get >/dev/null 2>&1; then + as_root apt-get update + as_root apt-get install -y ca-certificates curl tar python3 python3-venv + elif command -v dnf >/dev/null 2>&1; then + as_root dnf install -y ca-certificates curl tar python3 + elif command -v pacman >/dev/null 2>&1; then + as_root pacman -Sy --needed --noconfirm ca-certificates curl tar python + else + echo "Install curl, tar, Python 3, python3-venv, and systemd, then run this command again." >&2 + exit 1 + fi + } + + if ! command -v curl >/dev/null 2>&1 \ + || ! command -v tar >/dev/null 2>&1 \ + || ! command -v python3 >/dev/null 2>&1; then + install_dependencies + fi + if [ "$INSTALL_MODE" = "web" ] && ! command -v systemctl >/dev/null 2>&1; then + echo "DropForge currently requires a systemd-based Linux server." >&2 + exit 1 + fi + + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + if ! python3 -m venv "$tmp_dir/venv-check" >/dev/null 2>&1; then + install_dependencies + fi + rm -rf "$tmp_dir/venv-check" + + archive_url="https://github.com/$REPO/archive/$REF.tar.gz" + release_id="$(date -u +%Y%m%d%H%M%S)-$$" + release_dir="$APP_DIR/releases/$release_id" + service_user="$(id -un)" + service_group="$(id -gn)" + + echo "Installing tdminer $INSTALL_MODE interface from $archive_url" + curl -fL "$archive_url" -o "$tmp_dir/source.tar.gz" + tar -xzf "$tmp_dir/source.tar.gz" -C "$tmp_dir" + extracted="$(find "$tmp_dir" -mindepth 1 -maxdepth 1 -type d ! -name venv-check | head -n 1)" + if [ -z "$extracted" ]; then + echo "Could not extract DropForge source." >&2 + exit 1 + fi + + mkdir -p "$APP_DIR/releases" "$DATA_DIR" "$INSTALL_DIR" + chmod 700 "$DATA_DIR" + migrate_data_file "$APP_DIR/source" "cookies.jar" + migrate_data_file "$APP_DIR/source" "settings.json" + if [ -d "$APP_DIR/source/cache" ] && [ ! -d "$DATA_DIR/cache" ]; then + mv "$APP_DIR/source/cache" "$DATA_DIR/cache" + fi + migrate_data_file "$INSTALL_DIR" "cookies.jar" + migrate_data_file "$INSTALL_DIR" "settings.json" + if [ -d "$INSTALL_DIR/cache" ] && [ ! -d "$DATA_DIR/cache" ]; then + mv "$INSTALL_DIR/cache" "$DATA_DIR/cache" + fi + + mv "$extracted" "$release_dir" + python3 -m venv "$release_dir/venv" + "$release_dir/venv/bin/python" -m pip install --upgrade pip + if [ "$INSTALL_MODE" = "web" ]; then + requirements_file="requirements-web.txt" + else + requirements_file="requirements-tui.txt" + fi + "$release_dir/venv/bin/python" -m pip install -r "$release_dir/$requirements_file" + + previous_release="" + if [ -L "$APP_DIR/current" ]; then + previous_release="$(readlink "$APP_DIR/current")" + fi + + if [ "$INSTALL_MODE" = "cli" ]; then + if [ -f "/etc/systemd/system/$SERVICE_NAME.service" ]; then + as_root systemctl disable --now "$SERVICE_NAME.service" + fi + rm -f "$APP_DIR/current.new" + ln -s "$release_dir" "$APP_DIR/current.new" + mv -Tf "$APP_DIR/current.new" "$APP_DIR/current" + cat > "$INSTALL_DIR/tdminer" < "$MODE_FILE" + echo "tdminer CLI is installed." + echo "Persistent data: $DATA_DIR" + echo "Run: $INSTALL_DIR/tdminer" + echo "Run the same curl command again to update. Use TDMINER_MODE=web to switch interfaces." + exit 0 + fi + + if [ ! -f "$DATA_DIR/web-auth.sqlite3" ]; then + admin_password="${TDMINER_ADMIN_PASSWORD:-$(python3 -c 'import secrets; print(secrets.token_urlsafe(18))')}" + recovery_code="${TDMINER_RECOVERY_CODE:-$(python3 -c 'import secrets; print(secrets.token_urlsafe(24))')}" + TDMINER_DATA_DIR="$DATA_DIR" \ + TDMINER_ADMIN_PASSWORD="$admin_password" \ + TDMINER_RECOVERY_CODE="$recovery_code" \ + "$release_dir/venv/bin/python" "$release_dir/tdminer_web.py" provision + else + echo "Existing web password, sessions, Twitch cookies, and settings preserved." + fi + + rm -f "$APP_DIR/current.new" + ln -s "$release_dir" "$APP_DIR/current.new" + mv -Tf "$APP_DIR/current.new" "$APP_DIR/current" + + cat > "$tmp_dir/$SERVICE_NAME.service" <&2 + exit 1 + fi + + cat > "$INSTALL_DIR/tdminer-web" <&2 + exit 2 + ;; +esac +EOF + chmod +x "$INSTALL_DIR/tdminer-web" + rm -f "$INSTALL_DIR/tdminer" + printf '%s\n' web > "$MODE_FILE" + printf '%s\n' "$HOST" > "$HOST_FILE" + + if [ "$HOST" = "0.0.0.0" ] && command -v ufw >/dev/null 2>&1 && as_root ufw status 2>/dev/null | grep -q '^Status: active'; then + as_root ufw allow "$PORT/tcp" comment DropForge + fi + + server_ip="$(hostname -I 2>/dev/null | awk '{print $1}')" + [ -n "$server_ip" ] || server_ip="SERVER_IP" + echo "" + echo "DropForge is installed and running." + if [ "$HOST" = "127.0.0.1" ]; then + echo "Open locally: http://127.0.0.1:$PORT" + echo "Recommended private remote access: tailscale serve --bg http://127.0.0.1:$PORT" + else + echo "Open: http://$server_ip:$PORT" + echo "Warning: 0.0.0.0 exposes DropForge to every reachable network interface." + fi + echo "Persistent data: $DATA_DIR" + echo "Service helper: $INSTALL_DIR/tdminer-web" + echo "Run the same curl command again to update without replacing data or browser sessions." + echo "Use TDMINER_MODE=cli to switch interfaces." + # ponytail: releases are retained for rollback; add retention after storage becomes measurable. + exit 0 +fi + for cmd in curl unzip; do if ! command -v "$cmd" >/dev/null 2>&1; then echo "Missing required command: $cmd" >&2 @@ -91,16 +376,8 @@ for cmd in curl unzip; do done case "$os" in - Linux) - case "$arch" in - x86_64|amd64) asset="Twitch.Drops.Miner.TUI.Linux-x86_64.zip" ;; - aarch64|arm64) asset="Twitch.Drops.Miner.TUI.Linux-aarch64.zip" ;; - *) echo "Unsupported Linux architecture: $arch" >&2; exit 1 ;; - esac - binary="tdminer" - ;; Darwin) - asset="Twitch.Drops.Miner.TUI.MacOS.zip" + asset="DropForge.TUI.MacOS.zip" binary="tdminer" ;; *) diff --git a/tdminer_web.py b/tdminer_web.py new file mode 100644 index 0000000..0a0eeac --- /dev/null +++ b/tdminer_web.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from multiprocessing import freeze_support + +from web.main import main + + +if __name__ == "__main__": + freeze_support() + raise SystemExit(main()) diff --git a/tests/test_installer.py b/tests/test_installer.py new file mode 100644 index 0000000..7d60ad2 --- /dev/null +++ b/tests/test_installer.py @@ -0,0 +1,17 @@ +from pathlib import Path +import unittest + + +class InstallerTests(unittest.TestCase): + def test_web_bind_policy_defaults_to_localhost(self) -> None: + script = (Path(__file__).parents[1] / "scripts" / "install.sh").read_text(encoding="utf8") + self.assertIn('[ -n "$HOST" ] || HOST="127.0.0.1"', script) + self.assertIn('127.0.0.1|0.0.0.0)', script) + self.assertIn('printf \'%s\\n\' "$HOST" > "$HOST_FILE"', script) + self.assertIn('tailscale serve --bg http://127.0.0.1:$PORT', script) + web_main = (Path(__file__).parents[1] / "web" / "main.py").read_text(encoding="utf8") + self.assertIn('os.environ.get("TDMINER_HOST", "127.0.0.1")', web_main) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 10aa3f4..0ad1176 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from unittest.mock import PropertyMock, patch -from core.constants import PriorityMode +from core.constants import PriorityMode, State from tui.app import TwitchDropsTUI from tui.manager import TUIManager from tui.state import CampaignSnapshot, DropSnapshot, TUIState @@ -257,6 +257,18 @@ async def test_settings_refresh_does_not_emit_priority_mode_change(self): class TUIManagerTests(unittest.IsolatedAsyncioTestCase): + def test_invalidate_auth_resets_auth_state_and_restarts(self): + events = [] + twitch = SimpleNamespace( + settings=SimpleNamespace(priority=[], exclude=set()), + _auth_state=SimpleNamespace(invalidate=lambda: events.append("invalidate")), + change_state=events.append, + ) + + TUIManager(twitch)._invalidate_auth() + + self.assertEqual(events, ["invalidate", State.RESTART]) + def test_inventory_snapshot_reads_settings_from_manager(self): settings = SimpleNamespace( priority=["Game"], diff --git a/tests/test_tui_cli.py b/tests/test_tui_cli.py index 98c5278..69ced2b 100644 --- a/tests/test_tui_cli.py +++ b/tests/test_tui_cli.py @@ -36,7 +36,7 @@ def test_screen_includes_himanm_credit(self): text = manager._screen_text() - self.assertIn("TDMinER by HimanM", text) + self.assertIn("DropForge by HimanM", text) def test_command_completer_includes_common_commands(self): self.assertIn("/channels next", PortableCLIManager.COMMANDS) diff --git a/tests/test_web_auth.py b/tests/test_web_auth.py new file mode 100644 index 0000000..8dc623f --- /dev/null +++ b/tests/test_web_auth.py @@ -0,0 +1,75 @@ +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +from aiohttp.test_utils import TestClient, TestServer + +from web.auth import AuthStore +from web.controller import MinerController +from web.server import create_app + + +class AuthStoreTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.store = AuthStore(Path(self.temp.name, "auth.sqlite3")) + + def tearDown(self): + self.temp.cleanup() + + def test_password_session_and_recovery_rotation(self): + self.assertTrue(self.store.provision("correct horse battery", "recovery-code-long-enough")) + self.assertFalse(self.store.provision("another strong password", "another-recovery-code")) + self.assertTrue(self.store.verify_password("correct horse battery")) + self.assertFalse(self.store.verify_password("wrong password")) + + token, session = self.store.create_session() + self.assertEqual(self.store.get_session(token), session) + + next_recovery = self.store.reset_password( + "recovery-code-long-enough", "new correct horse battery" + ) + self.assertIsNotNone(next_recovery) + self.assertIsNone(self.store.get_session(token)) + self.assertTrue(self.store.verify_password("new correct horse battery")) + self.assertIsNone( + self.store.reset_password("recovery-code-long-enough", "third correct horse battery") + ) + + +class WebStateTests(unittest.TestCase): + def test_stopped_miner_does_not_expose_stale_device_code(self): + controller = MinerController() + controller.manager = SimpleNamespace( + snapshot=lambda: { + "login": { + "status": "Login required", + "user_id": "-", + "activation_url": "https://www.twitch.tv/activate", + "user_code": "ABCDEFGH", + } + } + ) + + snapshot = controller.snapshot() + + self.assertEqual(snapshot["login"]["activation_url"], "") + self.assertEqual(snapshot["login"]["user_code"], "") + + +class WebResponseTests(unittest.IsolatedAsyncioTestCase): + async def test_api_responses_are_never_cached(self): + with tempfile.TemporaryDirectory() as directory: + app = create_app( + Path(directory, "auth.sqlite3"), + Path(directory), + auto_start=False, + ) + async with TestClient(TestServer(app)) as client: + response = await client.get("/api/session") + self.assertEqual(response.headers["Cache-Control"], "no-store") + + +if __name__ == "__main__": + unittest.main() diff --git a/tui/__init__.py b/tui/__init__.py index 6aafcdf..e9095dc 100644 --- a/tui/__init__.py +++ b/tui/__init__.py @@ -1,2 +1,2 @@ -"""Textual frontend for Twitch Drops Miner.""" +"""DropForge Textual frontend.""" diff --git a/tui/app.py b/tui/app.py index 0e69005..5737c7f 100644 --- a/tui/app.py +++ b/tui/app.py @@ -268,8 +268,8 @@ def compose(self) -> ComposeResult: yield Footer() def on_mount(self) -> None: - self.title = "Twitch Drops Miner TUI" - self.sub_title = "tdminer" + self.title = "DropForge" + self.sub_title = "Twitch drops miner" self._setup_tables() self._ready_for_refresh = True self.refresh_all() diff --git a/tui/cli.py b/tui/cli.py index 27beb60..24bee80 100644 --- a/tui/cli.py +++ b/tui/cli.py @@ -693,12 +693,12 @@ def _render_splash(self, width: int, height: int, elapsed: float) -> str: ) console.print() console.print( - Text("Twitch Drops Miner", style=f"bold {_C_AMBER}"), + Text("DropForge", style=f"bold {_C_AMBER}"), justify="center", ) else: console.print( - Text("TDMinER", style=f"bold {_C_CYAN}"), + Text("DropForge", style=f"bold {_C_CYAN}"), justify="center", ) console.print( @@ -726,9 +726,9 @@ def _rich_title(self, width: int) -> str: console = Console(file=StringIO(), width=width, force_terminal=True) right = datetime.now().strftime("%H:%M:%S") title = Text() - title.append("TDMinER ", style=f"bold {_C_CYAN}") + title.append("DropForge ", style=f"bold {_C_CYAN}") title.append("by HimanM", style=_C_DIM) - padding = max(1, width - len("TDMinER by HimanM") - len(right) - 2) + padding = max(1, width - len("DropForge by HimanM") - len(right) - 2) title.append(" " * padding) title.append(right, style=_C_DIM) console.print(title) diff --git a/tui/main.py b/tui/main.py index a8f3604..aa01997 100644 --- a/tui/main.py +++ b/tui/main.py @@ -82,7 +82,7 @@ def debug_gql(self) -> int: def parse_args(argv: list[str] | None = None) -> ParsedArgs: parser = Parser( SELF_PATH.name, - description="A terminal UI for mining timed Twitch drops.", + description="DropForge terminal interface for mining timed Twitch drops.", ) parser.add_argument("--version", action="version", version=f"v{__version__}") parser.add_argument( diff --git a/tui/manager.py b/tui/manager.py index 9e52795..e90090e 100644 --- a/tui/manager.py +++ b/tui/manager.py @@ -11,7 +11,6 @@ from core.constants import PriorityMode, State from core.exceptions import ExitRequest from core.translate import _ -from tui.app import TwitchDropsTUI from tui.state import ( CampaignSnapshot, ChannelSnapshot, @@ -28,6 +27,7 @@ from models.channel import Channel from models.inventory import DropsCampaign, TimedDrop from network.twitch import Twitch + from tui.app import TwitchDropsTUI logger = logging.getLogger("TwitchDrops") @@ -401,6 +401,8 @@ def _update_settings_text(self) -> None: def start(self) -> None: if self._app_task is not None and not self._app_task.done(): return + from tui.app import TwitchDropsTUI + self._app_ready.clear() self._app = TwitchDropsTUI( self.state, @@ -531,7 +533,7 @@ def _reload(self) -> None: self._twitch.change_state(State.INVENTORY_FETCH) def _invalidate_auth(self) -> None: - self._twitch.invalidate() + self._twitch._auth_state.invalidate() self._twitch.change_state(State.RESTART) def _switch_channel(self) -> None: diff --git a/version.py b/version.py index 9d9454a..05ad504 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -__version__ = "19.6.Opinionated_By_HimanM" +__version__ = "20.0.Opinionated_By_HimanM" diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..956ba05 --- /dev/null +++ b/web/__init__.py @@ -0,0 +1 @@ +"""DropForge self-hosted web frontend.""" diff --git a/web/auth.py b/web/auth.py new file mode 100644 index 0000000..0f1bf08 --- /dev/null +++ b/web/auth.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import os +import secrets +import sqlite3 +import time +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator + + +SESSION_TTL = 60 * 60 * 24 * 7 +PASSWORD_MIN_LENGTH = 12 +_SCRYPT_N = 1 << 14 +_SCRYPT_R = 8 +_SCRYPT_P = 1 + + +def validate_password(password: str) -> None: + encoded = password.encode("utf-8") + if len(password) < PASSWORD_MIN_LENGTH: + raise ValueError(f"Password must be at least {PASSWORD_MIN_LENGTH} characters.") + if len(encoded) > 1024: + raise ValueError("Password is too long.") + + +def _b64(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") + + +def _unb64(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _hash_secret(secret: str, salt: bytes | None = None) -> str: + salt = salt or secrets.token_bytes(16) + digest = hashlib.scrypt( + secret.encode("utf-8"), + salt=salt, + n=_SCRYPT_N, + r=_SCRYPT_R, + p=_SCRYPT_P, + dklen=32, + maxmem=64 * 1024 * 1024, + ) + return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${_b64(salt)}${_b64(digest)}" + + +def _verify_secret(secret: str, encoded: str) -> bool: + try: + algorithm, n, r, p, salt, expected = encoded.split("$", 5) + if algorithm != "scrypt": + return False + digest = hashlib.scrypt( + secret.encode("utf-8"), + salt=_unb64(salt), + n=int(n), + r=int(r), + p=int(p), + dklen=32, + maxmem=64 * 1024 * 1024, + ) + return hmac.compare_digest(digest, _unb64(expected)) + except (ValueError, TypeError): + return False + + +def _token_hash(token: str) -> str: + return hashlib.sha256(token.encode("ascii")).hexdigest() + + +@dataclass(frozen=True, slots=True) +class Session: + token_hash: str + csrf_token: str + expires_at: int + + +class AuthStore: + def __init__(self, path: Path) -> None: + self.path = path + path.parent.mkdir(parents=True, exist_ok=True) + self._initialize() + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.path, timeout=10) + connection.execute("PRAGMA foreign_keys = ON") + return connection + + @contextmanager + def _db(self) -> Iterator[sqlite3.Connection]: + connection = self._connect() + try: + with connection: + yield connection + finally: + connection.close() + + def _initialize(self) -> None: + with self._db() as db: + db.executescript( + """ + CREATE TABLE IF NOT EXISTS secrets ( + name TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + csrf_token TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + """ + ) + if os.name != "nt": + self.path.chmod(0o600) + + def is_provisioned(self) -> bool: + with self._db() as db: + row = db.execute("SELECT 1 FROM secrets WHERE name = 'password'").fetchone() + return row is not None + + def provision(self, password: str, recovery_code: str) -> bool: + validate_password(password) + if len(recovery_code) < 20: + raise ValueError("Recovery code must be at least 20 characters.") + with self._db() as db: + if db.execute("SELECT 1 FROM secrets WHERE name = 'password'").fetchone(): + return False + db.executemany( + "INSERT INTO secrets(name, value) VALUES (?, ?)", + ( + ("password", _hash_secret(password)), + ("recovery", _hash_secret(recovery_code)), + ), + ) + return True + + def verify_password(self, password: str) -> bool: + with self._db() as db: + row = db.execute("SELECT value FROM secrets WHERE name = 'password'").fetchone() + return row is not None and _verify_secret(password, row[0]) + + def create_session(self) -> tuple[str, Session]: + token = secrets.token_urlsafe(32) + session = Session( + token_hash=_token_hash(token), + csrf_token=secrets.token_urlsafe(24), + expires_at=int(time.time()) + SESSION_TTL, + ) + with self._db() as db: + db.execute("DELETE FROM sessions WHERE expires_at <= ?", (int(time.time()),)) + db.execute( + "INSERT INTO sessions(token_hash, csrf_token, expires_at) VALUES (?, ?, ?)", + (session.token_hash, session.csrf_token, session.expires_at), + ) + return token, session + + def get_session(self, token: str) -> Session | None: + token_hash = _token_hash(token) + with self._db() as db: + row = db.execute( + "SELECT csrf_token, expires_at FROM sessions WHERE token_hash = ?", + (token_hash,), + ).fetchone() + if row is None: + return None + if row[1] <= int(time.time()): + db.execute("DELETE FROM sessions WHERE token_hash = ?", (token_hash,)) + return None + return Session(token_hash=token_hash, csrf_token=row[0], expires_at=row[1]) + + def delete_session(self, token: str) -> None: + with self._db() as db: + db.execute("DELETE FROM sessions WHERE token_hash = ?", (_token_hash(token),)) + + def change_password(self, current_password: str, new_password: str) -> bool: + validate_password(new_password) + with self._db() as db: + row = db.execute("SELECT value FROM secrets WHERE name = 'password'").fetchone() + if row is None or not _verify_secret(current_password, row[0]): + return False + db.execute( + "UPDATE secrets SET value = ? WHERE name = 'password'", + (_hash_secret(new_password),), + ) + db.execute("DELETE FROM sessions") + return True + + def reset_password(self, recovery_code: str, new_password: str) -> str | None: + validate_password(new_password) + with self._db() as db: + row = db.execute("SELECT value FROM secrets WHERE name = 'recovery'").fetchone() + if row is None or not _verify_secret(recovery_code, row[0]): + return None + next_recovery = secrets.token_urlsafe(24) + db.execute( + "UPDATE secrets SET value = ? WHERE name = 'password'", + (_hash_secret(new_password),), + ) + db.execute( + "UPDATE secrets SET value = ? WHERE name = 'recovery'", + (_hash_secret(next_recovery),), + ) + db.execute("DELETE FROM sessions") + return next_recovery + + def force_password(self, new_password: str) -> str: + validate_password(new_password) + next_recovery = secrets.token_urlsafe(24) + with self._db() as db: + db.execute( + "INSERT INTO secrets(name, value) VALUES ('password', ?) " + "ON CONFLICT(name) DO UPDATE SET value = excluded.value", + (_hash_secret(new_password),), + ) + db.execute( + "INSERT INTO secrets(name, value) VALUES ('recovery', ?) " + "ON CONFLICT(name) DO UPDATE SET value = excluded.value", + (_hash_secret(next_recovery),), + ) + db.execute("DELETE FROM sessions") + return next_recovery diff --git a/web/controller.py b/web/controller.py new file mode 100644 index 0000000..e072dd2 --- /dev/null +++ b/web/controller.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import asyncio +import io +import logging +import traceback +from argparse import Namespace +from typing import Any + +from core.constants import FILE_FORMATTER, LOCK_PATH, LOG_PATH +from core.exceptions import CaptchaRequired +from core.settings import Settings +from core.translate import _ +from core.utils import lock_file +from network.twitch import Twitch +from web.manager import WebManager + + +class MinerController: + def __init__(self) -> None: + self.manager: WebManager | None = None + self._client: Twitch | None = None + self._task: asyncio.Task[None] | None = None + self._lock = asyncio.Lock() + self._instance_lock: io.TextIOWrapper | None = None + self._logging_configured = False + self.last_error = "" + + @property + def running(self) -> bool: + return self._task is not None and not self._task.done() + + async def start(self) -> bool: + async with self._lock: + if self.running: + return False + self.last_error = "" + self._task = asyncio.create_task(self._run(), name="tdminer") + await asyncio.sleep(0) + return True + + async def stop(self) -> bool: + async with self._lock: + if not self.running or self.manager is None: + return False + task = self._task + self.manager.close() + if task is not None: + try: + await asyncio.wait_for(asyncio.shield(task), timeout=20) + except asyncio.TimeoutError: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + return True + + async def close(self) -> None: + if self.running: + await self.stop() + + async def _run(self) -> None: + success, instance_lock = lock_file(LOCK_PATH) + if not success: + self.last_error = f"Another tdminer instance is already running or the lock is busy: {LOCK_PATH}" + return + self._instance_lock = instance_lock + args = Namespace( + log=True, + tray=False, + dump=False, + logging_level=logging.INFO, + debug_ws=logging.NOTSET, + debug_gql=logging.NOTSET, + ) + try: + settings = Settings(args) + if not self._logging_configured: + logger = logging.getLogger("TwitchDrops") + logger.setLevel(settings.logging_level) + if settings.log: + handler = logging.FileHandler(LOG_PATH) + handler.setFormatter(FILE_FORMATTER) + logger.addHandler(handler) + logging.getLogger("TwitchDrops.gql").setLevel(settings.debug_gql) + logging.getLogger("TwitchDrops.websocket").setLevel(settings.debug_ws) + self._logging_configured = True + client = Twitch(settings, gui_factory=WebManager) + self._client = client + self.manager = client.gui + try: + _.set_language(settings.language) + except ValueError: + pass + await client.run() + except CaptchaRequired: + self.last_error = _("error", "captcha") + client.print(self.last_error) + except asyncio.CancelledError: + raise + except Exception: + self.last_error = traceback.format_exc() + if self._client is not None: + self._client.print("Fatal error encountered:") + self._client.print(self.last_error) + finally: + if self._client is not None: + await self._client.shutdown() + self._client.save(force=True) + self._client.gui.stop() + self._client = None + instance_lock.close() + self._instance_lock = None + + def snapshot(self) -> dict[str, Any]: + state = self.manager.snapshot() if self.manager is not None else { + "status": "Stopped", + "icon_state": "idle", + "login": {"status": "Miner stopped", "user_id": "-", "activation_url": "", "user_code": ""}, + "current_drop": {}, + "channels": [], + "campaigns": [], + "websockets": [], + "settings": {}, + "selected_channel_id": None, + "logs": [], + } + if not self.running: + state["login"]["activation_url"] = "" + state["login"]["user_code"] = "" + return { + **state, + "miner": { + "running": self.running, + "last_error": self.last_error, + }, + } diff --git a/web/frontend/components.json b/web/frontend/components.json new file mode 100644 index 0000000..29393a4 --- /dev/null +++ b/web/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "phosphor", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/web/frontend/index.html b/web/frontend/index.html new file mode 100644 index 0000000..41933e1 --- /dev/null +++ b/web/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + DropForge + + +
+ + + diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json new file mode 100644 index 0000000..e9a8b7b --- /dev/null +++ b/web/frontend/package-lock.json @@ -0,0 +1,1998 @@ +{ + "name": "dropforge-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dropforge-web", + "version": "1.0.0", + "dependencies": { + "@base-ui/react": "^1.6.0", + "@fontsource-variable/manrope": "^5.2.8", + "@phosphor-icons/react": "^2.1.10", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "motion": "^12.42.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^26.1.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "tailwindcss": "^4.3.2", + "tw-animate-css": "^1.4.0", + "typescript": "^7.0.2", + "vite": "^8.1.4" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@base-ui/react": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.6.0.tgz", + "integrity": "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@base-ui/utils": "0.3.1", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", + "integrity": "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@fontsource-variable/manrope": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz", + "integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "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.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz", + "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "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.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "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.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "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.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "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/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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/framer-motion": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", + "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.42.2", + "motion-utils": "^12.39.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "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/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "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/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/motion": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", + "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.42.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.42.2", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", + "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.39.0" + } + }, + "node_modules/motion-utils": { + "version": "12.39.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", + "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "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/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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "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/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "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.3.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 + } + } + } + } +} diff --git a/web/frontend/package.json b/web/frontend/package.json new file mode 100644 index 0000000..0a8f3df --- /dev/null +++ b/web/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "dropforge-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "tsc -b --pretty false" + }, + "dependencies": { + "@base-ui/react": "^1.6.0", + "@fontsource-variable/manrope": "^5.2.8", + "@phosphor-icons/react": "^2.1.10", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "motion": "^12.42.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "tailwind-merge": "^3.6.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.2", + "@types/node": "^26.1.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "tailwindcss": "^4.3.2", + "tw-animate-css": "^1.4.0", + "typescript": "^7.0.2", + "vite": "^8.1.4" + } +} diff --git a/web/frontend/public/favicon-v2.png b/web/frontend/public/favicon-v2.png new file mode 100644 index 0000000..ac5a89b Binary files /dev/null and b/web/frontend/public/favicon-v2.png differ diff --git a/web/frontend/src/auth-screen.tsx b/web/frontend/src/auth-screen.tsx new file mode 100644 index 0000000..616b0ee --- /dev/null +++ b/web/frontend/src/auth-screen.tsx @@ -0,0 +1,213 @@ +import { useState, type FormEvent } from "react" +import { + ArrowRightIcon, + CheckIcon, + CopyIcon, + EyeIcon, + EyeSlashIcon, + GithubLogoIcon, + KeyIcon, + LockKeyOpenIcon, + ShieldCheckIcon, +} from "@phosphor-icons/react" +import { motion, useReducedMotion } from "motion/react" + +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import type { SessionMeta } from "@/types" + +type Props = { onAuthenticated: (session: SessionMeta) => void } + +async function responseJson(response: Response) { + const body = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(body.error || "The server could not complete that request.") + return body +} + +export function AuthScreen({ onAuthenticated }: Props) { + const reduce = useReducedMotion() + const [view, setView] = useState<"login" | "reset" | "recovery">("login") + const [password, setPassword] = useState("") + const [newPassword, setNewPassword] = useState("") + const [recovery, setRecovery] = useState("") + const [nextRecovery, setNextRecovery] = useState("") + const [showPassword, setShowPassword] = useState(false) + const [busy, setBusy] = useState(false) + const [copied, setCopied] = useState(false) + const [error, setError] = useState("") + + async function login(event: FormEvent) { + event.preventDefault() + setBusy(true) + setError("") + try { + await responseJson( + await fetch("/api/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }), + ) + const session = await responseJson(await fetch("/api/session")) + onAuthenticated(session as SessionMeta) + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Login failed.") + } finally { + setBusy(false) + } + } + + async function reset(event: FormEvent) { + event.preventDefault() + setBusy(true) + setError("") + try { + const body = await responseJson( + await fetch("/api/password/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ recovery_code: recovery, new_password: newPassword }), + }), + ) + setNextRecovery(body.recovery_code) + setPassword("") + setNewPassword("") + setRecovery("") + setView("recovery") + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Password reset failed.") + } finally { + setBusy(false) + } + } + + async function copyRecovery() { + await navigator.clipboard.writeText(nextRecovery) + setCopied(true) + window.setTimeout(() => setCopied(false), 1500) + } + + return ( +
+
+
+
+ DropForge icon +
+

DropForge

+

Private mining control room

+
+
+ +

+ Drops keep moving. Your miner stays close. +

+

+ Control campaigns, channels, settings, and Twitch device login from one private server. +

+
+
+ Salted scrypt passwords + Revocable sessions +
+
+ +
+
+ DropForge icon +
+

DropForge

+

Private mining control room

+
+
+ +
+ {view === "login" && ( + +

Welcome back

+

Sign in to manage the miner on this server.

+
+
+ +
+ setPassword(event.target.value)} + required + /> + +
+ {error &&

{error}

} +
+ +
+ +
+ )} + + {view === "reset" && ( + +
+

Reset password

+

Use the recovery code printed during installation. Resetting revokes every browser session.

+
+
+ + setRecovery(event.target.value)} required /> +
+
+ + setNewPassword(event.target.value)} required /> +

Use at least 12 characters.

+
+ {error &&

{error}

} + +
+ +
+ )} + + {view === "recovery" && ( + +
+

Password reset

+

Save this new one-time recovery code before signing in.

+
+ {nextRecovery} + +
+ +
+ )} +
+ +
+ Self-hosted on your server + HimanM +
+
+
+ ) +} diff --git a/web/frontend/src/components/ui/badge.tsx b/web/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..b20959d --- /dev/null +++ b/web/frontend/src/components/ui/badge.tsx @@ -0,0 +1,52 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/web/frontend/src/components/ui/button.tsx b/web/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..b033601 --- /dev/null +++ b/web/frontend/src/components/ui/button.tsx @@ -0,0 +1,58 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/web/frontend/src/components/ui/dialog.tsx b/web/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..8fa5d9b --- /dev/null +++ b/web/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "@phosphor-icons/react" + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogOverlay({ + className, + ...props +}: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + }> + Close + + )} +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/web/frontend/src/components/ui/input.tsx b/web/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..7d21bab --- /dev/null +++ b/web/frontend/src/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/web/frontend/src/components/ui/progress.tsx b/web/frontend/src/components/ui/progress.tsx new file mode 100644 index 0000000..844e869 --- /dev/null +++ b/web/frontend/src/components/ui/progress.tsx @@ -0,0 +1,81 @@ +import { Progress as ProgressPrimitive } from "@base-ui/react/progress" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + children, + value, + ...props +}: ProgressPrimitive.Root.Props) { + return ( + + {children} + + + + + ) +} + +function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) { + return ( + + ) +} + +function ProgressIndicator({ + className, + ...props +}: ProgressPrimitive.Indicator.Props) { + return ( + + ) +} + +function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) { + return ( + + ) +} + +function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) { + return ( + + ) +} + +export { + Progress, + ProgressTrack, + ProgressIndicator, + ProgressLabel, + ProgressValue, +} diff --git a/web/frontend/src/components/ui/select.tsx b/web/frontend/src/components/ui/select.tsx new file mode 100644 index 0000000..aff8f2a --- /dev/null +++ b/web/frontend/src/components/ui/select.tsx @@ -0,0 +1,201 @@ +"use client" + +import * as React from "react" +import { Select as SelectPrimitive } from "@base-ui/react/select" + +import { cn } from "@/lib/utils" +import { CaretDownIcon, CheckIcon, CaretUpIcon } from "@phosphor-icons/react" + +const Select = SelectPrimitive.Root + +function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) { + return ( + + ) +} + +function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { + return ( + + ) +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: SelectPrimitive.Trigger.Props & { + size?: "sm" | "default" +}) { + return ( + + {children} + + } + /> + + ) +} + +function SelectContent({ + className, + children, + side = "bottom", + sideOffset = 4, + align = "center", + alignOffset = 0, + alignItemWithTrigger = true, + ...props +}: SelectPrimitive.Popup.Props & + Pick< + SelectPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger" + >) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: SelectPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: SelectPrimitive.Item.Props) { + return ( + + + {children} + + + } + > + + + + ) +} + +function SelectSeparator({ + className, + ...props +}: SelectPrimitive.Separator.Props) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/web/frontend/src/components/ui/separator.tsx b/web/frontend/src/components/ui/separator.tsx new file mode 100644 index 0000000..4f65961 --- /dev/null +++ b/web/frontend/src/components/ui/separator.tsx @@ -0,0 +1,23 @@ +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + ...props +}: SeparatorPrimitive.Props) { + return ( + + ) +} + +export { Separator } diff --git a/web/frontend/src/components/ui/skeleton.tsx b/web/frontend/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..0118624 --- /dev/null +++ b/web/frontend/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/web/frontend/src/components/ui/switch.tsx b/web/frontend/src/components/ui/switch.tsx new file mode 100644 index 0000000..9b8b44b --- /dev/null +++ b/web/frontend/src/components/ui/switch.tsx @@ -0,0 +1,32 @@ +"use client" + +import { Switch as SwitchPrimitive } from "@base-ui/react/switch" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + size = "default", + ...props +}: SwitchPrimitive.Root.Props & { + size?: "sm" | "default" +}) { + return ( + + + + ) +} + +export { Switch } diff --git a/web/frontend/src/components/ui/tabs.tsx b/web/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..2adaeb6 --- /dev/null +++ b/web/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,80 @@ +import { Tabs as TabsPrimitive } from "@base-ui/react/tabs" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: TabsPrimitive.Root.Props) { + return ( + + ) +} + +const tabsListVariants = cva( + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", + { + variants: { + variant: { + default: "bg-muted", + line: "gap-1 bg-transparent", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function TabsList({ + className, + variant = "default", + ...props +}: TabsPrimitive.List.Props & VariantProps) { + return ( + + ) +} + +function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) { + return ( + + ) +} + +function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/web/frontend/src/components/ui/textarea.tsx b/web/frontend/src/components/ui/textarea.tsx new file mode 100644 index 0000000..04d27f7 --- /dev/null +++ b/web/frontend/src/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { + return ( +