commit c7fb8f8f687dc32f1fa12dae7c4b792611bb0ef2 Author: WpyQwq <3911625973@qq.com> Date: Sat Sep 19 11:20:43 2026 +0800 Initial commit: WpywMail 桌面客户端:Node 零依赖本地服务 + React 19 / shadcn-ui 界面,支持收发信、注册、找回密码、会话与账号管理 diff --git a/README.md b/README.md new file mode 100644 index 0000000..6908d71 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# WpywMail 客户端 + +给自建邮件服务器 **WpywMail**(`mail.example.com`)写的桌面客户端。 +收信走 **IMAP4rev1(993 隐式 TLS)**,发信走 **SMTP 提交(587 STARTTLS + AUTH)**。 + +界面用 **shadcn/ui + Tailwind CSS v4 + Vite(React 19)** —— 结构与视觉以 shadcn 官方的 +Mail 示例为基线,控件不自己发明。后端是**零依赖的 Node 服务**,负责讲 IMAP/SMTP 与 MIME。 + +## 怎么启动 + +双击 **`start.cmd`**。它会: + +1. 若 `ui/dist` 尚未构建,自动 `npm install` + `npm run build`(首次约一两分钟); +2. 起一个只监听 `127.0.0.1:8788` 的本地服务(界面与邮件协议之间的桥); +3. 自动打开浏览器指向 `http://127.0.0.1:8788/`。 + +关闭那个黑窗口就等于退出客户端。需要 Node.js 20+(当前机器 v24)。 + +改界面时用热更新:`npm --prefix ui run dev`(已配好把 `/api` 代理到 8788)。 + +首次启动若 `data\account.json` 里没有账号,界面会要求登录;登录成功后凭据写回该文件(只在本机)。 + +## 现在能做什么 + +| 能力 | 说明 | +|---|---| +| 收信 | 6 个文件夹(收件箱/草稿/已发送/归档/垃圾邮件/废纸篓),带未读数 | +| 邮件列表 | 头像、发件人、主题、时间、大小、未读圆点、旗标、已回复/草稿标记 | +| 阅读区 | 中文主题与正文正确解码、附件下载、发件人与收件人信息、完整时间 | +| 撰写 / 回复 / 转发 | 弹窗式;中文头部自动 RFC 2047 编码;多附件;回复带 `In-Reply-To`/`References` | +| 快速回复 | 阅读区底部直接写,`Ctrl+Enter` 发送 | +| 草稿 | 「存草稿」写入 Drafts | +| 搜索 | 主题 / 发件人 / 收件人 / UID,**支持中文**(见「已知限制」) | +| 管理动作 | 已读未读、旗标、归档、删除(默认进废纸篓)、切换文件夹 | +| 主题 | 深色/浅色双主题,切换后记忆(默认深色) | + +### 键盘 + +| 键 | 动作 | +|---|---| +| `J` / `K`(或 ↑↓) | 上下选邮件 | +| `Enter` | 打开 | +| `C` | 撰写 | +| `R` / `F` | 回复 / 转发 | +| `S` | 加/取消旗标 | +| `U` | 已读/未读切换 | +| `E` | 归档 | +| `#` | 删除 | +| `/` | 聚焦搜索框 | +| `Esc` | 关闭弹窗 / 取消焦点 | + +## 架构 + +``` +start.cmd + └─ node server/index.js 本地服务(仅 127.0.0.1:8788):托管界面 + JSON API + ├─ server/imap.js IMAP4rev1 客户端(自己实现,含字面量感知解析器) + ├─ server/smtp.js SMTP 提交客户端(STARTTLS + AUTH PLAIN,DATA 按字节写出) + ├─ server/mime.js MIME 编解码(RFC 5322/2047/2231/2045) + ├─ server/account.js 账户配置读写(data/account.json) + ├─ ui/ 界面源码(shadcn/ui + Tailwind + Vite)→ 构建到 ui/dist + └─ web/ 旧版纯手写界面(兜底;ui/dist 不存在时才会被发出去) +``` + +**为什么后端零依赖**:IMAP/SMTP/MIME 是这块的硬骨头,自己实现能对症下药 —— 实测用它挖出了服务端 +三个真实缺陷(见 `E:\deepseek\artifacts\wpywmail-server-findings.md`),并且不引入供应链风险。 +**为什么界面用现成组件库**:控件观感不必自己发明。shadcn 是「把源码复制进项目」的组件(不是黑盒依赖), +随手可改,同时白拿 Radix 的可访问性底座与 Tailwind 的迭代速度。 + +## 设计系统 + +结构实例化 **shadcn/ui 官方 Mail 示例**(左:账号 + 文件夹导航;中:邮件列表;右:阅读与操作)。 +配色、圆角、阴影、暗色全部用 **shadcn 默认(new-york / neutral)主题 token**,原样写在 +`ui/src/styles.css`(`oklch()` 变量 + `.dark` 覆盖),所以 light/dark 都是"官方观感"。 +换主题只改那一个文件的变量块。 + +字体走系统栈(Segoe UI / 微软雅黑 / PingFang)—— 邮件客户端要的是"读起来像系统原生",不是品牌表达。 + +## 验收记录 + +| 套件 | 结果 | 报告 | +|---|---|---| +| MIME 单元测试 `node tools/mime-test.js` | **25/25** | `E:\deepseek\artifacts\client-mime-test.txt` | +| 协议闭环自测 `node tools/smoke.js` | **20/20** | `E:\deepseek\artifacts\client-smoke.txt` | +| 后端 API 链路 `node tools/api-check.js` | **10/10** | `E:\deepseek\artifacts\client-api-check.txt` | +| 界面运行时(新 UI)`node tools/ui-check-v2.js` | **8/8** | `E:\deepseek\artifacts\client-ui-check-v2.txt` | + +`smoke.js` 是真正的闭环:**用本项目的代码发一封中文信 → 服务器投递 → 再用本项目的 IMAP 代码 +把它从收件箱取回并解析**,断言主题与正文(含全角标点)往返无损。 + +`ui-check-v2.js` 用 **Edge 无头 + CDP(Node 自带 WebSocket,零依赖)** 真开页面:收集 JS 异常与 +控制台报错、断言文件夹/列表/阅读区/撰写弹窗都渲染、验证主题切换真的改变背景色、检查无横向溢出。 + +`api-check.js` 覆盖界面所用的那条链路:发送 → 到达 → 旗标 → 未读 → 移动(归档/移回)→ 删除 → 清理痕迹。 + +## 已知限制(都是真的,没藏) + +1. **搜索是「服务端取回摘要 + 本地过滤」**,窗口上限 500 封,**不含正文全文**。 + 原因:服务端 `SEARCH` 没实现 `BODY`/`OR`,且命令行按 `Encoding.ASCII` 解码 → 中文检索词到服务端 + 会变成 `??`(详见服务端缺陷记录)。客户端绕过了它,所以中文搜索可用。 +2. **不渲染 HTML 邮件正文**,只渲染解析出的纯文本(避免 XSS)。只有 HTML 的邮件会退化成去标签文本。 +3. **附件只支持「添加并随信发出」与「下载」**,不做内联图片渲染。 +4. **密码以明文存在 `data\account.json`**(本机、便携)。要更稳妥可以用 Windows DPAPI 包一层。 +5. 未实现:IDLE 实时推送(目前 30 秒轮询文件夹计数)、会话视图、离线缓存、富文本撰写、多账号。 +6. 服务端目前**没有 `SPECIAL-USE`**,文件夹靠名字映射。 +7. `web/`(旧手写界面)已退役但保留作兜底;日常运行的是 `ui/dist`。 + +## 排障 + +- **界面显示"正在连接邮件服务器…"不动**:看黑窗口里的报错,多半是服务器 993 连不上或密码变了。 +- **发送失败**:报错原样显示服务端返回(如 `550 Relay denied`)。家用 DNS 偶发瞬时解析失败 + (`EAI_AGAIN`)已加一次自动重试。 +- **改了界面没生效**:`npm --prefix ui run build` 重新构建(`ui/dist` 是静态产物,改了源码不会自动更新)。 +- **端口被占**:设环境变量 `WPYWMAIL_PORT` 换端口。 diff --git a/package.json b/package.json new file mode 100644 index 0000000..3afa49d --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "wpywmail-client", + "version": "0.1.0", + "private": true, + "description": "WpywMail 桌面客户端(Node.js 零依赖本地服务 + Edge 界面)", + "type": "commonjs", + "main": "server/index.js", + "scripts": { + "start": "node server/index.js", + "smoke": "node tools/smoke.mjs" + }, + "engines": { + "node": ">=20" + } +} diff --git a/server/account-api.js b/server/account-api.js new file mode 100644 index 0000000..50427b6 --- /dev/null +++ b/server/account-api.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * 邮件服务器「账号接口」客户端(对接服务端 v2.2.0 的账号体系)。 + * + * 走的是服务器上**只放账号类接口**的公网 HTTPS 入口(默认 https://mail.example.com:9443): + * 注册、邮箱验证码、找回密码、登录换 token、改密码、会话与资料。 + * 邮件读写(读信/发件/队列)**不经过这里**,仍然走 IMAP 993 / SMTP 587 —— + * 那个入口对邮件与管理接口一律返回 404(可在服务器上用 /api/messages 验证)。 + * + * 零依赖:用 Node 内置的全局 fetch(Node 18+)。 + */ + +const DEFAULT_API_BASE = 'https://mail.example.com:9443'; + +/** 服务器给的错误信息本身就是中文说明,直接透传给 UI;只补上网络层失败的可读提示。 */ +async function call(base, path, options = {}) { + const { method = 'GET', body, token, timeoutMs = 20000 } = options; + const root = String(base || DEFAULT_API_BASE).replace(/\/+$/, ''); + const url = root + path; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const headers = { 'Content-Type': 'application/json; charset=utf-8' }; + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch(url, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { error: text }; + } + if (!res.ok) { + const err = new Error((data && data.error) || `账号接口返回 HTTP ${res.status}`); + err.status = res.status; + err.data = data; + throw err; + } + return data; + } catch (err) { + if (err && err.status) throw err; + if (err && err.name === 'AbortError') throw new Error(`账号接口超时:${url}`); + throw new Error(`连不上账号接口 ${url}:${err && err.message}`); + } finally { + clearTimeout(timer); + } +} + +function createAccountApi(base) { + const apiBase = String(base || DEFAULT_API_BASE).replace(/\/+$/, ''); + return { + apiBase, + policy: () => call(apiBase, '/api/auth/policy'), + register: (body) => call(apiBase, '/api/register', { method: 'POST', body }), + verifyRegistration: (body) => call(apiBase, '/api/register/verify', { method: 'POST', body }), + resendCode: (body) => call(apiBase, '/api/register/resend', { method: 'POST', body }), + forgot: (email) => call(apiBase, '/api/auth/forgot', { method: 'POST', body: { email } }), + reset: (body) => call(apiBase, '/api/auth/reset', { method: 'POST', body }), + login: (email, password) => call(apiBase, '/api/login', { method: 'POST', body: { email, password } }), + logout: (token) => call(apiBase, '/api/logout', { method: 'POST', body: {}, token }), + changePassword: (token, body) => call(apiBase, '/api/account/password', { method: 'POST', body, token }), + profile: (token) => call(apiBase, '/api/me', { token }), + updateProfile: (token, displayName) => + call(apiBase, '/api/account/profile', { method: 'PATCH', body: { displayName }, token }), + sessions: (token) => call(apiBase, '/api/account/sessions', { token }), + revokeSessions: (token, body) => call(apiBase, '/api/account/sessions/revoke', { method: 'POST', body, token }), + audit: (token, limit = 30) => call(apiBase, `/api/account/audit?limit=${limit}`, { token }), + }; +} + +module.exports = { DEFAULT_API_BASE, createAccountApi }; diff --git a/server/account.js b/server/account.js new file mode 100644 index 0000000..4627e0e --- /dev/null +++ b/server/account.js @@ -0,0 +1,92 @@ +'use strict'; + +/** + * 账户配置的读取与保存。 + * + * 位置优先级: + * 1. 环境变量 WPYWMAIL_ACCOUNT_FILE 指定的文件(自测/多账户时用) + * 2. %LOCALAPPDATA%\WpywMailClient\account.json(默认,跟着用户走) + * + * 安全说明(不粉饰):密码以明文存在这个文件里。这是自研客户端的常见做法 + * (Thunderbird 之类会用系统凭据库/主密码,那需要额外依赖)。本文件只放在 + * 当前用户自己的 profile 目录下,不做任何网络传输;若在意,可把 + * account.savePassword 设为 false,每次启动时手动输入密码。 + */ + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const DEFAULTS = { + host: 'mail.example.com', + imapPort: 993, + smtpPort: 587, + implicitTls: true, // 993 隐式 TLS + startTls: true, // 587 先 STARTTLS 再 AUTH + user: '', + password: '', + displayName: '', + domain: 'wpy.email', + savePassword: true, + // 服务器「账号接口」入口(注册 / 找回密码 / 会话与资料)。 + // 只放账号类接口,邮件读写仍然走 IMAP/SMTP —— 这里看不到任何邮件数据。 + apiBase: 'https://mail.example.com:9443', + // 界面默认值 + pageSize: 50, + folders: { inbox: 'INBOX', sent: 'Sent', drafts: 'Drafts', archive: 'Archive', trash: 'Trash', junk: 'Junk' }, +}; + +function defaultDir() { + if (process.env.WPYWMAIL_CONFIG_DIR) return process.env.WPYWMAIL_CONFIG_DIR; + const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'); + return path.join(base, 'WpywMailClient'); +} + +function accountFile() { + if (process.env.WPYWMAIL_ACCOUNT_FILE) return process.env.WPYWMAIL_ACCOUNT_FILE; + return path.join(defaultDir(), 'account.json'); +} + +function loadAccount() { + const file = accountFile(); + let data = {}; + try { + if (fs.existsSync(file)) data = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (err) { + throw new Error(`账户配置读取失败(${file}):${err.message}`); + } + return Object.assign({}, DEFAULTS, data, { _file: file }); +} + +function saveAccount(account) { + const file = accountFile(); + const clean = Object.assign({}, account); + delete clean._file; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(clean, null, 2), { encoding: 'utf8', mode: 0o600 }); + return file; +} + +/** 供 UI 与自测复用:把配置变成两个客户端 */ +function connectionOptions(account) { + return { + imap: { + host: account.host, + port: account.imapPort, + user: account.user, + password: account.password, + implicitTls: !!account.implicitTls, + startTls: !!account.startTls, + }, + smtp: { + host: account.host, + port: account.smtpPort, + user: account.user, + password: account.password, + startTls: !!account.startTls, + heloName: account.heloName || undefined, + }, + }; +} + +module.exports = { DEFAULTS, accountFile, defaultDir, loadAccount, saveAccount, connectionOptions }; diff --git a/server/imap.js b/server/imap.js new file mode 100644 index 0000000..b2c66fe --- /dev/null +++ b/server/imap.js @@ -0,0 +1,791 @@ +'use strict'; + +/** + * 零依赖 IMAP4rev1 客户端(面向 WpywMail,但按 RFC 3501 通用实现)。 + * + * 为什么自己写而不装库: + * 本客户端刻意保持「零 npm 依赖」—— 没有供应链风险、不需要联网安装、 + * 在 Windows 上双击即用。IMAP 的核心(行 + {n} 字面量 + 标签应答) + * 规模可控,自己实现反而更容易对症下药。 + * + * 已实现:CAPABILITY / LOGIN / LOGOUT / LIST / STATUS / SELECT / EXAMINE / + * UID SEARCH / UID FETCH / UID STORE / UID COPY / EXPUNGE / APPEND / IDLE + * + * 关键难点是**字面量**:形如 `* 1 FETCH (BODY[] {1234}\r\n<1234 字节裸数据>)` + * 的应答不是「一行」,必须先按 CRLF 读到 `{1234}`,再精确读 1234 字节, + * 然后继续读同一条逻辑行的剩余部分。本文件用「分段 + 哨兵」的方式处理。 + */ + +const tls = require('node:tls'); +const net = require('node:net'); +const { EventEmitter } = require('node:events'); +const { decodeWords } = require('./mime'); + +const CRLF = '\r\n'; +const LITERAL_SENTINEL = '\u0000LIT'; + +class ImapError extends Error { + constructor(message, info = {}) { + super(message); + this.name = 'ImapError'; + this.status = info.status || null; // 'NO' / 'BAD' / null + this.command = info.command || null; + this.detail = info.detail || ''; + } +} + +/** 把 IMAP 的 quoted string / atom 做转义后包成带引号的字符串 */ +function quote(value) { + return '"' + String(value).replace(/[\\"]/g, (m) => '\\' + m) + '"'; +} + +// ─────────────────────────────────────────────────────────── 应答解析 + +/** + * 把「分段 + 字面量」拼成一条逻辑行字符串,字面量用 \u0000LIT\u0000 占位。 + * 之后就可以对整行做统一分词了。 + */ +function assembleLogical(parts, literals) { + let out = parts[0] || ''; + for (let i = 0; i < literals.length; i++) { + out += LITERAL_SENTINEL + i + '\u0000'; + out += parts[i + 1] || ''; + } + return out; +} + +/** 分词:括号 / 带引号字符串 / 字面量占位 / 裸原子 */ +function tokenize(text) { + const tokens = []; + let i = 0; + const n = text.length; + while (i < n) { + const c = text[i]; + if (c === ' ' || c === '\t') { i++; continue; } + if (c === '(') { tokens.push({ t: 'open' }); i++; continue; } + if (c === ')') { tokens.push({ t: 'close' }); i++; continue; } + if (c === '"') { + let j = i + 1; + let out = ''; + while (j < n) { + if (text[j] === '\\') { out += text[j + 1]; j += 2; continue; } + if (text[j] === '"') break; + out += text[j]; + j++; + } + tokens.push({ t: 'str', v: out }); + i = j + 1; + continue; + } + if (c === '\u0000') { + const m = /^\u0000LIT(\d+)\u0000/.exec(text.slice(i)); + if (m) { + tokens.push({ t: 'lit', i: Number(m[1]) }); + i += m[0].length; + continue; + } + } + let j = i; + while (j < n && text[j] !== ' ' && text[j] !== '(' && text[j] !== ')' && text[j] !== '"' && text[j] !== '\u0000') j++; + const atom = text.slice(i, j); + tokens.push({ t: 'atom', v: atom === 'NIL' ? null : atom }); + i = j; + } + return tokens; +} + +/** 由分词结果构建嵌套数组(NIL → null,字面量 → Buffer) */ +function buildTree(tokens, literals) { + let pos = 0; + function parse() { + const out = []; + while (pos < tokens.length) { + const tk = tokens[pos]; + if (tk.t === 'open') { pos++; out.push(parse()); continue; } + if (tk.t === 'close') { pos++; return out; } + pos++; + if (tk.t === 'str' || tk.t === 'atom') out.push(tk.v); + else if (tk.t === 'lit') out.push(literals[tk.i]); + } + return out; + } + return parse(); +} + +/** 一步到位:逻辑行的分段+字面量 → 树 */ +function parseResponse(parts, literals) { + return buildTree(tokenize(assembleLogical(parts, literals)), literals); +} + +/** 把 FETCH 的 (KEY VALUE KEY VALUE ...) 扁平列表转成对象,键统一大写 */ +function fetchItemsToObject(list) { + const out = {}; + for (let i = 0; i < list.length - 1; i += 2) { + const key = list[i]; + if (typeof key !== 'string') continue; + out[key.toUpperCase()] = list[i + 1]; + } + return out; +} + +/** ENVELOPE 地址项 → {name, mailbox, host} */ +function addressFrom(raw) { + if (!Array.isArray(raw)) return null; + const [name, , mailbox, host] = raw; + if (!mailbox && !host) return null; + const addr = [mailbox, host].filter(Boolean).join('@'); + return { name: name || '', address: addr }; +} + +/** ENVELOPE 结构 → 可用的头信息(RFC 3501 第 7.4.2 节,共 10 个字段) */ +function parseEnvelope(env) { + if (!Array.isArray(env)) return null; + const [date, subject, from, sender, replyTo, to, cc, bcc, inReplyTo, messageId] = env; + const addrs = (x) => (Array.isArray(x) ? x.map(addressFrom).filter(Boolean) : []); + return { + date: date || null, + subject: subject || '', + from: addrs(from), + sender: addrs(sender), + replyTo: addrs(replyTo), + to: addrs(to), + cc: addrs(cc), + bcc: addrs(bcc), + inReplyTo: inReplyTo || null, + messageId: messageId || null, + }; +} + +// ─────────────────────────────────────────────────────────── 客户端 + +class ImapClient extends EventEmitter { + /** + * @param {object} opts + * @param {string} opts.host + * @param {number} [opts.port=993] + * @param {string} opts.user + * @param {string} opts.password + * @param {boolean} [opts.implicitTls=true] true=993 直接 TLS;false=143 明文(可再 STARTTLS) + * @param {boolean} [opts.startTls=false] implicitTls=false 时是否发 STARTTLS + * @param {number} [opts.timeoutMs=30000] + * @param {boolean} [opts.debug=false] + */ + constructor(opts) { + super(); + this.opts = Object.assign( + { port: 993, implicitTls: true, startTls: false, timeoutMs: 30000, debug: false }, + opts + ); + this.socket = null; + this.buffer = Buffer.alloc(0); + this.literals = []; // 当前逻辑行收集到的字面量 + this.parts = []; // 当前逻辑行收集到的文本分段 + this.tagSeq = 0; + this.pending = new Map(); // tag -> {resolve, reject, untagged, command, timer} + this.current = null; // 最近一次已发出、尚未完成的命令(用于归集未标记应答) + this.continuation = null; // 等待 "+" 的 promise resolver(IDLE / APPEND / AUTHENTICATE) + this.capabilities = []; + this.selected = null; + this.exists = 0; + this.closed = false; + this._lineCount = 0; + this.on('error', () => {}); // 避免未处理的 error 事件把进程带崩 + } + + get connected() { + return !!this.socket && !this.socket.destroyed; + } + + _log(dir, text) { + if (!this.opts.debug) return; + const one = String(text).replace(/\r?\n/g, '\\n').slice(0, 300); + process.stderr.write(`[imap ${dir}] ${one}\n`); + } + + async connect() { + const { host, port, implicitTls, timeoutMs } = this.opts; + this.socket = await new Promise((resolve, reject) => { + const onError = (err) => reject(new ImapError(`连接 ${host}:${port} 失败:${err.message}`)); + const sock = implicitTls + ? tls.connect({ host, port, servername: host }, () => resolve(sock)) + : net.connect({ host, port }, () => resolve(sock)); + sock.setNoDelay(true); + sock.once('error', onError); + sock.setTimeout(timeoutMs, () => reject(new ImapError(`连接 ${host}:${port} 超时`))); + }); + + this.socket.setTimeout(0); + this.socket.on('data', (chunk) => this._onData(chunk)); + this.socket.on('error', (err) => this._failAll(err)); + this.socket.on('close', () => { + this.closed = true; + this._failAll(new ImapError('连接已被对端关闭')); + this.emit('close'); + }); + + // 服务器问候语(* OK [CAPABILITY ...] ...)——等它到达并解析 + const greeting = await this._waitGreeting(); + this._log('<', greeting); + + if (!implicitTls && this.opts.startTls) { + await this._command('STARTTLS'); + await this._upgradeToTls(); + const caps = await this._command('CAPABILITY'); + this.capabilities = (caps.untaggedText().find((l) => /CAPABILITY/i.test(l)) || '') + .replace(/^\*\s*CAPABILITY\s*/i, '') + .split(/\s+/) + .filter(Boolean); + } + return greeting; + } + + _waitGreeting() { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new ImapError('等待服务器问候语超时')), this.opts.timeoutMs); + this._greetResolve = (line) => { clearTimeout(timer); resolve(line); }; + }); + } + + _upgradeToTls() { + const { host } = this.opts; + return new Promise((resolve, reject) => { + this.socket.removeAllListeners('data'); + const secured = tls.connect({ socket: this.socket, servername: host }, () => { + this.socket = secured; + this.buffer = Buffer.alloc(0); + this.parts = []; + this.literals = []; + secured.setNoDelay(true); + secured.on('data', (chunk) => this._onData(chunk)); + secured.on('error', (err) => this._failAll(err)); + secured.on('close', () => { + this.closed = true; + this._failAll(new ImapError('连接已被对端关闭')); + }); + resolve(); + }); + secured.once('error', reject); + }); + } + + // ───────────────────────────────────────── 数据接收与逻辑行组装 + + _onData(chunk) { + this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk; + this._drain(); + } + + /** + * 从缓冲区里不断提取「完整逻辑行」。 + * 一条逻辑行可能因为 {n} 字面量而被切成多段,这里用 parts/literals 累积。 + */ + _drain() { + for (;;) { + const idx = this.buffer.indexOf(CRLF); + if (idx < 0) { + // 没有 CRLF,且缓冲区已经很大 → 可能是异常数据,保护一下 + if (this.buffer.length > 64 * 1024 * 1024) { + this._failAll(new ImapError('单行缓冲超过 64MB,疑似协议异常')); + this.socket.destroy(); + } + return; + } + const lineBuf = this.buffer.slice(0, idx); + const line = lineBuf.toString('latin1'); + const m = /\{(\d+)\}$/.exec(line); + if (m) { + const need = Number(m[1]); + const after = idx + 2; + if (this.buffer.length < after + need) return; // 字面量还没到齐 + const literal = this.buffer.slice(after, after + need); + this.parts.push(line.slice(0, m.index)); + this.literals.push(literal); + this.buffer = this.buffer.slice(after + need); + continue; // 继续读这条逻辑行后面的内容 + } + // 完整逻辑行 + this.parts.push(line); + const lineParts = this.parts; + const lineLiterals = this.literals; + this.parts = []; + this.literals = []; + this.buffer = this.buffer.slice(idx + 2); + this._lineCount++; + this._handleLine(lineParts, lineLiterals); + } + } + + _handleLine(parts, literals) { + const head = parts.join('\u0000LIT\u0000').replace(/\u0000LIT\d+\u0000/g, '').trim(); + this._log('<', head.slice(0, 400) + (literals.length ? ` [+${literals.length} 字面量]` : '')); + + // 1) 续行请求 "+ ..." + if (head.startsWith('+')) { + const cb = this.continuation; + this.continuation = null; + if (cb) cb(null, head.slice(1).trim()); + return; + } + + // 2) 带标签的完成应答 + const tm = /^(\S+)\s+(OK|NO|BAD)\b([\s\S]*)$/i.exec(head); + if (tm && this.pending.has(tm[1])) { + const tag = tm[1]; + const entry = this.pending.get(tag); + this.pending.delete(tag); + clearTimeout(entry.timer); + if (this.current === entry) this.current = null; + const status = tm[2].toUpperCase(); + const text = tm[3].trim(); + if (status === 'OK') { + // 把 entry 上的辅助方法一并挂到返回值上,调用方才能用 res.untaggedText() / res.tree() + entry.resolve({ + status, + text, + untagged: entry.untagged, + lines: entry.untagged, + untaggedText: entry.untaggedText, + tree: entry.tree, + response: entry, + }); + } else { + // 顺手把 [ ... ] 里的状态码也带进错误信息,便于排查 + entry.reject(new ImapError(`${entry.command} 被拒绝:${status} ${text}`, { + status, command: entry.command, detail: text, + })); + } + return; + } + + // 3) 问候语 + if (/^\*\s+(OK|PREAUTH)/i.test(head) && this._greetResolve) { + const r = this._greetResolve; + this._greetResolve = null; + const cm = /\[CAPABILITY\s+([^\]]+)\]/i.exec(head); + if (cm) this.capabilities = cm[1].trim().split(/\s+/); + r(head); + this._dispatchUntagged(parts, literals, head); + return; + } + + // 4) 未标记应答 + this._dispatchUntagged(parts, literals, head); + } + + _dispatchUntagged(parts, literals, head) { + const tree = parseResponse(parts, literals); + + // 已选邮箱的状态变化 → 主动广播给上层(IDLE 期间尤其重要) + const existsMatch = /^\*\s+(\d+)\s+EXISTS/i.exec(head); + if (existsMatch) { + this.exists = Number(existsMatch[1]); + this.emit('exists', this.exists); + } + if (/^\*\s+\d+\s+EXPUNGE/i.test(head)) this.emit('expunge', Number(head.split(/\s+/)[1])); + if (/^\*\s+BYE/i.test(head)) this.emit('bye', head); + + if (this.current) this.current.untagged.push({ head, tree, parts, literals }); + else this.emit('untagged', { head, tree }); + } + + _failAll(err) { + for (const [, entry] of this.pending) { + clearTimeout(entry.timer); + entry.reject(err); + } + this.pending.clear(); + this.current = null; + if (this.continuation) { + const cb = this.continuation; + this.continuation = null; + cb(err); + } + this.emit('error', err); + } + + // ───────────────────────────────────────── 命令 + + _write(text) { + this._log('>', text); + this.socket.write(text); + } + + /** 发一条命令并等它的带标签应答 */ + _command(command, { collect = true, timeoutMs = null } = {}) { + if (!this.connected) return Promise.reject(new ImapError('连接不可用')); + const tag = 'W' + String(++this.tagSeq).padStart(4, '0'); + return new Promise((resolve, reject) => { + const entry = { + command, untagged: [], resolve, reject, tag, + untaggedText: () => entry.untagged.map((u) => u.head), + tree: () => entry.untagged.map((u) => u.tree), + }; + const timer = setTimeout(() => { + this.pending.delete(tag); + if (this.current === entry) this.current = null; + reject(new ImapError(`${command} 超时(${(timeoutMs || this.opts.timeoutMs) / 1000}s)`, { command })); + }, timeoutMs || this.opts.timeoutMs); + entry.timer = timer; + this.pending.set(tag, entry); + if (collect) this.current = entry; + this._write(`${tag} ${command}${CRLF}`); + }); + } + + /** 等服务器发 "+"(IDLE / APPEND 用) */ + _waitContinuation(timeoutMs = this.opts.timeoutMs) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.continuation = null; + reject(new ImapError('等待续行(+)超时')); + }, timeoutMs); + this.continuation = (err, text) => { + clearTimeout(timer); + if (err) reject(err); + else resolve(text); + }; + }); + } + + /** 开始一条命令但不等待完成,返回 {tag, done};用于需要等 "+" 的多步命令 */ + _beginCommand(command, timeoutMs = this.opts.timeoutMs) { + const tag = 'W' + String(++this.tagSeq).padStart(4, '0'); + const done = new Promise((resolve, reject) => { + const entry = { + command, untagged: [], resolve, reject, tag, + untaggedText: () => entry.untagged.map((u) => u.head), + tree: () => entry.untagged.map((u) => u.tree), + }; + entry.timer = setTimeout(() => { + this.pending.delete(tag); + if (this.current === entry) this.current = null; + reject(new ImapError(`${command} 超时(${timeoutMs / 1000}s)`, { command })); + }, timeoutMs); + this.pending.set(tag, entry); + this.current = entry; + }); + this._write(`${tag} ${command}${CRLF}`); + return { tag, done }; + } + + async capability() { + const res = await this._command('CAPABILITY'); + const line = res.untaggedText().find((l) => /CAPABILITY/i.test(l)) || ''; + this.capabilities = line.replace(/^\*\s*CAPABILITY\s*/i, '').trim().split(/\s+/).filter(Boolean); + return this.capabilities; + } + + /** + * 登录。策略(按兼容性从高到低): + * 1. LOGIN —— 最通用,WpywMail 支持 + * 2. AUTHENTICATE PLAIN 两步式(先命令、等 "+"、再发 base64)—— 应对禁用了 LOGIN 的服务器 + * 3. AUTHENTICATE PLAIN 行内初始应答 —— 需要 SASL-IR,实测本服务器不接受,放最后 + * 返回实际使用的方式,便于排查。 + */ + async login() { + const { user, password } = this.opts; + if (!user) throw new ImapError('缺少用户名'); + const plain = Buffer.from(`\u0000${user}\u0000${password}`, 'utf8').toString('base64'); + const canPlain = (this.capabilities || []).some((c) => /^AUTH=PLAIN$/i.test(c)); + + try { + await this._command(`LOGIN ${quote(user)} ${quote(password)}`); + this.authMethod = 'LOGIN'; + return 'LOGIN'; + } catch (err) { + if (!canPlain) throw err; // 服务器不支持 PLAIN,原始错误直接抛出更有用 + this._loginError = err; + } + + try { + const { done } = this._beginCommand('AUTHENTICATE PLAIN'); + await this._waitContinuation(30000); + this._write(`${plain}${CRLF}`); + await done; + this.authMethod = 'AUTHENTICATE PLAIN(两步式)'; + return this.authMethod; + } catch (err) { + this._loginError = err; + } + + await this._command(`AUTHENTICATE PLAIN ${plain}`); + this.authMethod = 'AUTHENTICATE PLAIN(行内)'; + return this.authMethod; + } + + /** LIST "" "*" → [{flags, delimiter, name, raw}] */ + async list(reference = '', pattern = '*') { + const res = await this._command(`LIST ${quote(reference)} ${quote(pattern)}`); + const boxes = []; + for (const u of res.untagged) { + const m = /^\*\s+LIST\s+\((.*?)\)\s+(NIL|"[^"]*")\s+(.+)$/i.exec(u.head); + if (!m) continue; + const flags = m[1].trim() ? m[1].trim().split(/\s+/) : []; + const delim = m[2] === 'NIL' ? null : m[2].slice(1, -1); + let name = m[3].trim(); + if (name.startsWith('"')) name = name.slice(1, -1).replace(/\\(.)/g, '$1'); + boxes.push({ flags, delimiter: delim, name, raw: u.head }); + } + return boxes; + } + + /** STATUS "INBOX" (MESSAGES UNSEEN RECENT UIDNEXT UIDVALIDITY) */ + async status(mailbox, items = ['MESSAGES', 'UNSEEN', 'RECENT', 'UIDNEXT', 'UIDVALIDITY']) { + const res = await this._command(`STATUS ${quote(mailbox)} (${items.join(' ')})`); + const out = {}; + for (const u of res.untagged) { + if (!/^\*\s+STATUS/i.test(u.head)) continue; + const open = u.head.indexOf('('); + const close = u.head.lastIndexOf(')'); + if (open < 0 || close < 0) continue; + const body = u.head.slice(open + 1, close).trim(); + const toks = body.split(/\s+/); + for (let i = 0; i < toks.length - 1; i += 2) { + out[toks[i].toUpperCase()] = Number(toks[i + 1]); + } + } + return out; + } + + /** SELECT / EXAMINE */ + async select(mailbox, { readonly = false } = {}) { + const res = await this._command(`${readonly ? 'EXAMINE' : 'SELECT'} ${quote(mailbox)}`, { + timeoutMs: 60000, + }); + const info = { mailbox, readonly, exists: 0, recent: 0, unseen: null, uidValidity: null, uidNext: null, flags: [] }; + for (const u of res.untagged) { + let m; + if ((m = /^\*\s+(\d+)\s+EXISTS/i.exec(u.head))) info.exists = Number(m[1]); + else if ((m = /^\*\s+(\d+)\s+RECENT/i.exec(u.head))) info.recent = Number(m[1]); + else if ((m = /^\*\s+FLAGS\s+\((.*?)\)/i.exec(u.head))) info.flags = m[1].trim().split(/\s+/).filter(Boolean); + else if ((m = /\[UNSEEN\s+(\d+)\]/i.exec(u.head))) info.unseen = Number(m[1]); + else if ((m = /\[UIDVALIDITY\s+(\d+)\]/i.exec(u.head))) info.uidValidity = Number(m[1]); + else if ((m = /\[UIDNEXT\s+(\d+)\]/i.exec(u.head))) info.uidNext = Number(m[1]); + } + this.selected = mailbox; + this.exists = info.exists; + return info; + } + + /** UID SEARCH [criteria...] → UID 数组 */ + async searchUid(criteria = ['ALL']) { + const res = await this._command(`UID SEARCH ${criteria.join(' ')}`, { timeoutMs: 120000 }); + const line = res.untaggedText().find((l) => /^\*\s+SEARCH/i.test(l)) || ''; + const body = line.replace(/^\*\s+SEARCH\s*/i, '').trim(); + if (!body) return []; + return body.split(/\s+/).map(Number).filter((x) => Number.isFinite(x)); + } + + /** + * UID FETCH:取摘要(信封/旗标/大小/时间)。 + * @param {number[]|string} uids + */ + async fetchSummaries(uids, { chunkSize = 200 } = {}) { + const list = Array.isArray(uids) ? uids : [uids]; + const out = []; + for (let i = 0; i < list.length; i += chunkSize) { + const chunk = list.slice(i, i + chunkSize); + if (!chunk.length) continue; + const set = chunk.join(','); + const res = await this._command( + `UID FETCH ${set} (UID FLAGS RFC822.SIZE INTERNALDATE ENVELOPE)`, + { timeoutMs: 180000 } + ); + for (const u of res.untagged) { + if (!/FETCH/i.test(u.head)) continue; + const tree = u.tree; + const idx = tree.findIndex((x) => x === 'FETCH'); + if (idx < 0 || !Array.isArray(tree[idx + 1])) continue; + const items = fetchItemsToObject(tree[idx + 1]); + out.push(this._summarize(items, u.literals)); + } + } + return out; + } + + _summarize(items, literals) { + const env = parseEnvelope(items.ENVELOPE); + // RFC 3501:ENVELOPE 里的字符串是「与报文中一致的原文」,编码字不会被服务器解码, + // 所以客户端必须自己解 RFC 2047,否则列表里会显示 =?UTF-8?B?...?= + if (env) { + env.subjectRaw = env.subject; + env.subject = decodeWords(env.subject || ''); + for (const key of ['from', 'to', 'cc', 'replyTo', 'sender']) { + if (Array.isArray(env[key])) { + for (const a of env[key]) if (a && a.name) a.name = decodeWords(a.name); + } + } + } + const flags = Array.isArray(items.FLAGS) ? items.FLAGS : []; + // BODY[] / BODY[HEADER] 这类键的值就是字面量 + let raw = null; + for (const k of Object.keys(items)) { + if (/^BODY(\[.*\])?$/.test(k) || k === 'RFC822') { + if (Buffer.isBuffer(items[k])) raw = items[k]; + } + } + if (raw == null && literals && literals.length) raw = literals[0]; + return { + uid: Number(items.UID), + flags, + size: Number(items['RFC822.SIZE'] || 0), + internalDate: items.INTERNALDATE ? String(items.INTERNALDATE) : null, + envelope: env, + seen: flags.some((f) => /\\Seen/i.test(f)), + flagged: flags.some((f) => /\\Flagged/i.test(f)), + answered: flags.some((f) => /\\Answered/i.test(f)), + draft: flags.some((f) => /\\Draft/i.test(f)), + raw, + }; + } + + /** UID FETCH 整封原始报文(BODY.PEEK[]:不置 \Seen) */ + async fetchRaw(uid, { peek = true } = {}) { + const item = peek ? 'BODY.PEEK[]' : 'BODY[]'; + const res = await this._command(`UID FETCH ${uid} (UID FLAGS ${item})`, { timeoutMs: 180000 }); + for (const u of res.untagged) { + if (!/FETCH/i.test(u.head)) continue; + const tree = u.tree; + const idx = tree.findIndex((x) => x === 'FETCH'); + if (idx < 0 || !Array.isArray(tree[idx + 1])) continue; + const items = fetchItemsToObject(tree[idx + 1]); + const buf = this._extractBody(items, u.literals); + if (buf) { + const flags = Array.isArray(items.FLAGS) ? items.FLAGS : []; + return { uid, raw: buf, flags }; + } + } + return null; + } + + _extractBody(items, literals) { + for (const k of Object.keys(items)) { + if (/^BODY(\[.*\])?$/i.test(k) || /^RFC822(\.TEXT)?$/i.test(k)) { + if (Buffer.isBuffer(items[k])) return items[k]; + } + } + return literals && literals.length ? literals[literals.length - 1] : null; + } + + /** UID STORE:mode 为 '+FLAGS' / '-FLAGS' / 'FLAGS';flags 例如 ['\\Seen'] */ + async storeFlags(uid, mode, flags) { + const uids = Array.isArray(uid) ? uid.join(',') : String(uid); + const list = flags.length ? '(' + flags.join(' ') + ')' : '()'; + const res = await this._command(`UID STORE ${uids} ${mode} ${list}`, { timeoutMs: 60000 }); + const updated = []; + for (const u of res.untagged) { + if (!/FETCH/i.test(u.head)) continue; + const tree = u.tree; + const idx = tree.findIndex((x) => x === 'FETCH'); + if (idx >= 0 && Array.isArray(tree[idx + 1])) updated.push(this._summarize(fetchItemsToObject(tree[idx + 1]), u.literals)); + } + return updated; + } + + /** UID COPY:移动的第一步(IMAP 没有 MOVE,服务器也没广告 MOVE) */ + async copy(uid, mailbox) { + const uids = Array.isArray(uid) ? uid.join(',') : String(uid); + await this._command(`UID COPY ${uids} ${quote(mailbox)}`, { timeoutMs: 120000 }); + return true; + } + + /** 把邮件标为删除并 EXPUNGE(真正的「移动」= COPY + 标记删除 + EXPUNGE) */ + async deleteUid(uid, { expunge = true } = {}) { + await this.storeFlags(uid, '+FLAGS', ['\\Deleted']); + if (expunge) await this.expunge(); + return true; + } + + async expunge() { + await this._command('EXPUNGE', { timeoutMs: 120000 }); + return true; + } + + /** APPEND 一封邮件到指定邮箱 */ + async append(mailbox, rawBuffer, { flags = [], date = null } = {}) { + const size = rawBuffer.length; + const flagPart = flags.length ? `(${flags.join(' ')}) ` : ''; + const datePart = date ? `${quote(date)} ` : ''; + // 先发命令头,等 "+",再发字面量本体 + const tag = 'W' + String(++this.tagSeq).padStart(4, '0'); + const wait = this._waitContinuation(60000); + const done = new Promise((resolve, reject) => { + const entry = { + command: `APPEND ${mailbox}`, untagged: [], resolve, reject, tag, + untaggedText: () => entry.untagged.map((u) => u.head), + tree: () => entry.untagged.map((u) => u.tree), + }; + entry.timer = setTimeout(() => { + this.pending.delete(tag); + reject(new ImapError('APPEND 超时')); + }, 180000); + this.pending.set(tag, entry); + this.current = entry; + }); + this._write(`${tag} APPEND ${quote(mailbox)} ${flagPart}${datePart}{${size}}${CRLF}`); + await wait; + this._log('>', `<${size} 字节字面量>`); + this.socket.write(rawBuffer); + this._write(CRLF); + await done; + return true; + } + + /** + * IDLE:服务器有变化时会触发 'exists' / 'expunge' / 'bye' 事件。 + * 返回一个 stop() 用于结束 IDLE(内部会发 DONE 并等 tagged OK)。 + */ + async idle({ maxMs = 9 * 60 * 1000 } = {}) { + const tag = 'W' + String(++this.tagSeq).padStart(4, '0'); + const finished = new Promise((resolve, reject) => { + const entry = { + command: 'IDLE', untagged: [], resolve, reject, tag, + untaggedText: () => entry.untagged.map((u) => u.head), + tree: () => entry.untagged.map((u) => u.tree), + }; + entry.timer = setTimeout(() => { + this.pending.delete(tag); + reject(new ImapError('IDLE 超时')); + }, maxMs); + this.pending.set(tag, entry); + this.current = entry; + }); + const cont = this._waitContinuation(30000); + this._write(`${tag} IDLE${CRLF}`); + await cont; + + let stopped = false; + const stop = async () => { + if (stopped) return; + stopped = true; + this._write(`DONE${CRLF}`); + try { await finished; } catch { /* 忽略停止时的噪音 */ } + }; + // 让调用方既能 await idle() 拿到 stop,也方便忘记停止时自动收尾 + return stop; + } + + /** 优雅退出 */ + async logout() { + try { + if (this.connected) await this._command('LOGOUT', { timeoutMs: 8000 }); + } catch { /* 忽略 */ } + this._destroy(); + } + + _destroy() { + try { this.socket?.destroy(); } catch { /* 忽略 */ } + this.closed = true; + } +} + +module.exports = { + ImapClient, + ImapError, + // 导出解析器,便于单测与客户端侧复用 + parseResponse, + parseEnvelope, + tokenize, + buildTree, + fetchItemsToObject, +}; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..9c998ca --- /dev/null +++ b/server/index.js @@ -0,0 +1,730 @@ +'use strict'; + +/** + * WpywMail 客户端本地服务。 + * + * 职责:① 保持一条到 mail.example.com 的 IMAP 会话;② 把 IMAP 能力包成 JSON API; + * ③ 托管界面静态文件。界面在浏览器里跑,因此不需要任何原生壳。 + * + * 设计要点: + * - **一条连接、串行化**:IMAP 是单连接状态机,多个请求并发会互相踩。 + * 所有 IMAP 操作都过 _queue(),保证同一时刻只有一条命令在飞。 + * - **按需重连**:连接掉了自动重建一次,失败则把错误抛给界面(界面显示离线态)。 + * - 只监听 127.0.0.1,绝不对外暴露。 + */ + +const http = require('node:http'); +const fs = require('node:fs'); +const path = require('node:path'); +const { URL } = require('node:url'); + +const { ImapClient, ImapError } = require('./imap'); +const { SmtpClient } = require('./smtp'); +const mime = require('./mime'); +const { loadAccount, saveAccount, connectionOptions } = require('./account'); +const { createAccountApi, DEFAULT_API_BASE } = require('./account-api'); +const { sanitizeHtml, buildDocument } = require('./sanitize'); + +const HOST = process.env.WPYWMAIL_HOST || '127.0.0.1'; +const PORT = Number(process.env.WPYWMAIL_PORT || 8788); +const WEB_DIR = path.join(__dirname, '..', 'web'); +// 新界面(shadcn/ui + Vite)的构建产物;存在就优先发它,否则回退到旧的纯手写界面 +const UI_DIST = path.join(__dirname, '..', 'ui', 'dist'); +const SITE_DIR = fs.existsSync(path.join(UI_DIST, 'index.html')) ? UI_DIST : WEB_DIR; + +// ─────────────────────────────────────────────────────────── IMAP 会话 + +class Session { + constructor(account) { + this.account = account; + this.options = connectionOptions(account); + this.imap = null; + this.connecting = null; + this.tail = Promise.resolve(); // 串行化队列 + this.cache = { folders: null, foldersAt: 0, summaries: new Map() }; + this.lastError = null; + } + + /** 把任意异步操作串到队列尾部,保证 IMAP 上一时刻只有一条命令 */ + _queue(fn) { + const run = this.tail.then(fn, fn); + // 队列本身不因单次失败而中断 + this.tail = run.then(() => undefined, () => undefined); + return run; + } + + async connect({ force = false } = {}) { + if (this.imap && this.imap.connected && !force) return this.imap; + if (this.connecting) return this.connecting; + this.connecting = (async () => { + const c = new ImapClient(this.options.imap); + try { + await c.connect(); + await c.capability(); + await c.login(); + c.on('error', (err) => { this.lastError = err.message; }); + this.imap = c; + this.lastError = null; + this.cache.folders = null; + return c; + } catch (err) { + try { c._destroy(); } catch { /* 忽略 */ } + this.imap = null; + this.lastError = err.message; + throw err; + } finally { + this.connecting = null; + } + })(); + return this.connecting; + } + + /** 带一次自动重连的操作包装 */ + async withImap(fn) { + return this._queue(async () => { + let client = await this.connect(); + try { + return await fn(client); + } catch (err) { + // 连接类错误 → 重连一次再试;业务类错误(NO/BAD)直接抛 + const retriable = err instanceof ImapError + ? !/被拒绝/.test(err.message) + : true; + if (!retriable) throw err; + client = await this.connect({ force: true }); + return fn(client); + } + }); + } + + async folders({ refresh = false, maxAgeMs = 15000 } = {}) { + const now = Date.now(); + if (!refresh && this.cache.folders && now - this.cache.foldersAt < maxAgeMs) { + return this.cache.folders; + } + return this.withImap(async (client) => { + const boxes = await client.list(); + const out = []; + for (const b of boxes) { + const st = await client.status(b.name).catch(() => ({})); + out.push({ + name: b.name, + flags: b.flags, + messages: st.MESSAGES || 0, + unseen: st.UNSEEN || 0, + recent: st.RECENT || 0, + }); + } + // 常见顺序:收件箱最前,其次草稿/已发送,最后是归档/垃圾/废纸篓 + const order = ['INBOX', 'Drafts', 'Sent', 'Archive', 'Junk', 'Trash']; + out.sort((a, b) => { + const ia = order.indexOf(a.name.toUpperCase()); + const ib = order.indexOf(b.name.toUpperCase()); + return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib); + }); + this.cache.folders = out; + this.cache.foldersAt = Date.now(); + return out; + }); + } + + async messageList({ folder, limit = 50, offset = 0, query = '' }) { + return this.withImap(async (client) => { + await client.select(folder, { readonly: false }); + const allUids = await client.searchUid(['ALL']); + + // ── 搜索 + // 为什么不用服务器端 SEARCH:WpywMail 的 SEARCH 只实现了 SEEN/UNSEEN/FLAGGED/ + // UNFLAGGED/DELETED/FROM/SUBJECT/TEXT/UID,**没有 BODY、没有 OR**(未知条件被静默忽略), + // 而且它的命令行是按 Encoding.ASCII 解码的 —— 中文检索词到了服务端会变成 "??", + // 永远匹配不到。所以这里改为:取回摘要(已解码 RFC 2047)后在本地过滤。 + // 代价是搜索窗口有上限(下面 500 封);个人邮箱足够,且永远正确。 + if (query && query.trim()) { + const needle = query.trim().toLowerCase(); + const window = allUids.slice().sort((a, b) => b - a).slice(0, 500); + const summaries = window.length ? await client.fetchSummaries(window) : []; + const hit = summaries.filter((s) => { + const env = s.envelope || {}; + const hay = [ + env.subject || '', + ...(env.from || []).map((a) => `${a.name} ${a.address}`), + ...(env.to || []).map((a) => `${a.name} ${a.address}`), + ...(env.cc || []).map((a) => `${a.name} ${a.address}`), + ].join('\n').toLowerCase(); + if (hay.includes(needle)) return true; + // 支持直接按 UID 找 + return /^\d+$/.test(needle) && String(s.uid) === needle; + }); + const total = hit.length; + return { + folder, total, offset, limit, query: query.trim(), + messages: hit.slice(offset, offset + limit).map(mapSummary), + }; + } + + const total = allUids.length; + const newestFirst = allUids.slice().sort((a, b) => b - a); + const page = newestFirst.slice(offset, offset + limit); + const summaries = page.length ? await client.fetchSummaries(page) : []; + const byUid = new Map(summaries.map((s) => [s.uid, s])); + const ordered = page.map((u) => byUid.get(u)).filter(Boolean); + return { folder, total, offset, limit, messages: ordered.map(mapSummary) }; + }); + } + + async message(uid, folder) { + return this.withImap(async (client) => { + await client.select(folder, { readonly: false }); + const raw = await client.fetchRaw(uid, { peek: true }); + if (!raw) throw new Error(`找不到 UID ${uid}`); + const parsed = mime.parseMessage(raw.raw); + return { + uid, + folder, + subject: parsed.subject, + from: parsed.from, + to: parsed.to, + cc: parsed.cc, + replyTo: parsed.replyTo, + date: parsed.date, + messageId: parsed.messageId, + inReplyTo: parsed.inReplyTo, + references: parsed.references, + text: parsed.text, + html: parsed.html, + size: parsed.size, + seen: raw.flags.some((f) => /\\Seen/i.test(f)), + flagged: raw.flags.some((f) => /\\Flagged/i.test(f)), + flags: raw.flags, + attachments: parsed.attachments.map((a, i) => ({ + index: i, + filename: a.filename, + contentType: a.contentType, + size: a.size, + inline: a.inline, + contentId: a.contentId, + })), + _attachments: parsed.attachments, // 仅服务端内部使用,不外发 + }; + }); + } + + async attachment(uid, index, folder) { + const msg = await this.message(uid, folder); + const att = msg._attachments[index]; + if (!att) throw new Error('附件不存在'); + return att; + } + + async setFlags(uid, folder, { seen, flagged, answered }) { + return this.withImap(async (client) => { + await client.select(folder, { readonly: false }); + const ops = []; + if (typeof seen === 'boolean') ops.push([seen ? '+FLAGS' : '-FLAGS', ['\\Seen']]); + if (typeof flagged === 'boolean') ops.push([flagged ? '+FLAGS' : '-FLAGS', ['\\Flagged']]); + if (typeof answered === 'boolean') ops.push([answered ? '+FLAGS' : '-FLAGS', ['\\Answered']]); + let last = null; + for (const [mode, flags] of ops) last = await client.storeFlags(uid, mode, flags); + this.cache.folders = null; + return last; + }); + } + + async deleteMessage(uid, folder, { permanent = false } = {}) { + const trash = this.account.folders.trash || 'Trash'; + return this.withImap(async (client) => { + await client.select(folder, { readonly: false }); + const isTrash = folder.toUpperCase() === String(trash).toUpperCase(); + if (!permanent && !isTrash) { + try { + await client.copy(uid, trash); + } catch (err) { + // 垃圾箱不存在就直接原地删除 + if (!/不存在|NONEXISTENT|NO/i.test(err.message)) throw err; + } + } + await client.deleteUid(uid, { expunge: true }); + this.cache.folders = null; + return { moved: !permanent && !isTrash, folder: isTrash || permanent ? null : trash }; + }); + } + + async moveMessage(uid, from, to) { + return this.withImap(async (client) => { + await client.select(from, { readonly: false }); + await client.copy(uid, to); + await client.deleteUid(uid, { expunge: true }); + this.cache.folders = null; + return true; + }); + } + + async saveDraft({ to, subject, text, html }) { + const drafts = this.account.folders.drafts || 'Drafts'; + const raw = mime.buildMessage({ + from: { name: this.account.displayName || '', address: this.account.user }, + to: to ? [{ name: '', address: to }] : [], + subject, text, html, domain: this.account.domain, + }); + return this.withImap(async (client) => { + await client.append(drafts, raw, { flags: ['\\Draft'] }); + this.cache.folders = null; + return true; + }); + } + + async send({ to, cc, bcc, subject, text, html, inReplyTo, references, attachments }) { + const list = (v) => String(v || '').split(/[,;]/).map((s) => s.trim()).filter(Boolean); + const toList = list(to); + if (!toList.length) throw new Error('至少需要一个收件人'); + const ccList = list(cc); + const bccList = list(bcc); + + const raw = mime.buildMessage({ + from: { name: this.account.displayName || '', address: this.account.user }, + to: toList.map((a) => ({ name: '', address: a })), + cc: ccList.map((a) => ({ name: '', address: a })), + subject, + text, + html, + inReplyTo: inReplyTo || undefined, + references: references || undefined, + domain: this.account.domain, + attachments: (attachments || []).map((a) => ({ + filename: a.filename, + contentType: a.contentType || 'application/octet-stream', + content: Buffer.from(a.base64 || '', 'base64'), + })), + }); + + const smtp = new SmtpClient(this.options.smtp); + // 家用/运营商 DNS 偶尔会瞬时解析失败(EAI_AGAIN);发信是用户主动动作, + // 这种瞬时错误重试一次比直接报错体验好得多。 + const transient = (err) => /EAI_AGAIN|EAI_NODATA|EAI_FAIL|ETIMEDOUT|ECONNRESET|socket hang up/i.test(err && err.message || ''); + const attempt = async () => { + await smtp.connect(); + await smtp.hello(); + await smtp.auth(); + await smtp.sendMail(this.account.user, [...toList, ...ccList, ...bccList], raw); + }; + try { + try { + await attempt(); + } catch (err) { + if (!transient(err)) throw err; + await new Promise((r) => setTimeout(r, 1200)); + await attempt(); + } + } finally { + try { await smtp.quit(); } catch { /* 忽略 */ } + } + + // 自发自收时服务器会投递到收件箱;同时把副本放到「已发送」 + const sent = this.account.folders.sent || 'Sent'; + try { + await this.withImap(async (client) => { + await client.append(sent, raw); + }); + } catch { /* 存副本失败不影响发送结果 */ } + this.cache.folders = null; + return { ok: true, bytes: raw.length, recipients: [...toList, ...ccList, ...bccList] }; + } + + async close() { + if (this.imap) { + const c = this.imap; + this.imap = null; + try { await c.logout(); } catch { /* 忽略 */ } + } + } +} + +function quoteStr(s) { + return '"' + String(s).replace(/[\\"]/g, (m) => '\\' + m) + '"'; +} + +/** IMAP 摘要 → 列表项(统一字段名,界面直接用) */ +function mapSummary(s) { + return { + uid: s.uid, + subject: s.envelope ? s.envelope.subject : '', + from: s.envelope ? s.envelope.from : [], + to: s.envelope ? s.envelope.to : [], + date: s.envelope ? s.envelope.date : null, + internalDate: s.internalDate, + size: s.size, + seen: s.seen, + flagged: s.flagged, + answered: s.answered, + draft: s.draft, + flags: s.flags, + }; +} + +// ─────────────────────────────────────────────────────────── HTTP + +let session = null; + +function json(res, code, body) { + const data = Buffer.from(JSON.stringify(body), 'utf8'); + res.writeHead(code, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': data.length, + 'Cache-Control': 'no-store', + }); + res.end(data); +} + +function readBody(req, limit = 60 * 1024 * 1024) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on('data', (c) => { + size += c.length; + if (size > limit) { reject(new Error('请求体过大')); req.destroy(); return; } + chunks.push(c); + }); + req.on('end', () => { + const buf = Buffer.concat(chunks); + if (!buf.length) return resolve({}); + try { resolve(JSON.parse(buf.toString('utf8'))); } catch (e) { reject(new Error('请求体不是合法 JSON')); } + }); + req.on('error', reject); + }); +} + +const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.png': 'image/png', + '.ico': 'image/x-icon', +}; + +function serveStatic(req, res, urlPath) { + // 浏览器会主动请求 /favicon.ico:直接回 204,避免日志和验收里出现无意义的 404 + if (urlPath === '/favicon.ico') { res.writeHead(204); res.end(); return; } + const rel = urlPath === '/' ? 'index.html' : decodeURIComponent(urlPath).replace(/^\/+/, ''); + const target = path.join(SITE_DIR, rel); + // 目录穿越保护 + if (!target.startsWith(SITE_DIR)) { res.writeHead(403); res.end('forbidden'); return; } + fs.stat(target, (err, st) => { + if (err || !st.isFile()) { + // SPA 回退:没有扩展名的路径一律回 index.html(前端路由) + if (!path.extname(rel)) { + const indexFile = path.join(SITE_DIR, 'index.html'); + fs.stat(indexFile, (e2, st2) => { + if (e2 || !st2.isFile()) { res.writeHead(404); res.end('404'); return; } + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Length': st2.size, + 'Cache-Control': 'no-store', + }); + fs.createReadStream(indexFile).pipe(res); + }); + return; + } + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('404'); + return; + } + const type = MIME_TYPES[path.extname(target).toLowerCase()] || 'application/octet-stream'; + const isHtml = path.extname(target).toLowerCase() === '.html'; + res.writeHead(200, { + 'Content-Type': type, + 'Content-Length': st.size, + 'Cache-Control': isHtml ? 'no-store' : 'public, max-age=3600', + }); + fs.createReadStream(target).pipe(res); + }); +} + +async function handleApi(req, res, url) { + const p = url.pathname; + const q = url.searchParams; + + // ── 状态 + if (p === '/api/state' && req.method === 'GET') { + const acc = session ? session.account : loadAccount(); + let folders = null; + let error = null; + let connected = false; + if (session) { + try { + folders = await session.folders(); + connected = true; + } catch (err) { + error = err.message; + connected = false; + } + } + return json(res, 200, { + account: { + host: acc.host, imapPort: acc.imapPort, smtpPort: acc.smtpPort, + user: acc.user, displayName: acc.displayName, domain: acc.domain, + hasPassword: !!acc.password, accountFile: acc._file || null, + }, + connected, folders, error, + capabilities: session && session.imap ? session.imap.capabilities : [], + serverTime: new Date().toISOString(), + }); + } + + // ── 登录 / 登出(客户端本地会话) + if (p === '/api/login' && req.method === 'POST') { + const body = await readBody(req); + const acc = loadAccount(); + if (body.host) acc.host = body.host; + if (body.imapPort) acc.imapPort = Number(body.imapPort); + if (body.smtpPort) acc.smtpPort = Number(body.smtpPort); + if (body.user) acc.user = body.user; + if (body.password) acc.password = body.password; + if (body.displayName != null) acc.displayName = body.displayName; + if (body.save !== false) saveAccount(acc); + if (session) await session.close(); + session = new Session(acc); + await session.connect(); + const folders = await session.folders({ refresh: true }); + return json(res, 200, { ok: true, folders }); + } + + if (p === '/api/logout' && req.method === 'POST') { + if (session) await session.close(); + session = null; + return json(res, 200, { ok: true }); + } + + // ── 账号体系(无需登录:注册 / 邮箱验证码 / 找回密码) + // 走服务器的公网账号入口,和 IMAP/SMTP 是两条独立通道。 + const accApi = (acc) => createAccountApi((acc && acc.apiBase) || DEFAULT_API_BASE); + const accountRoute = async (res, fn) => { + try { + return json(res, 200, await fn()); + } catch (err) { + const status = err && err.status ? err.status : 502; + return json(res, status, { + error: (err && err.message) || String(err), + pendingVerification: !!(err && err.data && err.data.pendingVerification), + retryAfterSeconds: (err && err.data && err.data.retryAfterSeconds) || null, + }); + } + }; + const currentAccount = () => (session ? session.account : loadAccount()); + + if (p === '/api/account/policy' && req.method === 'GET') { + return accountRoute(res, async () => { + const api = accApi(currentAccount()); + const policy = await api.policy(); + return { policy, apiBase: api.apiBase }; + }); + } + + if (p === '/api/account/register' && req.method === 'POST') { + const body = await readBody(req); + return accountRoute(res, async () => accApi(currentAccount()).register({ + email: body.email, + password: body.password, + displayName: body.displayName || '', + inviteCode: body.inviteCode || '', + })); + } + + if (p === '/api/account/verify' && req.method === 'POST') { + const body = await readBody(req); + return accountRoute(res, async () => accApi(currentAccount()).verifyRegistration({ email: body.email, code: body.code })); + } + + if (p === '/api/account/resend' && req.method === 'POST') { + const body = await readBody(req); + return accountRoute(res, async () => accApi(currentAccount()).resendCode({ email: body.email, purpose: body.purpose || 'register' })); + } + + if (p === '/api/account/forgot' && req.method === 'POST') { + const body = await readBody(req); + return accountRoute(res, async () => accApi(currentAccount()).forgot(body.email)); + } + + if (p === '/api/account/reset' && req.method === 'POST') { + const body = await readBody(req); + return accountRoute(res, async () => accApi(currentAccount()).reset({ email: body.email, code: body.code, password: body.password })); + } + + if (!session) return json(res, 409, { error: '尚未登录' }); + + // ── 账号体系(需登录:资料 / 会话 / 改密码 / 审计) + // 客户端自己只持 IMAP 凭据,这里按需换一个账号接口 token(换不到就只报错,不影响收发信)。 + async function accountToken() { + if (session.apiToken) return session.apiToken; + const acc = session.account; + if (!acc || !acc.user || !acc.password) throw new Error('本机没有保存密码,无法管理账号设置'); + const r = await accApi(acc).login(acc.user, acc.password); + session.apiToken = (r && r.token) || null; + if (!session.apiToken) throw new Error('账号接口没有返回登录令牌'); + return session.apiToken; + } + + if (p === '/api/account/overview' && req.method === 'GET') { + return accountRoute(res, async () => { + const api = accApi(session.account); + const token = await accountToken(); + const [me, sessions, audit] = await Promise.all([api.profile(token), api.sessions(token), api.audit(token, 20)]); + return { profile: me && me.user ? me.user : null, sessions: (sessions && sessions.sessions) || [], audit: (audit && audit.events) || [] }; + }); + } + + if (p === '/api/account/profile' && req.method === 'PATCH') { + const body = await readBody(req); + return accountRoute(res, async () => { + const api = accApi(session.account); + const r = await api.updateProfile(await accountToken(), body.displayName || ''); + // 本地也更新一份,界面/发件显示名立刻跟上 + session.account.displayName = r && r.user ? r.user.displayName : session.account.displayName; + saveAccount(session.account); + return r; + }); + } + + if (p === '/api/account/password' && req.method === 'POST') { + const body = await readBody(req); + return accountRoute(res, async () => { + const api = accApi(session.account); + const r = await api.changePassword(await accountToken(), { currentPassword: body.currentPassword, password: body.password }); + // 改完密码要把本机保存的密码一起换掉,否则下次登录还用旧密码 + if (body.password) { + session.account.password = body.password; + saveAccount(session.account); + } + session.apiToken = null; // 旧 token 已被服务器保留(当前会话),但重新换一个更保险 + return r; + }); + } + + if (p === '/api/account/sessions/revoke' && req.method === 'POST') { + const body = await readBody(req); + return accountRoute(res, async () => accApi(session.account).revokeSessions(await accountToken(), body || {})); + } + + // ── 文件夹 + if (p === '/api/folders' && req.method === 'GET') { + const folders = await session.folders({ refresh: q.get('refresh') === '1' }); + return json(res, 200, { folders }); + } + + // ── 邮件列表 + if (p === '/api/messages' && req.method === 'GET') { + const folder = q.get('folder') || session.account.folders.inbox; + const limit = Math.min(Number(q.get('limit') || session.account.pageSize || 50), 300); + const offset = Math.max(Number(q.get('offset') || 0), 0); + const query = q.get('q') || ''; + const out = await session.messageList({ folder, limit, offset, query }); + return json(res, 200, out); + } + + // ── 单封详情 / 附件 + let m; + if ((m = /^\/api\/messages\/(\d+)\/attachments\/(\d+)$/.exec(p)) && req.method === 'GET') { + const att = await session.attachment(Number(m[1]), Number(m[2]), q.get('folder') || session.account.folders.inbox); + res.writeHead(200, { + 'Content-Type': att.contentType || 'application/octet-stream', + 'Content-Length': att.content.length, + 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(att.filename)}`, + 'Cache-Control': 'no-store', + }); + return res.end(att.content); + } + + if ((m = /^\/api\/messages\/(\d+)$/.exec(p)) && req.method === 'GET') { + const msg = await session.message(Number(m[1]), q.get('folder') || session.account.folders.inbox); + delete msg._attachments; + // HTML 正文是**别人写的代码**:这里做第一层净化(界面还会把它放进不带 allow-scripts 的沙箱 iframe) + if (msg.html) { + const clean = sanitizeHtml(msg.html, { allowRemoteImages: q.get('images') === '1' }); + msg.html = clean.html; + msg.htmlDocument = buildDocument(clean.html, { theme: q.get('theme') === 'dark' ? 'dark' : 'light' }); + msg.blockedImages = clean.blockedImages; + msg.sanitized = clean.removed; + } + return json(res, 200, msg); + } + + // ── 旗标 + if (p === '/api/flags' && req.method === 'POST') { + const body = await readBody(req); + await session.setFlags(Number(body.uid), body.folder || session.account.folders.inbox, body); + return json(res, 200, { ok: true }); + } + + // ── 删除 / 移动 + if (p === '/api/delete' && req.method === 'POST') { + const body = await readBody(req); + const out = await session.deleteMessage(Number(body.uid), body.folder || session.account.folders.inbox, + { permanent: !!body.permanent }); + return json(res, 200, Object.assign({ ok: true }, out)); + } + + if (p === '/api/move' && req.method === 'POST') { + const body = await readBody(req); + await session.moveMessage(Number(body.uid), body.folder || session.account.folders.inbox, body.target); + return json(res, 200, { ok: true }); + } + + // ── 发送 / 存草稿 + if (p === '/api/send' && req.method === 'POST') { + const body = await readBody(req); + const out = await session.send(body); + return json(res, 200, out); + } + + if (p === '/api/drafts' && req.method === 'POST') { + const body = await readBody(req); + await session.saveDraft(body); + return json(res, 200, { ok: true }); + } + + return json(res, 404, { error: '未知的 API 路径:' + p }); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host || HOST}`); + if (!url.pathname.startsWith('/api/')) return serveStatic(req, res, url.pathname); + handleApi(req, res, url).catch((err) => { + const code = err && /超时/.test(err.message) ? 504 : 500; + json(res, code, { error: err.message, name: err.name || 'Error' }); + }); +}); + +async function main() { + // 启动即有账户配置就自动连一次(失败不阻止启动,界面会显示离线态与错误) + try { + const acc = loadAccount(); + if (acc.user && acc.password) { + session = new Session(acc); + await session.connect(); + const folders = await session.folders({ refresh: true }); + process.stdout.write(`[ok] 已连接 ${acc.host} 账号 ${acc.user},文件夹 ${folders.length} 个\n`); + } else { + process.stdout.write('[warn] 账户未配置(缺账号或密码),界面会要求登录\n'); + } + } catch (err) { + session = session || new Session(loadAccount()); + process.stdout.write(`[warn] 启动自动连接失败:${err.message}\n`); + } + + server.listen(PORT, HOST, () => { + process.stdout.write(`WpywMail 客户端已启动:http://${HOST}:${PORT}/\n`); + process.stdout.write('(关闭此窗口即退出客户端)\n'); + }); +} + +if (require.main === module) { + main().catch((err) => { + process.stderr.write('启动失败:' + (err.stack || err) + '\n'); + process.exit(1); + }); +} + +module.exports = { server, Session }; diff --git a/server/mime.js b/server/mime.js new file mode 100644 index 0000000..394bdda --- /dev/null +++ b/server/mime.js @@ -0,0 +1,614 @@ +'use strict'; + +/** + * MIME 解析与组装(RFC 5322 / 2047 / 2231 / 2045-2049)。 + * + * 这一层决定了「中文对不对」。生产环境的教训: + * - 头部必须能解 RFC 2047 编码字(否则主题显示成 =?utf-8?B?...?=) + * - 头部若被写成裸 UTF-8,必须按 UTF-8 优先解(按 Latin-1 解会得到 [æµè¯]) + * - 正文要按声明的 charset 解,且要能回退到 GBK/Big5(老邮件) + * - 组装时要主动用 RFC 2047 编码非 ASCII 头部,不能把裸中文写进 Subject + */ + +const { TextDecoder } = require('node:util'); +const crypto = require('node:crypto'); + +const CRLF = '\r\n'; + +// ─────────────────────────────────────────────────────────── 字符集 + +const DECODERS = new Map(); +function decoderFor(charset) { + const key = String(charset || 'utf-8').trim().toLowerCase().replace(/^["']|["']$/g, ''); + if (DECODERS.has(key)) return DECODERS.get(key); + const aliases = { + 'utf8': 'utf-8', + 'utf-8': 'utf-8', + 'gb2312': 'gbk', + 'gb18030': 'gbk', + 'gbk': 'gbk', + 'big5': 'big5', + 'big5-hkscs': 'big5', + 'shift_jis': 'shift_jis', + 'shift-jis': 'shift_jis', + 'sjis': 'shift_jis', + 'euc-kr': 'euc-kr', + 'ks_c_5601-1987': 'euc-kr', + 'latin1': 'windows-1252', + 'iso-8859-1': 'windows-1252', + 'us-ascii': 'utf-8', + 'ascii': 'utf-8', + 'windows-1252': 'windows-1252', + }; + const label = aliases[key] || key; + let dec = null; + try { + dec = new TextDecoder(label, { fatal: false }); + } catch { + dec = null; + } + DECODERS.set(key, dec); + return dec; +} + +/** 按 charset 解码字节;失败则依次尝试 utf-8 / gbk */ +function decodeCharset(buffer, charset) { + if (!buffer || !buffer.length) return ''; + const dec = decoderFor(charset); + if (dec) { + try { + return dec.decode(buffer); + } catch { /* 落到下面的回退 */ } + } + try { + return new TextDecoder('utf-8', { fatal: false }).decode(buffer); + } catch { + return buffer.toString('latin1'); + } +} + +// ─────────────────────────────────────────────────────────── 传输编码 + +/** quoted-printable → 原始字节(含软换行合并) */ +function decodeQuotedPrintable(buffer) { + const text = buffer.toString('latin1'); + const out = []; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '=') { + const n1 = text[i + 1]; + const n2 = text[i + 2]; + if (n1 === '\r' && n2 === '\n') { i += 2; continue; } + if (n1 === '\n') { i += 1; continue; } + if (/[0-9A-Fa-f]{2}/.test((n1 || '') + (n2 || ''))) { + out.push(parseInt(n1 + n2, 16)); + i += 2; + continue; + } + } + out.push(c.charCodeAt(0) & 0xff); + } + return Buffer.from(out); +} + +function decodeTransfer(buffer, encoding) { + const enc = String(encoding || '').trim().toLowerCase(); + if (enc === 'base64') { + const clean = buffer.toString('latin1').replace(/[^A-Za-z0-9+/=]/g, ''); + try { return Buffer.from(clean, 'base64'); } catch { return buffer; } + } + if (enc === 'quoted-printable') return decodeQuotedPrintable(buffer); + return buffer; +} + +// ─────────────────────────────────────────────────────────── RFC 2047 + +function decodeOneWord(charset, encoding, text) { + let bytes; + if (encoding.toUpperCase() === 'B') { + try { bytes = Buffer.from(text.replace(/\s+/g, ''), 'base64'); } catch { return text; } + } else { + // Q:下划线代表空格,=XX 代表字节 + const bytesArr = []; + for (let i = 0; i < text.length; i++) { + const c = text[i]; + if (c === '_') { bytesArr.push(0x20); continue; } + if (c === '=' && /[0-9A-Fa-f]{2}/.test(text.substr(i + 1, 2))) { + bytesArr.push(parseInt(text.substr(i + 1, 2), 16)); + i += 2; + continue; + } + bytesArr.push(c.charCodeAt(0) & 0xff); + } + bytes = Buffer.from(bytesArr); + } + return decodeCharset(bytes, charset); +} + +/** 解码头部里的 RFC 2047 编码字;相邻编码字之间的空白会被丢弃(RFC 2047 规定) */ +function decodeWords(input) { + const s = String(input == null ? '' : input); + if (!s.includes('=?')) return s; + const re = /=\?([^?\s]+)\?([BbQq])\?([^?]*)\?=/g; + let out = ''; + let last = 0; + let m; + let prevWasWord = false; + while ((m = re.exec(s)) !== null) { + let between = s.slice(last, m.index); + if (prevWasWord && /^\s*$/.test(between)) between = ''; + out += between + decodeOneWord(m[1], m[2], m[3]); + prevWasWord = true; + last = m.index + m[0].length; + } + out += s.slice(last); + return out.replace(/[\r\n]+/g, ' ').trim(); +} + +/** 把可能是裸 UTF-8 的头部值还原成可读文本 */ +function smartDecodeHeader(rawValue) { + const s = String(rawValue == null ? '' : rawValue); + if (s.includes('=?')) return decodeWords(s); + // 全是 ASCII 直接返回 + if (!/[\u0080-\u00ff]/.test(s)) return s.trim(); + // 含高位字节:优先按 UTF-8 解,失败再按 Latin-1 + const bytes = Buffer.from(s, 'latin1'); + const utf8 = decodeCharset(bytes, 'utf-8'); + if (!utf8.includes('\ufffd')) return utf8.trim(); + return s.trim(); +} + +/** RFC 2231(filename*=UTF-8''...)与普通 filename 参数 */ +function decodeParameter(value) { + if (value == null) return ''; + const v = String(value).trim(); + const m = /^([^']*)'([^']*)'(.*)$/s.exec(v); + if (m) { + const charset = m[1] || 'utf-8'; + const encoded = m[3]; + // 关键:直接把 %XX 还原成**字节**,不要先 decodeURIComponent 成字符串。 + // 先转字符串再用 Buffer.from(str,'binary'/'latin1') 会把多字节字符按单字节截断, + // 中文文件名会变成一串 U+FFFD(这是在真机上踩到的坑)。 + const bytes = []; + for (let i = 0; i < encoded.length; i++) { + const ch = encoded[i]; + if (ch === '%' && /^[0-9A-Fa-f]{2}$/.test(encoded.substr(i + 1, 2))) { + bytes.push(parseInt(encoded.substr(i + 1, 2), 16)); + i += 2; + } else { + bytes.push(ch.charCodeAt(0) & 0xff); + } + } + return decodeCharset(Buffer.from(bytes), charset); + } + return smartDecodeHeader(v.replace(/^"|"$/g, '')); +} + +// ─────────────────────────────────────────────────────────── 头部 + +/** 拆出头部与正文(按字节,保持忠实) */ +function splitHeadBody(buffer) { + const idx = buffer.indexOf('\r\n\r\n'); + if (idx >= 0) return { head: buffer.subarray(0, idx), body: buffer.subarray(idx + 4) }; + const idx2 = buffer.indexOf('\n\n'); + if (idx2 >= 0) return { head: buffer.subarray(0, idx2), body: buffer.subarray(idx2 + 2) }; + return { head: buffer, body: Buffer.alloc(0) }; +} + +/** 解析头部为 [{name, value}](折行拼接) */ +function parseHeaders(headBuffer) { + const text = headBuffer.toString('latin1'); + const lines = text.split(/\r?\n/); + const headers = []; + let cur = null; + for (const line of lines) { + if (/^[ \t]/.test(line) && cur) { + cur.value += CRLF + line; + continue; + } + const i = line.indexOf(':'); + if (i <= 0) { cur = null; continue; } + cur = { name: line.slice(0, i).trim(), value: line.slice(i + 1) }; + headers.push(cur); + } + return headers; +} + +/** Content-Type 解析:mime 类型 + 参数表 */ +function parseContentType(value) { + const s = String(value || 'text/plain'); + const semi = s.indexOf(';'); + const mime = (semi < 0 ? s : s.slice(0, semi)).trim().toLowerCase() || 'text/plain'; + const params = {}; + if (semi >= 0) { + const rest = s.slice(semi + 1); + // 参数可能带引号并含分号,用状态机切分 + const chunks = []; + let buf = ''; + let inQuote = false; + for (const ch of rest) { + if (ch === '"') { inQuote = !inQuote; buf += ch; continue; } + if (ch === ';' && !inQuote) { chunks.push(buf); buf = ''; continue; } + buf += ch; + } + if (buf) chunks.push(buf); + for (const chunk of chunks) { + const eq = chunk.indexOf('='); + if (eq < 0) continue; + const k = chunk.slice(0, eq).trim().toLowerCase(); + const v = chunk.slice(eq + 1).trim(); + params[k] = v; + } + } + // RFC 2231 的分段参数:filename*0*=utf-8''%E9%AA%8C; filename*1*=... → 合并成 filename* + const keys = Object.keys(params); + const groups = new Map(); + for (const k of keys) { + const m = /^([a-z0-9_-]+)\*(\d+)(\*?)$/.exec(k); + if (!m) continue; + if (!groups.has(m[1])) groups.set(m[1], []); + groups.get(m[1]).push({ idx: Number(m[2]), encoded: m[3] === '*', key: k }); + } + for (const [base, parts] of groups) { + if (parts.length < 2 && !String(parts[0].key).endsWith('*')) continue; + parts.sort((a, b) => a.idx - b.idx); + let joined = ''; + let allEncoded = true; + for (const p of parts) { + const raw = params[p.key].replace(/^"|"$/g, ''); + joined += raw; + if (!p.encoded) allEncoded = false; + } + params[base + (allEncoded ? '*' : '')] = joined; + for (const p of parts) delete params[p.key]; + } + return { mime, params }; +} + +/** 从 Content-Disposition 里取文件名 */ +function filenameFrom(headers) { + const disp = getHeader(headers, 'content-disposition'); + if (disp) { + const { params } = parseContentType(disp); + if (params['filename*']) return decodeParameter(params['filename*']); + if (params.filename) return decodeParameter(params.filename); + } + const ct = parseContentType(getHeader(headers, 'content-type')); + if (ct.params['name*']) return decodeParameter(ct.params['name*']); + if (ct.params.name) return decodeParameter(ct.params.name); + return ''; +} + +function getHeader(headers, name) { + const lower = name.toLowerCase(); + const hit = headers.find((h) => h.name.toLowerCase() === lower); + return hit ? hit.value : null; +} + +/** 地址列表解析:`"张三" , c@d.com` */ +function parseAddressList(rawValue) { + const s = smartDecodeHeader(rawValue || ''); + if (!s.trim()) return []; + const parts = []; + let buf = ''; + let inQuote = false; + let inAngle = false; + for (const ch of s) { + if (ch === '"') inQuote = !inQuote; + if (ch === '<' && !inQuote) inAngle = true; + if (ch === '>' && !inQuote) inAngle = false; + if (ch === ',' && !inQuote && !inAngle) { parts.push(buf); buf = ''; continue; } + buf += ch; + } + if (buf.trim()) parts.push(buf); + return parts.map((p) => { + const t = p.trim(); + const m = /^(.*?)<([^>]+)>\s*$/.exec(t); + if (m) { + const name = m[1].trim().replace(/^"|"$/g, '').trim(); + return { name, address: m[2].trim() }; + } + return { name: '', address: t.replace(/^"|"$/g, '') }; + }).filter((a) => a.address); +} + +// ─────────────────────────────────────────────────────────── 正文解析 + +/** 粗略地从 HTML 提取纯文本(没有 text/plain 部分时的回退) */ +function htmlToText(html) { + return String(html) + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '\n') + .replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /gi, ' ') + .replace(/</gi, '<').replace(/>/gi, '>') + .replace(/"/gi, '"').replace(/'/gi, "'") + .replace(/&/gi, '&') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** + * 递归解析一个 MIME 实体。 + * @returns {{headers, mime, params, text, html, attachments, children}} + */ +function parseEntity(headBuffer, bodyBuffer, depth = 0) { + const headers = parseHeaders(headBuffer); + const ct = parseContentType(getHeader(headers, 'content-type')); + const transfer = (getHeader(headers, 'content-transfer-encoding') || '').trim(); + const node = { + headers, mime: ct.mime, params: ct.params, + text: null, html: null, attachments: [], children: [], + }; + + if (ct.mime.startsWith('multipart/') && (ct.params.boundary || ct.params.boundary === '')) { + const boundary = String(ct.params.boundary || '').replace(/^"|"$/g, ''); + const parts = splitMultipart(bodyBuffer, boundary); + for (const p of parts) { + if (depth > 8) break; + const child = parseEntity(p.head, p.body, depth + 1); + node.children.push(child); + if (child.text != null && node.text == null) node.text = child.text; + if (child.html != null && node.html == null) node.html = child.html; + node.attachments.push(...child.attachments); + } + // 有些坑爹邮件把文本塞在 multipart 的 preamble 里 + return node; + } + + const decoded = decodeTransfer(bodyBuffer, transfer); + const charset = ct.params.charset; + const disposition = (parseContentType(getHeader(headers, 'content-disposition')).mime || ''); + const filename = filenameFrom(headers); + const isAttachment = + disposition === 'attachment' || + (!!filename && !/^text\/(plain|html)$/i.test(ct.mime)) || + /^(image|application|audio|video)\//i.test(ct.mime) && !!filename; + + if (!isAttachment && ct.mime === 'text/plain') { + node.text = decodeCharset(decoded, charset).replace(/\r\n/g, '\n'); + return node; + } + if (!isAttachment && ct.mime === 'text/html') { + node.html = decodeCharset(decoded, charset).replace(/\r\n/g, '\n'); + return node; + } + if (ct.mime === 'message/rfc822') { + const inner = parseEntityFromBuffer(decoded, depth + 1); + node.children.push(inner); + if (inner.text != null && node.text == null) node.text = inner.text; + if (inner.html != null && node.html == null) node.html = inner.html; + node.attachments.push(...inner.attachments); + return node; + } + + // 其余一律当附件(含无文件名的内联图) + node.attachments.push({ + filename: filename || '(未命名)', + contentType: ct.mime, + contentId: (getHeader(headers, 'content-id') || '').replace(/^<|>$/g, '') || null, + inline: disposition === 'inline', + size: decoded.length, + content: decoded, + }); + return node; +} + +function parseEntityFromBuffer(buffer, depth = 0) { + const { head, body } = splitHeadBody(buffer); + return parseEntity(head, body, depth); +} + +/** 按 boundary 切分 multipart 正文 */ +function splitMultipart(bodyBuffer, boundary) { + const delim = Buffer.from('--' + boundary); + const parts = []; + let start = bodyBuffer.indexOf(delim); + if (start < 0) return parts; + start += delim.length; + // 跳过紧随其后的 CRLF + if (bodyBuffer[start] === 0x0d && bodyBuffer[start + 1] === 0x0a) start += 2; + else if (bodyBuffer[start] === 0x0a) start += 1; + + for (;;) { + const next = bodyBuffer.indexOf(delim, start); + if (next < 0) break; + let end = next; + // 去掉分隔符前的 CRLF + if (end >= 2 && bodyBuffer[end - 2] === 0x0d && bodyBuffer[end - 1] === 0x0a) end -= 2; + else if (end >= 1 && bodyBuffer[end - 1] === 0x0a) end -= 1; + const chunk = bodyBuffer.subarray(start, end); + const { head, body } = splitHeadBody(chunk); + parts.push({ head, body }); + // 判断是否为结束分隔符 "--boundary--" + const afterDelim = next + delim.length; + if (bodyBuffer[afterDelim] === 0x2d && bodyBuffer[afterDelim + 1] === 0x2d) break; + start = afterDelim; + if (bodyBuffer[start] === 0x0d && bodyBuffer[start + 1] === 0x0a) start += 2; + else if (bodyBuffer[start] === 0x0a) start += 1; + } + return parts; +} + +/** 顶级解析:给出客户端 UI 需要的全部字段 */ +function parseMessage(rawBuffer) { + const buffer = Buffer.isBuffer(rawBuffer) ? rawBuffer : Buffer.from(rawBuffer); + const root = parseEntityFromBuffer(buffer, 0); + const headers = root.headers; + const subject = smartDecodeHeader(getHeader(headers, 'subject') || ''); + const from = parseAddressList(getHeader(headers, 'from')); + const to = parseAddressList(getHeader(headers, 'to')); + const cc = parseAddressList(getHeader(headers, 'cc')); + const replyTo = parseAddressList(getHeader(headers, 'reply-to')); + const text = root.text != null ? root.text : (root.html ? htmlToText(root.html) : ''); + return { + headers, + headerMap: headers.reduce((m, h) => { m[h.name.toLowerCase()] = h.value; return m; }, {}), + subject, + from, to, cc, replyTo, + date: getHeader(headers, 'date') ? smartDecodeHeader(getHeader(headers, 'date')) : null, + messageId: (getHeader(headers, 'message-id') || '').trim() || null, + inReplyTo: (getHeader(headers, 'in-reply-to') || '').trim() || null, + references: (getHeader(headers, 'references') || '').trim() || null, + text, + html: root.html || null, + attachments: root.attachments, + size: buffer.length, + }; +} + +// ─────────────────────────────────────────────────────────── 组装(发信) + +/** 非 ASCII 头部值 → RFC 2047 编码字(按 45 字节分块,保证整词 ≤75 字符) */ +function encodeHeaderValue(value) { + const s = String(value == null ? '' : value).replace(/[\r\n]+/g, ' '); + // eslint-disable-next-line no-control-regex + if (/^[\x20-\x7e]*$/.test(s)) return s; + const bytes = Buffer.from(s, 'utf8'); + const chunks = []; + let cur = []; + let curLen = 0; + for (const ch of s) { + const b = Buffer.byteLength(ch, 'utf8'); + if (curLen + b > 45) { chunks.push(Buffer.from(cur.join(''), 'utf8')); cur = []; curLen = 0; } + cur.push(ch); + curLen += b; + } + if (cur.length) chunks.push(Buffer.from(cur.join(''), 'utf8')); + return chunks.map((b) => `=?UTF-8?B?${b.toString('base64')}?=`).join(CRLF + ' '); +} + +/** 组装地址头 */ +function formatAddress(addr) { + const name = addr.name ? encodeHeaderValue(addr.name) : ''; + if (!name) return addr.address; + const needsQuote = !/^[\x20-\x7e]*$/.test(name) || /[",;:<>@\\()\[\]]/.test(name); + return `${needsQuote && !name.startsWith('=?') ? `"${name.replace(/"/g, '\\"')}"` : name} <${addr.address}>`; +} + +function wrap76(base64) { + const lines = []; + for (let i = 0; i < base64.length; i += 76) lines.push(base64.slice(i, i + 76)); + return lines.join(CRLF); +} + +function generateMessageId(domain) { + const rnd = crypto.randomBytes(12).toString('hex'); + return `<${Date.now()}.${rnd}@${domain || 'localhost'}>`; +} + +function rfc2822Date(d = new Date()) { + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const pad = (n) => String(n).padStart(2, '0'); + const off = -d.getTimezoneOffset(); + const sign = off >= 0 ? '+' : '-'; + const abs = Math.abs(off); + return `${days[d.getDay()]}, ${pad(d.getDate())} ${months[d.getMonth()]} ${d.getFullYear()} ` + + `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} ` + + `${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`; +} + +/** + * 组装一封可发送的 MIME 报文(返回 UTF-8 Buffer,CRLF 行尾)。 + * @param {object} msg {from:{name,address}, to:[], cc:[], subject, text, html, + * attachments:[{filename,contentType,content}], inReplyTo, + * references, domain, date} + */ +function buildMessage(msg) { + const domain = msg.domain || (msg.from && msg.from.address ? String(msg.from.address).split('@')[1] : 'localhost'); + const headers = []; + headers.push(['From', formatAddress(msg.from)]); + if (msg.to && msg.to.length) headers.push(['To', msg.to.map(formatAddress).join(', ')]); + if (msg.cc && msg.cc.length) headers.push(['Cc', msg.cc.map(formatAddress).join(', ')]); + headers.push(['Subject', encodeHeaderValue(msg.subject || '')]); + headers.push(['Date', msg.date || rfc2822Date()]); + headers.push(['Message-ID', msg.messageId || generateMessageId(domain)]); + if (msg.inReplyTo) headers.push(['In-Reply-To', msg.inReplyTo]); + if (msg.references) headers.push(['References', msg.references]); + headers.push(['MIME-Version', '1.0']); + + const attachments = msg.attachments || []; + const useHtml = !!msg.html; + const textPart = (body, mime, charset = 'UTF-8') => { + const isAscii = /^[\x09\x0a\x0d\x20-\x7e]*$/.test(body); + const enc = isAscii ? '7bit' : 'base64'; + const payload = isAscii ? body.replace(/\r?\n/g, CRLF) : wrap76(Buffer.from(body, 'utf8').toString('base64')); + return [ + `Content-Type: ${mime}; charset=${charset}`, + `Content-Transfer-Encoding: ${enc}`, + '', + payload, + ].join(CRLF); + }; + + let bodyText; + if (attachments.length === 0 && !useHtml) { + headers.push(['Content-Type', 'text/plain; charset=UTF-8']); + const isAscii = /^[\x09\x0a\x0d\x20-\x7e]*$/.test(msg.text || ''); + headers.push(['Content-Transfer-Encoding', isAscii ? '7bit' : 'base64']); + bodyText = isAscii + ? (msg.text || '').replace(/\r?\n/g, CRLF) + : wrap76(Buffer.from(msg.text || '', 'utf8').toString('base64')); + } else if (attachments.length === 0 && useHtml) { + const alt = 'alt-' + crypto.randomBytes(10).toString('hex'); + headers.push(['Content-Type', `multipart/alternative; boundary="${alt}"`]); + bodyText = [ + `--${alt}`, textPart(msg.text || '', 'text/plain'), '', + `--${alt}`, textPart(msg.html, 'text/html'), '', + `--${alt}--`, '', + ].join(CRLF); + } else { + const mixed = 'mix-' + crypto.randomBytes(10).toString('hex'); + headers.push(['Content-Type', `multipart/mixed; boundary="${mixed}"`]); + const chunks = []; + if (useHtml) { + const alt = 'alt-' + crypto.randomBytes(10).toString('hex'); + chunks.push(`--${mixed}`, `Content-Type: multipart/alternative; boundary="${alt}"`, ''); + chunks.push(`--${alt}`, textPart(msg.text || '', 'text/plain'), ''); + chunks.push(`--${alt}`, textPart(msg.html, 'text/html'), ''); + chunks.push(`--${alt}--`, ''); + } else { + chunks.push(`--${mixed}`, textPart(msg.text || '', 'text/plain'), ''); + } + for (const att of attachments) { + const content = Buffer.isBuffer(att.content) ? att.content : Buffer.from(att.content || '', 'base64'); + const name = encodeHeaderValue(att.filename || 'attachment'); + chunks.push(`--${mixed}`); + chunks.push(`Content-Type: ${att.contentType || 'application/octet-stream'}; name="${name}"`); + chunks.push('Content-Transfer-Encoding: base64'); + chunks.push(`Content-Disposition: attachment; filename="${name}"`); + chunks.push(''); + chunks.push(wrap76(content.toString('base64'))); + chunks.push(''); + } + chunks.push(`--${mixed}--`, ''); + bodyText = chunks.join(CRLF); + } + + const head = headers.map(([k, v]) => `${k}: ${v}`).join(CRLF); + return Buffer.from(head + CRLF + CRLF + bodyText, 'utf8'); +} + +module.exports = { + parseMessage, + buildMessage, + parseHeaders, + parseAddressList, + parseContentType, + decodeWords, + decodeParameter, + smartDecodeHeader, + decodeCharset, + decodeQuotedPrintable, + decodeTransfer, + encodeHeaderValue, + htmlToText, + splitMultipart, + filenameFrom, + getHeader, + rfc2822Date, + generateMessageId, +}; diff --git a/server/sanitize.js b/server/sanitize.js new file mode 100644 index 0000000..7dd2107 --- /dev/null +++ b/server/sanitize.js @@ -0,0 +1,126 @@ +'use strict'; + +/** + * HTML 邮件净化。 + * + * 为什么需要它:邮件正文是**别人**写的代码,直接塞进页面等于把我们的界面交给发件人。 + * 这里做第一层(去脚本/事件/危险协议 + 默认不加载远程图片), + * 第二层在界面上:正文渲染在 `', + '', + ].join(''); + await api(base, 'POST', '/api/send', { + to: creds.user, subject, text: `这是纯文本兜底 ${stamp}`, html: evilHtml, + }); + step('已发出带脚本的 HTML 测试邮件', true, subject); + + // ── 2) 等服务端投递到自己的收件箱,并检查净化结果 + let message = null; + for (let i = 0; i < 30 && !message; i++) { + await sleep(1000); + const list = await api(base, 'GET', `/api/messages?folder=INBOX&limit=20`); + message = (list.messages || []).find((m) => m.subject === subject) || null; + } + step('测试邮件已投递到收件箱', !!message, message ? `uid=${message.uid}` : '(未收到)'); + if (!message) throw new Error('没收到测试邮件,后续无法继续'); + uid = message.uid; + + const detail = await api(base, 'GET', `/api/messages/${uid}?folder=INBOX`); + step('服务端净化: + + +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..7f475d4 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,3481 @@ +{ + "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-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-scroll-area": "^1.2.10", + "@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" + } + }, + "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-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-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-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-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/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..e2aeb4c --- /dev/null +++ b/ui/package.json @@ -0,0 +1,40 @@ +{ + "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-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-scroll-area": "^1.2.10", + "@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" + } +} 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..eaf44d0 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,477 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Menu, Moon, RefreshCw, Search, Sun, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ComposeDialog, type ComposeMode } from '@/components/mail/ComposeDialog'; +import { LoginScreen } from '@/components/mail/LoginScreen'; +import { MailDisplay } from '@/components/mail/MailDisplay'; +import { MailList } from '@/components/mail/MailList'; +import { MailSidebar, folderLabel } from '@/components/mail/MailSidebar'; +import { api, type AccountProfile, type Folder, type MailDetail, type MailSummary, type StateResponse } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +type Compose = { open: boolean; mode: ComposeMode; source: MailDetail | null }; + +export default function App() { + const [booted, setBooted] = useState(false); + const [state, setState] = useState(null); + const [folders, setFolders] = useState([]); + const [folder, setFolder] = useState('INBOX'); + const [messages, setMessages] = useState([]); + const [total, setTotal] = useState(0); + const [listLoading, setListLoading] = useState(true); + const [selected, setSelected] = useState(null); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [query, setQuery] = useState(''); + const [searchInput, setSearchInput] = useState(''); + const [compose, setCompose] = useState({ open: false, mode: 'new', source: null }); + const [toast, setToast] = useState<{ text: string; error?: boolean } | null>(null); + const [dark, setDark] = useState(() => document.documentElement.classList.contains('dark')); + const [sidebarOpen, setSidebarOpen] = useState(false); + const [profile, setProfile] = useState(null); + const searchRef = useRef(null); + const toastTimer = useRef(null); + + const notify = useCallback((text: string, error = false) => { + setToast({ text, error }); + if (toastTimer.current) window.clearTimeout(toastTimer.current); + toastTimer.current = window.setTimeout(() => setToast(null), error ? 6000 : 2600); + }, []); + + /* ── 主题 ─────────────────────────────────────────────── */ + useEffect(() => { + document.documentElement.classList.toggle('dark', dark); + try { + localStorage.setItem('wpywmail-theme', dark ? 'dark' : 'light'); + } catch { + /* 忽略 */ + } + }, [dark]); + + /* ── 启动 ─────────────────────────────────────────────── */ + const loadFolders = useCallback(async (refresh = true) => { + try { + const { folders: next } = await api.folders(refresh); + setFolders(next); + return next; + } catch { + return []; + } + }, []); + + // 文件夹计数刷新走防抖:每刷一次要在同一条 IMAP 连接上串行跑 6 个 STATUS, + // 点一封邮件就立刻刷会打断阅读节奏。合并到 700ms 后一次性刷新。 + const foldersTimer = useRef(null); + const scheduleFoldersRefresh = useCallback(() => { + if (foldersTimer.current) window.clearTimeout(foldersTimer.current); + foldersTimer.current = window.setTimeout(() => { + foldersTimer.current = null; + scheduleFoldersRefresh(); + }, 700); + }, [loadFolders]); + + const loadMessages = useCallback( + async (targetFolder: string, q = query) => { + setListLoading(true); + try { + const data = await api.messages(targetFolder, { limit: 100, q: q || undefined }); + setMessages(data.messages); + setTotal(data.total); + } catch (err) { + setMessages([]); + setTotal(0); + notify(`读取列表失败:${err instanceof Error ? err.message : String(err)}`, true); + } finally { + setListLoading(false); + } + }, + [notify, query], + ); + + useEffect(() => { + void (async () => { + try { + const s = await api.state(); + setState(s); + if (s.connected && s.folders) { + setFolders(s.folders); + const inbox = s.folders.find((f) => f.name.toUpperCase() === 'INBOX')?.name ?? s.folders[0]?.name ?? 'INBOX'; + setFolder(inbox); + await loadMessages(inbox, ''); + // 账号设置(显示名/会话/审计)走服务器的账号接口;拿不到就静默跳过,不影响收发信 + void api.account + .overview() + .then((r) => setProfile(r.profile)) + .catch(() => setProfile(null)); + } + } catch (err) { + notify(`初始化失败:${err instanceof Error ? err.message : String(err)}`, true); + } finally { + setBooted(true); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /* ── 文件夹轮询(只刷计数,不打断阅读) ─────────────────── */ + useEffect(() => { + if (!state?.connected) return; + const id = window.setInterval(() => { + if (!document.hidden) void loadFolders(true); + }, 30000); + return () => window.clearInterval(id); + }, [state?.connected, loadFolders]); + + /* ── 打开邮件 ─────────────────────────────────────────── */ + // 正文缓存 + 相邻预取:J/K 逐封翻的时候不该每次都等一个网络往返。 + const cacheRef = useRef(new Map()); + const prefetchingRef = useRef(new Set()); + + const prefetch = useCallback( + (uid: number, targetFolder: string) => { + if (cacheRef.current.has(uid) || prefetchingRef.current.has(uid)) return; + prefetchingRef.current.add(uid); + api + .message(uid, targetFolder) + .then((mail) => cacheRef.current.set(uid, mail)) + .catch(() => {}) + .finally(() => prefetchingRef.current.delete(uid)); + }, + [], + ); + + const openMail = useCallback( + async (uid: number) => { + setSelected(uid); + const cached = cacheRef.current.get(uid); + if (cached) { + // 命中缓存:立刻显示,后台再刷新一次(已读状态等可能变了) + setDetail(cached); + setDetailLoading(false); + } else { + setDetailLoading(true); + } + try { + const mail = await api.message(uid, folder); + cacheRef.current.set(uid, mail); + setDetail(mail); + if (!mail.seen) { + setMessages((prev) => prev.map((m) => (m.uid === uid ? { ...m, seen: true } : m))); + void api.setFlags(uid, folder, { seen: true }).then(() => scheduleFoldersRefresh()).catch(() => {}); + } + // 预取上下相邻两封,翻页时直接命中缓存 + const idx = messages.findIndex((m) => m.uid === uid); + if (idx >= 0) { + if (messages[idx + 1]) prefetch(messages[idx + 1].uid, folder); + if (messages[idx - 1]) prefetch(messages[idx - 1].uid, folder); + } + } catch (err) { + if (!cached) { + setDetail(null); + notify(`打开失败:${err instanceof Error ? err.message : String(err)}`, true); + } + } finally { + setDetailLoading(false); + } + }, + [folder, loadFolders, messages, notify, prefetch], + ); + + const selectFolder = useCallback( + async (name: string) => { + setFolder(name); + setQuery(''); + setSearchInput(''); + setSelected(null); + setDetail(null); + setSidebarOpen(false); + await loadMessages(name, ''); + }, + [loadMessages], + ); + + /* ── 动作 ─────────────────────────────────────────────── */ + const afterMutate = useCallback( + async (uid: number, message: string) => { + setMessages((prev) => prev.filter((m) => m.uid !== uid)); + if (selected === uid) { + setSelected(null); + setDetail(null); + } + notify(message); + scheduleFoldersRefresh(); + }, + [notify, scheduleFoldersRefresh, selected], + ); + + const toggleFlag = useCallback(async () => { + if (selected == null) return; + const current = messages.find((m) => m.uid === selected)?.flagged ?? detail?.flagged ?? false; + try { + await api.setFlags(selected, folder, { flagged: !current }); + setMessages((prev) => prev.map((m) => (m.uid === selected ? { ...m, flagged: !current } : m))); + setDetail((d) => (d && d.uid === selected ? { ...d, flagged: !current } : d)); + notify(!current ? '已加旗标' : '已取消旗标'); + } catch (err) { + notify(`操作失败:${err instanceof Error ? err.message : String(err)}`, true); + } + }, [detail?.flagged, folder, messages, notify, selected]); + + const toggleSeen = useCallback(async () => { + if (selected == null) return; + const current = messages.find((m) => m.uid === selected)?.seen ?? detail?.seen ?? true; + try { + await api.setFlags(selected, folder, { seen: !current }); + setMessages((prev) => prev.map((m) => (m.uid === selected ? { ...m, seen: !current } : m))); + setDetail((d) => (d && d.uid === selected ? { ...d, seen: !current } : d)); + scheduleFoldersRefresh(); + notify(!current ? '已标为已读' : '已标为未读'); + } catch (err) { + notify(`操作失败:${err instanceof Error ? err.message : String(err)}`, true); + } + }, [detail?.seen, folder, loadFolders, messages, notify, selected]); + + const archive = useCallback(async () => { + if (selected == null) return; + try { + await api.move(selected, folder, 'Archive'); + await afterMutate(selected, '已归档'); + } catch (err) { + notify(`归档失败:${err instanceof Error ? err.message : String(err)}`, true); + } + }, [afterMutate, folder, notify, selected]); + + const remove = useCallback(async () => { + if (selected == null) return; + try { + const out = await api.remove(selected, folder); + await afterMutate(selected, out.moved ? `已移入 ${folderLabel(out.folder || 'Trash')}` : '已删除'); + } catch (err) { + notify(`删除失败:${err instanceof Error ? err.message : String(err)}`, true); + } + }, [afterMutate, folder, notify, selected]); + + const openCompose = useCallback((mode: ComposeMode, source: MailDetail | null = null) => { + setCompose({ open: true, mode, source }); + }, []); + + const quickReply = useCallback( + async (body: string) => { + if (!detail) return; + const target = detail.replyTo?.[0]?.address ?? detail.from?.[0]?.address ?? ''; + try { + const out = await api.send({ + to: target, + subject: /^re:/i.test(detail.subject) ? detail.subject : `Re: ${detail.subject}`, + text: body, + inReplyTo: detail.messageId ?? undefined, + references: detail.references ?? detail.messageId ?? undefined, + }); + notify(`已回复 ${out.recipients.join('、')}`); + scheduleFoldersRefresh(); + } catch (err) { + notify(`发送失败:${err instanceof Error ? err.message : String(err)}`, true); + } + }, + [detail, notify, scheduleFoldersRefresh], + ); + + /* ── 键盘 ─────────────────────────────────────────────── */ + const move = useCallback( + (delta: number) => { + if (messages.length === 0) return; + const idx = messages.findIndex((m) => m.uid === selected); + const next = Math.min(Math.max((idx < 0 ? (delta > 0 ? -1 : messages.length) : idx) + delta, 0), messages.length - 1); + const uid = messages[next].uid; + void openMail(uid); + document.querySelector(`[data-uid="${uid}"]`)?.scrollIntoView({ block: 'nearest' }); + }, + [messages, openMail, selected], + ); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + const target = e.target as HTMLElement | null; + const typing = !!target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName); + if (compose.open) return; + if (e.key === 'Escape') { + if (typing) target?.blur(); + else setSidebarOpen(false); + return; + } + if (typing) return; + if (e.key === 'j' || e.key === 'ArrowDown') return move(1); + if (e.key === 'k' || e.key === 'ArrowUp') return move(-1); + if (e.key === 'Enter' && selected != null) return void openMail(selected); + if (e.key === 'c') return openCompose('new'); + if (e.key === '/') { + e.preventDefault(); + searchRef.current?.focus(); + return; + } + if (!detail) return; + if (e.key === 'r') return openCompose('reply', detail); + if (e.key === 'f') return openCompose('forward', detail); + if (e.key === 's') return void toggleFlag(); + if (e.key === 'u') return void toggleSeen(); + if (e.key === 'e') return void archive(); + if (e.key === '#') return void remove(); + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [archive, compose.open, detail, move, openCompose, openMail, remove, selected, toggleFlag, toggleSeen]); + + const account = useMemo( + () => ({ + user: state?.account.user ?? '', + displayName: state?.account.displayName || state?.account.user || '', + host: state?.account.host ?? '', + }), + [state], + ); + + /* ── 渲染 ─────────────────────────────────────────────── */ + if (!booted) { + return ( +
正在连接邮件服务器…
+ ); + } + + if (!state?.connected && !state?.account.hasPassword) { + return ( + { + const s = await api.state(); + setState(s); + if (s.folders) { + setFolders(s.folders); + const inbox = s.folders.find((f) => f.name.toUpperCase() === 'INBOX')?.name ?? 'INBOX'; + setFolder(inbox); + await loadMessages(inbox, ''); + } + notify('已连接'); + }} + /> + ); + } + + return ( +
+ {/* 侧栏(窄屏收进抽屉) */} + void selectFolder(n)} + onCompose={() => openCompose('new')} + onDisplayName={(name) => setState((s) => (s ? { ...s, account: { ...s.account, displayName: name } } : s))} + className={cn('w-64 shrink-0', 'max-lg:absolute max-lg:z-30 max-lg:h-full', !sidebarOpen && 'max-lg:hidden')} + /> + + {/* 列表 */} +
+
+ +
+ + setSearchInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setQuery(searchInput.trim()); + void loadMessages(folder, searchInput.trim()); + } + }} + placeholder="搜索主题 / 发件人(回车)" + className="h-9 pl-8" + /> + {(query || searchInput) && ( + + )} +
+ + +
+ + void openMail(uid)} + className="min-h-0 flex-1" + /> +
+ + {/* 阅读区 */} + void archive()} + onDelete={() => void remove()} + onToggleFlag={() => void toggleFlag()} + onToggleSeen={() => void toggleSeen()} + onReply={() => openCompose('reply', detail)} + onForward={() => openCompose('forward', detail)} + onQuickReply={quickReply} + /> + + setCompose((c) => ({ ...c, open: false }))} + onSent={(info) => { + notify(`已发送给 ${info.recipients.join('、')}`); + scheduleFoldersRefresh(); + if (folder.toUpperCase() === 'SENT') void loadMessages(folder, query); + }} + onError={(m) => notify(m, true)} + /> + + {toast && ( +
+ {toast.text} +
+ )} +
+ ); +} diff --git a/ui/src/components/mail/AccountDialog.tsx b/ui/src/components/mail/AccountDialog.tsx new file mode 100644 index 0000000..c3ba62a --- /dev/null +++ b/ui/src/components/mail/AccountDialog.tsx @@ -0,0 +1,261 @@ +import { useEffect, useState } from 'react'; +import { Settings2, ShieldCheck, LogOut, Save, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { api, type AccountProfile, type AccountSession, type AuditEvent } from '@/lib/api'; + +const REASON_LABEL: Record = { + register: '注册', + 'register-verify': '注册验证通过', + 'login-ok': '登录成功', + 'login-failed': '登录失败', + 'login-locked': '登录被锁定拦截', + 'reset-request': '申请重置密码', + 'reset-ok': '密码已重置', + 'password-changed': '修改密码', + 'profile-updated': '修改资料', + 'session-revoked': '退出其他设备', + 'code-sent': '重发验证码', + 'code-failed': '验证码输错', + 'account-purged': '账号被删除', +}; + +/** + * 账号设置:显示名、会话(在哪登录过 / 退出其他设备)、改密码、最近的安全记录。 + * + * 这些走服务器的「账号接口」(客户端后端按需用 IMAP 凭据换一个 token), + * 所以服务器没开这个入口时这里会明确报错,但**不影响正常的收发信**。 + */ +export function AccountDialog({ account, onDisplayName }: { account: AccountProfile | null; onDisplayName?: (name: string) => void }) { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [profile, setProfile] = useState(null); + const [sessions, setSessions] = useState([]); + const [audit, setAudit] = useState([]); + const [displayName, setDisplayName] = useState(''); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + + async function load() { + setLoading(true); + setError(null); + try { + const r = await api.account.overview(); + setProfile(r.profile); + setSessions(r.sessions || []); + setAudit(r.audit || []); + setDisplayName((r.profile && r.profile.displayName) || ''); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + if (open) void load(); + }, [open]); + + async function saveName() { + setBusy(true); + setError(null); + setNotice(null); + try { + const r = await api.account.updateProfile(displayName); + setProfile(r.user); + onDisplayName?.(r.user.displayName); + setNotice('显示名已更新。'); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function changePassword() { + setBusy(true); + setError(null); + setNotice(null); + try { + const r = await api.account.changePassword({ currentPassword, password: newPassword }); + setNotice(`密码已修改,其他设备被退出(${r.revokedSessions} 个会话)。`); + setCurrentPassword(''); + setNewPassword(''); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function revokeOthers() { + setBusy(true); + setError(null); + setNotice(null); + try { + const r = await api.account.revokeSessions({}); + setNotice(`已退出其他设备(${r.revoked} 个会话)。`); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( + + + + + + + 账号设置 + + {profile?.email || account?.email || '当前账号'} · 登录 {sessions.length} 个设备 + + + + {error && ( +
+ {error} +
+ )} + {notice &&
{notice}
} + +
+

显示名

+
+ setDisplayName(e.target.value)} placeholder="发件时显示的名字" /> + +
+
+ + + +
+
+

+ + 登录设备 +

+
+ + +
+
+
    + {sessions.length === 0 &&
  • {loading ? '读取中…' : '暂无会话记录'}
  • } + {sessions.map((s) => ( +
  • +
    +
    {s.tokenPrefix}…
    +
    + 创建 {fmt(s.createdAt)} · 到期 {fmt(s.expiresAt)} +
    +
    + {s.current && 当前} +
  • + ))} +
+
+ + + +
+

修改密码

+
+ setCurrentPassword(e.target.value)} + placeholder="当前密码" + autoComplete="current-password" + /> + setNewPassword(e.target.value)} + placeholder="新密码(至少 12 位)" + autoComplete="new-password" + /> +
+ +

改完会自动更新本机保存的密码,并把其他设备踢下线。

+
+ + + +
+

最近的安全记录

+
    + {audit.length === 0 &&
  • {loading ? '读取中…' : '暂无记录'}
  • } + {audit.slice(0, 12).map((e, i) => ( +
  • + + {REASON_LABEL[e.reason] || e.reason} + {e.detail ? ` · ${e.detail}` : ''} + + {fmt(e.at)} +
  • + ))} +
+
+ + + + +
+
+ ); +} + +function fmt(value: string | null): string { + if (!value) return '—'; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return value; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} diff --git a/ui/src/components/mail/ComposeDialog.tsx b/ui/src/components/mail/ComposeDialog.tsx new file mode 100644 index 0000000..291f2cc --- /dev/null +++ b/ui/src/components/mail/ComposeDialog.tsx @@ -0,0 +1,227 @@ +import { useEffect, useRef, useState } from 'react'; +import { Loader2, Paperclip, Send, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { Textarea } from '@/components/ui/textarea'; +import { api, type MailDetail } from '@/lib/api'; +import { formatSize } from '@/lib/utils'; + +export type ComposeMode = 'new' | 'reply' | 'forward'; + +type Pending = { filename: string; contentType: string; base64: string; size: number }; + +async function fileToBase64(file: File): Promise { + const buf = new Uint8Array(await file.arrayBuffer()); + let binary = ''; + const chunk = 0x8000; + for (let i = 0; i < buf.length; i += chunk) { + binary += String.fromCharCode(...buf.subarray(i, i + chunk)); + } + return btoa(binary); +} + +export function ComposeDialog({ + open, + mode, + source, + selfAddress, + onClose, + onSent, + onError, +}: { + open: boolean; + mode: ComposeMode; + source: MailDetail | null; + selfAddress: string; + onClose: () => void; + onSent: (info: { recipients: string[]; bytes: number }) => void; + onError: (message: string) => void; +}) { + const [to, setTo] = useState(''); + const [cc, setCc] = useState(''); + const [subject, setSubject] = useState(''); + const [text, setText] = useState(''); + const [pending, setPending] = useState([]); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(''); + const fileRef = useRef(null); + + // 打开时按模式预填(回复/转发) + useEffect(() => { + if (!open) return; + setStatus(''); + setPending([]); + if (mode === 'reply' && source) { + const src = source.replyTo?.[0] ?? source.from?.[0]; + setTo(src?.address ?? ''); + setSubject(/^re:/i.test(source.subject) ? source.subject : `Re: ${source.subject}`); + setText( + `\n\n——— 原邮件 ———\n发件人:${source.from.map((a) => a.name || a.address).join('、')}\n时间:${source.date ?? ''}\n\n${source.text}`, + ); + } else if (mode === 'forward' && source) { + setTo(''); + setSubject(/^fwd:/i.test(source.subject) ? source.subject : `Fwd: ${source.subject}`); + setText( + `\n\n——— 转发的邮件 ———\n发件人:${source.from.map((a) => a.name || a.address).join('、')}\n收件人:${source.to + .map((a) => a.name || a.address) + .join('、')}\n时间:${source.date ?? ''}\n主题:${source.subject}\n\n${source.text}`, + ); + } else { + setTo(''); + setCc(''); + setSubject(''); + setText(''); + } + }, [open, mode, source]); + + async function addFiles(files: FileList | null) { + if (!files?.length) return; + const next: Pending[] = []; + for (const f of Array.from(files)) { + next.push({ + filename: f.name, + contentType: f.type || 'application/octet-stream', + base64: await fileToBase64(f), + size: f.size, + }); + } + setPending((prev) => [...prev, ...next]); + } + + async function send() { + if (!to.trim()) { + onError('请填写收件人'); + return; + } + setBusy(true); + setStatus('发送中…'); + try { + const out = await api.send({ + to, + cc, + subject, + text, + inReplyTo: mode === 'reply' ? source?.messageId ?? undefined : undefined, + references: mode === 'reply' ? source?.references ?? source?.messageId ?? undefined : undefined, + attachments: pending.map(({ filename, contentType, base64 }) => ({ filename, contentType, base64 })), + }); + onSent({ recipients: out.recipients, bytes: out.bytes }); + onClose(); + } catch (err) { + setStatus(''); + onError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function draft() { + setBusy(true); + try { + await api.saveDraft({ to, subject, text }); + setStatus('已存入草稿'); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + const title = mode === 'reply' ? '回复' : mode === 'forward' ? '转发' : '新邮件'; + + return ( + !v && onClose()}> + + + {title} + + +
+
+ 收件人 + setTo(e.target.value)} placeholder="someone@example.com" /> +
+
+ 抄送 + setCc(e.target.value)} placeholder="可选,多个用逗号分隔" /> +
+
+ 主题 + setSubject(e.target.value)} /> +
+ +