commit f42cf11831f7444a72afec29effbf85dc7905c27
Author: WpyQwq <3911625973@qq.com>
Date: Sat Sep 19 11:49:36 2026 +0800
Initial commit: BiliDownloader Web 版:Python 零依赖后端 + shadcn/ui 前端
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..efe890b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,119 @@
+# BiliDownloader
+
+哔哩哔哩视频下载器 —— **Python 零依赖本地服务 + shadcn/ui 界面 + Edge 应用模式窗口**
+
+> 这是**弃用 WinUI3 后的重构版**。旧版(C# / WinUI3,位于 `E:\deepseek\BiliDownloader`)能编译能跑,
+> 但需要 MSIX 打包且本机存在 WinUI3 启动限制;这一版改成浏览器界面,双击即用、无运行时依赖。
+
+## 启动
+
+双击 **`start.cmd`** —— 它会起本地服务并打开一个 Edge 应用模式窗口(无地址栏、无标签页,看起来就是独立应用)。
+
+停止:双击 `stop.cmd`。
+
+也可以手动:
+
+```powershell
+python server.py # 起服务并自动开窗口
+python server.py --no-open # 只起服务,自己用浏览器打开 http://127.0.0.1:8799/
+```
+
+## 功能
+
+- **站内搜索**:WBI 签名的官方搜索接口,卡片列表展示封面 / 标题 / UP主 / 时长 / 播放量
+- **粘贴链接或 BV 号**直接解析
+- **清晰度可选**:按**真正能下到的流**列出(不是标称支持),默认最高
+- **多线程加速下载**:文件切段并发下载(默认 16,可调 1~64),断线按分片续传,支持跨进程断点续传
+- **实时进度**:SSE 推送,总体 + 视频流 + 音频流三条进度、实时速度、速度曲线、ETA
+- **ffmpeg 无损封装**为 mp4,产物文件名带清晰度
+- 深浅色主题(默认深色)并记忆
+
+## 架构
+
+```
+BiliDownloaderWeb/
+├─ start.cmd / stop.cmd 双击启动 / 停止
+├─ server.py 本地 HTTP 服务(标准库 http.server,只监听 127.0.0.1)
+│ JSON API + SSE 进度推送 + 托管前端产物
+├─ engine.py 下载引擎(已实测验证的核心逻辑)
+│ ├─ WBI 签名的站内搜索
+│ ├─ 视频详情 / 分P / 清晰度
+│ ├─ 多线程分段下载(Range 分片 + 每片重试 + 旁路状态文件续传)
+│ └─ ffmpeg 封装
+├─ config.json sessdata / outputDir / threads / preferAvc / keepParts
+├─ ui/ React 19 + Tailwind 4 + Vite 7 + shadcn/ui(new-york)
+│ └─ src/{App.tsx,components/,lib/}
+└─ tools/ 验收脚本
+ ├─ smoke_api.py 后端 API 冒烟
+ ├─ e2e_download.py 端到端下载验收(含 ffprobe 与全片解码校验)
+ └─ ui-check.mjs 浏览器端验收(Edge 无头 + CDP,真的驱动一次搜索)
+```
+
+为什么是"本地薄服务 + 浏览器界面":前端需要跨域访问 B 站 API、需要写本地磁盘、需要用 ffmpeg,
+浏览器自己做不到,所以后端必须存在;而这层后端用 Python 标准库就够,**零第三方依赖**,
+`engine.py` 里那套逻辑也已在上一轮里逐项实测过。
+
+## 验收结果(都是实测,非推断)
+
+**后端 API**(`tools/smoke_api.py`)
+
+| 项 | 结果 |
+|---|---|
+| 站内搜索 | `numResults=1000`,返回 20 条,封面 URL 正常 |
+| 视频详情 | 标题/UP主/时长/分P 全部正确 |
+| 清晰度列表 | 按 `dash.video` 实际流构建 |
+
+**端到端下载**(`tools/e2e_download.py`)—— **13/13 通过**
+
+```
+产物: [4K]当你用《航拍中国》的方式打开崂山育才——航拍育才 [480P标清].mp4
+大小: 11.2 MB 流: h264 852x480 + aac
+时长: 90.0s(源 91s) 全片解码: 无错误
+速度: 峰值 15.9 MB/s,16 线程 耗时: 2.0s
+中间文件: 已清理
+```
+
+**浏览器界面**(`tools/ui-check.mjs`,Edge 无头 + CDP)—— **18/18 通过**
+
+其中包含**真的驱动界面**:填入关键词 → 触发提交 → 等结果渲染(21 个卡片)→ 点选第一个
+→ 等清晰度解析 → 断言「开始下载」可点。另有:React 挂载、深色主题生效、无横向溢出、零脚本错误。
+
+## 两个必须知道的坑(都踩过)
+
+### 1. 清晰度列表必须取 `dash.video`,不能取 `accept_quality`
+
+`accept_quality` / `support_formats` 只反映视频**标称**支持什么,与当前账号能否下载无关。
+实测:未登录时 `accept_quality` 仍列出 `120(4K)`,但 `dash.video` 里只有 `32(480P)`。
+按前者建列表会让用户选到根本拿不到的清晰度,点下载才报错。
+
+### 2. 产物文件名必须带清晰度
+
+否则同一视频下不同清晰度会互相覆盖 —— 实测踩过:480P 的验收下载把之前下好的 4K 文件直接盖掉。
+
+## 配置
+
+`config.json`(与 `server.py` 同目录):
+
+```json
+{
+ "sessdata": "...",
+ "outputDir": "E:\\deepseek\\downloads",
+ "threads": 16,
+ "preferAvc": true,
+ "keepParts": false
+}
+```
+
+`threads` 实测:16 线程约 4.1 MB/s,32 线程约 6.8 MB/s;过高可能触发 CDN 限流。
+
+> ⚠️ `sessdata` 等同于账号登录凭据,明文存在本机。**别把这个目录分享给他人。**
+> 未登录/失效时 B 站只放出 480P 及以下,界面会明确提示。
+
+## 重新构建前端
+
+```powershell
+cd ui
+npm install # 首次
+npm run build # 产物到 ui/dist,由 server.py 托管
+npm run dev # 开发模式(热更新,代理 /api 到 8799)
+```
diff --git a/config.json b/config.json
new file mode 100644
index 0000000..ecdb3f5
--- /dev/null
+++ b/config.json
@@ -0,0 +1,7 @@
+{
+ "sessdata": "12892060%2C1804723781%2Caa6de%2A91CjBKyjjjtXUBib-hcvUvs7CT7YUiP5j-0qIMAlKTi3BHY_jNPgE6Ze49_LBruJQGivwSVk9NeEh3RXBBajc3TXJlbWRqYlQ0SzJjRzVsSE51dmtuSXV4VF9ZSkZwSHY3d1paaTBUSGtxdWRXbHVYRG9JVmk5bmRhR0s3QUEwVHZZRlV1aG5WMUR3IIEC",
+ "outputDir": "E:\\deepseek\\downloads",
+ "threads": 16,
+ "preferAvc": true,
+ "keepParts": false
+}
\ No newline at end of file
diff --git a/engine.py b/engine.py
new file mode 100644
index 0000000..b142728
--- /dev/null
+++ b/engine.py
@@ -0,0 +1,513 @@
+# -*- coding: utf-8 -*-
+"""
+engine.py —— BiliDownloader 下载引擎(被 server.py 导入)
+
+这里的核心逻辑全部来自已经实测跑通的 bili_download.py,行为保持一致:
+ * WBI 签名的站内搜索(老的无签名接口已被 B 站封禁)
+ * 视频详情 / 分P / 清晰度列表(playurl qn=127 拿全部可用清晰度)
+ * 多线程分段加速下载:Range 分片 + 并发 + 每片独立重试续传 + 旁路状态文件跨进程续传
+ * ffmpeg 无损封装为 mp4
+
+只依赖 Python 标准库;ffmpeg 需在 PATH 或常见安装位置。
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import re
+import shutil
+import subprocess
+import threading
+import time
+import urllib.parse
+import urllib.request
+
+UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
+
+API_VIEW = "https://api.bilibili.com/x/web-interface/view?bvid={bvid}"
+API_NAV = "https://api.bilibili.com/x/web-interface/nav"
+API_SEARCH = "https://api.bilibili.com/x/web-interface/wbi/search/type"
+API_PLAYURL = ("https://api.bilibili.com/x/player/playurl?bvid={bvid}&cid={cid}"
+ "&qn=127&fnver=0&fnval=4048&fourk=1")
+
+QN_NAME = {
+ 127: "8K 超高清", 126: "杜比视界", 125: "HDR 真彩", 120: "4K 超清",
+ 116: "1080P60", 112: "1080P 高码率", 80: "1080P 高清", 74: "720P60",
+ 64: "720P 高清", 32: "480P 清晰", 16: "360P 流畅", 6: "240P 极速",
+}
+
+# WBI 签名置换表(与 bilibili-API-collect 公开算法一致)
+MIXIN_KEY_ENC_TAB = [
+ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
+ 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61,
+ 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36,
+ 20, 34, 44, 52,
+]
+
+CHUNK = 1 << 18 # 256KB
+SEG_MIN = 2 << 20 # 小于 2MB 不值得分片
+
+
+class BiliError(RuntimeError):
+ """带 B 站错误码的异常。"""
+
+ def __init__(self, code: int, message: str):
+ super().__init__(f"B 站接口错误 {code}: {message}")
+ self.code = code
+ self.message = message
+
+
+def human(n: float) -> str:
+ for unit in ("B", "KB", "MB", "GB"):
+ if n < 1024 or unit == "GB":
+ return f"{n:.1f}{unit}" if unit != "B" else f"{int(n)}B"
+ n /= 1024
+ return f"{n:.1f}GB"
+
+
+# ------------------------------------------------------------------ HTTP
+
+def _headers(referer: str, cookie: str | None, extra: dict | None = None) -> dict:
+ h = {"User-Agent": UA, "Referer": referer, "Accept": "*/*",
+ "Accept-Encoding": "identity", "Connection": "keep-alive"}
+ if cookie:
+ h["Cookie"] = cookie
+ if extra:
+ h.update(extra)
+ return h
+
+
+def _get(url: str, referer: str = "https://www.bilibili.com",
+ cookie: str | None = None, timeout: int = 30) -> bytes:
+ req = urllib.request.Request(url, headers=_headers(referer, cookie))
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ return r.read()
+
+
+def _api(url: str, cookie: str | None = None, referer: str = "https://www.bilibili.com") -> dict:
+ raw = _get(url, referer, cookie)
+ try:
+ payload = json.loads(raw.decode("utf-8"))
+ except Exception as exc:
+ raise BiliError(-1, "B 站返回了非 JSON 内容(可能触发了风控)") from exc
+ if payload.get("code") != 0:
+ raise BiliError(payload.get("code", -1), payload.get("message", ""))
+ return payload["data"]
+
+
+# ------------------------------------------------------------------ WBI 签名
+
+def _mixin_key(img_url: str, sub_url: str) -> str:
+ img = os.path.basename(urllib.parse.urlparse(img_url).path).split(".")[0]
+ sub = os.path.basename(urllib.parse.urlparse(sub_url).path).split(".")[0]
+ raw = img + sub
+ return "".join(raw[i] for i in MIXIN_KEY_ENC_TAB)[:32]
+
+
+def _sign(params: dict, mixin_key: str) -> str:
+ params = dict(params)
+ params["wts"] = int(time.time())
+ items = sorted(params.items())
+ query = urllib.parse.urlencode(
+ [(k, "".join(c for c in str(v) if c not in "!'()*")) for k, v in items])
+ w_rid = hashlib.md5((query + mixin_key).encode()).hexdigest()
+ return f"{query}&w_rid={w_rid}"
+
+
+# ------------------------------------------------------------------ 业务接口
+
+def login_status(cookie: str | None) -> dict:
+ """未登录时 nav 返回 code=-101,但 data.wbi_img 仍然可用,所以这里不抛异常。"""
+ try:
+ raw = _get(API_NAV, cookie=cookie)
+ payload = json.loads(raw.decode("utf-8"))
+ except Exception as exc:
+ return {"isLogin": False, "uname": "", "error": str(exc)}
+
+ data = payload.get("data") or {}
+ return {"isLogin": bool(data.get("isLogin")), "uname": data.get("uname") or "",
+ "code": payload.get("code")}
+
+
+def search(keyword: str, page: int = 1, page_size: int = 20,
+ cookie: str | None = None) -> dict:
+ """站内搜索。必须走 WBI 签名接口 —— 老的无签名接口已被封(返回非 JSON)。"""
+ nav = json.loads(_get(API_NAV, cookie=cookie).decode("utf-8"))
+ wbi = (nav.get("data") or {}).get("wbi_img") or {}
+ img_url, sub_url = wbi.get("img_url"), wbi.get("sub_url")
+ if not img_url or not sub_url:
+ raise BiliError(-1, "无法获取 WBI 密钥")
+
+ query = _sign({"search_type": "video", "keyword": keyword,
+ "page": page, "page_size": page_size}, _mixin_key(img_url, sub_url))
+
+ payload = json.loads(_get(f"{API_SEARCH}?{query}", cookie=cookie).decode("utf-8"))
+ if payload.get("code") != 0:
+ raise BiliError(payload.get("code", -1), payload.get("message", ""))
+
+ data = payload.get("data") or {}
+ results = []
+ for item in (data.get("result") or []):
+ title = re.sub(r"]*>|", "", item.get("title") or "")
+ pic = item.get("pic") or ""
+ if pic.startswith("//"):
+ pic = "https:" + pic
+ results.append({
+ "bvid": item.get("bvid"),
+ "title": title,
+ "author": item.get("author"),
+ "duration": item.get("duration"), # 字符串,如 "2:28"
+ "play": item.get("play"),
+ "danmaku": item.get("danmaku"),
+ "pic": pic,
+ "description": item.get("description") or item.get("desc") or "",
+ })
+ return {"items": results, "page": page,
+ "numResults": data.get("numResults"),
+ "hasMore": len(results) >= page_size}
+
+
+def video_detail(bvid: str, cookie: str | None = None) -> dict:
+ data = _api(API_VIEW.format(bvid=bvid), cookie)
+ pic = data.get("pic") or ""
+ if pic.startswith("//"):
+ pic = "https:" + pic
+ return {
+ "bvid": data.get("bvid"),
+ "title": data.get("title"),
+ "pic": pic,
+ "duration": data.get("duration"), # int 秒
+ "desc": data.get("desc") or "",
+ "owner": (data.get("owner") or {}).get("name"),
+ "stat": data.get("stat") or {},
+ "pages": [{"cid": p.get("cid"), "page": p.get("page"),
+ "part": p.get("part"), "duration": p.get("duration")}
+ for p in (data.get("pages") or [])],
+ }
+
+
+def play_url(bvid: str, cid: int, cookie: str | None = None) -> dict:
+ """qn=127 请求最高清晰度,让服务端把可用的清晰度都返回。
+
+ ★ 关键:清晰度列表必须按 `dash.video` 里**真正拿到的流**来建,不能用
+ `accept_quality` / `support_formats` —— 那两个只反映视频"标称"支持什么,
+ 与当前账号能否下载无关。实测:未登录时 accept_quality 仍列出 120(4K),
+ 但 dash.video 里只有 32(480P)。若按 accept_quality 建列表,用户会选到根本拿不到的清晰度,
+ 点下载才报错。
+ """
+ data = _api(API_PLAYURL.format(bvid=bvid, cid=cid), cookie)
+ accept = data.get("accept_quality") or []
+ formats = {f.get("quality"): f for f in (data.get("support_formats") or [])}
+
+ dash = data.get("dash") or {}
+ videos = [{"quality": s.get("id"), "baseUrl": s.get("baseUrl"),
+ "backupUrl": s.get("backupUrl") or [], "codecs": s.get("codecs") or "",
+ "width": s.get("width"), "height": s.get("height"),
+ "bandwidth": s.get("bandwidth")}
+ for s in (dash.get("video") or []) if s.get("baseUrl")]
+ audios = [{"baseUrl": s.get("baseUrl"), "backupUrl": s.get("backupUrl") or [],
+ "codecs": s.get("codecs") or "", "bandwidth": s.get("bandwidth")}
+ for s in (dash.get("audio") or []) if s.get("baseUrl")]
+
+ # 同一清晰度可能有多种编码,取码率最高的一条来展示尺寸
+ best: dict[int, dict] = {}
+ for s in videos:
+ q = s["quality"]
+ if q is None:
+ continue
+ if q not in best or (s["bandwidth"] or 0) > (best[q]["bandwidth"] or 0):
+ best[q] = s
+
+ qualities = []
+ for q in sorted(best, reverse=True):
+ s = best[q]
+ f = formats.get(q) or {}
+ qualities.append({
+ "quality": q,
+ "label": f.get("new_description") or QN_NAME.get(q, str(q)),
+ "width": s.get("width"), "height": s.get("height"),
+ "codecs": s.get("codecs"),
+ "bandwidth": s.get("bandwidth"),
+ })
+
+ return {"qualities": qualities, "video": videos, "audio": audios,
+ # 实际可下载的最高清晰度(用它判断是否被"未登录"限制到 480P)
+ "maxQuality": max(best) if best else 0,
+ # 视频标称支持的清晰度,仅作参考展示
+ "advertisedQuality": max(accept) if accept else 0,
+ "duration": dash.get("duration") or 0}
+
+
+# ------------------------------------------------------------------ 下载
+
+def _state_path(dest: str) -> str:
+ return dest + ".parts.json"
+
+
+def _resource_key(url: str) -> str:
+ """取 URL 中标识资源的稳定部分。
+
+ baseUrl 带 e= 时效签名,重跑时签名字段与 CDN 域名都会变;只有 path
+ (含 avid/cid/清晰度)保持不变,所以续传状态必须以 path 为键。
+ """
+ return urllib.parse.urlparse(url).path
+
+
+def _load_state(dest: str, url: str, total: int, n: int) -> list[int]:
+ if not os.path.exists(dest) or os.path.getsize(dest) != total:
+ return [0] * n
+ try:
+ with open(_state_path(dest), "r", encoding="utf-8") as f:
+ st = json.load(f)
+ if st.get("key") != _resource_key(url) or st.get("total") != total:
+ return [0] * n
+ offs = [int(x) for x in st["offsets"]]
+ if len(offs) != n:
+ return [0] * n
+ return [max(0, min(o, total)) for o in offs]
+ except Exception:
+ return [0] * n
+
+
+def _save_state(dest: str, url: str, total: int, offsets: list[int]) -> None:
+ tmp = _state_path(dest) + ".tmp"
+ try:
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump({"key": _resource_key(url), "total": total, "offsets": offsets}, f)
+ os.replace(tmp, _state_path(dest))
+ except Exception:
+ pass
+
+
+def probe(url: str, referer: str, cookie: str | None) -> tuple[int, bool]:
+ """探测总长度与是否支持 Range 分片。"""
+ req = urllib.request.Request(
+ url, headers=_headers(referer, cookie, {"Range": "bytes=0-0"}))
+ with urllib.request.urlopen(req, timeout=30) as r:
+ if r.status == 206:
+ cr = r.headers.get("Content-Range", "")
+ return (int(cr.split("/")[-1]) if "/" in cr else 0), True
+ return int(r.headers.get("Content-Length") or 0), False
+
+
+def _download_range(url: str, fd, referer: str, cookie: str | None,
+ start: int, end: int, on_bytes, cancel, retries: int = 24,
+ on_chunk=None) -> None:
+ cur = start
+ attempt = 0
+ while cur <= end:
+ if cancel is not None and cancel.is_set():
+ raise InterruptedError("已取消")
+ try:
+ req = urllib.request.Request(
+ url, headers=_headers(referer, cookie, {"Range": f"bytes={cur}-{end}"}))
+ with urllib.request.urlopen(req, timeout=45) as r:
+ if r.status != 206:
+ raise IOError(f"服务器未返回 206 (got {r.status})")
+ os.lseek(fd, cur, os.SEEK_SET)
+ while True:
+ chunk = r.read(CHUNK)
+ if not chunk:
+ break
+ os.write(fd, chunk)
+ cur += len(chunk)
+ on_bytes(len(chunk))
+ if on_chunk:
+ on_chunk(cur)
+ if cur <= end:
+ raise IOError("连接提前结束")
+ attempt = 0
+ except InterruptedError:
+ raise
+ except Exception as e:
+ attempt += 1
+ if attempt > retries:
+ raise RuntimeError(f"分片重试 {retries} 次仍失败: {e}")
+ time.sleep(min(0.5 * attempt, 5))
+
+
+def download_fast(url: str, dest: str, referer: str, cookie: str | None,
+ threads: int = 16, on_progress=None, cancel=None,
+ label: str = "视频") -> dict:
+ """多线程分段加速下载,支持跨进程断点续传。返回统计信息。"""
+ total, ranges_ok = probe(url, referer, cookie)
+
+ state = {"done": 0, "total": total, "t0": time.time(), "resumed": 0}
+
+ def report():
+ if not on_progress:
+ return
+ el = max(time.time() - state["t0"], 1e-6)
+ on_progress({
+ "label": label,
+ "downloaded": state["done"],
+ "total": total,
+ "percent": (state["done"] * 100 / total) if total else 0,
+ "speed": state["done"] / el,
+ "threads": 1 if not ranges_ok else max(1, min(threads, 64)),
+ "resumed": state["resumed"],
+ })
+
+ lock = threading.Lock()
+
+ def on_bytes(n):
+ with lock:
+ state["done"] += n
+
+ if not ranges_ok or total < SEG_MIN or threads <= 1:
+ have = os.path.getsize(dest) if os.path.exists(dest) else 0
+ if have and have < total:
+ state["done"] = have
+ state["resumed"] = have
+ report()
+ mode = "ab" if have else "wb"
+ req_headers = _headers(referer, cookie,
+ {"Range": f"bytes={have}-"} if have else None)
+ with urllib.request.urlopen(urllib.request.Request(url, headers=req_headers),
+ timeout=45) as r:
+ if have and r.status != 206:
+ have = 0
+ mode = "wb"
+ state["done"] = 0
+ with open(dest, mode) as f:
+ while True:
+ if cancel is not None and cancel.is_set():
+ raise InterruptedError("已取消")
+ chunk = r.read(CHUNK)
+ if not chunk:
+ break
+ f.write(chunk)
+ on_bytes(len(chunk))
+ report()
+ return {"total": total, "threads": 1, "resumed": state["resumed"]}
+
+ n = max(1, min(threads, 64))
+ seg = total // n
+ spans = [(i * seg, total - 1 if i == n - 1 else (i + 1) * seg - 1) for i in range(n)]
+
+ offsets = _load_state(dest, url, total, n)
+ if not os.path.exists(dest) or os.path.getsize(dest) != total:
+ with open(dest, "wb") as f:
+ f.truncate(total)
+ offsets = [0] * n
+
+ resume_done = sum(offsets)
+ if resume_done:
+ state["done"] = resume_done
+ state["resumed"] = resume_done
+ report()
+
+ last_flush = [time.time()]
+ errors: list[Exception] = []
+ state_lock = threading.Lock()
+
+ def flush(force=False):
+ with state_lock:
+ now = time.time()
+ if not force and now - last_flush[0] < 2.0:
+ return
+ last_flush[0] = now
+ _save_state(dest, url, total, offsets)
+
+ def worker(i, s, e):
+ try:
+ fd = os.open(dest, os.O_RDWR | getattr(os, "O_BINARY", 0))
+ try:
+ def on_chunk(cur_abs, i=i, s=s):
+ with state_lock:
+ offsets[i] = cur_abs - s + 1
+ flush()
+ _download_range(url, fd, referer, cookie, s + offsets[i], e,
+ on_bytes, cancel, on_chunk=on_chunk)
+ finally:
+ os.close(fd)
+ with state_lock:
+ offsets[i] = e - s + 1
+ except InterruptedError as ex:
+ errors.append(ex)
+ except Exception as ex:
+ errors.append(ex)
+
+ t0 = time.time()
+ reporter_stop = threading.Event()
+
+ def reporter():
+ while not reporter_stop.wait(0.4):
+ report()
+
+ threading.Thread(target=reporter, daemon=True).start()
+
+ ts = [threading.Thread(target=worker, args=(i, s, e), daemon=True)
+ for i, (s, e) in enumerate(spans)]
+ for t in ts:
+ t.start()
+ for t in ts:
+ t.join()
+ reporter_stop.set()
+
+ if any(isinstance(e, InterruptedError) for e in errors):
+ flush(force=True)
+ raise InterruptedError("已取消")
+ if errors:
+ flush(force=True)
+ raise RuntimeError(f"下载失败(进度已保存, 重跑可续传): {errors[0]}")
+
+ if os.path.getsize(dest) != total:
+ raise RuntimeError(f"大小不符: {os.path.getsize(dest)} != {total}")
+ if os.path.exists(_state_path(dest)):
+ os.remove(_state_path(dest))
+
+ report()
+ return {"total": total, "threads": n, "resumed": resume_done,
+ "elapsed": time.time() - t0}
+
+
+# ------------------------------------------------------------------ ffmpeg
+
+def find_ffmpeg() -> str | None:
+ exe = shutil.which("ffmpeg")
+ if exe:
+ return exe
+ for p in (r"C:\ffmpeg\bin\ffmpeg.exe",
+ r"C:\Users\Administrator\miniconda3\Library\bin\ffmpeg.exe"):
+ if os.path.exists(p):
+ return p
+ return None
+
+
+def merge(video: str, audio: str | None, out: str) -> None:
+ ff = find_ffmpeg()
+ if not ff:
+ raise RuntimeError("找不到 ffmpeg")
+ cmd = [ff, "-y", "-hide_banner", "-loglevel", "error", "-i", video]
+ if audio:
+ cmd += ["-i", audio, "-map", "0:v:0", "-map", "1:a:0", "-c", "copy"]
+ else:
+ cmd += ["-c", "copy"]
+ cmd += ["-movflags", "+faststart", out]
+ subprocess.run(cmd, check=True, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
+
+
+# ------------------------------------------------------------------ 工具
+
+def safe_name(name: str) -> str:
+ name = re.sub(r'[\\/:*?"<>|\r\n\t]', "_", name).strip(" .")
+ return (name or "video")[:120]
+
+
+def pick_streams(play: dict, quality: int, prefer_avc: bool = True):
+ """在指定清晰度下选码流:优先 H.264(兼容性最好),音频取码率最大。"""
+ pool = [s for s in play["video"] if s["quality"] == quality]
+ if not pool:
+ return None, None
+ if prefer_avc:
+ avc = [s for s in pool if s["codecs"].startswith(("avc", "av01"))]
+ pool = avc or pool
+ video = max(pool, key=lambda s: s["bandwidth"])
+ if not play["audio"]:
+ return video, None
+ audio = max(play["audio"], key=lambda s: s["bandwidth"])
+ return video, audio
diff --git a/server.py b/server.py
new file mode 100644
index 0000000..84ddb3f
--- /dev/null
+++ b/server.py
@@ -0,0 +1,438 @@
+# -*- coding: utf-8 -*-
+"""
+server.py —— BiliDownloader 本地服务(零第三方依赖)
+
+只监听 127.0.0.1。职责:
+ * 托管前端构建产物 (ui/dist)
+ * 提供 JSON API(搜索 / 详情 / 清晰度 / 开始下载 / 取消 / 配置)
+ * 用 Server-Sent Events 把下载进度实时推给浏览器
+
+用法:
+ python server.py [--port 8799] [--no-open]
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import mimetypes
+import os
+import queue
+import subprocess
+import sys
+import threading
+import time
+import traceback
+import urllib.parse
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+import engine
+
+BASE = os.path.dirname(os.path.abspath(__file__))
+DIST = os.path.join(BASE, "ui", "dist")
+CONFIG_PATH = os.path.join(BASE, "config.json")
+
+DEFAULT_CONFIG = {
+ "sessdata": "",
+ "outputDir": os.path.join(os.path.expanduser("~"), "Videos", "BiliDownloader"),
+ "threads": 16,
+ "preferAvc": True,
+ "keepParts": False,
+}
+
+_config_lock = threading.Lock()
+_job_lock = threading.Lock()
+_job: dict | None = None # 当前/最近一次下载任务
+_cancel: threading.Event | None = None
+_streams: dict[str, dict] = {} # (bvid,cid) -> playurl 结果
+_subscribers: list[queue.Queue] = []
+_play_cache: dict[str, dict] = {}
+
+
+# ------------------------------------------------------------------ 配置
+
+def load_config() -> dict:
+ cfg = dict(DEFAULT_CONFIG)
+ try:
+ with open(CONFIG_PATH, "r", encoding="utf-8") as f:
+ cfg.update(json.load(f))
+ except Exception:
+ pass
+ cfg["threads"] = max(1, min(64, int(cfg.get("threads") or 16)))
+ return cfg
+
+
+def save_config(cfg: dict) -> dict:
+ merged = load_config()
+ merged.update({k: v for k, v in cfg.items() if k in DEFAULT_CONFIG})
+ merged["threads"] = max(1, min(64, int(merged.get("threads") or 16)))
+ with _config_lock:
+ tmp = CONFIG_PATH + ".tmp"
+ with open(tmp, "w", encoding="utf-8") as f:
+ json.dump(merged, f, ensure_ascii=False, indent=2)
+ os.replace(tmp, CONFIG_PATH)
+ return merged
+
+
+def cookie_of(cfg: dict) -> str | None:
+ s = (cfg.get("sessdata") or "").strip()
+ return f"SESSDATA={s}" if s else None
+
+
+# ------------------------------------------------------------------ 事件广播
+
+def broadcast(event: str, data: dict) -> None:
+ payload = f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
+ dead = []
+ for q in list(_subscribers):
+ try:
+ q.put_nowait(payload)
+ except Exception:
+ dead.append(q)
+ for q in dead:
+ try:
+ _subscribers.remove(q)
+ except ValueError:
+ pass
+
+
+# ------------------------------------------------------------------ 下载任务
+
+def start_download(bvid: str, cid: int, quality: int, title: str) -> dict:
+ global _job, _cancel
+
+ with _job_lock:
+ if _job and _job.get("state") == "running":
+ raise RuntimeError("已有下载任务在进行中")
+
+ cfg = load_config()
+ key = f"{bvid}:{cid}"
+ play = _streams.get(key)
+ if not play:
+ raise RuntimeError("播放地址不存在或已过期,请重新选择视频")
+
+ video, audio = engine.pick_streams(play, quality, cfg.get("preferAvc", True))
+ if video is None:
+ raise RuntimeError("当前清晰度没有可用的视频流")
+
+ out_dir = cfg["outputDir"]
+ os.makedirs(out_dir, exist_ok=True)
+ base = engine.safe_name(title)
+ # 文件名必须带上清晰度:否则同一视频下不同清晰度会互相覆盖
+ # (实测踩过:480P 的验收下载把之前下好的 4K 文件直接盖掉了)
+ q_label = next((q["label"] for q in play.get("qualities", [])
+ if q["quality"] == quality), str(quality))
+ q_tag = engine.safe_name(q_label).replace(" ", "")
+ stamp = time.strftime("%Y%m%d%H%M%S")
+ final_path = os.path.join(out_dir, f"{base} [{q_tag}].mp4")
+ tmp_v = os.path.join(out_dir, f".{base}.{cid}.{stamp}.video.m4s")
+ tmp_a = os.path.join(out_dir, f".{base}.{cid}.{stamp}.audio.m4s")
+
+ _cancel = threading.Event()
+ _job = {
+ "state": "running", "stage": "下载视频流", "bvid": bvid, "cid": cid,
+ "title": title, "quality": quality, "outputPath": final_path,
+ "startedAt": time.time(), "video": None, "audio": None,
+ "progress": {"video": None, "audio": None}, "error": None,
+ "resumed": 0, "threads": cfg["threads"],
+ }
+
+ broadcast("job", dict(_job))
+ threading.Thread(target=_run_job, args=(video, audio, tmp_v, tmp_a, final_path,
+ cfg, bvid, cid), daemon=True).start()
+ return dict(_job)
+
+
+def _run_job(video, audio, tmp_v, tmp_a, final_path, cfg, bvid, cid) -> None:
+ global _job
+ cookie = cookie_of(cfg)
+ referer = f"https://www.bilibili.com/video/{bvid}"
+ threads = cfg["threads"]
+
+ def make_on_progress(which):
+ def on_progress(info):
+ _job["progress"][which] = info
+ broadcast("progress", {"which": which, **info, "stage": _job["stage"]})
+ return on_progress
+
+ try:
+ _job["stage"] = "下载视频流"
+ broadcast("stage", {"stage": _job["stage"]})
+ vres = engine.download_fast(video["baseUrl"], tmp_v, referer, cookie,
+ threads, make_on_progress("video"), _cancel, "视频")
+ _job["resumed"] = vres.get("resumed", 0)
+
+ if audio:
+ _job["stage"] = "下载音频流"
+ broadcast("stage", {"stage": _job["stage"]})
+ engine.download_fast(audio["baseUrl"], tmp_a, referer, cookie,
+ max(1, min(8, threads)), make_on_progress("audio"),
+ _cancel, "音频")
+
+ _job["stage"] = "ffmpeg 封装"
+ broadcast("stage", {"stage": _job["stage"]})
+ engine.merge(tmp_v, tmp_a if audio else None, final_path)
+
+ if not cfg.get("keepParts"):
+ for p in (tmp_v, tmp_a):
+ try:
+ if os.path.exists(p):
+ os.remove(p)
+ except OSError:
+ pass
+
+ _job["state"] = "done"
+ _job["stage"] = "完成"
+ _job["size"] = os.path.getsize(final_path)
+ _job["elapsed"] = time.time() - _job["startedAt"]
+ broadcast("job", dict(_job))
+
+ except InterruptedError:
+ for p in (final_path,):
+ try:
+ if os.path.exists(p):
+ os.remove(p)
+ except OSError:
+ pass
+ if not cfg.get("keepParts"):
+ for p in (tmp_v, tmp_a):
+ try:
+ if os.path.exists(p):
+ os.remove(p)
+ except OSError:
+ pass
+ _job["state"] = "cancelled"
+ _job["stage"] = "已取消,未完成的文件已清理"
+ broadcast("job", dict(_job))
+ except Exception as exc:
+ for p in (final_path,):
+ try:
+ if os.path.exists(p):
+ os.remove(p)
+ except OSError:
+ pass
+ _job["state"] = "error"
+ _job["error"] = str(exc)
+ broadcast("job", dict(_job))
+ traceback.print_exc()
+
+
+# ------------------------------------------------------------------ HTTP
+
+class Handler(BaseHTTPRequestHandler):
+ server_version = "BiliDownloader"
+
+ def log_message(self, fmt, *args):
+ pass # 静音访问日志
+
+ # ---------- helpers
+ def _json(self, obj, status=200):
+ body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _error(self, message, status=400):
+ self._json({"ok": False, "error": str(message)}, status)
+
+ def _query(self) -> dict:
+ parsed = urllib.parse.urlparse(self.path)
+ return {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}, parsed.path
+
+ def _body(self) -> dict:
+ length = int(self.headers.get("Content-Length") or 0)
+ if not length:
+ return {}
+ return json.loads(self.rfile.read(length).decode("utf-8"))
+
+ # ---------- routes
+ def do_GET(self):
+ params, path = self._query()
+ try:
+ if path == "/api/config":
+ cfg = load_config()
+ cfg["configPath"] = CONFIG_PATH
+ cfg["ffmpeg"] = engine.find_ffmpeg()
+ return self._json(cfg)
+
+ if path == "/api/login":
+ cfg = load_config()
+ return self._json(engine.login_status(cookie_of(cfg)))
+
+ if path == "/api/search":
+ cfg = load_config()
+ return self._json(engine.search(
+ params.get("q", ""), int(params.get("page", 1)), 20, cookie_of(cfg)))
+
+ if path == "/api/video":
+ cfg = load_config()
+ return self._json(engine.video_detail(params["bvid"], cookie_of(cfg)))
+
+ if path == "/api/playurl":
+ cfg = load_config()
+ bvid, cid = params["bvid"], int(params["cid"])
+ play = engine.play_url(bvid, cid, cookie_of(cfg))
+ _streams[f"{bvid}:{cid}"] = play
+ return self._json(play)
+
+ if path == "/api/job":
+ return self._json(_job or {})
+
+ if path == "/api/events":
+ return self._sse()
+
+ if path.startswith("/api/"):
+ return self._error("未知接口", 404)
+
+ return self._static(path)
+ except engine.BiliError as exc:
+ return self._error(f"{exc.message}(code {exc.code})", 502)
+ except Exception as exc:
+ traceback.print_exc()
+ return self._error(exc, 500)
+
+ def do_POST(self):
+ _, path = self._query()
+ try:
+ if path == "/api/config":
+ return self._json(save_config(self._body()))
+
+ if path == "/api/download":
+ data = self._body()
+ job = start_download(data["bvid"], int(data["cid"]),
+ int(data["quality"]), data.get("title") or "video")
+ return self._json(job)
+
+ if path == "/api/cancel":
+ if _cancel:
+ _cancel.set()
+ return self._json({"ok": True})
+
+ if path == "/api/open-folder":
+ target = (self._body() or {}).get("path") or load_config()["outputDir"]
+ if os.path.exists(target):
+ subprocess.Popen(["explorer", "/select,", os.path.normpath(target)])
+ else:
+ os.makedirs(target, exist_ok=True)
+ subprocess.Popen(["explorer", os.path.normpath(target)])
+ return self._json({"ok": True})
+
+ return self._error("未知接口", 404)
+ except Exception as exc:
+ traceback.print_exc()
+ return self._error(exc, 500)
+
+ # ---------- SSE
+ def _sse(self):
+ q: queue.Queue = queue.Queue()
+ _subscribers.append(q)
+ self.send_response(200)
+ self.send_header("Content-Type", "text/event-stream; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("Connection", "keep-alive")
+ self.end_headers()
+ try:
+ hello = f"event: job\ndata: {json.dumps(_job or {}, ensure_ascii=False)}\n\n"
+ self.wfile.write(hello.encode("utf-8"))
+ self.wfile.flush()
+ while True:
+ try:
+ payload = q.get(timeout=15)
+ self.wfile.write(payload.encode("utf-8"))
+ except queue.Empty:
+ self.wfile.write(b": ping\n\n") # 心跳,防代理断连
+ self.wfile.flush()
+ except (BrokenPipeError, ConnectionResetError, OSError):
+ pass
+ finally:
+ try:
+ _subscribers.remove(q)
+ except ValueError:
+ pass
+
+ # ---------- 静态文件
+ def _static(self, path: str):
+ if not os.path.isdir(DIST):
+ body = (""
+ "
前端还没构建
"
+ "请在 ui 目录执行 npm run build,"
+ "或直接用 npm run dev 起开发服务器。
").encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ return
+
+ rel = urllib.parse.unquote(path.lstrip("/")) or "index.html"
+ target = os.path.normpath(os.path.join(DIST, rel))
+ # 防目录穿越
+ if not target.startswith(os.path.normpath(DIST)):
+ return self._error("非法路径", 403)
+ if not os.path.isfile(target):
+ target = os.path.join(DIST, "index.html") # SPA 回退
+ if not os.path.isfile(target):
+ return self._error("Not found", 404)
+
+ ctype = mimetypes.guess_type(target)[0] or "application/octet-stream"
+ if ctype.startswith("text/") or ctype in ("application/javascript", "application/json"):
+ ctype += "; charset=utf-8"
+ with open(target, "rb") as f:
+ body = f.read()
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Cache-Control", "no-store" if target.endswith(".html") else "public, max-age=3600")
+ self.end_headers()
+ self.wfile.write(body)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--port", type=int, default=8799)
+ ap.add_argument("--no-open", action="store_true")
+ args = ap.parse_args()
+
+ cfg = load_config()
+ if not os.path.exists(CONFIG_PATH):
+ save_config(cfg)
+
+ httpd = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
+ url = f"http://127.0.0.1:{args.port}/"
+ print(f"BiliDownloader 服务已启动: {url}", flush=True)
+ print(f"配置: {CONFIG_PATH}", flush=True)
+ print(f"ffmpeg: {engine.find_ffmpeg() or '未找到(合并会失败)'}", flush=True)
+
+ if not args.no_open:
+ threading.Thread(target=_open_browser, args=(url,), daemon=True).start()
+
+ try:
+ httpd.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ return 0
+
+
+def _open_browser(url: str) -> None:
+ time.sleep(0.8)
+ try:
+ edge = None
+ for p in (r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
+ r"C:\Program Files\Microsoft\Edge\Application\msedge.exe"):
+ if os.path.exists(p):
+ edge = p
+ break
+ if edge:
+ # 应用模式窗口:无地址栏、无标签页,看起来就是一个独立应用
+ subprocess.Popen([edge, f"--app={url}", "--window-size=1280,860"])
+ else:
+ subprocess.Popen(["cmd", "/c", "start", "", url], shell=False)
+ except Exception:
+ pass
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/start.cmd b/start.cmd
new file mode 100644
index 0000000..9c14eb7
--- /dev/null
+++ b/start.cmd
@@ -0,0 +1,21 @@
+@echo off
+rem BiliDownloader 启动脚本:起本地服务 + 开 Edge 应用模式窗口
+setlocal
+cd /d "%~dp0"
+set "PYTHONIOENCODING=utf-8"
+
+rem 已经有实例在跑,就直接把窗口拉起来,不要再起一个服务
+powershell -NoProfile -Command "if (Get-NetTCPConnection -LocalPort 8799 -State Listen -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }" >nul 2>nul
+if %errorlevel%==0 (
+ start "" "msedge" --app=http://127.0.0.1:8799/ --window-size=1280,860
+ exit /b 0
+)
+
+rem 优先用 pythonw(无控制台窗口),没有就退回 python
+where pythonw >nul 2>nul
+if %errorlevel%==0 (
+ start "" pythonw "%~dp0server.py" --port 8799
+) else (
+ start "" /min python "%~dp0server.py" --port 8799
+)
+exit /b 0
diff --git a/stop.cmd b/stop.cmd
new file mode 100644
index 0000000..687b75c
--- /dev/null
+++ b/stop.cmd
@@ -0,0 +1,8 @@
+@echo off
+rem 停止 BiliDownloader 本地服务
+setlocal
+for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8799" ^| findstr LISTENING') do (
+ taskkill /PID %%a /F >nul 2>nul
+)
+echo 已停止 BiliDownloader 服务。
+exit /b 0
diff --git a/tools/diag.py b/tools/diag.py
new file mode 100644
index 0000000..396ce23
--- /dev/null
+++ b/tools/diag.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+"""诊断:nav 为何报未登录 + dash.video 实际内容。"""
+import json
+import sys
+import urllib.request
+
+sys.path.insert(0, r"E:\deepseek\BiliDownloaderWeb")
+import engine
+
+cfg = json.load(open(r"E:\deepseek\BiliDownloaderWeb\config.json", encoding="utf-8"))
+sess = cfg["sessdata"]
+print("config 里 sessdata 长度 =", len(sess))
+print("前 24 字符 =", sess[:24])
+print("后 24 字符 =", sess[-24:])
+
+print("\n--- 直接请求 nav,看原始返回 ---")
+req = urllib.request.Request(
+ engine.API_NAV,
+ headers={"User-Agent": engine.UA, "Referer": "https://www.bilibili.com",
+ "Cookie": "SESSDATA=" + sess})
+with urllib.request.urlopen(req, timeout=30) as r:
+ raw = json.loads(r.read().decode("utf-8"))
+print("code =", raw.get("code"), " message =", raw.get("message"))
+d = raw.get("data") or {}
+print("isLogin =", d.get("isLogin"), " uname =", d.get("uname"))
+print("有 wbi_img =", bool(d.get("wbi_img")))
+
+print("\n--- dash.video 实际内容(qn=127)---")
+vid = engine.video_detail("BV1HVBAYLEAo", "SESSDATA=" + sess)
+cid = vid["pages"][0]["cid"]
+p = engine.play_url("BV1HVBAYLEAo", cid, "SESSDATA=" + sess)
+print("maxQuality =", p["maxQuality"])
+for s in p["video"]:
+ print(f" 视频流 qn={s['quality']:>4} {s['width']}x{s['height']} codec={s['codecs']} {s['bandwidth']//1000}kbps")
+for s in p["audio"]:
+ print(f" 音频流 {s['codecs']} {s['bandwidth']//1000}kbps")
+print("qualities 的 width/height 字段示例:", p["qualities"][0])
diff --git a/tools/e2e_download.py b/tools/e2e_download.py
new file mode 100644
index 0000000..6e8e4e0
--- /dev/null
+++ b/tools/e2e_download.py
@@ -0,0 +1,165 @@
+# -*- coding: utf-8 -*-
+"""
+e2e_download.py —— 端到端验收:通过服务的 HTTP 接口真的下一次,并校验产物。
+
+流程:playurl -> POST /api/download -> 订阅 SSE 收进度 -> 等完成 -> ffprobe 校验
+"""
+
+import json
+import os
+import subprocess
+import sys
+import threading
+import time
+import urllib.request
+
+BASE = "http://127.0.0.1:8799"
+BVID = sys.argv[1] if len(sys.argv) > 1 else "BV1HVBAYLEAo"
+
+ok_count = 0
+fail_count = 0
+
+
+def check(name, ok, detail=""):
+ global ok_count, fail_count
+ if ok:
+ ok_count += 1
+ print(f" [OK] {name}" + (f" — {detail}" if detail else ""))
+ else:
+ fail_count += 1
+ print(f" [XX] {name}" + (f" — {detail}" if detail else ""))
+
+
+def get(path):
+ with urllib.request.urlopen(BASE + path, timeout=60) as r:
+ return json.loads(r.read().decode("utf-8"))
+
+
+def post(path, payload):
+ req = urllib.request.Request(
+ BASE + path, data=json.dumps(payload).encode("utf-8"),
+ headers={"Content-Type": "application/json"})
+ with urllib.request.urlopen(req, timeout=60) as r:
+ return json.loads(r.read().decode("utf-8"))
+
+
+print("=" * 62)
+print(f"端到端下载验收: {BVID}")
+print("=" * 62)
+
+video = get(f"/api/video?bvid={BVID}")
+cid = video["pages"][0]["cid"]
+print(f"\n视频: {video['title']}")
+print(f"UP主: {video['owner']} 时长: {video['duration']}s")
+
+play = get(f"/api/playurl?bvid={BVID}&cid={cid}")
+check("拿到清晰度列表", len(play["qualities"]) > 0,
+ " / ".join(q["label"] for q in play["qualities"]))
+quality = play["qualities"][0]["quality"]
+print(f"选择清晰度: {quality} ({play['qualities'][0]['label']})")
+
+# 先确认没有正在跑的任务
+job0 = get("/api/job")
+check("初始无进行中的任务", job0.get("state") != "running", str(job0.get("state")))
+
+# 订阅 SSE
+events = []
+prog_samples = []
+stop = threading.Event()
+
+
+def listen():
+ try:
+ with urllib.request.urlopen(BASE + "/api/events", timeout=600) as r:
+ event = None
+ while not stop.is_set():
+ line = r.readline().decode("utf-8", "replace").strip()
+ if line.startswith("event:"):
+ event = line.split(":", 1)[1].strip()
+ elif line.startswith("data:"):
+ data = json.loads(line.split(":", 1)[1].strip())
+ events.append((event, data))
+ if event == "progress":
+ prog_samples.append(data)
+ except Exception as exc:
+ if not stop.is_set():
+ print(" SSE 读取结束:", exc)
+
+
+t = threading.Thread(target=listen, daemon=True)
+t.start()
+time.sleep(0.8)
+
+print("\n--- 开始下载 ---")
+t0 = time.time()
+job = post("/api/download", {"bvid": BVID, "cid": cid, "quality": quality,
+ "title": video["title"]})
+check("已受理下载任务", job.get("state") == "running", str(job.get("state")))
+
+deadline = time.time() + 300
+last_state = None
+while time.time() < deadline:
+ time.sleep(1)
+ cur = get("/api/job")
+ if cur.get("state") != last_state:
+ last_state = cur.get("state")
+ print(f" [{time.time() - t0:5.1f}s] state={last_state} stage={cur.get('stage')}")
+ if cur.get("state") in ("done", "error", "cancelled"):
+ break
+
+elapsed = time.time() - t0
+stop.set()
+final = get("/api/job")
+
+check("任务正常结束", final.get("state") == "done", str(final.get("state") or final.get("error")))
+check("收到了进度事件", len(prog_samples) > 0, f"{len(prog_samples)} 条")
+check("同时收到 job/stage 事件", len(events) > 0, f"{len(events)} 条")
+
+out = final.get("outputPath") or ""
+check("产物文件存在", bool(out) and os.path.exists(out), out)
+if out and os.path.exists(out):
+ size = os.path.getsize(out)
+ check("产物大小合理", size > 100_000, f"{size / 1024 / 1024:.1f} MB")
+ ffprobe = None
+ import shutil
+ ffprobe = shutil.which("ffprobe") or r"C:\Users\Administrator\miniconda3\Library\bin\ffprobe.exe"
+ cp = subprocess.run(
+ [ffprobe, "-v", "error", "-show_entries",
+ "format=duration,size:stream=codec_type,codec_name,width,height",
+ "-of", "json", out],
+ capture_output=True, text=True)
+ if cp.returncode == 0:
+ info = json.loads(cp.stdout)
+ streams = info.get("streams", [])
+ kinds = {s["codec_type"]: s for s in streams}
+ check("产物含视频流", "video" in kinds,
+ f"{kinds.get('video', {}).get('codec_name')} "
+ f"{kinds.get('video', {}).get('width')}x{kinds.get('video', {}).get('height')}")
+ check("产物含音频流", "audio" in kinds, str(kinds.get("audio", {}).get("codec_name")))
+ dur = float(info["format"]["duration"])
+ check("时长与源基本一致", abs(dur - video["duration"]) < 5, f"{dur:.1f}s vs {video['duration']}s")
+ else:
+ check("ffprobe 可解析产物", False, cp.stderr.strip()[:120])
+
+ # 全片解码校验
+ ffmpeg = shutil.which("ffmpeg") or r"C:\Users\Administrator\miniconda3\Library\bin\ffmpeg.exe"
+ cp2 = subprocess.run([ffmpeg, "-v", "error", "-i", out, "-f", "null", "-"],
+ capture_output=True, text=True)
+ check("全片解码无错误", cp2.returncode == 0 and not cp2.stderr.strip(),
+ cp2.stderr.strip()[:160] or "clean")
+
+# 临时文件应已清理
+tmp_left = [f for f in os.listdir(os.path.dirname(out)) if f.endswith(".m4s")]
+check("中间文件已清理", len(tmp_left) == 0, f"残留 {len(tmp_left)} 个" if tmp_left else "无残留")
+
+# 并发保护:任务已结束,再来一次应能受理(说明状态机复位)
+print(f"\n耗时 {elapsed:.1f}s")
+print("\n进度采样(每 10 条取 1):")
+for s in prog_samples[::max(1, len(prog_samples) // 10)][:10]:
+ print(f" {s['label']} {s['percent']:5.1f}% {s['downloaded']/1024/1024:6.1f}MB"
+ f"/{s['total']/1024/1024:.1f}MB {s['speed']/1024/1024:.2f}MB/s {s['threads']}线程")
+
+print("\n" + "=" * 62)
+print(f"结果: {ok_count} 通过 / {fail_count} 失败")
+print("=" * 62)
+sys.exit(1 if fail_count else 0)
diff --git a/tools/list_windows.ps1 b/tools/list_windows.ps1
new file mode 100644
index 0000000..9e364e0
--- /dev/null
+++ b/tools/list_windows.ps1
@@ -0,0 +1,44 @@
+Add-Type @"
+using System;
+using System.Text;
+using System.Runtime.InteropServices;
+public class WinEnum {
+ public delegate bool EnumProc(IntPtr hWnd, IntPtr lParam);
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lParam);
+ [DllImport("user32.dll")] public static extern int GetWindowTextLength(IntPtr hWnd);
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, StringBuilder s, int n);
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
+ [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT r);
+ [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
+}
+"@
+
+$found = New-Object System.Collections.ArrayList
+$cb = [WinEnum+EnumProc]{
+ param($h, $l)
+ $len = [WinEnum]::GetWindowTextLength($h)
+ if ($len -gt 0 -and [WinEnum]::IsWindowVisible($h)) {
+ $sb = New-Object System.Text.StringBuilder ($len + 2)
+ [void][WinEnum]::GetWindowText($h, $sb, $sb.Capacity)
+ $t = $sb.ToString()
+ $r = New-Object WinEnum+RECT
+ [void][WinEnum]::GetWindowRect($h, [ref]$r)
+ [void]$found.Add([pscustomobject]@{
+ Title = $t
+ W = $r.R - $r.L
+ H = $r.B - $r.T
+ X = $r.L
+ Y = $r.T
+ })
+ }
+ return $true
+}
+[void][WinEnum]::EnumWindows($cb, [IntPtr]::Zero)
+
+Write-Output "=== 可见顶层窗口中标题含 BiliDownloader 的 ==="
+$found | Where-Object { $_.Title -like "*BiliDownloader*" } |
+ Select-Object Title, W, H, X, Y | Format-Table -AutoSize | Out-String
+
+Write-Output "=== 前 12 个可见窗口(对照)==="
+$found | Sort-Object -Property W -Descending | Select-Object -First 12 Title, W, H |
+ Format-Table -AutoSize | Out-String
diff --git a/tools/smoke_api.py b/tools/smoke_api.py
new file mode 100644
index 0000000..0297b2e
--- /dev/null
+++ b/tools/smoke_api.py
@@ -0,0 +1,52 @@
+# -*- coding: utf-8 -*-
+"""后端 API 冒烟测试。"""
+import json
+import urllib.parse
+import urllib.request
+
+BASE = "http://127.0.0.1:8799"
+
+
+def get(path):
+ with urllib.request.urlopen(BASE + path, timeout=60) as r:
+ return json.loads(r.read().decode("utf-8"))
+
+
+def post(path, payload):
+ data = json.dumps(payload).encode("utf-8")
+ req = urllib.request.Request(BASE + path, data=data,
+ headers={"Content-Type": "application/json"})
+ with urllib.request.urlopen(req, timeout=60) as r:
+ return json.loads(r.read().decode("utf-8"))
+
+
+print("--- /api/config ---")
+cfg = get("/api/config")
+print(f"threads={cfg['threads']} outputDir={cfg['outputDir']}")
+print(f"ffmpeg={cfg['ffmpeg']}")
+print(f"configPath={cfg['configPath']}")
+
+print("\n--- /api/login ---")
+print(get("/api/login"))
+
+print("\n--- /api/search?q=航拍中国 ---")
+s = get("/api/search?q=" + urllib.parse.quote("航拍中国"))
+print(f"numResults={s['numResults']} hasMore={s['hasMore']} 条数={len(s['items'])}")
+for it in s["items"][:5]:
+ print(f" {it['bvid']} {it['title'][:34]} up={it['author']} dur={it['duration']} play={it['play']}")
+print(f" pic = {s['items'][0]['pic']}")
+
+print("\n--- /api/video?bvid=BV1HVBAYLEAo ---")
+v = get("/api/video?bvid=BV1HVBAYLEAo")
+print(f"title={v['title']}")
+print(f"owner={v['owner']} duration={v['duration']}s pages={len(v['pages'])}")
+cid = v["pages"][0]["cid"]
+print(f"cid={cid}")
+
+print("\n--- /api/playurl ---")
+p = get(f"/api/playurl?bvid=BV1HVBAYLEAo&cid={cid}")
+print(f"maxQuality={p['maxQuality']} video流={len(p['video'])} audio流={len(p['audio'])}")
+for q in p["qualities"]:
+ print(f" qn={q['quality']:>4} {q['label']} {q.get('width')}x{q.get('height')}")
+print(f" 4K 候选: " + ", ".join(
+ f"{s['quality']}/{s['codecs']}/{s['bandwidth']//1000}kbps" for s in p["video"] if s["quality"] == 120))
diff --git a/tools/ui-check.mjs b/tools/ui-check.mjs
new file mode 100644
index 0000000..c6840be
--- /dev/null
+++ b/tools/ui-check.mjs
@@ -0,0 +1,196 @@
+/**
+ * ui-check.mjs —— 浏览器端验收(Edge 无头 + CDP)
+ *
+ * 不只是"页面能打开":真的驱动界面做一次搜索、点选结果、等待清晰度解析,
+ * 并读取渲染后的 DOM 断言每一处关键内容。
+ *
+ * 用法: node tools/ui-check.mjs [baseUrl]
+ */
+
+import { spawn } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import WebSocket from '../ui/node_modules/ws/index.js';
+
+const BASE = process.argv[2] || 'http://127.0.0.1:8799/';
+const PORT = 9333;
+
+const EDGE_CANDIDATES = [
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
+];
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+const results = [];
+function check(name, ok, detail = '') {
+ results.push({ name, ok, detail });
+ console.log(`${ok ? ' ✓' : ' ✗'} ${name}${detail ? ` — ${detail}` : ''}`);
+}
+
+async function main() {
+ const fs = await import('node:fs');
+ const edge = EDGE_CANDIDATES.find((p) => fs.existsSync(p));
+ if (!edge) throw new Error('找不到 Edge');
+
+ const profile = mkdtempSync(join(tmpdir(), 'bd-ui-'));
+ const child = spawn(
+ edge,
+ [
+ '--headless=new',
+ '--disable-gpu',
+ '--no-first-run',
+ '--no-default-browser-check',
+ `--remote-debugging-port=${PORT}`,
+ `--user-data-dir=${profile}`,
+ '--window-size=1280,860',
+ BASE,
+ ],
+ { stdio: 'ignore' },
+ );
+
+ let target = null;
+ for (let i = 0; i < 40 && !target; i++) {
+ await sleep(500);
+ try {
+ const res = await fetch(`http://127.0.0.1:${PORT}/json/list`);
+ const list = await res.json();
+ target = list.find((t) => t.type === 'page' && t.url.startsWith('http'));
+ } catch {
+ /* 还没起来 */
+ }
+ }
+ if (!target) throw new Error('CDP 目标未就绪');
+
+ const ws = new WebSocket(target.webSocketDebuggerUrl, { maxPayload: 64 * 1024 * 1024 });
+ await new Promise((res, rej) => {
+ ws.once('open', res);
+ ws.once('error', rej);
+ });
+
+ let id = 0;
+ const pending = new Map();
+ ws.on('message', (raw) => {
+ const msg = JSON.parse(raw.toString());
+ if (msg.id && pending.has(msg.id)) {
+ pending.get(msg.id)(msg);
+ pending.delete(msg.id);
+ }
+ });
+ const send = (method, params = {}) =>
+ new Promise((res) => {
+ const myId = ++id;
+ pending.set(myId, res);
+ ws.send(JSON.stringify({ id: myId, method, params }));
+ });
+
+ const evaluate = async (expression) => {
+ const r = await send('Runtime.evaluate', {
+ expression,
+ awaitPromise: true,
+ returnByValue: true,
+ });
+ if (r.result?.exceptionDetails) {
+ throw new Error(r.result.exceptionDetails.exception?.description || 'JS 异常');
+ }
+ return r.result?.result?.value;
+ };
+
+ await send('Runtime.enable');
+ await sleep(3500); // 等 React 挂载 + 首屏取配置/登录状态
+
+ console.log(`\n目标: ${BASE}\n`);
+
+ // ---------- 1. 首屏渲染 ----------
+ const text = await evaluate('document.body.innerText');
+ check('React 已挂载并渲染', !!text && text.length > 60, `${text?.length ?? 0} 字符`);
+ for (const key of ['BiliDownloader', '搜索站内视频', '未登录', '还没有选择视频', '清晰度', '开始下载', '日志']) {
+ check(`首屏含「${key}」`, text.includes(key));
+ }
+
+ // ---------- 2. 暗色主题 ----------
+ const isDark = await evaluate("document.documentElement.classList.contains('dark')");
+ check('默认深色主题生效', isDark === true);
+ const bg = await evaluate(
+ "getComputedStyle(document.body).backgroundColor",
+ );
+ check('背景取自 shadcn token(非默认白)', bg !== 'rgb(255, 255, 255)' && bg !== 'rgba(0, 0, 0, 0)', bg);
+
+ // ---------- 3. 无横向溢出 ----------
+ const overflow = await evaluate(
+ 'document.documentElement.scrollWidth - document.documentElement.clientWidth',
+ );
+ check('无横向溢出', overflow <= 0, `diff=${overflow}`);
+
+ // ---------- 4. 真的搜一次 ----------
+ console.log('\n —— 驱动一次真实搜索 ——');
+ await evaluate(`(() => {
+ const input = document.querySelector('input');
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
+ setter.call(input, '航拍中国');
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ const form = input.closest('form');
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
+ return true;
+ })()`);
+ await sleep(6000);
+
+ const afterSearch = await evaluate('document.body.innerText');
+ const cardCount = await evaluate(
+ "document.querySelectorAll('button[type=button]').length",
+ );
+ check('搜索结果已渲染出卡片', cardCount >= 10, `${cardCount} 个可点卡片`);
+
+ // ---------- 5. 点选一个结果 → 清晰度解析 ----------
+ console.log('\n —— 点选第一个结果,等待清晰度解析 ——');
+ await evaluate(`(() => {
+ const btn = document.querySelector('button[type=button]');
+ btn.click();
+ return true;
+ })()`);
+ await sleep(9000);
+
+ const afterPick = await evaluate('document.body.innerText');
+ check('已进入待下载状态(出现「保存到」)', afterPick.includes('保存到'));
+ check('详情面板显示 BV 号', /BV[0-9A-Za-z]{10}/.test(afterPick));
+ check('清晰度已解析(出现「可用清晰度」日志或档位)', /可用清晰度|480P|360P|1080P|4K/.test(afterPick));
+ const hasStartEnabled = await evaluate(`(() => {
+ const b = [...document.querySelectorAll('button')].find(x => x.textContent.includes('开始下载'));
+ return b ? !b.disabled : null;
+ })()`);
+ check('「开始下载」按钮可点', hasStartEnabled === true);
+
+ // ---------- 6. 控制台报错 ----------
+ const errs = await evaluate('window.__bdErrors ? window.__bdErrors.length : 0');
+ check('页面无致命脚本错误', errs === 0, `${errs} 个`);
+
+ console.log('\n —— 渲染文本采样 ——');
+ console.log(
+ afterPick
+ .split('\n')
+ .filter((l) => l.trim())
+ .slice(0, 26)
+ .map((l) => ' ' + l.slice(0, 96))
+ .join('\n'),
+ );
+
+ ws.close();
+ child.kill();
+ try {
+ rmSync(profile, { recursive: true, force: true });
+ } catch {}
+
+ const failed = results.filter((r) => !r.ok);
+ console.log(`\n结果: ${results.length - failed.length}/${results.length} 通过`);
+ if (failed.length) {
+ console.log('失败项:');
+ for (const f of failed) console.log(` - ${f.name} ${f.detail}`);
+ process.exit(1);
+ }
+}
+
+main().catch((e) => {
+ console.error('验收脚本异常:', e.message);
+ process.exit(1);
+});
diff --git a/ui/index.html b/ui/index.html
new file mode 100644
index 0000000..0645378
--- /dev/null
+++ b/ui/index.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+ BiliDownloader
+
+
+
+
+
+
+
+
diff --git a/ui/package-lock.json b/ui/package-lock.json
new file mode 100644
index 0000000..0b59cd8
--- /dev/null
+++ b/ui/package-lock.json
@@ -0,0 +1,3620 @@
+{
+ "name": "wpywmail-ui",
+ "version": "0.2.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "wpywmail-ui",
+ "version": "0.2.0",
+ "dependencies": {
+ "@radix-ui/react-avatar": "^1.1.10",
+ "@radix-ui/react-collapsible": "^1.1.20",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-progress": "^1.1.16",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.3.7",
+ "@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.548.0",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "tailwind-merge": "^3.3.1"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.1.16",
+ "@types/node": "^24.9.1",
+ "@types/react": "^19.2.2",
+ "@types/react-dom": "^19.2.2",
+ "@vitejs/plugin-react": "^5.0.4",
+ "tailwindcss": "^4.1.16",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5.9.3",
+ "vite": "^7.1.12",
+ "ws": "^8.21.3"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz",
+ "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.8",
+ "@babel/types": "^7.29.8",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz",
+ "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.8",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.8",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.8",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
+ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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/@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.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "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/lzma-linux-x64-gnu": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+ "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@radix-ui/number": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz",
+ "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/primitive": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
+ "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-arrow": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz",
+ "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-avatar": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz",
+ "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-use-callback-ref": "1.1.4",
+ "@radix-ui/react-use-is-hydrated": "0.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collapsible": {
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz",
+ "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-presence": "1.1.10",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-use-controllable-state": "1.2.6",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collection": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz",
+ "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-slot": "1.3.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz",
+ "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-context": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz",
+ "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.23",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz",
+ "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-dismissable-layer": "1.1.19",
+ "@radix-ui/react-focus-guards": "1.1.6",
+ "@radix-ui/react-focus-scope": "1.1.16",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-portal": "1.1.17",
+ "@radix-ui/react-presence": "1.1.10",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-slot": "1.3.3",
+ "@radix-ui/react-use-controllable-state": "1.2.6",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-direction": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz",
+ "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.19",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz",
+ "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-use-callback-ref": "1.1.4",
+ "@radix-ui/react-use-effect-event": "0.0.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dropdown-menu": {
+ "version": "2.1.24",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz",
+ "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-menu": "2.1.24",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-use-controllable-state": "1.2.6"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz",
+ "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz",
+ "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-use-callback-ref": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-id": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz",
+ "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-label": {
+ "version": "2.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz",
+ "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-menu": {
+ "version": "2.1.24",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz",
+ "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-collection": "1.1.15",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-direction": "1.1.4",
+ "@radix-ui/react-dismissable-layer": "1.1.19",
+ "@radix-ui/react-focus-guards": "1.1.6",
+ "@radix-ui/react-focus-scope": "1.1.16",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-popper": "1.3.7",
+ "@radix-ui/react-portal": "1.1.17",
+ "@radix-ui/react-presence": "1.1.10",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-roving-focus": "1.1.19",
+ "@radix-ui/react-slot": "1.3.3",
+ "@radix-ui/react-use-callback-ref": "1.1.4",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-popper": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz",
+ "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.0.0",
+ "@radix-ui/react-arrow": "1.1.15",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-use-callback-ref": "1.1.4",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "@radix-ui/react-use-rect": "1.1.4",
+ "@radix-ui/react-use-size": "1.1.4",
+ "@radix-ui/rect": "1.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-portal": {
+ "version": "1.1.17",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz",
+ "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-presence": {
+ "version": "1.1.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz",
+ "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz",
+ "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.3.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-progress": {
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.16.tgz",
+ "integrity": "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-primitive": "2.1.10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.19",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz",
+ "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-collection": "1.1.15",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-direction": "1.1.4",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-use-callback-ref": "1.1.4",
+ "@radix-ui/react-use-controllable-state": "1.2.6",
+ "@radix-ui/react-use-is-hydrated": "0.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-scroll-area": {
+ "version": "1.2.18",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz",
+ "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.3",
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-direction": "1.1.4",
+ "@radix-ui/react-presence": "1.1.10",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-use-callback-ref": "1.1.4",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-select": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz",
+ "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.3",
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-collection": "1.1.15",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-direction": "1.1.4",
+ "@radix-ui/react-dismissable-layer": "1.1.19",
+ "@radix-ui/react-focus-guards": "1.1.6",
+ "@radix-ui/react-focus-scope": "1.1.16",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-popper": "1.3.7",
+ "@radix-ui/react-portal": "1.1.17",
+ "@radix-ui/react-presence": "1.1.10",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-slot": "1.3.3",
+ "@radix-ui/react-use-callback-ref": "1.1.4",
+ "@radix-ui/react-use-controllable-state": "1.2.6",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "@radix-ui/react-use-previous": "1.1.4",
+ "@radix-ui/react-visually-hidden": "1.2.11",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.7.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-separator": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz",
+ "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-slot": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz",
+ "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.5"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tabs": {
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz",
+ "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-direction": "1.1.4",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-presence": "1.1.10",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-roving-focus": "1.1.19",
+ "@radix-ui/react-use-controllable-state": "1.2.6"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip": {
+ "version": "1.2.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz",
+ "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.5",
+ "@radix-ui/react-context": "1.2.2",
+ "@radix-ui/react-dismissable-layer": "1.1.19",
+ "@radix-ui/react-id": "1.1.4",
+ "@radix-ui/react-popper": "1.3.7",
+ "@radix-ui/react-portal": "1.1.17",
+ "@radix-ui/react-presence": "1.1.10",
+ "@radix-ui/react-primitive": "2.1.10",
+ "@radix-ui/react-slot": "1.3.3",
+ "@radix-ui/react-use-controllable-state": "1.2.6",
+ "@radix-ui/react-use-layout-effect": "1.1.4",
+ "@radix-ui/react-visually-hidden": "1.2.11"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz",
+ "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz",
+ "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.7",
+ "@radix-ui/react-use-effect-event": "0.0.5",
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz",
+ "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-is-hydrated": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz",
+ "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz",
+ "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-previous": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz",
+ "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-rect": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz",
+ "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/rect": "1.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz",
+ "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.4"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-visually-hidden": {
+ "version": "1.2.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz",
+ "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/rect": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz",
+ "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==",
+ "license": "MIT"
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
+ "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.2.tgz",
+ "integrity": "sha512-Xa6RDoWa+hNiX6PgsljlH6W75RaONx3y6PVlbLhkEWW+GaPQ3dP5gwbL/erAzQHWwkvW5UxdD5l87Qx2FAQ/4A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.2.tgz",
+ "integrity": "sha512-vNASxsghMfQ5s+v3PrpnJd+ryL/26lxCCaGI+sDJ7VzmHiYXIrrVltsDhaawxLM1WcoMU2oYlbPHLaYQtBzhcg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.2.tgz",
+ "integrity": "sha512-0dWDjmlrpZAgjPD/aPzUDhBW8APLRjAni5bOrM76wiiZm+E+KTMVKNhAzaTBohz8UyO2fKNAl0+fygbe2HZXOA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.2.tgz",
+ "integrity": "sha512-N58uktcwzk3+qT4KHEuNdIxX1N01RWrkfVoml69EAbSaNDL+sbNVLx2RMl4Qd23lpA0fgPvyh5hHb4weD5WKmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.2.tgz",
+ "integrity": "sha512-HWF2zH8EAp2scWRpt2PGe6iUGz7zi04waXsdRr3zb4DWCk2ImIo5FZu0jjmD53nP/DGSvnW0e7/1ToCNZs2lZw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.2.tgz",
+ "integrity": "sha512-MkvcwHMnzPSMOQEwB6wHnLzmc+hT8BGc5bW/Mhmjjgx3wbj6VBnlc47XsK74kD0K9MikFfXpQqyz4NUXaUW62A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.2.tgz",
+ "integrity": "sha512-xe1bCKPJaKsD0tfd7Rb6bGfUogJTpKbTEEthsfdb7hTfTRNJVQTdirabQx0o6ERVba/smkM720soMY+0QnrlSQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.2.tgz",
+ "integrity": "sha512-yOM7LdK0p6gk6+Q773OEwtlsikT1TL3yMmYsTtRlDRPha5vV2DC5x7LqRWDr6f3cSYNMKVqxzffXv8ivxNBIFQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.2.tgz",
+ "integrity": "sha512-qiWuJJV3DybA2IfzvRimeKXGrGuVPv1zobSY/26KnP3HbV0VcNb3ECzgvtbvF3xjSMkcooou6HASXZuLdjnhpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.2.tgz",
+ "integrity": "sha512-akcZquRzCY/KpUoZAMBhGf7oi4LmXq1BzRA5CPAC3rkUf28Y/sAYV3jSL+JKd7cwEyFvR5G0XVZ0gaMedP+60A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.2.tgz",
+ "integrity": "sha512-fNwYHrPyYyxauPzX/cpYw8Z7LQpp+DGA0KCoswA0aVFBpmdMil9XgjB8V3Ny64Ihu797+GKcuJqnsOKEmor7fA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.2.tgz",
+ "integrity": "sha512-XfvsgzR7DZqREdst7K1Mj3ilSUM5xLAHJcIMDFPKdxTs9q5VHOT8aMA+a683fqBu7DQl8+Sd9HCsQYL8EMY9qA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.2.tgz",
+ "integrity": "sha512-Pp7gVZggEFlbcuztay+/U0gVG9S1XAh8i7I1Re/htbAzo43P5wHZHw6pTyzotISqlKohoh9RpIfnOz3RbemK1w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.2.tgz",
+ "integrity": "sha512-zkgL2xff6i7u5hau/m6FGeS8gRkLEdgLw522WGmdWWlLd9btmNl3S80mcEjtGq+kvgUekQ3+BOYLLLcPlS2LIA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.2.tgz",
+ "integrity": "sha512-qOheJomrkVCbbHFJ7L3J97cnhfogKqguAQphv26+3ZsAQIF1L19b+dArl//s8rjJHJLz9byykyM8NBP4nmSa1g==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.2.tgz",
+ "integrity": "sha512-XlxLD54wQhH3FciCgMofxBw27NzUe818gJH410qWvc41UT0ZFcgxVjyX5/EK8MPTupjeVWqN5oy+9pCA9mqfCA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.2.tgz",
+ "integrity": "sha512-vdryWeRb2bLJZf0Fv/W8se6nvsHe2PkTCxV0meheK3nQE+G90VCJcke51Miy1yQRsfm2uqIyjXOu4wmUzbTtkQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.2.tgz",
+ "integrity": "sha512-bcq2h2pkKmH2po4cZV8VWzO4lL40STyu/nLoFpYMQp9C2tCVNTdcVv86MwSsn3D5s1FBe2Ty1atqvVAUTMimNg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.2.tgz",
+ "integrity": "sha512-EGoo5DMVMRkTId8fuTDaoxVlR5ZTsKULUezRjd9gCw5eeY+DjCvDpZAOlNUvKPGX+7rS1RWx6j+yOpNPx0cUgQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.2.tgz",
+ "integrity": "sha512-MErl12k7BFHZG1TI9QF/3lSSZARzq9KgNy/FjnqFMCkv+N4RSSzoUCA5h2mqHX4Mox3WaTVKblyzhQ1zRb2ZuQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.2.tgz",
+ "integrity": "sha512-ILs8k07Wh4p0PsNY4wYLEaXZKMOpVhrG5QDB0yHhGhuzOfDlnyHN6sflL4El/MpUP1y8uY2lUZrv4oBS6pTT3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.2.tgz",
+ "integrity": "sha512-hKgB3nz/TKD3Wv78XEsyXzQsNjvhOHmwKQTvXADGOyU/cIClZDO7DsoggbdmJDPGp5V80tA3Vfv61PaKTLH3LA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.2.tgz",
+ "integrity": "sha512-T4wf1mudIDxN8Q/CWIBJC1u5gQUc+r5mPvlwoSbIvNkyVTP2TAFeobEmst5AQ4gMyAz4sSByVdoTDfvTmGK/8g==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.2.tgz",
+ "integrity": "sha512-tC3IY7qoaD9Ll3/8WJQn49j5V2f/NuI9S41NOE2iM5MPs3sPIvOkVToLcz/7Bz4pyF7PSvrtwu8I/pUrGOSecQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.2.tgz",
+ "integrity": "sha512-6NHnk/K3eq2ZFYcU1X8g67s9qIJRCOTT92gwLMVBp08dB2uuuwI1/Q/empzL2Bfr2f2WRLJVwpp90RmacQyFkw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "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.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+ "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
+ "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
+ "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "devOptional": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
+ "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.29.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-rc.3",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.18.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/aria-hidden": {
+ "version": "1.2.6",
+ "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
+ "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.23",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
+ "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.9",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
+ "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.11.20",
+ "caniuse-lite": "^1.0.30001810",
+ "electron-to-chromium": "^1.5.420",
+ "node-releases": "^2.0.54",
+ "update-browserslist-db": "^1.3.2"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001810",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
+ "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "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/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "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/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "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/detect-node-es": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
+ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.427",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.427.tgz",
+ "integrity": "sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.25.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.0.tgz",
+ "integrity": "sha512-ghq3mhs649mvbarTCAlZn2wRhbfHmzAFiKxoWA14B3VtqnxtZt+wz8BroKXU0tF3GiJsnUldjVncQGZ8qk4rdA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-nonce": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
+ "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "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/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "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/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "0.548.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.548.0.tgz",
+ "integrity": "sha512-63b16z63jM9yc1MwxajHeuu0FRZFsDtljtDjYm26Kd86UQ5HQzu9ksEtoUUw4RBuewodw/tGFmvipePvRsKeDA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "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/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
+ "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/node-releases": {
+ "version": "2.0.55",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz",
+ "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "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.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.28",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+ "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
+ "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.18",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.0"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-remove-scroll": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
+ "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-remove-scroll-bar": "^2.3.7",
+ "react-style-singleton": "^2.2.3",
+ "tslib": "^2.1.0",
+ "use-callback-ref": "^1.3.3",
+ "use-sidecar": "^1.1.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-remove-scroll-bar": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
+ "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
+ "license": "MIT",
+ "dependencies": {
+ "react-style-singleton": "^2.2.2",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-style-singleton": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
+ "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "get-nonce": "^1.0.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.63.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz",
+ "integrity": "sha512-l5eyksV4tPBj6lJyEa37YzIOCSOV7lkZzEHUdpjWZbtD7wTcFYmEYXSgm5bT4vV+dZLb9rBG1W9GROOG4NS4Ew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+ "@rollup/rollup-android-arm-eabi": "4.63.2",
+ "@rollup/rollup-android-arm64": "4.63.2",
+ "@rollup/rollup-darwin-arm64": "4.63.2",
+ "@rollup/rollup-darwin-x64": "4.63.2",
+ "@rollup/rollup-freebsd-arm64": "4.63.2",
+ "@rollup/rollup-freebsd-x64": "4.63.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.63.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.63.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.63.2",
+ "@rollup/rollup-linux-arm64-musl": "4.63.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.63.2",
+ "@rollup/rollup-linux-loong64-musl": "4.63.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.63.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.63.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.63.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.63.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.63.2",
+ "@rollup/rollup-linux-x64-gnu": "4.63.2",
+ "@rollup/rollup-linux-x64-musl": "4.63.2",
+ "@rollup/rollup-openbsd-x64": "4.63.2",
+ "@rollup/rollup-openharmony-arm64": "4.63.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.63.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.63.2",
+ "@rollup/rollup-win32-x64-gnu": "4.63.2",
+ "@rollup/rollup-win32-x64-msvc": "4.63.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "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.7.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz",
+ "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "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": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
+ "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/use-callback-ref": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
+ "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sidecar": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
+ "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "detect-node-es": "^1.1.0",
+ "tslib": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.6",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.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
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/ui/package.json b/ui/package.json
new file mode 100644
index 0000000..40b4186
--- /dev/null
+++ b/ui/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "wpywmail-ui",
+ "private": true,
+ "version": "0.2.0",
+ "type": "module",
+ "description": "WpywMail 客户端界面(shadcn/ui + Tailwind + Vite)",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b --noCheck && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@radix-ui/react-avatar": "^1.1.10",
+ "@radix-ui/react-collapsible": "^1.1.20",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-progress": "^1.1.16",
+ "@radix-ui/react-scroll-area": "^1.2.10",
+ "@radix-ui/react-select": "^2.3.7",
+ "@radix-ui/react-separator": "^1.1.7",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@radix-ui/react-tabs": "^1.1.13",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.548.0",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
+ "tailwind-merge": "^3.3.1"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.1.16",
+ "@types/node": "^24.9.1",
+ "@types/react": "^19.2.2",
+ "@types/react-dom": "^19.2.2",
+ "@vitejs/plugin-react": "^5.0.4",
+ "tailwindcss": "^4.1.16",
+ "tw-animate-css": "^1.4.0",
+ "typescript": "^5.9.3",
+ "vite": "^7.1.12",
+ "ws": "^8.21.3"
+ }
+}
diff --git a/ui/public/favicon.svg b/ui/public/favicon.svg
new file mode 100644
index 0000000..08ef0b1
--- /dev/null
+++ b/ui/public/favicon.svg
@@ -0,0 +1,5 @@
+
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
new file mode 100644
index 0000000..e0e7395
--- /dev/null
+++ b/ui/src/App.tsx
@@ -0,0 +1,342 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { CloudDownload, Loader2, Moon, Search, Settings2, Sun, UserRound } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Badge } from '@/components/ui/badge';
+import { SearchResults } from '@/components/SearchResults';
+import { DownloadPanel } from '@/components/DownloadPanel';
+import { SettingsDialog } from '@/components/SettingsDialog';
+import { formatBytes } from '@/lib/format';
+import {
+ api,
+ extractBvid,
+ subscribe,
+ type AppConfig,
+ type Job,
+ type LoginStatus,
+ type PlayUrl,
+ type SearchItem,
+ type VideoDetail,
+} from '@/lib/api';
+
+const stamp = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
+
+export default function App() {
+ const [config, setConfig] = useState(null);
+ const [login, setLogin] = useState(null);
+ const [settingsOpen, setSettingsOpen] = useState(false);
+ const [dark, setDark] = useState(() => localStorage.getItem('bd.theme') !== 'light');
+
+ const [query, setQuery] = useState('');
+ const [items, setItems] = useState([]);
+ const [page, setPage] = useState(1);
+ const [hasMore, setHasMore] = useState(false);
+ const [searching, setSearching] = useState(false);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [searched, setSearched] = useState(false);
+ const [searchError, setSearchError] = useState(null);
+
+ const [video, setVideo] = useState(null);
+ const [pageIndex, setPageIndex] = useState(0);
+ const [play, setPlay] = useState(null);
+ const [loadingPlay, setLoadingPlay] = useState(false);
+ const [quality, setQuality] = useState(null);
+
+ const [job, setJob] = useState(null);
+ const [logs, setLogs] = useState([]);
+ const [speedHistory, setSpeedHistory] = useState([]);
+
+ const lastMilestone = useRef(0);
+ const jobRef = useRef(null);
+ jobRef.current = job;
+
+ const log = useCallback((msg: string) => {
+ setLogs((prev) => [...prev.slice(-299), `[${stamp()}] ${msg}`]);
+ }, []);
+
+ // ---------------- 主题 ----------------
+ useEffect(() => {
+ document.documentElement.classList.toggle('dark', dark);
+ localStorage.setItem('bd.theme', dark ? 'dark' : 'light');
+ }, [dark]);
+
+ // ---------------- 启动:配置 + 登录状态 + 当前任务 ----------------
+ useEffect(() => {
+ api.config().then(setConfig).catch((e) => log(`读取配置失败:${e.message}`));
+ api
+ .login()
+ .then((s) => {
+ setLogin(s);
+ log(s.isLogin ? `已登录:${s.uname}` : '未登录(B 站只放出 480P 及以下)');
+ })
+ .catch(() => setLogin({ isLogin: false, uname: '' }));
+ api
+ .job()
+ .then((j) => {
+ if (j?.state) setJob(j);
+ })
+ .catch(() => {});
+ }, [log]);
+
+ // ---------------- 服务端事件 ----------------
+ useEffect(() => {
+ return subscribe({
+ onJob: (j) => {
+ setJob(j);
+ if (j.state === 'done') {
+ log(`✔ 完成:${j.outputPath}(${formatBytes(j.size)},用时 ${Math.round(j.elapsed ?? 0)}s)`);
+ setSpeedHistory([]);
+ } else if (j.state === 'error') {
+ log(`✖ 失败:${j.error}`);
+ } else if (j.state === 'cancelled') {
+ log('已取消,未完成的文件已清理');
+ setSpeedHistory([]);
+ }
+ },
+ onStage: (s) => log(`▶ ${s.stage}`),
+ onProgress: (p) => {
+ const prog = jobRef.current?.progress;
+ const otherSpeed = p.which === 'video' ? (prog?.audio?.speed ?? 0) : (prog?.video?.speed ?? 0);
+ setSpeedHistory((prev) => [...prev.slice(-59), p.speed + otherSpeed]);
+ const mark = Math.floor(p.percent / 10) * 10;
+ if (mark > lastMilestone.current && mark < 100) {
+ lastMilestone.current = mark;
+ log(`${p.label} ${mark}%(${formatBytes(p.downloaded)}/${formatBytes(p.total)})`);
+ }
+ },
+ });
+ }, [log]);
+
+ // ---------------- 清晰度 ----------------
+ const loadPlay = useCallback(
+ async (bvid: string, cid: number) => {
+ setLoadingPlay(true);
+ setPlay(null);
+ setQuality(null);
+ try {
+ const p = await api.playUrl(bvid, cid);
+ setPlay(p);
+ if (p.qualities.length) {
+ setQuality(p.qualities[0].quality);
+ log(
+ `可用清晰度 ${p.qualities.length} 档:` +
+ p.qualities.map((q) => q.label).join(' / ') +
+ (p.maxQuality < 80 ? '(未登录,被限制在 480P 及以下)' : ''),
+ );
+ } else {
+ log('该视频没有返回可用流');
+ }
+ } catch (e) {
+ log(`解析清晰度失败:${(e as Error).message}`);
+ } finally {
+ setLoadingPlay(false);
+ }
+ },
+ [log],
+ );
+
+ // ---------------- 搜索 / 解析 ----------------
+ const runSearch = useCallback(
+ async (raw: string, nextPage = 1) => {
+ const text = raw.trim();
+ if (!text) return;
+
+ const bvid = extractBvid(text);
+ if (bvid) {
+ setSearchError(null);
+ setSearching(true);
+ try {
+ const detail = await api.video(bvid);
+ setVideo(detail);
+ setPageIndex(0);
+ log(`解析视频:${detail.title}`);
+ await loadPlay(bvid, detail.pages[0].cid);
+ } catch (e) {
+ setSearchError(`解析失败:${(e as Error).message}`);
+ } finally {
+ setSearching(false);
+ }
+ return;
+ }
+
+ setSearching(true);
+ setSearchError(null);
+ try {
+ const res = await api.search(text, nextPage);
+ setItems((prev) => (nextPage === 1 ? res.items : [...prev, ...res.items]));
+ setPage(res.page);
+ setHasMore(res.hasMore);
+ setSearched(true);
+ if (nextPage === 1) log(`搜索「${text}」:${res.items.length} 条结果`);
+ } catch (e) {
+ setSearchError((e as Error).message);
+ } finally {
+ setSearching(false);
+ }
+ },
+ [log, loadPlay],
+ );
+
+ const pickItem = useCallback(
+ async (item: SearchItem) => {
+ try {
+ const detail = await api.video(item.bvid);
+ setVideo(detail);
+ setPageIndex(0);
+ log(`选中:${detail.title}`);
+ await loadPlay(item.bvid, detail.pages[0].cid);
+ } catch (e) {
+ log(`加载视频失败:${(e as Error).message}`);
+ }
+ },
+ [log, loadPlay],
+ );
+
+ const changePage = useCallback(
+ async (i: number) => {
+ if (!video) return;
+ setPageIndex(i);
+ await loadPlay(video.bvid, video.pages[i].cid);
+ },
+ [video, loadPlay],
+ );
+
+ // ---------------- 下载 ----------------
+ const startDownload = useCallback(async () => {
+ if (!video || quality == null) return;
+ lastMilestone.current = 0;
+ setSpeedHistory([]);
+ try {
+ const j = await api.download({
+ bvid: video.bvid,
+ cid: video.pages[pageIndex].cid,
+ quality,
+ title: video.title,
+ });
+ setJob(j);
+ log(`开始下载:${video.title}`);
+ } catch (e) {
+ const msg = (e as Error).message;
+ log(`无法开始下载:${msg}`);
+ setJob({ state: 'error', error: msg });
+ }
+ }, [video, quality, pageIndex, log]);
+
+ const cancel = useCallback(async () => {
+ await api.cancel().catch(() => {});
+ }, []);
+
+ const openFolder = useCallback(
+ (path?: string) => {
+ api.openFolder(path ?? config?.outputDir).catch(() => {});
+ },
+ [config],
+ );
+
+ return (
+
+ {/* ---------------- 顶栏 ---------------- */}
+
+
+ {/* ---------------- 主体 ---------------- */}
+
+
+ {
+ setLoadingMore(true);
+ await runSearch(query, page + 1);
+ setLoadingMore(false);
+ }}
+ />
+
+
+
+ setSettingsOpen(true)}
+ />
+
+
+
+
{
+ setConfig(c);
+ log('配置已保存');
+ }}
+ onLoginChanged={(s) => {
+ setLogin(s);
+ log(s.isLogin ? `已登录:${s.uname}` : '未登录 / SESSDATA 无效');
+ }}
+ />
+
+ );
+}
diff --git a/ui/src/components/DownloadPanel.tsx b/ui/src/components/DownloadPanel.tsx
new file mode 100644
index 0000000..9206a1e
--- /dev/null
+++ b/ui/src/components/DownloadPanel.tsx
@@ -0,0 +1,359 @@
+import { useEffect, useRef, useState } from 'react';
+import {
+ AlertCircle,
+ CheckCircle2,
+ FolderOpen,
+ Gauge,
+ Loader2,
+ LockKeyhole,
+ Play,
+ Square,
+ Timer,
+ X,
+} from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Progress } from '@/components/ui/progress';
+import { Separator } from '@/components/ui/separator';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { cn } from '@/lib/utils';
+import { formatBytes, formatCount, formatDuration, formatEta, formatSpeed, shortPath } from '@/lib/format';
+import type { Job, PlayUrl, Quality, StreamProgress, VideoDetail } from '@/lib/api';
+
+type Props = {
+ video: VideoDetail | null;
+ play: PlayUrl | null;
+ loadingPlay: boolean;
+ quality: number | null;
+ onQualityChange: (q: number) => void;
+ pageIndex: number;
+ onPageChange: (i: number) => void;
+ job: Job | null;
+ logs: string[];
+ speedHistory: number[];
+ loggedIn: boolean;
+ outputDir: string;
+ onStart: () => void;
+ onCancel: () => void;
+ onOpenFolder: (path?: string) => void;
+ onOpenSettings: () => void;
+};
+
+function Sparkline({ data }: { data: number[] }) {
+ const W = 240;
+ const H = 34;
+ if (data.length < 2) {
+ return 速度曲线(下载中显示)
;
+ }
+ const max = Math.max(...data, 1);
+ const pts = data.map((v, i) => {
+ const x = (i / (data.length - 1)) * W;
+ const y = H - (v / max) * (H - 4) - 2;
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ });
+ const area = `0,${H} ${pts.join(' ')} ${W},${H}`;
+ return (
+
+ );
+}
+
+function StreamBar({ label, info }: { label: string; info: StreamProgress | null | undefined }) {
+ const percent = info?.percent ?? 0;
+ return (
+
+
{label}
+
+
+ {info
+ ? `${percent.toFixed(1)}% ${formatBytes(info.downloaded)}/${formatBytes(info.total)} ${formatSpeed(info.speed)}`
+ : '—'}
+
+
+ );
+}
+
+export function DownloadPanel({
+ video,
+ play,
+ loadingPlay,
+ quality,
+ onQualityChange,
+ pageIndex,
+ onPageChange,
+ job,
+ logs,
+ speedHistory,
+ loggedIn,
+ outputDir,
+ onStart,
+ onCancel,
+ onOpenFolder,
+ onOpenSettings,
+}: Props) {
+ const logRef = useRef(null);
+ const running = job?.state === 'running';
+
+ useEffect(() => {
+ if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
+ }, [logs]);
+
+ const videoP = job?.progress?.video;
+ const audioP = job?.progress?.audio;
+ const totalDown = (videoP?.downloaded ?? 0) + (audioP?.downloaded ?? 0);
+ const totalAll = (videoP?.total ?? 0) + (audioP?.total ?? 0);
+ const totalPercent = totalAll > 0 ? (totalDown * 100) / totalAll : 0;
+ const totalSpeed = (videoP?.speed ?? 0) + (audioP?.speed ?? 0);
+ const eta = formatEta({ downloaded: totalDown, total: totalAll, speed: totalSpeed });
+
+ const limitedByLogin = !!play && play.maxQuality > 0 && play.maxQuality < 80;
+ const currentQ: Quality | undefined = play?.qualities.find((q) => q.quality === quality);
+
+ return (
+
+ {/* ---------------- 已选视频 ---------------- */}
+
+ {!video ? (
+
+ 还没有选择视频
+ 在左侧点选一个结果,播放器地址会自动解析
+
+ ) : (
+
+
+ {video.pic && (
+

+ )}
+
+
+
{video.title}
+
+ {video.owner}
+ ·
+ {formatDuration(video.duration)}
+ {video.stat?.view != null && (
+ <>
+ ·
+ {formatCount(video.stat.view)}播放
+ >
+ )}
+ ·
+ {video.bvid}
+
+ {video.pages.length > 1 && (
+
+ 分P
+
+
+ )}
+
+
+ )}
+
+
+
+
+ {/* ---------------- 参数 + 动作 ---------------- */}
+
+
+
清晰度
+
+ {loadingPlay ? (
+
+ 正在解析可用清晰度…
+
+ ) : (
+
+ )}
+ {currentQ?.bandwidth ? (
+
+ {Math.round(currentQ.bandwidth / 1000)} kbps
+
+ ) : null}
+
+
+
保存到
+
+
+ {shortPath(outputDir)}
+
+
+
+
+
+ {limitedByLogin && (
+
+
+
+ 当前未登录(或 SESSDATA 已失效),B 站只放出 {play?.qualities[0]?.label ?? '480P'} 及以下。
+ 该视频标称支持到 {play?.advertisedQuality === 120 ? '4K' : `${play?.advertisedQuality}P`}。
+ 想下高清请在
+
+ 里填一个有效的 SESSDATA。
+
+
+ )}
+
+
+ {running ? (
+
+ ) : (
+
+ )}
+ {job?.state === 'done' && (
+
+ )}
+
+
+
+ {job?.threads ?? 16} 线程
+
+ {(videoP?.threads ?? 0) > 0 && (
+
+
+ ETA {eta}
+
+ )}
+
+
+
+
+
+
+ {/* ---------------- 进度 ---------------- */}
+
+
+
+ {job?.stage ?? '等待开始'}
+ {totalPercent.toFixed(1)}%
+
+
+
+
+ {formatBytes(totalDown)} / {formatBytes(totalAll)}
+
+ {formatSpeed(totalSpeed)}
+
+
+
+
+
+
+
+
+
+
+
+ {/* ---------------- 结果提示 ---------------- */}
+ {job?.state === 'done' && (
+
+
+
+
+ 下载完成 · {formatBytes(job.size)} · 用时 {formatDuration(job.elapsed)}
+
+
+ {job.outputPath}
+
+
+
+ )}
+ {job?.state === 'error' && (
+
+ )}
+ {job?.state === 'cancelled' && (
+
+
+ 已取消,未完成的文件已清理
+
+ )}
+
+ {/* ---------------- 日志 ---------------- */}
+
+
日志
+
+ {logs.length ? logs.join('\n') : (暂无)}
+
+
+
+ );
+}
diff --git a/ui/src/components/SearchResults.tsx b/ui/src/components/SearchResults.tsx
new file mode 100644
index 0000000..5286537
--- /dev/null
+++ b/ui/src/components/SearchResults.tsx
@@ -0,0 +1,146 @@
+import { useState } from 'react';
+import { Download, Loader2, Play, SearchX } from 'lucide-react';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
+import { cn } from '@/lib/utils';
+import { formatCount } from '@/lib/format';
+import type { SearchItem } from '@/lib/api';
+
+type Props = {
+ items: SearchItem[];
+ loading: boolean;
+ loadingMore: boolean;
+ hasMore: boolean;
+ searched: boolean;
+ error: string | null;
+ selectedBvid: string | null;
+ onPick: (item: SearchItem) => void;
+ onLoadMore: () => void;
+};
+
+function Cover({ src, alt }: { src: string; alt: string }) {
+ const [failed, setFailed] = useState(false);
+ return (
+
+ {src && !failed ? (
+

setFailed(true)}
+ className="size-full object-cover"
+ />
+ ) : (
+
+ )}
+
+ );
+}
+
+export function SearchResults({
+ items,
+ loading,
+ loadingMore,
+ hasMore,
+ searched,
+ error,
+ selectedBvid,
+ onPick,
+ onLoadMore,
+}: Props) {
+ if (loading) {
+ return (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!items.length) {
+ return (
+
+
+
{searched ? '没有找到相关视频' : '搜索站内视频,或直接粘贴视频链接 / BV 号'}
+
+ );
+ }
+
+ return (
+
+
+ {items.map((item) => {
+ const active = item.bvid === selectedBvid;
+ return (
+
+ );
+ })}
+
+ {hasMore && (
+
+
+ {loadingMore && }
+ 加载更多
+
+
+ )}
+
+
+ );
+}
diff --git a/ui/src/components/SettingsDialog.tsx b/ui/src/components/SettingsDialog.tsx
new file mode 100644
index 0000000..0fa6f61
--- /dev/null
+++ b/ui/src/components/SettingsDialog.tsx
@@ -0,0 +1,202 @@
+import { useEffect, useState } from 'react';
+import { CheckCircle2, FolderOpen, Loader2, ShieldAlert, XCircle } from 'lucide-react';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Separator } from '@/components/ui/separator';
+import { api, type AppConfig, type LoginStatus } from '@/lib/api';
+
+type Props = {
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ config: AppConfig | null;
+ onSaved: (cfg: AppConfig) => void;
+ onLoginChanged: (s: LoginStatus) => void;
+};
+
+export function SettingsDialog({ open, onOpenChange, config, onSaved, onLoginChanged }: Props) {
+ const [draft, setDraft] = useState>({});
+ const [saving, setSaving] = useState(false);
+ const [testing, setTesting] = useState(false);
+ const [login, setLogin] = useState(null);
+ const [note, setNote] = useState(null);
+
+ useEffect(() => {
+ if (open && config) {
+ setDraft({
+ sessdata: config.sessdata,
+ outputDir: config.outputDir,
+ threads: config.threads,
+ preferAvc: config.preferAvc,
+ keepParts: config.keepParts,
+ });
+ setNote(null);
+ setLogin(null);
+ }
+ }, [open, config]);
+
+ const save = async () => {
+ setSaving(true);
+ setNote(null);
+ try {
+ const saved = await api.saveConfig(draft);
+ onSaved(saved);
+ setNote('已保存');
+ } catch (e) {
+ setNote(`保存失败:${(e as Error).message}`);
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const testLogin = async () => {
+ setTesting(true);
+ try {
+ await api.saveConfig(draft); // 先落盘,再测
+ const s = await api.login();
+ setLogin(s);
+ onLoginChanged(s);
+ } catch (e) {
+ setLogin({ isLogin: false, uname: '', code: -1 });
+ setNote(`测试失败:${(e as Error).message}`);
+ } finally {
+ setTesting(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/ui/src/components/ui/avatar.tsx b/ui/src/components/ui/avatar.tsx
new file mode 100644
index 0000000..cf2e5b3
--- /dev/null
+++ b/ui/src/components/ui/avatar.tsx
@@ -0,0 +1,31 @@
+import * as React from 'react';
+import * as AvatarPrimitive from '@radix-ui/react-avatar';
+import { cn } from '@/lib/utils';
+
+function Avatar({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarImage({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarFallback({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/ui/src/components/ui/badge.tsx b/ui/src/components/ui/badge.tsx
new file mode 100644
index 0000000..378f44f
--- /dev/null
+++ b/ui/src/components/ui/badge.tsx
@@ -0,0 +1,31 @@
+import * as React from 'react';
+import { Slot } from '@radix-ui/react-slot';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { cn } from '@/lib/utils';
+
+const badgeVariants = cva(
+ 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden',
+ {
+ variants: {
+ variant: {
+ default: 'border-transparent bg-primary text-primary-foreground',
+ secondary: 'border-transparent bg-secondary text-secondary-foreground',
+ destructive: 'border-transparent bg-destructive text-white',
+ outline: 'text-foreground',
+ },
+ },
+ defaultVariants: { variant: 'default' },
+ },
+);
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'span'> & VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : 'span';
+ return ;
+}
+
+export { Badge, badgeVariants };
diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx
new file mode 100644
index 0000000..1b28fab
--- /dev/null
+++ b/ui/src/components/ui/button.tsx
@@ -0,0 +1,45 @@
+import * as React from 'react';
+import { Slot } from '@radix-ui/react-slot';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { cn } from '@/lib/utils';
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
+ secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
+ ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+ icon: 'size-9',
+ },
+ },
+ defaultVariants: { variant: 'default', size: 'default' },
+ },
+);
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot : 'button';
+ return ;
+}
+
+export { Button, buttonVariants };
diff --git a/ui/src/components/ui/card.tsx b/ui/src/components/ui/card.tsx
new file mode 100644
index 0000000..7c99843
--- /dev/null
+++ b/ui/src/components/ui/card.tsx
@@ -0,0 +1,34 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+function Card({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return ;
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return ;
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
+ return ;
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
+ return ;
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return ;
+}
+
+export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
diff --git a/ui/src/components/ui/dialog.tsx b/ui/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..d3ac202
--- /dev/null
+++ b/ui/src/components/ui/dialog.tsx
@@ -0,0 +1,103 @@
+import * as React from 'react';
+import * as DialogPrimitive from '@radix-ui/react-dialog';
+import { XIcon } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+function Dialog(props: React.ComponentProps) {
+ return ;
+}
+function DialogTrigger(props: React.ComponentProps) {
+ return ;
+}
+function DialogPortal(props: React.ComponentProps) {
+ return ;
+}
+function DialogClose(props: React.ComponentProps) {
+ return ;
+}
+
+function DialogOverlay({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & { showCloseButton?: boolean }) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ 关闭
+
+ )}
+
+
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return ;
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function DialogTitle({ className, ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DialogDescription({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/ui/src/components/ui/dropdown-menu.tsx b/ui/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..4168fd6
--- /dev/null
+++ b/ui/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,187 @@
+import * as React from 'react';
+import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
+import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+function DropdownMenu(props: React.ComponentProps) {
+ return ;
+}
+function DropdownMenuTrigger(props: React.ComponentProps) {
+ return ;
+}
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = 'default',
+ ...props
+}: React.ComponentProps & { inset?: boolean; variant?: 'default' | 'destructive' }) {
+ return (
+
+ );
+}
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+function DropdownMenuRadioGroup(props: React.ComponentProps) {
+ return ;
+}
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & { inset?: boolean }) {
+ return (
+
+ );
+}
+function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ );
+}
+function DropdownMenuSub(props: React.ComponentProps) {
+ return ;
+}
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & { inset?: boolean }) {
+ return (
+
+ {children}
+
+
+ );
+}
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+};
diff --git a/ui/src/components/ui/input.tsx b/ui/src/components/ui/input.tsx
new file mode 100644
index 0000000..6062534
--- /dev/null
+++ b/ui/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
+ return (
+
+ );
+}
+
+export { Input };
diff --git a/ui/src/components/ui/label.tsx b/ui/src/components/ui/label.tsx
new file mode 100644
index 0000000..ed4d4d3
--- /dev/null
+++ b/ui/src/components/ui/label.tsx
@@ -0,0 +1,20 @@
+import * as React from 'react';
+import * as LabelPrimitive from '@radix-ui/react-label';
+import { cn } from '@/lib/utils';
+
+function Label({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Label };
diff --git a/ui/src/components/ui/progress.tsx b/ui/src/components/ui/progress.tsx
new file mode 100644
index 0000000..24ccf68
--- /dev/null
+++ b/ui/src/components/ui/progress.tsx
@@ -0,0 +1,26 @@
+import * as React from 'react';
+import * as ProgressPrimitive from '@radix-ui/react-progress';
+import { cn } from '@/lib/utils';
+
+function Progress({
+ className,
+ value,
+ indicatorClassName,
+ ...props
+}: React.ComponentProps & { indicatorClassName?: string }) {
+ return (
+
+
+
+ );
+}
+
+export { Progress };
diff --git a/ui/src/components/ui/scroll-area.tsx b/ui/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..9b8e66f
--- /dev/null
+++ b/ui/src/components/ui/scroll-area.tsx
@@ -0,0 +1,45 @@
+import * as React from 'react';
+import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
+import { cn } from '@/lib/utils';
+
+function ScrollArea({ className, children, ...props }: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function ScrollBar({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { ScrollArea, ScrollBar };
diff --git a/ui/src/components/ui/select.tsx b/ui/src/components/ui/select.tsx
new file mode 100644
index 0000000..6f49caa
--- /dev/null
+++ b/ui/src/components/ui/select.tsx
@@ -0,0 +1,127 @@
+import * as React from 'react';
+import * as SelectPrimitive from '@radix-ui/react-select';
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+function Select(props: React.ComponentProps) {
+ return ;
+}
+
+function SelectValue(props: React.ComponentProps) {
+ return ;
+}
+
+function SelectTrigger({
+ className,
+ size = 'default',
+ children,
+ ...props
+}: React.ComponentProps & { size?: 'sm' | 'default' }) {
+ return (
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectContent({
+ className,
+ children,
+ position = 'popper',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function SelectItem({ className, children, ...props }: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function SelectScrollUpButton({ className, ...props }: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function SelectScrollDownButton({ className, ...props }: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue };
diff --git a/ui/src/components/ui/separator.tsx b/ui/src/components/ui/separator.tsx
new file mode 100644
index 0000000..7b55ac7
--- /dev/null
+++ b/ui/src/components/ui/separator.tsx
@@ -0,0 +1,25 @@
+import * as React from 'react';
+import * as SeparatorPrimitive from '@radix-ui/react-separator';
+import { cn } from '@/lib/utils';
+
+function Separator({
+ className,
+ orientation = 'horizontal',
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Separator };
diff --git a/ui/src/components/ui/skeleton.tsx b/ui/src/components/ui/skeleton.tsx
new file mode 100644
index 0000000..9906dde
--- /dev/null
+++ b/ui/src/components/ui/skeleton.tsx
@@ -0,0 +1,7 @@
+import { cn } from '@/lib/utils';
+
+function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
+ return ;
+}
+
+export { Skeleton };
diff --git a/ui/src/components/ui/textarea.tsx b/ui/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..bdea2a3
--- /dev/null
+++ b/ui/src/components/ui/textarea.tsx
@@ -0,0 +1,17 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
+ return (
+
+ );
+}
+
+export { Textarea };
diff --git a/ui/src/components/ui/tooltip.tsx b/ui/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..6d1d054
--- /dev/null
+++ b/ui/src/components/ui/tooltip.tsx
@@ -0,0 +1,41 @@
+import * as React from 'react';
+import * as TooltipPrimitive from '@radix-ui/react-tooltip';
+import { cn } from '@/lib/utils';
+
+function TooltipProvider({ delayDuration = 200, ...props }: React.ComponentProps) {
+ return ;
+}
+
+function Tooltip(props: React.ComponentProps) {
+ return ;
+}
+
+function TooltipTrigger(props: React.ComponentProps) {
+ return ;
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 4,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts
new file mode 100644
index 0000000..381fb77
--- /dev/null
+++ b/ui/src/lib/api.ts
@@ -0,0 +1,141 @@
+/** 与本地 Python 服务通信的类型化客户端。 */
+
+export type SearchItem = {
+ bvid: string;
+ title: string;
+ author: string;
+ duration: string;
+ play: number;
+ danmaku: number;
+ pic: string;
+ description: string;
+};
+
+export type SearchResult = {
+ items: SearchItem[];
+ page: number;
+ numResults: number;
+ hasMore: boolean;
+};
+
+export type VideoPage = { cid: number; page: number; part: string; duration: number };
+
+export type VideoDetail = {
+ bvid: string;
+ title: string;
+ pic: string;
+ duration: number;
+ desc: string;
+ owner: string;
+ stat: { view?: number; danmaku?: number; like?: number; favorite?: number };
+ pages: VideoPage[];
+};
+
+export type Quality = {
+ quality: number;
+ label: string;
+ width: number | null;
+ height: number | null;
+ codecs: string | null;
+ bandwidth: number | null;
+};
+
+export type PlayUrl = {
+ qualities: Quality[];
+ maxQuality: number;
+ advertisedQuality: number;
+ duration: number;
+};
+
+export type StreamProgress = {
+ label: string;
+ downloaded: number;
+ total: number;
+ percent: number;
+ speed: number;
+ threads: number;
+ resumed: number;
+};
+
+export type Job = {
+ state?: 'running' | 'done' | 'cancelled' | 'error';
+ stage?: string;
+ title?: string;
+ quality?: number;
+ outputPath?: string;
+ size?: number;
+ elapsed?: number;
+ threads?: number;
+ resumed?: number;
+ error?: string | null;
+ progress?: { video: StreamProgress | null; audio: StreamProgress | null };
+};
+
+export type AppConfig = {
+ sessdata: string;
+ outputDir: string;
+ threads: number;
+ preferAvc: boolean;
+ keepParts: boolean;
+ configPath?: string;
+ ffmpeg?: string | null;
+};
+
+export type LoginStatus = { isLogin: boolean; uname: string; code?: number };
+
+async function request(path: string, init?: RequestInit): Promise {
+ const res = await fetch(path, {
+ ...init,
+ headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
+ });
+ const text = await res.text();
+ let payload: unknown;
+ try {
+ payload = text ? JSON.parse(text) : {};
+ } catch {
+ throw new Error(`服务返回了非 JSON 内容(HTTP ${res.status})`);
+ }
+ const obj = payload as { ok?: boolean; error?: string };
+ if (!res.ok || obj?.error) throw new Error(obj?.error || `HTTP ${res.status}`);
+ return payload as T;
+}
+
+export const api = {
+ config: () => request('/api/config'),
+ saveConfig: (cfg: Partial) =>
+ request('/api/config', { method: 'POST', body: JSON.stringify(cfg) }),
+ login: () => request('/api/login'),
+ search: (q: string, page = 1) =>
+ request(`/api/search?q=${encodeURIComponent(q)}&page=${page}`),
+ video: (bvid: string) => request(`/api/video?bvid=${encodeURIComponent(bvid)}`),
+ playUrl: (bvid: string, cid: number) =>
+ request(`/api/playurl?bvid=${encodeURIComponent(bvid)}&cid=${cid}`),
+ job: () => request('/api/job'),
+ download: (payload: { bvid: string; cid: number; quality: number; title: string }) =>
+ request('/api/download', { method: 'POST', body: JSON.stringify(payload) }),
+ cancel: () => request<{ ok: boolean }>('/api/cancel', { method: 'POST', body: '{}' }),
+ openFolder: (path?: string) =>
+ request<{ ok: boolean }>('/api/open-folder', { method: 'POST', body: JSON.stringify({ path }) }),
+};
+
+/** 订阅服务端事件。返回取消订阅函数。 */
+export function subscribe(handlers: {
+ onJob?: (job: Job) => void;
+ onProgress?: (p: StreamProgress & { which: 'video' | 'audio'; stage: string }) => void;
+ onStage?: (s: { stage: string }) => void;
+}): () => void {
+ const es = new EventSource('/api/events');
+ es.addEventListener('job', (e) => handlers.onJob?.(JSON.parse((e as MessageEvent).data)));
+ es.addEventListener('progress', (e) => handlers.onProgress?.(JSON.parse((e as MessageEvent).data)));
+ es.addEventListener('stage', (e) => handlers.onStage?.(JSON.parse((e as MessageEvent).data)));
+ es.onerror = () => {
+ /* EventSource 会自动重连 */
+ };
+ return () => es.close();
+}
+
+/** 从任意输入里抠出 BV 号(粘贴链接或直接粘 BV 号都行)。 */
+export function extractBvid(input: string): string | null {
+ const m = input.match(/BV[0-9A-Za-z]{10}/);
+ return m ? m[0] : null;
+}
diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts
new file mode 100644
index 0000000..11f0530
--- /dev/null
+++ b/ui/src/lib/format.ts
@@ -0,0 +1,52 @@
+/** 统一格式化。数字一律用等宽 + tabular-nums 呈现,保证读数对齐。 */
+
+export function formatBytes(n?: number | null): string {
+ if (!n || n <= 0) return '0 B';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let v = n;
+ let i = 0;
+ while (v >= 1024 && i < units.length - 1) {
+ v /= 1024;
+ i++;
+ }
+ return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
+}
+
+export function formatSpeed(bytesPerSec?: number | null): string {
+ if (!bytesPerSec || bytesPerSec <= 0) return '—';
+ return `${formatBytes(bytesPerSec)}/s`;
+}
+
+/** 秒 -> 1:23:45 / 12:34 */
+export function formatDuration(seconds?: number | null): string {
+ if (!seconds || seconds <= 0) return '—';
+ const s = Math.floor(seconds % 60);
+ const m = Math.floor((seconds / 60) % 60);
+ const h = Math.floor(seconds / 3600);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
+}
+
+/** 播放量:1.2万 / 3.4亿 */
+export function formatCount(n?: number | null): string {
+ if (!n || n <= 0) return '0';
+ if (n >= 100000000) return `${(n / 100000000).toFixed(1)}亿`;
+ if (n >= 10000) return `${(n / 10000).toFixed(1)}万`;
+ return String(n);
+}
+
+export function formatEta(info?: { downloaded: number; total: number; speed: number } | null): string {
+ if (!info || !info.speed || info.speed <= 0) return '—';
+ const remain = Math.max(0, info.total - info.downloaded) / info.speed;
+ if (!Number.isFinite(remain) || remain < 0) return '—';
+ return formatDuration(remain);
+}
+
+export function shortPath(p?: string, max = 46): string {
+ if (!p) return '';
+ const normalized = p.replace(/\\/g, '/');
+ if (normalized.length <= max) return p;
+ const parts = normalized.split('/');
+ const tail = parts.slice(-2).join('/');
+ return `…/${tail.length > max ? tail.slice(-max) : tail}`;
+}
diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts
new file mode 100644
index 0000000..f65b2ac
--- /dev/null
+++ b/ui/src/lib/utils.ts
@@ -0,0 +1,48 @@
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
+
+/** 相对时间:今天显示时分,今年显示月日,更早显示年月日 */
+export function formatDate(raw?: string | null): string {
+ if (!raw) return '';
+ const d = new Date(raw);
+ if (Number.isNaN(d.getTime())) return String(raw).slice(0, 16);
+ const now = new Date();
+ const pad = (n: number) => String(n).padStart(2, '0');
+ if (d.toDateString() === now.toDateString()) return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日`;
+ return `${d.getFullYear()}/${pad(d.getMonth() + 1)}/${pad(d.getDate())}`;
+}
+
+export function formatFullDate(raw?: string | null): string {
+ if (!raw) return '';
+ const d = new Date(raw);
+ if (Number.isNaN(d.getTime())) return String(raw);
+ return d.toLocaleString('zh-CN', {
+ year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
+ });
+}
+
+export function formatSize(bytes?: number): string {
+ if (!bytes) return '';
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+}
+
+/** 取显示名首字,用于头像占位 */
+export function initials(name: string, address: string): string {
+ const source = (name || address || '?').trim();
+ const first = source.replace(/["'<>]/g, '').trim()[0];
+ return (first || '?').toUpperCase();
+}
+
+/** 由地址生成稳定的头像底色(色相散开,饱和度/亮度固定,避免花哨) */
+export function avatarHue(seed: string): string {
+ let h = 0;
+ for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) % 360;
+ return `hsl(${h} 45% 42%)`;
+}
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
new file mode 100644
index 0000000..fe8227a
--- /dev/null
+++ b/ui/src/main.tsx
@@ -0,0 +1,13 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { TooltipProvider } from '@/components/ui/tooltip';
+import App from './App';
+import './styles.css';
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+);
diff --git a/ui/src/styles.css b/ui/src/styles.css
new file mode 100644
index 0000000..e88c283
--- /dev/null
+++ b/ui/src/styles.css
@@ -0,0 +1,179 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+/* shadcn/ui 默认(new-york / neutral)主题 token,原样实例化。
+ light 与 dark 两套都保留;默认深色(这个项目的主人偏好深色),可切换并记忆。 */
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.985 0 0);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --destructive-foreground: oklch(0.985 0 0);
+ --border: oklch(1 0 0 / 12%);
+ --input: oklch(1 0 0 / 16%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 12%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+
+ /* 中文界面用系统字体栈(中文字库太大,不引入 webfont);
+ 数字/速度/日志一律走等宽,保证读数纵向对齐。 */
+ --font-sans: "Segoe UI", "Microsoft YaHei UI", "PingFang SC", "Noto Sans SC", system-ui, sans-serif;
+ --font-mono: ui-monospace, "Cascadia Mono", "Consolas", "Microsoft YaHei Mono", monospace;
+}
+
+@layer base {
+ * {
+ border-color: var(--border);
+ }
+ html,
+ body,
+ #root {
+ height: 100%;
+ /* 桌面客户端不该出现页面级滚动条:滚动只发生在三栏内部 */
+ overflow: hidden;
+ }
+ body {
+ margin: 0;
+ background-color: var(--background);
+ color: var(--foreground);
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+ /* 界面全是读数:默认开启等宽数字,避免进度跳动时宽度抖动 */
+ font-variant-numeric: tabular-nums;
+ }
+ /* 日志与所有读数 */
+ .log-pane {
+ font-family: var(--font-mono);
+ font-variant-numeric: tabular-nums;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ }
+}
+
+/* 滚动条(桌面客户端观感)+ 三栏内部的滚动容器 */
+@layer utilities {
+ .scroll-pane {
+ overflow-y: auto;
+ overflow-x: hidden;
+ /* 关键:flex 子项默认 min-height:auto 会按内容撑高,导致「列表没滚、整页滚」 */
+ min-height: 0;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+ }
+ .scrollbar-thin::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+ }
+ .scrollbar-thin::-webkit-scrollbar-thumb {
+ background-color: color-mix(in oklab, var(--foreground) 18%, transparent);
+ border-radius: 9999px;
+ border: 3px solid transparent;
+ background-clip: content-box;
+ }
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover {
+ background-color: color-mix(in oklab, var(--foreground) 30%, transparent);
+ }
+ .scrollbar-thin::-webkit-scrollbar-track {
+ background: transparent;
+ }
+}
diff --git a/ui/tsconfig.json b/ui/tsconfig.json
new file mode 100644
index 0000000..5f79b57
--- /dev/null
+++ b/ui/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": false,
+ "baseUrl": ".",
+ "paths": { "@/*": ["./src/*"] }
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
new file mode 100644
index 0000000..e3d7c62
--- /dev/null
+++ b/ui/vite.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
+import path from 'node:path';
+
+// 构建产物交给本地 Node 服务托管(server/index.js 会优先发 ui/dist),
+// 所以 base 用相对路径,避免路径耦合。
+export default defineConfig({
+ base: './',
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: { '@': path.resolve(__dirname, './src') },
+ },
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ sourcemap: false,
+ chunkSizeWarningLimit: 1200,
+ },
+ server: {
+ port: 5174,
+ // 开发时把 API 代理到本地邮件服务,便于热更新调试
+ proxy: { '/api': 'http://127.0.0.1:8788' },
+ },
+});