Compare commits

..
101 Commits
Author SHA1 Message Date
AxTps 52b3606062 feat: 资源下载页未登录时显示登录提示
Sync source to CNB / Sync Git ref (push) Skipped
未登录时不请求接口,直接展示「登录后浏览资源下载」,避免 401 错误弹窗。
2026-10-22 21:46:06 +08:00
AxTps fdfc2d36f5 fix: 资源下载接口带上登录凭据
Sync source to CNB / Sync Git ref (push) Skipped
download/list、download/count、download/category/list 在后端位于 requireLogin 内,
前端却以 auth: false 请求,未携带 token 导致 401,已登录用户也被提示未登录。
2026-10-22 21:43:38 +08:00
AxTps 30fbf5e5a6 fix: 补齐维基页缺失的中英文案
Sync source to CNB / Sync Git ref (push) Skipped
WikiView / WikiBrowser 使用的 24 个文案键未进 locale,导致 i18n-check 报缺键。
2026-10-22 21:30:21 +08:00
AxTps 9c92e157a5 feat: 皮肤站玩家管理补充二次确认、切换正版入口与默认徽章
Sync source to CNB / Sync Git ref (push) Skipped
- 删除/重命名/切换正版改为两段式确认,取消可整体复位
- 玩家列表与详情区均提供切换正版按钮
- 默认角色在列表和详情区显示「默认」徽章
- 补 rename/delete/mojang 三组确认文案
2026-10-21 22:25:30 +08:00
AxTps bc37e5d436 feat: 皮肤站玩家管理(新建/重命名/删除/默认角色/切换正版)
Sync source to CNB / Sync Git ref (push) Skipped
- skin-site-api.ts 补 setDefault/clearDefault/defaultPlayer/switchMojang/adaptLegacyInternal,并恢复 deleteLauncherRelease
- SkinSiteProfileView 增加玩家管理 UI 与状态
- en-US / zh-CN 各补 15 个 profile-view 文案
2026-10-21 22:19:47 +08:00
AxTps 0aa901ad48 merge: 同步 main 到 feat/home 2026-10-21 21:34:49 +08:00
Disy 6b0b2814a5 chore: release 1.0.2-beta13 2026-10-21 16:38:10 +08:00
Disy 115d31e0b4 fix(ci): restore cross-platform Rust compilation caches 2026-10-21 09:40:46 +08:00
Disy c44fb1a0a5 chore: release 1.0.2-beta12 2026-10-21 09:16:55 +08:00
AxTps afc03152e4 fix: 验证码图片改为完整显示并补上角度提示
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-20 22:15:04 +08:00
AxTps fd341d4b9e fix: 统一皮肤站文案脚本的拼写
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-20 22:09:31 +08:00
AxTps 0af663ac8c fix: 皮肤站玩家列表加载失败后自动重试并显示错误状态
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-20 22:05:24 +08:00
AxTps 26a29714b2 fix: 补回维基界面缺失的文案键
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-20 22:02:56 +08:00
AxTps 6501eb6aea merge: 同步 main 到 feat/home
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-20 21:48:33 +08:00
Disy bf95d715a4 fix: make hosted downloads controllable and failures diagnosable
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
2026-10-20 21:25:19 +08:00
Disy 1838afce56 chore: release 1.0.2-beta11 2026-10-20 20:36:53 +08:00
Disy 7f3a1cdf5c fix: localize skin site UI and renew JWT on startup
Catalog missing skin-site messages and check source IDs during i18n validation. Renew the saved JWT before loading session data, guard overlapping login/logout requests, and reload announcements after authentication with a retry action.
2026-10-20 20:28:13 +08:00
Disy 2d7683b0bb ci: add credential-safe skin site authentication diagnosis 2026-10-20 19:45:32 +08:00
Disy 6edc44fe26 fix(ci): match skin site response envelopes 2026-10-20 19:19:36 +08:00
Disy f81a588dc1 fix(ci): include skin site API error details in failures 2026-10-20 19:17:07 +08:00
Disy be3a1eda8c fix(ci): publish skin site releases with chunked uploads
Use bounded chunks with retries, processing deadlines and verified activation. Fail publication explicitly on upload errors. Add a manual recovery workflow for existing release packages.
2026-10-20 19:14:58 +08:00
Disy 0098e9eb07 docs: add beta10 security release notes 2026-10-20 17:10:39 +08:00
Disy 629fe9c26a chore: bump launcher version to 1.0.2-beta10 2026-10-20 17:07:32 +08:00
Disy 159f9e3f38 fix(security): backport upstream modpack path traversal fixes
Backport Mystic-Stars/Axolotl b2f81391e584aebf3ef07e8db2fc9cdad2ffb4c7. Adapt validation to this fork and protect CurseForge archive paths and MRPack uninstall cleanup.
2026-10-20 17:04:46 +08:00
AxTps 79d49d7916 fix: 在线玩家提示渲染 MOTD 颜色
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-19 22:36:32 +08:00
AxTps 9c530247ef fix: 备份支持外部游戏目录实例
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-19 22:15:41 +08:00
AxTps daaaebc5a8 fix: 修复服务器地图被 CSP 拦截、字体命令缺少权限与审核表单空数组报错
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-19 22:00:06 +08:00
AxTps 598fb5c679 chore: 发布 1.0.2-beta9
Sync source to CNB / Sync Git ref (push) Skipped
Prepare pnpm cache / prepare (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (Linux x64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Starlight release / Build (Windows x64) (push) Canceled after 0s
2026-10-19 21:48:11 +08:00
AxTps 2542f6dcb6 feat: 维基页支持关注与移动页面
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-19 21:36:40 +08:00
AxTps 1a4fd54b68 feat: 维基页支持搜索、最近更新与分类浏览
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 15:32:06 +08:00
AxTps a325298eb6 feat: 维基阅读页补历史与版本对比
Sync source to CNB / Sync Git ref (push) Skipped
- 阅读页新增阅读/历史模式切换
- 历史列表展示修订号、作者、摘要、时间与小修改标记
- 支持查看任意历史修订的渲染内容
- 与当前版本的逐行差异对比,新增/删除行高亮
- 支持撤销到指定修订,带二次确认
- 修复导航栈与修订列表变量重名
- 补充维基历史相关 API:历史列表、修订详情、差异对比、撤销
- 中英文各补 13 个文案
2026-10-18 15:04:05 +08:00
AxTps aaf394f790 feat: 发版时自动同步到皮肤站
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
- build-on-tag 新增发布步骤,将 skin-site-update 包上传到皮肤站并激活
- 通过 SKIN_SITE_CI_UPLOAD_TOKEN 环境变量鉴权,未配置时跳过并告警
- 上传或激活失败不阻断发版流程
2026-10-18 14:02:57 +08:00
AxTps 3c8ac04269 feat: 补齐皮肤站管理台与维基阅读(用户、审核、Mod、聊天室、维基迁移)
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
feat: 新增皮肤站管理台资源下载、服务器、审计与捐赠页
2026-10-18 13:15:01 +08:00
AxTps 71b8d111fb feat: 新增皮肤站维基只读浏览页
Sync source to CNB / Sync Git ref (push) Skipped
- 新增 WikiView 页面:输入标题加载页面,支持内部词条跳转与返回栈
- 新增 WikiNodeRenderer 递归渲染器:标题、段落、加粗斜体下划线删除线、高亮、行内代码、黑幕、分隔线、内外链、有序无序列表、引用、代码块、表格、附件、提示块
- 新增 WikiNotice 提示块与 WikiAttachmentNode 附件组件,图片支持点击查看大图
- 补充维基节点与附件类型定义,新增附件查询接口与附件内容 URL 拼接
- 主页导航 Wiki 卡片改为站内打开
- 中英文各补 10 个文案
2026-10-18 13:13:03 +08:00
AxTps febbd84ae7 feat: 皮肤站管理台 Wiki 补 MediaWiki 迁移中心
Sync source to CNB / Sync Git ref (push) Skipped
- 新增迁移中心 Tab
- 自动迁移:填写 api.php 地址,可选 Bot 凭据,支持明确允许内网来源;可配置过时页面筛选规则(未更新天数、维护者统计窗口、维护者人数上限、过时标记)
- XML 导入:上传 MediaWiki 导出文件建立暂存任务
- 迁移任务列表:状态标签、审核进度条、已审核/导入/排除统计、错误信息
- 任务详情:分页加载候选页面,逐条显示更新时间、维护者数量、可疑原因,可设置目标标题与是否覆盖,执行导入或排除
- 整批发布与回滚,失败或已回滚任务可删除记录
- 补充迁移相关 API 与类型定义
- 中英文各补 55 个文案
2026-10-18 12:57:26 +08:00
AxTps 667c1a0900 feat: 聊天室管理接入实时语音子系统
Sync source to CNB / Sync Git ref (push) Skipped
- 顶部展示语音运行时状态:状态标签、LiveKit 版本、下载进度,未就绪时每 5 秒轮询
- 部署失败或不支持平台时提供重新部署入口
- 语音库配置弹窗:启用开关、目录、镜像地址、公网地址、监听地址、信令与媒体端口、启动超时、外部 IP 与节点 IP
- 语音日志弹窗:增量拉取、自动刷新开关、手动刷新、最多保留 2000 条
- 房间语音容量设置:参与人数与旁听人数上限
- 补充语音相关 API 与类型定义
- 中英文各补 26 个文案
2026-10-18 12:29:45 +08:00
AxTps ac38c98203 feat: 聊天室管理补房管任命、聚落主与审计
Sync source to CNB / Sync Git ref (push) Skipped
- 新增 ChatUserPicker 用户选择器:支持按用户名/邮箱/账号 ID 或玩家名称搜索,封禁与游客不可选,支持排除与白名单
- 房管任命与撤销
- 聚落房间可任命聚落主,展示当前聚落主
- 房间审计记录查看,操作类型本地化映射
- 补充对应 API 与类型定义
- i18n 白名单加入 ID 缩写
- 中英文各补 31 个文案
2026-10-18 12:20:45 +08:00
AxTps ef1435bd31 feat: 新增皮肤站管理台聊天室房间管理
Sync source to CNB / Sync Git ref (push) Skipped
- 房间列表:头像、名称、可见性、类型、语音标记、前置权限、创建时间
- 创建/编辑房间:名称、简介、公开或私有、普通或聚落、前置条件判定与所需权限
- 房间头像:上传、更换、移除,支持 PNG/JPEG/WebP,5 MB 限制
- 删除房间带二次确认
- 新增 RoomAvatar 与 RoomAvatarEditor 组件,头像按附件 id 缓存 Blob URL
- 补充聊天室相关 API:房间列表、创建、更新、删除、头像更新、附件上传、头像获取
- 管理台导航接入聊天室入口
- 中英文各补 37 个文案
2026-10-18 12:13:34 +08:00
AxTps 87de306245 feat: 补全皮肤站管理台 Mod 页上传与批量操作
Sync source to CNB / Sync Git ref (push) Skipped
Prepare pnpm cache / prepare (push) Canceled after 0s
- 新增 Mod 上传弹窗:拖拽或选择 .jar、多文件、大小限制、规则标签附加
- 上传流程:xxHash64 查重、同名检测、名称冲突确认(覆盖/跳过)、429 限流退避重试、HashDiff 重传
- Mod 列表支持多选模式:全选本页、取消选择、批量删除
- 新增服务器地址过滤,配合名称搜索与标签筛选
- 补充 Mod 相关 API:文件哈希、按哈希/名称查重、上传、批量更新
- 引入 xxhash-wasm 依赖
- 中英文各补 32 个文案
2026-10-18 12:02:39 +08:00
AxTps fbcbb803ac feat: 补全皮肤站管理台用户页
Sync source to CNB / Sync Git ref (push) Skipped
- 用户列表:搜索(用户名/邮箱/UUID)、分页、封禁状态、注册时间
- 编辑用户:用户名、邮箱、QQ、密码、权限组、封禁状态
- 绑定玩家:展示 Mojang / StarLight 来源与旧内服适配标记
- Bot 管理:查看状态、修改机器人用户名、使 Token 失效、封禁/解封 Bot 创建
- 频率限制:滑块与数字输入设置丢包频率,支持保存与取消
- 管理审计:按用户或全局查看,游标分页加载更早记录
- 邀请码:按邮箱生成邀请码并复制
- 补充对应 API 与类型定义,中英文各补 39 个文案
2026-10-18 11:18:55 +08:00
AxTps 7b3a5212e6 feat: 完成玩家审核试卷编辑器
Sync source to CNB / Sync Git ref (push) Skipped
- 新增试卷管理 Tab:列表展示、启用状态、题数/总分/通过分、目标权限标签、复制直链
- 新增试卷编辑弹窗:标题、描述、阅卷模式、通过分数、启用开关
- 目标权限多选、前置身份开关 + 判断方式(ANY/ALL)+ 权限多选
- 题目编辑:五种题型、上下移、删除、信息题、选答题、生死题、自动批复
- 选择题选项增删改、默认选项、正确选项、多选计分模式、选项得分/扣分
- 填空/简答作答字数上限与标准答案
- 查重开关与通过邮件配置(标题/正文/补充说明/附带邀请码)
- 保存前校验:标题、题目、权限、通过分、邮件必填项
- 公开端审核类型改名 Public 前缀,避免与后台同名冲突
- 补充中英文 locale 共 37 个键
2026-10-18 10:25:48 +08:00
AxTps 8d73689356 feat: 新增皮肤站管理台维基管理页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 09:11:58 +08:00
AxTps 4a3b4268b9 fix: 补齐皮肤站主页导航卡片的中英文案
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 09:01:43 +08:00
AxTps 64f2f85607 fix: 重命名公开下载与地图接口避免导出冲突
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 08:38:14 +08:00
AxTps ede28e0062 feat: 新增皮肤站管理台 Mod 管理页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 08:24:55 +08:00
AxTps b0178c046f feat: 新增皮肤站管理台捐赠管理页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 08:01:24 +08:00
AxTps 57ff974d79 feat: 服务器管理页仅对网页管理员展示
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 07:45:39 +08:00
AxTps ac9373ddae feat: 新增皮肤站管理台服务器与审计页 2026-10-18 07:43:37 +08:00
AxTps 47c4a3973d feat: 新增皮肤站管理台资源下载页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 07:37:15 +08:00
AxTps a85e88339f chore: 发布 1.0.2-beta8
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (Linux x64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Starlight release / Build (Windows x64) (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
2026-10-18 07:30:37 +08:00
AxTps d4eb492772 merge: 整合包下载地址切换到独立下载域名
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
2026-10-18 07:25:04 +08:00
AxTps e1aa1b47ec feat: 整合包下载地址切换到独立下载域名
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 07:24:19 +08:00
AxTps 023447fe85 Merge pull request #23 from ApTx2012/feat/home
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
feat: 皮肤站会话改为 API 驱动
2026-10-18 07:15:33 +08:00
AxTps 1baf1a041e Merge pull request #22 from ApTx2012/feat/ui
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
fix: 引导步骤切换时自动滚动到目标元素
2026-10-17 22:56:34 +08:00
AxTps 6a0d9b719c Merge pull request #19 from ApTx2012/feat/backend
fix: LogShare 采集改用轻量日志收集,修复崩溃日志上传失败
2026-10-17 22:56:15 +08:00
AxTps ba43b9d5bc feat: 新增皮肤站管理台启动器版本页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:54:43 +08:00
AxTps 1c671943b7 feat: 新增皮肤站管理台玩家审核页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:50:21 +08:00
AxTps eef7d02f45 fix: 补全皮肤站管理台 i18n 白名单
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:43:50 +08:00
AxTps 947a739672 feat: 新增皮肤站管理台用户与玩家页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:38:03 +08:00
AxTps 799d542227 feat: 新增皮肤站管理台概览与公告页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:27:41 +08:00
AxTps 77258eee9d feat: 新增皮肤站管理控制台框架
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:21:03 +08:00
AxTps a4615c7b73 feat: 新增皮肤站问题创建与通知中心
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:07:47 +08:00
AxTps 146b88afce feat: 新增皮肤站问题详情与评论
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 21:59:25 +08:00
AxTps 9bdc965cd5 feat: 新增皮肤站问题列表视图
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 21:45:01 +08:00
AxTps d6d4fcf8a0 feat: 新增皮肤站玩家审核视图
Sync source to CNB / Sync Git ref (push) Skipped
- API 层补充公开审核表单目录与提交申请接口。
- SkinSiteReviewView:表单列表、动态题目渲染(单选 / 多选 / 判断 /
  填空 / 问答 / 信息题)、必填校验,提交后展示审核状态。
- 主页导航卡的「玩家审核」改为打开该视图。
2026-10-17 21:34:38 +08:00
AxTps 8cc4782497 feat: 新增皮肤站服务器地图视图
Sync source to CNB / Sync Git ref (push) Skipped
- API 层补充地图服务器列表接口。
- SkinSiteMapView:内外服切换、选择子服与地图,用内嵌 iframe 展示,
  并提供在浏览器中打开的入口。
- 主页导航卡的「地图」改为打开该视图。
2026-10-17 21:28:04 +08:00
AxTps 77b381b8d0 feat: 新增皮肤站资源下载视图
Sync source to CNB / Sync Git ref (push) Skipped
- API 层补充下载列表、数量与分类接口。
- SkinSiteDownloadsView:按分类筛选下载项,展示文件名、描述、
  上传者与状态标签,点击后用系统浏览器打开下载地址。
- 主页导航卡的「下载」改为打开该视图,其余入口仍走浏览器。
2026-10-17 21:24:36 +08:00
AxTps 4124f26095 feat: 新增皮肤站玩家管理视图
Sync source to CNB / Sync Git ref (push) Skipped
- SkinSiteProfileView:玩家列表、选中玩家的 3D 预览,以及皮肤 /
  披风 / 鞘翅的上传与删除;Mojang 账号只读。
- 账号卡增加「My players」入口,主页在该视图与首页之间切换。
- 3D 预览复用 SkinPreviewRenderer。
2026-10-17 21:18:44 +08:00
AxTps 041d288b25 feat: 新增皮肤站账号设置弹窗
Sync source to CNB / Sync Git ref (push) Skipped
- API 层补充改名、改网站密码、改 Minecraft 密码、上传头像。
- SkinSiteProfileModal:用户名修改、头像上传(居中裁剪为 256×256)、
  网站密码修改、Minecraft 密码修改与恢复默认。
- 主页账号卡增加「Account」入口打开该弹窗。
2026-10-17 19:33:48 +08:00
AxTps 86306b8ddd refactor: 移除内嵌皮肤站页面
Sync source to CNB / Sync Git ref (push) Skipped
主页已改为原生界面,整页内嵌网页不再需要:

- 删除 SkinSiteFrame.vue 与 StarlightSkin.vue。
- 移除 /starlight-skin 路由。
- 删除不再使用的 skinSiteFrameUrl 导出。
- 实例玩家弹窗的"去皮肤站登录"改为直接打开登录弹窗,不再跳转页面。
2026-10-17 19:19:05 +08:00
AxTps 8599a014ad feat: 主页改为原生皮肤站界面
Sync source to CNB / Sync Git ref (push) Skipped
主页原先内嵌整个皮肤站网页,现在换成启动器自己的界面,数据全部走
接口:

- SkinSiteAccountCard:登录状态、玩家列表(头像由皮肤裁剪生成)、
  每日运势。
- SkinSiteNavCards:资源下载、玩家审核、Wiki、漏洞追踪、地图、个人
  资料的入口,点击后用系统浏览器打开。
- SkinSiteAnnouncements:拉取公告并按重要度与时间排序。
- SkinSiteDonationCard:捐款累计金额与人数。
- SkinSiteHome:把以上拼装成主页,并显示可复制的接入地址。
- API 层补充捐款与公告接口。

Index.vue 的 iframe 换成 SkinSiteHome。
2026-10-17 19:11:04 +08:00
AxTps bf175160b0 Merge remote-tracking branch 'origin/main' into feat/home 2026-10-17 18:57:46 +08:00
AxTps d17a66e44f chore: 补充 1.0.2-beta7 更新公告
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (Linux x64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Starlight release / Build (Windows x64) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
汇总 beta6 以来的改动:实时日志积压修复、更新通道默认值跟随安装
包版本、Linux 游戏目录默认回退到用户主目录。
2026-10-17 18:56:23 +08:00
AxTps 3198eac35a feat: 新增皮肤站注册与找回密码弹窗
Sync source to CNB / Sync Git ref (push) Skipped
补齐登录之外的账号流程:

- SkinSiteRegisterModal:邮箱验证码(60 秒倒计时)、密码与确认、
  可选 QQ、按服务端配置显示邀请码,提交前走图片验证码。
- SkinSitePasswordResetModal:邮箱验证码、新密码与确认,同样走验证码。
- 登录弹窗底部增加「注册」「忘记密码」入口,取消或完成后回到登录。
- API 层补充注册、注册条件、邮箱验证码、密码重置接口。
2026-10-17 18:50:38 +08:00
AxTps d83a65a052 refactor: 移除隐藏的皮肤站会话 iframe
Sync source to CNB / Sync Git ref (push) Skipped
会话改为 API 驱动后,这个隐藏 iframe 只负责收发 postMessage。它每
10 秒检查一次"90 秒内没收到消息就重置会话",而现在永远收不到消息,
90 秒后必然把刚登录的会话清掉。

删除组件,改由 App.vue 在状态初始化完成后调用 initSkinSiteSession()
恢复登录态。
2026-10-17 18:45:57 +08:00
AxTps 00f0f111d8 feat: 新增皮肤站原生登录与验证码弹窗
Sync source to CNB / Sync Git ref (push) Skipped
登录入口原先会切到主页并打开内嵌网页。改为弹出启动器自己的登录弹窗:

- SkinSiteLoginModal:账号密码校验规则与皮肤站一致,登录前走验证码,
  成功后保存 JWT 并刷新会话。
- SkinSiteCaptchaModal:拉取旋转图片验证码,用滑块调整角度后提交。
- AccountsCard 的登录按钮改为打开该弹窗,登录成功后重新拉取玩家列表。
- 修正 verifyCaptcha 的参数:服务端校验的是旋转角度 degree,不是文字答案。
2026-10-17 18:42:34 +08:00
AxTps 0edf96fc2c feat: 皮肤站会话改为 API 驱动,不再依赖内嵌网页
Sync source to CNB / Sync Git ref (push) Skipped
主页原先通过隐藏 iframe + postMessage 与皮肤站网页通信,登录态、
玩家列表、运势等数据都要绕一圈从网页里取。改为启动器直接调用皮肤站
HTTP 接口:

- 新增 helpers/skin-site-api.ts:请求封装(tauriFetch 绕开 CORS)、
  JWT 本地存取、认证与玩家/皮肤接口。
- 新增 helpers/skin-site-render.ts:从皮肤图裁出头像,以及把图片转
  成 data URL。
- 重写 composables/skin-site-session.ts:登录态由本地 JWT 决定,
  玩家列表改为请求接口后逐个拉取皮肤并渲染头像;运势、皮肤上传同样
  走接口。旧的 iframe 相关导出保留为兼容空实现。
- 删除仅测试 postMessage 协议的 skin-site-session.test.ts。

下载凭据仍然返回皮肤站 JWT:Rust 侧 hosted_set_session 期望的就是它,
instance-player 与远程公告的鉴权方式保持不变。
2026-10-17 18:35:58 +08:00
AxTps 871b0ee001 Merge pull request #21 from ApTx2012/feat/instance
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
fix: Linux 系统安装时游戏目录默认回退到用户主目录
2026-10-17 18:17:54 +08:00
AxTps adebde714d Merge pull request #20 from ApTx2012/feat/updater
fix: 更新渠道默认值跟随安装包版本
2026-10-17 18:17:41 +08:00
AxTps 0195cbf5ac fix: 修复 collect_crash_context 中凭据收集的类型不匹配
Sync source to CNB / Sync Git ref (push) Skipped
Credentials::get_all 返回 DashMap,直接 collect 会得到
Vec<(Uuid, Credentials)>,与 CrashContext.credentials 的
Vec<Credentials> 不符,导致编译失败。补上与 analyze_crash 一致的
.map(|credential| credential.1)。
2026-10-17 18:16:36 +08:00
AxTps 13a56f4200 fix: Linux 系统安装时游戏目录默认回退到用户主目录
Sync source to CNB / Sync Git ref (push) Skipped
从 deb 等系统包安装时,启动器可执行文件位于 /usr/bin,此前一键安装
StarLight 实例的"选择游戏目录"弹窗会默认到这个系统目录,普通用户既
无写权限,也不适合存放游戏数据。

将默认目录计算移入 theseus::default_game_dir_root():Linux 下若可执行
文件位于 /usr、/opt、/snap 等系统路径,回退到用户主目录;便携版与其
他平台仍使用可执行文件所在目录。
2026-10-17 16:12:28 +08:00
AxTps d3a510eee6 fix: 引导步骤切换时自动滚动到目标元素
Sync source to CNB / Sync Git ref (push) Skipped
新手引导进行到"更新设置"这一步时,目标按钮位于设置页左侧导航列表的
滚动区域内,可能在视口之外。此前引导只读取目标位置、不会滚动过去,
气泡指向屏幕外,玩家看不到"更新"按钮,容易反复点击版本号。

新增 ensureTargetVisible():步骤切换后若目标不在视口内,就平滑滚动
到视口中央,滚动后刷新目标位置让气泡跟随。
2026-10-17 15:37:42 +08:00
AxTps 8b94e7dbbe fix: 更新渠道默认值跟随安装包版本
Sync source to CNB / Sync Git ref (push) Skipped
从 GitHub 下载 Beta 安装包安装后,更新通道默认选中 Release,
因为 update-channel.json 状态文件不存在时会硬编码回退到 release。

新增 theseus::default_update_channel(),按编译期版本号判断:含预发布
后缀(如 1.0.2-beta7)默认 Beta,否则 Release。数据库渠道与设置页
渠道两处默认值统一改为调用它,保证两者一致。
2026-10-13 22:11:06 +08:00
AxTps ccb96a8cf1 fix: LogShare 采集改用轻量日志收集,修复崩溃日志上传失败
Sync source to CNB / Sync Git ref (push) Skipped
崩溃弹窗的 LogShare 摘要与 LogAgent 深度分析此前都会失败。原因是
logshare 的 prepare_upload 直接调用完整崩溃分析 analyze_crash,会
额外扫描 mod jar、比对 mod 快照并查询 Windows 事件,在这种大型实例
上容易出错或超时,错误被前端 uploadTicketForInstance 捕获成 null,
最终表现为无法上传/无法准备诊断。

对齐 Axolotl 的做法,新增轻量的 collect_crash_context /
collect_crash_logs,只做路径解析、候选收集、读取与脱敏,不触发
mod 扫描与快照副作用。SourceCandidate / SourceText 补充 upload_name
字段(采集时按目录加 crash-reports/ 前缀),logshare 的 prepare_upload
改调该轻量函数。
2026-10-13 22:01:02 +08:00
Disy 0f35a5f707 fix: 修复实时日志积压并更新至 1.0.2-beta7
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
2026-10-12 20:00:25 +08:00
AxTps 6fb73490d3 chore: 发布 1.0.2-beta6
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (Linux x64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Starlight release / Build (Windows x64) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
- 版本号更新为 1.0.2-beta6(package.json / Cargo.toml / Cargo.lock)
- catalog 新增 1.0.2-beta6 更新公告:键盘快捷键、崩溃日志分享与 AI 分析、日志类型扩展、日志导出、实例分组拖拽,以及公告刷新与盔甲翻译修复
2026-10-11 15:53:34 +08:00
AxTps 2dcf26c9d9 Merge pull request #18 from ApTx2012/feat/backend-logtype
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
feat: 扩展日志类型,支持 JVM 崩溃与启动器日志
2026-10-11 15:44:25 +08:00
AxTps 9ccacba3fd Merge remote-tracking branch 'origin/main' into feat/backend-logtype
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-11 15:42:28 +08:00
AxTps 54499dd9da Merge pull request #17 from ApTx2012/feat/backend
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
feat: 崩溃日志分享与 AI 分析
2026-10-11 15:41:23 +08:00
AxTps fce6c60759 Merge remote-tracking branch 'origin/main' into feat/backend
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-11 15:40:27 +08:00
AxTps dcbad9447d Merge remote-tracking branch 'origin/main' into feat/backend-logtype
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-11 15:08:59 +08:00
AxTps 4abf643852 Merge pull request #4 from ApTx2012/automation/sync-lobehub-models
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
chore(ai): sync LobeHub models
2026-10-11 15:07:54 +08:00
AxTps a8d8e1674b feat: 扩展日志类型,支持 JVM 崩溃与启动器日志
Sync source to CNB / Sync Git ref (push) Skipped
LogType 新增 JvmCrash 与 LauncherLog,使崩溃弹窗能列出
hs_err_pid*.log、launcher_log.txt、latest_stdout.log 等日志。

- 新增 log_directories_for_type 与 is_log_file_for_type,统一
  各日志类型的目录解析与文件名筛选
- 新增 isolated_minecraft_root,兼容 versions 版本隔离布局
- get_logs 追加 JvmCrash 与 LauncherLog 两类采集
- get_logs_by_filename / get_output_by_filename /
  delete_logs_by_filename 改用目录列表按存在性查找
- get_logs_from_type 遍历多目录并按文件名去重
2026-10-11 14:53:20 +08:00
AxTps 60f3fd8816 Merge pull request #16 from ApTx2012/feat/ui
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
feat: 新增键盘快捷键设置
2026-10-11 14:09:48 +08:00
AxTps c71c220446 feat: 崩溃弹窗集成日志分享与 AI 分析
Sync source to CNB / Sync Git ref (push) Skipped
将崩溃弹窗替换为双栏布局:左侧展示诊断摘要与分享入口,
右侧提供日志文件浏览器与 LogAgent 流式分析输出。

- MinecraftCrashModal 改用新版的分享票据、结构化摘要与 AI 事件流
- 新增 minecraft-crash-logs 工具函数,用于筛选与合并崩溃日志
- 事件层新增 logshare_ai_listener 监听 AI 分析进度
- log-share 工具更新为支持 provider 路由的新版
- Button 组件对齐新版(补 icon-only、circular、2xs 与 chip/highlight 变体)
- console 导出 LogViewport,供日志浏览器复用
- 补齐 en-US / zh-CN 相关文案
2026-10-11 14:05:33 +08:00
AxTps a95153d0b1 fix: 补全崩溃日志分享命令层的类型导入
apps/app 命令层缺少 LogShareSettings、LogShareUploadResponse、
SharedLog 三个类型的导入,导致 desktop 编译报 E0425。
2026-10-11 14:04:52 +08:00
AxTps 853147b04d feat: 新增崩溃日志分享设置界面
Sync source to CNB / Sync Git ref (push) Skipped
- 设置页新增「日志分享」与「已分享日志」区块(默认实例设置内)
- 补齐 helpers/logs.js 的 logshare 调用(上传/分析/删除/记录)
- 日志分享设置支持分享服务、AI 来源、自动上传、多文件、不留存、进度等选项
- 已分享日志支持查看列表与删除
- 补 en-US / zh-CN 文案
- i18n 白名单补充 LogShare AI
2026-10-11 12:23:53 +08:00
AxTps 8d61826c5f feat: 新增崩溃日志分享与 AI 分析(后端)
Sync source to CNB / Sync Git ref (push) Skipped
- 新增 LogShare 接入:上传崩溃日志、查询结构化分析、SSE 流式 AI 解释、删除分享
- 新增本地已分享日志记录(shared_logs)与分享设置(log_share_settings)
- 日志上传来源标识为 starlight/<版本>
- 新增 logshare_ai 事件用于向前端推送 AI 流式输出
- 暴露 11 个 tauri 命令并补齐 ACL
2026-10-11 12:15:33 +08:00
AxTps 2e92887eb2 feat: 新增键盘快捷键设置
Sync source to CNB / Sync Git ref (push) Skipped
- 设置页新增「键盘快捷键」分类,可自定义快捷键绑定
- 支持快速滚动(Home/End/PageUp/PageDown)与导航跳转(Ctrl/Cmd+数字)
- 快捷键绑定与开关按动作独立持久化,默认关闭
- 新增 ui 包 keybinding 组件与工具(KeybindingModal/KeybindingChips 等)
2026-10-11 11:14:46 +08:00
AxTps 47bdb1e79c Merge pull request #15 from ApTx2012/fix/ui
Sync source to CNB / Sync Git ref (push) Skipped
Rust checks / shear (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
fix: 修复远端公告不刷新与盔甲面板未翻译
2026-10-11 10:32:49 +08:00
github-actions[bot] 98396c6bcd chore(ai): sync LobeHub models at 6c7a6fb033e4
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 09:55:54 +00:00
171 changed files with 33229 additions and 2801 deletions

No files matched your search

+19 -8
View File
@@ -58,24 +58,34 @@ jobs:
node-version-file: .nvmrc
cache: pnpm
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Restore Rust dependencies
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: desktop-${{ runner.os }}-${{ runner.arch }}
- name: Set up Rust compiler cache
if: runner.os == 'Windows'
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
id: sccache-setup
continue-on-error: true
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.18.0
- name: Check Rust compiler cache availability
id: sccache
if: runner.os == 'Windows'
if: steps.sccache-setup.outcome == 'success'
continue-on-error: true
shell: bash
env:
SCCACHE_GHA_ENABLED: 'true'
run: ${SCCACHE_PATH} rustc -vV
run: '"${SCCACHE_PATH}" "$(rustup which rustc)" -vV'
- name: Enable Rust compiler cache
if: runner.os == 'Windows' && steps.sccache.outcome == 'success'
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo 'RUSTC_WRAPPER=sccache' >> "$GITHUB_ENV"
echo 'SCCACHE_GHA_ENABLED=true' >> "$GITHUB_ENV"
echo 'SCCACHE_CACHE_SIZE=4G' >> "$GITHUB_ENV"
- name: Report unavailable Rust compiler cache
if: steps.sccache-setup.outcome == 'failure' || steps.sccache.outcome == 'failure'
shell: bash
run: echo '::warning::Rust compiler cache is unavailable; this build will continue without compiler caching.'
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- run: pnpm install --frozen-lockfile
@@ -112,9 +122,10 @@ jobs:
}
Write-Output "Built native NSIS installer: $($installers[0].Name)"
- name: Show Rust compiler cache statistics
if: runner.os == 'Windows' && steps.sccache.outcome == 'success' && always()
if: always() && steps.sccache.outcome == 'success'
continue-on-error: true
shell: bash
run: ${SCCACHE_PATH} --show-stats
run: '"${SCCACHE_PATH}" --show-stats'
website:
needs: guardrails
+43 -11
View File
@@ -51,6 +51,7 @@ jobs:
node scripts/axolotl/check-migrations.mjs --release
node scripts/axolotl/brand-guard.mjs
node scripts/axolotl/i18n-check.mjs
node --test scripts/axolotl/publish-skin-site.test.mjs
cargo fmt --all --check
- name: Check updater signing configuration
@@ -130,26 +131,38 @@ jobs:
- name: Set up Rust
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
- name: Restore Rust dependencies
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
shared-key: desktop-${{ runner.os }}-${{ runner.arch }}
- name: Set up Rust compiler cache
if: runner.os == 'Windows'
uses: mozilla-actions/sccache-action@2df7dbab909c49ab7d3382d05da469f3f975c2d6 # v0.0.7
id: sccache-setup
continue-on-error: true
uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
with:
version: v0.18.0
- name: Check Rust compiler cache availability
id: sccache
if: runner.os == 'Windows'
if: steps.sccache-setup.outcome == 'success'
continue-on-error: true
shell: bash
env:
SCCACHE_GHA_ENABLED: 'true'
run: ${SCCACHE_PATH} rustc -vV
run: '"${SCCACHE_PATH}" "$(rustup which rustc)" -vV'
- name: Enable Rust compiler cache
if: runner.os == 'Windows' && steps.sccache.outcome == 'success'
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo 'RUSTC_WRAPPER=sccache' >> "$GITHUB_ENV"
echo 'SCCACHE_GHA_ENABLED=true' >> "$GITHUB_ENV"
echo 'SCCACHE_CACHE_SIZE=4G' >> "$GITHUB_ENV"
- name: Report unavailable Rust compiler cache
if: steps.sccache-setup.outcome == 'failure' || steps.sccache.outcome == 'failure'
shell: bash
run: echo '::warning::Rust compiler cache is unavailable; this build will continue without compiler caching.'
- name: Add macOS universal targets
if: runner.os == 'macOS'
@@ -192,11 +205,6 @@ jobs:
updaterJsonPreferNsis: true
retryAttempts: 2
- name: Show Rust compiler cache statistics
if: runner.os == 'Windows' && steps.sccache.outcome == 'success' && always()
shell: bash
run: ${SCCACHE_PATH} --show-stats
- name: Publish Windows distribution variants
if: runner.os == 'Windows'
env:
@@ -282,10 +290,19 @@ jobs:
throw "Binary not found at $appExe"
}
- name: Show Rust compiler cache statistics
if: always() && steps.sccache.outcome == 'success'
continue-on-error: true
shell: bash
run: '"${SCCACHE_PATH}" --show-stats'
verify-and-publish:
name: Verify and publish
needs: build
runs-on: ubuntu-24.04
concurrency:
group: skin-site-${{ github.ref_name }}
cancel-in-progress: false
env:
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }}
@@ -298,6 +315,11 @@ jobs:
fetch-depth: 0
ref: ${{ github.ref }}
- name: Set up Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version-file: .nvmrc
- name: Download updater signatures
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -374,6 +396,16 @@ jobs:
)
gh release upload "${GITHUB_REF_NAME}" "release-check/${bundle_name}" --clobber
- name: Publish to skin site
env:
SKIN_SITE_TOKEN: ${{ secrets.SKIN_SITE_CI_UPLOAD_TOKEN }}
SKIN_SITE_URL: ${{ vars.SKIN_SITE_URL || 'https://skin.starlight.cool' }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
bundle="release-check/Starlight_Launcher_${version}_skin-site-update.zip"
node scripts/axolotl/publish-skin-site.mjs "${bundle}" "${GITHUB_REF_NAME}"
- name: Notify Update Server to publish release assets
if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }}
env:
@@ -0,0 +1,49 @@
name: Diagnose skin site CI authentication
on:
workflow_dispatch:
permissions:
contents: read
jobs:
diagnose:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Inspect token format without revealing credentials
env:
SKIN_SITE_TOKEN: ${{ secrets.SKIN_SITE_CI_UPLOAD_TOKEN }}
SKIN_SITE_URL: ${{ vars.SKIN_SITE_URL || 'https://skin.starlight.cool' }}
run: |
node --input-type=module <<'NODE'
const token = process.env.SKIN_SITE_TOKEN || ''
const checks = {
configured: token.length > 0,
containsWhitespace: /\s/.test(token),
containsComma: token.includes(','),
containsQuotes: /["']/.test(token),
containsBearerPrefix: /^Bearer\s/i.test(token),
bearerTokenFormatValid: /^[A-Za-z0-9._~+/-]+=*$/.test(token),
}
console.log(JSON.stringify(checks))
if (!checks.bearerTokenFormatValid) {
console.error('::error::CI secret is not a valid Bearer token. No credential value has been logged.')
process.exitCode = 1
} else {
const base = process.env.SKIN_SITE_URL.replace(/\/$/, '')
if (new URL(base).protocol !== 'https:') throw new Error('HTTPS is required')
try {
const response = await fetch(base + '/starlight/launcher/admin', {
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
redirect: 'error', signal: AbortSignal.timeout(30000),
})
console.log('Read-only CI authentication HTTP status: ' + response.status)
await response.body?.cancel()
if (!response.ok) process.exitCode = 1
} catch {
console.error('::error::Read-only CI authentication request failed; credentials have not been logged.')
process.exitCode = 1
}
}
NODE
+56
View File
@@ -0,0 +1,56 @@
name: Sync release to skin site
on:
workflow_dispatch:
inputs:
tag:
description: Existing GitHub release tag to sync (for example v1.0.2-beta10)
required: true
type: string
permissions:
contents: read
concurrency:
group: skin-site-${{ inputs.tag }}
cancel-in-progress: false
jobs:
publish:
name: Upload and activate existing release
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
- name: Check out publisher
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version-file: .nvmrc
- name: Download complete update package
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo '::error::Expected a semantic version tag beginning with v'
exit 1
fi
version="${RELEASE_TAG#v}"
gh release download "${RELEASE_TAG}" \
--pattern "Starlight_Launcher_${version}_skin-site-update.zip" \
--dir release-check
- name: Publish to skin site
env:
RELEASE_TAG: ${{ inputs.tag }}
SKIN_SITE_TOKEN: ${{ secrets.SKIN_SITE_CI_UPLOAD_TOKEN }}
SKIN_SITE_URL: ${{ vars.SKIN_SITE_URL || 'https://skin.starlight.cool' }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
node scripts/axolotl/publish-skin-site.mjs \
"release-check/Starlight_Launcher_${version}_skin-site-update.zip" "${RELEASE_TAG}"
Generated
+2 -2
View File
@@ -6841,7 +6841,7 @@ dependencies = [
[[package]]
name = "theseus"
version = "1.0.2-beta4"
version = "1.0.2-beta13"
dependencies = [
"ariadne",
"async-minecraft-ping",
@@ -6945,7 +6945,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "1.0.2-beta4"
version = "1.0.2-beta13"
dependencies = [
"ashpd",
"async_zip",
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@modrinth/app-frontend",
"private": true,
"version": "1.0.2-beta4",
"version": "1.0.2-beta13",
"type": "module",
"scripts": {
"dev": "vite",
@@ -50,7 +50,8 @@
"vue": "^3.5.13",
"vue-i18n": "^10.0.0",
"vue-router": "^4.6.0",
"vuedraggable": "^4.1.0"
"vuedraggable": "^4.1.0",
"xxhash-wasm": "1.1.0"
},
"devDependencies": {
"@eslint/compat": "^1.1.1",
+64 -41
View File
@@ -94,13 +94,14 @@ import NavButton from '@/components/ui/NavButton.vue'
import NavRail from '@/components/ui/NavRail.vue'
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
import RemoteAnnouncements from '@/components/ui/RemoteAnnouncements.vue'
import StartupAnnouncementModal from '@/components/ui/StartupAnnouncementModal.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import StartupAnnouncementModal from '@/components/ui/StartupAnnouncementModal.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { initSkinSiteSession } from '@/composables/skin-site-session'
import { useDropImport } from '@/composables/useDropImport'
import { setupKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { minecraftLaunchErrorKey } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { AxolotlBrandConfig, config, getOfficialLabrinthBaseUrl } from '@/config'
@@ -132,6 +133,8 @@ import {
} from '@/helpers/settings.ts'
import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
import { installUiScale, setUiScale } from '@/helpers/ui-scale'
import { shouldDelayAppUpdate } from '@/helpers/app-update-policy'
import {
areUpdatesEnabled,
backupAppDbForUpdate,
@@ -145,7 +148,6 @@ import {
isNetworkMetered,
setRestartAfterPendingUpdate,
} from '@/helpers/utils.js'
import { installUiScale, setUiScale } from '@/helpers/ui-scale'
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
import i18n, { resolveInitialLocale } from '@/i18n.config'
import {
@@ -184,6 +186,8 @@ const onSchematicWorkshopPage = computed(() => route.path === '/lab/schematic-pr
const isSchematicFile = (path: string) => /\.(litematic|schematic|schem)$/i.test(path)
const APP_LEFT_NAV_WIDTH = '4rem'
setupKeyboardShortcuts()
const discoverContentPath = computed(() => {
const projectType = route.params.projectType
if (
@@ -1156,6 +1160,7 @@ async function setupApp() {
stateInitialized.value = true
showOnboarding.value = !onboarded
void reconcileMojangAuthSourceAtStartup().catch(handleError)
void initSkinSiteSession().catch(handleError)
isMaximized.value = await getCurrentWindow().isMaximized()
@@ -1994,34 +1999,55 @@ function showDelayedUpdatePopup() {
}
let lastUpdateChannel = 'release'
let updateCheckRevision = 0
async function clearAvailableUpdate(isCurrent) {
const unlisten = unlistenUpdateDownload
unlistenUpdateDownload = null
await unlisten?.()
if (!isCurrent()) return
availableUpdate.value = null
updateSize.value = null
appUpdateDownload.progress.value = 0
finishedDownloading.value = false
downloading.value = false
clearDelayedUpdatePopup()
}
async function performUpdateCheck() {
const revision = ++updateCheckRevision
const isLatestCheck = () => revision === updateCheckRevision
const currentResult = () => (availableUpdate.value ? 'available' : 'up-to-date')
const channel = await getUpdateChannel()
const preferences = await getUpdatePreferences()
if (!isLatestCheck()) return currentResult()
updatesPaused.value = preferences.updatesPaused
if (updatesPaused.value) return 'paused'
if (channel !== lastUpdateChannel) {
availableUpdate.value = null
updateSize.value = null
appUpdateDownload.progress.value = 0
finishedDownloading.value = false
downloading.value = false
await clearAvailableUpdate(isLatestCheck)
if (!isLatestCheck()) return currentResult()
lastUpdateChannel = channel
}
const update = await checkAppUpdate(channel)
let update
try {
update = await checkAppUpdate(channel)
} catch (error) {
if (!isLatestCheck()) return currentResult()
throw error
}
if (!isLatestCheck()) return currentResult()
if (!update || update.version !== availableUpdate.value?.version) {
await clearAvailableUpdate(isLatestCheck)
}
if (!isLatestCheck()) return currentResult()
if (!update) {
console.log('No update available')
return 'up-to-date'
}
const publishedAt = Date.parse(update.publishedAt ?? '')
if (
channel === 'release' &&
!preferences.immediateUpdateFetch &&
!update.forceUpdate &&
(!Number.isFinite(publishedAt) || Date.now() < publishedAt + 24 * 60 * 60 * 1000)
) {
if (shouldDelayAppUpdate(update, channel, preferences.immediateUpdateFetch)) {
console.warn(
Number.isFinite(publishedAt)
? `Update ${update.version} is waiting for the release delay.`
@@ -2038,6 +2064,9 @@ async function performUpdateCheck() {
return 'available'
}
const meteredNetwork = await isNetworkMetered()
if (!isLatestCheck()) return currentResult()
appUpdateDownload.progress.value = 0
finishedDownloading.value = false
downloading.value = false
@@ -2046,7 +2075,7 @@ async function performUpdateCheck() {
console.log(`Update ${update.version} is available.`)
metered.value = await isNetworkMetered()
metered.value = meteredNetwork
if (!metered.value) {
console.log('Starting download of update')
@@ -2058,7 +2087,9 @@ async function performUpdateCheck() {
}
getUpdateSize(update.rid)
.then((size) => (updateSize.value = size))
.then((size) => {
if (availableUpdate.value?.rid === update.rid) updateSize.value = size
})
.catch((error) => console.warn('Failed to fetch update size', error))
return 'available'
}
@@ -2088,6 +2119,7 @@ async function downloadUpdate(versionToDownload) {
handleError(`Failed to download update: no version available`)
return
}
if (availableUpdate.value?.rid !== versionToDownload.rid) return
if (downloading.value || appUpdateDownload.progress.value !== 0) {
console.error(`Update ${versionToDownload.version} already downloading`)
@@ -2096,35 +2128,27 @@ async function downloadUpdate(versionToDownload) {
console.log(`Downloading update ${versionToDownload.version} from Update Server`)
downloading.value = true
const isCurrent = () => availableUpdate.value?.rid === versionToDownload.rid
let unlisten
try {
enqueueUpdateForInstallation(versionToDownload.rid)
.then(() => {
downloading.value = false
finishedDownloading.value = true
unlistenUpdateDownload?.().then(() => {
unlistenUpdateDownload = null
})
console.log('Finished downloading!')
markAppUpdateActionable(versionToDownload.version, 'downloaded')
scheduleDelayedUpdatePopup()
})
.catch((error) => {
downloading.value = false
appUpdateDownload.progress.value = 0
unlistenUpdateDownload?.().then(() => {
unlistenUpdateDownload = null
})
handleError(error)
})
unlistenUpdateDownload = await subscribeToDownloadProgress(
appUpdateDownload,
versionToDownload.version,
)
unlisten = await subscribeToDownloadProgress(appUpdateDownload, versionToDownload.version)
if (!isCurrent()) return
unlistenUpdateDownload = unlisten
await enqueueUpdateForInstallation(versionToDownload.rid)
if (!isCurrent()) return
downloading.value = false
finishedDownloading.value = true
markAppUpdateActionable(versionToDownload.version, 'downloaded')
scheduleDelayedUpdatePopup()
} catch (error) {
if (!isCurrent()) return
downloading.value = false
appUpdateDownload.progress.value = 0
handleError(error)
} finally {
await unlisten?.()
if (unlistenUpdateDownload === unlisten) unlistenUpdateDownload = null
}
}
@@ -2225,7 +2249,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<template>
<AnniversaryCelebration />
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<SkinSiteSessionFrame v-if="stateInitialized" />
<InstancePlayerModal v-if="stateInitialized" />
<StartupAnnouncementModal ref="startupAnnouncementModal" />
<RemoteAnnouncements :ready="stateInitialized && !showOnboarding && !stateFailed" />
+395 -60
View File
@@ -32,6 +32,320 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
id: 'launcher-1.0.2-beta13',
version: '1.0.2-beta13',
publishedAt: '2026-10-21',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta13',
'zh-CN': 'Starlight Launcher 1.0.2-beta13',
},
changes: {
changed: [
{
'en-US':
'The redesigned home page brings together the 3D skin preview, skin site and Microsoft player selection, announcements, donation acknowledgements and a compact navigation toolbar.',
'zh-CN':
'新版首页集中展示 3D 皮肤预览、皮肤站与微软玩家切换、公告、捐赠鸣谢及底部功能工具栏。',
},
],
fixed: [
{
'en-US':
'The home page keeps the current 3D skin preview stable when accounts or avatars refresh in the background, avoiding unnecessary texture reloads and model flicker.',
'zh-CN':
'后台刷新账号或头像时保持当前 3D 皮肤预览稳定,避免无关刷新导致纹理重新加载和模型闪烁。',
},
],
},
},
{
id: 'launcher-1.0.2-beta12',
version: '1.0.2-beta12',
publishedAt: '2026-10-21',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta12',
'zh-CN': 'Starlight Launcher 1.0.2-beta12',
},
changes: {
fixed: [
{
'en-US':
'Fixed automatic updates ignoring server rollbacks to an earlier launcher version.',
'zh-CN': '修复启动器忽略服务端旧版本回滚的问题,回滚后可自动下载并在退出或重启时安装。',
},
{
'en-US':
'Fixed outdated update checks and downloads overriding a newly selected update version.',
'zh-CN': '修复旧的更新检查或下载结果覆盖新目标版本的问题。',
},
],
},
notes: {
'en-US':
'Install this version once to enable automatic rollback support for subsequent updates.',
'zh-CN': '需要先安装一次此版本,之后才能自动跟随服务端回滚。',
},
},
{
id: 'launcher-1.0.2-beta11',
version: '1.0.2-beta11',
publishedAt: '2026-10-20',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta11',
'zh-CN': 'Starlight Launcher 1.0.2-beta11',
},
changes: {
fixed: [
{
'en-US':
'Filled missing Chinese translations across skin site sign-in, announcements, verification, registration, account settings, downloads and maps, and localized remaining user and Wiki labels.',
'zh-CN':
'补齐皮肤站登录、公告、验证、注册、账号设置、下载和地图等界面的中文翻译,并修复用户信息与维基权限选项中的英文标签。',
},
{
'en-US':
'Renew the saved skin site JWT on launcher startup before loading the user and announcements. Fixed overlapping refresh, sign-in and sign-out requests that could leave the account in an incorrect state.',
'zh-CN':
'启动启动器时先刷新已保存的皮肤站 JWT,再加载用户与公告;修复刷新、登录和退出请求交错时可能导致的账号状态异常。',
},
{
'en-US':
'Announcements now reload after sign-in, show a sign-in hint when signed out, and offer a retry action after a loading failure.',
'zh-CN': '公告在登录后自动重新加载,未登录时显示登录提示,加载失败时可直接重试。',
},
],
changed: [
{
'en-US':
'Skin site release publishing now uses chunked uploads and reports synchronization failures explicitly. Localization checks also scan message IDs used in source to catch missing catalog entries.',
'zh-CN':
'皮肤站自动发布改用分片上传,同步失败会明确报错;国际化检查新增源码词条扫描,防止再次遗漏语言包内容。',
},
],
},
notes: {
'en-US':
'If the previously saved JWT has already expired, sign in again once to restore the session.',
'zh-CN': '如果此前保存的 JWT 已经过期,需要重新登录一次以恢复会话。',
},
},
{
id: 'launcher-1.0.2-beta10',
version: '1.0.2-beta10',
publishedAt: '2026-10-20',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta10',
'zh-CN': 'Starlight Launcher 1.0.2-beta10',
},
changes: {
security: [
{
'en-US':
'Backported the upstream Axolotl modpack path traversal fix to prevent malicious modpacks from writing files outside the intended installation directory.',
'zh-CN':
'合入上游 Axolotl 整合包路径穿越安全补丁,阻止恶意整合包向安装目录之外写入文件。',
},
{
'en-US':
'Strengthened path validation for Modrinth and CurseForge modpacks, server installations, cached files and modpack uninstall cleanup, including unsafe Windows path aliases.',
'zh-CN':
'加强 Modrinth 与 CurseForge 整合包、服务端安装、缓存文件及整合包卸载清理的路径校验,拦截不安全的 Windows 路径别名。',
},
],
},
notes: {
'en-US':
'Users of earlier versions should update promptly, especially before installing modpacks from untrusted sources.',
'zh-CN': '建议旧版本用户尽快更新,尤其是在安装来源不明的整合包之前。',
},
},
{
id: 'launcher-1.0.2-beta9',
version: '1.0.2-beta9',
publishedAt: '2026-10-19',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta9',
'zh-CN': 'Starlight Launcher 1.0.2-beta9',
},
changes: {
added: [
{
'en-US':
'Finished the skin site admin console: system overview, announcements, user and player management, review paper editor, mod upload with batch operations, chat room management with real-time voice, servers, audit log, resource downloads, donations and launcher releases.',
'zh-CN':
'补全皮肤站管理台:系统概览、公告、用户与玩家管理、审核试卷编辑器、Mod 上传与批量操作、聊天室房间管理与实时语音、服务器、审计日志、资源下载、捐赠与启动器版本管理。',
},
{
'en-US':
'Added Wiki management to the admin console, including a MediaWiki migration center that imports pages from an existing wiki and lets you review, publish or roll back the result.',
'zh-CN':
'管理台新增维基管理,包含 MediaWiki 迁移中心:可从现有 Wiki 导入页面,并对结果进行审核、发布或回滚。',
},
{
'en-US':
'Added a read-only Wiki reader in the launcher: render pages, follow links between entries, and keep a back stack while browsing.',
'zh-CN': '启动器新增维基阅读页:渲染词条正文、跟随词条链接跳转,并在浏览时保留返回栈。',
},
],
changed: [
{
'en-US':
'Releases are now published to the skin site automatically when a tag is pushed, so the download page stays in sync without a manual upload.',
'zh-CN': '推送 tag 发版时会自动把版本同步到皮肤站,下载页无需再手动上传即可保持同步。',
},
{
'en-US': 'Server management pages are only shown to web administrators.',
'zh-CN': '服务器管理页仅对网页管理员展示。',
},
],
fixed: [
{
'en-US': 'Fixed the Chinese and English text on the skin site home navigation cards.',
'zh-CN': '修复皮肤站主页导航卡片的中英文案。',
},
{
'en-US': 'Renamed the public download and map endpoints to resolve a duplicate export.',
'zh-CN': '重命名公开下载与地图接口,解决导出冲突。',
},
],
},
},
{
id: 'launcher-1.0.2-beta8',
version: '1.0.2-beta8',
publishedAt: '2026-10-18',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta8',
'zh-CN': 'Starlight Launcher 1.0.2-beta8',
},
changes: {
added: [
{
'en-US':
'The skin site is now fully native inside the launcher: sign-in, registration, password reset, account settings, players, downloads, world map and player review all run through the launcher UI instead of an embedded web page.',
'zh-CN':
'皮肤站全面改为启动器原生界面:登录、注册、找回密码、账号设置、角色管理、资源下载、服务器地图与玩家审核均不再依赖内嵌网页。',
},
{
'en-US':
'Added a bug tracker: browse and filter issues, read details and comments, post replies, create new issues and follow notifications.',
'zh-CN':
'新增问题追踪:可浏览与筛选问题、查看详情与评论、发表回复、提交新问题并接收通知。',
},
{
'en-US':
'Added an admin console with a permission gate: system overview, announcements, users, players, player review and launcher releases.',
'zh-CN':
'新增管理控制台(带权限校验):系统概览、公告管理、用户管理、玩家管理、玩家审核与启动器版本管理。',
},
],
changed: [
{
'en-US':
'Hosted modpack downloads now go through the dedicated download server, keeping large downloads off the main API host.',
'zh-CN': '整合包下载改为走独立的下载服务器,避免大文件下载挤占主 API 服务。',
},
],
fixed: [
{
'en-US':
'LogShare now collects logs with a lightweight reader, fixing crash log uploads that previously failed.',
'zh-CN': 'LogShare 改用轻量日志收集,修复崩溃日志上传失败的问题。',
},
{
'en-US': 'The onboarding tour now scrolls each step into view when the step changes.',
'zh-CN': '引导教程在切换步骤时自动滚动到目标元素。',
},
{
'en-US': 'Fixed a type mismatch while collecting crash context credentials.',
'zh-CN': '修复崩溃上下文凭据收集中的类型不匹配问题。',
},
],
},
},
{
id: 'launcher-1.0.2-beta7',
version: '1.0.2-beta7',
publishedAt: '2026-10-17',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta7',
'zh-CN': 'Starlight Launcher 1.0.2-beta7',
},
changes: {
fixed: [
{
'en-US': 'Fixed real-time log backlog that could stall the console under heavy output.',
'zh-CN': '修复实时日志积压导致控制台卡顿的问题。',
},
{
'en-US':
'The update channel now defaults to the build type: installing a Beta build starts on the Beta channel instead of Release.',
'zh-CN':
'更新通道默认值改为跟随安装包版本:安装 Beta 版后默认使用 Beta 通道,不再回落为 Release。',
},
{
'en-US':
'On Linux, the default game directory for StarLight instances no longer points at the system install path (e.g. /usr/bin); it falls back to the home directory.',
'zh-CN':
'Linux 上 StarLight 实例的默认游戏目录不再指向系统安装路径(如 /usr/bin),改为回退到用户主目录。',
},
],
},
},
{
id: 'launcher-1.0.2-beta6',
version: '1.0.2-beta6',
publishedAt: '2026-10-11',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta6',
'zh-CN': 'Starlight Launcher 1.0.2-beta6',
},
changes: {
added: [
{
'en-US':
'Added keyboard shortcut settings: enable and rebind navigation and quick-scrolling shortcuts.',
'zh-CN': '新增键盘快捷键设置:可启用并自定义导航与快速滚动快捷键。',
},
{
'en-US':
'Added crash log sharing and AI analysis: share crash diagnostics to LogShare for a structured summary and optional AI analysis, with a rebuilt crash window that browses log files and streams the analysis.',
'zh-CN':
'新增崩溃日志分享与 AI 分析:可将崩溃诊断分享到 LogShare 获取结构化摘要与 AI 分析;崩溃窗口重做为双栏布局,可浏览日志文件并流式查看分析结果。',
},
{
'en-US':
'Crash logs now also collect JVM crash reports (hs_err_pid*.log) and launcher logs.',
'zh-CN': '崩溃日志现在也会收集 JVM 崩溃报告(hs_err_pid*.log)与启动器日志。',
},
{
'en-US': 'Added a launcher log export entry.',
'zh-CN': '新增启动器日志导出入口。',
},
{
'en-US': 'Instance groups can now be reordered by dragging.',
'zh-CN': '实例分组支持拖拽重排。',
},
],
changed: [
{
'en-US': 'Synced the latest supported AI chat model list from LobeHub.',
'zh-CN': '同步 LobeHub 最新的 AI 聊天模型列表。',
},
],
fixed: [
{
'en-US': 'Fixed remote announcements not refreshing.',
'zh-CN': '修复远端公告不刷新的问题。',
},
{
'en-US': 'Completed the Chinese translations for the armor preview feature.',
'zh-CN': '补齐盔甲预览功能的中文翻译。',
},
],
},
},
{
id: 'launcher-1.0.2-beta5',
version: '1.0.2-beta5',
@@ -43,12 +357,16 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US': 'Added a remote announcements center: a bell in the top bar opens a panel listing launcher announcements fetched from the Starlight skin site.',
'zh-CN': '新增远程公告中心:顶栏的铃铛可打开公告面板,展示从 Starlight 皮肤站拉取的启动器公告。',
'en-US':
'Added a remote announcements center: a bell in the top bar opens a panel listing launcher announcements fetched from the Starlight skin site.',
'zh-CN':
'新增远程公告中心:顶栏的铃铛可打开公告面板,展示从 Starlight 皮肤站拉取的启动器公告。',
},
{
'en-US': 'Added a custom Minecraft window title option: set a global toggle and default title, and let each instance override it. The game window is renamed on Windows.',
'zh-CN': '新增自定义 Minecraft 窗口标题:可设置全局开关与默认标题,并允许各实例单独覆盖;Windows 下会重命名游戏窗口。',
'en-US':
'Added a custom Minecraft window title option: set a global toggle and default title, and let each instance override it. The game window is renamed on Windows.',
'zh-CN':
'新增自定义 Minecraft 窗口标题:可设置全局开关与默认标题,并允许各实例单独覆盖;Windows 下会重命名游戏窗口。',
},
],
fixed: [
@@ -70,24 +388,31 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US': 'Added armor and armor-trim rendering to the skin preview, letting you try on armor pieces and trim patterns directly in the skin editor.',
'zh-CN': '皮肤预览新增盔甲与盔甲纹饰渲染,可在皮肤编辑器内直接试穿各部位盔甲并调整纹饰图案。',
'en-US':
'Added armor and armor-trim rendering to the skin preview, letting you try on armor pieces and trim patterns directly in the skin editor.',
'zh-CN':
'皮肤预览新增盔甲与盔甲纹饰渲染,可在皮肤编辑器内直接试穿各部位盔甲并调整纹饰图案。',
},
{
'en-US': 'Armor preview selections in the skin editor are now saved locally, so they persist across sessions.',
'en-US':
'Armor preview selections in the skin editor are now saved locally, so they persist across sessions.',
'zh-CN': '皮肤编辑器中的盔甲预览选择现在会保存在本地,跨会话保留。',
},
],
changed: [
{
'en-US': 'Improved download scheduling to reduce concurrency automatically when tasks are waiting for capacity, making large Minecraft installations more reliable on constrained connections.',
'zh-CN': '优化下载任务调度:任务因容量不足排队时会自动降低并发,提升大型 Minecraft 版本在受限网络环境下的安装稳定性。',
'en-US':
'Improved download scheduling to reduce concurrency automatically when tasks are waiting for capacity, making large Minecraft installations more reliable on constrained connections.',
'zh-CN':
'优化下载任务调度:任务因容量不足排队时会自动降低并发,提升大型 Minecraft 版本在受限网络环境下的安装稳定性。',
},
],
fixed: [
{
'en-US': 'Fixed downloads timing out while waiting for internal connection capacity, which could cause Minecraft installations to fail even when the network was available.',
'zh-CN': '修复下载任务等待内部连接容量时过早超时,导致网络正常的情况下 Minecraft 安装仍可能失败的问题。',
'en-US':
'Fixed downloads timing out while waiting for internal connection capacity, which could cause Minecraft installations to fail even when the network was available.',
'zh-CN':
'修复下载任务等待内部连接容量时过早超时,导致网络正常的情况下 Minecraft 安装仍可能失败的问题。',
},
{
'en-US': 'Fixed instance group changes not saving for some instances.',
@@ -107,8 +432,10 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
fixed: [
{
'en-US': 'Fixed the anniversary celebration banner not showing above the window title bar, and made the banner background semi-transparent so the content behind it stays visible.',
'zh-CN': '修复周年庆庆祝横幅被窗口标题栏遮挡、无法显示的问题,并将横幅背景改为半透明,避免遮住下方内容。',
'en-US':
'Fixed the anniversary celebration banner not showing above the window title bar, and made the banner background semi-transparent so the content behind it stays visible.',
'zh-CN':
'修复周年庆庆祝横幅被窗口标题栏遮挡、无法显示的问题,并将横幅背景改为半透明,避免遮住下方内容。',
},
],
},
@@ -124,8 +451,10 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
fixed: [
{
'en-US': 'Fixed the launcher failing to start with "no such table: synced_pack_catalog": the cross-instance sync migration was missing the synced_pack_catalog and synced_pack_instances tables.',
'zh-CN': '修复启动器因 "no such table: synced_pack_catalog" 无法启动的问题:跨实例同步迁移遗漏了 synced_pack_catalog 与 synced_pack_instances 两张表。',
'en-US':
'Fixed the launcher failing to start with "no such table: synced_pack_catalog": the cross-instance sync migration was missing the synced_pack_catalog and synced_pack_instances tables.',
'zh-CN':
'修复启动器因 "no such table: synced_pack_catalog" 无法启动的问题:跨实例同步迁移遗漏了 synced_pack_catalog 与 synced_pack_instances 两张表。',
},
],
},
@@ -141,15 +470,20 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US': 'Cross-instance sync: share game options, resource packs, data packs, multiplayer servers, command history, and creative hotbars across instances, with per-option toggles and a fine-grained game-settings editor.',
'zh-CN': '跨实例同步:可跨实例共享游戏选项、资源包、数据包、多人服务器、命令历史与创造模式快捷栏,支持逐项开关与精细的游戏设置编辑器。',
'en-US':
'Cross-instance sync: share game options, resource packs, data packs, multiplayer servers, command history, and creative hotbars across instances, with per-option toggles and a fine-grained game-settings editor.',
'zh-CN':
'跨实例同步:可跨实例共享游戏选项、资源包、数据包、多人服务器、命令历史与创造模式快捷栏,支持逐项开关与精细的游戏设置编辑器。',
},
{
'en-US': 'Starlight turns 12! On October 4th every year the launcher now shows a celebration banner and falling confetti. The anniversary number is computed from 2014.',
'zh-CN': 'Starlight 十二周年!每年 10 月 4 日,启动器会显示庆祝横幅与飘落的彩带、蛋糕,周年数按 2014 年起算。',
'en-US':
'Starlight turns 12! On October 4th every year the launcher now shows a celebration banner and falling confetti. The anniversary number is computed from 2014.',
'zh-CN':
'Starlight 十二周年!每年 10 月 4 日,启动器会显示庆祝横幅与飘落的彩带、蛋糕,周年数按 2014 年起算。',
},
{
'en-US': 'Added a developer feature flag to preview the anniversary celebration at any time.',
'en-US':
'Added a developer feature flag to preview the anniversary celebration at any time.',
'zh-CN': '新增开发者功能开关,可随时预览周年庆效果。',
},
],
@@ -166,8 +500,10 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US': 'Instance backups: create deduplicated snapshots of an instance while it is closed, preview and restore them, exclude specific files or folders, and move the backup repository.',
'zh-CN': '实例备份:在实例关闭时创建去重快照,可预览与恢复,支持排除指定文件或文件夹,并可迁移备份仓库。',
'en-US':
'Instance backups: create deduplicated snapshots of an instance while it is closed, preview and restore them, exclude specific files or folders, and move the backup repository.',
'zh-CN':
'实例备份:在实例关闭时创建去重快照,可预览与恢复,支持排除指定文件或文件夹,并可迁移备份仓库。',
},
{
'en-US': "Customize the launcher's UI font and monospace font in appearance settings.",
@@ -176,12 +512,16 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
],
fixed: [
{
'en-US': 'Fixed beta builds not auto-updating from beta9 to beta10 (prerelease versions were compared as text instead of by number).',
'zh-CN': '修复 beta 版本无法从 beta9 自动更新到 beta10 的问题(预发布版本此前按文本而非数字比较)。',
'en-US':
'Fixed beta builds not auto-updating from beta9 to beta10 (prerelease versions were compared as text instead of by number).',
'zh-CN':
'修复 beta 版本无法从 beta9 自动更新到 beta10 的问题(预发布版本此前按文本而非数字比较)。',
},
{
'en-US': 'Fixed a cross-crate visibility issue that prevented the launcher from compiling with the ignore-SSL-errors option.',
'zh-CN': '修复因跨 crate 可见性导致的编译失败,此前启用“忽略 SSL 证书错误”选项时无法编译。',
'en-US':
'Fixed a cross-crate visibility issue that prevented the launcher from compiling with the ignore-SSL-errors option.',
'zh-CN':
'修复因跨 crate 可见性导致的编译失败,此前启用“忽略 SSL 证书错误”选项时无法编译。',
},
],
},
@@ -197,11 +537,13 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US': 'Reworked the instance-page tour into four steps covering content, files, worlds, and logs.',
'en-US':
'Reworked the instance-page tour into four steps covering content, files, worlds, and logs.',
'zh-CN': '重做实例页导览,分为内容、文件、世界、日志四步。',
},
{
'en-US': 'When the install location is a drive root, the installer now creates a Starlight subfolder automatically.',
'en-US':
'When the install location is a drive root, the installer now creates a Starlight subfolder automatically.',
'zh-CN': '安装位置选到盘符根目录时,安装器会自动创建 Starlight 子文件夹。',
},
],
@@ -215,11 +557,13 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'zh-CN': '修复开发者功能开关无法保存的问题。',
},
{
'en-US': 'Fixed instance group edits not saving for instances without an applied content set.',
'en-US':
'Fixed instance group edits not saving for instances without an applied content set.',
'zh-CN': '修复没有应用内容集的实例无法保存分组修改的问题。',
},
{
'en-US': 'Fixed the launcher crashing on startup when feature flags were out of sync between the frontend and backend.',
'en-US':
'Fixed the launcher crashing on startup when feature flags were out of sync between the frontend and backend.',
'zh-CN': '修复前后端功能开关不同步导致启动器启动崩溃的问题。',
},
{
@@ -336,12 +680,10 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
'en-US':
'Fixed the onboarding tour skipping steps automatically, getting stuck, or not navigating between steps; the tour bubble no longer covers the left navigation bar.',
'zh-CN':
'修复新手导览自动跳过、卡住或不跳转的问题;导览气泡不再遮挡左侧导航栏。',
'zh-CN': '修复新手导览自动跳过、卡住或不跳转的问题;导览气泡不再遮挡左侧导航栏。',
},
{
'en-US':
'Fixed the onboarding tour target on the minimal home layout.',
'en-US': 'Fixed the onboarding tour target on the minimal home layout.',
'zh-CN': '修复精简主页下导览目标找不到的问题。',
},
],
@@ -372,13 +714,11 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
],
fixed: [
{
'en-US':
'Fixed the developer feature flags page rendering blank.',
'en-US': 'Fixed the developer feature flags page rendering blank.',
'zh-CN': '修复开发者“功能开关”页面空白的问题。',
},
{
'en-US':
'Fixed a build error that prevented the launcher from compiling.',
'en-US': 'Fixed a build error that prevented the launcher from compiling.',
'zh-CN': '修复导致启动器无法编译的构建错误。',
},
],
@@ -395,8 +735,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
fixed: [
{
'en-US':
'Fixed a build error in 1.0.1-beta3 that prevented the launcher from compiling.',
'en-US': 'Fixed a build error in 1.0.1-beta3 that prevented the launcher from compiling.',
'zh-CN': '修复 1.0.1-beta3 中导致启动器无法编译的构建错误。',
},
],
@@ -455,8 +794,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
],
fixed: [
{
'en-US':
'Improved the reliability of live logs and database switching.',
'en-US': 'Improved the reliability of live logs and database switching.',
'zh-CN': '提高实时日志与数据库切换的可靠性。',
},
],
@@ -548,15 +886,13 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'zh-CN': '修复更新清单的下载地址与发布顺序,更新检查现在可以成功。',
},
{
'en-US':
'Updated the signing key pair used to sign update packages.',
'en-US': 'Updated the signing key pair used to sign update packages.',
'zh-CN': '更新了用于签名更新包的密钥对。',
},
],
changed: [
{
'en-US':
'Removed upstream release announcements prior to 1.0.0-beta1.',
'en-US': 'Removed upstream release announcements prior to 1.0.0-beta1.',
'zh-CN': '移除了 1.0.0-beta1 之前的上游版本公告。',
},
],
@@ -573,13 +909,11 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: {
added: [
{
'en-US':
'Installing a modpack now lets you choose the game directory.',
'en-US': 'Installing a modpack now lets you choose the game directory.',
'zh-CN': '一键安装整合包时现在可以选择游戏目录。',
},
{
'en-US':
'Approved (hosted) modpack sync and StarLight instance mode were added.',
'en-US': 'Approved (hosted) modpack sync and StarLight instance mode were added.',
'zh-CN': '新增获批整合包同步与 StarLight 实例模式。',
},
{
@@ -590,8 +924,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
],
changed: [
{
'en-US':
'The about page now plays a looping wither victory animation.',
'en-US': 'The about page now plays a looping wither victory animation.',
'zh-CN': '关于页现在会播放循环的凋灵胜利动画。',
},
],
@@ -603,8 +936,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'修复从 .minecraft 文件夹导入实例的多项问题(内容复制、版本隔离、符号链接、加载器版本识别等)。',
},
{
'en-US':
'Fixed the instance log view stopping updates and overlapping log lines.',
'en-US': 'Fixed the instance log view stopping updates and overlapping log lines.',
'zh-CN': '修复实例日志窗口停更与日志行重叠的问题。',
},
{
@@ -623,8 +955,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'zh-CN': '修复未登录皮肤站时启动卡住且不再弹出玩家选择的问题。',
},
{
'en-US':
'Fixed various skin site login, player list sync, and display issues.',
'en-US': 'Fixed various skin site login, player list sync, and display issues.',
'zh-CN': '修复皮肤站登录、玩家列表同步及若干显示问题。',
},
],
@@ -643,7 +974,8 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
'en-US':
'The launcher has been rebranded from Axolotl Launcher to Starlight Launcher, with a new icon and a refreshed about page.',
'zh-CN': '启动器已从 Axolotl Launcher 更名为 Starlight Launcher,并更换了全新图标与关于页。',
'zh-CN':
'启动器已从 Axolotl Launcher 更名为 Starlight Launcher,并更换了全新图标与关于页。',
},
{
'en-US':
@@ -657,7 +989,8 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
],
added: [
{
'en-US': 'A "Starlight" entry was added to the sidebar, which embeds the StarLight skin site.',
'en-US':
'A "Starlight" entry was added to the sidebar, which embeds the StarLight skin site.',
'zh-CN': '侧边栏新增「斯达莱特」入口,内嵌 StarLight 皮肤站。',
},
{
@@ -676,10 +1009,12 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
'en-US':
'The hard-coded Starlight official server was removed from "Pinned servers"; only servers you favorite yourself are shown now.',
'zh-CN': '移除了「固定的服务器」中写死的 Starlight 官方服务器,现在只显示你自己收藏的服务器。',
'zh-CN':
'移除了「固定的服务器」中写死的 Starlight 官方服务器,现在只显示你自己收藏的服务器。',
},
{
'en-US': 'The swimming axolotl animation on the about page was replaced with a static nether star.',
'en-US':
'The swimming axolotl animation on the about page was replaced with a static nether star.',
'zh-CN': '关于页中游动的美西螈动画已替换为静止的下界之星。',
},
],
@@ -1,11 +1,10 @@
<script setup lang="ts">
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import SkinSiteLoginModal from '@/components/ui/skin-site/SkinSiteLoginModal.vue'
import {
openSkinSiteLogin,
requestSkinSitePlayers,
skinSitePlayers,
skinSiteStatus,
@@ -28,7 +27,7 @@ const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const microsoftLogin = ref<InstanceType<typeof MinecraftLoginModal>>()
const router = useRouter()
const skinSiteLoginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
const accounts = ref<PlayerChoice[]>([])
const active = ref(false)
const loading = ref(false)
@@ -137,9 +136,8 @@ function signInSkinSite() {
rejectSelection?.(new PlayerSelectionNavigatedAwayError())
resolveSelection = undefined
rejectSelection = undefined
openSkinSiteLogin()
skinSiteLoginModal.value?.show()
modal.value?.hide()
void router.push('/starlight-skin')
}
const unregister = registerInstancePlayerPicker((id, saved) => {
@@ -246,5 +244,6 @@ onUnmounted(() => {
<p v-if="saving" class="m-0" role="status">{{ formatMessage(messages.saving) }}</p>
</div>
</NewModal>
<SkinSiteLoginModal ref="skinSiteLoginModal" @signed-in="refresh" />
<MinecraftLoginModal ref="microsoftLogin" @complete="refresh" />
</template>
@@ -5,12 +5,6 @@ import test from 'node:test'
import { createRenderer, h, nextTick, Suspense } from 'vue'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
} from '../../composables/skin-site-session.ts'
import { useNetworkStatus } from '../../composables/useNetworkStatus.ts'
// Exercise the real component's setup and reactive account list. Only platform,
@@ -19,6 +13,7 @@ const require = createRequire(import.meta.url)
const { parse, compileScript } = require('vue/compiler-sfc')
const componentUrl = new URL('./AccountsCard.vue', import.meta.url).href
const stubs: Record<string, string> = {
'@tauri-apps/plugin-http': `export const fetch=(...args)=>globalThis.accountTestFetch(...args)`,
'@modrinth/assets': `export const CopyIcon={}, LogInIcon={}, PlusIcon={}, RadioButtonCheckedIcon={}, RadioButtonIcon={}, RefreshCwIcon={}, SpinnerIcon={}, TrashIcon={}`,
'@modrinth/ui': `export const Accordion={}, Avatar={}, ButtonStyled={}; export const defineMessages=x=>x; export const injectNotificationManager=()=>({handleError:()=>{}}); export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})`,
'@tanstack/vue-query': `export const useQueryClient=()=>({refetchQueries:async()=>{}})`,
@@ -26,6 +21,7 @@ const stubs: Record<string, string> = {
'vue-router': `export const useRoute=()=>({fullPath:'/'}); export const useRouter=()=>({push:async()=>{}})`,
'@/assets/netherstar.png': `export default 'logo'`,
'@/components/ui/MinecraftLoginModal.vue': `export default {}`,
'@/components/ui/skin-site/SkinSiteLoginModal.vue': `export default {}`,
'@/helpers/events': `export const process_listener=async()=>()=>{}`,
'@/helpers/rendering/batch-skin-renderer.ts': `export const getPlayerHeadUrl=async()=> 'head'`,
'@/helpers/skins': `export const get_available_skins=async()=>[]`,
@@ -59,6 +55,13 @@ const hooks = registerHooks({
},
})
const { default: AccountsCard } = await import(componentUrl)
const {
resetSkinSiteSession,
signInSkinSite,
signOutSkinSite,
selectedSkinSitePlayerId,
SKIN_SITE_ORIGIN,
} = await import('../../composables/skin-site-session.ts')
hooks.deregister()
type TestNode = Record<string, unknown>
@@ -72,6 +75,7 @@ type CardState = {
defaultUser: string | undefined
selectedAccount: TestAccount | undefined
setAccount: (account: TestAccount) => Promise<void>
setSkinSitePlayer: (id: string) => Promise<void>
getAccountAvatarUrl: (account: TestAccount) => string | undefined
}
const renderer = createRenderer<TestNode, TestNode>({
@@ -93,37 +97,39 @@ const flush = async () => {
const firstId = '12345678-1234-1234-1234-123456789abc'
const secondId = '22345678-1234-1234-1234-123456789abc'
async function fixture(hasOfficial = true) {
async function fixture(hasOfficial = true, officialId = 'official', linkedMojang = false) {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
const stored: TestAccount[] = hasOfficial
? [{ account_type: 'microsoft', profile: { id: 'official', name: 'Official' } }]
? [{ account_type: 'microsoft', profile: { id: officialId, name: 'Official' } }]
: []
let selected: string | undefined = hasOfficial ? 'official' : undefined
let selected: string | undefined = hasOfficial ? officialId : undefined
let rejectLogin = false
let loginCalls = 0
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type === 'starlight-pack-token-request')
message({
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'fixture.jwt',
})
if (data.type === 'starlight-skin-players-request')
message({
type: 'starlight-skin-players-result',
requestId: data.requestId,
ok: true,
players: [
{ uuid: firstId, name: 'First', isMojang: false, skinState: 'empty' },
{ uuid: secondId, name: 'Second', isMojang: false, skinState: 'empty' },
],
})
const storage = new Map<string, string>()
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
} as unknown as Window
function message(data: unknown) {
receiveSkinSiteMessage({ origin: SKIN_SITE_ORIGIN, source: frame, data } as MessageEvent, frame)
})
;(
globalThis as unknown as { accountTestFetch: (url: string) => Promise<Response> }
).accountTestFetch = async (url) => {
const path = new URL(url).pathname
const payload = path.endsWith('/login')
? { username: 'Owner', jwt: 'fixture.jwt' }
: path.endsWith('/user')
? { uuid: 'owner', username: 'Owner', permissions: [] }
: path.endsWith('/player')
? [
{ uuid: firstId, name: 'First', isMojang: linkedMojang || firstId === officialId },
{ uuid: secondId, name: 'Second', isMojang: false },
]
: {}
return Response.json({ payload })
}
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
useNetworkStatus().refreshBrowserOffline()
@@ -163,22 +169,18 @@ async function fixture(hasOfficial = true) {
},
})
resetSkinSiteSession()
setSkinSiteFrame(frame)
const node = h({ ...AccountsCard, render: () => null })
const app = renderer.createApp({ render: () => h(Suspense, null, { default: () => node }) })
app.mount({})
await flush()
return {
state: (node.component! as unknown as { setupState: CardState }).setupState,
login() {
message({
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'owner', username: 'Owner' },
})
async login() {
await signInSkinSite({ username: 'Owner', password: 'password', clientId: 'test' })
await flush()
},
signOut() {
message({ type: 'starlight-skin-session', status: 'signed-out' })
signOutSkinSite()
},
failLogin(value: boolean) {
rejectLogin = value
@@ -192,7 +194,7 @@ async function fixture(hasOfficial = true) {
close() {
app.unmount()
resetSkinSiteSession()
setSkinSiteFrame(null)
Reflect.deleteProperty(globalThis, 'localStorage')
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else Reflect.deleteProperty(globalThis, 'window')
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
@@ -205,7 +207,7 @@ test('sidebar offers every skin-site player before any game authentication or la
const view = await fixture(false)
try {
view.failLogin(true)
view.login()
await view.login()
await flush()
assert.deepEqual(
view.state.accounts.map((account) => account.profile.id),
@@ -221,7 +223,7 @@ test('sidebar offers every skin-site player before any game authentication or la
test('choosing an unused skin-site player authenticates and persists the default without a launch', async () => {
const view = await fixture()
try {
view.login()
await view.login()
await flush()
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === secondId)!,
@@ -238,7 +240,7 @@ test('choosing an unused skin-site player authenticates and persists the default
test('failed player authentication preserves the default and allows retry', async () => {
const view = await fixture()
try {
view.login()
await view.login()
await flush()
view.failLogin(true)
await view.state.setAccount(
@@ -259,7 +261,7 @@ test('failed player authentication preserves the default and allows retry', asyn
test('signing out removes unregistered choices while keeping saved accounts', async () => {
const view = await fixture()
try {
view.login()
await view.login()
await flush()
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === firstId)!,
@@ -271,7 +273,7 @@ test('signing out removes unregistered choices while keeping saved accounts', as
[firstId, 'official'],
)
assert.equal(view.selected, firstId)
view.login()
await view.login()
await flush()
assert.equal(view.state.accounts.length, 3)
assert.equal(view.loginCalls, 1)
@@ -283,10 +285,71 @@ test('signing out removes unregistered choices while keeping saved accounts', as
test('a new player without a skin can render the shared empty avatar', async () => {
const view = await fixture(false)
try {
view.login()
await view.login()
await flush()
assert.equal(view.state.getAccountAvatarUrl(view.state.accounts[0]), undefined)
} finally {
view.close()
}
})
test('choosing a player from the home avatar rail switches the persisted account and shared skin selection', async () => {
const view = await fixture()
try {
await view.login()
await view.state.setSkinSitePlayer(secondId)
assert.equal(view.selected, secondId)
assert.equal(view.state.selectedAccount?.profile.id, secondId)
assert.equal(selectedSkinSitePlayerId.value, secondId)
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === 'official')!,
)
assert.equal(view.selected, 'official')
assert.equal(selectedSkinSitePlayerId.value, null)
} finally {
view.close()
}
})
test('failed home player selection keeps the current skin selection as well as the account', async () => {
const view = await fixture()
try {
await view.login()
await view.state.setSkinSitePlayer(firstId)
view.failLogin(true)
await view.state.setSkinSitePlayer(secondId)
assert.equal(view.selected, firstId)
assert.equal(selectedSkinSitePlayerId.value, firstId)
} finally {
view.close()
}
})
test('a Microsoft player linked on the skin site keeps official skin and cape management', async () => {
const view = await fixture(true, firstId)
try {
await view.login()
assert.equal(selectedSkinSitePlayerId.value, null)
await view.state.setSkinSitePlayer(secondId)
await view.state.setAccount(
view.state.accounts.find((account) => account.profile.id === firstId)!,
)
assert.equal(view.selected, firstId)
assert.equal(selectedSkinSitePlayerId.value, null)
} finally {
view.close()
}
})
test('site players linked to Mojang remain selectable without a saved Microsoft credential', async () => {
const view = await fixture(false, 'official', true)
try {
await view.login()
assert.equal(view.state.accounts.length, 2)
await view.state.setSkinSitePlayer(firstId)
assert.equal(view.selected, firstId)
assert.equal(selectedSkinSitePlayerId.value, firstId)
} finally {
view.close()
}
})
@@ -244,6 +244,7 @@
</div>
</Accordion>
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
<SkinSiteLoginModal ref="skinSiteLoginModal" @signed-in="onSkinSiteSignedIn" />
</template>
<script setup lang="ts">
@@ -269,12 +270,12 @@ import { useQueryClient } from '@tanstack/vue-query'
import { listen } from '@tauri-apps/api/event'
import type { Ref } from 'vue'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute } from 'vue-router'
import axolotlLogo from '@/assets/netherstar.png'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import SkinSiteLoginModal from '@/components/ui/skin-site/SkinSiteLoginModal.vue'
import {
openSkinSiteLogin,
requestSkinSitePlayers,
selectedSkinSitePlayerId,
selectSkinSitePlayer,
@@ -298,21 +299,20 @@ import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins'
import { get_available_skins } from '@/helpers/skins'
import { handleSevereError } from '@/store/error.js'
import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const { browserOffline, offline, refreshBrowserOffline } = useNetworkStatus()
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
const themeStore = useTheming()
const skinSiteLoginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
async function goToSkinSiteLogin() {
openSkinSiteLogin()
// The login action must reveal the iframe even if Minimal Home was selected.
themeStore.homeLayout = 'standard'
await router.push('/').catch(handleError)
skinSiteLoginModal.value?.show()
}
function onSkinSiteSignedIn() {
void requestSkinSitePlayers().catch(() => {})
}
const refreshingNetwork = ref(false)
@@ -373,7 +373,6 @@ const accounts = computed(() => {
)
if (skinSiteStatus.value === 'signed-in' && skinSiteUser.value) {
for (const player of skinSitePlayers.value) {
if (player.isMojang) continue
const id = player.uuid.replaceAll('-', '').toLowerCase()
const stored = byId.get(id)
if (stored && stored.account_type !== 'yggdrasil') continue
@@ -409,8 +408,12 @@ async function refreshSkinSitePlayers() {
await requestSkinSitePlayers().catch(() => {})
}
function setSkinSitePlayer(playerId: string) {
selectSkinSitePlayer(playerId)
async function setSkinSitePlayer(playerId: string) {
const normalizedId = playerId.replaceAll('-', '').toLowerCase()
const account = accounts.value.find(
(candidate) => candidate.profile.id.replaceAll('-', '').toLowerCase() === normalizedId,
)
if (account) await setAccount(account)
}
const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
@@ -531,6 +534,12 @@ function setLoginDisabled(value: boolean) {
}
defineExpose({
accounts,
defaultUser,
accountSelectionPending,
setAccount,
getAccountSkin,
getAccountAvatarUrl,
accountChangeRevision,
login,
refreshValues,
@@ -669,7 +678,15 @@ async function setAccount(account: MinecraftCredential) {
})
}
await persistDefaultUser(userId)
selectSkinSitePlayer(null)
const sitePlayer =
account.account_type === 'yggdrasil'
? skinSitePlayers.value.find(
(player) =>
player.uuid.replaceAll('-', '').toLowerCase() ===
userId.replaceAll('-', '').toLowerCase(),
)
: undefined
selectSkinSitePlayer(sitePlayer?.uuid ?? null)
defaultUser.value = userId
equippedSkin.value = null
notifyAccountChange()
@@ -682,9 +699,20 @@ async function setAccount(account: MinecraftCredential) {
}
watch(
[skinSitePlayers, defaultUser, skinSiteUser],
[skinSitePlayers, defaultUser, skinSiteUser, accounts],
([availablePlayers, selectedLocalUser]) => {
if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
if (selectedLocalUser) {
const account = accounts.value.find((candidate) => candidate.profile.id === selectedLocalUser)
const match =
account?.account_type === 'yggdrasil'
? availablePlayers.find(
(player) =>
player.uuid.replaceAll('-', '').toLowerCase() ===
selectedLocalUser.replaceAll('-', '').toLowerCase(),
)
: undefined
selectSkinSitePlayer(match?.uuid ?? null)
} else if (!selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
selectSkinSitePlayer(availablePlayers[0].uuid)
}
},
File diff suppressed because it is too large. Load diff
@@ -1,23 +0,0 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { skinSiteFrameUrl } from '@/composables/skin-site-session'
const { formatMessage } = useVIntl()
const messages = defineMessages({
frameTitle: {
id: 'app.starlight-skin.frame-title',
defaultMessage: 'StarLight Skin Site',
},
})
</script>
<template>
<iframe
:src="skinSiteFrameUrl"
allow="clipboard-write; clipboard-read"
:title="formatMessage(messages.frameTitle)"
class="block h-full min-h-0 w-full border-0"
/>
</template>
@@ -1,65 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { clearHostedSession } from '@/helpers/hosted-packs'
const frame = ref<HTMLIFrameElement>()
let lastMessage = 0
let expiryTimer: ReturnType<typeof setInterval> | undefined
watch(
[skinSiteStatus, () => skinSiteUser.value?.uuid],
() => {
void clearHostedSession().catch(() => {})
},
{ flush: 'sync' },
)
function connect() {
resetSkinSiteSession()
lastMessage = 0
const contentWindow = frame.value?.contentWindow ?? null
setSkinSiteFrame(contentWindow)
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
}
function receive(event: MessageEvent) {
if (receiveSkinSiteMessage(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
}
onMounted(() => {
window.addEventListener('message', receive)
connect()
expiryTimer = setInterval(() => {
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
}, 10_000)
})
onUnmounted(() => {
window.removeEventListener('message', receive)
clearInterval(expiryTimer)
setSkinSiteFrame(null)
resetSkinSiteSession()
void clearHostedSession().catch(() => {})
})
</script>
<template>
<iframe
ref="frame"
:src="`${SKIN_SITE_ORIGIN}/`"
title="StarLight Skin Site session"
aria-hidden="true"
tabindex="-1"
class="pointer-events-none fixed -left-[10000px] top-0 h-px w-px opacity-0"
@load="connect"
/>
</template>
@@ -0,0 +1,62 @@
export interface CrashLogFile {
age: number
filename: string
log_type: 'InfoLog' | 'CrashReport' | 'JvmCrash' | 'LauncherLog'
output?: string | null
}
const RUN_ASSOCIATION_WINDOW_SECONDS = 3 * 60
export function shouldUseLogShareAutoAnalysis(settings: {
ai_source: 'logshare' | 'custom'
auto_upload: boolean
no_storage?: boolean
}): boolean {
return settings.ai_source === 'logshare' && settings.auto_upload
}
function isRelevantLog(file: CrashLogFile): boolean {
const lower = file.filename.toLowerCase()
if (file.log_type === 'CrashReport') return lower.endsWith('.txt')
if (file.log_type === 'JvmCrash') return lower.startsWith('hs_err') && lower.endsWith('.log')
return ['latest.log', 'debug.log', 'launcher_log.txt', 'latest_stdout.log'].includes(lower)
}
export function selectCrashLogFiles(files: CrashLogFile[]): CrashLogFile[] {
const sorted = files.filter(isRelevantLog).sort((left, right) => right.age - left.age)
const anchor = sorted[0]?.age
if (anchor === undefined) return []
const singletonTypes = new Set<CrashLogFile['log_type']>()
return sorted.filter((file) => {
if (anchor - file.age > RUN_ASSOCIATION_WINDOW_SECONDS) return false
if (file.log_type !== 'CrashReport' && file.log_type !== 'JvmCrash') return true
if (singletonTypes.has(file.log_type)) return false
singletonTypes.add(file.log_type)
return true
})
}
export function crashLogKey(file: CrashLogFile): string {
return `${file.log_type}:${file.filename}`
}
export function crashLogLabel(file: CrashLogFile): string {
return file.log_type === 'CrashReport' ? `crash-reports/${file.filename}` : file.filename
}
export function preferredCrashLogKey(files: CrashLogFile[]): string {
const preferred =
files.find((file) => file.log_type === 'CrashReport') ??
files.find((file) => file.log_type === 'JvmCrash') ??
files.find((file) => file.filename.toLowerCase() === 'latest.log') ??
files[0]
return preferred ? crashLogKey(preferred) : ''
}
export function combineCrashLogs(files: CrashLogFile[]): string {
return files
.filter((file) => file.output)
.map((file) => `===== ${crashLogLabel(file)} =====\n${file.output}`)
.join('\n\n')
}
@@ -197,6 +197,27 @@ export function useOnboardingTour(
requestAnimationFrame(updateRect)
}
// Bring the current step target into view when the tour jumps to a step
// whose target lives inside a scrollable container (e.g. the settings
// sidebar). Without this the bubble can point at an off-screen element.
function ensureTargetVisible() {
const target = targetElement.value
if (!target) return
const rect = target.getBoundingClientRect()
const outOfView =
rect.top < 0 ||
rect.left < 0 ||
rect.bottom > window.innerHeight ||
rect.right > window.innerWidth
if (!outOfView) return
target.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' })
requestAnimationFrame(() => {
if (targetElement.value === target) {
targetRect.value = target.getBoundingClientRect()
}
})
}
function goTo(destination: StepDestination) {
if (destination === 'complete') {
events.complete()
@@ -343,6 +364,7 @@ export function useOnboardingTour(
await nextTick()
updateBubbleSize()
updateTarget()
ensureTargetVisible()
})
watch(mode, () => {
@@ -6,6 +6,8 @@ import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import CrashAnalysisAISettings from './CrashAnalysisAISettings.vue'
import LogShareSettings from './LogShareSettings.vue'
import SharedLogsSettings from './SharedLogsSettings.vue'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
@@ -391,5 +393,7 @@ watch(
</SettingsSection>
<CrashAnalysisAISettings />
<LogShareSettings />
<SharedLogsSettings />
</div>
</template>
@@ -0,0 +1,231 @@
<script setup lang="ts">
import {
defineMessages,
type KeyBinding,
KeybindingChips,
KeybindingModal,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { getNavShortcutEnabled, setNavShortcutEnabled } from '@/helpers/nav-shortcut-state'
import { getQuickScrollEnabled, setQuickScrollEnabled } from '@/helpers/scroll-top-state'
import { NAV_ACTIONS, SCROLL_ACTIONS, type ShortcutAction } from '@/helpers/shortcut-actions'
import {
buildConflicts,
clearStoredBinding,
resolveAllBindings,
storeBinding,
} from '@/helpers/shortcut-bindings'
import { useTheming } from '@/store/theme'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const themeStore = useTheming()
themeStore.quickScrollEnabled = getQuickScrollEnabled()
themeStore.shortcutBindings = resolveAllBindings()
for (const action of NAV_ACTIONS) {
themeStore[action.enabledField] = getNavShortcutEnabled(action.id)
}
const messages = defineMessages({
scrollTitle: {
id: 'app.shortcut-settings.scroll-title',
defaultMessage: 'Quick scrolling',
},
scrollDescription: {
id: 'app.shortcut-settings.scroll-description',
defaultMessage: 'Move through long pages with the keyboard.',
},
enable: { id: 'app.shortcut-settings.enable', defaultMessage: 'Enable quick scrolling' },
navTitle: {
id: 'app.shortcut-settings.nav-title',
defaultMessage: 'Navigation shortcuts',
},
navDescription: {
id: 'app.shortcut-settings.nav-description',
defaultMessage: 'Jump to a menu item with its shortcut. Each shortcut is off until enabled.',
},
changeShortcut: {
id: 'app.shortcut-settings.change-shortcut',
defaultMessage: 'Change the shortcut for {action}',
},
})
function actionLabel(action: ShortcutAction) {
return formatMessage(action.label)
}
function actionDescription(action: ShortcutAction) {
return formatMessage(action.description)
}
function bindingFor(action: ShortcutAction): KeyBinding {
return themeStore.shortcutBindings[action.id] ?? action.defaultBinding
}
function sameBinding(left: KeyBinding, right: KeyBinding) {
return (
left.device === right.device &&
left.code === right.code &&
left.mod === right.mod &&
left.shift === right.shift &&
left.alt === right.alt
)
}
const editing = ref<ShortcutAction | null>(null)
const keybindingModal = ref<InstanceType<typeof KeybindingModal>>()
const editingLabel = computed(() => (editing.value ? actionLabel(editing.value) : undefined))
const editingBinding = computed(() => (editing.value ? bindingFor(editing.value) : null))
const editingDefault = computed(() => editing.value?.defaultBinding ?? null)
const editingConflicts = computed(() =>
editing.value
? buildConflicts(editing.value.id, (id) => {
const action = [...SCROLL_ACTIONS, ...NAV_ACTIONS].find((candidate) => candidate.id === id)
return action ? actionLabel(action) : id
})
: [],
)
function openShortcut(action: ShortcutAction) {
editing.value = action
keybindingModal.value?.show()
}
function saveShortcut(binding: KeyBinding) {
const action = editing.value
if (!action) return
// A combination matching the default is stored as "not changed", so a later
// change to the default still reaches this shortcut.
if (sameBinding(binding, action.defaultBinding)) clearStoredBinding(action.id)
else storeBinding(action.id, binding)
themeStore.shortcutBindings = { ...themeStore.shortcutBindings, [action.id]: binding }
}
function toggleQuickScroll(value: unknown) {
themeStore.quickScrollEnabled = !!value
setQuickScrollEnabled(themeStore.quickScrollEnabled)
}
function toggleNavShortcut(action: ShortcutAction, value: unknown) {
themeStore[action.enabledField] = !!value
setNavShortcutEnabled(action.id, !!value)
}
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsSection>
<template #header>
<h2 class="m-0 text-lg font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.scrollTitle) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.scrollDescription) }}
</p>
</template>
<SettingsRow>
<template #label>
<span id="settings-target-shortcuts-enable" tabindex="-1">
{{ formatMessage(messages.enable) }}
</span>
</template>
<template #control>
<Toggle
id="quick-scroll-enabled"
:model-value="themeStore.quickScrollEnabled"
@update:model-value="toggleQuickScroll"
/>
</template>
</SettingsRow>
<SettingsRow v-for="action in SCROLL_ACTIONS" :key="action.id">
<template #label>
<span :id="`settings-target-${action.id}`" tabindex="-1">{{ actionLabel(action) }}</span>
</template>
<template #description>{{ actionDescription(action) }}</template>
<template #control>
<button
type="button"
class="keybinding-trigger"
:aria-label="formatMessage(messages.changeShortcut, { action: actionLabel(action) })"
@click="openShortcut(action)"
>
<KeybindingChips :binding="bindingFor(action)" />
</button>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
<template #header>
<h2
id="settings-target-shortcuts-nav"
tabindex="-1"
class="m-0 text-lg font-semibold text-[var(--color-text-primary)]"
>
{{ formatMessage(messages.navTitle) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.navDescription) }}
</p>
</template>
<SettingsRow v-for="action in NAV_ACTIONS" :key="action.id">
<template #label>{{ actionLabel(action) }}</template>
<template #description>{{ actionDescription(action) }}</template>
<template #control>
<div class="flex flex-wrap items-center justify-end gap-3">
<button
type="button"
class="keybinding-trigger"
:aria-label="formatMessage(messages.changeShortcut, { action: actionLabel(action) })"
@click="openShortcut(action)"
>
<KeybindingChips :binding="bindingFor(action)" />
</button>
<Toggle
:id="`nav-shortcut-${action.id}`"
:model-value="themeStore[action.enabledField]"
@update:model-value="(value) => toggleNavShortcut(action, value)"
/>
</div>
</template>
</SettingsRow>
</SettingsSection>
<KeybindingModal
ref="keybindingModal"
:action-label="editingLabel"
:binding="editingBinding"
:default-binding="editingDefault"
:conflicts="editingConflicts"
@save="saveShortcut"
/>
</div>
</template>
<style scoped>
.keybinding-trigger {
cursor: pointer;
border-radius: var(--radius-md);
border: 1px solid transparent;
background: transparent;
padding: 0.25rem 0.375rem;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.keybinding-trigger:hover,
.keybinding-trigger:focus-visible {
border-color: var(--surface-4);
background: var(--surface-3);
}
</style>
@@ -0,0 +1,354 @@
<script setup lang="ts">
import { Combobox, defineMessages, injectNotificationManager, Toggle, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import { type AIState, getAIState, sharedAIState } from '@/helpers/ai'
import {
get_crash_analysis_ai_settings,
get_log_share_settings,
update_crash_analysis_ai_settings,
update_log_share_settings,
} from '@/helpers/logs'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
type CrashAISettings = {
enabled: boolean
provider_id: string
model_id: string
ai_source: 'logshare' | 'custom'
}
type LogShareSettings = {
share_provider: 'logshare' | 'mclogs'
ai_source: string
auto_upload: boolean
multi_file: boolean
no_storage: boolean
show_progress: boolean
}
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const settings = ref<LogShareSettings>({
share_provider: 'logshare',
ai_source: 'logshare',
auto_upload: true,
multi_file: true,
no_storage: false,
show_progress: true,
})
const aiSettings = ref<CrashAISettings>({
enabled: false,
provider_id: '',
model_id: '',
ai_source: 'logshare',
})
const loading = ref(true)
const saving = ref(false)
const messages = defineMessages({
title: { id: 'app.log-share.settings.title', defaultMessage: 'Log sharing & AI analysis' },
description: {
id: 'app.log-share.settings.description',
defaultMessage:
'Share crash diagnostics with LogShare for a structured summary and optional AI analysis. LogShare is preferred; mclo.gs is used as a fallback.',
},
shareProvider: {
id: 'app.log-share.settings.share-provider',
defaultMessage: 'Log sharing service',
},
shareProviderDescription: {
id: 'app.log-share.settings.share-provider-description',
defaultMessage: 'Where the diagnostic link is uploaded when you share it.',
},
aiSource: { id: 'app.log-share.settings.ai-source', defaultMessage: 'AI analysis source' },
aiSourceLogShare: {
id: 'app.log-share.settings.ai-source-logshare',
defaultMessage: 'LogShare AI',
},
aiSourceCustom: {
id: 'app.log-share.settings.ai-source-custom',
defaultMessage: 'Custom AI provider',
},
aiSourceDescription: {
id: 'app.log-share.settings.ai-source-description',
defaultMessage:
'Both sources send a sanitized and shortened crash context. AI output may be inaccurate.',
},
enabled: {
id: 'app.crash-analysis.ai.settings.enabled',
defaultMessage: 'Enable AI explanations',
},
enabledDescription: {
id: 'app.crash-analysis.ai.settings.enabled-description',
defaultMessage: 'Local rule-based crash analysis remains available without AI.',
},
provider: { id: 'app.crash-analysis.ai.settings.provider', defaultMessage: 'AI provider' },
model: { id: 'app.crash-analysis.ai.settings.model', defaultMessage: 'Text model' },
noProviders: {
id: 'app.crash-analysis.ai.settings.no-providers',
defaultMessage: 'Enable a provider and a text model above before using AI crash explanations.',
},
autoUpload: {
id: 'app.log-share.settings.auto-upload',
defaultMessage: 'Upload to get a structured summary automatically',
},
autoUploadDescription: {
id: 'app.log-share.settings.auto-upload-description',
defaultMessage: 'Only effective in LogShare AI mode.',
},
multiFile: { id: 'app.log-share.settings.multi-file', defaultMessage: 'Multi-file upload' },
multiFileDescription: {
id: 'app.log-share.settings.multi-file-description',
defaultMessage: 'Upload latest.log, crash reports, hs_err and launcher logs together.',
},
noStorage: {
id: 'app.log-share.settings.no-storage',
defaultMessage: 'Analyze without storing logs',
},
noStorageDescription: {
id: 'app.log-share.settings.no-storage-description',
defaultMessage:
'Submit content directly for analysis and skip the stored upload. Disables the automatic summary.',
},
showProgress: {
id: 'app.log-share.settings.show-progress',
defaultMessage: 'Show AI analysis progress',
},
showProgressDescription: {
id: 'app.log-share.settings.show-progress-description',
defaultMessage: 'Display thinking, tool and limit status while LogShare AI is working.',
},
})
const emptyState: AIState = { settings: { enabled: false }, catalog_source: '', providers: [] }
const aiState = computed(() => sharedAIState.value ?? emptyState)
const providers = computed(() =>
aiState.value.providers.filter(
(provider) => provider.enabled && provider.models.some((model) => model.enabled),
),
)
const providerOptions = computed(() =>
providers.value.map((provider) => ({
value: provider.provider_id,
label: provider.custom_name || provider.provider_id,
})),
)
const modelOptions = computed(
() =>
providers.value
.find((provider) => provider.provider_id === aiSettings.value.provider_id)
?.models.filter((model) => model.enabled)
.map((model) => ({ value: model.id, label: model.name || model.id })) ?? [],
)
const isCustom = computed(() => aiSettings.value.ai_source === 'custom')
const noStorageEnabled = computed(() => aiSettings.value.ai_source === 'logshare')
const shareProviderOptions = [
{ value: 'logshare', label: 'LogShare' },
{ value: 'mclogs', label: 'mclo.gs' },
]
const aiSourceOptions = [
{ value: 'logshare', label: formatMessage(messages.aiSourceLogShare) },
{ value: 'custom', label: formatMessage(messages.aiSourceCustom) },
]
async function saveShare(next: LogShareSettings): Promise<void> {
saving.value = true
try {
await update_log_share_settings(next)
settings.value = next
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function saveAI(next: CrashAISettings): Promise<void> {
saving.value = true
try {
await update_crash_analysis_ai_settings(next)
aiSettings.value = next
settings.value = { ...settings.value, ai_source: next.ai_source }
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
function updateShare(patch: Partial<LogShareSettings>): void {
void saveShare({ ...settings.value, ...patch })
}
function selectShareProvider(value: unknown): void {
const provider = value === 'mclogs' ? 'mclogs' : 'logshare'
updateShare({ share_provider: provider, ai_source: aiSettings.value.ai_source })
}
function selectAiSource(value: unknown): void {
const source = value === 'custom' ? 'custom' : 'logshare'
void saveAI({ ...aiSettings.value, ai_source: source })
}
function updateEnabled(enabled: boolean): void {
void saveAI({ ...aiSettings.value, enabled })
}
function updateProvider(providerId: string): void {
const provider = providers.value.find((item) => item.provider_id === providerId)
const modelId = provider?.models.find((model) => model.enabled)?.id ?? ''
void saveAI({ ...aiSettings.value, provider_id: providerId, model_id: modelId })
}
function updateModel(modelId: string): void {
void saveAI({ ...aiSettings.value, model_id: modelId })
}
onMounted(async () => {
try {
const [nextShare, nextAI] = await Promise.all([
get_log_share_settings(),
get_crash_analysis_ai_settings(),
getAIState(),
])
settings.value = { ...nextShare, ai_source: nextAI.ai_source }
aiSettings.value = nextAI
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
})
</script>
<template>
<SettingsSection
v-if="!loading"
id="settings-target-log-share"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
>
<SettingsRow>
<template #label>{{ formatMessage(messages.shareProvider) }}</template>
<template #description>{{ formatMessage(messages.shareProviderDescription) }}</template>
<template #control>
<Combobox
id="log-share-provider"
:model-value="settings.share_provider"
:options="shareProviderOptions"
:disabled="saving"
@update:model-value="selectShareProvider($event)"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.aiSource) }}</template>
<template #description>{{ formatMessage(messages.aiSourceDescription) }}</template>
<template #control>
<Combobox
id="log-share-ai-source"
:model-value="aiSettings.ai_source"
:options="aiSourceOptions"
:disabled="saving"
@update:model-value="selectAiSource($event)"
/>
</template>
</SettingsRow>
<template v-if="isCustom">
<SettingsRow>
<template #label>{{ formatMessage(messages.enabled) }}</template>
<template #description>{{ formatMessage(messages.enabledDescription) }}</template>
<template #control>
<Toggle
id="crash-analysis-ai-enabled"
:model-value="aiSettings.enabled"
:disabled="saving || !aiState.settings.enabled || !providers.length"
@update:model-value="updateEnabled(!!$event)"
/>
</template>
</SettingsRow>
<template v-if="providers.length">
<SettingsRow>
<template #label>{{ formatMessage(messages.provider) }}</template>
<template #control>
<Combobox
id="crash-analysis-ai-provider"
:model-value="aiSettings.provider_id"
:options="providerOptions"
:disabled="saving"
@update:model-value="updateProvider(String($event))"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.model) }}</template>
<template #control>
<Combobox
id="crash-analysis-ai-model"
:model-value="aiSettings.model_id"
:options="modelOptions"
:disabled="saving || !aiSettings.provider_id"
@update:model-value="updateModel(String($event))"
/>
</template>
</SettingsRow>
</template>
<p v-else class="m-0 p-4 text-sm text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.noProviders) }}
</p>
</template>
<SettingsRow>
<template #label>{{ formatMessage(messages.autoUpload) }}</template>
<template #description>{{ formatMessage(messages.autoUploadDescription) }}</template>
<template #control>
<Toggle
id="log-share-auto-upload"
:model-value="settings.auto_upload"
:disabled="saving || !noStorageEnabled || settings.no_storage"
@update:model-value="updateShare({ auto_upload: !!$event })"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.multiFile) }}</template>
<template #description>{{ formatMessage(messages.multiFileDescription) }}</template>
<template #control>
<Toggle
id="log-share-multi-file"
:model-value="settings.multi_file"
:disabled="saving"
@update:model-value="updateShare({ multi_file: !!$event })"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.noStorage) }}</template>
<template #description>{{ formatMessage(messages.noStorageDescription) }}</template>
<template #control>
<Toggle
id="log-share-no-storage"
:model-value="settings.no_storage"
:disabled="saving || !noStorageEnabled"
@update:model-value="updateShare({ no_storage: !!$event })"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.showProgress) }}</template>
<template #description>{{ formatMessage(messages.showProgressDescription) }}</template>
<template #control>
<Toggle
id="log-share-show-progress"
:model-value="settings.show_progress"
:disabled="saving"
@update:model-value="updateShare({ show_progress: !!$event })"
/>
</template>
</SettingsRow>
</SettingsSection>
</template>
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import { delete_shared_log, list_shared_logs } from '@/helpers/logs'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
type SharedLog = {
id: string
url: string
raw: string
token: string
provider: string
instance_id: string | null
instance_name: string | null
truncated: boolean
created_at: number
}
const { formatMessage } = useVIntl()
const { addNotification, handleError } = injectNotificationManager()
const logs = ref<SharedLog[]>([])
const loading = ref(true)
const deletingId = ref<string | null>(null)
const messages = defineMessages({
title: { id: 'app.log-share.shared-logs.title', defaultMessage: 'Shared logs' },
description: {
id: 'app.log-share.shared-logs.description',
defaultMessage:
'Every diagnostic you have shared is listed here. Delete an entry to remove it from your history and request its removal from the sharing service.',
},
empty: { id: 'app.log-share.shared-logs.empty', defaultMessage: 'No shared logs yet.' },
delete: { id: 'app.log-share.shared-logs.delete', defaultMessage: 'Delete' },
deleting: { id: 'app.log-share.shared-logs.deleting', defaultMessage: 'Deleting…' },
deleted: {
id: 'app.log-share.shared-logs.deleted',
defaultMessage: 'Shared log removed.',
},
deleteFailed: {
id: 'app.log-share.shared-logs.delete-failed',
defaultMessage: 'Failed to delete the shared log.',
},
truncated: { id: 'app.log-share.shared-logs.truncated', defaultMessage: 'Truncated' },
})
async function refresh(): Promise<void> {
try {
logs.value = await list_shared_logs()
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
}
async function remove(log: SharedLog): Promise<void> {
if (deletingId.value) return
deletingId.value = log.id
try {
await delete_shared_log(log.id, log.token)
logs.value = logs.value.filter((item) => item.id !== log.id)
addNotification({ title: formatMessage(messages.deleted), type: 'success' })
} catch (error) {
handleError(error)
addNotification({ title: formatMessage(messages.deleteFailed), type: 'error' })
} finally {
deletingId.value = null
}
}
function formatDate(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleString()
}
onMounted(refresh)
</script>
<template>
<SettingsSection
id="settings-target-shared-logs"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
>
<SettingsRow stacked>
<template #control>
<div class="flex w-full min-w-0 flex-col gap-2">
<p v-if="loading" class="m-0 p-2 text-sm text-[var(--color-text-tertiary)]">…</p>
<p v-else-if="!logs.length" class="m-0 p-2 text-sm text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.empty) }}
</p>
<ul v-else class="m-0 flex max-h-72 list-none flex-col gap-1 overflow-y-auto p-2">
<li
v-for="log in logs"
:key="log.id"
class="flex items-center gap-3 rounded-lg bg-surface-2 px-3 py-2"
>
<a
:href="log.url || `https://logshare.cn/${log.id}`"
target="_blank"
rel="noopener noreferrer"
class="min-w-0 flex-1 truncate text-[var(--color-text-default)] underline"
>
{{ log.url || log.id }}
</a>
<span class="shrink-0 text-xs text-[var(--color-text-tertiary)]">
{{ log.provider }}
<template v-if="log.truncated">
· {{ formatMessage(messages.truncated) }}
</template>
<template v-if="log.instance_name"> · {{ log.instance_name }} </template>
· {{ formatDate(log.created_at) }}
</span>
<Button type="outlined" :disabled="deletingId === log.id" @click="remove(log)"
>{{
deletingId === log.id
? formatMessage(messages.deleting)
: formatMessage(messages.delete)
}}
</Button>
</li>
</ul>
</div>
</template>
</SettingsRow>
</SettingsSection>
</template>
@@ -3,6 +3,7 @@ import type { MessageDescriptor } from '@modrinth/ui'
export type SettingsCategoryId =
| 'interface'
| 'home-navigation'
| 'shortcut-settings'
| 'language-translation'
| 'ai'
| 'java-performance'
@@ -45,6 +46,15 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
group: 'launcher',
onboardingId: 'settings-tab-home-navigation',
},
{
id: 'shortcut-settings',
name: {
id: 'app.settings.tabs.shortcut-settings',
defaultMessage: 'Keyboard shortcuts',
},
group: 'launcher',
onboardingId: 'settings-tab-shortcut-settings',
},
{
id: 'language-translation',
name: {
@@ -7,6 +7,7 @@ import {
GameIcon,
GaugeIcon,
InfoIcon,
KeyboardIcon,
LanguagesIcon,
LayoutTemplateIcon,
PaintbrushIcon,
@@ -49,6 +50,10 @@ const categoryContent: Record<SettingsCategoryId, Pick<SettingsCategory, 'icon'
icon: LayoutTemplateIcon,
content: defineAsyncComponent(() => import('./HomeNavigationSettings.vue')),
},
'shortcut-settings': {
icon: KeyboardIcon,
content: defineAsyncComponent(() => import('./KeybindSettings.vue')),
},
'language-translation': {
icon: LanguagesIcon,
content: defineAsyncComponent(() => import('./LanguageTranslationSettings.vue')),
@@ -21,6 +21,7 @@ import {
const settingsComponentFiles = {
interface: ['./AppearanceSettings.vue'],
'home-navigation': ['./AppearanceSettings.vue'],
'shortcut-settings': ['./KeybindSettings.vue'],
'language-translation': ['./LanguageSettings.vue', './TranslationSettings.vue'],
ai: ['./AISettings.vue'],
'java-performance': ['./JavaSettings.vue'],
@@ -30,6 +31,7 @@ const settingsComponentFiles = {
'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'],
updates: ['./UpdateSettings.vue'],
about: ['./AboutSettings.vue'],
logs: ['./LogsSettings.vue'],
'feature-flags': ['./FeatureFlagSettings.vue'],
} as const
@@ -168,8 +170,7 @@ test('developer-only settings stay out of the normal search categories', () => {
)
})
test('settings navigation groups preserve the intended Starlight information architecture', () => {
test('settings navigation groups preserve the intended Starlight information architecture', () => {
const categoriesForGroup = (
group: 'launcher' | 'game' | 'data-privacy' | 'support' | 'developer',
developerMode = false,
@@ -181,6 +182,7 @@ test('developer-only settings stay out of the normal search categories', () => {
assert.deepEqual(categoriesForGroup('launcher'), [
'interface',
'home-navigation',
'shortcut-settings',
'language-translation',
'ai',
])
@@ -191,7 +193,7 @@ test('developer-only settings stay out of the normal search categories', () => {
'network-multiplayer',
])
assert.deepEqual(categoriesForGroup('data-privacy'), ['storage-backups'])
assert.deepEqual(categoriesForGroup('support'), ['updates', 'about'])
assert.deepEqual(categoriesForGroup('support'), ['updates', 'about', 'logs'])
assert.deepEqual(categoriesForGroup('developer'), [])
assert.deepEqual(categoriesForGroup('developer', true), ['feature-flags'])
})
@@ -0,0 +1,307 @@
<script setup lang="ts">
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { type SkinSiteChatUserInfo, skinSiteChatUserSearch } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const props = withDefaults(
defineProps<{
roomId?: number
label?: string
placeholder?: string
excludeUuids?: string[]
allowedUuids?: string[]
allowedHint?: string
}>(),
{
roomId: undefined,
label: undefined,
placeholder: undefined,
excludeUuids: () => [],
allowedUuids: undefined,
allowedHint: undefined,
},
)
const model = defineModel<SkinSiteChatUserInfo | null>({ required: true })
const messages = defineMessages({
label: { id: 'app.skin-site.chat.user-picker.label', defaultMessage: 'Pick a user' },
placeholder: {
id: 'app.skin-site.chat.user-picker.placeholder',
defaultMessage: 'Search username, email or account ID',
},
byUser: { id: 'app.skin-site.chat.user-picker.by-user', defaultMessage: 'By user' },
byPlayer: { id: 'app.skin-site.chat.user-picker.by-player', defaultMessage: 'By player' },
hint: {
id: 'app.skin-site.chat.user-picker.hint',
defaultMessage: 'Fuzzy search on username and email, or search by account ID.',
},
playerHint: {
id: 'app.skin-site.chat.user-picker.player-hint',
defaultMessage: 'Enter a player name to find the bound user.',
},
empty: {
id: 'app.skin-site.chat.user-picker.empty',
defaultMessage: 'No matching user.',
},
banned: {
id: 'app.skin-site.chat.user-picker.banned',
defaultMessage: 'This account is banned.',
},
guest: {
id: 'app.skin-site.chat.user-picker.guest',
defaultMessage: 'Guests cannot become a manager or settlement owner.',
},
alreadyIn: {
id: 'app.skin-site.chat.user-picker.already-in',
defaultMessage: 'This user is already in the room.',
},
notAllowed: {
id: 'app.skin-site.chat.user-picker.not-allowed',
defaultMessage: 'This user is outside the allowed range.',
},
select: { id: 'app.skin-site.chat.user-picker.select', defaultMessage: 'Select' },
selected: {
id: 'app.skin-site.chat.user-picker.selected',
defaultMessage: 'Selected',
},
idLabel: { id: 'app.skin-site.chat.user-picker.id', defaultMessage: 'ID' },
emailLabel: { id: 'app.skin-site.chat.user-picker.email', defaultMessage: 'Email' },
registeredLabel: {
id: 'app.skin-site.chat.user-picker.registered',
defaultMessage: 'Registered',
},
bannedStatus: { id: 'app.skin-site.chat.user-picker.banned-status', defaultMessage: 'Banned' },
activeStatus: { id: 'app.skin-site.chat.user-picker.active-status', defaultMessage: 'Active' },
})
const keyword = ref('')
const searchMode = ref<'user' | 'player'>('user')
const results = ref<SkinSiteChatUserInfo[]>([])
const preview = ref<SkinSiteChatUserInfo | null>(null)
const loading = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
let requestId = 0
const currentLabel = computed(() => props.label ?? formatMessage(messages.label))
const currentPlaceholder = computed(() => props.placeholder ?? formatMessage(messages.placeholder))
watch([keyword, searchMode], () => {
clearTimeout(timer)
const query = keyword.value.trim()
if (!query) {
results.value = []
preview.value = null
loading.value = false
return
}
timer = setTimeout(() => void search(query), 300)
})
watch(model, (value) => {
if (value) return
keyword.value = ''
preview.value = null
results.value = []
})
watch(
() => props.roomId,
() => {
model.value = null
},
)
onBeforeUnmount(() => clearTimeout(timer))
async function search(query: string) {
const current = ++requestId
loading.value = true
try {
const res = await skinSiteChatUserSearch(query, props.roomId, searchMode.value)
if (current !== requestId) return
if (!res.success) throw new Error(res.errorMessage)
results.value = res.payload ?? []
preview.value = results.value.length === 1 ? results.value[0] : null
} catch (error) {
if (current === requestId) {
results.value = []
handleError(error)
}
} finally {
if (current === requestId) loading.value = false
}
}
function unavailableReason(user: SkinSiteChatUserInfo): string | null {
if (user.banned) return formatMessage(messages.banned)
if (user.permissions.includes('GUEST')) return formatMessage(messages.guest)
if (props.excludeUuids.includes(user.uuid)) return formatMessage(messages.alreadyIn)
if (props.allowedUuids && !props.allowedUuids.includes(user.uuid)) {
return props.allowedHint ?? formatMessage(messages.notAllowed)
}
return null
}
function selectPreview() {
if (preview.value && !unavailableReason(preview.value)) {
model.value = preview.value
}
}
function formatTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
</script>
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<span class="text-xs font-semibold text-secondary">{{ currentLabel }}</span>
<div class="flex gap-2">
<select
v-model="searchMode"
class="shrink-0 rounded-lg border border-solid border-surface-5 bg-bg-raised px-2 py-1.5 text-xs text-contrast outline-none focus:border-brand"
>
<option value="user">{{ formatMessage(messages.byUser) }}</option>
<option value="player">{{ formatMessage(messages.byPlayer) }}</option>
</select>
<input
v-model.trim="keyword"
type="text"
maxlength="100"
:placeholder="
searchMode === 'player' ? formatMessage(messages.playerHint) : currentPlaceholder
"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
autocomplete="off"
/>
</div>
<span class="text-[11px] text-secondary">
{{
searchMode === 'player'
? formatMessage(messages.playerHint)
: formatMessage(messages.hint)
}}
</span>
</div>
<div
v-if="keyword && !loading"
class="flex max-h-56 flex-col gap-1.5 overflow-y-auto rounded-lg border border-solid border-surface-5 bg-bg-raised p-2"
>
<button
v-for="user in results"
:key="user.uuid"
type="button"
class="flex w-full items-center justify-between gap-3 rounded-lg border border-solid p-2 text-left transition-colors"
:class="
preview?.uuid === user.uuid
? 'border-brand bg-brand/10'
: 'border-transparent bg-bg hover:border-surface-5'
"
@click="preview = user"
>
<span class="flex min-w-0 items-center gap-2">
<span
class="grid size-8 shrink-0 place-items-center rounded-lg bg-brand/15 text-xs font-bold text-brand"
>
{{ user.username.slice(0, 1).toUpperCase() }}
</span>
<span class="min-w-0">
<strong class="block truncate text-xs text-contrast">{{ user.username }}</strong>
<span class="block truncate text-[10px] text-secondary">
{{ formatMessage(messages.idLabel) }} {{ user.id }} · {{ user.email }}
</span>
</span>
</span>
<span
v-if="unavailableReason(user)"
class="shrink-0 rounded-full bg-red/20 px-2 py-0.5 text-[10px] font-semibold text-red"
>
{{ unavailableReason(user) }}
</span>
<span
v-else-if="model?.uuid === user.uuid"
class="shrink-0 rounded-full bg-brand px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.selected) }}
</span>
</button>
<p v-if="!results.length" class="m-0 py-4 text-center text-xs text-secondary">
{{ formatMessage(messages.empty) }}
</p>
</div>
<section
v-if="preview"
class="flex flex-col gap-3 rounded-lg border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="m-0 text-sm font-bold text-contrast">{{ preview.username }}</h3>
<p class="m-0 text-[11px] text-secondary">
{{ formatMessage(messages.idLabel) }} {{ preview.id }}
</p>
</div>
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="preview.banned ? 'bg-red text-contrast' : 'bg-green text-contrast'"
>
{{ formatMessage(preview.banned ? messages.bannedStatus : messages.activeStatus) }}
</span>
</div>
<div class="grid gap-2 text-[11px] sm:grid-cols-2">
<div class="min-w-0">
<span class="block text-secondary">UUID</span>
<span class="block break-all font-mono text-contrast">{{ preview.uuid }}</span>
</div>
<div>
<span class="block text-secondary">{{ formatMessage(messages.emailLabel) }}</span>
<span class="block break-all text-contrast">{{ preview.email }}</span>
</div>
<div>
<span class="block text-secondary">QQ</span>
<span class="block text-contrast">{{ preview.qq ?? '—' }}</span>
</div>
<div>
<span class="block text-secondary">{{ formatMessage(messages.registeredLabel) }}</span>
<span class="block text-contrast">{{ formatTime(preview.registerTime) }}</span>
</div>
</div>
<div class="flex flex-wrap gap-1.5">
<span
v-for="perm in preview.permissions"
:key="perm"
class="rounded bg-bg px-2 py-0.5 font-mono text-[10px] text-secondary"
>
{{ perm }}
</span>
</div>
<p v-if="unavailableReason(preview)" class="m-0 text-[11px] text-red">
{{ unavailableReason(preview) }}
</p>
<button
type="button"
class="w-full rounded-lg bg-brand px-3 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="Boolean(unavailableReason(preview))"
@click="selectPreview"
>
{{
model?.uuid === preview.uuid
? formatMessage(messages.selected)
: formatMessage(messages.select)
}}
</button>
</section>
</div>
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { loadCachedRoomAvatar } from './room-avatar-cache'
const props = withDefaults(
defineProps<{
roomId: number
avatarAttachmentId?: number | null
name: string
size?: 'sm' | 'md' | 'lg' | 'xl'
}>(),
{ avatarAttachmentId: null, size: 'md' },
)
const avatarUrl = ref<string | null>(null)
const loading = ref(false)
const sizeClass = computed(
() =>
({
sm: 'size-9 rounded-lg text-lg',
md: 'size-10 rounded-lg text-xl',
lg: 'size-12 rounded-lg text-2xl',
xl: 'size-20 rounded-xl text-3xl',
})[props.size],
)
watch(
() => [props.roomId, props.avatarAttachmentId] as const,
async ([roomId, attachmentId]) => {
avatarUrl.value = null
if (!attachmentId) {
loading.value = false
return
}
loading.value = true
const url = await loadCachedRoomAvatar(roomId, attachmentId)
if (props.roomId === roomId && props.avatarAttachmentId === attachmentId) {
avatarUrl.value = url
}
loading.value = false
},
{ immediate: true },
)
</script>
<template>
<div
class="grid shrink-0 place-items-center overflow-hidden border border-solid border-surface-5 bg-brand/10 font-bold text-brand"
:class="sizeClass"
>
<img
v-if="avatarUrl"
:src="avatarUrl"
:alt="name"
class="h-full w-full object-cover"
decoding="async"
/>
<span v-else-if="loading" class="text-[10px] text-secondary">…</span>
<span v-else class="select-none">#</span>
</div>
</template>
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onBeforeUnmount, ref, watch } from 'vue'
import { skinSiteChatAttachmentUpload } from '@/helpers/skin-site-api'
import RoomAvatar from './RoomAvatar.vue'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const props = withDefaults(
defineProps<{
roomId?: number
avatarAttachmentId?: number | null
name: string
token?: string | null
remove?: boolean
}>(),
{ roomId: 0, avatarAttachmentId: null, token: null, remove: false },
)
const emit = defineEmits<{
(e: 'update:token', value: string | null): void
(e: 'update:remove', value: boolean): void
(e: 'update:uploading', value: boolean): void
}>()
const messages = defineMessages({
label: { id: 'app.skin-site.chat.avatar.label', defaultMessage: 'Room avatar' },
upload: { id: 'app.skin-site.chat.avatar.upload', defaultMessage: 'Upload avatar' },
replace: { id: 'app.skin-site.chat.avatar.replace', defaultMessage: 'Replace avatar' },
remove: { id: 'app.skin-site.chat.avatar.remove', defaultMessage: 'Remove avatar' },
hint: {
id: 'app.skin-site.chat.avatar.hint',
defaultMessage: 'PNG, JPEG or WebP, up to 8192 px per side and 5 MB.',
},
badFormat: {
id: 'app.skin-site.chat.avatar.bad-format',
defaultMessage: 'Only PNG, JPEG and WebP are supported.',
},
tooLarge: {
id: 'app.skin-site.chat.avatar.too-large',
defaultMessage: 'Avatar must not exceed 5 MB.',
},
notImage: {
id: 'app.skin-site.chat.avatar.not-image',
defaultMessage: 'Avatar must be an image.',
},
})
const MAX_BYTES = 5 * 1024 * 1024
const ALLOWED = ['png', 'jpg', 'jpeg', 'webp']
const uploading = ref(false)
const previewUrl = ref<string | null>(null)
watch(
() => props.token,
(token) => {
if (!token) revokePreview()
},
)
function revokePreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = null
}
async function selectAvatar(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
const ext = file.name.split('.').pop()?.toLowerCase()
if (!ext || !ALLOWED.includes(ext)) {
addNotification({ type: 'error', title: formatMessage(messages.badFormat) })
return
}
if (file.size > MAX_BYTES) {
addNotification({ type: 'error', title: formatMessage(messages.tooLarge) })
return
}
uploading.value = true
emit('update:uploading', true)
try {
const res = await skinSiteChatAttachmentUpload(file)
if (!res.success) throw new Error(res.errorMessage)
if (res.payload.attachment.kind !== 'IMAGE') {
addNotification({ type: 'error', title: formatMessage(messages.notImage) })
return
}
revokePreview()
previewUrl.value = URL.createObjectURL(file)
emit('update:token', res.payload.token)
emit('update:remove', false)
} catch (error) {
handleError(error)
} finally {
uploading.value = false
emit('update:uploading', false)
}
}
function removeAvatar() {
revokePreview()
emit('update:token', null)
emit('update:remove', true)
}
onBeforeUnmount(revokePreview)
</script>
<template>
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.label) }}
</span>
<div class="flex flex-wrap items-center gap-3">
<div
v-if="previewUrl"
class="size-20 overflow-hidden rounded-xl border border-solid border-surface-5 bg-bg-raised"
>
<img :src="previewUrl" alt="" class="h-full w-full object-cover" />
</div>
<RoomAvatar
v-else
:room-id="roomId"
:avatar-attachment-id="remove ? null : avatarAttachmentId"
:name="name || 'Room'"
size="xl"
/>
<div class="flex flex-wrap gap-2">
<label
class="cursor-pointer rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
:class="uploading ? 'pointer-events-none opacity-50' : ''"
>
{{ uploading ? '…' : (avatarAttachmentId || previewUrl ? formatMessage(messages.replace) : formatMessage(messages.upload)) }}
<input
type="file"
class="hidden"
accept=".png,.jpg,.jpeg,.webp"
@change="selectAvatar"
/>
</label>
<button
v-if="previewUrl || (avatarAttachmentId && !remove)"
type="button"
class="rounded-lg bg-red/20 px-3 py-1.5 text-xs font-semibold text-red hover:brightness-110 disabled:opacity-50"
:disabled="uploading"
@click="removeAvatar"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
<p class="m-0 text-[11px] text-secondary">{{ formatMessage(messages.hint) }}</p>
</div>
</template>
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { SparklesIcon, SpinnerIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import {
requestSkinSiteLuck,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
const { formatMessage } = useVIntl()
const messages = defineMessages({
signedOutTitle: { id: 'app.skin-site.home.signed-out-title', defaultMessage: 'Not signed in' },
signedOutBody: {
id: 'app.skin-site.home.signed-out-body',
defaultMessage: 'Sign in to the StarLight skin site to manage players and download packs.',
},
players: { id: 'app.skin-site.home.players', defaultMessage: 'Your players' },
noPlayers: {
id: 'app.skin-site.home.no-players',
defaultMessage: 'No players yet. Add one on the skin site.',
},
luck: { id: 'app.skin-site.home.luck', defaultMessage: "Today's luck" },
luckUnavailable: { id: 'app.skin-site.home.luck-unavailable', defaultMessage: 'Unavailable' },
loading: { id: 'app.skin-site.home.loading', defaultMessage: 'Loading…' },
error: { id: 'app.skin-site.home.error', defaultMessage: 'Could not load players.' },
signIn: { id: 'app.skin-site.home.sign-in', defaultMessage: 'Sign in' },
settings: { id: 'app.skin-site.home.settings', defaultMessage: 'Account' },
admin: { id: 'app.skin-site.home.admin', defaultMessage: 'Admin' },
myPlayers: { id: 'app.skin-site.home.my-players', defaultMessage: 'My players' },
})
const emit = defineEmits<{
(e: 'sign-in' | 'open-settings' | 'open-players' | 'open-admin'): void
}>()
const luck = ref<number | null>(null)
const luckLoading = ref(false)
const signedIn = computed(() => skinSiteStatus.value === 'signed-in' && !!skinSiteUser.value)
const isAdmin = computed(() => {
const permissions = skinSiteUser.value?.permissions ?? []
return permissions.includes('WEB_ADMIN') || permissions.includes('SERVER_ADMIN')
})
const players = computed(() => skinSitePlayers.value)
const playersLoading = computed(
() => skinSitePlayersStatus.value === 'checking' || skinSitePlayersStatus.value === 'idle',
)
const playersError = computed(() => skinSitePlayersStatus.value === 'error')
async function loadLuck() {
if (!signedIn.value) {
luck.value = null
return
}
luckLoading.value = true
try {
luck.value = await requestSkinSiteLuck()
} catch {
luck.value = null
} finally {
luckLoading.value = false
}
}
onMounted(loadLuck)
watch(signedIn, loadLuck)
</script>
<template>
<div
class="flex flex-col gap-4 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<div v-if="!signedIn" class="flex flex-col items-start gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.signedOutTitle) }}
</h3>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.signedOutBody) }}</p>
<ButtonStyled color="brand">
<button type="button" @click="emit('sign-in')">
{{ formatMessage(messages.signIn) }}
</button>
</ButtonStyled>
</div>
<template v-else>
<div class="flex items-center gap-3">
<div
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand-highlight text-lg font-bold text-contrast"
>
{{ skinSiteUser!.username.slice(0, 1).toUpperCase() }}
</div>
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-contrast">{{ skinSiteUser!.username }}</span>
<span class="truncate text-xs text-secondary">{{ skinSiteUser!.uuid }}</span>
</div>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('open-settings')"
>
{{ formatMessage(messages.settings) }}
</button>
<button
v-if="isAdmin"
type="button"
class="shrink-0 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('open-admin')"
>
{{ formatMessage(messages.admin) }}
</button>
<div
class="ml-auto flex shrink-0 items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-sm"
:title="formatMessage(messages.luck)"
>
<SpinnerIcon v-if="luckLoading" class="size-4 animate-spin" />
<template v-else-if="luck !== null">
<SparklesIcon class="size-4 text-brand" />
<span class="font-semibold text-contrast">{{ luck }}</span>
</template>
<span v-else class="text-xs text-secondary">{{
formatMessage(messages.luckUnavailable)
}}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.players)
}}</span>
<div v-if="playersLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="playersError" class="m-0 text-sm text-red">
{{ formatMessage(messages.error) }}
</p>
<p v-else-if="!players.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.noPlayers) }}
</p>
<div v-else class="flex flex-wrap gap-3">
<div
v-for="player in players"
:key="player.uuid"
class="flex items-center gap-2 rounded-xl bg-button-bg px-2 py-1.5"
>
<Avatar
:src="player.headDataUrl"
:alt="player.name"
size="2rem"
pixelated
class="!rounded-md"
/>
<div class="flex flex-col">
<span class="text-sm font-medium text-contrast">{{ player.name }}</span>
<span v-if="player.isMojang" class="text-[10px] uppercase text-secondary"
>Mojang</span
>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { skinSiteUser } from '@/composables/skin-site-session'
import AdminAccessAuditPage from './admin/AdminAccessAuditPage.vue'
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
import AdminChatPage from './admin/AdminChatPage.vue'
import AdminDonationsPage from './admin/AdminDonationsPage.vue'
import AdminDownloadsPage from './admin/AdminDownloadsPage.vue'
import AdminLauncherPage from './admin/AdminLauncherPage.vue'
import AdminModsPage from './admin/AdminModsPage.vue'
import AdminPlayersPage from './admin/AdminPlayersPage.vue'
import AdminReviewPage from './admin/AdminReviewPage.vue'
import AdminServersPage from './admin/AdminServersPage.vue'
import AdminSummaryPage from './admin/AdminSummaryPage.vue'
import AdminUsersPage from './admin/AdminUsersPage.vue'
import AdminWikiPage from './admin/AdminWikiPage.vue'
const emit = defineEmits<{ (e: 'back'): void }>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.title', defaultMessage: 'Admin console' },
back: { id: 'app.skin-site.admin.back', defaultMessage: 'Back to home' },
noAccess: { id: 'app.skin-site.admin.no-access', defaultMessage: 'You do not have access.' },
noAccessHint: {
id: 'app.skin-site.admin.no-access-hint',
defaultMessage: 'Sign in with an administrator account to use this console.',
},
groupSecurity: { id: 'app.skin-site.admin.group.security', defaultMessage: 'Security' },
groupUsers: { id: 'app.skin-site.admin.group.users', defaultMessage: 'Users' },
groupCommunity: { id: 'app.skin-site.admin.group.community', defaultMessage: 'Community' },
groupResources: { id: 'app.skin-site.admin.group.resources', defaultMessage: 'Resources' },
itemServers: { id: 'app.skin-site.admin.item.servers', defaultMessage: 'Servers' },
itemSummary: { id: 'app.skin-site.admin.item.summary', defaultMessage: 'Summary' },
itemAccessAudit: { id: 'app.skin-site.admin.item.access-audit', defaultMessage: 'Access audit' },
itemUsers: { id: 'app.skin-site.admin.item.users', defaultMessage: 'Users' },
itemPlayers: { id: 'app.skin-site.admin.item.players', defaultMessage: 'Players' },
itemReview: { id: 'app.skin-site.admin.item.review', defaultMessage: 'Player review' },
itemBroadcast: { id: 'app.skin-site.admin.item.broadcast', defaultMessage: 'Broadcast' },
itemChat: { id: 'app.skin-site.admin.item.chat', defaultMessage: 'Chat rooms' },
itemWiki: { id: 'app.skin-site.admin.item.wiki', defaultMessage: 'Wiki' },
itemDonations: { id: 'app.skin-site.admin.item.donations', defaultMessage: 'Donations' },
itemMods: { id: 'app.skin-site.admin.item.mods', defaultMessage: 'Mods' },
itemLauncher: { id: 'app.skin-site.admin.item.launcher', defaultMessage: 'Launcher' },
itemDownloads: { id: 'app.skin-site.admin.item.downloads', defaultMessage: 'Downloads' },
})
type AdminKey =
| 'servers'
| 'summary'
| 'access-audit'
| 'users'
| 'players'
| 'review'
| 'broadcast'
| 'chat'
| 'wiki'
| 'donations'
| 'mods'
| 'launcher'
| 'downloads'
interface NavItem {
key: AdminKey
label: string
}
interface NavGroup {
id: string
label: string
items: NavItem[]
}
const navGroups = computed<NavGroup[]>(() => [
{
id: 'security',
label: formatMessage(messages.groupSecurity),
items: [
...(hasWebAdmin.value
? [{ key: 'servers' as AdminKey, label: formatMessage(messages.itemServers) }]
: []),
{ key: 'summary', label: formatMessage(messages.itemSummary) },
{ key: 'access-audit', label: formatMessage(messages.itemAccessAudit) },
],
},
{
id: 'users',
label: formatMessage(messages.groupUsers),
items: [
{ key: 'users', label: formatMessage(messages.itemUsers) },
{ key: 'players', label: formatMessage(messages.itemPlayers) },
{ key: 'review', label: formatMessage(messages.itemReview) },
],
},
{
id: 'community',
label: formatMessage(messages.groupCommunity),
items: [
{ key: 'broadcast', label: formatMessage(messages.itemBroadcast) },
{ key: 'chat', label: formatMessage(messages.itemChat) },
{ key: 'wiki', label: formatMessage(messages.itemWiki) },
...(hasWebAdmin.value
? [{ key: 'donations' as AdminKey, label: formatMessage(messages.itemDonations) }]
: []),
],
},
{
id: 'resources',
label: formatMessage(messages.groupResources),
items: [
{ key: 'mods', label: formatMessage(messages.itemMods) },
{ key: 'launcher', label: formatMessage(messages.itemLauncher) },
{ key: 'downloads', label: formatMessage(messages.itemDownloads) },
],
},
])
const hasAdminAccess = computed(() => {
const permissions = skinSiteUser.value?.permissions ?? []
return permissions.includes('WEB_ADMIN') || permissions.includes('SERVER_ADMIN')
})
const hasWebAdmin = computed(() => {
const permissions = skinSiteUser.value?.permissions ?? []
return permissions.includes('WEB_ADMIN')
})
const current = ref<AdminKey>('summary')
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<button
type="button"
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<div
v-if="!hasAdminAccess"
class="flex flex-col gap-1 rounded-xl border border-solid border-surface-5 bg-bg-raised p-6"
>
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.noAccess) }}</h2>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.noAccessHint) }}</p>
</div>
<div v-else class="flex min-h-0 flex-1 gap-4">
<nav class="flex w-48 shrink-0 flex-col gap-3 overflow-y-auto">
<div v-for="group in navGroups" :key="group.id" class="flex flex-col gap-1">
<span class="px-2 text-[10px] font-bold tracking-wide text-secondary uppercase">
{{ group.label }}
</span>
<button
v-for="item in group.items"
:key="item.key"
type="button"
class="rounded-lg px-2 py-1.5 text-left text-xs font-semibold transition-colors"
:class="
current === item.key
? 'bg-brand text-contrast'
: 'text-secondary hover:bg-button-bg hover:text-contrast'
"
@click="current = item.key"
>
{{ item.label }}
</button>
</div>
</nav>
<section
class="min-w-0 flex-1 overflow-y-auto rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<AdminSummaryPage v-if="current === 'summary'" />
<AdminServersPage v-else-if="current === 'servers' && hasWebAdmin" />
<AdminAccessAuditPage v-else-if="current === 'access-audit'" />
<AdminBroadcastPage v-else-if="current === 'broadcast'" />
<AdminChatPage v-else-if="current === 'chat'" />
<AdminUsersPage v-else-if="current === 'users'" />
<AdminPlayersPage v-else-if="current === 'players'" />
<AdminReviewPage v-else-if="current === 'review'" />
<AdminLauncherPage v-else-if="current === 'launcher'" />
<AdminModsPage v-else-if="current === 'mods'" />
<AdminWikiPage v-else-if="current === 'wiki'" />
<AdminDownloadsPage v-else-if="current === 'downloads'" />
<AdminDonationsPage v-else-if="current === 'donations' && hasWebAdmin" />
<p v-else class="m-0 text-sm text-secondary">{{ current }}</p>
</section>
</div>
</div>
</template>
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module'
import test from 'node:test'
import { createRenderer, nextTick, ref } from 'vue'
const testSession = {
skinSiteStatus: ref('checking'),
skinSiteUser: ref<{ uuid: string } | null>(null),
}
const testGlobal = globalThis as unknown as {
skinSiteAnnouncementSession: typeof testSession
skinSiteAnnouncementFetch: () => Promise<unknown>
}
testGlobal.skinSiteAnnouncementSession = testSession
const require = createRequire(import.meta.url)
const { parse, compileScript } = require('vue/compiler-sfc')
const componentUrl = new URL('./SkinSiteAnnouncements.vue', import.meta.url).href
const stubs: Record<string, string> = {
'@modrinth/assets': 'export const NewspaperIcon={}, SpinnerIcon={}, StarIcon={}',
'@modrinth/ui':
'export const commonMessages={retryButton:{defaultMessage:"Retry"}}; export const defineMessages=x=>x; export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})',
'@/helpers/skin-site-api':
'export const skinSiteAnnouncements=()=>globalThis.skinSiteAnnouncementFetch()',
'@/composables/skin-site-session':
'export const {skinSiteStatus,skinSiteUser}=globalThis.skinSiteAnnouncementSession; export const reloadSkinSiteSession=async()=>{}',
}
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier in stubs)
return {
url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`,
shortCircuit: true,
}
return nextResolve(specifier, context)
},
load(url, context, nextLoad) {
if (url !== componentUrl) return nextLoad(url, context)
const { descriptor } = parse(readFileSync(new URL(url), 'utf8'))
return {
format: 'module',
source: stripTypeScriptTypes(
compileScript(descriptor, { id: 'skin-announcement-test' }).content,
),
shortCircuit: true,
}
},
})
const { default: Announcements } = await import(componentUrl)
hooks.deregister()
Announcements.render = () => null
type Node = Record<string, unknown>
const renderer = createRenderer<Node, Node>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
insert() {},
remove() {},
setText() {},
setElementText() {},
patchProp() {},
parentNode: () => null,
nextSibling: () => null,
})
const flush = async () => {
await new Promise((resolve) => setImmediate(resolve))
await nextTick()
}
type State = {
announcements: Array<{ id: number }>
loading: boolean
failed: boolean
load: () => Promise<void>
}
let requests = 0
function mount(t: test.TestContext) {
const app = renderer.createApp(Announcements)
const instance = app.mount({})
t.after(() => app.unmount())
return instance.$.setupState as State
}
test.beforeEach(() => {
testSession.skinSiteStatus.value = 'checking'
testSession.skinSiteUser.value = null
requests = 0
testGlobal.skinSiteAnnouncementFetch = async () => {
requests++
return { success: true, payload: [{ id: 1, title: '公告', updateAt: 1, star: false }] }
}
})
test('announcements wait for startup renewal and reload when sign-in finishes', async (t) => {
const state = mount(t)
await flush()
assert.equal(requests, 0)
assert.equal(state.loading, true)
testSession.skinSiteUser.value = { uuid: 'owner' }
testSession.skinSiteStatus.value = 'signed-in'
await flush()
assert.equal(requests, 1)
assert.equal(state.announcements.length, 1)
})
test('signed-out users do not request protected announcements or see a load failure', async (t) => {
testSession.skinSiteStatus.value = 'signed-out'
const state = mount(t)
await flush()
assert.equal(requests, 0)
assert.equal(state.loading, false)
assert.equal(state.failed, false)
})
test('signing out clears announcements and ignores an old in-flight response', async (t) => {
testSession.skinSiteStatus.value = 'signed-in'
let finish!: (value: unknown) => void
testGlobal.skinSiteAnnouncementFetch = () =>
new Promise((resolve) => {
finish = resolve
})
const state = mount(t)
await flush()
testSession.skinSiteStatus.value = 'signed-out'
await flush()
finish({ success: true, payload: [{ id: 2 }] })
await flush()
assert.deepEqual(state.announcements, [])
assert.equal(state.failed, false)
})
test('a failed announcement request can be retried', async (t) => {
testSession.skinSiteStatus.value = 'signed-in'
testGlobal.skinSiteAnnouncementFetch = async () => ({ success: false })
const state = mount(t)
await flush()
assert.equal(state.failed, true)
testGlobal.skinSiteAnnouncementFetch = async () => ({ success: true, payload: [{ id: 3 }] })
await state.load()
assert.equal(state.failed, false)
assert.equal(state.announcements[0].id, 3)
})
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { NewspaperIcon, SpinnerIcon, StarIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { onUnmounted, ref, watch } from 'vue'
import {
reloadSkinSiteSession,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { type SkinSiteAnnouncement, skinSiteAnnouncements } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.skin-site.home.announcements.title', defaultMessage: 'Announcements' },
empty: {
id: 'app.skin-site.home.announcements.empty',
defaultMessage: 'No announcements right now.',
},
loading: { id: 'app.skin-site.home.announcements.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.home.announcements.failed',
defaultMessage: 'Could not load announcements.',
},
signedOut: {
id: 'app.skin-site.home.announcements.signed-out',
defaultMessage: 'Sign in to view announcements.',
},
})
const announcements = ref<SkinSiteAnnouncement[]>([])
const loading = ref(true)
const failed = ref(false)
let requestId = 0
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleDateString()
} catch {
return ''
}
}
async function load() {
const currentRequest = ++requestId
announcements.value = []
failed.value = skinSiteStatus.value === 'error'
loading.value = skinSiteStatus.value === 'checking'
if (skinSiteStatus.value !== 'signed-in') return
loading.value = true
try {
const result = await skinSiteAnnouncements()
if (currentRequest !== requestId) return
if (result.success) {
announcements.value = [...(result.payload ?? [])]
.sort((a, b) => {
if (a.star !== b.star) return a.star ? -1 : 1
return b.updateAt - a.updateAt
})
.slice(0, 5)
} else {
failed.value = true
}
} catch {
if (currentRequest === requestId) failed.value = true
} finally {
if (currentRequest === requestId) loading.value = false
}
}
async function retry() {
if (skinSiteStatus.value === 'error') await reloadSkinSiteSession()
else await load()
}
watch([skinSiteStatus, () => skinSiteUser.value?.uuid], load, { immediate: true })
onUnmounted(() => {
requestId++
})
</script>
<template>
<section class="announcements">
<header class="flex items-center gap-2 border-0 border-b border-solid border-surface-5 pb-4">
<NewspaperIcon class="size-5 text-brand" />
<h2 class="m-0 text-base font-semibold text-contrast">{{ formatMessage(messages.title) }}</h2>
<span
v-if="announcements.length"
class="ml-auto rounded-full bg-button-bg px-2 py-0.5 text-xs text-secondary"
>{{ announcements.length }}</span
>
</header>
<div v-if="loading" class="announcement-feedback" role="status">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<div v-else-if="failed" class="announcement-feedback">
<p class="m-0">{{ formatMessage(messages.failed) }}</p>
<button type="button" class="text-brand hover:underline" @click="retry">
{{ formatMessage(commonMessages.retryButton) }}
</button>
</div>
<p v-else-if="skinSiteStatus === 'signed-out'" class="announcement-feedback">
{{ formatMessage(messages.signedOut) }}
</p>
<p v-else-if="!announcements.length" class="announcement-feedback">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col">
<article v-for="item in announcements" :key="item.id" class="announcement-entry">
<div class="flex items-start gap-2">
<StarIcon v-if="item.star" class="mt-0.5 size-4 shrink-0 text-brand" />
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ item.title }}
</span>
<span class="shrink-0 text-xs text-secondary">{{ formatDate(item.updateAt) }}</span>
</div>
<p v-if="item.subtitle" class="m-0 text-sm leading-relaxed text-secondary">
{{ item.subtitle }}
</p>
</article>
</div>
</section>
</template>
<style scoped>
.announcements {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.5rem;
padding: 1.5rem;
border: 1px solid var(--color-divider);
border-radius: var(--radius-xl);
background: var(--color-raised-bg);
}
.announcement-entry {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 1.25rem 0;
border-bottom: 1px solid var(--color-divider);
}
.announcement-entry:last-child {
border-bottom: 0;
padding-bottom: 0.25rem;
}
.announcement-feedback {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.625rem;
margin: 0;
padding: 1.5rem 0 0.5rem;
color: var(--color-secondary);
font-size: 0.875rem;
}
</style>
@@ -0,0 +1,187 @@
<script setup lang="ts">
import {
defineMessages,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { type SkinSiteBugIssueType, skinSiteCreateBugIssue } from '@/helpers/skin-site-api'
const emit = defineEmits<{ (e: 'created', id: number): void }>()
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.bug-create.title', defaultMessage: 'New issue' },
type: { id: 'app.skin-site.bug-create.type', defaultMessage: 'Type' },
titleField: { id: 'app.skin-site.bug-create.title-field', defaultMessage: 'Title' },
titlePlaceholder: {
id: 'app.skin-site.bug-create.title-placeholder',
defaultMessage: 'A short summary of the problem',
},
description: { id: 'app.skin-site.bug-create.description', defaultMessage: 'Description' },
descriptionPlaceholder: {
id: 'app.skin-site.bug-create.description-placeholder',
defaultMessage: 'What happened, and how can it be reproduced?',
},
privateLabel: { id: 'app.skin-site.bug-create.private', defaultMessage: 'Private issue' },
privateHint: {
id: 'app.skin-site.bug-create.private-hint',
defaultMessage: 'Only you and the maintainers can see it.',
},
images: { id: 'app.skin-site.bug-create.images', defaultMessage: 'Images' },
imagesHint: {
id: 'app.skin-site.bug-create.images-hint',
defaultMessage: 'Optional, up to 5 images.',
},
submit: { id: 'app.skin-site.bug-create.submit', defaultMessage: 'Create issue' },
submitting: { id: 'app.skin-site.bug-create.submitting', defaultMessage: 'Creating…' },
created: { id: 'app.skin-site.bug-create.created', defaultMessage: 'Issue created.' },
cancel: { id: 'app.skin-site.bug-create.cancel', defaultMessage: 'Cancel' },
typeBug: { id: 'app.skin-site.bugs.type.bug', defaultMessage: 'Bug' },
typeFeature: { id: 'app.skin-site.bugs.type.feature', defaultMessage: 'Feature' },
typeImprovement: { id: 'app.skin-site.bugs.type.improvement', defaultMessage: 'Improvement' },
})
const modal = ref<InstanceType<typeof NewModal>>()
const type = ref<SkinSiteBugIssueType>('BUG')
const title = ref('')
const description = ref('')
const isPrivate = ref(false)
const images = ref<File[]>([])
const working = ref(false)
const TYPE_OPTIONS: SkinSiteBugIssueType[] = ['BUG', 'FEATURE', 'IMPROVEMENT']
function typeLabel(t: SkinSiteBugIssueType): string {
if (t === 'FEATURE') return formatMessage(messages.typeFeature)
if (t === 'IMPROVEMENT') return formatMessage(messages.typeImprovement)
return formatMessage(messages.typeBug)
}
const canSubmit = computed(
() => title.value.trim().length > 0 && description.value.trim().length > 0 && !working.value,
)
function reset() {
type.value = 'BUG'
title.value = ''
description.value = ''
isPrivate.value = false
images.value = []
working.value = false
}
function show() {
reset()
modal.value?.show()
}
function onFiles(event: Event) {
const input = event.target as HTMLInputElement
images.value = Array.from(input.files ?? []).slice(0, 5)
}
async function submit() {
if (!canSubmit.value) return
working.value = true
try {
const result = await skinSiteCreateBugIssue(
{
type: type.value,
title: title.value.trim(),
description: description.value.trim(),
private: isPrivate.value,
},
images.value,
)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.created) })
modal.value?.hide()
emit('created', result.payload.id)
} catch (error) {
handleError(error)
} finally {
working.value = false
}
}
function cancel() {
modal.value?.hide()
reset()
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="reset">
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.type) }}</span>
<select
v-model="type"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast"
>
<option v-for="t in TYPE_OPTIONS" :key="t" :value="t">{{ typeLabel(t) }}</option>
</select>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.titleField) }}</span>
<StyledInput
v-model="title"
type="text"
:placeholder="formatMessage(messages.titlePlaceholder)"
/>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.description) }}</span>
<textarea
v-model="description"
rows="5"
class="w-full resize-y rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
:placeholder="formatMessage(messages.descriptionPlaceholder)"
/>
</label>
<label class="flex items-start gap-2">
<input v-model="isPrivate" type="checkbox" class="mt-1" />
<span class="flex flex-col">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.privateLabel) }}
</span>
<span class="text-xs text-secondary">{{ formatMessage(messages.privateHint) }}</span>
</span>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.images) }}</span>
<input type="file" accept="image/*" multiple @change="onFiles" />
<span class="text-xs text-secondary">{{ formatMessage(messages.imagesHint) }}</span>
</label>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="cancel"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSubmit"
@click="submit"
>
{{ working ? formatMessage(messages.submitting) : formatMessage(messages.submit) }}
</button>
</div>
</div>
</NewModal>
</template>
@@ -0,0 +1,425 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteBugComment,
skinSiteBugComments,
type SkinSiteBugIssue,
skinSiteBugIssue,
type SkinSiteBugIssueType,
type SkinSiteBugPriority,
type SkinSiteBugResolution,
type SkinSiteBugStatus,
skinSiteCreateBugComment,
skinSiteDeleteBugComment,
} from '@/helpers/skin-site-api'
const props = defineProps<{ issueId: number }>()
const emit = defineEmits<{ (e: 'back'): void }>()
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
back: { id: 'app.skin-site.bug-detail.back', defaultMessage: 'Back to list' },
loading: { id: 'app.skin-site.bug-detail.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.bug-detail.failed', defaultMessage: 'Could not load this issue.' },
issueNo: { id: 'app.skin-site.bug-detail.issue-no', defaultMessage: 'Issue #{id}' },
by: { id: 'app.skin-site.bug-detail.by', defaultMessage: 'by {name}' },
assignee: { id: 'app.skin-site.bug-detail.assignee', defaultMessage: 'Assignee: {name}' },
unassigned: { id: 'app.skin-site.bug-detail.unassigned', defaultMessage: 'Unassigned' },
descriptionEmpty: {
id: 'app.skin-site.bug-detail.description-empty',
defaultMessage: 'No description provided.',
},
resolution: { id: 'app.skin-site.bug-detail.resolution', defaultMessage: 'Resolution: {name}' },
commentsTitle: { id: 'app.skin-site.bug-detail.comments-title', defaultMessage: 'Comments' },
commentsEmpty: {
id: 'app.skin-site.bug-detail.comments-empty',
defaultMessage: 'No comments yet. Be the first to reply.',
},
commentsLoading: {
id: 'app.skin-site.bug-detail.comments-loading',
defaultMessage: 'Loading comments…',
},
commentPlaceholder: {
id: 'app.skin-site.bug-detail.comment-placeholder',
defaultMessage: 'Write a comment…',
},
commentSubmit: { id: 'app.skin-site.bug-detail.comment-submit', defaultMessage: 'Comment' },
commentSending: { id: 'app.skin-site.bug-detail.comment-sending', defaultMessage: 'Sending…' },
commentEmptyError: {
id: 'app.skin-site.bug-detail.comment-empty-error',
defaultMessage: 'Comment cannot be empty.',
},
commentPosted: {
id: 'app.skin-site.bug-detail.comment-posted',
defaultMessage: 'Comment posted.',
},
commentDeleted: {
id: 'app.skin-site.bug-detail.comment-deleted',
defaultMessage: 'Comment deleted.',
},
delete: { id: 'app.skin-site.bug-detail.delete', defaultMessage: 'Delete' },
deleting: { id: 'app.skin-site.bug-detail.deleting', defaultMessage: 'Deleting…' },
system: { id: 'app.skin-site.bug-detail.system', defaultMessage: 'System' },
typeBug: { id: 'app.skin-site.bugs.type.bug', defaultMessage: 'Bug' },
typeFeature: { id: 'app.skin-site.bugs.type.feature', defaultMessage: 'Feature' },
typeImprovement: { id: 'app.skin-site.bugs.type.improvement', defaultMessage: 'Improvement' },
statusPending: { id: 'app.skin-site.bugs.status.pending', defaultMessage: 'Pending' },
statusConfirmed: { id: 'app.skin-site.bugs.status.confirmed', defaultMessage: 'Confirmed' },
statusNeedsInfo: { id: 'app.skin-site.bugs.status.needs-info', defaultMessage: 'Needs info' },
statusInProgress: { id: 'app.skin-site.bugs.status.in-progress', defaultMessage: 'In progress' },
statusVerifying: { id: 'app.skin-site.bugs.status.verifying', defaultMessage: 'Verifying' },
statusClosed: { id: 'app.skin-site.bugs.status.closed', defaultMessage: 'Closed' },
priorityCritical: { id: 'app.skin-site.bugs.priority.critical', defaultMessage: 'Critical' },
priorityHigh: { id: 'app.skin-site.bugs.priority.high', defaultMessage: 'High' },
priorityNormal: { id: 'app.skin-site.bugs.priority.normal', defaultMessage: 'Normal' },
priorityLow: { id: 'app.skin-site.bugs.priority.low', defaultMessage: 'Low' },
resFixed: { id: 'app.skin-site.bugs.resolution.fixed', defaultMessage: 'Fixed' },
resWontFix: { id: 'app.skin-site.bugs.resolution.wont-fix', defaultMessage: 'Won’t fix' },
resByDesign: { id: 'app.skin-site.bugs.resolution.by-design', defaultMessage: 'By design' },
resDuplicate: { id: 'app.skin-site.bugs.resolution.duplicate', defaultMessage: 'Duplicate' },
resCannotReproduce: {
id: 'app.skin-site.bugs.resolution.cannot-reproduce',
defaultMessage: 'Cannot reproduce',
},
resInvalid: { id: 'app.skin-site.bugs.resolution.invalid', defaultMessage: 'Invalid' },
})
const issue = ref<SkinSiteBugIssue | null>(null)
const loading = ref(true)
const failed = ref(false)
const comments = ref<SkinSiteBugComment[]>([])
const commentsLoading = ref(true)
const commentsHasMore = ref(false)
const commentsLoadingMore = ref(false)
const commentDraft = ref('')
const commentSending = ref(false)
const deletingId = ref<number | null>(null)
function typeLabel(type: SkinSiteBugIssueType): string {
switch (type) {
case 'FEATURE':
return formatMessage(messages.typeFeature)
case 'IMPROVEMENT':
return formatMessage(messages.typeImprovement)
default:
return formatMessage(messages.typeBug)
}
}
function statusLabel(status: SkinSiteBugStatus): string {
switch (status) {
case 'CONFIRMED':
return formatMessage(messages.statusConfirmed)
case 'NEEDS_INFO':
return formatMessage(messages.statusNeedsInfo)
case 'IN_PROGRESS':
return formatMessage(messages.statusInProgress)
case 'VERIFYING':
return formatMessage(messages.statusVerifying)
case 'CLOSED':
return formatMessage(messages.statusClosed)
default:
return formatMessage(messages.statusPending)
}
}
function priorityLabel(priority: SkinSiteBugPriority): string {
switch (priority) {
case 'CRITICAL':
return formatMessage(messages.priorityCritical)
case 'HIGH':
return formatMessage(messages.priorityHigh)
case 'LOW':
return formatMessage(messages.priorityLow)
default:
return formatMessage(messages.priorityNormal)
}
}
function resolutionLabel(resolution: SkinSiteBugResolution): string {
switch (resolution) {
case 'WONT_FIX':
return formatMessage(messages.resWontFix)
case 'BY_DESIGN':
return formatMessage(messages.resByDesign)
case 'DUPLICATE':
return formatMessage(messages.resDuplicate)
case 'CANNOT_REPRODUCE':
return formatMessage(messages.resCannotReproduce)
case 'INVALID':
return formatMessage(messages.resInvalid)
default:
return formatMessage(messages.resFixed)
}
}
function formatDateTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
const canSubmit = computed(() => !commentSending.value && commentDraft.value.trim().length > 0)
async function loadIssue() {
loading.value = true
failed.value = false
try {
const result = await skinSiteBugIssue(props.issueId)
if (!result.success) throw new Error(result.errorMessage)
issue.value = result.payload
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function loadComments(reset = true) {
if (reset) {
commentsLoading.value = true
comments.value = []
} else {
commentsLoadingMore.value = true
}
try {
const result = await skinSiteBugComments(props.issueId, reset ? 0 : comments.value.length, 50)
if (!result.success) throw new Error(result.errorMessage)
const page = result.payload
comments.value = reset ? page.items : [...comments.value, ...page.items]
commentsHasMore.value = page.hasMore
} catch (error) {
handleError(error)
} finally {
commentsLoading.value = false
commentsLoadingMore.value = false
}
}
async function submitComment() {
const content = commentDraft.value.trim()
if (!content || commentSending.value) return
commentSending.value = true
try {
const result = await skinSiteCreateBugComment(props.issueId, content)
if (!result.success) throw new Error(result.errorMessage)
comments.value = [...comments.value, result.payload]
commentDraft.value = ''
addNotification({ type: 'success', title: formatMessage(messages.commentPosted) })
} catch (error) {
handleError(error)
} finally {
commentSending.value = false
}
}
async function deleteComment(comment: SkinSiteBugComment) {
if (deletingId.value !== null) return
deletingId.value = comment.id
try {
const result = await skinSiteDeleteBugComment(comment.id)
if (!result.success) throw new Error(result.errorMessage)
comments.value = comments.value.filter((c) => c.id !== comment.id)
addNotification({ type: 'success', title: formatMessage(messages.commentDeleted) })
} catch (error) {
handleError(error)
} finally {
deletingId.value = null
}
}
onMounted(() => {
void loadIssue()
void loadComments(true)
})
</script>
<template>
<div class="flex flex-col gap-4">
<button
type="button"
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<template v-else-if="issue">
<header
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<div class="flex flex-wrap items-center gap-2">
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ typeLabel(issue.type) }}
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="{
'bg-green text-contrast':
issue.status === 'CONFIRMED' || issue.status === 'VERIFYING',
'bg-yellow text-contrast':
issue.status === 'NEEDS_INFO' || issue.status === 'IN_PROGRESS',
'bg-button-bg text-secondary': issue.status === 'PENDING',
'bg-surface-5 text-secondary': issue.status === 'CLOSED',
}"
>
{{ statusLabel(issue.status) }}
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="{
'bg-red text-contrast': issue.priority === 'CRITICAL',
'bg-orange text-contrast': issue.priority === 'HIGH',
'bg-button-bg text-secondary':
issue.priority === 'NORMAL' || issue.priority === 'LOW',
}"
>
{{ priorityLabel(issue.priority) }}
</span>
<span v-if="issue.private" class="shrink-0 text-[10px] font-semibold text-secondary">
🔒
</span>
<span class="min-w-0 flex-1 truncate text-xs text-secondary">
{{ formatMessage(messages.issueNo, { id: issue.id }) }}
</span>
</div>
<h1 class="m-0 text-xl font-bold text-contrast">{{ issue.title }}</h1>
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
<span>{{ formatMessage(messages.by, { name: issue.authorName }) }}</span>
<span>{{ formatDateTime(issue.createdAt) }}</span>
<span v-if="issue.assigneeName">
{{ formatMessage(messages.assignee, { name: issue.assigneeName }) }}
</span>
<span v-else>{{ formatMessage(messages.unassigned) }}</span>
</div>
<span v-if="issue.resolution" class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.resolution, { name: resolutionLabel(issue.resolution) }) }}
</span>
</header>
<div class="rounded-xl border border-solid border-surface-5 bg-bg-raised p-4">
<p
v-if="issue.description"
class="m-0 whitespace-pre-wrap break-words text-sm text-contrast"
>
{{ issue.description }}
</p>
<p v-else class="m-0 text-sm text-secondary">
{{ formatMessage(messages.descriptionEmpty) }}
</p>
<div v-if="issue.images.length" class="mt-3 flex flex-wrap gap-2">
<span
v-for="image in issue.images"
:key="image.hash"
class="rounded-lg bg-surface-5 px-2 py-1 text-[10px] font-mono text-secondary"
>
{{ image.hash }}
</span>
</div>
</div>
<section class="flex flex-col gap-3">
<h2 class="m-0 text-base font-bold text-contrast">
{{ formatMessage(messages.commentsTitle) }}
</h2>
<div class="flex flex-col gap-2">
<div v-if="commentsLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.commentsLoading) }}
</div>
<p
v-else-if="!comments.length"
class="m-0 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.commentsEmpty) }}
</p>
<div
v-for="comment in comments"
v-else
:key="comment.id"
class="flex flex-col gap-1.5 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
<span class="font-semibold text-contrast">{{ comment.authorName }}</span>
<span
v-if="comment.system"
class="rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold"
>
{{ formatMessage(messages.system) }}
</span>
<span>{{ formatDateTime(comment.createdAt) }}</span>
<span v-if="comment.editedAt">(edited)</span>
<button
v-if="comment.canDelete"
type="button"
class="ml-auto rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary hover:brightness-110 disabled:opacity-50"
:disabled="deletingId !== null"
@click="deleteComment(comment)"
>
{{
deletingId === comment.id
? formatMessage(messages.deleting)
: formatMessage(messages.delete)
}}
</button>
</div>
<p class="m-0 whitespace-pre-wrap break-words text-sm text-contrast">
{{ comment.content }}
</p>
</div>
</div>
<button
v-if="commentsHasMore"
type="button"
class="w-fit self-center rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="commentsLoadingMore"
@click="loadComments(false)"
>
{{ formatMessage(messages.commentsTitle) }}
</button>
<form class="flex flex-col gap-2" @submit.prevent="submitComment">
<textarea
v-model="commentDraft"
rows="3"
class="w-full resize-y rounded-xl border border-solid border-surface-5 bg-bg-raised p-3 text-sm text-contrast outline-none focus:border-brand"
:placeholder="formatMessage(messages.commentPlaceholder)"
/>
<button
type="submit"
class="w-fit self-end rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSubmit"
>
{{
commentSending
? formatMessage(messages.commentSending)
: formatMessage(messages.commentSubmit)
}}
</button>
</form>
</section>
</template>
</div>
</template>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
type SkinSiteBugIssue,
skinSiteBugIssues,
type SkinSiteBugIssueType,
type SkinSiteBugPriority,
type SkinSiteBugStatus,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.bugs.title', defaultMessage: 'Bugs & suggestions' },
back: { id: 'app.skin-site.bugs.back', defaultMessage: 'Back to home' },
subtitle: {
id: 'app.skin-site.bugs.subtitle',
defaultMessage: 'Report issues and follow their progress.',
},
loading: { id: 'app.skin-site.bugs.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.bugs.failed', defaultMessage: 'Could not load issues.' },
empty: { id: 'app.skin-site.bugs.empty', defaultMessage: 'No issues match these filters.' },
all: { id: 'app.skin-site.bugs.all', defaultMessage: 'All' },
more: { id: 'app.skin-site.bugs.more', defaultMessage: 'Load more' },
openOnly: { id: 'app.skin-site.bugs.open-only', defaultMessage: 'Open only' },
create: { id: 'app.skin-site.bugs.create', defaultMessage: 'New issue' },
notifications: {
id: 'app.skin-site.bugs.notifications',
defaultMessage: 'Notifications',
},
comments: { id: 'app.skin-site.bugs.comments', defaultMessage: '{count} comments' },
typeBug: { id: 'app.skin-site.bugs.type.bug', defaultMessage: 'Bug' },
typeFeature: { id: 'app.skin-site.bugs.type.feature', defaultMessage: 'Feature' },
typeImprovement: { id: 'app.skin-site.bugs.type.improvement', defaultMessage: 'Improvement' },
statusPending: { id: 'app.skin-site.bugs.status.pending', defaultMessage: 'Pending' },
statusConfirmed: { id: 'app.skin-site.bugs.status.confirmed', defaultMessage: 'Confirmed' },
statusNeedsInfo: { id: 'app.skin-site.bugs.status.needs-info', defaultMessage: 'Needs info' },
statusInProgress: { id: 'app.skin-site.bugs.status.in-progress', defaultMessage: 'In progress' },
statusVerifying: { id: 'app.skin-site.bugs.status.verifying', defaultMessage: 'Verifying' },
statusClosed: { id: 'app.skin-site.bugs.status.closed', defaultMessage: 'Closed' },
priorityCritical: { id: 'app.skin-site.bugs.priority.critical', defaultMessage: 'Critical' },
priorityHigh: { id: 'app.skin-site.bugs.priority.high', defaultMessage: 'High' },
priorityNormal: { id: 'app.skin-site.bugs.priority.normal', defaultMessage: 'Normal' },
priorityLow: { id: 'app.skin-site.bugs.priority.low', defaultMessage: 'Low' },
})
const emit = defineEmits<{
(e: 'back' | 'create' | 'notifications'): void
(e: 'open', id: number): void
}>()
const items = ref<SkinSiteBugIssue[]>([])
const hasMore = ref(false)
const loading = ref(true)
const loadingMore = ref(false)
const failed = ref(false)
const typeFilter = ref<SkinSiteBugIssueType | ''>('')
const statusFilter = ref<SkinSiteBugStatus | ''>('')
const priorityFilter = ref<SkinSiteBugPriority | ''>('')
const openOnly = ref(true)
const TYPE_OPTIONS: SkinSiteBugIssueType[] = ['BUG', 'FEATURE', 'IMPROVEMENT']
const STATUS_OPTIONS: SkinSiteBugStatus[] = [
'PENDING',
'CONFIRMED',
'NEEDS_INFO',
'IN_PROGRESS',
'VERIFYING',
'CLOSED',
]
const PRIORITY_OPTIONS: SkinSiteBugPriority[] = ['CRITICAL', 'HIGH', 'NORMAL', 'LOW']
function typeLabel(type: SkinSiteBugIssueType): string {
switch (type) {
case 'FEATURE':
return formatMessage(messages.typeFeature)
case 'IMPROVEMENT':
return formatMessage(messages.typeImprovement)
default:
return formatMessage(messages.typeBug)
}
}
function statusLabel(status: SkinSiteBugStatus): string {
switch (status) {
case 'CONFIRMED':
return formatMessage(messages.statusConfirmed)
case 'NEEDS_INFO':
return formatMessage(messages.statusNeedsInfo)
case 'IN_PROGRESS':
return formatMessage(messages.statusInProgress)
case 'VERIFYING':
return formatMessage(messages.statusVerifying)
case 'CLOSED':
return formatMessage(messages.statusClosed)
default:
return formatMessage(messages.statusPending)
}
}
function priorityLabel(priority: SkinSiteBugPriority): string {
switch (priority) {
case 'CRITICAL':
return formatMessage(messages.priorityCritical)
case 'HIGH':
return formatMessage(messages.priorityHigh)
case 'LOW':
return formatMessage(messages.priorityLow)
default:
return formatMessage(messages.priorityNormal)
}
}
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleDateString()
} catch {
return ''
}
}
async function load(reset = true) {
if (reset) {
loading.value = true
items.value = []
} else {
loadingMore.value = true
}
failed.value = false
try {
const result = await skinSiteBugIssues({
offset: reset ? 0 : items.value.length,
limit: 20,
type: typeFilter.value || undefined,
status: statusFilter.value || undefined,
priority: priorityFilter.value || undefined,
openOnly: openOnly.value,
})
if (!result.success) throw new Error(result.errorMessage)
const page = result.payload
items.value = reset ? page.items : [...items.value, ...page.items]
hasMore.value = page.hasMore
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
loadingMore.value = false
}
}
function clearFilters() {
typeFilter.value = ''
statusFilter.value = ''
priorityFilter.value = ''
void load(true)
}
onMounted(() => load(true))
</script>
<template>
<div class="flex flex-col gap-4">
<button
type="button"
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<header class="flex flex-wrap items-start gap-3">
<div class="flex flex-col gap-1">
<h1 class="m-0 text-xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
</div>
<div class="ml-auto flex items-center gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="emit('notifications')"
>
{{ formatMessage(messages.notifications) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="emit('create')"
>
{{ formatMessage(messages.create) }}
</button>
</div>
</header>
<div class="flex flex-wrap items-center gap-2">
<select
v-model="typeFilter"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
@change="load(true)"
>
<option value="">{{ formatMessage(messages.all) }}</option>
<option v-for="t in TYPE_OPTIONS" :key="t" :value="t">{{ typeLabel(t) }}</option>
</select>
<select
v-model="statusFilter"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
@change="load(true)"
>
<option value="">{{ formatMessage(messages.all) }}</option>
<option v-for="s in STATUS_OPTIONS" :key="s" :value="s">{{ statusLabel(s) }}</option>
</select>
<select
v-model="priorityFilter"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
@change="load(true)"
>
<option value="">{{ formatMessage(messages.all) }}</option>
<option v-for="p in PRIORITY_OPTIONS" :key="p" :value="p">{{ priorityLabel(p) }}</option>
</select>
<label class="flex items-center gap-1.5 text-xs text-secondary">
<input v-model="openOnly" type="checkbox" @change="load(true)" />
{{ formatMessage(messages.openOnly) }}
</label>
<button
type="button"
class="ml-auto rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="clearFilters"
>
{{ formatMessage(messages.all) }}
</button>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!items.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="issue in items"
:key="issue.id"
class="flex cursor-pointer flex-col gap-1.5 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3 hover:border-brand"
@click="emit('open', issue.id)"
>
<div class="flex flex-wrap items-center gap-2">
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ typeLabel(issue.type) }}
</span>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
#{{ issue.id }} {{ issue.title }}
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="{
'bg-green text-contrast':
issue.status === 'CONFIRMED' || issue.status === 'VERIFYING',
'bg-yellow text-contrast':
issue.status === 'NEEDS_INFO' || issue.status === 'IN_PROGRESS',
'bg-button-bg text-secondary': issue.status === 'PENDING',
'bg-surface-5 text-secondary': issue.status === 'CLOSED',
}"
>
{{ statusLabel(issue.status) }}
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="{
'bg-red text-contrast': issue.priority === 'CRITICAL',
'bg-orange text-contrast': issue.priority === 'HIGH',
'bg-button-bg text-secondary':
issue.priority === 'NORMAL' || issue.priority === 'LOW',
}"
>
{{ priorityLabel(issue.priority) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
<span>{{ issue.authorName }}</span>
<span>{{ formatDate(issue.createdAt) }}</span>
<span>{{ formatMessage(messages.comments, { count: issue.commentCount }) }}</span>
<span v-if="issue.assigneeName">→ {{ issue.assigneeName }}</span>
</div>
</div>
<button
v-if="hasMore"
type="button"
class="w-fit self-center rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="loadingMore"
@click="load(false)"
>
{{ formatMessage(messages.more) }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
type SkinSiteBugNotification,
skinSiteBugNotifications,
skinSiteMarkBugNotificationRead,
} from '@/helpers/skin-site-api'
const emit = defineEmits<{ (e: 'back'): void; (e: 'open', id: number): void }>()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.bug-notifications.title', defaultMessage: 'Notifications' },
back: { id: 'app.skin-site.bug-notifications.back', defaultMessage: 'Back to home' },
subtitle: {
id: 'app.skin-site.bug-notifications.subtitle',
defaultMessage: 'Activity on issues you reported or follow.',
},
loading: { id: 'app.skin-site.bug-notifications.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.bug-notifications.failed',
defaultMessage: 'Could not load notifications.',
},
empty: { id: 'app.skin-site.bug-notifications.empty', defaultMessage: 'No new notifications.' },
item: {
id: 'app.skin-site.bug-notifications.item',
defaultMessage: '{name} updated issue #{id}',
},
markAll: {
id: 'app.skin-site.bug-notifications.mark-all',
defaultMessage: 'Mark all as read',
},
markedAll: {
id: 'app.skin-site.bug-notifications.marked-all',
defaultMessage: 'All notifications marked as read.',
},
})
const items = ref<SkinSiteBugNotification[]>([])
const loading = ref(true)
const failed = ref(false)
const marking = ref(false)
function formatDateTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteBugNotifications()
if (!result.success) throw new Error(result.errorMessage)
items.value = result.payload ?? []
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function markAllRead() {
if (marking.value || !items.value.length) return
marking.value = true
try {
await skinSiteMarkBugNotificationRead()
items.value = []
} catch (error) {
handleError(error)
} finally {
marking.value = false
}
}
onMounted(() => void load())
</script>
<template>
<div class="flex flex-col gap-4">
<button
type="button"
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<header class="flex flex-wrap items-center gap-3">
<div class="flex flex-col gap-1">
<h1 class="m-0 text-xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
</div>
<button
v-if="items.length"
type="button"
class="ml-auto rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110 disabled:opacity-50"
:disabled="marking"
@click="markAllRead"
>
{{ formatMessage(messages.markAll) }}
</button>
</header>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p
v-else-if="!items.length"
class="m-0 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="item in items"
:key="item.id"
class="flex cursor-pointer flex-wrap items-center gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3 hover:border-brand"
@click="emit('open', item.issueId)"
>
<span class="min-w-0 flex-1 truncate text-sm text-contrast">
{{ formatMessage(messages.item, { name: item.actorName, id: item.issueId }) }}
</span>
<span class="shrink-0 text-xs text-secondary">{{ formatDateTime(item.createdAt) }}</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,156 @@
<script setup lang="ts">
import {
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { requestCaptcha, verifyCaptcha } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.captcha.title', defaultMessage: 'Let us check you are human' },
subtitle: {
id: 'app.skin-site.captcha.subtitle',
defaultMessage: 'Drag the slider to rotate the image upright.',
},
submit: { id: 'app.skin-site.captcha.submit', defaultMessage: 'Submit' },
loading: { id: 'app.skin-site.captcha.loading', defaultMessage: 'Loading captcha…' },
})
const emit = defineEmits<{ (e: 'verified', clientId: string): void; (e: 'cancel'): void }>()
const modal = ref<InstanceType<typeof NewModal>>()
const loading = ref(false)
const submitting = ref(false)
const image = ref('')
const challengeClientId = ref('')
const degree = ref(0)
/** The image is rotated by the server; the slider counter-rotates the preview. */
const previewRotation = computed(() => `${degree.value}deg`)
function resetState() {
degree.value = 0
image.value = ''
challengeClientId.value = ''
}
async function loadCaptcha() {
loading.value = true
try {
const result = await requestCaptcha()
if (!result.success) throw new Error(result.errorMessage)
image.value = result.payload.image ?? ''
challengeClientId.value = result.payload.clientId
degree.value = 0
} catch (error) {
handleError(error)
modal.value?.hide()
emit('cancel')
} finally {
loading.value = false
}
}
async function show() {
resetState()
modal.value?.show()
await loadCaptcha()
}
async function submit() {
if (submitting.value || !challengeClientId.value) return
submitting.value = true
try {
const result = await verifyCaptcha({
clientId: challengeClientId.value,
degree: degree.value,
})
if (!result.success) {
handleError(new Error(result.errorMessage))
await loadCaptcha()
return
}
modal.value?.hide()
emit('verified', result.payload)
} catch (error) {
handleError(error)
} finally {
submitting.value = false
}
}
function cancel() {
modal.value?.hide()
emit('cancel')
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="resetState">
<div class="flex flex-col items-center gap-6">
<p class="m-0 text-secondary">{{ formatMessage(messages.subtitle) }}</p>
<div
class="relative size-64 overflow-hidden rounded-2xl border-2 border-solid border-surface-5 bg-bg"
>
<img
v-if="image"
:src="image"
alt=""
class="size-full object-contain transition-transform duration-75"
:style="{ transform: `rotate(${previewRotation})` }"
draggable="false"
/>
<div v-else class="flex size-full items-center justify-center text-sm text-secondary">
{{ formatMessage(messages.loading) }}
</div>
</div>
<div class="w-full max-w-sm">
<input
v-model.number="degree"
type="range"
min="0"
max="360"
step="1"
class="w-full accent-brand"
:disabled="loading || submitting"
/>
<div class="mt-1 flex items-center justify-between text-xs text-secondary">
<span>0°</span>
<span class="font-semibold text-contrast">{{ degree }}°</span>
<span>360°</span>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
@click="cancel"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 font-semibold text-contrast"
:disabled="loading || submitting || !image"
@click="submit"
>
{{ formatMessage(loading ? messages.loading : messages.submit) }}
</button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ChevronRightIcon, HeartIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
type SkinSitePublicDonationSummary,
skinSitePublicDonationSummary,
} from '@/helpers/skin-site-api'
import { formatDonationAmount } from './donation-format'
import SkinSiteDonationModal from './SkinSiteDonationModal.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.skin-site.home.donation.title', defaultMessage: 'Donation thanks' },
total: { id: 'app.skin-site.home.donation.total', defaultMessage: 'Total raised' },
donors: { id: 'app.skin-site.home.donation.donors', defaultMessage: '{count} donors' },
unavailable: {
id: 'app.skin-site.home.donation.unavailable',
defaultMessage: 'Donation summary unavailable.',
},
loading: { id: 'app.skin-site.home.donation.loading', defaultMessage: 'Loading…' },
})
const summary = ref<SkinSitePublicDonationSummary | null>(null)
const loading = ref(true)
const failed = ref(false)
const donationModal = ref<InstanceType<typeof SkinSiteDonationModal>>()
onMounted(async () => {
try {
const result = await skinSitePublicDonationSummary()
if (result.success) summary.value = result.payload
else failed.value = true
} catch {
failed.value = true
} finally {
loading.value = false
}
})
</script>
<template>
<button type="button" class="donation-card" @click="donationModal?.show()">
<span class="donation-icon"><HeartIcon class="size-6" /></span>
<div class="flex min-w-0 flex-1 flex-col">
<span class="text-sm font-semibold text-contrast">{{ formatMessage(messages.title) }}</span>
<span v-if="loading" class="flex items-center gap-1 text-xs text-secondary">
<SpinnerIcon class="size-3 animate-spin" />
{{ formatMessage(messages.loading) }}
</span>
<span v-else-if="failed" class="text-xs text-secondary">
{{ formatMessage(messages.unavailable) }}
</span>
<span v-else-if="summary" class="text-xs text-secondary">{{
formatMessage(messages.donors, { count: summary.donorCount })
}}</span>
</div>
<strong v-if="summary" class="donation-amount">{{
formatDonationAmount(summary.totalAmount)
}}</strong>
<ChevronRightIcon class="size-5 shrink-0 text-secondary" />
</button>
<SkinSiteDonationModal ref="donationModal" />
</template>
<style scoped>
.donation-card {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
gap: 1rem;
padding: 1.25rem;
border: 1px solid var(--color-divider);
border-radius: var(--radius-xl);
background: var(--color-raised-bg);
color: var(--color-contrast);
text-align: left;
font: inherit;
cursor: pointer;
transition:
border-color 180ms,
background 180ms;
}
.donation-card:hover {
border-color: var(--color-brand);
background: var(--color-button-bg);
}
.donation-card:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: 3px;
}
.donation-icon {
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem;
border-radius: var(--radius-lg);
background: var(--color-brand-highlight);
color: var(--color-brand);
}
.donation-amount {
font-variant-numeric: tabular-nums;
font-size: 1.25rem;
white-space: nowrap;
}
@media (max-width: 520px) {
.donation-card {
gap: 0.625rem;
padding: 1rem;
}
.donation-amount {
font-size: 1rem;
}
.donation-icon {
display: none;
}
}
</style>
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module'
import test from 'node:test'
import { createRenderer, nextTick } from 'vue'
const testGlobal = globalThis as unknown as {
donationTestFetch: (offset: number) => Promise<unknown>
}
const require = createRequire(import.meta.url)
const { parse, compileScript } = require('vue/compiler-sfc')
const componentUrl = new URL('./SkinSiteDonationModal.vue', import.meta.url).href
const stubs: Record<string, string> = {
'@modrinth/assets': 'export const HeartIcon={}, SpinnerIcon={}, StarIcon={}',
'@modrinth/ui':
'export const Avatar={}, ButtonStyled={}, NewModal={}, commonMessages={retryButton:{defaultMessage:"Retry"}}; export const defineMessages=x=>x; export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})',
'@/helpers/skin-site-api':
'export const skinSiteDonationList=offset=>globalThis.donationTestFetch(offset)',
}
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier in stubs)
return {
url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`,
shortCircuit: true,
}
if (specifier === './donation-format')
return nextResolve(new URL('./donation-format.ts', import.meta.url).href, context)
return nextResolve(specifier, context)
},
load(url, context, nextLoad) {
if (url !== componentUrl) return nextLoad(url, context)
const { descriptor } = parse(readFileSync(new URL(url), 'utf8'))
return {
format: 'module',
source: stripTypeScriptTypes(compileScript(descriptor, { id: 'donation-test' }).content),
shortCircuit: true,
}
},
})
const { default: Donations } = await import(componentUrl)
hooks.deregister()
Donations.render = () => null
type Node = Record<string, unknown>
const renderer = createRenderer<Node, Node>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
insert() {},
remove() {},
setText() {},
setElementText() {},
patchProp() {},
parentNode: () => null,
nextSibling: () => null,
})
type State = {
items: Array<{ id: number }>
loading: boolean
failed: boolean
hasMore: boolean
load: (reset?: boolean) => Promise<void>
show: () => void
}
function mount(t: test.TestContext) {
const app = renderer.createApp(Donations)
const instance = app.mount({})
t.after(() => app.unmount())
return (instance.$ as unknown as { setupState: State }).setupState
}
const page = (id: number, hasMore = true) => ({
success: true,
payload: {
items: [{ id, rank: id, username: 'Donor', avatar: null, amount: '12.50' }],
summary: { totalAmount: '12.50', donorCount: 1 },
hasMore,
},
})
const flush = async () => {
await new Promise((resolve) => setImmediate(resolve))
await nextTick()
}
test('donations load on opening and a failed next page preserves the list and retries the same offset', async (t) => {
const offsets: number[] = []
let fail = false
testGlobal.donationTestFetch = async (offset) => {
offsets.push(offset)
return fail ? { success: false, errorMessage: 'Offline' } : page(offset + 1, offset === 0)
}
const state = mount(t)
assert.deepEqual(offsets, [])
state.show()
await flush()
assert.deepEqual(
state.items.map((item) => item.id),
[1],
)
fail = true
await state.load()
assert.equal(state.failed, true)
assert.deepEqual(
state.items.map((item) => item.id),
[1],
)
fail = false
await state.load()
assert.deepEqual(offsets, [0, 1, 1])
assert.deepEqual(
state.items.map((item) => item.id),
[1, 2],
)
assert.equal(state.hasMore, false)
})
test('reopening the donation list discards an older in-flight page', async (t) => {
const finish: Array<(result: unknown) => void> = []
testGlobal.donationTestFetch = () => new Promise((resolve) => finish.push(resolve))
const state = mount(t)
state.show()
state.show()
finish[1](page(2, false))
await flush()
finish[0](page(1))
await flush()
assert.deepEqual(
state.items.map((item) => item.id),
[2],
)
assert.equal(state.loading, false)
assert.equal(state.hasMore, false)
})
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { HeartIcon, SpinnerIcon, StarIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { onUnmounted, ref } from 'vue'
import {
skinSiteDonationList,
type SkinSitePublicDonationEntry,
type SkinSitePublicDonationSummary,
} from '@/helpers/skin-site-api'
import { formatDonationAmount } from './donation-format'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.skin-site.home.donation.title', defaultMessage: 'Donation thanks' },
total: { id: 'app.skin-site.home.donation.total', defaultMessage: 'Total raised' },
donors: { id: 'app.skin-site.home.donation.donors', defaultMessage: '{count} donors' },
thanks: {
id: 'app.skin-site.home.donation.thanks',
defaultMessage: 'Thank you to everyone who helps keep StarLight running.',
},
empty: { id: 'app.skin-site.home.donation.empty', defaultMessage: 'No donations yet.' },
failed: {
id: 'app.skin-site.home.donation.list-failed',
defaultMessage: 'Could not load donations.',
},
more: { id: 'app.skin-site.home.donation.more', defaultMessage: 'Load more' },
})
const modal = ref<InstanceType<typeof NewModal>>()
const items = ref<SkinSitePublicDonationEntry[]>([])
const summary = ref<SkinSitePublicDonationSummary | null>(null)
const loading = ref(false)
const failed = ref(false)
const hasMore = ref(false)
let generation = 0
async function load(reset = false) {
if (loading.value && !reset) return
const request = ++generation
if (reset) {
items.value = []
summary.value = null
hasMore.value = false
}
loading.value = true
failed.value = false
try {
const result = await skinSiteDonationList(items.value.length)
if (request !== generation) return
if (!result.success) throw new Error(result.errorMessage)
items.value = [...items.value, ...result.payload.items]
summary.value = result.payload.summary
hasMore.value = result.payload.hasMore
} catch {
if (request === generation) failed.value = true
} finally {
if (request === generation) loading.value = false
}
}
function show() {
modal.value?.show()
void load(true)
}
onUnmounted(() => {
generation++
})
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
max-width="42rem"
width="42rem"
scrollable
>
<div class="flex flex-col gap-6">
<div class="donation-summary">
<HeartIcon class="size-7 text-brand" />
<span class="text-sm text-secondary">{{ formatMessage(messages.total) }}</span>
<strong v-if="summary" class="text-3xl text-contrast tabular-nums">{{
formatDonationAmount(summary.totalAmount)
}}</strong>
<span v-if="summary" class="text-sm text-secondary">{{
formatMessage(messages.donors, { count: summary.donorCount })
}}</span>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.thanks) }}</p>
</div>
<div
v-if="loading && !items.length"
class="flex justify-center p-6 text-secondary"
role="status"
>
<SpinnerIcon class="size-6 animate-spin" />
</div>
<ol v-if="items.length" class="donation-list">
<li v-for="donor in items" :key="donor.id" class="donor-row">
<span class="donor-rank" :class="{ podium: donor.rank <= 3 }"
><StarIcon v-if="donor.rank <= 3" class="size-4" /><span v-else>#</span
>{{ donor.rank }}</span
>
<Avatar :src="donor.avatar ?? undefined" :alt="donor.username" size="2.25rem" />
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">{{
donor.username
}}</span>
<strong class="shrink-0 tabular-nums text-contrast">{{
formatDonationAmount(donor.amount)
}}</strong>
</li>
</ol>
<p v-else-if="!loading && !failed" class="m-0 py-4 text-center text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div
v-if="failed"
class="flex items-center justify-center gap-3 text-sm text-secondary"
role="alert"
>
<span>{{ formatMessage(messages.failed) }}</span>
<button
type="button"
class="border-0 bg-transparent text-brand cursor-pointer"
@click="load()"
>
{{ formatMessage(commonMessages.retryButton) }}
</button>
</div>
</div>
<template v-if="hasMore && !failed" #actions>
<ButtonStyled
><button type="button" :disabled="loading" @click="load()">
<SpinnerIcon v-if="loading" class="size-4 animate-spin" />{{
formatMessage(messages.more)
}}
</button></ButtonStyled
>
</template>
</NewModal>
</template>
<style scoped>
.donation-summary {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.625rem;
padding: 1.5rem;
border-radius: var(--radius-xl);
background: var(--color-bg);
text-align: center;
}
.donation-list {
margin: 0;
padding: 0;
list-style: none;
}
.donor-row {
display: flex;
min-width: 0;
align-items: center;
gap: 0.875rem;
padding: 1rem 0;
border-bottom: 1px solid var(--color-divider);
font-size: 0.875rem;
}
.donor-row:last-child {
border: 0;
}
.donor-rank {
display: flex;
align-items: center;
gap: 0.25rem;
width: 2.5rem;
flex-shrink: 0;
color: var(--color-secondary);
font-variant-numeric: tabular-nums;
}
.donor-rank.podium {
color: var(--color-brand);
}
</style>
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import { skinSiteStatus, skinSiteUser } from '@/composables/skin-site-session'
import {
skinSitePublicDownloadCategories,
type SkinSitePublicDownloadCategory,
type SkinSitePublicDownloadItem,
skinSitePublicDownloadList,
type SkinSitePublicDownloadStatus,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.downloads.title', defaultMessage: 'Downloads' },
subtitle: {
id: 'app.skin-site.downloads.subtitle',
defaultMessage: 'Files shared by the server. Pick a category to narrow the list.',
},
back: { id: 'app.skin-site.downloads.back', defaultMessage: 'Back to home' },
all: { id: 'app.skin-site.downloads.all', defaultMessage: 'All' },
empty: { id: 'app.skin-site.downloads.empty', defaultMessage: 'Nothing to download yet.' },
loading: { id: 'app.skin-site.downloads.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.downloads.failed', defaultMessage: 'Could not load downloads.' },
goDownload: { id: 'app.skin-site.downloads.go', defaultMessage: 'Download' },
uploader: { id: 'app.skin-site.downloads.uploader', defaultMessage: 'by {name}' },
statusOk: { id: 'app.skin-site.downloads.status.ok', defaultMessage: 'OK' },
statusNew: { id: 'app.skin-site.downloads.status.new', defaultMessage: 'New' },
statusLegacy: { id: 'app.skin-site.downloads.status.legacy', defaultMessage: 'Legacy' },
statusOutdated: { id: 'app.skin-site.downloads.status.outdated', defaultMessage: 'Outdated' },
statusRemoval: { id: 'app.skin-site.downloads.status.removal', defaultMessage: 'Removal' },
signedOut: {
id: 'app.skin-site.downloads.signed-out',
defaultMessage: 'Sign in to browse downloads.',
},
})
const emit = defineEmits<{ (e: 'back'): void }>()
const items = ref<SkinSitePublicDownloadItem[]>([])
const categories = ref<SkinSitePublicDownloadCategory[]>([])
const selectedCategory = ref<string | null>(null)
const loading = ref(true)
const failed = ref(false)
const signedIn = computed(() => skinSiteStatus.value === 'signed-in' && !!skinSiteUser.value)
const filtered = computed(() =>
selectedCategory.value
? items.value.filter((item) => item.category.includes(selectedCategory.value!))
: items.value,
)
function statusLabel(status: SkinSitePublicDownloadStatus): string {
switch (status) {
case 'NEW':
return formatMessage(messages.statusNew)
case 'LEGACY':
return formatMessage(messages.statusLegacy)
case 'OUTDATED':
return formatMessage(messages.statusOutdated)
case 'REMOVAL':
return formatMessage(messages.statusRemoval)
default:
return formatMessage(messages.statusOk)
}
}
async function openUrl(url: string) {
const { openUrl: open } = await import('@tauri-apps/plugin-opener')
await open(url)
}
function download(item: SkinSitePublicDownloadItem) {
const route = item.routes?.[0]
void openUrl(route?.url ?? item.url)
}
async function load() {
loading.value = true
failed.value = false
if (!signedIn.value) {
loading.value = false
return
}
try {
const [listResult, categoryResult] = await Promise.all([
skinSitePublicDownloadList(),
skinSitePublicDownloadCategories(),
])
if (!listResult.success) throw new Error(listResult.errorMessage)
items.value = listResult.payload ?? []
if (categoryResult.success) categories.value = categoryResult.payload ?? []
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="flex flex-col gap-4">
<button
type="button"
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<header class="flex flex-col gap-1">
<h1 class="m-0 text-xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
</header>
<div v-if="categories.length" class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
:class="
selectedCategory === null
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:brightness-110'
"
@click="selectedCategory = null"
>
{{ formatMessage(messages.all) }}
</button>
<button
v-for="category in categories"
:key="category.id"
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
:class="
selectedCategory === category.name
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:brightness-110'
"
@click="selectedCategory = category.name"
>
{{ category.name }}
</button>
</div>
<p v-if="!signedIn" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.signedOut) }}
</p>
<div v-else-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!filtered.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="item in filtered"
:key="item.id"
class="flex flex-wrap items-center gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex flex-wrap items-center gap-2">
<span class="min-w-0 truncate text-sm font-semibold text-contrast">
{{ item.filename }}
</span>
<span
v-for="status in item.status"
:key="status"
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="{
'bg-green text-contrast': status === 'NEW',
'bg-yellow text-contrast': status === 'LEGACY',
'bg-red text-contrast': status === 'OUTDATED' || status === 'REMOVAL',
'bg-button-bg text-secondary':
status !== 'NEW' &&
status !== 'LEGACY' &&
status !== 'OUTDATED' &&
status !== 'REMOVAL',
}"
>
{{ statusLabel(status) }}
</span>
</div>
<span v-if="item.description" class="truncate text-xs text-secondary">
{{ item.description }}
</span>
<span v-if="item.uploader" class="text-xs text-secondary">
{{ formatMessage(messages.uploader, { name: item.uploader }) }}
</span>
</div>
<button
type="button"
class="flex shrink-0 items-center gap-1 rounded-full bg-brand px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
@click="download(item)"
>
<DownloadIcon class="size-3.5" />
{{ formatMessage(messages.goDownload) }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { skinSiteUser } from '@/composables/skin-site-session'
import SkinSiteAdminView from './SkinSiteAdminView.vue'
import SkinSiteAnnouncements from './SkinSiteAnnouncements.vue'
import SkinSiteBugCreateModal from './SkinSiteBugCreateModal.vue'
import SkinSiteBugDetailView from './SkinSiteBugDetailView.vue'
import SkinSiteBugListView from './SkinSiteBugListView.vue'
import SkinSiteBugNotificationsView from './SkinSiteBugNotificationsView.vue'
import SkinSiteDonationCard from './SkinSiteDonationCard.vue'
import SkinSiteDownloadsView from './SkinSiteDownloadsView.vue'
import SkinSiteMapView from './SkinSiteMapView.vue'
import SkinSiteNavCards from './SkinSiteNavCards.vue'
import SkinSitePlayerPreview from './SkinSitePlayerPreview.vue'
import SkinSiteProfileModal from './SkinSiteProfileModal.vue'
import SkinSiteProfileView from './SkinSiteProfileView.vue'
import SkinSiteReviewView from './SkinSiteReviewView.vue'
import WikiView from './WikiView.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
account: { id: 'app.skin-site.home.settings', defaultMessage: 'Account' },
admin: { id: 'app.skin-site.home.admin', defaultMessage: 'Admin' },
})
const profileModal = ref<InstanceType<typeof SkinSiteProfileModal>>()
const isAdmin = computed(() =>
skinSiteUser.value?.permissions.some(
(permission) => permission === 'WEB_ADMIN' || permission === 'SERVER_ADMIN',
),
)
const view = ref<
| 'home'
| 'players'
| 'downloads'
| 'map'
| 'review'
| 'bugs'
| 'bug-detail'
| 'bug-notifications'
| 'admin'
| 'wiki'
>('home')
const selectedBugId = ref<number | null>(null)
function openBug(id: number) {
selectedBugId.value = id
view.value = 'bug-detail'
}
function backToBugList() {
selectedBugId.value = null
view.value = 'bugs'
}
const bugCreateModal = ref<InstanceType<typeof SkinSiteBugCreateModal>>()
function openBugNotifications() {
view.value = 'bug-notifications'
}
function onBugCreated(id: number) {
openBug(id)
}
</script>
<template>
<div class="flex h-full min-h-0 w-full flex-col overflow-y-auto">
<div class="skin-site-home mx-auto flex w-full max-w-6xl flex-col gap-6 p-6">
<template v-if="view === 'players'">
<div v-if="skinSiteUser" class="flex flex-wrap justify-end gap-2">
<ButtonStyled
><button type="button" @click="profileModal?.show()">
{{ formatMessage(messages.account) }}
</button></ButtonStyled
>
<ButtonStyled v-if="isAdmin"
><button type="button" @click="view = 'admin'">
{{ formatMessage(messages.admin) }}
</button></ButtonStyled
>
</div>
<SkinSiteProfileView @back="view = 'home'" />
</template>
<SkinSiteDownloadsView v-else-if="view === 'downloads'" @back="view = 'home'" />
<SkinSiteMapView v-else-if="view === 'map'" @back="view = 'home'" />
<SkinSiteReviewView v-else-if="view === 'review'" @back="view = 'home'" />
<SkinSiteBugListView
v-else-if="view === 'bugs'"
@back="view = 'home'"
@open="openBug"
@create="bugCreateModal?.show()"
@notifications="openBugNotifications"
/>
<SkinSiteBugNotificationsView
v-else-if="view === 'bug-notifications'"
@back="view = 'bugs'"
@open="openBug"
/>
<SkinSiteAdminView v-else-if="view === 'admin'" @back="view = 'home'" />
<WikiView v-else-if="view === 'wiki'" />
<SkinSiteBugDetailView
v-else-if="view === 'bug-detail' && selectedBugId !== null"
:issue-id="selectedBugId"
@back="backToBugList"
/>
<template v-else>
<div class="home-overview">
<SkinSitePlayerPreview />
<SkinSiteAnnouncements />
</div>
<SkinSiteDonationCard />
</template>
<div class="home-toolbar">
<SkinSiteNavCards
:active="view === 'bug-detail' || view === 'bug-notifications' ? 'bugs' : view"
@navigate="(target) => (view = target === view ? 'home' : target)"
/>
</div>
</div>
<SkinSiteBugCreateModal ref="bugCreateModal" @created="onBugCreated" />
<SkinSiteProfileModal ref="profileModal" />
</div>
</template>
<style scoped>
.skin-site-home {
box-sizing: border-box;
min-height: 100%;
container-type: inline-size;
}
.home-overview {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
gap: 2rem;
align-items: start;
}
.home-toolbar {
position: sticky;
bottom: 0;
z-index: 10;
margin-top: auto;
padding-top: 0.75rem;
background: var(--color-bg);
}
@container (max-width: 48rem) {
.home-overview {
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
}
}
</style>
@@ -0,0 +1,185 @@
<script setup lang="ts">
import {
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { reloadSkinSiteSession, signInSkinSite } from '@/composables/skin-site-session'
import SkinSiteCaptchaModal from './SkinSiteCaptchaModal.vue'
import SkinSitePasswordResetModal from './SkinSitePasswordResetModal.vue'
import SkinSiteRegisterModal from './SkinSiteRegisterModal.vue'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.login.title', defaultMessage: 'Sign in to StarLight' },
username: { id: 'app.skin-site.login.username', defaultMessage: 'Username or email' },
password: { id: 'app.skin-site.login.password', defaultMessage: 'Password' },
signIn: { id: 'app.skin-site.login.sign-in', defaultMessage: 'Sign in' },
signingIn: { id: 'app.skin-site.login.signing-in', defaultMessage: 'Signing in…' },
register: { id: 'app.skin-site.login.register', defaultMessage: 'Create account' },
forgotPassword: { id: 'app.skin-site.login.forgot', defaultMessage: 'Forgot password?' },
usernameHint: {
id: 'app.skin-site.login.username-hint',
defaultMessage: '3–16 characters, or a registered email address.',
},
passwordHint: {
id: 'app.skin-site.login.password-hint',
defaultMessage: '6–50 characters with upper case, lower case and a digit.',
},
success: { id: 'app.skin-site.login.success', defaultMessage: 'Signed in to StarLight.' },
})
const PASSWORD_RE = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{6,50}$/
const modal = ref<InstanceType<typeof NewModal>>()
const captchaModal = ref<InstanceType<typeof SkinSiteCaptchaModal>>()
const registerModal = ref<InstanceType<typeof SkinSiteRegisterModal>>()
const passwordResetModal = ref<InstanceType<typeof SkinSitePasswordResetModal>>()
function openRegister() {
modal.value?.hide()
registerModal.value?.show()
}
function openPasswordReset() {
modal.value?.hide()
passwordResetModal.value?.show()
}
const username = ref('')
const password = ref('')
const working = ref(false)
const emit = defineEmits<{ (e: 'signed-in' | 'cancel'): void }>()
const usernameValid = computed(() => {
const value = username.value.trim()
if (!value) return false
if (value.includes('@')) return value.length < 64
return value.length >= 3 && value.length <= 16
})
const passwordValid = computed(() => PASSWORD_RE.test(password.value))
const canSubmit = computed(() => usernameValid.value && passwordValid.value && !working.value)
function reset() {
username.value = ''
password.value = ''
working.value = false
}
function show() {
reset()
modal.value?.show()
}
async function beginSignIn() {
if (!canSubmit.value) return
captchaModal.value?.show()
}
async function onCaptchaVerified(clientId: string) {
working.value = true
try {
const result = await signInSkinSite({
username: username.value.trim(),
password: password.value,
clientId,
})
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
await reloadSkinSiteSession()
addNotification({ type: 'success', title: formatMessage(messages.success) })
modal.value?.hide()
reset()
emit('signed-in')
} catch (error) {
handleError(error)
} finally {
working.value = false
}
}
function cancel() {
modal.value?.hide()
emit('cancel')
}
function onRegisterSignedIn() {
emit('signed-in')
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="reset">
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.username) }}</span>
<StyledInput v-model="username" type="text" autocomplete="username" />
<span class="text-xs text-secondary">{{ formatMessage(messages.usernameHint) }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.password) }}</span>
<StyledInput v-model="password" type="password" autocomplete="current-password" />
<span class="text-xs text-secondary">{{ formatMessage(messages.passwordHint) }}</span>
</label>
<div class="flex justify-between text-sm">
<button type="button" class="text-brand hover:underline" @click="openRegister">
{{ formatMessage(messages.register) }}
</button>
<button type="button" class="text-brand hover:underline" @click="openPasswordReset">
{{ formatMessage(messages.forgotPassword) }}
</button>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
@click="cancel"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 font-semibold text-contrast disabled:opacity-50"
:disabled="!canSubmit"
@click="beginSignIn"
>
{{ formatMessage(working ? messages.signingIn : messages.signIn) }}
</button>
</div>
</template>
</NewModal>
<SkinSiteRegisterModal
ref="registerModal"
@signed-in="onRegisterSignedIn"
@cancel="modal?.show()"
/>
<SkinSitePasswordResetModal
ref="passwordResetModal"
@done="modal?.show()"
@cancel="modal?.show()"
/>
<SkinSiteCaptchaModal
ref="captchaModal"
@verified="onCaptchaVerified"
@cancel="working = false"
/>
</template>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ExternalIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import { type SkinSitePublicMapServer, skinSitePublicMapServers } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.map.title', defaultMessage: 'Server map' },
back: { id: 'app.skin-site.map.back', defaultMessage: 'Back to home' },
internal: { id: 'app.skin-site.map.internal', defaultMessage: 'Internal' },
external: { id: 'app.skin-site.map.external', defaultMessage: 'External' },
server: { id: 'app.skin-site.map.server', defaultMessage: 'Server' },
map: { id: 'app.skin-site.map.map', defaultMessage: 'Map' },
openExternal: { id: 'app.skin-site.map.open', defaultMessage: 'Open in browser' },
loading: { id: 'app.skin-site.map.loading', defaultMessage: 'Loading maps…' },
empty: { id: 'app.skin-site.map.empty', defaultMessage: 'No maps available.' },
failed: { id: 'app.skin-site.map.failed', defaultMessage: 'Could not load maps.' },
})
const emit = defineEmits<{ (e: 'back'): void }>()
const servers = ref<SkinSitePublicMapServer[]>([])
const internal = ref(false)
const serverIndex = ref(0)
const mapIndex = ref(0)
const loading = ref(true)
const failed = ref(false)
const filteredServers = computed(() => servers.value.filter((s) => s.internal === internal.value))
const selectedServer = computed(() => filteredServers.value[serverIndex.value])
const selectedMap = computed(() => selectedServer.value?.maps[mapIndex.value])
watch(internal, () => {
serverIndex.value = 0
mapIndex.value = 0
})
watch(serverIndex, () => {
mapIndex.value = 0
})
async function openExternal() {
if (!selectedMap.value) return
const { openUrl } = await import('@tauri-apps/plugin-opener')
await openUrl(selectedMap.value.url)
}
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSitePublicMapServers()
if (!result.success) throw new Error(result.errorMessage)
servers.value = result.payload ?? []
if (!servers.value.some((s) => !s.internal)) internal.value = true
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="flex items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<h1 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<select
v-model="internal"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
>
<option :value="false">{{ formatMessage(messages.external) }}</option>
<option :value="true">{{ formatMessage(messages.internal) }}</option>
</select>
<select
v-if="filteredServers.length"
v-model.number="serverIndex"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
>
<option v-for="(server, index) in filteredServers" :key="index" :value="index">
{{ server.name }}
</option>
</select>
<select
v-if="selectedServer?.maps.length"
v-model.number="mapIndex"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
>
<option v-for="(map, index) in selectedServer.maps" :key="index" :value="index">
{{ map.name }}
</option>
</select>
<button
v-if="selectedMap"
type="button"
class="ml-auto flex items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openExternal"
>
<ExternalIcon class="size-3.5" />
{{ formatMessage(messages.openExternal) }}
</button>
</div>
<div
v-if="loading"
class="flex flex-1 items-center justify-center gap-2 text-sm text-secondary"
>
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 flex-1 text-center text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!selectedMap" class="m-0 flex-1 text-center text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<iframe
v-else
:key="selectedMap.url"
:src="selectedMap.url"
:title="`${selectedServer?.name} · ${selectedMap.name}`"
class="min-h-0 w-full flex-1 rounded-lg border border-solid border-surface-5"
allow="fullscreen"
/>
</div>
</template>
@@ -0,0 +1,109 @@
<script setup lang="ts">
import {
BookIcon,
BugIcon,
DownloadIcon,
MapIcon,
ShieldCheckIcon,
UserIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import SkinSiteSelectionRail from './SkinSiteSelectionRail.vue'
defineProps<{ active: string }>()
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'navigate', view: 'downloads' | 'review' | 'bugs' | 'map' | 'wiki' | 'players'): void
}>()
const messages = defineMessages({
title: { id: 'app.skin-site.home.nav.title', defaultMessage: 'Skin site' },
download: { id: 'app.skin-site.home.nav.download', defaultMessage: 'Downloads' },
downloadDesc: {
id: 'app.skin-site.home.nav.download-desc',
defaultMessage: 'Browse files shared by the server.',
},
review: { id: 'app.skin-site.home.nav.review', defaultMessage: 'Player review' },
reviewDesc: {
id: 'app.skin-site.home.nav.review-desc',
defaultMessage: 'Submit and review player applications.',
},
wiki: { id: 'app.skin-site.home.nav.wiki', defaultMessage: 'Server wiki' },
wikiDesc: {
id: 'app.skin-site.home.nav.wiki-desc',
defaultMessage: 'Guides and documentation for the server.',
},
bugs: { id: 'app.skin-site.home.nav.bugs', defaultMessage: 'Bug tracker' },
bugsDesc: {
id: 'app.skin-site.home.nav.bugs-desc',
defaultMessage: 'Report issues and follow their progress.',
},
map: { id: 'app.skin-site.home.nav.map', defaultMessage: 'Server map' },
mapDesc: {
id: 'app.skin-site.home.nav.map-desc',
defaultMessage: 'Explore the live world map.',
},
profile: { id: 'app.skin-site.home.nav.profile', defaultMessage: 'Profile' },
profileDesc: {
id: 'app.skin-site.home.nav.profile-desc',
defaultMessage: 'Manage your account and textures.',
},
})
const destinations = ['downloads', 'review', 'wiki', 'bugs', 'map', 'players'] as const
const items = computed(() => [
{
id: 'downloads',
label: formatMessage(messages.download),
description: formatMessage(messages.downloadDesc),
icon: DownloadIcon,
},
{
id: 'review',
label: formatMessage(messages.review),
description: formatMessage(messages.reviewDesc),
icon: ShieldCheckIcon,
},
{
id: 'wiki',
label: formatMessage(messages.wiki),
description: formatMessage(messages.wikiDesc),
icon: BookIcon,
},
{
id: 'bugs',
label: formatMessage(messages.bugs),
description: formatMessage(messages.bugsDesc),
icon: BugIcon,
},
{
id: 'map',
label: formatMessage(messages.map),
description: formatMessage(messages.mapDesc),
icon: MapIcon,
},
{
id: 'players',
label: formatMessage(messages.profile),
description: formatMessage(messages.profileDesc),
icon: UserIcon,
},
])
function navigate(id: string) {
const destination = destinations.find((value) => value === id)
if (destination) emit('navigate', destination)
}
</script>
<template>
<SkinSiteSelectionRail
:items="items"
:selected="active"
:label="formatMessage(messages.title)"
@select="navigate"
/>
</template>
@@ -0,0 +1,238 @@
<script setup lang="ts">
import {
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, onUnmounted, ref } from 'vue'
import { skinSitePasswordReset, skinSitePasswordResetEmailVerify } from '@/helpers/skin-site-api'
import SkinSiteCaptchaModal from './SkinSiteCaptchaModal.vue'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.reset.title', defaultMessage: 'Reset password' },
email: { id: 'app.skin-site.reset.email', defaultMessage: 'Email' },
emailHint: {
id: 'app.skin-site.reset.email-hint',
defaultMessage: 'Use the address you registered with.',
},
code: { id: 'app.skin-site.reset.code', defaultMessage: 'Verification code' },
sendCode: { id: 'app.skin-site.reset.send-code', defaultMessage: 'Send code' },
sending: { id: 'app.skin-site.reset.sending', defaultMessage: 'Sending…' },
resendIn: { id: 'app.skin-site.reset.resend-in', defaultMessage: 'Sent ({seconds}s)' },
newPassword: { id: 'app.skin-site.reset.new-password', defaultMessage: 'New password' },
repeatPassword: { id: 'app.skin-site.reset.repeat-password', defaultMessage: 'Repeat password' },
passwordHint: {
id: 'app.skin-site.reset.password-hint',
defaultMessage: '6–50 characters with upper case, lower case and a digit.',
},
mismatch: { id: 'app.skin-site.reset.mismatch', defaultMessage: 'Passwords do not match.' },
submit: { id: 'app.skin-site.reset.submit', defaultMessage: 'Reset password' },
success: {
id: 'app.skin-site.reset.success',
defaultMessage: 'Password updated. You can sign in now.',
},
})
const PASSWORD_RE = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{6,50}$/
const COUNTDOWN_SECONDS = 60
const modal = ref<InstanceType<typeof NewModal>>()
const captchaModal = ref<InstanceType<typeof SkinSiteCaptchaModal>>()
const email = ref('')
const code = ref('')
const newPassword = ref('')
const repeatPassword = ref('')
const sending = ref(false)
const working = ref(false)
const countdown = ref(0)
let timer: ReturnType<typeof setInterval> | undefined
const emit = defineEmits<{ (e: 'done' | 'cancel'): void }>()
function stopTimer() {
if (timer) clearInterval(timer)
timer = undefined
}
function startCountdown() {
countdown.value = COUNTDOWN_SECONDS
stopTimer()
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0) stopTimer()
}, 1000)
}
const canSendCode = computed(
() => email.value.trim().length > 0 && !sending.value && countdown.value <= 0,
)
const passwordValid = computed(() => PASSWORD_RE.test(newPassword.value))
const passwordsMatch = computed(() => newPassword.value === repeatPassword.value)
const canSubmit = computed(
() =>
email.value.trim().length > 0 &&
code.value.trim().length === 6 &&
passwordValid.value &&
passwordsMatch.value &&
!working.value,
)
function reset() {
stopTimer()
email.value = ''
code.value = ''
newPassword.value = ''
repeatPassword.value = ''
sending.value = false
working.value = false
countdown.value = 0
}
function show() {
reset()
modal.value?.show()
}
async function sendCode() {
if (!canSendCode.value) return
sending.value = true
try {
const result = await skinSitePasswordResetEmailVerify(email.value.trim())
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
addNotification({ type: 'success', title: result.payload })
startCountdown()
} catch (error) {
handleError(error)
} finally {
sending.value = false
}
}
function beginReset() {
if (!canSubmit.value) return
if (!passwordsMatch.value) {
handleError(new Error(formatMessage(messages.mismatch)))
return
}
captchaModal.value?.show()
}
async function onCaptchaVerified(clientId: string) {
working.value = true
try {
const result = await skinSitePasswordReset({
clientId,
email: email.value.trim(),
emailVerifyCode: code.value.trim(),
newPassword: newPassword.value,
})
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
addNotification({ type: 'success', title: formatMessage(messages.success) })
modal.value?.hide()
emit('done')
} catch (error) {
handleError(error)
} finally {
working.value = false
}
}
function cancel() {
modal.value?.hide()
emit('cancel')
}
onUnmounted(stopTimer)
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="reset">
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.email) }}</span>
<StyledInput v-model="email" type="email" autocomplete="email" />
<span class="text-xs text-secondary">{{ formatMessage(messages.emailHint) }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.code) }}</span>
<div class="flex gap-2">
<StyledInput v-model="code" type="text" class="flex-1" maxlength="6" />
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-4 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSendCode"
@click="sendCode"
>
{{
sending
? formatMessage(messages.sending)
: countdown > 0
? formatMessage(messages.resendIn, { seconds: countdown })
: formatMessage(messages.sendCode)
}}
</button>
</div>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.newPassword) }}</span>
<StyledInput v-model="newPassword" type="password" autocomplete="new-password" />
<span class="text-xs text-secondary">{{ formatMessage(messages.passwordHint) }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{
formatMessage(messages.repeatPassword)
}}</span>
<StyledInput v-model="repeatPassword" type="password" autocomplete="new-password" />
<span v-if="repeatPassword && !passwordsMatch" class="text-xs text-red">
{{ formatMessage(messages.mismatch) }}
</span>
</label>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
@click="cancel"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 font-semibold text-contrast disabled:opacity-50"
:disabled="!canSubmit"
@click="beginReset"
>
{{ formatMessage(messages.submit) }}
</button>
</div>
</template>
</NewModal>
<SkinSiteCaptchaModal
ref="captchaModal"
@verified="onCaptchaVerified"
@cancel="working = false"
/>
</template>
@@ -0,0 +1,194 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module'
import test from 'node:test'
import { createRenderer, nextTick, ref } from 'vue'
const session = {
skinSitePlayers: ref([]),
selectedSkinSitePlayerId: ref(null),
skinSiteStatus: ref('signed-in'),
skinSitePlayersStatus: ref('ready'),
}
const testGlobal = globalThis as unknown as {
previewTestSession: typeof session
previewNormalize: (skin: { texture: string }) => Promise<string>
}
testGlobal.previewTestSession = session
const require = createRequire(import.meta.url)
const { parse, compileScript } = require('vue/compiler-sfc')
const componentUrl = new URL('./SkinSitePlayerPreview.vue', import.meta.url).href
const stubs: Record<string, string> = {
'@modrinth/assets': 'export const RefreshCwIcon={}, SpinnerIcon={}, UserIcon={}',
'@modrinth/ui':
'export const commonMessages={retryButton:{defaultMessage:"Retry"}}; export const defineMessages=x=>x; export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})',
'@modrinth/ui/src/components/skin/SkinPreviewRenderer.vue': 'export default {}',
'./SkinSiteSelectionRail.vue': 'export default {}',
'@/composables/skin-site-session':
'export const {skinSitePlayers,selectedSkinSitePlayerId,skinSiteStatus,skinSitePlayersStatus}=globalThis.previewTestSession; export const requestSkinSitePlayers=async()=>[]',
'@/helpers/skins':
'export const get_normalized_skin_texture=skin=>globalThis.previewNormalize(skin)',
}
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier in stubs)
return {
url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`,
shortCircuit: true,
}
return nextResolve(specifier, context)
},
load(url, context, nextLoad) {
if (url !== componentUrl) return nextLoad(url, context)
const { descriptor } = parse(readFileSync(new URL(url), 'utf8'))
return {
format: 'module',
source: stripTypeScriptTypes(
compileScript(descriptor, { id: 'player-preview-test' }).content,
),
shortCircuit: true,
}
},
})
const { default: Preview } = await import(componentUrl)
hooks.deregister()
Preview.render = () => null
type Node = Record<string, unknown>
const renderer = createRenderer<Node, Node>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
insert() {},
remove() {},
setText() {},
setElementText() {},
patchProp() {},
parentNode: () => null,
nextSibling: () => null,
})
const flush = async () => {
await new Promise((resolve) => setImmediate(resolve))
await nextTick()
}
function mountPreview(t: test.TestContext, card: unknown) {
const app = renderer.createApp(Preview)
app.provide('accountsCard', card)
const instance = app.mount({})
t.after(() => app.unmount())
return (instance.$ as unknown as { setupState: { texture: string; textureLoading: boolean } })
.setupState
}
test('home normalizes official textures and ignores a late texture after switching players', async (t) => {
const accounts = [
{ profile: { id: 'first', name: 'First' }, account_type: 'microsoft' },
{ profile: { id: 'second', name: 'Second' }, account_type: 'microsoft' },
]
const card = ref({
accounts,
defaultUser: 'first',
getAccountSkin: (account: (typeof accounts)[number]) => ({
texture: `https://textures.test/${account.profile.id}.png`,
variant: 'CLASSIC',
}),
getAccountAvatarUrl: () => undefined,
})
const requests: Array<{ texture: string; finish: (texture: string) => void }> = []
testGlobal.previewNormalize = (skin) =>
new Promise((resolve) => requests.push({ texture: skin.texture, finish: resolve }))
const state = mountPreview(t, card)
assert.equal(requests[0].texture, 'https://textures.test/first.png')
card.value.defaultUser = 'second'
await flush()
assert.equal(state.texture, '')
requests[1].finish('data:image/png;base64,normalized-second')
await flush()
requests[0].finish('data:image/png;base64,normalized-first')
await flush()
assert.equal(state.texture, 'data:image/png;base64,normalized-second')
assert.equal(state.textureLoading, false)
})
test('account polling, avatar updates and another player changing skin keep the current preview loaded', async (t) => {
const avatar = ref('first-head')
const skins = ref<Record<string, { texture: string; variant: 'CLASSIC' | 'SLIM' }>>({
first: { texture: 'https://textures.test/first.png', variant: 'CLASSIC' },
second: { texture: 'https://textures.test/second.png', variant: 'CLASSIC' },
})
const card = ref({
accounts: [
{ profile: { id: 'first', name: 'First' }, account_type: 'microsoft' },
{ profile: { id: 'second', name: 'Second' }, account_type: 'microsoft' },
],
defaultUser: 'first',
getAccountSkin: (account: { profile: { id: string } }) => skins.value[account.profile.id],
getAccountAvatarUrl: () => avatar.value,
})
const requests: string[] = []
testGlobal.previewNormalize = (skin) => {
requests.push(skin.texture)
return requests.length === 1
? Promise.resolve('data:image/png;base64,current-skin')
: new Promise(() => {})
}
const state = mountPreview(t, card)
await flush()
assert.equal(state.texture, 'data:image/png;base64,current-skin')
card.value.accounts = card.value.accounts.map((account) => ({
...account,
profile: { ...account.profile },
}))
await flush()
assert.equal(
state.texture,
'data:image/png;base64,current-skin',
'polling must not clear the visible skin',
)
assert.equal(requests.length, 1, 'unchanged accounts must not normalize the skin again')
avatar.value = 'updated-head'
await flush()
assert.equal(state.texture, 'data:image/png;base64,current-skin')
assert.equal(requests.length, 1, 'avatar-only changes must not reload the 3D skin')
skins.value.second = { texture: 'https://textures.test/second-new.png', variant: 'SLIM' }
await flush()
assert.equal(state.texture, 'data:image/png;base64,current-skin')
assert.equal(state.textureLoading, false)
assert.equal(
requests.length,
1,
'another player changing skin must not reload the current preview',
)
})
test('the current player changing texture or model still reloads the preview', async (t) => {
const skin = ref({ texture: 'https://textures.test/original.png', variant: 'CLASSIC' })
const card = ref({
accounts: [{ profile: { id: 'first', name: 'First' }, account_type: 'microsoft' }],
defaultUser: 'first',
getAccountSkin: () => skin.value,
getAccountAvatarUrl: () => undefined,
})
const requests: string[] = []
testGlobal.previewNormalize = async (current) => {
requests.push(current.texture)
return `data:image/png;base64,skin-${requests.length}`
}
const state = mountPreview(t, card)
await flush()
skin.value.texture = 'https://textures.test/updated.png'
await flush()
assert.equal(state.texture, 'data:image/png;base64,skin-2')
skin.value.variant = 'SLIM'
await flush()
assert.equal(state.texture, 'data:image/png;base64,skin-3')
assert.deepEqual(requests, [
'https://textures.test/original.png',
'https://textures.test/updated.png',
'https://textures.test/updated.png',
])
})
@@ -0,0 +1,238 @@
<script setup lang="ts">
import { RefreshCwIcon, SpinnerIcon, UserIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import SkinPreviewRenderer from '@modrinth/ui/src/components/skin/SkinPreviewRenderer.vue'
import type { Ref } from 'vue'
import { computed, inject, onUnmounted, ref, watch } from 'vue'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import {
requestSkinSitePlayers,
selectedSkinSitePlayerId,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
} from '@/composables/skin-site-session'
import { get_normalized_skin_texture } from '@/helpers/skins'
import SkinSiteSelectionRail from './SkinSiteSelectionRail.vue'
const { formatMessage } = useVIntl()
const accountsCard = inject<Ref<InstanceType<typeof AccountsCard> | undefined>>('accountsCard')
const pendingId = ref<string | null>(null)
const messages = defineMessages({
players: { id: 'app.skin-site.home.preview.players', defaultMessage: 'Players' },
empty: { id: 'app.skin-site.home.preview.empty', defaultMessage: 'No players yet' },
emptyHint: {
id: 'app.skin-site.home.preview.empty-hint',
defaultMessage: 'Sign in or add a Minecraft account in the sidebar to see your skin here.',
},
unavailable: {
id: 'app.skin-site.home.preview.unavailable',
defaultMessage: 'This player has no skin to preview.',
},
loading: { id: 'app.skin-site.home.preview.loading', defaultMessage: 'Loading players…' },
failed: {
id: 'app.skin-site.home.preview.failed',
defaultMessage: 'Could not load skin-site players.',
},
})
const normalizeId = (id: string) => id.replaceAll('-', '').toLowerCase()
const players = computed(() =>
(accountsCard?.value?.accounts ?? []).map((account) => {
const sitePlayer =
account.account_type === 'yggdrasil'
? skinSitePlayers.value.find(
(player) => normalizeId(player.uuid) === normalizeId(account.profile.id),
)
: undefined
const skin = accountsCard?.value?.getAccountSkin(account)
return {
id: account.profile.id,
label: account.profile.name,
avatar: sitePlayer?.headDataUrl ?? accountsCard?.value?.getAccountAvatarUrl(account),
texture: sitePlayer?.skinDataUrl ?? skin?.texture ?? '',
variant: sitePlayer?.model
? sitePlayer.model === 'slim'
? ('SLIM' as const)
: ('CLASSIC' as const)
: (skin?.variant ?? 'CLASSIC'),
account,
}
}),
)
const selectedId = computed(() => {
const id = selectedSkinSitePlayerId.value ?? accountsCard?.value?.defaultUser
return (
players.value.find((player) => id && normalizeId(player.id) === normalizeId(id))?.id ?? null
)
})
const selectedPlayer = computed(() =>
players.value.find((player) => player.id === selectedId.value),
)
const loading = computed(
() =>
!players.value.length &&
(skinSiteStatus.value === 'checking' ||
skinSitePlayersStatus.value === 'checking' ||
!accountsCard?.value),
)
const texture = ref('')
const textureLoading = ref(false)
let textureRevision = 0
watch(
// Background account/head refreshes rebuild player objects without changing the skin.
[
() => selectedPlayer.value?.id,
() => selectedPlayer.value?.texture,
() => selectedPlayer.value?.variant,
],
async () => {
const player = selectedPlayer.value
const revision = ++textureRevision
texture.value = ''
textureLoading.value = !!player?.texture
if (!player?.texture) return
try {
const normalized = await get_normalized_skin_texture({
texture_key: `${player.id}:${player.texture}`,
texture: player.texture,
variant: player.variant,
source: 'custom_external',
is_equipped: true,
})
if (revision === textureRevision) texture.value = normalized
} catch {
if (revision === textureRevision && player.texture.startsWith('data:image/'))
texture.value = player.texture
} finally {
if (revision === textureRevision) textureLoading.value = false
}
},
{ immediate: true },
)
onUnmounted(() => {
textureRevision++
})
async function select(id: string) {
if (pendingId.value) return
const player = players.value.find((player) => player.id === id)
if (!player) return
pendingId.value = id
try {
await accountsCard?.value?.setAccount(player.account)
} finally {
pendingId.value = null
}
}
</script>
<template>
<section class="player-preview" :aria-label="formatMessage(messages.players)">
<SkinSiteSelectionRail
v-if="players.length"
:items="players"
:selected="selectedId"
:label="formatMessage(messages.players)"
vertical
:disabled="!!pendingId || accountsCard?.accountSelectionPending"
@select="select"
/>
<div class="preview-stage" :aria-busy="!!pendingId || loading || textureLoading">
<SkinPreviewRenderer
v-if="selectedPlayer && texture"
:texture-src="texture"
:variant="selectedPlayer.variant"
:nametag="selectedPlayer.label"
:initial-rotation="Math.PI / 8"
fit
/>
<div v-else class="preview-empty" role="status">
<SpinnerIcon v-if="loading || textureLoading" class="size-7 animate-spin" />
<UserIcon v-else class="size-10 opacity-40" />
<span v-if="loading || textureLoading">{{ formatMessage(messages.loading) }}</span>
<template v-else-if="selectedPlayer">
<strong class="text-contrast">{{ selectedPlayer.label }}</strong>
<span>{{ formatMessage(messages.unavailable) }}</span>
</template>
<template v-else>
<strong class="text-contrast">{{ formatMessage(messages.empty) }}</strong>
<span>{{ formatMessage(messages.emptyHint) }}</span>
</template>
</div>
<div v-if="pendingId" class="preview-switching">
<SpinnerIcon class="size-4 animate-spin" />
</div>
</div>
<div v-if="skinSitePlayersStatus === 'error'" class="preview-error">
<span>{{ formatMessage(messages.failed) }}</span>
<button type="button" @click="requestSkinSitePlayers().catch(() => {})">
<RefreshCwIcon class="size-4" />{{ formatMessage(commonMessages.retryButton) }}
</button>
</div>
</section>
</template>
<style scoped>
.player-preview {
position: relative;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 1.5rem;
min-width: 0;
}
.preview-stage {
position: relative;
min-width: 0;
height: clamp(22rem, 57dvh, 38rem);
}
.preview-stage:only-child {
grid-column: 1 / -1;
}
.preview-empty {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0.75rem;
padding: 1.5rem;
text-align: center;
color: var(--color-secondary);
font-size: 0.875rem;
}
.preview-switching {
position: absolute;
top: 1rem;
right: 1rem;
color: var(--color-brand);
}
.preview-error {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
color: var(--color-secondary);
font-size: 0.75rem;
}
.preview-error button {
display: flex;
align-items: center;
gap: 0.375rem;
border: 0;
background: transparent;
color: var(--color-brand);
cursor: pointer;
}
@media (max-width: 720px) {
.player-preview {
gap: 0.75rem;
}
.preview-stage {
height: clamp(20rem, 48dvh, 28rem);
}
}
</style>
@@ -0,0 +1,402 @@
<script setup lang="ts">
import {
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, onUnmounted, ref } from 'vue'
import { reloadSkinSiteSession, skinSiteUser } from '@/composables/skin-site-session'
import {
skinSiteChangePassword,
skinSiteChangeUsername,
skinSiteChangeYggdrasilPassword,
skinSiteUploadAvatar,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.profile.title', defaultMessage: 'Account settings' },
basic: { id: 'app.skin-site.profile.basic', defaultMessage: 'Profile' },
username: { id: 'app.skin-site.profile.username', defaultMessage: 'Username' },
usernameHint: {
id: 'app.skin-site.profile.username-hint',
defaultMessage: '3–16 characters.',
},
save: { id: 'app.skin-site.profile.save', defaultMessage: 'Save' },
avatar: { id: 'app.skin-site.profile.avatar', defaultMessage: 'Avatar' },
chooseAvatar: { id: 'app.skin-site.profile.choose-avatar', defaultMessage: 'Choose image' },
avatarHint: {
id: 'app.skin-site.profile.avatar-hint',
defaultMessage: 'Square images work best; the centre is cropped to 256×256.',
},
security: { id: 'app.skin-site.profile.security', defaultMessage: 'Security' },
sitePasswordHint: {
id: 'app.skin-site.profile.site-password-hint',
defaultMessage: 'This is the password used to sign in to the skin site.',
},
oldPassword: { id: 'app.skin-site.profile.old-password', defaultMessage: 'Current password' },
newPassword: { id: 'app.skin-site.profile.new-password', defaultMessage: 'New password' },
repeatPassword: {
id: 'app.skin-site.profile.repeat-password',
defaultMessage: 'Repeat new password',
},
mismatch: { id: 'app.skin-site.profile.mismatch', defaultMessage: 'Passwords do not match.' },
passwordHint: {
id: 'app.skin-site.profile.password-hint',
defaultMessage: '6–50 characters with upper case, lower case and a digit.',
},
submitPassword: {
id: 'app.skin-site.profile.submit-password',
defaultMessage: 'Update password',
},
minecraftPassword: {
id: 'app.skin-site.profile.minecraft-password',
defaultMessage: 'Minecraft password',
},
minecraftPasswordHint: {
id: 'app.skin-site.profile.minecraft-password-hint',
defaultMessage: 'Used by the launcher; defaults to the website password.',
},
resetDefault: {
id: 'app.skin-site.profile.reset-default',
defaultMessage: 'Reset to website password',
},
saved: { id: 'app.skin-site.profile.saved', defaultMessage: 'Saved.' },
avatarUploaded: {
id: 'app.skin-site.profile.avatar-uploaded',
defaultMessage: 'Avatar updated.',
},
passwordChanged: {
id: 'app.skin-site.profile.password-changed',
defaultMessage: 'Password updated.',
},
minecraftPasswordChanged: {
id: 'app.skin-site.profile.minecraft-password-changed',
defaultMessage: 'Minecraft password updated.',
},
})
const PASSWORD_RE = /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)\S{6,50}$/
const modal = ref<InstanceType<typeof NewModal>>()
const username = ref('')
const savingUsername = ref(false)
const avatarPreview = ref('')
const uploadingAvatar = ref(false)
const oldPassword = ref('')
const newPassword = ref('')
const repeatPassword = ref('')
const savingPassword = ref(false)
const newMinecraftPassword = ref('')
const repeatMinecraftPassword = ref('')
const savingMinecraftPassword = ref(false)
const usernameValid = computed(() => {
const value = username.value.trim()
return value.length >= 3 && value.length <= 16
})
const usernameChanged = computed(
() => !!skinSiteUser.value && username.value.trim() !== skinSiteUser.value.username,
)
const passwordValid = computed(() => PASSWORD_RE.test(newPassword.value))
const passwordsMatch = computed(() => newPassword.value === repeatPassword.value)
const canSavePassword = computed(
() => !!oldPassword.value && passwordValid.value && passwordsMatch.value && !savingPassword.value,
)
const minecraftPasswordValid = computed(() => PASSWORD_RE.test(newMinecraftPassword.value))
const minecraftPasswordsMatch = computed(
() => newMinecraftPassword.value === repeatMinecraftPassword.value,
)
const canSaveMinecraftPassword = computed(
() =>
minecraftPasswordValid.value && minecraftPasswordsMatch.value && !savingMinecraftPassword.value,
)
function show() {
username.value = skinSiteUser.value?.username ?? ''
avatarPreview.value = ''
oldPassword.value = ''
newPassword.value = ''
repeatPassword.value = ''
newMinecraftPassword.value = ''
repeatMinecraftPassword.value = ''
modal.value?.show()
}
async function saveUsername() {
if (!usernameChanged.value || !usernameValid.value) return
savingUsername.value = true
try {
const result = await skinSiteChangeUsername(username.value.trim())
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
await reloadSkinSiteSession()
addNotification({ type: 'success', title: formatMessage(messages.saved) })
} catch (error) {
handleError(error)
} finally {
savingUsername.value = false
}
}
/** Centre-crop the picked image to a square and scale it to 256×256. */
async function cropToSquare(file: File): Promise<Blob> {
const bitmap = await createImageBitmap(file)
const side = Math.min(bitmap.width, bitmap.height)
const sx = (bitmap.width - side) / 2
const sy = (bitmap.height - side) / 2
const canvas = document.createElement('canvas')
canvas.width = 256
canvas.height = 256
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('无法创建画布')
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(bitmap, sx, sy, side, side, 0, 0, 256, 256)
bitmap.close?.()
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('无法处理图片'))), 'image/png')
})
}
async function onAvatarPicked(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploadingAvatar.value = true
try {
const blob = await cropToSquare(file)
avatarPreview.value = URL.createObjectURL(blob)
const result = await skinSiteUploadAvatar(blob)
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
await reloadSkinSiteSession()
addNotification({ type: 'success', title: formatMessage(messages.avatarUploaded) })
} catch (error) {
handleError(error)
} finally {
uploadingAvatar.value = false
}
}
async function savePassword() {
if (!canSavePassword.value) return
savingPassword.value = true
try {
const result = await skinSiteChangePassword(oldPassword.value, newPassword.value)
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
oldPassword.value = ''
newPassword.value = ''
repeatPassword.value = ''
addNotification({ type: 'success', title: formatMessage(messages.passwordChanged) })
} catch (error) {
handleError(error)
} finally {
savingPassword.value = false
}
}
async function saveMinecraftPassword(resetToDefault: boolean) {
if (!resetToDefault && !canSaveMinecraftPassword.value) return
savingMinecraftPassword.value = true
try {
const result = await skinSiteChangeYggdrasilPassword(
resetToDefault ? null : newMinecraftPassword.value,
)
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
newMinecraftPassword.value = ''
repeatMinecraftPassword.value = ''
addNotification({ type: 'success', title: formatMessage(messages.minecraftPasswordChanged) })
} catch (error) {
handleError(error)
} finally {
savingMinecraftPassword.value = false
}
}
function close() {
modal.value?.hide()
}
onUnmounted(() => {
if (avatarPreview.value) URL.revokeObjectURL(avatarPreview.value)
})
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)">
<div class="flex flex-col gap-6">
<section class="flex flex-col gap-3">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.basic) }}
</h3>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.username)
}}</span>
<div class="flex gap-2">
<StyledInput v-model="username" type="text" class="flex-1" maxlength="16" />
<button
type="button"
class="shrink-0 rounded-full bg-brand px-4 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!usernameChanged || !usernameValid || savingUsername"
@click="saveUsername"
>
{{ formatMessage(messages.save) }}
</button>
</div>
<span class="text-xs text-secondary">{{ formatMessage(messages.usernameHint) }}</span>
</label>
<div class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.avatar)
}}</span>
<div class="flex items-center gap-3">
<img
v-if="avatarPreview"
:src="avatarPreview"
alt=""
class="size-12 rounded-lg object-cover"
/>
<label
class="cursor-pointer rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
>
{{ formatMessage(messages.chooseAvatar) }}
<input
type="file"
accept="image/*"
class="hidden"
:disabled="uploadingAvatar"
@change="onAvatarPicked"
/>
</label>
</div>
<span class="text-xs text-secondary">{{ formatMessage(messages.avatarHint) }}</span>
</div>
</section>
<section class="flex flex-col gap-3">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.security) }}
</h3>
<span class="text-sm text-secondary">{{ formatMessage(messages.sitePasswordHint) }}</span>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<StyledInput
v-model="oldPassword"
type="password"
:placeholder="formatMessage(messages.oldPassword)"
autocomplete="current-password"
/>
<StyledInput
v-model="newPassword"
type="password"
:placeholder="formatMessage(messages.newPassword)"
autocomplete="new-password"
/>
<StyledInput
v-model="repeatPassword"
type="password"
:placeholder="formatMessage(messages.repeatPassword)"
autocomplete="new-password"
/>
</div>
<span class="text-xs text-secondary">{{ formatMessage(messages.passwordHint) }}</span>
<span v-if="repeatPassword && !passwordsMatch" class="text-xs text-red">
{{ formatMessage(messages.mismatch) }}
</span>
<div class="flex justify-end">
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSavePassword"
@click="savePassword"
>
{{ formatMessage(messages.submitPassword) }}
</button>
</div>
</section>
<section class="flex flex-col gap-3">
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.minecraftPassword)
}}</span>
<span class="text-xs text-secondary">{{
formatMessage(messages.minecraftPasswordHint)
}}</span>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<StyledInput
v-model="newMinecraftPassword"
type="password"
:placeholder="formatMessage(messages.newPassword)"
autocomplete="new-password"
/>
<StyledInput
v-model="repeatMinecraftPassword"
type="password"
:placeholder="formatMessage(messages.repeatPassword)"
autocomplete="new-password"
/>
</div>
<span v-if="repeatMinecraftPassword && !minecraftPasswordsMatch" class="text-xs text-red">
{{ formatMessage(messages.mismatch) }}
</span>
<div class="flex flex-wrap justify-end gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast"
:disabled="savingMinecraftPassword"
@click="saveMinecraftPassword(true)"
>
{{ formatMessage(messages.resetDefault) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSaveMinecraftPassword"
@click="saveMinecraftPassword(false)"
>
{{ formatMessage(messages.submitPassword) }}
</button>
</div>
</section>
</div>
<template #actions>
<div class="flex justify-end">
<button
type="button"
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
@click="close"
>
{{ formatMessage(commonMessages.closeButton) }}
</button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,687 @@
<script setup lang="ts">
import { SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import SkinPreviewRenderer from '@modrinth/ui/src/components/skin/SkinPreviewRenderer.vue'
import { computed, onMounted, ref, watch } from 'vue'
import {
requestSkinSitePlayers,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import {
skinSiteAddPlayer,
skinSiteClearDefaultPlayer,
skinSiteDefaultPlayer,
skinSiteDeletePlayer,
skinSiteDeleteTexture,
skinSitePlayerSkin,
type SkinSitePlayerSkin,
skinSiteRenamePlayer,
skinSiteSetDefaultPlayer,
skinSiteSwitchMojang,
skinSiteUploadTexture,
type SkinTextureType,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const emit = defineEmits<{ (e: 'back'): void }>()
const messages = defineMessages({
title: { id: 'app.skin-site.profile-view.title', defaultMessage: 'Your players' },
signedOut: {
id: 'app.skin-site.profile-view.signed-out',
defaultMessage: 'Sign in to manage your players.',
},
loading: { id: 'app.skin-site.profile-view.loading', defaultMessage: 'Loading players…' },
noPlayers: {
id: 'app.skin-site.profile-view.no-players',
defaultMessage: 'No players yet.',
},
selectHint: {
id: 'app.skin-site.profile-view.select-hint',
defaultMessage: 'Select a player to preview and edit its textures.',
},
skin: { id: 'app.skin-site.profile-view.skin', defaultMessage: 'Skin' },
cape: { id: 'app.skin-site.profile-view.cape', defaultMessage: 'Cape' },
elytra: { id: 'app.skin-site.profile-view.elytra', defaultMessage: 'Elytra' },
upload: { id: 'app.skin-site.profile-view.upload', defaultMessage: 'Upload' },
remove: { id: 'app.skin-site.profile-view.remove', defaultMessage: 'Remove' },
readonly: {
id: 'app.skin-site.profile-view.readonly',
defaultMessage: 'Mojang accounts are managed by Mojang; textures cannot be edited here.',
},
uploaded: { id: 'app.skin-site.profile-view.uploaded', defaultMessage: 'Texture updated.' },
removed: { id: 'app.skin-site.profile-view.removed', defaultMessage: 'Texture removed.' },
back: { id: 'app.skin-site.profile-view.back', defaultMessage: 'Back to home' },
addPlayer: { id: 'app.skin-site.profile-view.add-player', defaultMessage: 'New player' },
playerNamePlaceholder: {
id: 'app.skin-site.profile-view.player-name-placeholder',
defaultMessage: 'Player name',
},
confirm: { id: 'app.skin-site.profile-view.confirm', defaultMessage: 'Confirm' },
cancel: { id: 'app.skin-site.profile-view.cancel', defaultMessage: 'Cancel' },
rename: { id: 'app.skin-site.profile-view.rename', defaultMessage: 'Rename' },
delete: { id: 'app.skin-site.profile-view.delete', defaultMessage: 'Delete' },
setDefault: { id: 'app.skin-site.profile-view.set-default', defaultMessage: 'Set as default' },
clearDefault: {
id: 'app.skin-site.profile-view.clear-default',
defaultMessage: 'Clear default',
},
toggleMojang: {
id: 'app.skin-site.profile-view.toggle-mojang',
defaultMessage: 'Toggle Mojang',
},
isDefault: { id: 'app.skin-site.profile-view.is-default', defaultMessage: 'Default' },
added: { id: 'app.skin-site.profile-view.added', defaultMessage: 'Player added.' },
renamed: { id: 'app.skin-site.profile-view.renamed', defaultMessage: 'Player renamed.' },
deleted: { id: 'app.skin-site.profile-view.deleted', defaultMessage: 'Player deleted.' },
defaultSet: {
id: 'app.skin-site.profile-view.default-set',
defaultMessage: 'Default player updated.',
},
mojangToggled: {
id: 'app.skin-site.profile-view.mojang-toggled',
defaultMessage: 'Mojang status updated.',
},
deleteConfirm: {
id: 'app.skin-site.profile-view.delete-confirm',
defaultMessage: 'Delete this player? This cannot be undone.',
},
mojangConfirm: {
id: 'app.skin-site.profile-view.mojang-confirm',
defaultMessage: 'Toggle Mojang status for this player?',
},
renameConfirm: {
id: 'app.skin-site.profile-view.rename-confirm',
defaultMessage: 'Rename this player?',
},
})
const selectedId = ref<string | null>(null)
const texture = ref<SkinSitePlayerSkin>({})
const loadingTexture = ref(false)
const uploading = ref<SkinTextureType | null>(null)
const signedIn = computed(() => skinSiteStatus.value === 'signed-in' && !!skinSiteUser.value)
const players = computed(() => skinSitePlayers.value)
const playersLoading = computed(
() => skinSitePlayersStatus.value === 'checking' || skinSitePlayersStatus.value === 'idle',
)
const selectedPlayer = computed(
() => players.value.find((p) => p.uuid === selectedId.value) ?? null,
)
const editable = computed(() => !!selectedPlayer.value && !selectedPlayer.value.isMojang)
const skinVariant = computed<'CLASSIC' | 'SLIM'>(() =>
texture.value.model === 'slim' ? 'SLIM' : 'CLASSIC',
)
function selectPlayer(id: string) {
selectedId.value = id
}
async function loadTexture(uuid: string) {
loadingTexture.value = true
texture.value = {}
try {
const result = await skinSitePlayerSkin(uuid)
if (result.success && result.payload) texture.value = result.payload
} catch (error) {
handleError(error)
} finally {
loadingTexture.value = false
}
}
watch(selectedId, (id) => {
if (id) void loadTexture(id)
else texture.value = {}
})
// --- Player management ---
const defaultUuid = ref<string | null>(null)
const busy = ref<string | null>(null)
async function loadDefaultPlayer() {
if (!signedIn.value) {
defaultUuid.value = null
return
}
try {
const res = await skinSiteDefaultPlayer()
defaultUuid.value = res.success ? (res.payload?.uuid ?? null) : null
} catch {
defaultUuid.value = null
}
}
const showAdd = ref(false)
const newName = ref('')
const renamingId = ref<string | null>(null)
const renameValue = ref('')
const confirmingRename = ref(false)
const confirmingDeleteId = ref<string | null>(null)
const confirmingMojangId = ref<string | null>(null)
function resetDialogs() {
showAdd.value = false
newName.value = ''
renamingId.value = null
renameValue.value = ''
confirmingRename.value = false
confirmingDeleteId.value = null
confirmingMojangId.value = null
}
async function submitAdd() {
const name = newName.value.trim()
if (!name || busy.value) return
busy.value = 'add'
try {
const res = await skinSiteAddPlayer(name)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.added) })
resetDialogs()
await requestSkinSitePlayers()
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
function startRename(player: { uuid: string; name: string }) {
renamingId.value = player.uuid
renameValue.value = player.name
confirmingRename.value = false
}
function requestRename() {
const name = renameValue.value.trim()
if (!renamingId.value || !name) return
confirmingRename.value = true
}
async function submitRename() {
const name = renameValue.value.trim()
if (!renamingId.value || !name || busy.value) return
if (!confirmingRename.value) {
confirmingRename.value = true
return
}
busy.value = 'rename'
try {
const res = await skinSiteRenamePlayer(renamingId.value, name)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.renamed) })
resetDialogs()
await requestSkinSitePlayers()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
function requestDelete(uuid: string) {
if (busy.value) return
confirmingDeleteId.value = uuid
}
async function submitDelete(uuid: string) {
if (busy.value) return
if (confirmingDeleteId.value !== uuid) {
confirmingDeleteId.value = uuid
return
}
busy.value = 'delete'
try {
const res = await skinSiteDeletePlayer(uuid)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
resetDialogs()
if (selectedId.value === uuid) selectedId.value = null
await requestSkinSitePlayers()
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
async function setDefaultPlayer(uuid: string) {
if (busy.value) return
busy.value = 'default'
try {
const res = await skinSiteSetDefaultPlayer(uuid)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.defaultSet) })
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
async function clearDefaultPlayer() {
if (busy.value) return
busy.value = 'default'
try {
const res = await skinSiteClearDefaultPlayer()
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.defaultSet) })
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
function requestToggleMojang(uuid: string) {
if (busy.value) return
confirmingMojangId.value = uuid
}
async function toggleMojang(uuid: string) {
if (busy.value) return
if (confirmingMojangId.value !== uuid) {
confirmingMojangId.value = uuid
return
}
busy.value = 'mojang'
try {
const res = await skinSiteSwitchMojang(uuid)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.mojangToggled) })
await requestSkinSitePlayers()
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
async function onUpload(type: SkinTextureType, event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || !selectedId.value) return
uploading.value = type
try {
const metadata: Record<string, string> = {}
if (type === 'SKIN') metadata.model = texture.value.model ?? 'default'
const result = await skinSiteUploadTexture(selectedId.value, file, type, metadata)
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
addNotification({ type: 'success', title: formatMessage(messages.uploaded) })
await loadTexture(selectedId.value)
} catch (error) {
handleError(error)
} finally {
uploading.value = null
}
}
async function removeTexture(type: SkinTextureType) {
if (!selectedId.value) return
uploading.value = type
try {
const result = await skinSiteDeleteTexture(selectedId.value, type)
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
addNotification({ type: 'success', title: formatMessage(messages.removed) })
await loadTexture(selectedId.value)
} catch (error) {
handleError(error)
} finally {
uploading.value = null
}
}
onMounted(() => {
if (signedIn.value) {
void requestSkinSitePlayers().catch(() => {})
void loadDefaultPlayer()
}
})
watch(signedIn, (value) => {
if (value) void requestSkinSitePlayers().catch(() => {})
})
</script>
<template>
<div class="flex flex-col gap-4">
<button
type="button"
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<div v-if="!signedIn" class="rounded-2xl border border-solid border-surface-5 bg-bg-raised p-4">
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.signedOut) }}</p>
</div>
<div v-else class="grid grid-cols-1 gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
<!-- Player list -->
<div
class="flex flex-col gap-2 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex items-center justify-between gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</h3>
<button
v-if="!showAdd"
type="button"
class="shrink-0 rounded-full bg-brand px-2.5 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="showAdd = true"
>
+ {{ formatMessage(messages.addPlayer) }}
</button>
</div>
<div v-if="showAdd" class="flex flex-col gap-1.5 rounded-xl bg-button-bg p-2">
<input
v-model="newName"
type="text"
:placeholder="formatMessage(messages.playerNamePlaceholder)"
class="w-full rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="submitAdd"
/>
<div class="flex gap-1.5">
<button
type="button"
class="flex-1 rounded-lg bg-brand px-2 py-1 text-xs font-semibold text-contrast disabled:opacity-50"
:disabled="busy === 'add' || !newName.trim()"
@click="submitAdd"
>
{{ formatMessage(messages.confirm) }}
</button>
<button
type="button"
class="rounded-lg bg-bg-raised px-2 py-1 text-xs font-semibold text-secondary"
@click="resetDialogs"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
</div>
<div
v-if="renamingId"
class="flex flex-col gap-1.5 rounded-xl bg-button-bg p-2"
>
<input
v-model="renameValue"
type="text"
:placeholder="formatMessage(messages.playerNamePlaceholder)"
class="w-full rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="submitRename"
/>
<p v-if="confirmingRename" class="m-0 text-[11px] text-secondary">
{{ formatMessage(messages.renameConfirm) }}
</p>
<div class="flex gap-1.5">
<button
type="button"
class="flex-1 rounded-lg bg-brand px-2 py-1 text-xs font-semibold text-contrast disabled:opacity-50"
:disabled="busy === 'rename' || !renameValue.trim()"
@click="submitRename"
>
{{ formatMessage(confirmingRename ? messages.confirm : messages.rename) }}
</button>
<button
type="button"
class="rounded-lg bg-bg-raised px-2 py-1 text-xs font-semibold text-secondary"
@click="resetDialogs"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
</div>
<div v-if="playersLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="!players.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.noPlayers) }}
</p>
<div
v-for="player in players"
:key="player.uuid"
class="flex items-center gap-2 rounded-xl px-2 py-1.5 transition-colors"
:class="
player.uuid === selectedId
? 'bg-brand-highlight text-contrast'
: 'bg-button-bg hover:brightness-110'
"
@click="selectPlayer(player.uuid)"
>
<Avatar
:src="player.headDataUrl"
:alt="player.name"
size="1.75rem"
pixelated
class="!rounded-md"
/>
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ player.name }}</span>
<span
v-if="defaultUuid === player.uuid"
class="shrink-0 rounded bg-brand px-1.5 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.isDefault) }}
</span>
<span v-if="player.isMojang" class="shrink-0 text-[10px] uppercase text-secondary"
>Mojang</span
>
<div class="flex shrink-0 items-center gap-1">
<button
v-if="defaultUuid === player.uuid"
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-brand"
:disabled="busy !== null"
@click.stop="clearDefaultPlayer"
>
{{ formatMessage(messages.clearDefault) }}
</button>
<button
v-else
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-secondary hover:bg-bg"
:disabled="busy !== null"
@click.stop="setDefaultPlayer(player.uuid)"
>
{{ formatMessage(messages.setDefault) }}
</button>
<button
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-secondary hover:bg-bg"
:disabled="busy !== null"
@click.stop="startRename(player)"
>
{{ formatMessage(messages.rename) }}
</button>
<button
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-secondary hover:bg-bg"
:disabled="busy !== null"
@click.stop="requestToggleMojang(player.uuid)"
>
{{ formatMessage(messages.toggleMojang) }}
</button>
<button
v-if="confirmingDeleteId === player.uuid"
type="button"
class="rounded bg-red px-1.5 py-0.5 text-[10px] font-semibold text-contrast"
:disabled="busy !== null"
@click.stop="submitDelete(player.uuid)"
>
{{ formatMessage(messages.confirm) }}
</button>
<button
v-else
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-red hover:bg-bg"
:disabled="busy !== null"
@click.stop="requestDelete(player.uuid)"
>
{{ formatMessage(messages.delete) }}
</button>
<button
v-if="confirmingDeleteId === player.uuid || confirmingMojangId === player.uuid"
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-secondary hover:bg-bg"
@click.stop="resetDialogs"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
</div>
<div
v-if="confirmingDeleteId === player.uuid"
class="rounded-lg bg-button-bg px-2 py-1 text-[11px] text-red"
>
{{ formatMessage(messages.deleteConfirm) }}
</div>
<div
v-if="confirmingMojangId === player.uuid"
class="rounded-lg bg-button-bg px-2 py-1 text-[11px] text-secondary"
>
{{ formatMessage(messages.mojangConfirm) }}
<button
type="button"
class="ml-1 rounded bg-brand px-1.5 py-0.5 text-[10px] font-semibold text-contrast"
:disabled="busy !== null"
@click.stop="toggleMojang(player.uuid)"
>
{{ formatMessage(messages.confirm) }}
</button>
</div>
</div>
<!-- Preview + texture actions -->
<div
class="flex flex-col gap-4 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<p v-if="!selectedPlayer" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.selectHint) }}
</p>
<template v-else>
<div class="flex flex-wrap items-center gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">{{ selectedPlayer.name }}</h3>
<span
v-if="defaultUuid === selectedPlayer.uuid"
class="rounded bg-brand px-1.5 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.isDefault) }}
</span>
<span v-if="selectedPlayer.isMojang" class="text-xs text-secondary">Mojang</span>
<button
v-if="confirmingMojangId === selectedPlayer.uuid"
type="button"
class="rounded-full bg-brand px-2.5 py-1 text-xs font-semibold text-contrast"
:disabled="busy !== null"
@click="toggleMojang(selectedPlayer.uuid)"
>
{{ formatMessage(messages.confirm) }}
</button>
<button
v-else
type="button"
class="rounded-full bg-button-bg px-2.5 py-1 text-xs font-semibold text-secondary hover:brightness-110"
:disabled="busy !== null"
@click="requestToggleMojang(selectedPlayer.uuid)"
>
{{ formatMessage(messages.toggleMojang) }}
</button>
</div>
<p v-if="confirmingMojangId === selectedPlayer.uuid" class="m-0 text-xs text-secondary">
{{ formatMessage(messages.mojangConfirm) }}
</p>
<div class="relative h-80 w-full overflow-hidden rounded-xl bg-button-bg">
<div v-if="loadingTexture" class="absolute inset-0 flex items-center justify-center">
<SpinnerIcon class="size-6 animate-spin text-secondary" />
</div>
<SkinPreviewRenderer
v-else
:texture-src="texture.skin ?? ''"
:cape-src="texture.cape"
:variant="skinVariant"
:nametag="selectedPlayer.name"
:initial-rotation="Math.PI / 8"
class="size-full"
/>
</div>
<p v-if="!editable" class="m-0 text-xs text-secondary">
{{ formatMessage(messages.readonly) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="entry in [
{ type: 'SKIN' as SkinTextureType, label: messages.skin, value: texture.skin },
{ type: 'CAPE' as SkinTextureType, label: messages.cape, value: texture.cape },
{
type: 'ELYTRA' as SkinTextureType,
label: messages.elytra,
value: texture.elytra,
},
]"
:key="entry.type"
class="flex items-center gap-2 rounded-xl bg-button-bg px-3 py-2"
>
<span class="min-w-0 flex-1 text-sm font-medium text-contrast">
{{ formatMessage(entry.label) }}
</span>
<span class="shrink-0 text-xs text-secondary">
{{ entry.value ? '✓' : '—' }}
</span>
<label
class="flex shrink-0 cursor-pointer items-center gap-1 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast"
>
<UploadIcon class="size-3" />
{{ formatMessage(messages.upload) }}
<input
type="file"
accept="image/png"
class="hidden"
:disabled="uploading !== null"
@change="(e) => onUpload(entry.type, e)"
/>
</label>
<button
v-if="entry.value"
type="button"
class="flex shrink-0 items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-red"
:disabled="uploading !== null"
@click="removeTexture(entry.type)"
>
<TrashIcon class="size-3" />
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
@@ -0,0 +1,299 @@
<script setup lang="ts">
import {
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, onUnmounted, ref } from 'vue'
import { reloadSkinSiteSession, signInSkinSite } from '@/composables/skin-site-session'
import {
skinSiteRegister,
skinSiteRegisterCondition,
skinSiteRegisterEmailVerify,
} from '@/helpers/skin-site-api'
import SkinSiteCaptchaModal from './SkinSiteCaptchaModal.vue'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.register.title', defaultMessage: 'Create a StarLight account' },
email: { id: 'app.skin-site.register.email', defaultMessage: 'Email' },
code: { id: 'app.skin-site.register.code', defaultMessage: 'Verification code' },
sendCode: { id: 'app.skin-site.register.send-code', defaultMessage: 'Send code' },
sending: { id: 'app.skin-site.register.sending', defaultMessage: 'Sending…' },
resendIn: { id: 'app.skin-site.register.resend-in', defaultMessage: 'Sent ({seconds}s)' },
password: { id: 'app.skin-site.register.password', defaultMessage: 'Password' },
repeatPassword: {
id: 'app.skin-site.register.repeat-password',
defaultMessage: 'Repeat password',
},
passwordHint: {
id: 'app.skin-site.register.password-hint',
defaultMessage: '6–50 characters with upper case, lower case and a digit.',
},
mismatch: { id: 'app.skin-site.register.mismatch', defaultMessage: 'Passwords do not match.' },
qq: { id: 'app.skin-site.register.qq', defaultMessage: 'QQ (optional)' },
inviteCode: { id: 'app.skin-site.register.invite-code', defaultMessage: 'Invite code' },
inviteRequired: {
id: 'app.skin-site.register.invite-required',
defaultMessage: 'This server requires an invite code.',
},
submit: { id: 'app.skin-site.register.submit', defaultMessage: 'Create account' },
success: {
id: 'app.skin-site.register.success',
defaultMessage: 'Account created and signed in.',
},
closed: {
id: 'app.skin-site.register.closed',
defaultMessage: 'Registration is currently closed on this server.',
},
})
const PASSWORD_RE = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{6,50}$/
const COUNTDOWN_SECONDS = 60
const modal = ref<InstanceType<typeof NewModal>>()
const captchaModal = ref<InstanceType<typeof SkinSiteCaptchaModal>>()
const email = ref('')
const code = ref('')
const password = ref('')
const repeatPassword = ref('')
const qq = ref('')
const inviteCode = ref('')
const inviteRequired = ref(false)
const registerAllowed = ref(true)
const sending = ref(false)
const working = ref(false)
const countdown = ref(0)
const loadingCondition = ref(false)
let timer: ReturnType<typeof setInterval> | undefined
const emit = defineEmits<{ (e: 'signed-in' | 'cancel'): void }>()
function stopTimer() {
if (timer) clearInterval(timer)
timer = undefined
}
function startCountdown() {
countdown.value = COUNTDOWN_SECONDS
stopTimer()
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0) stopTimer()
}, 1000)
}
const canSendCode = computed(
() => email.value.trim().length > 0 && !sending.value && countdown.value <= 0,
)
const passwordValid = computed(() => PASSWORD_RE.test(password.value))
const passwordsMatch = computed(() => password.value === repeatPassword.value)
const qqValid = computed(() => qq.value.trim() === '' || /^\d{5,}$/.test(qq.value.trim()))
const inviteValid = computed(() => !inviteRequired.value || inviteCode.value.trim().length > 0)
const canSubmit = computed(
() =>
registerAllowed.value &&
email.value.trim().length > 0 &&
code.value.trim().length === 6 &&
passwordValid.value &&
passwordsMatch.value &&
qqValid.value &&
inviteValid.value &&
!working.value,
)
function reset() {
stopTimer()
email.value = ''
code.value = ''
password.value = ''
repeatPassword.value = ''
qq.value = ''
inviteCode.value = ''
sending.value = false
working.value = false
countdown.value = 0
}
async function loadCondition() {
loadingCondition.value = true
try {
const result = await skinSiteRegisterCondition()
if (result.success) {
registerAllowed.value = result.payload.registerAllowed
inviteRequired.value = result.payload.inviteCodeRequired
} else {
registerAllowed.value = false
}
} finally {
loadingCondition.value = false
}
}
async function show() {
reset()
modal.value?.show()
await loadCondition()
}
async function sendCode() {
if (!canSendCode.value) return
sending.value = true
try {
const result = await skinSiteRegisterEmailVerify(email.value.trim())
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
addNotification({ type: 'success', title: result.payload })
startCountdown()
} catch (error) {
handleError(error)
} finally {
sending.value = false
}
}
function beginRegister() {
if (!canSubmit.value) return
if (!passwordsMatch.value) {
handleError(new Error(formatMessage(messages.mismatch)))
return
}
captchaModal.value?.show()
}
async function onCaptchaVerified(clientId: string) {
working.value = true
try {
const result = await skinSiteRegister({
email: email.value.trim(),
emailVerifyCode: code.value.trim(),
password: password.value,
qq: qq.value.trim() ? Number(qq.value.trim()) : undefined,
inviteCode: inviteCode.value.trim() || undefined,
clientId,
})
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
// Registration returns a JWT; persist it through the normal sign-in path.
await signInSkinSite({ username: result.payload.username, password: password.value, clientId })
await reloadSkinSiteSession()
addNotification({ type: 'success', title: formatMessage(messages.success) })
modal.value?.hide()
emit('signed-in')
} catch (error) {
handleError(error)
} finally {
working.value = false
}
}
function cancel() {
modal.value?.hide()
emit('cancel')
}
onUnmounted(stopTimer)
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="reset">
<div v-if="!loadingCondition && !registerAllowed" class="text-secondary">
{{ formatMessage(messages.closed) }}
</div>
<div v-else class="flex flex-col gap-4">
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.email) }}</span>
<StyledInput v-model="email" type="email" autocomplete="email" />
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.code) }}</span>
<div class="flex gap-2">
<StyledInput v-model="code" type="text" class="flex-1" maxlength="6" />
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-4 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSendCode"
@click="sendCode"
>
{{
sending
? formatMessage(messages.sending)
: countdown > 0
? formatMessage(messages.resendIn, { seconds: countdown })
: formatMessage(messages.sendCode)
}}
</button>
</div>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.password) }}</span>
<StyledInput v-model="password" type="password" autocomplete="new-password" />
<span class="text-xs text-secondary">{{ formatMessage(messages.passwordHint) }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{
formatMessage(messages.repeatPassword)
}}</span>
<StyledInput v-model="repeatPassword" type="password" autocomplete="new-password" />
<span v-if="repeatPassword && !passwordsMatch" class="text-xs text-red">
{{ formatMessage(messages.mismatch) }}
</span>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.qq) }}</span>
<StyledInput v-model="qq" type="text" inputmode="numeric" />
</label>
<label v-if="inviteRequired" class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.inviteCode) }}</span>
<StyledInput v-model="inviteCode" type="text" />
<span class="text-xs text-secondary">{{ formatMessage(messages.inviteRequired) }}</span>
</label>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
@click="cancel"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 font-semibold text-contrast disabled:opacity-50"
:disabled="!canSubmit"
@click="beginRegister"
>
{{ formatMessage(messages.submit) }}
</button>
</div>
</template>
</NewModal>
<SkinSiteCaptchaModal
ref="captchaModal"
@verified="onCaptchaVerified"
@cancel="working = false"
/>
</template>
@@ -0,0 +1,313 @@
<script setup lang="ts">
import { CheckCircleIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, StyledInput, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteReviewCatalog,
type SkinSiteReviewPublicPaper,
type SkinSiteReviewPublicQuestion,
type SkinSiteReviewReceipt,
skinSiteSubmitReview,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.review.title', defaultMessage: 'Player review' },
back: { id: 'app.skin-site.review.back', defaultMessage: 'Back to home' },
subtitle: {
id: 'app.skin-site.review.subtitle',
defaultMessage: 'Pick an application form and answer its questions.',
},
loading: { id: 'app.skin-site.review.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.review.failed', defaultMessage: 'Could not load the review forms.' },
empty: {
id: 'app.skin-site.review.empty',
defaultMessage: 'No application forms are available right now.',
},
email: { id: 'app.skin-site.review.email', defaultMessage: 'Your email' },
emailHint: {
id: 'app.skin-site.review.email-hint',
defaultMessage: 'Results are sent to this address.',
},
inviteCode: { id: 'app.skin-site.review.invite-code', defaultMessage: 'Invite code' },
submit: { id: 'app.skin-site.review.submit', defaultMessage: 'Submit application' },
submitting: { id: 'app.skin-site.review.submitting', defaultMessage: 'Submitting…' },
backToList: { id: 'app.skin-site.review.back-to-list', defaultMessage: 'Back to forms' },
required: { id: 'app.skin-site.review.required', defaultMessage: 'Required' },
optional: { id: 'app.skin-site.review.optional', defaultMessage: 'Optional' },
submitted: { id: 'app.skin-site.review.submitted', defaultMessage: 'Application submitted.' },
resultPending: { id: 'app.skin-site.review.result.pending', defaultMessage: 'Under review' },
resultPassed: { id: 'app.skin-site.review.result.passed', defaultMessage: 'Approved' },
resultRejected: { id: 'app.skin-site.review.result.rejected', defaultMessage: 'Rejected' },
})
const emit = defineEmits<{ (e: 'back'): void }>()
const papers = ref<SkinSiteReviewPublicPaper[]>([])
const loading = ref(true)
const failed = ref(false)
const activePaper = ref<SkinSiteReviewPublicPaper | null>(null)
const email = ref('')
const inviteCode = ref('')
const answers = ref<Record<string, string | string[]>>({})
const submitting = ref(false)
const receipt = ref<SkinSiteReviewReceipt | null>(null)
const emailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value.trim()))
const requiredAnswered = computed(() => {
if (!activePaper.value) return false
return (activePaper.value.questions ?? []).every((q) => {
if (q.informationQuestion || q.optional) return true
const value = answers.value[q.id]
if (Array.isArray(value)) return value.length > 0
return typeof value === 'string' ? value.trim().length > 0 : false
})
})
const canSubmit = computed(() => emailValid.value && requiredAnswered.value && !submitting.value)
function defaultAnswer(q: SkinSiteReviewPublicQuestion): string | string[] {
if (q.type === 'MULTIPLE' || q.type === 'SINGLE') {
const preset = (q.options ?? []).filter((o) => o.defaultSelected).map((o) => o.id)
return q.type === 'MULTIPLE' ? preset : (preset[0] ?? '')
}
return ''
}
function openPaper(paper: SkinSiteReviewPublicPaper) {
activePaper.value = paper
answers.value = {}
for (const q of paper.questions ?? []) answers.value[q.id] = defaultAnswer(q)
email.value = ''
inviteCode.value = ''
receipt.value = null
}
function closePaper() {
activePaper.value = null
answers.value = {}
receipt.value = null
}
function toggleMultiple(q: SkinSiteReviewPublicQuestion, optionId: string, checked: boolean) {
const current = Array.isArray(answers.value[q.id]) ? (answers.value[q.id] as string[]) : []
answers.value[q.id] = checked
? [...current.filter((id) => id !== optionId), optionId]
: current.filter((id) => id !== optionId)
}
async function loadCatalog() {
loading.value = true
failed.value = false
try {
const result = await skinSiteReviewCatalog()
if (!result.success) throw new Error(result.errorMessage)
papers.value = result.payload?.papers ?? []
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function submitApplication() {
if (!activePaper.value || !canSubmit.value) return
submitting.value = true
try {
const payloadAnswers: Record<string, unknown> = {}
for (const q of activePaper.value.questions ?? []) {
if (q.informationQuestion) continue
payloadAnswers[q.id] = answers.value[q.id] ?? ''
}
const result = await skinSiteSubmitReview({
paperId: activePaper.value.id,
email: email.value.trim(),
answers: payloadAnswers,
...(inviteCode.value.trim() ? { inviteCode: inviteCode.value.trim() } : {}),
})
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
receipt.value = result.payload
addNotification({ type: 'success', title: formatMessage(messages.submitted) })
} catch (error) {
handleError(error)
} finally {
submitting.value = false
}
}
function resultLabel(result?: string): string {
switch (result) {
case 'PASSED':
return formatMessage(messages.resultPassed)
case 'REJECTED':
return formatMessage(messages.resultRejected)
default:
return formatMessage(messages.resultPending)
}
}
onMounted(loadCatalog)
</script>
<template>
<div class="flex flex-col gap-4">
<button
type="button"
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="activePaper ? closePaper() : emit('back')"
>
← {{ activePaper ? formatMessage(messages.backToList) : formatMessage(messages.back) }}
</button>
<template v-if="!activePaper">
<header class="flex flex-col gap-1">
<h1 class="m-0 text-xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
</header>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!papers.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<button
v-for="paper in papers"
:key="paper.id"
type="button"
class="flex flex-col items-start gap-1 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3 text-left transition-colors hover:bg-button-bg"
@click="openPaper(paper)"
>
<span class="text-sm font-semibold text-contrast">{{ paper.title }}</span>
<span v-if="paper.description" class="text-xs text-secondary">{{ paper.description }}</span>
</button>
</template>
<div
v-else-if="receipt"
class="flex flex-col items-center gap-3 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-6 text-center"
>
<CheckCircleIcon class="size-10 text-brand" />
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.submitted) }}</h2>
<p class="m-0 text-sm text-secondary">{{ resultLabel(receipt.result) }}</p>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast"
@click="closePaper"
>
{{ formatMessage(messages.backToList) }}
</button>
</div>
<template v-else>
<h1 class="m-0 text-xl font-bold text-contrast">{{ activePaper.title }}</h1>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">{{ formatMessage(messages.email) }}</span>
<StyledInput v-model="email" type="email" autocomplete="email" />
<span class="text-xs text-secondary">{{ formatMessage(messages.emailHint) }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.inviteCode)
}}</span>
<StyledInput v-model="inviteCode" type="text" />
</label>
<div
v-for="question in activePaper.questions ?? []"
:key="question.id"
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-contrast">{{ question.title }}</span>
<span
v-if="!question.optional && !question.informationQuestion"
class="rounded-full bg-brand-highlight px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.required) }}
</span>
</div>
<span v-if="question.annotation" class="text-xs text-secondary">{{
question.annotation
}}</span>
<span v-if="question.informationQuestion" class="text-xs text-secondary">
{{ question.answer }}
</span>
<template v-else-if="question.type === 'SINGLE' || question.type === 'TRUE_FALSE'">
<label
v-for="option in question.options ?? []"
:key="option.id"
class="flex items-center gap-2 text-sm"
>
<input
type="radio"
:name="question.id"
:value="option.id"
:checked="answers[question.id] === option.id"
@change="answers[question.id] = option.id"
/>
<span class="text-contrast">{{ option.description }}</span>
</label>
</template>
<template v-else-if="question.type === 'MULTIPLE'">
<label
v-for="option in question.options ?? []"
:key="option.id"
class="flex items-center gap-2 text-sm"
>
<input
type="checkbox"
:checked="
Array.isArray(answers[question.id]) &&
(answers[question.id] as string[]).includes(option.id)
"
@change="
toggleMultiple(question, option.id, ($event.target as HTMLInputElement).checked)
"
/>
<span class="text-contrast">{{ option.description }}</span>
</label>
</template>
<StyledInput
v-else-if="question.type === 'FILL'"
v-model="answers[question.id] as string"
type="text"
:maxlength="question.maxLength ?? 50"
/>
<textarea
v-else
v-model="answers[question.id] as string"
rows="4"
class="w-full rounded-lg border border-solid border-surface-5 bg-button-bg p-2 text-sm text-contrast"
:maxlength="question.maxLength ?? 300"
/>
</div>
<button
type="button"
class="w-fit self-end rounded-full bg-brand px-5 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSubmit"
@click="submitApplication"
>
{{ formatMessage(submitting ? messages.submitting : messages.submit) }}
</button>
</template>
</div>
</template>
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { Avatar } from '@modrinth/ui'
import { useResizeObserver } from '@vueuse/core'
import type { Component } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
export interface SelectionItem {
id: string
label: string
description?: string
icon?: Component
avatar?: string
}
const props = withDefaults(
defineProps<{
items: SelectionItem[]
selected: string | null
label: string
vertical?: boolean
disabled?: boolean
}>(),
{ vertical: false, disabled: false },
)
const emit = defineEmits<{ select: [id: string] }>()
const rail = ref<HTMLElement>()
const indicator = ref({ x: 0, y: 0, width: 0, height: 0 })
const ready = ref(false)
const active = computed(() => props.items.some((item) => item.id === props.selected))
async function updateIndicator() {
await nextTick()
const button = rail.value?.querySelector<HTMLElement>('[aria-pressed="true"]')
if (!button) return
indicator.value = {
x: button.offsetLeft,
y: button.offsetTop,
width: button.offsetWidth,
height: button.offsetHeight,
}
if (!ready.value)
requestAnimationFrame(() => {
ready.value = true
})
}
watch(() => [props.selected, props.items], updateIndicator, { deep: true })
onMounted(updateIndicator)
useResizeObserver(rail, updateIndicator)
</script>
<template>
<div class="selection-rail" :class="{ vertical }" :aria-label="label" role="group">
<div ref="rail" class="selection-track">
<span
v-show="active"
aria-hidden="true"
class="selection-indicator"
:class="{ ready }"
:style="{
transform: `translate3d(${indicator.x}px, ${indicator.y}px, 0)`,
width: `${indicator.width}px`,
height: `${indicator.height}px`,
}"
/>
<button
v-for="item in items"
:key="item.id"
v-tooltip="{
content: item.description ? `${item.label} · ${item.description}` : item.label,
placement: vertical ? 'right' : 'top',
}"
type="button"
class="selection-button"
:aria-label="item.label"
:aria-pressed="selected === item.id"
:disabled="disabled"
@click="emit('select', item.id)"
>
<Avatar
v-if="vertical"
:src="item.avatar"
:alt="item.label"
size="2rem"
pixelated
:unframed-natural-width="36"
/>
<component :is="item.icon" v-else-if="item.icon" class="size-5 shrink-0" />
<span v-if="!vertical" class="selection-label">{{ item.label }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.selection-rail {
min-width: 0;
padding: 0.375rem;
border: 1px solid var(--color-divider);
border-radius: var(--radius-xl);
background: var(--color-raised-bg);
}
.selection-track {
position: relative;
display: flex;
gap: 0.375rem;
}
.selection-rail:not(.vertical) {
container-type: inline-size;
}
.selection-button {
position: relative;
z-index: 1;
display: flex;
flex: 1;
min-width: 0;
align-items: center;
justify-content: center;
gap: 0.625rem;
padding: 0.875rem 0.75rem;
border: 0;
border-radius: var(--radius-lg);
background: transparent;
color: var(--color-secondary);
font: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition:
color 180ms,
background 180ms,
transform 180ms;
}
.selection-button:hover {
color: var(--color-contrast);
background: var(--color-button-bg);
}
.selection-button[aria-pressed='true'] {
color: var(--color-contrast);
}
.selection-button:active {
transform: scale(0.94);
}
.selection-button:focus-visible {
outline: 2px solid var(--color-brand);
outline-offset: -2px;
}
.selection-button:disabled {
cursor: wait;
}
.selection-indicator {
position: absolute;
top: 0;
left: 0;
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-contrast) 13%, var(--color-raised-bg));
pointer-events: none;
}
.selection-indicator.ready {
transition:
transform 480ms cubic-bezier(0.22, 1.4, 0.36, 1),
width 350ms ease,
height 350ms ease;
}
.vertical {
align-self: start;
}
.vertical .selection-track {
flex-direction: column;
}
.vertical .selection-button {
flex: none;
padding: 0.75rem;
}
.selection-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@container (max-width: 40rem) {
.selection-label {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.selection-indicator.ready,
.selection-button {
transition: none;
}
}
</style>
@@ -0,0 +1,493 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteWikiPageDetail,
skinSiteWikiPage,
skinSiteWikiDiff,
skinSiteWikiHistory,
skinSiteWikiRevert,
skinSiteWikiRevision,
type SkinSiteWikiDiffLine,
type SkinSiteWikiNode,
type SkinSiteWikiRevisionInfo,
} from '@/helpers/skin-site-api'
import WikiNodeRenderer from './wiki/WikiNodeRenderer.vue'
import WikiBrowser from './wiki/WikiBrowser.vue'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const props = withDefaults(defineProps<{ initialTitle?: string }>(), {
initialTitle: '',
})
const messages = defineMessages({
title: { id: 'app.skin-site.wiki.title', defaultMessage: 'StarLight Wiki' },
searchPlaceholder: {
id: 'app.skin-site.wiki.search-placeholder',
defaultMessage: 'Enter a page title',
},
open: { id: 'app.skin-site.wiki.open', defaultMessage: 'Open' },
loading: { id: 'app.skin-site.wiki.loading', defaultMessage: 'Loading…' },
notFound: {
id: 'app.skin-site.wiki.not-found',
defaultMessage: 'Page not found.',
},
empty: {
id: 'app.skin-site.wiki.empty',
defaultMessage: 'Enter a page title above to start reading.',
},
updatedAt: {
id: 'app.skin-site.wiki.updated-at',
defaultMessage: 'Last edited {date} by {author}',
},
revision: { id: 'app.skin-site.wiki.revision', defaultMessage: 'Revision #{id}' },
categories: { id: 'app.skin-site.wiki.categories', defaultMessage: 'Categories' },
back: { id: 'app.skin-site.wiki.back', defaultMessage: 'Back' },
history: { id: 'app.skin-site.wiki.history', defaultMessage: 'History' },
read: { id: 'app.skin-site.wiki.read', defaultMessage: 'Read' },
historyEmpty: {
id: 'app.skin-site.wiki.history-empty',
defaultMessage: 'No revisions yet.',
},
noSummary: {
id: 'app.skin-site.wiki.no-summary',
defaultMessage: 'No edit summary',
},
minor: { id: 'app.skin-site.wiki.minor', defaultMessage: 'Minor' },
viewRevision: { id: 'app.skin-site.wiki.view-revision', defaultMessage: 'View' },
revertTo: {
id: 'app.skin-site.wiki.revert-to',
defaultMessage: 'Revert to this',
},
confirmRevert: {
id: 'app.skin-site.wiki.confirm-revert',
defaultMessage: 'Confirm revert',
},
revertDone: {
id: 'app.skin-site.wiki.revert-done',
defaultMessage: 'Page reverted to the selected revision.',
},
revisionPreview: {
id: 'app.skin-site.wiki.revision-preview',
defaultMessage: 'Preview of revision #{id}',
},
closePreview: {
id: 'app.skin-site.wiki.close-preview',
defaultMessage: 'Close preview',
},
diffTitle: {
id: 'app.skin-site.wiki.diff-title',
defaultMessage: 'Line diff against the current revision',
},
cancel: { id: 'app.skin-site.wiki.cancel', defaultMessage: 'Cancel' },
})
const titleInput = ref(props.initialTitle)
const page = ref<SkinSiteWikiPageDetail | null>(null)
const loading = ref(false)
const notFound = ref(false)
const navStack = ref<string[]>([])
const currentTitle = computed(() => page.value?.title ?? '')
async function openPage(title: string) {
const trimmed = title.trim()
if (!trimmed) return
titleInput.value = trimmed
loading.value = true
notFound.value = false
try {
const res = await skinSiteWikiPage(trimmed)
if (!res.success) {
notFound.value = true
page.value = null
return
}
page.value = res.payload
if (currentTitle.value && navStack.value[navStack.value.length - 1] !== currentTitle.value) {
navStack.value.push(currentTitle.value)
}
} catch (error) {
handleError(error)
notFound.value = true
page.value = null
} finally {
loading.value = false
}
}
function goBack() {
const prev = navStack.value.pop()
if (prev) void openPage(prev)
}
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
const mode = ref<'read' | 'history'>('read')
const history = ref<SkinSiteWikiRevisionInfo[]>([])
const historyLoading = ref(false)
const selectedRevisionId = ref<number | null>(null)
const selectedRevisionNodes = ref<SkinSiteWikiNode[] | null>(null)
const diffLines = ref<SkinSiteWikiDiffLine[]>([])
const pendingRevertId = ref<number | null>(null)
const reverting = ref(false)
async function showHistory() {
if (!page.value) return
mode.value = 'history'
historyLoading.value = true
try {
const res = await skinSiteWikiHistory(page.value.title)
if (!res.success) throw new Error(res.errorMessage)
history.value = res.payload.items ?? []
} catch (error) {
handleError(error)
history.value = []
} finally {
historyLoading.value = false
}
}
function backToRead() {
mode.value = 'read'
selectedRevisionNodes.value = null
diffLines.value = []
pendingRevertId.value = null
}
async function viewRevision(revisionId: number) {
if (!page.value) return
try {
const res = await skinSiteWikiRevision(page.value.title, revisionId)
if (!res.success) throw new Error(res.errorMessage)
selectedRevisionId.value = revisionId
selectedRevisionNodes.value = res.payload.nodes ?? []
diffLines.value = []
if (revisionId !== page.value.revisionId) {
const diff = await skinSiteWikiDiff(page.value.title, revisionId, page.value.revisionId)
if (diff.success) diffLines.value = diff.payload ?? []
}
} catch (error) {
handleError(error)
}
}
async function revertToRevision(revisionId: number) {
if (!page.value || reverting.value) return
reverting.value = true
try {
const res = await skinSiteWikiRevert({
title: page.value.title,
revisionId,
baseRevisionId: page.value.revisionId,
})
if (!res.success) throw new Error(res.errorMessage)
page.value = res.payload
pendingRevertId.value = null
backToRead()
addNotification({ type: 'success', title: formatMessage(messages.revertDone) })
} catch (error) {
handleError(error)
} finally {
reverting.value = false
}
}
function onNavigate(title: string) {
if (title) void openPage(title)
}
onMounted(() => {
if (props.initialTitle) void openPage(props.initialTitle)
})
</script>
<template>
<div class="flex w-full flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<button
v-if="navStack.length"
type="button"
class="rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
@click="goBack"
>
← {{ formatMessage(messages.back) }}
</button>
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<input
v-model="titleInput"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="openPage(titleInput)"
/>
<button
type="button"
class="rounded-lg bg-brand px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
@click="openPage(titleInput)"
>
{{ formatMessage(messages.open) }}
</button>
<template v-if="page">
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-semibold"
:class="mode === 'read' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="backToRead"
>
{{ formatMessage(messages.read) }}
</button>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-semibold"
:class="mode === 'history' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="showHistory"
>
{{ formatMessage(messages.history) }}
</button>
</template>
</div>
<WikiBrowser @navigate="onNavigate" />
<div v-if="loading" class="flex min-h-48 items-center justify-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p
v-else-if="notFound"
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.notFound) }}
</p>
<p
v-else-if="!page"
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.empty) }}
</p>
<template v-else-if="mode === 'read'">
<div class="flex flex-wrap items-baseline justify-between gap-2 border-b border-solid border-surface-5 pb-2">
<h1 class="m-0 text-2xl font-bold text-contrast">{{ page.title }}</h1>
<div class="flex flex-wrap items-center gap-1.5">
<span class="text-[11px] text-secondary">
{{ formatMessage(messages.revision, { id: page.revisionId }) }}
</span>
<button
type="button"
class="rounded-lg px-2.5 py-1 text-xs font-semibold disabled:opacity-50"
:class="watching ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
:disabled="watchBusy"
@click="toggleWatch"
>
{{ formatMessage(watching ? messages.unwatch : messages.watch) }}
</button>
<button
v-if="page.canEdit"
type="button"
class="rounded-lg bg-button-bg px-2.5 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="openMove"
>
{{ formatMessage(messages.move) }}
</button>
</div>
</div>
<div
v-if="moving"
class="flex flex-wrap items-center gap-2 rounded-lg border border-solid border-surface-5 bg-bg-raised p-2"
>
<input
v-model="moveTarget"
type="text"
:placeholder="formatMessage(messages.moveTarget)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="confirmMove"
/>
<button
type="button"
class="rounded-lg bg-brand px-2.5 py-1 text-xs font-semibold text-contrast disabled:opacity-50"
:disabled="moveBusy"
@click="confirmMove"
>
{{ formatMessage(messages.moveConfirm) }}
</button>
<button
type="button"
class="rounded-lg bg-button-bg px-2.5 py-1 text-xs font-semibold text-secondary"
@click="moving = false"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
<p class="m-0 text-[11px] text-secondary">
{{
formatMessage(messages.updatedAt, {
date: formatDate(page.updatedAt),
author: page.authorName,
})
}}
</p>
<div
v-if="page.redirectTarget"
class="rounded-lg border border-solid border-yellow/40 bg-yellow/10 p-3 text-xs text-yellow"
>
重定向到
<button
type="button"
class="font-semibold underline underline-offset-2"
@click="openPage(page.redirectTarget ?? '')"
>
{{ page.redirectTarget }}
</button>
</div>
<article class="min-w-0 text-sm leading-7 text-contrast">
<WikiNodeRenderer :nodes="page.nodes" @navigate="onNavigate" />
</article>
<div v-if="page.categories.length" class="flex flex-wrap items-center gap-2 pt-2">
<span class="text-[11px] text-secondary">{{ formatMessage(messages.categories) }}:</span>
<span
v-for="category in page.categories"
:key="category"
class="rounded bg-bg-raised px-2 py-0.5 text-[11px] text-secondary"
>
{{ category }}
</span>
</div>
</template>
<template v-else>
<div v-if="historyLoading" class="flex min-h-32 items-center justify-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<template v-else>
<div
v-if="selectedRevisionNodes"
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.revisionPreview, { id: selectedRevisionId }) }}
</span>
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="selectedRevisionNodes = null; diffLines = []"
>
{{ formatMessage(messages.closePreview) }}
</button>
</div>
<article class="min-w-0 text-sm leading-7 text-contrast">
<WikiNodeRenderer :nodes="selectedRevisionNodes" @navigate="onNavigate" />
</article>
<details v-if="diffLines.length" class="rounded-lg border border-solid border-surface-5 p-3">
<summary class="cursor-pointer text-xs font-semibold text-contrast">
{{ formatMessage(messages.diffTitle) }}
</summary>
<div class="mt-2 max-h-96 overflow-auto font-mono text-[11px]">
<div
v-for="(line, idx) in diffLines"
:key="idx"
class="px-2 py-0.5 whitespace-pre-wrap"
:class="
line.type === 'ADDED'
? 'bg-green/15 text-green'
: line.type === 'REMOVED'
? 'bg-red/15 text-red'
: 'text-secondary'
"
>
{{ line.type === 'ADDED' ? '+' : line.type === 'REMOVED' ? '-' : ' ' }}{{ line.text }}
</div>
</div>
</details>
</div>
<p
v-if="!history.length"
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.historyEmpty) }}
</p>
<div v-else class="flex flex-col divide-y divide-surface-5">
<div
v-for="revision in history"
:key="revision.id"
class="flex flex-wrap items-start justify-between gap-3 py-3"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-contrast">{{ revision.authorName }}</span>
<span
v-if="revision.minor"
class="rounded bg-button-bg px-2 py-0.5 text-[10px] text-secondary"
>
{{ formatMessage(messages.minor) }}
</span>
<span class="text-[10px] text-secondary">#{{ revision.id }}</span>
</div>
<p class="m-0 mt-1 text-xs text-secondary">
{{ revision.summary || formatMessage(messages.noSummary) }}
</p>
<p class="m-0 mt-0.5 text-[10px] text-secondary">{{ formatDate(revision.createdAt) }}</p>
</div>
<div class="flex flex-wrap gap-1.5">
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="viewRevision(revision.id)"
>
{{ formatMessage(messages.viewRevision) }}
</button>
<template v-if="page && revision.id !== page.revisionId">
<button
v-if="pendingRevertId !== revision.id"
type="button"
class="rounded-lg bg-yellow/20 px-3 py-1 text-xs font-semibold text-yellow hover:brightness-110 disabled:opacity-50"
:disabled="reverting"
@click="pendingRevertId = revision.id"
>
{{ formatMessage(messages.revertTo) }}
</button>
<template v-else>
<button
type="button"
class="rounded-lg bg-yellow px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="reverting"
@click="revertToRevision(revision.id)"
>
{{ formatMessage(messages.confirmRevert) }}
</button>
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary"
@click="pendingRevertId = null"
>
{{ formatMessage(messages.cancel) }}
</button>
</template>
</template>
</div>
</div>
</div>
</template>
</template>
</div>
</template>
@@ -0,0 +1,273 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
skinSiteAccessAudit,
type SkinSiteAccessAuditEntry,
skinSiteUpdateAccessAuditSettings,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.audit.title', defaultMessage: 'Access audit' },
loading: { id: 'app.skin-site.admin.audit.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.audit.failed', defaultMessage: 'Could not load audit log.' },
empty: { id: 'app.skin-site.admin.audit.empty', defaultMessage: 'No audit entries.' },
subject: { id: 'app.skin-site.admin.audit.subject', defaultMessage: 'Subject' },
action: { id: 'app.skin-site.admin.audit.action', defaultMessage: 'Action' },
sourceIp: { id: 'app.skin-site.admin.audit.source-ip', defaultMessage: 'Source IP' },
reportedIp: {
id: 'app.skin-site.admin.audit.reported-ip',
defaultMessage: 'Reported player IP',
},
count: { id: 'app.skin-site.admin.audit.count', defaultMessage: 'Count' },
firstAt: { id: 'app.skin-site.admin.audit.first-at', defaultMessage: 'First seen' },
lastAt: { id: 'app.skin-site.admin.audit.last-at', defaultMessage: 'Last seen' },
proxy: { id: 'app.skin-site.admin.audit.proxy', defaultMessage: 'Proxy' },
internal: { id: 'app.skin-site.admin.audit.internal', defaultMessage: 'Internal' },
searchSubject: {
id: 'app.skin-site.admin.audit.search-subject',
defaultMessage: 'Subject',
},
searchIp: { id: 'app.skin-site.admin.audit.search-ip', defaultMessage: 'IP' },
searchAction: { id: 'app.skin-site.admin.audit.search-action', defaultMessage: 'Action' },
search: { id: 'app.skin-site.admin.audit.search', defaultMessage: 'Search' },
clear: { id: 'app.skin-site.admin.audit.clear', defaultMessage: 'Clear' },
loadMore: { id: 'app.skin-site.admin.audit.load-more', defaultMessage: 'Load more' },
retention: {
id: 'app.skin-site.admin.audit.retention',
defaultMessage: 'Retention (days)',
},
recordLimit: { id: 'app.skin-site.admin.audit.record-limit', defaultMessage: 'Record limit' },
saveRetention: {
id: 'app.skin-site.admin.audit.save-retention',
defaultMessage: 'Save',
},
retentionSaved: {
id: 'app.skin-site.admin.audit.retention-saved',
defaultMessage: 'Retention updated.',
},
})
const items = ref<SkinSiteAccessAuditEntry[]>([])
const nextCursor = ref<number | null>(null)
const retentionDays = ref(0)
const recordLimit = ref(0)
const loading = ref(true)
const loadingMore = ref(false)
const failed = ref(false)
const subjectFilter = ref('')
const ipFilter = ref('')
const actionFilter = ref('')
const retentionDraft = ref(0)
const savingRetention = ref(false)
function formatDateTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
async function load(reset = true) {
if (reset) {
loading.value = true
items.value = []
nextCursor.value = null
} else {
loadingMore.value = true
}
failed.value = false
try {
const result = await skinSiteAccessAudit({
before: reset ? undefined : (nextCursor.value ?? undefined),
subject: subjectFilter.value.trim() || undefined,
ip: ipFilter.value.trim() || undefined,
action: actionFilter.value.trim() || undefined,
})
if (!result.success) throw new Error(result.errorMessage)
const page = result.payload
items.value = reset ? page.items : [...items.value, ...page.items]
nextCursor.value = page.nextCursor
retentionDays.value = page.retentionDays
recordLimit.value = page.recordLimit
retentionDraft.value = page.retentionDays
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
loadingMore.value = false
}
}
function clearFilters() {
subjectFilter.value = ''
ipFilter.value = ''
actionFilter.value = ''
void load(true)
}
async function saveRetention() {
if (savingRetention.value) return
savingRetention.value = true
try {
const result = await skinSiteUpdateAccessAuditSettings(Number(retentionDraft.value) || 0)
if (!result.success) throw new Error(result.errorMessage)
retentionDays.value = result.payload.retentionDays
retentionDraft.value = result.payload.retentionDays
addNotification({ type: 'success', title: formatMessage(messages.retentionSaved) })
} catch (error) {
handleError(error)
} finally {
savingRetention.value = false
}
}
onMounted(() => void load(true))
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<span class="text-xs text-secondary">
{{ formatMessage(messages.recordLimit) }}: {{ recordLimit }}
</span>
</div>
<div class="flex flex-wrap items-end gap-2">
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchSubject) }}</span>
<input
v-model="subjectFilter"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="load(true)"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchIp) }}</span>
<input
v-model="ipFilter"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="load(true)"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchAction) }}</span>
<input
v-model="actionFilter"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="load(true)"
/>
</label>
<button
type="button"
class="rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="load(true)"
>
{{ formatMessage(messages.search) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="clearFilters"
>
{{ formatMessage(messages.clear) }}
</button>
</div>
<div class="flex flex-wrap items-end gap-2 rounded-xl bg-bg-raised p-3">
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.retention) }}</span>
<input
v-model.number="retentionDraft"
type="number"
min="1"
class="w-24 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="savingRetention"
@click="saveRetention"
>
{{ formatMessage(messages.saveRetention) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.retention) }}: {{ retentionDays }}
</span>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!items.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="entry in items"
:key="entry.id"
class="flex flex-col gap-1.5 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="shrink-0 text-xs text-secondary">#{{ entry.id }}</span>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ entry.subject }}
</span>
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ entry.action }}
</span>
<span class="shrink-0 text-xs text-secondary">×{{ entry.count }}</span>
<span
v-if="entry.proxy"
class="shrink-0 rounded-full bg-orange px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.proxy) }}
</span>
<span
v-if="entry.internalServer"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ formatMessage(messages.internal) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
<span>{{ formatMessage(messages.sourceIp) }}: {{ entry.sourceIp }}</span>
<span v-if="entry.reportedPlayerIp">
{{ formatMessage(messages.reportedIp) }}: {{ entry.reportedPlayerIp }}
</span>
<span>{{ formatMessage(messages.firstAt) }}: {{ formatDateTime(entry.firstAt) }}</span>
<span>{{ formatMessage(messages.lastAt) }}: {{ formatDateTime(entry.lastAt) }}</span>
</div>
</div>
<button
v-if="nextCursor !== null"
type="button"
class="w-fit self-center rounded-full bg-button-bg px-4 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="loadingMore"
@click="load(false)"
>
{{ formatMessage(messages.loadMore) }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,366 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteBroadcast,
type SkinSiteBroadcastInput,
skinSiteBroadcasts,
skinSiteCreateBroadcast,
skinSiteDeleteBroadcast,
skinSiteUpdateBroadcast,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.broadcast.title', defaultMessage: 'Announcements' },
loading: { id: 'app.skin-site.admin.broadcast.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.broadcast.failed', defaultMessage: 'Could not load.' },
empty: { id: 'app.skin-site.admin.broadcast.empty', defaultMessage: 'No announcements yet.' },
create: { id: 'app.skin-site.admin.broadcast.create', defaultMessage: 'New announcement' },
save: { id: 'app.skin-site.admin.broadcast.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.broadcast.cancel', defaultMessage: 'Cancel' },
edit: { id: 'app.skin-site.admin.broadcast.edit', defaultMessage: 'Edit' },
remove: { id: 'app.skin-site.admin.broadcast.remove', defaultMessage: 'Delete' },
titleField: { id: 'app.skin-site.admin.broadcast.title-field', defaultMessage: 'Title' },
subtitleField: { id: 'app.skin-site.admin.broadcast.subtitle-field', defaultMessage: 'Subtitle' },
contentField: { id: 'app.skin-site.admin.broadcast.content-field', defaultMessage: 'Content' },
expireAt: { id: 'app.skin-site.admin.broadcast.expire-at', defaultMessage: 'Expires at' },
star: { id: 'app.skin-site.admin.broadcast.star', defaultMessage: 'Highlight' },
level: { id: 'app.skin-site.admin.broadcast.level', defaultMessage: 'Importance' },
image: { id: 'app.skin-site.admin.broadcast.image', defaultMessage: 'Image' },
removeImage: { id: 'app.skin-site.admin.broadcast.remove-image', defaultMessage: 'Remove image' },
created: { id: 'app.skin-site.admin.broadcast.created', defaultMessage: 'Announcement created.' },
updated: { id: 'app.skin-site.admin.broadcast.updated', defaultMessage: 'Announcement updated.' },
deleted: { id: 'app.skin-site.admin.broadcast.deleted', defaultMessage: 'Announcement deleted.' },
confirmDelete: {
id: 'app.skin-site.admin.broadcast.confirm-delete',
defaultMessage: 'Delete “{title}”? This cannot be undone.',
},
uploader: { id: 'app.skin-site.admin.broadcast.uploader', defaultMessage: 'by {name}' },
})
const items = ref<SkinSiteBroadcast[]>([])
const loading = ref(true)
const failed = ref(false)
const saving = ref(false)
const deletingId = ref<number | null>(null)
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const form = ref({
title: '',
subtitle: '',
content: '',
expireAt: '',
star: false,
importantLevel: 0,
})
const imageFile = ref<File | null>(null)
const removeImage = ref(false)
const sorted = computed(() => [...items.value].sort((a, b) => b.id - a.id))
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
function toDateInput(ts: number): string {
try {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return (
d.getFullYear() +
'-' +
pad(d.getMonth() + 1) +
'-' +
pad(d.getDate()) +
'T' +
pad(d.getHours()) +
':' +
pad(d.getMinutes())
)
} catch {
return ''
}
}
function resetForm() {
form.value = {
title: '',
subtitle: '',
content: '',
expireAt: '',
star: false,
importantLevel: 0,
}
imageFile.value = null
removeImage.value = false
editingId.value = null
}
function openCreate() {
resetForm()
editorOpen.value = true
}
function openEdit(item: SkinSiteBroadcast) {
form.value = {
title: item.title,
subtitle: item.subtitle,
content: item.content,
expireAt: toDateInput(item.expireAt),
star: item.star,
importantLevel: item.importantLevel,
}
imageFile.value = null
removeImage.value = false
editingId.value = item.id
editorOpen.value = true
}
function closeEditor() {
editorOpen.value = false
resetForm()
}
function onImageChange(event: Event) {
const input = event.target as HTMLInputElement
imageFile.value = input.files?.[0] ?? null
}
function buildInput(): SkinSiteBroadcastInput {
const ts = form.value.expireAt ? new Date(form.value.expireAt).getTime() : 0
return {
title: form.value.title.trim(),
subtitle: form.value.subtitle.trim(),
content: form.value.content,
expireAt: Number.isFinite(ts) ? ts : 0,
star: form.value.star,
importantLevel: Number(form.value.importantLevel) || 0,
imageFile: imageFile.value,
removeImage: removeImage.value,
}
}
const canSave = computed(
() => form.value.title.trim().length > 0 && form.value.content.length > 0 && !saving.value,
)
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteBroadcasts()
if (!result.success) throw new Error(result.errorMessage)
items.value = result.payload ?? []
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function save() {
if (!canSave.value) return
saving.value = true
try {
const input = buildInput()
const result =
editingId.value === null
? await skinSiteCreateBroadcast(input)
: await skinSiteUpdateBroadcast(editingId.value, input)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(editingId.value === null ? messages.created : messages.updated),
})
closeEditor()
await load()
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function remove(item: SkinSiteBroadcast) {
if (deletingId.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDelete, { title: item.title }))) return
deletingId.value = item.id
try {
const result = await skinSiteDeleteBroadcast(item.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await load()
} catch (error) {
handleError(error)
} finally {
deletingId.value = null
}
}
onMounted(() => void load())
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<button
type="button"
class="ml-auto rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="openCreate"
>
{{ formatMessage(messages.create) }}
</button>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!sorted.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="item in sorted"
:key="item.id"
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
>
<span class="shrink-0 text-xs text-secondary">#{{ item.id }}</span>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-contrast">{{ item.title }}</span>
<span v-if="item.subtitle" class="truncate text-xs text-secondary">
{{ item.subtitle }}
</span>
</div>
<span class="shrink-0 text-xs text-secondary">{{ formatDate(item.updateAt) }}</span>
<span v-if="item.uploader" class="shrink-0 text-xs text-secondary">
{{ formatMessage(messages.uploader, { name: item.uploader }) }}
</span>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openEdit(item)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="shrink-0 rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingId !== null"
@click="remove(item)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
<div
v-if="editorOpen"
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.titleField) }}
</span>
<input
v-model="form.title"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.subtitleField) }}
</span>
<input
v-model="form.subtitle"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.contentField) }}
</span>
<textarea
v-model="form.content"
rows="5"
class="w-full resize-y rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<div class="flex flex-wrap items-end gap-3">
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.expireAt) }}
</span>
<input
v-model="form.expireAt"
type="datetime-local"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.level) }}
</span>
<input
v-model.number="form.importantLevel"
type="number"
min="0"
max="9"
class="w-20 rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex items-center gap-2 pb-2">
<input v-model="form.star" type="checkbox" />
<span class="text-sm text-contrast">{{ formatMessage(messages.star) }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.image) }}
</span>
<input type="file" accept="image/*" @change="onImageChange" />
</label>
<label v-if="editingId !== null" class="flex items-center gap-2 pb-2">
<input v-model="removeImage" type="checkbox" />
<span class="text-sm text-contrast">{{ formatMessage(messages.removeImage) }}</span>
</label>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="closeEditor"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSave"
@click="save"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</div>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,366 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteAdminDonations,
skinSiteCreateDonation,
skinSiteDeleteDonation,
type SkinSiteDonationAdminEntry,
type SkinSiteDonationSummary,
skinSiteUpdateDonation,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.donations.title', defaultMessage: 'Donations' },
loading: { id: 'app.skin-site.admin.donations.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.admin.donations.failed',
defaultMessage: 'Could not load donations.',
},
empty: { id: 'app.skin-site.admin.donations.empty', defaultMessage: 'No donations yet.' },
totalAmount: { id: 'app.skin-site.admin.donations.total-amount', defaultMessage: 'Total' },
donorCount: { id: 'app.skin-site.admin.donations.donor-count', defaultMessage: 'Donors' },
donor: { id: 'app.skin-site.admin.donations.donor', defaultMessage: 'Donor' },
amount: { id: 'app.skin-site.admin.donations.amount', defaultMessage: 'Amount' },
updatedAt: { id: 'app.skin-site.admin.donations.updated-at', defaultMessage: 'Updated' },
external: { id: 'app.skin-site.admin.donations.external', defaultMessage: 'External' },
linked: { id: 'app.skin-site.admin.donations.linked', defaultMessage: 'Linked' },
create: { id: 'app.skin-site.admin.donations.create', defaultMessage: 'Add donation' },
save: { id: 'app.skin-site.admin.donations.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.donations.cancel', defaultMessage: 'Cancel' },
edit: { id: 'app.skin-site.admin.donations.edit', defaultMessage: 'Edit' },
remove: { id: 'app.skin-site.admin.donations.remove', defaultMessage: 'Delete' },
amountField: { id: 'app.skin-site.admin.donations.amount-field', defaultMessage: 'Amount' },
displayName: {
id: 'app.skin-site.admin.donations.display-name',
defaultMessage: 'Display name',
},
userUuid: { id: 'app.skin-site.admin.donations.user-uuid', defaultMessage: 'User UUID' },
qq: { id: 'app.skin-site.admin.donations.qq', defaultMessage: 'QQ' },
appendMode: {
id: 'app.skin-site.admin.donations.append-mode',
defaultMessage: 'Append to existing amount',
},
created: { id: 'app.skin-site.admin.donations.created', defaultMessage: 'Donation created.' },
updated: { id: 'app.skin-site.admin.donations.updated', defaultMessage: 'Donation updated.' },
deleted: { id: 'app.skin-site.admin.donations.deleted', defaultMessage: 'Donation deleted.' },
confirmDelete: {
id: 'app.skin-site.admin.donations.confirm-delete',
defaultMessage: 'Delete the donation from “{name}”? This cannot be undone.',
},
loadMore: { id: 'app.skin-site.admin.donations.load-more', defaultMessage: 'Load more' },
})
const items = ref<SkinSiteDonationAdminEntry[]>([])
const summary = ref<SkinSiteDonationSummary | null>(null)
const hasMore = ref(false)
const loading = ref(true)
const loadingMore = ref(false)
const failed = ref(false)
const offset = ref(0)
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const editingVersion = ref(0)
const saving = ref(false)
const deletingId = ref<number | null>(null)
const form = ref({
amount: '',
displayName: '',
userUuid: '',
qq: '',
appendMode: false,
})
const canSave = computed(() => form.value.amount.trim().length > 0 && !saving.value)
function formatDateTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
function resetForm() {
form.value = { amount: '', displayName: '', userUuid: '', qq: '', appendMode: false }
editingId.value = null
editingVersion.value = 0
}
function openCreate() {
resetForm()
editorOpen.value = true
}
function openEdit(entry: SkinSiteDonationAdminEntry) {
form.value = {
amount: entry.donor.amount,
displayName: entry.donor.username,
userUuid: entry.userUuid ?? '',
qq: entry.qq ?? '',
appendMode: false,
}
editingId.value = entry.donor.id
editingVersion.value = entry.version
editorOpen.value = true
}
function closeEditor() {
editorOpen.value = false
resetForm()
}
async function load(reset = true) {
if (reset) {
loading.value = true
items.value = []
offset.value = 0
} else {
loadingMore.value = true
}
failed.value = false
try {
const result = await skinSiteAdminDonations(offset.value)
if (!result.success) throw new Error(result.errorMessage)
const page = result.payload
items.value = reset ? page.items : [...items.value, ...page.items]
summary.value = page.summary
hasMore.value = page.hasMore
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
loadingMore.value = false
}
}
async function loadMore() {
if (!hasMore.value) return
offset.value += 20
await load(false)
}
async function save() {
if (!canSave.value) return
saving.value = true
try {
if (editingId.value === null) {
const payload: Parameters<typeof skinSiteCreateDonation>[0] = {
amount: form.value.amount.trim(),
}
if (form.value.userUuid.trim()) payload.userUuid = form.value.userUuid.trim()
if (form.value.qq.trim()) payload.qq = form.value.qq.trim()
if (form.value.displayName.trim()) payload.displayName = form.value.displayName.trim()
const result = await skinSiteCreateDonation(payload)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.created) })
} else {
const result = await skinSiteUpdateDonation(editingId.value, {
amount: form.value.amount.trim(),
version: editingVersion.value,
displayName: form.value.displayName.trim() || undefined,
mode: form.value.appendMode ? 'APPEND' : 'REPLACE',
})
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.updated) })
}
closeEditor()
await load(true)
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function remove(entry: SkinSiteDonationAdminEntry) {
if (deletingId.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDelete, { name: entry.donor.username }))) return
deletingId.value = entry.donor.id
try {
const result = await skinSiteDeleteDonation(entry.donor.id, entry.version)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await load(true)
} catch (error) {
handleError(error)
} finally {
deletingId.value = null
}
}
onMounted(() => void load(true))
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<button
type="button"
class="ml-auto rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="openCreate"
>
{{ formatMessage(messages.create) }}
</button>
</div>
<div v-if="summary" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.totalAmount) }}</span>
<span class="text-xl font-bold text-contrast">{{ summary.totalAmount }}</span>
</div>
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.donorCount) }}</span>
<span class="text-xl font-bold text-contrast">{{ summary.donorCount }}</span>
</div>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!items.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="entry in items"
:key="entry.donor.id"
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
>
<span class="shrink-0 text-xs text-secondary">#{{ entry.donor.rank }}</span>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-contrast">
{{ entry.donor.username }}
</span>
<span class="truncate text-xs text-secondary">
{{ formatMessage(messages.updatedAt) }}:
{{ formatDateTime(entry.updatedAt) }}
</span>
</div>
<span class="shrink-0 text-sm font-bold text-contrast">{{ entry.donor.amount }}</span>
<span
v-if="entry.external"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ formatMessage(messages.external) }}
</span>
<span
v-else
class="shrink-0 rounded-full bg-green px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.linked) }}
</span>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openEdit(entry)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="shrink-0 rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingId !== null"
@click="remove(entry)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
<button
v-if="hasMore"
type="button"
class="w-fit self-center rounded-full bg-button-bg px-4 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="loadingMore"
@click="loadMore"
>
{{ formatMessage(messages.loadMore) }}
</button>
</div>
<div
v-if="editorOpen"
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.amountField) }}
</span>
<input
v-model="form.amount"
type="text"
inputmode="decimal"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.displayName) }}
</span>
<input
v-model="form.displayName"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<template v-if="editingId === null">
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.userUuid) }}
</span>
<input
v-model="form.userUuid"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.qq) }}
</span>
<input
v-model="form.qq"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
</template>
<label v-else class="flex items-center gap-2">
<input v-model="form.appendMode" type="checkbox" />
<span class="text-sm text-contrast">{{ formatMessage(messages.appendMode) }}</span>
</label>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="closeEditor"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSave"
@click="save"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,576 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteAddDownloadCategory,
skinSiteAddDownloadItem,
skinSiteBindDownloadCategory,
skinSiteDeleteDownloadCategory,
skinSiteDeleteDownloadItem,
skinSiteDownloadCategories,
type SkinSiteDownloadCategory,
type SkinSiteDownloadItem,
skinSiteDownloadItemCount,
skinSiteDownloadItems,
type SkinSiteDownloadRoute,
type SkinSiteDownloadStatus,
skinSiteUnbindDownloadCategory,
skinSiteUpdateDownloadItem,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.downloads.title', defaultMessage: 'Downloads' },
loading: { id: 'app.skin-site.admin.downloads.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.admin.downloads.failed',
defaultMessage: 'Could not load downloads.',
},
empty: { id: 'app.skin-site.admin.downloads.empty', defaultMessage: 'No download items.' },
categories: { id: 'app.skin-site.admin.downloads.categories', defaultMessage: 'Categories' },
all: { id: 'app.skin-site.admin.downloads.all', defaultMessage: 'All items' },
addCategory: { id: 'app.skin-site.admin.downloads.add-category', defaultMessage: 'Add' },
categoryName: {
id: 'app.skin-site.admin.downloads.category-name',
defaultMessage: 'Category name',
},
removeCategory: {
id: 'app.skin-site.admin.downloads.remove-category',
defaultMessage: 'Remove',
},
total: { id: 'app.skin-site.admin.downloads.total', defaultMessage: '{count} items' },
addItem: { id: 'app.skin-site.admin.downloads.add-item', defaultMessage: 'New item' },
save: { id: 'app.skin-site.admin.downloads.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.downloads.cancel', defaultMessage: 'Cancel' },
edit: { id: 'app.skin-site.admin.downloads.edit', defaultMessage: 'Edit' },
remove: { id: 'app.skin-site.admin.downloads.remove', defaultMessage: 'Delete' },
filename: { id: 'app.skin-site.admin.downloads.filename', defaultMessage: 'File name' },
description: {
id: 'app.skin-site.admin.downloads.description',
defaultMessage: 'Description',
},
routes: { id: 'app.skin-site.admin.downloads.routes', defaultMessage: 'Download routes' },
routeName: { id: 'app.skin-site.admin.downloads.route-name', defaultMessage: 'Name' },
routeUrl: { id: 'app.skin-site.admin.downloads.route-url', defaultMessage: 'URL' },
addRoute: { id: 'app.skin-site.admin.downloads.add-route', defaultMessage: 'Add route' },
removeRoute: { id: 'app.skin-site.admin.downloads.remove-route', defaultMessage: 'Remove' },
statuses: { id: 'app.skin-site.admin.downloads.statuses', defaultMessage: 'Statuses' },
bindCategory: {
id: 'app.skin-site.admin.downloads.bind-category',
defaultMessage: 'Categories',
},
uploader: { id: 'app.skin-site.admin.downloads.uploader', defaultMessage: 'by {name}' },
created: { id: 'app.skin-site.admin.downloads.created', defaultMessage: 'Item created.' },
updated: { id: 'app.skin-site.admin.downloads.updated', defaultMessage: 'Item updated.' },
deleted: { id: 'app.skin-site.admin.downloads.deleted', defaultMessage: 'Item deleted.' },
categoryAdded: {
id: 'app.skin-site.admin.downloads.category-added',
defaultMessage: 'Category added.',
},
categoryDeleted: {
id: 'app.skin-site.admin.downloads.category-deleted',
defaultMessage: 'Category deleted.',
},
confirmDeleteItem: {
id: 'app.skin-site.admin.downloads.confirm-delete-item',
defaultMessage: 'Delete “{name}”? This cannot be undone.',
},
confirmDeleteCategory: {
id: 'app.skin-site.admin.downloads.confirm-delete-category',
defaultMessage: 'Delete category “{name}”?',
},
})
const STATUS_OPTIONS: SkinSiteDownloadStatus[] = ['OK', 'NEW', 'LEGACY', 'OUTDATED', 'REMOVAL']
const items = ref<SkinSiteDownloadItem[]>([])
const categories = ref<SkinSiteDownloadCategory[]>([])
const total = ref(0)
const loading = ref(true)
const failed = ref(false)
const activeCategory = ref<string>('')
const newCategoryName = ref('')
const categoryWorking = ref(false)
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
const deletingId = ref<number | null>(null)
const form = ref({
filename: '',
description: '',
status: [] as SkinSiteDownloadStatus[],
routes: [{ name: '', url: '' }] as Array<{ name: string; url: string }>,
selectedCategories: [] as number[],
})
const canSave = computed(
() =>
form.value.filename.trim().length > 0 &&
form.value.routes.some((r) => r.url.trim().length > 0) &&
!saving.value,
)
function resetForm() {
form.value = {
filename: '',
description: '',
status: [],
routes: [{ name: '', url: '' }],
selectedCategories: [],
}
editingId.value = null
}
function openCreate() {
resetForm()
editorOpen.value = true
}
function openEdit(item: SkinSiteDownloadItem) {
form.value = {
filename: item.filename,
description: item.description,
status: [...item.status],
routes: item.routes.length
? item.routes.map((r) => ({ name: r.name ?? '', url: r.url }))
: [{ name: '', url: '' }],
selectedCategories: categories.value
.filter((c) => item.category.includes(c.name))
.map((c) => c.id),
}
editingId.value = item.id
editorOpen.value = true
}
function closeEditor() {
editorOpen.value = false
resetForm()
}
function addRouteRow() {
form.value.routes.push({ name: '', url: '' })
}
function removeRouteRow(index: number) {
if (form.value.routes.length <= 1) return
form.value.routes.splice(index, 1)
}
function toggleStatus(status: SkinSiteDownloadStatus) {
const i = form.value.status.indexOf(status)
if (i >= 0) form.value.status.splice(i, 1)
else form.value.status.push(status)
}
async function loadCategories() {
try {
const result = await skinSiteDownloadCategories()
if (result.success) categories.value = result.payload ?? []
} catch (error) {
handleError(error)
}
}
async function loadItems() {
loading.value = true
failed.value = false
try {
const listResult = await skinSiteDownloadItems({
limit: 200,
offset: 0,
category: activeCategory.value || undefined,
})
if (!listResult.success) throw new Error(listResult.errorMessage)
items.value = listResult.payload ?? []
const countResult = await skinSiteDownloadItemCount(activeCategory.value || undefined)
if (countResult.success && typeof countResult.payload === 'number') {
total.value = countResult.payload
}
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function selectCategory(name: string) {
activeCategory.value = name
await loadItems()
}
async function addCategory() {
const name = newCategoryName.value.trim()
if (!name || categoryWorking.value) return
categoryWorking.value = true
try {
const result = await skinSiteAddDownloadCategory(name)
if (!result.success) throw new Error(result.errorMessage)
newCategoryName.value = ''
addNotification({ type: 'success', title: formatMessage(messages.categoryAdded) })
await loadCategories()
} catch (error) {
handleError(error)
} finally {
categoryWorking.value = false
}
}
async function removeCategory(category: SkinSiteDownloadCategory) {
if (categoryWorking.value) return
if (!window.confirm(formatMessage(messages.confirmDeleteCategory, { name: category.name })))
return
categoryWorking.value = true
try {
const result = await skinSiteDeleteDownloadCategory(category.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.categoryDeleted) })
if (activeCategory.value === category.name) activeCategory.value = ''
await loadCategories()
await loadItems()
} catch (error) {
handleError(error)
} finally {
categoryWorking.value = false
}
}
async function save() {
if (!canSave.value) return
saving.value = true
try {
const routes: SkinSiteDownloadRoute[] = form.value.routes
.filter((r) => r.url.trim())
.map((r) => ({ name: r.name.trim() || null, url: r.url.trim() }))
const payload = {
routes,
filename: form.value.filename.trim(),
description: form.value.description,
status: form.value.status,
}
const result =
editingId.value === null
? await skinSiteAddDownloadItem(payload)
: await skinSiteUpdateDownloadItem(editingId.value, payload)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(editingId.value === null ? messages.created : messages.updated),
})
closeEditor()
await loadItems()
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function remove(item: SkinSiteDownloadItem) {
if (deletingId.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDeleteItem, { name: item.filename }))) return
deletingId.value = item.id
try {
const result = await skinSiteDeleteDownloadItem(item.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await loadItems()
} catch (error) {
handleError(error)
} finally {
deletingId.value = null
}
}
async function toggleItemCategory(item: SkinSiteDownloadItem, category: SkinSiteDownloadCategory) {
try {
const bound = item.category.includes(category.name)
const result = bound
? await skinSiteUnbindDownloadCategory(item.id, category.id)
: await skinSiteBindDownloadCategory(item.id, category.id)
if (!result.success) throw new Error(result.errorMessage)
await loadItems()
} catch (error) {
handleError(error)
}
}
onMounted(async () => {
await loadCategories()
await loadItems()
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<span class="text-xs text-secondary">
{{ formatMessage(messages.total, { count: total }) }}
</span>
<button
type="button"
class="ml-auto rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="openCreate"
>
{{ formatMessage(messages.addItem) }}
</button>
</div>
<div class="flex min-h-0 gap-4">
<aside class="flex w-56 shrink-0 flex-col gap-2">
<span class="text-xs font-bold tracking-wide text-secondary uppercase">
{{ formatMessage(messages.categories) }}
</span>
<button
type="button"
class="rounded-lg px-2 py-1.5 text-left text-xs font-semibold"
:class="
activeCategory === ''
? 'bg-brand text-contrast'
: 'text-secondary hover:bg-button-bg hover:text-contrast'
"
@click="selectCategory('')"
>
{{ formatMessage(messages.all) }}
</button>
<div v-for="category in categories" :key="category.id" class="flex items-center gap-1">
<button
type="button"
class="min-w-0 flex-1 truncate rounded-lg px-2 py-1.5 text-left text-xs font-semibold"
:class="
activeCategory === category.name
? 'bg-brand text-contrast'
: 'text-secondary hover:bg-button-bg hover:text-contrast'
"
@click="selectCategory(category.name)"
>
{{ category.name }}
</button>
<button
type="button"
class="shrink-0 rounded px-1 text-[10px] text-secondary hover:text-red"
:disabled="categoryWorking"
@click="removeCategory(category)"
>
×
</button>
</div>
<div class="flex items-center gap-1 pt-1">
<input
v-model="newCategoryName"
type="text"
:placeholder="formatMessage(messages.categoryName)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="addCategory"
/>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-2 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="categoryWorking || !newCategoryName.trim()"
@click="addCategory"
>
{{ formatMessage(messages.addCategory) }}
</button>
</div>
</aside>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!items.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="item in items"
:key="item.id"
class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="shrink-0 text-xs text-secondary">#{{ item.id }}</span>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ item.filename }}
</span>
<span
v-for="s in item.status"
:key="s"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ s }}
</span>
<span class="shrink-0 text-xs text-secondary">
{{ formatMessage(messages.uploader, { name: item.uploader }) }}
</span>
</div>
<div v-if="item.description" class="text-xs text-secondary">
{{ item.description }}
</div>
<div class="flex flex-wrap gap-1">
<span
v-for="route in item.routes"
:key="route.url"
class="rounded bg-bg px-2 py-0.5 font-mono text-[10px] text-secondary"
>
{{ route.name ? route.name + ': ' : '' }}{{ route.url }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-secondary">
{{ formatMessage(messages.bindCategory) }}:
</span>
<button
v-for="category in categories"
:key="category.id"
type="button"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="
item.category.includes(category.name)
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:text-contrast'
"
@click="toggleItemCategory(item, category)"
>
{{ category.name }}
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openEdit(item)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingId !== null"
@click="remove(item)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
</div>
</div>
</div>
<div
v-if="editorOpen"
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.filename) }}
</span>
<input
v-model="form.filename"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.description) }}
</span>
<textarea
v-model="form.description"
rows="3"
class="w-full resize-y rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<div class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.statuses) }}
</span>
<div class="flex flex-wrap gap-2">
<button
v-for="status in STATUS_OPTIONS"
:key="status"
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="
form.status.includes(status)
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary'
"
@click="toggleStatus(status)"
>
{{ status }}
</button>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.routes) }}
</span>
<div
v-for="(route, idx) in form.routes"
:key="idx"
class="flex flex-wrap items-center gap-2"
>
<input
v-model="route.name"
type="text"
:placeholder="formatMessage(messages.routeName)"
class="w-32 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<input
v-model="route.url"
type="text"
:placeholder="formatMessage(messages.routeUrl)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-2 py-1 text-[10px] font-semibold text-secondary hover:text-red disabled:opacity-50"
:disabled="form.routes.length <= 1"
@click="removeRouteRow(idx)"
>
{{ formatMessage(messages.removeRoute) }}
</button>
</div>
<button
type="button"
class="w-fit rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="addRouteRow"
>
{{ formatMessage(messages.addRoute) }}
</button>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="closeEditor"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSave"
@click="save"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,323 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteActivateLauncherRelease,
skinSiteDeleteLauncherRelease,
type SkinSiteLauncherAdminState,
skinSiteLauncherAdminState,
type SkinSiteLauncherRelease,
skinSiteUploadLauncherRelease,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.launcher.title', defaultMessage: 'Launcher releases' },
loading: { id: 'app.skin-site.admin.launcher.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.admin.launcher.failed',
defaultMessage: 'Could not load releases.',
},
empty: { id: 'app.skin-site.admin.launcher.empty', defaultMessage: 'No releases uploaded yet.' },
latestUrl: { id: 'app.skin-site.admin.launcher.latest-url', defaultMessage: 'Latest URL' },
version: { id: 'app.skin-site.admin.launcher.version', defaultMessage: 'Version' },
channel: { id: 'app.skin-site.admin.launcher.channel', defaultMessage: 'Channel' },
uploader: { id: 'app.skin-site.admin.launcher.uploader', defaultMessage: 'Uploaded by' },
uploadedAt: { id: 'app.skin-site.admin.launcher.uploaded-at', defaultMessage: 'Uploaded' },
publishedAt: { id: 'app.skin-site.admin.launcher.published-at', defaultMessage: 'Published' },
forceUpdate: { id: 'app.skin-site.admin.launcher.force-update', defaultMessage: 'Forced' },
active: { id: 'app.skin-site.admin.launcher.active', defaultMessage: 'Active' },
activate: { id: 'app.skin-site.admin.launcher.activate', defaultMessage: 'Activate' },
activated: { id: 'app.skin-site.admin.launcher.activated', defaultMessage: 'Release activated.' },
deleteRelease: {
id: 'app.skin-site.admin.launcher.delete',
defaultMessage: 'Delete release',
},
confirmDelete: {
id: 'app.skin-site.admin.launcher.confirm-delete',
defaultMessage:
'Permanently delete launcher {version} and all its installation files from the server? This version will no longer be available for download or rollback. Installed launchers will not be affected.',
},
deleted: {
id: 'app.skin-site.admin.launcher.deleted',
defaultMessage: 'Release and its installation files deleted.',
},
upload: { id: 'app.skin-site.admin.launcher.upload', defaultMessage: 'Upload release' },
uploadHint: {
id: 'app.skin-site.admin.launcher.upload-hint',
defaultMessage: 'Choose a release ZIP (max 4 GiB).',
},
uploading: { id: 'app.skin-site.admin.launcher.uploading', defaultMessage: 'Uploading…' },
uploadDone: {
id: 'app.skin-site.admin.launcher.upload-done',
defaultMessage: 'Release uploaded.',
},
forceFlag: {
id: 'app.skin-site.admin.launcher.force-flag',
defaultMessage: 'Force update',
},
prev: { id: 'app.skin-site.admin.launcher.prev', defaultMessage: 'Previous' },
next: { id: 'app.skin-site.admin.launcher.next', defaultMessage: 'Next' },
page: { id: 'app.skin-site.admin.launcher.page', defaultMessage: 'Page {page}' },
total: { id: 'app.skin-site.admin.launcher.total', defaultMessage: '{count} releases' },
})
const PAGE_SIZE = 10
const state = ref<SkinSiteLauncherAdminState | null>(null)
const loading = ref(true)
const failed = ref(false)
const offset = ref(0)
const uploading = ref(false)
const uploadPercent = ref(0)
const forceUpdate = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const activatingId = ref<number | null>(null)
const deletingId = ref<number | null>(null)
const mutating = computed(
() => uploading.value || activatingId.value !== null || deletingId.value !== null,
)
const releases = computed(() => state.value?.releases ?? [])
const total = computed(() => releases.value.length)
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const hasPrev = computed(() => offset.value > 0)
const hasNext = computed(() => offset.value + PAGE_SIZE < total.value)
const visible = computed(() => releases.value.slice(offset.value, offset.value + PAGE_SIZE))
function formatDateTime(ts?: number | null): string {
if (!ts) return '—'
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteLauncherAdminState()
if (!result.success) throw new Error(result.errorMessage)
state.value = result.payload
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
function prevPage() {
if (hasPrev.value) offset.value = Math.max(0, offset.value - PAGE_SIZE)
}
function nextPage() {
if (hasNext.value) offset.value += PAGE_SIZE
}
async function activate(release: SkinSiteLauncherRelease) {
if (release.active || mutating.value || loading.value) return
activatingId.value = release.id
try {
const result = await skinSiteActivateLauncherRelease(release.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.activated) })
await load()
} catch (error) {
handleError(error)
} finally {
activatingId.value = null
}
}
async function remove(release: SkinSiteLauncherRelease) {
if (release.active || mutating.value || loading.value) return
if (!window.confirm(formatMessage(messages.confirmDelete, { version: release.version }))) return
deletingId.value = release.id
try {
const result = await skinSiteDeleteLauncherRelease(release.id)
if (!result.success) throw new Error(result.errorMessage)
if (state.value)
state.value.releases = state.value.releases.filter((item) => item.id !== release.id)
offset.value = Math.min(offset.value, Math.max(0, (pageCount.value - 1) * PAGE_SIZE))
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
} catch (error) {
handleError(error)
} finally {
deletingId.value = null
}
}
function pickFile() {
fileInput.value?.click()
}
async function onFileChosen(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || mutating.value || loading.value) return
uploading.value = true
uploadPercent.value = 0
try {
const result = await skinSiteUploadLauncherRelease(
file,
forceUpdate.value,
(p) => (uploadPercent.value = Math.round(p)),
)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.uploadDone) })
offset.value = 0
await load()
} catch (error) {
handleError(error)
} finally {
uploading.value = false
uploadPercent.value = 0
}
}
onMounted(() => void load())
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<span class="text-xs text-secondary">
{{ formatMessage(messages.total, { count: total }) }}
</span>
</div>
<div
v-if="state && state.latestUrl"
class="flex flex-wrap items-center gap-2 rounded-xl bg-bg-raised p-3"
>
<span class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.latestUrl) }}
</span>
<code class="min-w-0 flex-1 truncate text-xs text-secondary">{{ state.latestUrl }}</code>
</div>
<div class="flex flex-wrap items-center gap-2">
<input
ref="fileInput"
type="file"
accept=".zip,application/zip"
class="hidden"
@change="onFileChosen"
/>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="mutating || loading"
@click="pickFile"
>
{{ formatMessage(messages.upload) }}
</button>
<label class="flex items-center gap-2 text-xs text-contrast">
<input v-model="forceUpdate" type="checkbox" :disabled="uploading" />
{{ formatMessage(messages.forceFlag) }}
</label>
<span class="text-xs text-secondary">{{ formatMessage(messages.uploadHint) }}</span>
</div>
<div v-if="uploading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.uploading) }} {{ uploadPercent }}%
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!total" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="release in visible"
:key="release.id"
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-contrast">
{{ release.version }}
<span class="text-xs font-normal text-secondary">#{{ release.id }}</span>
</span>
<span class="truncate text-xs text-secondary">
{{ formatMessage(messages.uploader) }}: {{ release.uploadedBy }} ·
{{ formatDateTime(release.uploadedAt) }}
</span>
</div>
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ release.channel }}
</span>
<span
v-if="release.forceUpdate"
class="shrink-0 rounded-full bg-orange px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.forceUpdate) }}
</span>
<span
v-if="release.active"
class="shrink-0 rounded-full bg-green px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.active) }}
</span>
<button
v-if="!release.active"
type="button"
class="shrink-0 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="mutating"
@click="activate(release)"
>
{{ formatMessage(messages.activate) }}
</button>
<button
v-if="!release.active"
type="button"
class="flex shrink-0 cursor-pointer items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-red hover:brightness-110 active:brightness-90 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="mutating"
@click="remove(release)"
>
<SpinnerIcon v-if="deletingId === release.id" class="size-3 animate-spin" />
{{ formatMessage(messages.deleteRelease) }}
</button>
</div>
<div class="flex items-center justify-center gap-3 pt-2">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasPrev"
@click="prevPage"
>
{{ formatMessage(messages.prev) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.page, { page }) }} / {{ pageCount }}
</span>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasNext"
@click="nextPage"
>
{{ formatMessage(messages.next) }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,982 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteActivateModRuntime,
skinSiteCreateModRuleTag,
skinSiteDeleteModFile,
skinSiteDeleteModRuleTag,
skinSiteDeleteModRuntime,
skinSiteDeleteMultiModFile,
skinSiteModCount,
type SkinSiteModInfo,
skinSiteModList,
type SkinSiteModRuleTag,
skinSiteModRuleTags,
type SkinSiteModRuntimeAdminState,
type SkinSiteModRuntimeRelease,
skinSiteModRuntimeReleases,
skinSiteModSearch,
skinSiteUpdateModInfo,
skinSiteUpdateModRuleTag,
skinSiteUpdateMultiModInfo,
skinSiteUploadModRuntime,
} from '@/helpers/skin-site-api'
import ModUploadModal from './ModUploadModal.vue'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.mods.title', defaultMessage: 'Mods' },
tabMods: { id: 'app.skin-site.admin.mods.tab-mods', defaultMessage: 'Mod files' },
tabTags: { id: 'app.skin-site.admin.mods.tab-tags', defaultMessage: 'Rule tags' },
tabRuntime: { id: 'app.skin-site.admin.mods.tab-runtime', defaultMessage: 'Runtime' },
loading: { id: 'app.skin-site.admin.mods.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.mods.failed', defaultMessage: 'Could not load mods.' },
empty: { id: 'app.skin-site.admin.mods.empty', defaultMessage: 'No mods found.' },
total: { id: 'app.skin-site.admin.mods.total', defaultMessage: '{count} mods' },
search: { id: 'app.skin-site.admin.mods.search', defaultMessage: 'Search by name' },
filterAll: { id: 'app.skin-site.admin.mods.filter-all', defaultMessage: 'All tags' },
prev: { id: 'app.skin-site.admin.mods.prev', defaultMessage: 'Previous' },
next: { id: 'app.skin-site.admin.mods.next', defaultMessage: 'Next' },
page: { id: 'app.skin-site.admin.mods.page', defaultMessage: 'Page {page}' },
name: { id: 'app.skin-site.admin.mods.name', defaultMessage: 'Name' },
version: { id: 'app.skin-site.admin.mods.version', defaultMessage: 'Version' },
modId: { id: 'app.skin-site.admin.mods.mod-id', defaultMessage: 'Mod ID' },
size: { id: 'app.skin-site.admin.mods.size', defaultMessage: 'Size' },
loaders: { id: 'app.skin-site.admin.mods.loaders', defaultMessage: 'Loaders' },
domainRules: { id: 'app.skin-site.admin.mods.domain-rules', defaultMessage: 'Domain rules' },
edit: { id: 'app.skin-site.admin.mods.edit', defaultMessage: 'Edit' },
save: { id: 'app.skin-site.admin.mods.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.mods.cancel', defaultMessage: 'Cancel' },
remove: { id: 'app.skin-site.admin.mods.remove', defaultMessage: 'Delete' },
updated: { id: 'app.skin-site.admin.mods.updated', defaultMessage: 'Mod updated.' },
deleted: { id: 'app.skin-site.admin.mods.deleted', defaultMessage: 'Mod deleted.' },
confirmDelete: {
id: 'app.skin-site.admin.mods.confirm-delete',
defaultMessage: 'Delete mod “{name}”? This cannot be undone.',
},
ruleTags: { id: 'app.skin-site.admin.mods.rule-tags', defaultMessage: 'Rule tags' },
tagName: { id: 'app.skin-site.admin.mods.tag-name', defaultMessage: 'Tag name' },
tagRules: { id: 'app.skin-site.admin.mods.tag-rules', defaultMessage: 'Rules' },
tagRulesHint: {
id: 'app.skin-site.admin.mods.tag-rules-hint',
defaultMessage: 'One rule per line.',
},
newTag: { id: 'app.skin-site.admin.mods.new-tag', defaultMessage: 'New tag' },
tagCreated: { id: 'app.skin-site.admin.mods.tag-created', defaultMessage: 'Tag created.' },
tagUpdated: { id: 'app.skin-site.admin.mods.tag-updated', defaultMessage: 'Tag updated.' },
tagDeleted: { id: 'app.skin-site.admin.mods.tag-deleted', defaultMessage: 'Tag deleted.' },
confirmDeleteTag: {
id: 'app.skin-site.admin.mods.confirm-delete-tag',
defaultMessage: 'Delete tag “{name}”?',
},
runtime: { id: 'app.skin-site.admin.mods.runtime', defaultMessage: 'Runtime releases' },
manifestUrl: { id: 'app.skin-site.admin.mods.manifest-url', defaultMessage: 'Manifest URL' },
uploadRuntime: {
id: 'app.skin-site.admin.mods.upload-runtime',
defaultMessage: 'Upload runtime',
},
runtimeVersion: {
id: 'app.skin-site.admin.mods.runtime-version',
defaultMessage: 'Version',
},
uploading: { id: 'app.skin-site.admin.mods.uploading', defaultMessage: 'Uploading…' },
runtimeUploaded: {
id: 'app.skin-site.admin.mods.runtime-uploaded',
defaultMessage: 'Runtime uploaded.',
},
activate: { id: 'app.skin-site.admin.mods.activate', defaultMessage: 'Activate' },
activated: { id: 'app.skin-site.admin.mods.activated', defaultMessage: 'Runtime activated.' },
available: { id: 'app.skin-site.admin.mods.available', defaultMessage: 'Available' },
unavailable: { id: 'app.skin-site.admin.mods.unavailable', defaultMessage: 'Unavailable' },
active: { id: 'app.skin-site.admin.mods.active', defaultMessage: 'Active' },
uploader: { id: 'app.skin-site.admin.mods.uploader', defaultMessage: 'by {name}' },
upload: { id: 'app.skin-site.admin.mods.upload', defaultMessage: 'Upload' },
selectMode: {
id: 'app.skin-site.admin.mods.select-mode',
defaultMessage: 'Select multiple',
},
exitSelect: {
id: 'app.skin-site.admin.mods.exit-select',
defaultMessage: 'Done selecting',
},
selectAll: { id: 'app.skin-site.admin.mods.select-all', defaultMessage: 'Select all' },
clearSelection: {
id: 'app.skin-site.admin.mods.clear-selection',
defaultMessage: 'Clear selection',
},
selectedCount: {
id: 'app.skin-site.admin.mods.selected-count',
defaultMessage: '{count} selected',
},
deleteSelected: {
id: 'app.skin-site.admin.mods.delete-selected',
defaultMessage: 'Delete selected',
},
confirmDeleteMulti: {
id: 'app.skin-site.admin.mods.confirm-delete-multi',
defaultMessage: 'Delete {count} selected mods? This cannot be undone.',
},
deletedMulti: {
id: 'app.skin-site.admin.mods.deleted-multi',
defaultMessage: 'Selected mods deleted.',
},
serverAddress: {
id: 'app.skin-site.admin.mods.server-address',
defaultMessage: 'Server address',
},
serverAddressHint: {
id: 'app.skin-site.admin.mods.server-address-hint',
defaultMessage: 'Filter by server address',
},
apply: { id: 'app.skin-site.admin.mods.apply', defaultMessage: 'Apply' },
clear: { id: 'app.skin-site.admin.mods.clear', defaultMessage: 'Clear' },
})
type Tab = 'mods' | 'tags' | 'runtime'
const tab = ref<Tab>('mods')
const PAGE_SIZE = 20
const mods = ref<SkinSiteModInfo[]>([])
const modTotal = ref(0)
const modOffset = ref(0)
const modLoading = ref(true)
const modFailed = ref(false)
const searchKeyword = ref('')
const tagFilter = ref<number | null>(null)
const editingHash = ref<string | null>(null)
const editForm = ref({ name: '', modId: '', version: '', domainRules: '' })
const savingMod = ref(false)
const deletingHash = ref<string | null>(null)
const selectedHashes = ref<Set<string>>(new Set())
const selectionMode = ref(false)
const deletingMulti = ref(false)
const serverAddressInput = ref('')
const serverAddressFilter = ref('')
const uploadOpen = ref(false)
const tags = ref<SkinSiteModRuleTag[]>([])
const tagsLoading = ref(false)
const tagEditorOpen = ref(false)
const editingTagId = ref<number | null>(null)
const tagForm = ref({ name: '', rules: '' })
const savingTag = ref(false)
const runtimeState = ref<SkinSiteModRuntimeAdminState | null>(null)
const runtimeLoading = ref(false)
const runtimeVersion = ref('')
const runtimeFile = ref<HTMLInputElement | null>(null)
const runtimeUploading = ref(false)
const activatingRuntimeId = ref<number | null>(null)
const deletingRuntimeId = ref<number | null>(null)
const modPage = computed(() => Math.floor(modOffset.value / PAGE_SIZE) + 1)
const modPageCount = computed(() => Math.max(1, Math.ceil(modTotal.value / PAGE_SIZE)))
const hasPrev = computed(() => modOffset.value > 0)
const hasNext = computed(() => modOffset.value + PAGE_SIZE < modTotal.value)
function formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return v.toFixed(i === 0 ? 0 : 2) + ' ' + units[i]
}
function formatDateTime(ts?: number | null): string {
if (!ts) return '—'
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
async function loadMods(reset = true) {
if (reset) {
modLoading.value = true
mods.value = []
modOffset.value = 0
}
modFailed.value = false
try {
const kw = searchKeyword.value.trim()
if (kw) {
const result = await skinSiteModSearch(kw, tagFilter.value)
if (!result.success) throw new Error(result.errorMessage)
mods.value = result.payload ?? []
modTotal.value = mods.value.length
} else {
const [listResult, countResult] = await Promise.all([
skinSiteModList({
limit: PAGE_SIZE,
offset: modOffset.value,
ruleTagId: tagFilter.value,
serverAddr: serverAddressFilter.value || undefined,
}),
skinSiteModCount({
ruleTagId: tagFilter.value,
serverAddr: serverAddressFilter.value || undefined,
}),
])
if (!listResult.success) throw new Error(listResult.errorMessage)
mods.value = listResult.payload ?? []
if (countResult.success && typeof countResult.payload === 'number') {
modTotal.value = countResult.payload
}
}
} catch (error) {
modFailed.value = true
handleError(error)
} finally {
modLoading.value = false
}
}
function prevPage() {
if (hasPrev.value) {
modOffset.value = Math.max(0, modOffset.value - PAGE_SIZE)
void loadMods(false)
}
}
function nextPage() {
if (hasNext.value) {
modOffset.value += PAGE_SIZE
void loadMods(false)
}
}
async function loadTags() {
tagsLoading.value = true
try {
const result = await skinSiteModRuleTags()
if (result.success) tags.value = result.payload ?? []
} catch (error) {
handleError(error)
} finally {
tagsLoading.value = false
}
}
async function loadRuntime() {
runtimeLoading.value = true
try {
const result = await skinSiteModRuntimeReleases()
if (result.success) runtimeState.value = result.payload
} catch (error) {
handleError(error)
} finally {
runtimeLoading.value = false
}
}
async function switchTab(next: Tab) {
tab.value = next
if (next === 'tags' && !tags.value.length) await loadTags()
if (next === 'runtime' && !runtimeState.value) await loadRuntime()
}
function startEdit(mod: SkinSiteModInfo) {
editingHash.value = mod.hash
editForm.value = {
name: mod.metadata.name ?? mod.name,
modId: mod.metadata.modId ?? '',
version: mod.metadata.version ?? '',
domainRules: mod.domainRules.join(', '),
}
}
function cancelEdit() {
editingHash.value = null
}
async function saveMod(mod: SkinSiteModInfo) {
if (savingMod.value) return
savingMod.value = true
try {
const payload: SkinSiteModInfo = {
...mod,
name: editForm.value.name.trim() || mod.name,
domainRules: editForm.value.domainRules
.split(',')
.map((r) => r.trim())
.filter(Boolean),
metadata: {
...mod.metadata,
name: editForm.value.name.trim() || undefined,
modId: editForm.value.modId.trim() || undefined,
version: editForm.value.version.trim() || undefined,
},
}
const result = await skinSiteUpdateModInfo(payload)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.updated) })
cancelEdit()
await loadMods(false)
} catch (error) {
handleError(error)
} finally {
savingMod.value = false
}
}
async function removeMod(mod: SkinSiteModInfo) {
if (deletingHash.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDelete, { name: mod.name }))) return
deletingHash.value = mod.hash
try {
const result = await skinSiteDeleteModFile(mod.hash)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await loadMods(false)
} catch (error) {
handleError(error)
} finally {
deletingHash.value = null
}
}
function toggleSelect(hash: string) {
const next = new Set(selectedHashes.value)
if (next.has(hash)) next.delete(hash)
else next.add(hash)
selectedHashes.value = next
}
function selectAllVisible() {
selectedHashes.value = new Set(mods.value.map((m) => m.hash))
}
function clearSelection() {
selectedHashes.value = new Set()
}
function exitSelectionMode() {
selectionMode.value = false
clearSelection()
}
async function deleteSelected() {
if (deletingMulti.value || !selectedHashes.value.size) return
const count = selectedHashes.value.size
if (!window.confirm(formatMessage(messages.confirmDeleteMulti, { count }))) return
deletingMulti.value = true
try {
const result = await skinSiteDeleteMultiModFile(Array.from(selectedHashes.value))
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deletedMulti) })
clearSelection()
selectionMode.value = false
await loadMods(false)
} catch (error) {
handleError(error)
} finally {
deletingMulti.value = false
}
}
function applyServerAddress() {
serverAddressFilter.value = serverAddressInput.value.trim()
void loadMods(true)
}
function clearServerAddress() {
serverAddressInput.value = ''
serverAddressFilter.value = ''
void loadMods(true)
}
function openNewTag() {
editingTagId.value = null
tagForm.value = { name: '', rules: '' }
tagEditorOpen.value = true
}
function openEditTag(tag: SkinSiteModRuleTag) {
editingTagId.value = tag.id
tagForm.value = { name: tag.name, rules: tag.rules.join('\n') }
tagEditorOpen.value = true
}
function closeTagEditor() {
tagEditorOpen.value = false
tagForm.value = { name: '', rules: '' }
editingTagId.value = null
}
async function saveTag() {
const name = tagForm.value.name.trim()
if (!name || savingTag.value) return
savingTag.value = true
try {
const rules = tagForm.value.rules
.split('\n')
.map((r) => r.trim())
.filter(Boolean)
const result =
editingTagId.value === null
? await skinSiteCreateModRuleTag(name, rules)
: await skinSiteUpdateModRuleTag(editingTagId.value, name, rules)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(editingTagId.value === null ? messages.tagCreated : messages.tagUpdated),
})
closeTagEditor()
await loadTags()
} catch (error) {
handleError(error)
} finally {
savingTag.value = false
}
}
async function removeTag(tag: SkinSiteModRuleTag) {
if (!window.confirm(formatMessage(messages.confirmDeleteTag, { name: tag.name }))) return
try {
const result = await skinSiteDeleteModRuleTag(tag.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.tagDeleted) })
if (tagFilter.value === tag.id) tagFilter.value = null
await loadTags()
} catch (error) {
handleError(error)
}
}
function pickRuntimeFile() {
runtimeFile.value?.click()
}
async function onRuntimeFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
const version = runtimeVersion.value.trim()
if (!file || !version || runtimeUploading.value) return
runtimeUploading.value = true
try {
const result = await skinSiteUploadModRuntime(version, file)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.runtimeUploaded) })
runtimeVersion.value = ''
await loadRuntime()
} catch (error) {
handleError(error)
} finally {
runtimeUploading.value = false
}
}
async function activateRuntime(release: SkinSiteModRuntimeRelease) {
if (activatingRuntimeId.value !== null) return
activatingRuntimeId.value = release.id
try {
const result = await skinSiteActivateModRuntime(release.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.activated) })
await loadRuntime()
} catch (error) {
handleError(error)
} finally {
activatingRuntimeId.value = null
}
}
async function removeRuntime(release: SkinSiteModRuntimeRelease) {
if (deletingRuntimeId.value !== null) return
deletingRuntimeId.value = release.id
try {
const result = await skinSiteDeleteModRuntime(release.id)
if (!result.success) throw new Error(result.errorMessage)
await loadRuntime()
} catch (error) {
handleError(error)
} finally {
deletingRuntimeId.value = null
}
}
onMounted(async () => {
await loadTags()
await loadMods(true)
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="tab === 'mods' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab('mods')"
>
{{ formatMessage(messages.tabMods) }}
</button>
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="tab === 'tags' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab('tags')"
>
{{ formatMessage(messages.tabTags) }}
</button>
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="tab === 'runtime' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab('runtime')"
>
{{ formatMessage(messages.tabRuntime) }}
</button>
</div>
<template v-if="tab === 'mods'">
<div class="flex flex-wrap items-center gap-2">
<input
v-model="searchKeyword"
type="text"
:placeholder="formatMessage(messages.search)"
class="min-w-0 flex-1 rounded-full border border-solid border-surface-5 bg-bg px-3 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="loadMods(true)"
/>
<select
v-model="tagFilter"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
@change="loadMods(true)"
>
<option :value="null">{{ formatMessage(messages.filterAll) }}</option>
<option v-for="tag in tags" :key="tag.id" :value="tag.id">{{ tag.name }}</option>
</select>
<button
type="button"
class="rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="uploadOpen = true"
>
{{ formatMessage(messages.upload) }}
</button>
<button
v-if="!selectionMode"
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="selectionMode = true"
>
{{ formatMessage(messages.selectMode) }}
</button>
<template v-else>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="selectAllVisible"
>
{{ formatMessage(messages.selectAll) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!selectedHashes.size"
@click="clearSelection"
>
{{ formatMessage(messages.clearSelection) }}
</button>
<button
type="button"
class="rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!selectedHashes.size || deletingMulti"
@click="deleteSelected"
>
{{ formatMessage(messages.deleteSelected) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="exitSelectionMode"
>
{{ formatMessage(messages.exitSelect) }}
</button>
</template>
<span class="text-xs text-secondary">
{{ formatMessage(messages.total, { count: modTotal }) }}
</span>
<span v-if="selectionMode && selectedHashes.size" class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.selectedCount, { count: selectedHashes.size }) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<input
v-model="serverAddressInput"
type="text"
:placeholder="formatMessage(messages.serverAddressHint)"
class="min-w-0 flex-1 rounded-full border border-solid border-surface-5 bg-bg px-3 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="applyServerAddress"
/>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="applyServerAddress"
>
{{ formatMessage(messages.apply) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!serverAddressFilter"
@click="clearServerAddress"
>
{{ formatMessage(messages.clear) }}
</button>
</div>
<div v-if="modLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="modFailed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!mods.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="mod in mods"
:key="mod.hash"
class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3"
:class="selectionMode && selectedHashes.has(mod.hash) ? 'ring-2 ring-brand' : ''"
>
<div class="flex flex-wrap items-center gap-2">
<input
v-if="selectionMode"
type="checkbox"
class="size-3.5 shrink-0"
:checked="selectedHashes.has(mod.hash)"
@change="toggleSelect(mod.hash)"
/>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ mod.metadata.name || mod.name }}
</span>
<span class="shrink-0 text-xs text-secondary">{{ formatSize(mod.size) }}</span>
<span
v-for="loader in mod.metadata.loaderType"
:key="loader"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ loader }}
</span>
</div>
<span class="truncate font-mono text-[10px] text-secondary">{{ mod.hash }}</span>
<div v-if="editingHash === mod.hash" class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.name) }}</span>
<input
v-model="editForm.name"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.modId) }}</span>
<input
v-model="editForm.modId"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.version) }}</span>
<input
v-model="editForm.version"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1 sm:col-span-3">
<span class="text-xs text-secondary">{{ formatMessage(messages.domainRules) }}</span>
<input
v-model="editForm.domainRules"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
</div>
<div class="flex flex-wrap gap-2">
<template v-if="editingHash === mod.hash">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="cancelEdit"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="savingMod"
@click="saveMod(mod)"
>
{{ formatMessage(messages.save) }}
</button>
</template>
<template v-else>
<span
v-for="rule in mod.domainRules"
:key="rule"
class="rounded bg-bg px-2 py-0.5 font-mono text-[10px] text-secondary"
>
{{ rule }}
</span>
<button
type="button"
class="ml-auto rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="startEdit(mod)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingHash !== null"
@click="removeMod(mod)"
>
{{ formatMessage(messages.remove) }}
</button>
</template>
</div>
</div>
<div class="flex items-center justify-center gap-3 pt-2">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasPrev"
@click="prevPage"
>
{{ formatMessage(messages.prev) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.page, { page: modPage }) }} / {{ modPageCount }}
</span>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasNext"
@click="nextPage"
>
{{ formatMessage(messages.next) }}
</button>
</div>
</div>
</template>
<template v-else-if="tab === 'tags'">
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="openNewTag"
>
{{ formatMessage(messages.newTag) }}
</button>
</div>
<div v-if="tagsLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<div v-else-if="!tags.length" class="rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="tag in tags"
:key="tag.id"
class="flex flex-wrap items-center gap-2 rounded-xl bg-bg-raised p-3"
>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ tag.name }}
</span>
<span class="text-xs text-secondary">×{{ tag.rules.length }}</span>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openEditTag(tag)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="removeTag(tag)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
<div
v-if="tagEditorOpen"
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.tagName) }}
</span>
<input
v-model="tagForm.name"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.tagRules) }}
</span>
<textarea
v-model="tagForm.rules"
rows="5"
:placeholder="formatMessage(messages.tagRulesHint)"
class="w-full resize-y rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 font-mono text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="closeTagEditor"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="savingTag || !tagForm.name.trim()"
@click="saveTag"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</div>
</template>
<template v-else>
<div class="flex flex-wrap items-center gap-2">
<input ref="runtimeFile" type="file" class="hidden" @change="onRuntimeFile" />
<input
v-model="runtimeVersion"
type="text"
:placeholder="formatMessage(messages.runtimeVersion)"
class="w-40 rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
/>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="runtimeUploading || !runtimeVersion.trim()"
@click="pickRuntimeFile"
>
{{
runtimeUploading
? formatMessage(messages.uploading)
: formatMessage(messages.uploadRuntime)
}}
</button>
</div>
<div
v-if="runtimeState && runtimeState.manifestUrl"
class="flex flex-wrap items-center gap-2 rounded-xl bg-bg-raised p-3"
>
<span class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.manifestUrl) }}
</span>
<code class="min-w-0 flex-1 truncate text-xs text-secondary">
{{ runtimeState.manifestUrl }}
</code>
</div>
<div v-if="runtimeLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p
v-else-if="!runtimeState || !runtimeState.releases.length"
class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="release in runtimeState.releases"
:key="release.id"
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-contrast">
{{ release.version }}
<span class="text-xs font-normal text-secondary">#{{ release.id }}</span>
</span>
<span class="truncate text-xs text-secondary">
{{ formatMessage(messages.uploader, { name: release.uploadedBy }) }} ·
{{ formatDateTime(release.uploadedAt) }}
</span>
</div>
<span class="shrink-0 text-xs text-secondary">
{{ formatSize(release.fileSize) }}
</span>
<span
v-if="release.active"
class="shrink-0 rounded-full bg-green px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.active) }}
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="release.available ? 'bg-button-bg text-secondary' : 'bg-orange text-contrast'"
>
{{
release.available
? formatMessage(messages.available)
: formatMessage(messages.unavailable)
}}
</span>
<button
v-if="!release.active"
type="button"
class="shrink-0 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="activatingRuntimeId !== null"
@click="activateRuntime(release)"
>
{{ formatMessage(messages.activate) }}
</button>
<button
type="button"
class="shrink-0 rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingRuntimeId !== null || release.active"
@click="removeRuntime(release)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
</template>
<ModUploadModal
v-if="uploadOpen"
:rule-tags="tags"
@finished="uploadOpen = false"
/>
</div>
</template>
@@ -0,0 +1,315 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteAdminDeletePlayer,
type SkinSiteAdminPlayer,
skinSiteAdminPlayerCount,
skinSiteAdminPlayerList,
skinSiteAdminUpdatePlayer,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.players.title', defaultMessage: 'Players' },
loading: { id: 'app.skin-site.admin.players.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.players.failed', defaultMessage: 'Could not load players.' },
empty: { id: 'app.skin-site.admin.players.empty', defaultMessage: 'No players found.' },
search: { id: 'app.skin-site.admin.players.search', defaultMessage: 'Search' },
searchName: { id: 'app.skin-site.admin.players.search-name', defaultMessage: 'Name' },
searchOwner: { id: 'app.skin-site.admin.players.search-owner', defaultMessage: 'Owner' },
searchUuid: { id: 'app.skin-site.admin.players.search-uuid', defaultMessage: 'UUID' },
total: { id: 'app.skin-site.admin.players.total', defaultMessage: '{count} players' },
prev: { id: 'app.skin-site.admin.players.prev', defaultMessage: 'Previous' },
next: { id: 'app.skin-site.admin.players.next', defaultMessage: 'Next' },
page: { id: 'app.skin-site.admin.players.page', defaultMessage: 'Page {page}' },
name: { id: 'app.skin-site.admin.players.name', defaultMessage: 'Name' },
owner: { id: 'app.skin-site.admin.players.owner', defaultMessage: 'Owner' },
source: { id: 'app.skin-site.admin.players.source', defaultMessage: 'Source' },
legacy: { id: 'app.skin-site.admin.players.legacy', defaultMessage: 'Legacy adapted' },
actions: { id: 'app.skin-site.admin.players.actions', defaultMessage: 'Actions' },
edit: { id: 'app.skin-site.admin.players.edit', defaultMessage: 'Edit' },
save: { id: 'app.skin-site.admin.players.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.players.cancel', defaultMessage: 'Cancel' },
remove: { id: 'app.skin-site.admin.players.remove', defaultMessage: 'Delete' },
updated: { id: 'app.skin-site.admin.players.updated', defaultMessage: 'Player updated.' },
deleted: { id: 'app.skin-site.admin.players.deleted', defaultMessage: 'Player deleted.' },
confirmDelete: {
id: 'app.skin-site.admin.players.confirm-delete',
defaultMessage: 'Delete player “{name}”? This cannot be undone.',
},
mojang: { id: 'app.skin-site.admin.players.mojang', defaultMessage: 'Mojang' },
custom: { id: 'app.skin-site.admin.players.custom', defaultMessage: 'Skin site' },
})
const PAGE_SIZE = 20
const items = ref<SkinSiteAdminPlayer[]>([])
const total = ref(0)
const offset = ref(0)
const loading = ref(true)
const failed = ref(false)
const keyword = ref('')
const searchField = ref<'name' | 'ownerUsername' | 'uuid'>('name')
const editingUuid = ref<string | null>(null)
const editName = ref('')
const editIsMojang = ref(false)
const saving = ref(false)
const deletingUuid = ref<string | null>(null)
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const hasPrev = computed(() => offset.value > 0)
const hasNext = computed(() => offset.value + PAGE_SIZE < total.value)
const SEARCH_FIELDS: Array<'name' | 'ownerUsername' | 'uuid'> = ['name', 'ownerUsername', 'uuid']
function searchFieldLabel(field: string): string {
if (field === 'ownerUsername') return formatMessage(messages.searchOwner)
if (field === 'uuid') return formatMessage(messages.searchUuid)
return formatMessage(messages.searchName)
}
function buildQuery() {
const kw = keyword.value.trim()
return {
limit: PAGE_SIZE,
offset: offset.value,
searchField: kw ? searchField.value : undefined,
keyword: kw || undefined,
}
}
async function load(reset = false) {
if (reset) offset.value = 0
loading.value = true
failed.value = false
try {
const query = buildQuery()
const [listResult, countResult] = await Promise.all([
skinSiteAdminPlayerList(query),
skinSiteAdminPlayerCount(
query.keyword ? { searchField: query.searchField, keyword: query.keyword } : undefined,
),
])
if (!listResult.success) throw new Error(listResult.errorMessage)
items.value = listResult.payload ?? []
if (countResult.success && typeof countResult.payload === 'number') {
total.value = countResult.payload
}
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function prevPage() {
if (!hasPrev.value) return
offset.value = Math.max(0, offset.value - PAGE_SIZE)
await load()
}
async function nextPage() {
if (!hasNext.value) return
offset.value += PAGE_SIZE
await load()
}
function startEdit(player: SkinSiteAdminPlayer) {
editingUuid.value = player.uuid
editName.value = player.name
editIsMojang.value = player.isMojang
}
function cancelEdit() {
editingUuid.value = null
editName.value = ''
editIsMojang.value = false
}
async function saveEdit() {
if (!editingUuid.value || saving.value) return
saving.value = true
try {
const result = await skinSiteAdminUpdatePlayer({
uuid: editingUuid.value,
name: editName.value.trim(),
isMojang: editIsMojang.value,
})
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.updated) })
cancelEdit()
await load()
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function remove(player: SkinSiteAdminPlayer) {
if (deletingUuid.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDelete, { name: player.name }))) return
deletingUuid.value = player.uuid
try {
const result = await skinSiteAdminDeletePlayer(player.uuid)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await load()
} catch (error) {
handleError(error)
} finally {
deletingUuid.value = null
}
}
onMounted(() => void load())
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<span class="text-xs text-secondary">
{{ formatMessage(messages.total, { count: total }) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<select
v-model="searchField"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
>
<option v-for="f in SEARCH_FIELDS" :key="f" :value="f">
{{ searchFieldLabel(f) }}
</option>
</select>
<input
v-model="keyword"
type="text"
:placeholder="formatMessage(messages.search)"
class="min-w-0 flex-1 rounded-full border border-solid border-surface-5 bg-bg px-3 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="load(true)"
/>
<button
type="button"
class="rounded-full bg-brand px-4 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="load(true)"
>
{{ formatMessage(messages.search) }}
</button>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!items.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="player in items"
:key="player.uuid"
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
>
<div class="flex min-w-0 flex-1 flex-col">
<input
v-if="editingUuid === player.uuid"
v-model="editName"
type="text"
class="w-full rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-sm text-contrast outline-none focus:border-brand"
/>
<span v-else class="truncate text-sm font-semibold text-contrast">
{{ player.name }}
</span>
<span class="truncate font-mono text-[10px] text-secondary">{{ player.uuid }}</span>
</div>
<span class="shrink-0 text-xs text-secondary">{{ player.ownerUsername }}</span>
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ player.isMojang ? formatMessage(messages.mojang) : formatMessage(messages.custom) }}
</span>
<span
v-if="player.legacyInternalAdapted"
class="shrink-0 rounded-full bg-yellow px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.legacy) }}
</span>
<template v-if="editingUuid === player.uuid">
<label class="flex shrink-0 items-center gap-1 text-xs text-contrast">
<input v-model="editIsMojang" type="checkbox" />
{{ formatMessage(messages.mojang) }}
</label>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="cancelEdit"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="shrink-0 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="saving"
@click="saveEdit"
>
{{ formatMessage(messages.save) }}
</button>
</template>
<template v-else>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="startEdit(player)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="shrink-0 rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingUuid !== null"
@click="remove(player)"
>
{{ formatMessage(messages.remove) }}
</button>
</template>
</div>
<div class="flex items-center justify-center gap-3 pt-2">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasPrev"
@click="prevPage"
>
{{ formatMessage(messages.prev) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.page, { page }) }} / {{ pageCount }}
</span>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasNext"
@click="nextPage"
>
{{ formatMessage(messages.next) }}
</button>
</div>
</div>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
type SkinSiteMapServer,
skinSiteMapServers,
skinSiteSaveMapServers,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.servers.title', defaultMessage: 'Map servers' },
loading: { id: 'app.skin-site.admin.servers.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.servers.failed', defaultMessage: 'Could not load servers.' },
empty: { id: 'app.skin-site.admin.servers.empty', defaultMessage: 'No map servers configured.' },
serverName: { id: 'app.skin-site.admin.servers.server-name', defaultMessage: 'Server name' },
internal: { id: 'app.skin-site.admin.servers.internal', defaultMessage: 'Internal' },
maps: { id: 'app.skin-site.admin.servers.maps', defaultMessage: 'Maps' },
mapName: { id: 'app.skin-site.admin.servers.map-name', defaultMessage: 'Map name' },
mapUrl: { id: 'app.skin-site.admin.servers.map-url', defaultMessage: 'Map URL' },
addMap: { id: 'app.skin-site.admin.servers.add-map', defaultMessage: 'Add map' },
removeMap: { id: 'app.skin-site.admin.servers.remove-map', defaultMessage: 'Remove' },
addServer: { id: 'app.skin-site.admin.servers.add-server', defaultMessage: 'Add server' },
removeServer: {
id: 'app.skin-site.admin.servers.remove-server',
defaultMessage: 'Remove server',
},
save: { id: 'app.skin-site.admin.servers.save', defaultMessage: 'Save changes' },
saving: { id: 'app.skin-site.admin.servers.saving', defaultMessage: 'Saving…' },
saved: { id: 'app.skin-site.admin.servers.saved', defaultMessage: 'Map servers saved.' },
})
const servers = ref<SkinSiteMapServer[]>([])
const loading = ref(true)
const failed = ref(false)
const saving = ref(false)
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteMapServers()
if (!result.success) throw new Error(result.errorMessage)
servers.value = result.payload ?? []
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
function addServer() {
servers.value.push({ name: '', internal: false, maps: [{ name: '', url: '' }] })
}
function removeServer(index: number) {
servers.value.splice(index, 1)
}
function addMap(server: SkinSiteMapServer) {
server.maps.push({ name: '', url: '' })
}
function removeMap(server: SkinSiteMapServer, index: number) {
server.maps.splice(index, 1)
}
async function save() {
if (saving.value) return
saving.value = true
try {
const cleaned = servers.value.map((s) => ({
name: s.name.trim(),
internal: s.internal,
maps: s.maps
.filter((m) => m.name.trim() || m.url.trim())
.map((m) => ({ name: m.name.trim(), url: m.url.trim() })),
}))
const result = await skinSiteSaveMapServers(cleaned)
if (!result.success) throw new Error(result.errorMessage)
servers.value = result.payload ?? cleaned
addNotification({ type: 'success', title: formatMessage(messages.saved) })
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
onMounted(() => void load())
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="addServer"
>
{{ formatMessage(messages.addServer) }}
</button>
<button
type="button"
class="ml-auto rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="saving"
@click="save"
>
{{ saving ? formatMessage(messages.saving) : formatMessage(messages.save) }}
</button>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!servers.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-3">
<div
v-for="(server, si) in servers"
:key="si"
class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<input
v-model="server.name"
type="text"
:placeholder="formatMessage(messages.serverName)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-sm text-contrast outline-none focus:border-brand"
/>
<label class="flex shrink-0 items-center gap-1.5 text-xs text-contrast">
<input v-model="server.internal" type="checkbox" />
{{ formatMessage(messages.internal) }}
</label>
<button
type="button"
class="shrink-0 rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="removeServer(si)"
>
{{ formatMessage(messages.removeServer) }}
</button>
</div>
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.maps) }}
</span>
<div v-for="(map, mi) in server.maps" :key="mi" class="flex flex-wrap items-center gap-2">
<input
v-model="map.name"
type="text"
:placeholder="formatMessage(messages.mapName)"
class="w-40 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<input
v-model="map.url"
type="text"
:placeholder="formatMessage(messages.mapUrl)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-2 py-1 text-[10px] font-semibold text-secondary hover:text-red"
@click="removeMap(server, mi)"
>
{{ formatMessage(messages.removeMap) }}
</button>
</div>
<button
type="button"
class="w-fit rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="addMap(server)"
>
{{ formatMessage(messages.addMap) }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { type SkinSiteAdminStatus, skinSiteAdminStatus } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.summary.title', defaultMessage: 'System overview' },
loading: { id: 'app.skin-site.admin.summary.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.summary.failed', defaultMessage: 'Could not load status.' },
uptime: { id: 'app.skin-site.admin.summary.uptime', defaultMessage: 'Uptime' },
memory: { id: 'app.skin-site.admin.summary.memory', defaultMessage: 'Memory usage' },
users: { id: 'app.skin-site.admin.summary.users', defaultMessage: 'Users' },
players: { id: 'app.skin-site.admin.summary.players', defaultMessage: 'Players' },
realtimeHint: {
id: 'app.skin-site.admin.summary.realtime-hint',
defaultMessage:
'Live CPU and GC charts require the admin WebSocket, which is not connected yet.',
},
})
const status = ref<SkinSiteAdminStatus | null>(null)
const loading = ref(true)
const failed = ref(false)
const now = ref(Date.now())
let ticker: number | undefined
const uptime = computed(() => {
if (!status.value) return ''
let ms = now.value - status.value.bootTime
if (ms < 0) ms = 0
const totalSeconds = Math.floor(ms / 1000)
const days = Math.floor(totalSeconds / 86400)
const hours = Math.floor((totalSeconds % 86400) / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const parts: string[] = []
if (days) parts.push(days + 'd')
if (hours) parts.push(hours + 'h')
if (minutes) parts.push(minutes + 'm')
parts.push(seconds + 's')
return parts.join(' ')
})
function formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let idx = 0
while (value >= 1024 && idx < units.length - 1) {
value /= 1024
idx++
}
return value.toFixed(idx === 0 ? 0 : 2) + ' ' + units[idx]
}
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteAdminStatus()
if (!result.success) throw new Error(result.errorMessage)
status.value = result.payload
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
onMounted(() => {
void load()
ticker = window.setInterval(() => {
now.value = Date.now()
}, 1000)
})
onUnmounted(() => {
if (ticker) clearInterval(ticker)
})
</script>
<template>
<div class="flex flex-col gap-4">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<template v-else-if="status">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.uptime) }}</span>
<span class="text-xl font-bold text-contrast">{{ uptime }}</span>
</div>
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.memory) }}</span>
<span class="text-xl font-bold text-contrast">{{ formatSize(status.memoryUsage) }}</span>
</div>
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.users) }}</span>
<span class="text-xl font-bold text-contrast">{{ status.userCount }}</span>
</div>
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.players) }}</span>
<span class="text-xl font-bold text-contrast">{{ status.playerCount }}</span>
</div>
</div>
<p class="m-0 rounded-xl border border-dashed border-surface-5 p-3 text-xs text-secondary">
{{ formatMessage(messages.realtimeHint) }}
</p>
</template>
</div>
</template>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,440 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
type SkinSiteModRuleTag,
skinSiteModExistsByHash,
skinSiteModExistsByName,
skinSiteModHashFromFile,
skinSiteUploadMod,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
defineProps<{ ruleTags: SkinSiteModRuleTag[] }>()
const emit = defineEmits<{ (e: 'finished'): void }>()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.mods.upload-title', defaultMessage: 'Upload mods' },
dropHint: {
id: 'app.skin-site.admin.mods.upload-drop',
defaultMessage: 'Drag .jar files here, or click to choose',
},
dropSub: {
id: 'app.skin-site.admin.mods.upload-sub',
defaultMessage: 'Multiple files supported, up to {size} MB each',
},
tagsLabel: {
id: 'app.skin-site.admin.mods.upload-tags',
defaultMessage: 'Attach rule tags',
},
queue: { id: 'app.skin-site.admin.mods.upload-queue', defaultMessage: 'Files' },
statusPending: {
id: 'app.skin-site.admin.mods.upload-pending',
defaultMessage: 'Pending',
},
statusChecking: {
id: 'app.skin-site.admin.mods.upload-checking',
defaultMessage: 'Checking…',
},
statusUploading: {
id: 'app.skin-site.admin.mods.upload-uploading',
defaultMessage: 'Uploading…',
},
statusConflict: {
id: 'app.skin-site.admin.mods.upload-conflict',
defaultMessage: 'Name exists',
},
statusSuccess: {
id: 'app.skin-site.admin.mods.upload-success',
defaultMessage: 'Done',
},
statusError: {
id: 'app.skin-site.admin.mods.upload-error',
defaultMessage: 'Failed',
},
existsAlready: {
id: 'app.skin-site.admin.mods.upload-exists',
defaultMessage: 'This file already exists on the server.',
},
nameConflictTitle: {
id: 'app.skin-site.admin.mods.upload-name-conflict',
defaultMessage: 'A mod with the same file name already exists',
},
nameConflictHint: {
id: 'app.skin-site.admin.mods.upload-name-conflict-hint',
defaultMessage: 'Overwrite the existing mod, or skip this file.',
},
overwrite: { id: 'app.skin-site.admin.mods.upload-overwrite', defaultMessage: 'Overwrite' },
skip: { id: 'app.skin-site.admin.mods.upload-skip', defaultMessage: 'Skip' },
close: { id: 'app.skin-site.admin.mods.upload-close', defaultMessage: 'Close' },
allDone: {
id: 'app.skin-site.admin.mods.upload-all-done',
defaultMessage: 'All files processed.',
},
tooLarge: {
id: 'app.skin-site.admin.mods.upload-too-large',
defaultMessage: 'File too large: {name}',
},
})
const MAX_MOD_SIZE_MB = 200
const MAX_RETRIES = 3
type TaskStatus = 'pending' | 'checking' | 'uploading' | 'conflict' | 'success' | 'error'
interface Task {
file: File
status: TaskStatus
message?: string
attempts: number
}
const queue = ref<Task[]>([])
const selectedTagIds = ref<Set<number>>(new Set())
const fileInput = ref<HTMLInputElement | null>(null)
const busy = ref(false)
const conflictTask = ref<Task | null>(null)
const finishedCount = computed(
() => queue.value.filter((t) => t.status === 'success' || t.status === 'error').length,
)
const allDone = computed(() => queue.value.length > 0 && finishedCount.value === queue.value.length)
const hasWork = computed(() => queue.value.length > 0)
function toggleTag(id: number) {
const next = new Set(selectedTagIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedTagIds.value = next
}
function pickFiles() {
if (busy.value) return
fileInput.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
enqueue(files)
}
function onDrop(event: DragEvent) {
event.preventDefault()
if (busy.value) return
enqueue(Array.from(event.dataTransfer?.files ?? []))
}
function enqueue(files: File[]) {
const jars = files.filter((f) => f.name.toLowerCase().endsWith('.jar'))
if (!jars.length) return
const accepted: Task[] = []
for (const file of jars) {
if (file.size > MAX_MOD_SIZE_MB * 1024 * 1024) {
addNotification({
type: 'error',
title: formatMessage(messages.tooLarge, { name: file.name }),
})
continue
}
accepted.push({ file, status: 'pending', attempts: 0 })
}
if (!accepted.length) return
queue.value = [...queue.value, ...accepted]
void runQueue()
}
async function runQueue() {
if (busy.value) return
busy.value = true
try {
for (;;) {
const task = queue.value.find((t) => t.status === 'pending')
if (!task) break
await processTask(task)
if (task.status === 'conflict') break
}
} finally {
busy.value = false
}
if (allDone.value) {
addNotification({ type: 'success', title: formatMessage(messages.allDone) })
emit('finished')
}
}
async function processTask(task: Task) {
task.status = 'checking'
task.message = undefined
try {
const hash = await skinSiteModHashFromFile(task.file)
const exists = await skinSiteModExistsByHash(hash)
if (exists.success) {
task.status = 'error'
task.message = formatMessage(messages.existsAlready)
return
}
const nameExists = await skinSiteModExistsByName(task.file.name)
if (nameExists.success) {
task.status = 'conflict'
conflictTask.value = task
return
}
await upload(task, false)
} catch (error) {
task.status = 'error'
task.message = error instanceof Error ? error.message : String(error)
handleError(error)
}
}
async function upload(task: Task, overwrite: boolean) {
task.status = 'uploading'
try {
const res = await skinSiteUploadMod(task.file, Array.from(selectedTagIds.value), overwrite)
if (res.success) {
task.status = 'success'
return
}
if (res.errorCode === 429) {
task.attempts++
if (task.attempts < MAX_RETRIES) {
task.status = 'pending'
task.message = 'rate limited, retrying'
setTimeout(() => void runQueue(), 1500 * task.attempts)
} else {
task.status = 'error'
task.message = res.errorMessage
}
return
}
if (res.errorType === 'HashDiff') {
task.attempts++
if (task.attempts < MAX_RETRIES) {
task.status = 'pending'
task.message = 'hash mismatch, retrying'
setTimeout(() => void runQueue(), 800)
} else {
task.status = 'error'
task.message = res.errorMessage
}
return
}
if (res.errorType === 'ModNameConflict') {
task.status = 'conflict'
conflictTask.value = task
return
}
task.status = 'error'
task.message = res.errorMessage
} catch (error) {
task.status = 'error'
task.message = error instanceof Error ? error.message : String(error)
}
}
async function resolveConflict(overwrite: boolean) {
const task = conflictTask.value
conflictTask.value = null
if (!task) return
if (overwrite) {
busy.value = true
try {
await upload(task, true)
} finally {
busy.value = false
}
} else {
task.status = 'error'
task.message = formatMessage(messages.skip)
}
void runQueue()
}
function close() {
if (busy.value) return
queue.value = []
conflictTask.value = null
emit('finished')
}
function statusLabel(status: TaskStatus): string {
switch (status) {
case 'pending':
return formatMessage(messages.statusPending)
case 'checking':
return formatMessage(messages.statusChecking)
case 'uploading':
return formatMessage(messages.statusUploading)
case 'conflict':
return formatMessage(messages.statusConflict)
case 'success':
return formatMessage(messages.statusSuccess)
default:
return formatMessage(messages.statusError)
}
}
function statusClass(status: TaskStatus): string {
switch (status) {
case 'success':
return 'bg-green text-contrast'
case 'error':
return 'bg-red text-contrast'
case 'conflict':
return 'bg-yellow text-contrast'
default:
return 'bg-button-bg text-secondary'
}
}
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" @click.self="close">
<div class="flex max-h-[88vh] w-full max-w-2xl flex-col gap-4 overflow-y-auto rounded-xl bg-bg p-6">
<div class="flex items-start justify-between gap-3">
<h3 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h3>
<button
type="button"
class="shrink-0 rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="busy"
@click="close"
>
✕
</button>
</div>
<div
class="flex min-h-40 cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-surface-5 p-6 text-center transition-colors hover:bg-bg-raised"
:class="busy ? 'cursor-not-allowed opacity-60' : ''"
@dragover.prevent
@dragenter.prevent
@drop.prevent="onDrop"
@click="pickFiles"
>
<input
ref="fileInput"
type="file"
class="hidden"
multiple
accept=".jar"
@change="onFileChange"
/>
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.dropHint) }}
</span>
<span class="text-xs text-secondary">
{{ formatMessage(messages.dropSub, { size: MAX_MOD_SIZE_MB }) }}
</span>
</div>
<div v-if="ruleTags.length" class="flex flex-col gap-2">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.tagsLabel) }}
</span>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in ruleTags"
:key="tag.id"
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
:class="
selectedTagIds.has(tag.id)
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:text-contrast'
"
@click="toggleTag(tag.id)"
>
{{ tag.name }}
</button>
</div>
</div>
<div v-if="hasWork" class="flex flex-col gap-2">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.queue) }} ({{ finishedCount }}/{{ queue.length }})
</span>
<div class="flex max-h-64 flex-col gap-1.5 overflow-y-auto">
<div
v-for="(task, index) in queue"
:key="task.file.name + index"
class="flex flex-wrap items-center gap-2 rounded-lg bg-bg-raised px-3 py-2"
>
<span class="min-w-0 flex-1 truncate text-xs text-contrast">
{{ task.file.name }}
</span>
<span class="shrink-0 text-[10px] text-secondary">
{{ (task.file.size / 1024 / 1024).toFixed(2) }} MB
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="statusClass(task.status)"
>
{{ statusLabel(task.status) }}
</span>
<span
v-if="task.message"
class="w-full truncate text-[10px] text-secondary"
:title="task.message"
>
{{ task.message }}
</span>
</div>
</div>
</div>
<div
v-if="busy"
class="flex items-center gap-2 text-xs text-secondary"
>
<SpinnerIcon class="size-3.5 animate-spin" />
{{ formatMessage(messages.statusUploading) }}
</div>
<div class="flex justify-end gap-2 border-t border-solid border-surface-5 pt-3">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110 disabled:opacity-50"
:disabled="busy"
@click="close"
>
{{ formatMessage(messages.close) }}
</button>
</div>
</div>
<div
v-if="conflictTask"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
>
<div class="flex w-full max-w-md flex-col gap-3 rounded-xl bg-bg p-6">
<h4 class="m-0 text-base font-bold text-contrast">
{{ formatMessage(messages.nameConflictTitle) }}
</h4>
<p class="m-0 text-xs break-all text-secondary">{{ conflictTask.file.name }}</p>
<p class="m-0 text-xs text-secondary">{{ formatMessage(messages.nameConflictHint) }}</p>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110"
@click="resolveConflict(false)"
>
{{ formatMessage(messages.skip) }}
</button>
<button
type="button"
class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="resolveConflict(true)"
>
{{ formatMessage(messages.overwrite) }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { formatDonationAmount } from './donation-format.ts'
test('donation totals and individual amounts carry yuan and exactly two decimals', () => {
for (const [amount, expected] of [
['12794.57', '¥12794.57'],
['300', '¥300.00'],
['0.1', '¥0.10'],
['1.005', '¥1.01'],
['0', '¥0.00'],
]) {
assert.equal(formatDonationAmount(amount), expected)
}
})
test('invalid amounts do not show a made-up zero donation', () => {
for (const amount of ['', 'unknown', 'Infinity']) assert.equal(formatDonationAmount(amount), '—')
})
@@ -0,0 +1,10 @@
const amountFormatter = new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
useGrouping: false,
})
export function formatDonationAmount(amount: string): string {
const value = Number(amount)
return amount.trim() && Number.isFinite(value) ? `¥${amountFormatter.format(value)}` : '—'
}
@@ -0,0 +1,30 @@
import { skinSiteChatRoomAvatarFetch } from '@/helpers/skin-site-api'
const avatarUrls = new Map<number, string>()
const pending = new Map<number, Promise<string | null>>()
export function loadCachedRoomAvatar(
roomId: number,
attachmentId: number,
): Promise<string | null> {
const cached = avatarUrls.get(attachmentId)
if (cached) return Promise.resolve(cached)
const running = pending.get(attachmentId)
if (running) return running
const request = skinSiteChatRoomAvatarFetch(roomId).then((blob) => {
pending.delete(attachmentId)
if (!blob) return null
const url = URL.createObjectURL(blob)
avatarUrls.set(attachmentId, url)
return url
})
pending.set(attachmentId, request)
return request
}
export function forgetRoomAvatar(attachmentId: number) {
const url = avatarUrls.get(attachmentId)
if (url) URL.revokeObjectURL(url)
avatarUrls.delete(attachmentId)
}
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteWikiAttachmentInfo,
skinSiteWikiAttachment,
skinSiteWikiAttachmentUrl,
} from '@/helpers/skin-site-api'
const props = defineProps<{ name: string; caption?: string | null }>()
const attachment = ref<SkinSiteWikiAttachmentInfo | null>(null)
const loading = ref(true)
const viewerOpen = ref(false)
const contentUrl = computed(() =>
attachment.value ? skinSiteWikiAttachmentUrl(attachment.value.id) : '#',
)
const imageCaption = computed(() => {
const caption = props.caption?.trim() ?? ''
return caption === props.name || caption === attachment.value?.name ? '' : caption
})
onMounted(async () => {
try {
const res = await skinSiteWikiAttachment(props.name)
if (res.success) attachment.value = res.payload
} catch {
// ignore
} finally {
loading.value = false
}
})
</script>
<template>
<span
v-if="loading"
class="inline-flex min-h-20 min-w-32 items-center justify-center rounded-lg border border-solid border-surface-5"
>
<span class="text-xs text-secondary">…</span>
</span>
<figure v-else-if="attachment?.image" class="my-5 inline-block max-w-full">
<button type="button" class="block max-w-full cursor-zoom-in rounded-lg" @click="viewerOpen = true">
<img
class="max-h-[70vh] max-w-full rounded-lg border border-solid border-surface-5 object-contain shadow-md"
:src="contentUrl"
:alt="caption || attachment.name"
loading="lazy"
decoding="async"
/>
</button>
<figcaption v-if="imageCaption" class="mt-2 text-center text-sm text-secondary">
{{ imageCaption }}
</figcaption>
</figure>
<a
v-else-if="attachment"
class="inline-flex items-center gap-1 text-brand underline-offset-2 hover:underline"
:href="contentUrl"
download
>
{{ caption || attachment.name }}
</a>
<span v-else class="text-red">附件“{{ name }}”不存在</span>
<div
v-if="viewerOpen"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 p-4"
@click.self="viewerOpen = false"
>
<img :src="contentUrl" :alt="caption || attachment?.name" class="max-h-[92vh] max-w-[92vw] object-contain" />
</div>
</template>
@@ -0,0 +1,238 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import {
type SkinSiteWikiPageSummary,
type SkinSiteWikiSearchPage,
skinSiteWikiCategory,
skinSiteWikiRecent,
skinSiteWikiSearch,
} from '@/helpers/skin-site-api'
const emit = defineEmits<{ navigate: [title: string] }>()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
searchPlaceholder: {
id: 'app.skin-site.wiki.browser.search-placeholder',
defaultMessage: 'Search wiki pages…',
},
tabSearch: { id: 'app.skin-site.wiki.browser.tab-search', defaultMessage: 'Search' },
tabRecent: { id: 'app.skin-site.wiki.browser.tab-recent', defaultMessage: 'Recent' },
tabCategory: { id: 'app.skin-site.wiki.browser.tab-category', defaultMessage: 'Category' },
categoryPlaceholder: {
id: 'app.skin-site.wiki.browser.category-placeholder',
defaultMessage: 'Category name',
},
loadMore: { id: 'app.skin-site.wiki.browser.load-more', defaultMessage: 'Load more' },
emptySearch: {
id: 'app.skin-site.wiki.browser.empty-search',
defaultMessage: 'No pages match your search.',
},
emptyList: {
id: 'app.skin-site.wiki.browser.empty-list',
defaultMessage: 'Nothing here yet.',
},
hintSearch: {
id: 'app.skin-site.wiki.browser.hint-search',
defaultMessage: 'Type a keyword to search.',
},
hintCategory: {
id: 'app.skin-site.wiki.browser.hint-category',
defaultMessage: 'Enter a category name to browse.',
},
byAuthor: {
id: 'app.skin-site.wiki.browser.by-author',
defaultMessage: 'by {author}',
},
})
type Tab = 'search' | 'recent' | 'category'
const tab = ref<Tab>('recent')
const keyword = ref('')
const categoryName = ref('')
const items = ref<SkinSiteWikiPageSummary[]>([])
const nextCursor = ref<number | null>(null)
const loading = ref(false)
const loaded = ref(false)
const canLoadMore = computed(() => nextCursor.value != null)
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleDateString()
} catch {
return '—'
}
}
function resetState() {
items.value = []
nextCursor.value = null
loaded.value = false
}
async function runSearch() {
const q = keyword.value.trim()
if (!q) {
resetState()
return
}
loading.value = true
try {
const res = await skinSiteWikiSearch(q)
if (!res.success) throw new Error(res.errorMessage)
items.value = res.payload ?? []
nextCursor.value = null
loaded.value = true
} catch (error) {
handleError(error)
resetState()
} finally {
loading.value = false
}
}
async function fetchPage(mode: 'recent' | 'category', append: boolean) {
if (mode === 'category' && !categoryName.value.trim()) {
resetState()
return
}
loading.value = true
try {
const before = append ? (nextCursor.value ?? undefined) : undefined
const res: { success: boolean; payload?: SkinSiteWikiSearchPage; errorMessage?: string } =
mode === 'recent'
? await skinSiteWikiRecent(before)
: await skinSiteWikiCategory(categoryName.value.trim(), before)
if (!res.success || !res.payload) throw new Error(res.errorMessage ?? 'request failed')
const page = res.payload
items.value = append ? [...items.value, ...page.items] : page.items
nextCursor.value = page.nextCursor ?? null
loaded.value = true
} catch (error) {
handleError(error)
if (!append) resetState()
} finally {
loading.value = false
}
}
function switchTab(next: Tab) {
if (tab.value === next) return
tab.value = next
resetState()
if (next === 'recent') void fetchPage('recent', false)
}
function loadMore() {
if (tab.value === 'recent') void fetchPage('recent', true)
else if (tab.value === 'category') void fetchPage('category', true)
}
watch(categoryName, () => {
if (tab.value === 'category') {
resetState()
}
})
let searchTimer: ReturnType<typeof setTimeout> | null = null
watch(keyword, () => {
if (tab.value !== 'search') return
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
void runSearch()
}, 300)
})
onMounted(() => {
void fetchPage('recent', false)
})
</script>
<template>
<div class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3">
<div class="flex flex-wrap items-center gap-2">
<button
v-for="t in (['search', 'recent', 'category'] as Tab[])"
:key="t"
type="button"
class="rounded-lg px-3 py-1 text-xs font-semibold"
:class="tab === t ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab(t)"
>
{{
formatMessage(
t === 'search'
? messages.tabSearch
: t === 'recent'
? messages.tabRecent
: messages.tabCategory,
)
}}
</button>
</div>
<input
v-if="tab === 'search'"
v-model="keyword"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
class="w-full rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="runSearch"
/>
<input
v-else-if="tab === 'category'"
v-model="categoryName"
type="text"
:placeholder="formatMessage(messages.categoryPlaceholder)"
class="w-full rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="fetchPage('category', false)"
/>
<div v-if="loading && !items.length" class="flex min-h-16 items-center justify-center gap-2 text-xs text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
</div>
<p v-else-if="tab === 'search' && !keyword.trim()" class="m-0 text-xs text-secondary">
{{ formatMessage(messages.hintSearch) }}
</p>
<p v-else-if="tab === 'category' && !categoryName.trim()" class="m-0 text-xs text-secondary">
{{ formatMessage(messages.hintCategory) }}
</p>
<p v-else-if="loaded && !items.length" class="m-0 text-xs text-secondary">
{{ formatMessage(tab === 'search' ? messages.emptySearch : messages.emptyList) }}
</p>
<div v-else class="flex flex-col divide-y divide-surface-5">
<button
v-for="item in items"
:key="item.title"
type="button"
class="flex flex-col items-start gap-0.5 py-2 text-left hover:brightness-110"
@click="emit('navigate', item.title)"
>
<span class="text-sm font-semibold text-contrast">{{ item.title }}</span>
<span class="text-[10px] text-secondary">
{{ formatMessage(messages.byAuthor, { author: item.authorName }) }} ·
{{ formatDate(item.updatedAt) }}
</span>
</button>
</div>
<button
v-if="canLoadMore && !loading"
type="button"
class="self-start rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="loadMore"
>
{{ formatMessage(messages.loadMore) }}
</button>
</div>
</template>
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { type SkinSiteWikiNode } from '@/helpers/skin-site-api'
import { safeExternalUrl, safeWikiColor } from './wiki-color'
defineOptions({ name: 'WikiNodeRenderer' })
defineProps<{ nodes: SkinSiteWikiNode[] }>()
const emit = defineEmits<{ (e: 'navigate', title: string): void }>()
function headingTag(level?: number | null): string {
return 'h' + Math.min(6, Math.max(1, level ?? 2))
}
function navigate(title?: string | null) {
emit('navigate', (title ?? '').replaceAll('_', ' '))
}
</script>
<template>
<template v-for="(node, index) in nodes" :key="node.type + '-' + index">
<component
:is="headingTag(node.level)"
v-if="node.type === 'heading'"
class="wiki-heading scroll-mt-24"
:style="{ textAlign: node.align ?? undefined }"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</component>
<p
v-else-if="node.type === 'paragraph'"
class="my-3 leading-7 break-words whitespace-pre-wrap"
:style="{ textAlign: node.align ?? undefined }"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</p>
<strong v-else-if="node.type === 'strong'" class="font-bold">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</strong>
<small v-else-if="node.type === 'small'">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</small>
<span
v-else-if="node.type === 'color'"
:style="{ color: safeWikiColor(node.color ?? '') ?? undefined }"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</span>
<em v-else-if="node.type === 'emphasis'" class="italic">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</em>
<u v-else-if="node.type === 'underline'">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</u>
<s v-else-if="node.type === 'strike'">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</s>
<mark v-else-if="node.type === 'highlight'" class="rounded bg-yellow/25 px-0.5 text-contrast">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</mark>
<code v-else-if="node.type === 'inlineCode'" class="rounded bg-button-bg px-1 font-mono text-sm">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</code>
<span
v-else-if="node.type === 'spoiler'"
class="cursor-help rounded bg-contrast text-contrast transition-colors hover:bg-transparent hover:text-inherit"
:title="node.tooltip || '我知道你在偷看'"
tabindex="0"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</span>
<br v-else-if="node.type === 'break'" />
<hr v-else-if="node.type === 'rule'" class="my-6 border-surface-5" />
<button
v-else-if="node.type === 'internalLink'"
type="button"
class="font-medium underline-offset-2 hover:underline"
:class="node.exists === false ? 'text-red' : 'text-brand'"
:title="node.exists === false ? '该词条尚未创建' : undefined"
@click="navigate(node.title)"
>
<WikiNodeRenderer v-if="node.children.length" :nodes="node.children" @navigate="navigate" />
<template v-else>{{ node.text }}</template>
</button>
<a
v-else-if="node.type === 'externalLink'"
class="text-brand underline-offset-2 hover:underline"
:href="safeExternalUrl(node.href)"
target="_blank"
rel="noopener noreferrer nofollow"
>
<WikiNodeRenderer v-if="node.children.length" :nodes="node.children" @navigate="navigate" />
<template v-else>{{ node.text }}</template>
</a>
<ul v-else-if="node.type === 'unorderedList'" class="my-3 list-disc pl-7">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</ul>
<ol
v-else-if="node.type === 'orderedList'"
:start="node.start ?? undefined"
class="my-3 list-decimal pl-7"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</ol>
<li v-else-if="node.type === 'listItem'" class="my-1">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</li>
<blockquote
v-else-if="node.type === 'quote'"
class="my-4 border-l-4 border-brand/60 bg-brand/5 px-4 py-2"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</blockquote>
<pre
v-else-if="node.type === 'code'"
class="my-4 overflow-x-auto rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 font-mono text-sm"
><code>{{ node.text }}</code></pre>
<div v-else-if="node.type === 'table'" class="my-5 overflow-x-auto">
<table class="w-full border-collapse text-sm">
<tbody>
<WikiNodeRenderer
v-if="node.children.length"
:nodes="node.children"
@navigate="navigate"
/>
<tr v-for="(row, rowIndex) in node.rows" :key="rowIndex">
<td v-for="(cell, cellIndex) in row" :key="cellIndex" class="border border-solid border-surface-5 px-2 py-1">
<WikiNodeRenderer :nodes="cell" @navigate="navigate" />
</td>
</tr>
</tbody>
</table>
</div>
<tr v-else-if="node.type === 'tableRow'">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</tr>
<component
:is="node.type === 'tableHeader' ? 'th' : 'td'"
v-else-if="node.type === 'tableCell' || node.type === 'tableHeader'"
class="border border-solid border-surface-5 px-2 py-1"
:colspan="node.colspan ?? 1"
:rowspan="node.rowspan ?? 1"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</component>
<WikiAttachmentNode
v-else-if="node.type === 'attachment' && node.title"
:name="node.title"
:caption="node.text"
/>
<WikiNotice
v-else-if="node.type === 'notice' && node.title"
:title="node.title"
:description="node.text"
:variant="node.variant"
>
<WikiNodeRenderer v-if="node.children.length" :nodes="node.children" @navigate="navigate" />
<p v-else-if="node.text" class="whitespace-pre-wrap">{{ node.text }}</p>
</WikiNotice>
<span v-else>{{ node.text }}</span>
</template>
</template>
<style scoped>
.wiki-heading {
margin-top: 1.65rem;
margin-bottom: 0.7rem;
border-bottom: 1px solid color-mix(in oklab, currentColor 16%, transparent);
padding-bottom: 0.4rem;
font-weight: 750;
line-height: 1.25;
}
h1.wiki-heading {
font-size: 2rem;
}
h2.wiki-heading {
font-size: 1.55rem;
}
h3.wiki-heading {
font-size: 1.25rem;
}
</style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
title: string
description?: string | null
variant?: string | null
}>()
const variantClass = computed(() => {
switch (props.variant) {
case 'warning':
return 'border-yellow/40 bg-yellow/10 text-yellow'
case 'error':
return 'border-red/40 bg-red/10 text-red'
case 'success':
return 'border-green/40 bg-green/10 text-green'
default:
return 'border-brand/40 bg-brand/10 text-brand'
}
})
</script>
<template>
<div class="my-4 rounded-lg border border-solid p-4" :class="variantClass">
<p class="m-0 text-sm font-bold">{{ title }}</p>
<p v-if="description" class="m-0 mt-1 text-xs whitespace-pre-wrap opacity-90">
{{ description }}
</p>
<slot />
</div>
</template>
@@ -0,0 +1,18 @@
export function safeWikiColor(value: unknown): string | null {
if (typeof value !== 'string') return null
const color = value.trim()
return color.length <= 80 &&
/^(?:[a-z]{1,20}|#[\da-f]{3}|#[\da-f]{4}|#[\da-f]{6}|#[\da-f]{8}|rgba?\([\d\s.,%]+\))$/i.test(color)
? color
: null
}
export function safeExternalUrl(url?: string | null): string {
if (!url) return '#'
try {
const parsed = new URL(url)
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : '#'
} catch {
return '#'
}
}
@@ -34,6 +34,7 @@ import {
useVIntl,
} from '@modrinth/ui'
import { getPingLevel } from '@modrinth/utils'
import { autoToHTML } from '@sfirew/minecraft-motd-parser'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import type { Component } from 'vue'
@@ -360,10 +361,12 @@ const messages = defineMessages({
}}
</span>
<template #popper>
<div class="flex flex-col gap-1">
<span v-for="player in serverStatus.players?.sample" :key="player.name">
{{ player.name }}
</span>
<div class="flex flex-col gap-1 font-minecraft">
<span
v-for="player in serverStatus.players?.sample"
:key="player.name"
v-html="autoToHTML(player.name)"
/>
</div>
</template>
</Tooltip>
@@ -1,714 +1,265 @@
import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test from 'node:test'
import {
clearHostedSession,
HOSTED_ACCOUNT_REQUIRED_MESSAGE,
hostedCreate,
hostedDefault,
hostedSync,
onHostedPackAttemptStarted,
setInstanceMode,
} from '../helpers/hosted-packs.ts'
import {
openSkinSiteLogin,
receiveSkinSiteMessage,
requestSkinSiteDownloadToken,
requestSkinSiteLuck,
requestSkinSitePlayers,
requestSkinSiteSkinUpdate,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteFrameUrl,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from './skin-site-session.ts'
import { forgetHostedCreation, useHostedCreation } from './useHostedCreation.ts'
import { useHostedSync } from './useHostedSync.ts'
test('reconnecting the same skin site frame waits for verification instead of reporting signed out', () => {
const frame = { postMessage() {} } as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
resetSkinSiteSession()
setSkinSiteFrame(frame)
assert.equal(skinSiteStatus.value, 'checking')
setSkinSiteFrame(null)
resetSkinSiteSession()
type FetchHandler = (url: string, options: RequestInit) => Promise<Response>
const testGlobal = globalThis as unknown as {
skinSiteTestFetch: FetchHandler
skinSiteTestHead: (texture: string) => Promise<string>
}
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '@/helpers/skin-site-render') {
const renderer = new URL('../helpers/skin-site-render.ts', import.meta.url).href
return {
url: `data:text/javascript,${encodeURIComponent(`export { fetchImageAsDataUrl } from ${JSON.stringify(renderer)}; export const renderSkinHead=texture=>globalThis.skinSiteTestHead(texture)`)}`,
shortCircuit: true,
}
}
if (specifier === '@tauri-apps/plugin-http') {
return {
url: 'data:text/javascript,export const fetch=(...args)=>globalThis.skinSiteTestFetch(...args)',
shortCircuit: true,
}
}
if (specifier.startsWith('@/')) {
return nextResolve(new URL(`../${specifier.slice(2)}.ts`, import.meta.url).href, context)
}
return nextResolve(specifier, context)
},
})
const session = await import('./skin-site-session.ts')
hooks.deregister()
test('hosted installation sends the JWT to native commands while Local needs no session', async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const calls: Array<{ command: string; args: Record<string, unknown> }> = []
let syncResponse: (() => Promise<unknown>) | undefined
let createdId = 'instance'
let instanceExists = false
let tokenRequests = 0
let accounts: Array<{
account_type: string
yggdrasil?: { api_root: string }
}> = [{ account_type: 'microsoft' }]
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type !== 'starlight-pack-token-request') return
tokenRequests++
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'site.jwt.secret',
},
} as MessageEvent,
frame,
)
},
} as unknown as Window
Object.defineProperty(globalThis, 'window', {
const user = { uuid: 'owner', username: 'Player', permissions: [] }
const ok = (payload: unknown) => Response.json({ payload })
const storage = new Map<string, string>()
const tokenKey = 'starlight-skin-site-token'
let calls: Array<{ path: string; token: string | undefined }> = []
let respond: FetchHandler
test.beforeEach(() => {
storage.clear()
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
__TAURI_INTERNALS__: {
async invoke(command: string, args: Record<string, unknown>) {
calls.push({ command, args })
if (command === 'plugin:install|hosted_has_game_account')
return accounts.some(
(account) =>
account.account_type === 'microsoft' ||
(account.account_type === 'yggdrasil' &&
account.yggdrasil?.api_root.replace(/\/+$/, '') ===
'https://skin.starlight.cool/yggdrasil'),
)
if (command === 'plugin:install|hosted_sync' && syncResponse) return syncResponse()
if (command === 'plugin:instance|instance_get')
return instanceExists ? { id: args.instanceId } : null
return command === 'plugin:install|hosted_create' ? createdId : null
},
},
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
})
try {
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site', username: 'Site' },
},
} as MessageEvent,
frame,
)
await hostedDefault()
assert.equal(await hostedCreate(), 'instance')
await hostedSync('instance')
await setInstanceMode('instance', 'starlight')
assert.equal(tokenRequests, 4)
assert.deepEqual(
calls
.map(({ command }) => command)
.filter(
(command) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
),
[
'hosted_set_session',
'hosted_default',
'hosted_set_session',
'hosted_create',
'hosted_set_session',
'hosted_sync',
'hosted_set_session',
'hosted_set_instance_mode',
].map((name) => `plugin:install|${name}`),
)
for (const call of calls.filter(({ command }) => command.endsWith('hosted_set_session'))) {
assert.equal(call.args.token, 'site.jwt.secret')
}
let rejectSync!: (error: unknown) => void
let syncCalls = 0
syncResponse = () => {
syncCalls++
return new Promise((_, reject) => {
rejectSync = reject
})
}
const originalPage = useHostedSync(() => 'shared-progress-instance')
const pendingSync = originalPage.sync()
const reopenedPage = useHostedSync(() => 'shared-progress-instance')
assert.equal(reopenedPage.busy.value, true)
assert.equal(useHostedSync(() => 'other-instance').busy.value, false)
await reopenedPage.sync()
await new Promise((resolve) => setImmediate(resolve))
assert.equal(syncCalls, 1)
rejectSync({ message: 'download interrupted' })
await pendingSync
assert.equal(reopenedPage.busy.value, false)
assert.match(reopenedPage.error.value, /download interrupted/)
assert.equal(
useHostedSync(() => 'shared-progress-instance').error.value,
reopenedPage.error.value,
)
syncResponse = async () => ({ revision: 'new-version' })
await reopenedPage.sync()
assert.equal(originalPage.error.value, '')
assert.deepEqual(originalPage.result.value, { revision: 'new-version' })
const creation = useHostedCreation()
createdId = 'deleted-instance'
syncResponse = async () => {
throw new Error('download failed')
}
await creation.install()
assert.equal(creation.createdInstance.value, 'deleted-instance')
assert.match(creation.installError.value, /download failed/)
createdId = 'replacement-instance'
syncResponse = async () => ({
version: '1',
downloadedBytes: 0,
changedFiles: 0,
preservedFiles: [],
})
const retryCallStart = calls.length
assert.equal(await creation.install(), 'replacement-instance')
assert.equal(calls[retryCallStart].command, 'plugin:instance|instance_get')
assert.equal(
calls.filter((call) => call.command.endsWith('|hosted_sync')).at(-1)?.args.instanceId,
'replacement-instance',
)
assert.equal(creation.installError.value, '')
forgetHostedCreation('unrelated-instance')
assert.equal(creation.completed.value, true)
forgetHostedCreation('replacement-instance')
assert.equal(creation.createdInstance.value, undefined)
assert.equal(creation.completed.value, false)
createdId = 'third-instance'
syncResponse = () =>
new Promise((_, reject) => {
rejectSync = reject
})
const deletedDuringSync = creation.install()
await new Promise((resolve) => setImmediate(resolve))
forgetHostedCreation('third-instance')
rejectSync(new Error('Unknown instance'))
await deletedDuringSync
assert.equal(creation.installError.value, '')
assert.equal(creation.createdInstance.value, undefined)
createdId = 'existing-instance'
syncResponse = async () => {
throw new Error('download interrupted')
}
await creation.install()
instanceExists = true
syncResponse = async () => ({
version: '1',
downloadedBytes: 0,
changedFiles: 0,
preservedFiles: [],
})
const createCount = calls.filter((call) => call.command.endsWith('|hosted_create')).length
assert.equal(await creation.install(), 'existing-instance')
assert.equal(
calls.filter((call) => call.command.endsWith('|hosted_create')).length,
createCount,
)
creation.acknowledge('existing-instance')
const requestsBeforeLogout = tokenRequests
resetSkinSiteSession()
await clearHostedSession()
assert.equal(calls.at(-1)!.args.token, null)
await setInstanceMode('instance', 'local')
assert.equal(tokenRequests, requestsBeforeLogout)
const attempts: string[] = []
const stopListening = onHostedPackAttemptStarted((id) => attempts.push(id))
accounts = []
const installCallsBeforeAccountFailure = calls.filter(
({ command }) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
).length
await assert.rejects(hostedSync('failed-instance'), {
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
})
await assert.rejects(hostedCreate(), {
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
})
assert.deepEqual(attempts, [])
assert.equal(
calls.filter(
({ command }) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
).length,
installCallsBeforeAccountFailure,
)
const unauthenticatedCreation = useHostedCreation()
await unauthenticatedCreation.install()
assert.equal(unauthenticatedCreation.installError.value, HOSTED_ACCOUNT_REQUIRED_MESSAGE)
assert.equal(unauthenticatedCreation.createdInstance.value, undefined)
accounts = [{ account_type: 'microsoft' }]
const unauthenticatedRetry = hostedSync('failed-instance')
await assert.rejects(unauthenticatedRetry)
assert.deepEqual(attempts, ['failed-instance'])
stopListening()
await assert.rejects(hostedSync('failed-instance'))
assert.equal(attempts.length, 1)
await assert.rejects(hostedCreate(), /无需选择玩家/)
} finally {
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else Reflect.deleteProperty(globalThis, 'window')
calls = []
respond = async (url) => {
if (url.endsWith('/user/refresh')) return ok({ username: 'Player', jwt: 'fresh-token' })
if (url.endsWith('/user')) return ok(user)
return ok([])
}
testGlobal.skinSiteTestFetch = async (url, options = {}) => {
calls.push({
path: new URL(url).pathname,
token: (options.headers as Record<string, string>)?.Authorization,
})
return respond(url, options)
}
session.resetSkinSiteSession()
})
test('pack download gets the site JWT without choosing a player and rejects stale replies', async () => {
const sent: Array<{ type: string; requestId: string }> = []
const frame = {
postMessage(data: { type: string; requestId: string }) {
sent.push(data)
},
} as unknown as Window
const receive = (data: unknown, origin = SKIN_SITE_ORIGIN) =>
receiveSkinSiteMessage(
{
origin,
source: frame,
data,
} as MessageEvent,
frame,
)
resetSkinSiteSession()
setSkinSiteFrame(frame)
await assert.rejects(requestSkinSiteDownloadToken(), /无需选择玩家/)
receive({
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site-user', username: 'Site' },
})
const token = requestSkinSiteDownloadToken()
const requestId = sent.at(-1)!.requestId
const reply = { type: 'starlight-pack-token-result', requestId, token: 'site.jwt.secret' }
assert.equal(receive(reply, 'https://evil.example'), false)
assert.equal(receive(reply), true)
assert.equal(await token, 'site.jwt.secret')
assert.equal(skinSitePlayers.value.length, 0)
assert.equal(JSON.stringify(skinSiteUser.value).includes('secret'), false)
const stale = requestSkinSiteDownloadToken()
const staleId = sent.at(-1)!.requestId
const rejected = assert.rejects(stale, /登录状态已变化/)
resetSkinSiteSession()
await rejected
assert.equal(receive({ ...reply, requestId: staleId }), false)
setSkinSiteFrame(null)
test.afterEach(async () => {
session.signOutSkinSite()
await new Promise((resolve) => setImmediate(resolve))
Reflect.deleteProperty(globalThis, 'localStorage')
})
test('skin site session accepts only the embedded origin and window, and redirects after verification', () => {
const frame = {} as Window
const message = (status: string, user?: unknown) =>
({
origin: SKIN_SITE_ORIGIN,
source: frame,
data: { type: 'starlight-skin-session', status, user },
}) as MessageEvent
resetSkinSiteSession()
openSkinSiteLogin()
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/login`)
const signedIn = message('signed-in', {
uuid: 'test-user',
username: '测试用户',
jwt: 'must-not-copy',
})
assert.equal(
receiveSkinSiteMessage({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame),
false,
)
assert.equal(receiveSkinSiteMessage(signedIn, {} as Window), false)
assert.equal(receiveSkinSiteMessage(message('signed-in', {}), frame), false)
assert.equal(skinSiteUser.value, null)
assert.equal(receiveSkinSiteMessage(signedIn, frame), true)
assert.deepEqual(skinSiteUser.value, { uuid: 'test-user', username: '测试用户' })
assert.equal(skinSiteStatus.value, 'signed-in')
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/profile`)
assert.equal(receiveSkinSiteMessage(message('checking'), frame), true)
assert.equal(skinSiteUser.value, null)
receiveSkinSiteMessage(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
assert.deepEqual(skinSiteUser.value, { uuid: 'second', username: '另一个账号' })
receiveSkinSiteMessage(message('signed-out'), frame)
assert.equal(skinSiteUser.value, null)
assert.equal(skinSiteStatus.value, 'signed-out')
receiveSkinSiteMessage(message('error'), frame)
assert.equal(skinSiteStatus.value, 'error')
resetSkinSiteSession()
})
test('luck requests use only the connected skin site frame and validate its result', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
const sessionMessage = {
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'lucky-user', username: 'Lucky' },
},
} as MessageEvent
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(sessionMessage, frame)
const automaticPlayersRequest = sent.at(-1)?.data as { type: string; requestId: string }
assert.equal(automaticPlayersRequest.type, 'starlight-skin-players-request')
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticPlayersRequest.requestId,
ok: true,
players: [],
},
} as MessageEvent,
frame,
)
const result = requestSkinSiteLuck()
assert.equal(sent.at(-1)?.targetOrigin, SKIN_SITE_ORIGIN)
const request = sent.at(-1)?.data as { type: string; requestId: string }
assert.equal(request.type, 'starlight-skin-luck-request')
assert.match(request.requestId, /^skin-luck-\d+-\d+$/)
assert.equal(
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-luck-result',
requestId: request.requestId,
ok: true,
luck: 88,
},
} as MessageEvent,
frame,
),
true,
)
assert.equal(await result, 88)
setSkinSiteFrame(null)
resetSkinSiteSession()
})
test('player requests validate identities and replace the collection atomically', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'skin-user', username: 'Skin User' },
},
} as MessageEvent,
frame,
)
const automaticRequest = sent.at(-1)?.data as { requestId: string }
assert.equal(skinSitePlayersStatus.value, 'checking')
assert.equal(
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'ready',
headDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
skinDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
model: 'slim',
},
{
uuid: 'fedcba9876543210fedcba9876543210',
name: 'NoSkinPlayer',
isMojang: false,
skinState: 'empty',
},
{ uuid: 'invalid', name: 'Ignored', isMojang: false },
],
},
} as MessageEvent,
frame,
),
true,
)
assert.deepEqual(skinSitePlayers.value, [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'ready',
headDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
skinDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
model: 'slim',
},
{
uuid: 'fedcba9876543210fedcba9876543210',
name: 'NoSkinPlayer',
isMojang: false,
skinState: 'empty',
},
test('startup renews the stored JWT before loading the user with the new token', async () => {
storage.set(tokenKey, 'old-token')
await session.initSkinSiteSession()
assert.deepEqual(calls.slice(0, 2), [
{ path: '/starlight/user/refresh', token: 'Bearer old-token' },
{ path: '/starlight/user', token: 'Bearer fresh-token' },
])
assert.equal(skinSitePlayersStatus.value, 'ready')
const transientFailure = requestSkinSitePlayers()
const transientFailureRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: transientFailureRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne Renamed',
isMojang: false,
skinState: 'error',
},
],
},
} as MessageEvent,
frame,
)
assert.equal((await transientFailure)[0].skinState, 'ready')
assert.equal(skinSitePlayers.value[0].name, 'PlayerOne Renamed')
assert.equal(skinSitePlayers.value[0].headDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
const retry = requestSkinSitePlayers()
const retryRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: retryRequest.requestId,
ok: true,
players: [],
},
} as MessageEvent,
frame,
)
assert.deepEqual(await retry, [])
assert.deepEqual(skinSitePlayers.value, [])
setSkinSiteFrame(null)
resetSkinSiteSession()
assert.equal(storage.get(tokenKey), 'fresh-token')
assert.equal(session.skinSiteStatus.value, 'signed-in')
})
test('skin updates are restricted to known non-Mojang players and validated results', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'skin-user', username: 'Skin User' },
},
} as MessageEvent,
frame,
)
const automaticRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'empty',
},
],
},
} as MessageEvent,
frame,
)
const result = requestSkinSiteSkinUpdate(
'0123456789abcdef0123456789abcdef',
'data:image/png;base64,SEVBRERBVEE=',
'slim',
)
const request = sent.at(-1)?.data as { type: string; requestId: string; playerId: string }
assert.equal(request.type, 'starlight-skin-update-request')
assert.equal(request.playerId, '0123456789abcdef0123456789abcdef')
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: { type: 'starlight-skin-update-result', requestId: request.requestId, ok: true },
} as MessageEvent,
frame,
)
await result
await assert.rejects(
requestSkinSiteSkinUpdate(
'fedcba9876543210fedcba9876543210',
'data:image/png;base64,SEVBRERBVEE=',
'default',
),
)
setSkinSiteFrame(null)
resetSkinSiteSession()
test('simultaneous startup consumers share one refresh', async () => {
storage.set(tokenKey, 'old-token')
await Promise.all([
session.initSkinSiteSession(),
session.initSkinSiteSession(),
session.waitForSkinSiteSession(),
])
assert.equal(calls.filter((call) => call.path.endsWith('/refresh')).length, 1)
})
test('hosted create auto-authenticates the first skin site player when no game account exists', async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
const calls: Array<{ command: string; args: Record<string, unknown> }> = []
let accounts: Array<{
account_type: string
yggdrasil?: { api_root: string }
}> = []
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type === 'starlight-pack-token-request') {
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'site.jwt.secret',
},
} as MessageEvent,
frame,
)
return
}
if (data.type === 'starlight-skin-players-request') {
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: data.requestId,
ok: true,
players: [
{
uuid: '11111111-1111-1111-1111-111111111111',
name: 'AutoPlayer',
isMojang: false,
skinState: 'empty',
},
],
},
} as MessageEvent,
frame,
)
}
},
} as unknown as Window
Object.defineProperty(globalThis, 'window', {
test('startup without a stored token does not make authenticated requests', async () => {
await session.initSkinSiteSession()
assert.equal(session.skinSiteStatus.value, 'signed-out')
assert.deepEqual(calls, [])
})
test('an expired token is cleared when renewal is rejected', async () => {
storage.set(tokenKey, 'expired-token')
respond = async () => Response.json({ errorMessage: 'Expired' }, { status: 401 })
await session.initSkinSiteSession()
assert.equal(storage.has(tokenKey), false)
assert.equal(session.skinSiteStatus.value, 'signed-out')
assert.equal(calls.length, 1)
})
test('temporary renewal failure preserves the token and allows startup retry', async () => {
storage.set(tokenKey, 'old-token')
respond = async () => Response.json({ errorMessage: 'Unavailable' }, { status: 503 })
await session.initSkinSiteSession()
assert.equal(session.skinSiteStatus.value, 'error')
assert.equal(storage.get(tokenKey), 'old-token')
respond = async (url) =>
url.endsWith('/refresh')
? ok({ jwt: 'fresh-token' })
: url.endsWith('/user')
? ok(user)
: ok([])
await session.initSkinSiteSession()
assert.equal(session.skinSiteStatus.value, 'signed-in')
})
test('a late refresh cannot restore a session after sign-out', async () => {
storage.set(tokenKey, 'old-token')
let finish!: (response: Response) => void
respond = () =>
new Promise((resolve) => {
finish = resolve
})
const initializing = session.initSkinSiteSession()
await new Promise((resolve) => setImmediate(resolve))
session.signOutSkinSite()
finish(ok({ username: 'Player', jwt: 'late-token' }))
await initializing
assert.equal(storage.has(tokenKey), false)
assert.equal(session.skinSiteStatus.value, 'signed-out')
assert.equal(session.skinSiteUser.value, null)
})
test('a malformed refresh response never replaces the saved token', async () => {
storage.set(tokenKey, 'old-token')
respond = async () => ok({ username: 'Player' })
await session.initSkinSiteSession()
assert.equal(storage.get(tokenKey), 'old-token')
assert.equal(session.skinSiteStatus.value, 'error')
})
test('a failed sign-in attempt does not invalidate a pending startup renewal', async () => {
storage.set(tokenKey, 'old-token')
let finish!: (response: Response) => void
respond = async (url) => {
if (url.endsWith('/refresh'))
return new Promise((resolve) => {
finish = resolve
})
if (url.endsWith('/login'))
return Response.json({ errorMessage: 'Invalid password' }, { status: 401 })
return url.endsWith('/user') ? ok(user) : ok([])
}
const initializing = session.initSkinSiteSession()
await new Promise((resolve) => setImmediate(resolve))
await session.signInSkinSite({ username: 'Player', password: 'wrong', clientId: 'captcha' })
finish(ok({ username: 'Player', jwt: 'fresh-token' }))
await initializing
assert.equal(session.skinSiteStatus.value, 'signed-in')
assert.equal(storage.get(tokenKey), 'fresh-token')
})
test('rejection of an old startup token does not discard a new successful login', async () => {
storage.set(tokenKey, 'expired-token')
let finishRefresh!: (response: Response) => void
let finishLogin!: (response: Response) => void
respond = async (url) => {
if (url.endsWith('/refresh'))
return new Promise((resolve) => {
finishRefresh = resolve
})
if (url.endsWith('/login'))
return new Promise((resolve) => {
finishLogin = resolve
})
return url.endsWith('/user') ? ok(user) : ok([])
}
const initializing = session.initSkinSiteSession()
const signingIn = session.signInSkinSite({
username: 'Player',
password: 'valid',
clientId: 'captcha',
})
await new Promise((resolve) => setImmediate(resolve))
finishRefresh(Response.json({ errorMessage: 'Expired' }, { status: 401 }))
await initializing
finishLogin(ok({ username: 'Player', jwt: 'login-token' }))
await signingIn
assert.equal(session.skinSiteStatus.value, 'signed-in')
assert.equal(storage.get(tokenKey), 'login-token')
})
test('explicit sign-out discards a late successful login', async () => {
let finishLogin!: (response: Response) => void
respond = () =>
new Promise((resolve) => {
finishLogin = resolve
})
const signingIn = session.signInSkinSite({
username: 'Player',
password: 'valid',
clientId: 'captcha',
})
await new Promise((resolve) => setImmediate(resolve))
session.signOutSkinSite()
finishLogin(ok({ username: 'Player', jwt: 'late-login-token' }))
await signingIn
assert.equal(session.skinSiteStatus.value, 'signed-out')
assert.equal(storage.has(tokenKey), false)
})
test('player heads are cropped from one downloaded local texture rather than a cross-origin image', async (t) => {
const textureUrl = 'https://skin.starlight.cool/yggdrasil/textures/test'
const textureData = 'data:image/png;base64,iVBORw=='
const previousReader = Object.getOwnPropertyDescriptor(globalThis, 'FileReader')
Object.defineProperty(globalThis, 'FileReader', {
configurable: true,
value: {
__TAURI_INTERNALS__: {
async invoke(command: string, args: Record<string, unknown>) {
calls.push({ command, args })
if (command === 'plugin:install|hosted_has_game_account') {
return accounts.some(
(account) =>
account.account_type === 'microsoft' ||
(account.account_type === 'yggdrasil' &&
account.yggdrasil?.api_root.replace(/\/+$/, '') ===
'https://skin.starlight.cool/yggdrasil'),
)
}
if (command === 'plugin:auth|login_skin_site_player') {
accounts = [
{
account_type: 'yggdrasil',
yggdrasil: { api_root: 'https://skin.starlight.cool/yggdrasil' },
},
]
return null
}
if (command === 'plugin:instance|instance_get') return null
return command === 'plugin:install|hosted_create' ? 'auto-instance' : null
},
},
value: class {
result: string | null = null
onload?: () => void
readAsDataURL(blob: Blob) {
void blob.arrayBuffer().then((bytes) => {
this.result = `data:${blob.type};base64,${Buffer.from(bytes).toString('base64')}`
this.onload?.()
})
}
},
})
try {
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site-user', username: 'Site' },
},
} as MessageEvent,
frame,
)
await requestSkinSitePlayers().catch(() => {})
assert.equal(await hostedCreate(), 'auto-instance')
assert.ok(
calls.some(({ command }) => command === 'plugin:auth|login_skin_site_player'),
'expected automatic player authentication',
)
} finally {
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else delete (globalThis as { window?: unknown }).window
t.after(() => {
if (previousReader) Object.defineProperty(globalThis, 'FileReader', previousReader)
else Reflect.deleteProperty(globalThis, 'FileReader')
})
t.mock.method(globalThis, 'fetch', async () => {
throw new TypeError('Cross-origin image request blocked')
})
testGlobal.skinSiteTestHead = async (source) => {
if (source !== textureData) throw new Error('Cross-origin canvas image blocked')
return 'data:image/png;base64,local-head'
}
respond = async (url) => {
if (url === textureUrl)
return new Response(new Uint8Array([137, 80, 78, 71]), {
headers: { 'Content-Type': 'image/png' },
})
if (url.endsWith('/player/skin/player')) return ok({ skin: textureUrl, model: 'slim' })
if (url.endsWith('/player')) return ok([{ uuid: 'player', name: 'Player', isMojang: false }])
return url.endsWith('/user') ? ok(user) : ok({ jwt: 'token' })
}
storage.set(tokenKey, 'old-token')
await session.initSkinSiteSession()
const [player] = await session.requestSkinSitePlayers()
assert.equal(player.skinState, 'ready')
assert.equal(player.skinDataUrl, textureData)
assert.equal(player.headDataUrl, 'data:image/png;base64,local-head')
assert.equal(player.model, 'slim')
assert.equal(calls.filter((call) => call.path === '/yggdrasil/textures/test').length, 1)
})
@@ -1,7 +1,25 @@
import { readonly, ref, watch } from 'vue'
import { readonly, ref } from 'vue'
import {
clearSkinSiteToken,
getSkinSiteToken,
setSkinSiteToken,
skinSiteCurrentUser,
skinSiteLogin,
skinSiteLuck,
skinSitePlayerList,
type SkinSitePlayerSkin,
skinSitePlayerSkin,
skinSiteRefresh,
type SkinSiteResponse,
skinSiteUploadTexture,
type SkinSiteUser as ApiSkinSiteUser,
} from '@/helpers/skin-site-api'
import { fetchImageAsDataUrl, renderSkinHead } from '@/helpers/skin-site-render'
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
export type SkinSiteUser = { uuid: string; username: string }
export type SkinSiteUser = { uuid: string; username: string; permissions: string[] }
export type SkinSiteStatus = 'checking' | 'signed-out' | 'signed-in' | 'error'
export type SkinSitePlayer = {
uuid: string
@@ -15,152 +33,301 @@ export type SkinSitePlayer = {
export type SkinSitePlayersStatus = 'idle' | 'checking' | 'ready' | 'error'
const user = ref<SkinSiteUser | null>(null)
const status = ref<SkinSiteStatus>('signed-out')
const status = ref<SkinSiteStatus>('checking')
const players = ref<SkinSitePlayer[]>([])
const playersStatus = ref<SkinSitePlayersStatus>('idle')
const selectedPlayerId = ref<string | null>(null)
const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`)
let connectedFrame: Window | null = null
let luckRequestSequence = 0
let playersRequestSequence = 0
let skinUpdateRequestSequence = 0
let packTokenRequestSequence = 0
const pendingPackTokens = new Map<
string,
{
resolve: (token: string) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
>()
function rejectPendingPackTokens() {
for (const request of pendingPackTokens.values()) {
clearTimeout(request.timeout)
request.reject(new Error('StarLight 登录状态已变化,请重试。'))
}
pendingPackTokens.clear()
}
export function requestSkinSiteDownloadToken(): Promise<string> {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(
new Error('请先在启动器中登录 StarLight 皮肤站,再下载整合包。无需选择玩家。'),
)
}
const requestId = `pack-token-${Date.now()}-${++packTokenRequestSequence}`
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pendingPackTokens.delete(requestId)
reject(new Error('获取 StarLight 登录凭据超时,请重试。'))
}, 10_000)
pendingPackTokens.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-pack-token-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch {
clearTimeout(timeout)
pendingPackTokens.delete(requestId)
reject(new Error('无法读取 StarLight 登录状态,请重新登录。'))
}
})
}
type PendingLuckRequest = {
resolve: (luck: number) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingLuckRequests = new Map<string, PendingLuckRequest>()
type PendingPlayersRequest = {
resolve: (players: SkinSitePlayer[]) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingPlayersRequests = new Map<string, PendingPlayersRequest>()
type PendingSkinUpdateRequest = {
resolve: () => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingSkinUpdateRequests = new Map<string, PendingSkinUpdateRequest>()
function isSafePngDataUrl(value: unknown, maxLength: number) {
return (
typeof value === 'string' &&
value.length <= maxLength &&
/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i.test(value)
)
}
function hasSafeSkinHead(player: SkinSitePlayer) {
if (!['ready', 'empty', 'error'].includes(player.skinState)) return false
if (player.skinState !== 'ready') return player.headDataUrl === undefined
return (
isSafePngDataUrl(player.headDataUrl, 100_000) &&
(player.skinDataUrl === undefined || isSafePngDataUrl(player.skinDataUrl, 2_000_000)) &&
(player.model === undefined || player.model === 'default' || player.model === 'slim')
)
}
let playersRequest: Promise<SkinSitePlayer[]> | null = null
export const skinSiteUser = readonly(user)
export const skinSiteStatus = readonly(status)
export const skinSitePlayers = readonly(players)
export const skinSitePlayersStatus = readonly(playersStatus)
export const selectedSkinSitePlayerId = readonly(selectedPlayerId)
export const skinSiteFrameUrl = readonly(frameUrl)
export async function waitForSkinSiteSession() {
if (status.value !== 'checking') return
await new Promise<void>((resolve, reject) => {
const stop = watch(status, next => {
if (next === 'checking') return
clearTimeout(timer)
stop()
resolve()
function toUser(raw: ApiSkinSiteUser): SkinSiteUser {
return {
uuid: raw.uuid,
username: raw.username,
permissions: Array.isArray(raw.permissions) ? raw.permissions : [],
}
}
function clearSessionState() {
user.value = null
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
let initialized: Promise<void> | null = null
let sessionRevision = 0
let loginRevision = 0
let refreshRequest: { token: string; revision: number; promise: Promise<boolean> } | null = null
/** Share renewals and discard results from a session that has since changed. */
function renewSkinSiteToken(): Promise<boolean> {
const token = getSkinSiteToken()
if (!token) return Promise.resolve(false)
const revision = sessionRevision
if (refreshRequest?.token === token && refreshRequest.revision === revision) {
return refreshRequest.promise
}
const request = {
token,
revision,
promise: (async () => {
let result: SkinSiteResponse<{ username: string; jwt: string }>
try {
result = await skinSiteRefresh()
} catch {
return false
}
if (revision !== sessionRevision || getSkinSiteToken() !== token) return false
if (result.success) {
if (typeof result.payload?.jwt !== 'string' || !result.payload.jwt.trim()) return false
setSkinSiteToken(result.payload.jwt)
return true
}
if (result.errorCode === 401 || result.errorCode === 403) invalidateSkinSiteSession()
return false
})(),
}
refreshRequest = request
const clearRequest = () => {
if (refreshRequest === request) refreshRequest = null
}
void request.promise.then(clearRequest, clearRequest)
return request.promise
}
/**
* Renew the saved JWT on each launcher startup before loading the user.
* Safe to call repeatedly;
* the first call performs the actual work and later ones reuse it.
*/
export function initSkinSiteSession(): Promise<void> {
if (initialized) return initialized
const revision = sessionRevision
initialized = (async () => {
const token = getSkinSiteToken()
if (!token) {
status.value = 'signed-out'
return
}
status.value = 'checking'
if (!(await renewSkinSiteToken())) {
if (revision === sessionRevision) status.value = 'error'
return
}
if (revision !== sessionRevision) return
const result = await skinSiteCurrentUser()
if (revision !== sessionRevision) return
if (result.success) {
user.value = toUser(result.payload)
status.value = 'signed-in'
void requestSkinSitePlayers().catch(() => {})
} else if (result.errorCode === 401 || result.errorCode === 403) {
clearSkinSiteToken()
clearSessionState()
status.value = 'signed-out'
} else {
status.value = 'error'
}
})()
.catch(() => {
if (revision === sessionRevision) status.value = 'error'
})
const timer = setTimeout(() => {
stop()
reject(new Error('皮肤站登录状态仍在检查,请稍后重试。'))
}, 10_000)
})
.finally(() => {
if (revision === sessionRevision && status.value === 'error') initialized = null
})
return initialized
}
function rejectPendingLuckRequests(message: string) {
for (const request of pendingLuckRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
/** Force a re-check (e.g. after the user signs in from another view). */
export function reloadSkinSiteSession(): Promise<void> {
sessionRevision++
initialized = null
return initSkinSiteSession()
}
export async function signInSkinSite(payload: {
username: string
password: string
clientId: string
}): Promise<SkinSiteResponse<{ username: string; jwt: string }>> {
const attemptRevision = ++loginRevision
const result = await skinSiteLogin(payload)
if (result.success && attemptRevision === loginRevision) {
const revision = ++sessionRevision
initialized = null
setSkinSiteToken(result.payload.jwt)
const current = await skinSiteCurrentUser()
if (revision !== sessionRevision) return result
if (current.success) {
user.value = toUser(current.payload)
status.value = 'signed-in'
void requestSkinSitePlayers().catch(() => {})
} else {
user.value = { uuid: '', username: result.payload.username, permissions: [] }
status.value = 'signed-in'
}
}
pendingLuckRequests.clear()
return result
}
function rejectPendingPlayersRequests(message: string) {
for (const request of pendingPlayersRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
/** Reject an old credential without cancelling a newer explicit login attempt. */
function invalidateSkinSiteSession() {
sessionRevision++
refreshRequest = null
clearSkinSiteToken()
clearSessionState()
status.value = 'signed-out'
initialized = null
}
export function signOutSkinSite() {
loginRevision++
invalidateSkinSiteSession()
}
/** Refresh the JWT; returns true when a new token was stored. */
export async function refreshSkinSiteSession(): Promise<boolean> {
if (status.value !== 'signed-in') return false
return renewSkinSiteToken()
}
// ---------------------------------------------------------------------------
// Compatibility shims for the old iframe-based API
// ---------------------------------------------------------------------------
/** @deprecated The session is no longer driven by an iframe. No-op. */
export function setSkinSiteFrame(_frame: Window | null) {}
/** @deprecated The session is no longer driven by an iframe. No-op. */
export function receiveSkinSiteMessage(_event: MessageEvent, _frame: Window | null): boolean {
return false
}
/** @deprecated Use the in-app login view instead. No-op. */
export function openSkinSiteLogin() {}
export function resetSkinSiteSession() {
loginRevision++
sessionRevision++
refreshRequest = null
clearSessionState()
status.value = getSkinSiteToken() ? 'checking' : 'signed-out'
initialized = null
}
/** Resolves once the initial session check has settled. */
export async function waitForSkinSiteSession(): Promise<void> {
await initSkinSiteSession()
}
// ---------------------------------------------------------------------------
// Download token (the launcher's hosted pack downloader)
// ---------------------------------------------------------------------------
/**
* The hosted pack downloader authenticates with the skin site JWT. We hold it
* locally now, so this simply surfaces it (refreshing once if it is missing).
*/
export async function requestSkinSiteDownloadToken(): Promise<string> {
if (status.value !== 'signed-in') {
await initSkinSiteSession()
}
pendingPlayersRequests.clear()
}
function rejectPendingSkinUpdateRequests(message: string) {
for (const request of pendingSkinUpdateRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
if (status.value !== 'signed-in') {
throw new Error('请先在启动器中登录 StarLight 皮肤站,再下载整合包。')
}
pendingSkinUpdateRequests.clear()
let token = getSkinSiteToken()
if (!token) {
if (await refreshSkinSiteSession()) token = getSkinSiteToken()
}
if (!token) throw new Error('StarLight 登录凭据不可用,请重新登录。')
return token
}
export function setSkinSiteFrame(frame: Window | null) {
if (frame && !user.value) status.value = 'checking'
if (connectedFrame === frame) return
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site connection changed.')
rejectPendingPlayersRequests('The skin site connection changed.')
rejectPendingSkinUpdateRequests('The skin site connection changed.')
connectedFrame = frame
// ---------------------------------------------------------------------------
// Players
// ---------------------------------------------------------------------------
async function buildPlayer(entry: {
uuid: string
name: string
isMojang: boolean
}): Promise<SkinSitePlayer> {
const base: SkinSitePlayer = {
uuid: entry.uuid,
name: entry.name,
isMojang: entry.isMojang,
skinState: 'empty',
}
const skinResult = await skinSitePlayerSkin(entry.uuid)
if (!skinResult.success) return { ...base, skinState: 'error' }
const texture: SkinSitePlayerSkin = skinResult.payload ?? {}
if (!texture.skin) return base
try {
const skinDataUrl = await fetchImageAsDataUrl(texture.skin)
const headDataUrl = await renderSkinHead(skinDataUrl)
return {
...base,
skinState: 'ready',
headDataUrl,
skinDataUrl,
model: texture.model === 'slim' ? 'slim' : 'default',
}
} catch {
return { ...base, skinState: 'error' }
}
}
export function requestSkinSitePlayers(): Promise<SkinSitePlayer[]> {
if (playersRequest) return playersRequest
if (status.value !== 'signed-in' || !user.value) {
// Keep the status meaningful so the UI does not spin forever: an
// unauthenticated request means "not loaded yet", not "loading".
playersStatus.value = 'idle'
return Promise.reject(new Error('请先登录 StarLight 皮肤站。'))
}
playersStatus.value = 'checking'
playersRequest = (async () => {
try {
const list = await skinSitePlayerList()
if (!list.success) throw new Error(list.errorMessage)
const built = await Promise.all(list.payload.map(buildPlayer))
players.value = built
if (selectedPlayerId.value && !built.some((p) => p.uuid === selectedPlayerId.value)) {
selectedPlayerId.value = null
}
playersStatus.value = 'ready'
return players.value
} catch (error) {
playersStatus.value = 'error'
throw error
} finally {
playersRequest = null
}
})()
return playersRequest
}
/**
* Drop any in-flight or cached player request and fetch again. Used by views
* that need to recover after a failed load (e.g. signing in from the skin
* page while the first attempt raced the session check).
*/
export function reloadSkinSitePlayers(): Promise<SkinSitePlayer[]> {
playersRequest = null
return requestSkinSitePlayers()
}
export function selectSkinSitePlayer(playerId: string | null) {
@@ -171,267 +338,59 @@ export function selectSkinSitePlayer(playerId: string | null) {
if (players.value.some((player) => player.uuid === playerId)) selectedPlayerId.value = playerId
}
export function openSkinSiteLogin() {
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
}
// ---------------------------------------------------------------------------
// Luck
// ---------------------------------------------------------------------------
export function resetSkinSiteSession() {
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site session ended.')
rejectPendingPlayersRequests('The skin site session ended.')
rejectPendingSkinUpdateRequests('The skin site session ended.')
user.value = null
status.value = 'signed-out'
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
export async function requestSkinSiteLuck(): Promise<number> {
if (status.value !== 'signed-in') throw new Error('请先登录 StarLight 皮肤站。')
const result = await skinSiteLuck()
if (!result.success) throw new Error(result.errorMessage)
return result.payload.luck
}
// ---------------------------------------------------------------------------
// Legacy compatibility
// ---------------------------------------------------------------------------
export function requestSkinSiteSkinUpdate(
/**
* Replace a player's skin texture.
*
* The caller hands us a base64 PNG (what the in-app editor produces); we turn
* it back into a File and upload it through the skin site's texture endpoint.
*/
export async function requestSkinSiteSkinUpdate(
playerId: string,
textureDataUrl: string,
model: 'default' | 'slim',
) {
): Promise<void> {
if (status.value !== 'signed-in' || !user.value) {
throw new Error('请先登录 StarLight 皮肤站。')
}
const player = players.value.find((candidate) => candidate.uuid === playerId)
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before changing a skin.'))
}
if (!player || player.isMojang) {
return Promise.reject(new Error('This skin site player cannot be changed.'))
throw new Error('该玩家无法修改皮肤。')
}
if (!isSafePngDataUrl(textureDataUrl, 2_000_000)) {
return Promise.reject(new Error('The selected skin texture is invalid or too large.'))
if (!/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i.test(textureDataUrl)) {
throw new Error('皮肤图片无效或过大。')
}
const requestId = `skin-update-${Date.now()}-${++skinUpdateRequestSequence}`
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingSkinUpdateRequests.delete(requestId)
reject(new Error('The skin site update timed out.'))
}, 20_000)
pendingSkinUpdateRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{
type: 'starlight-skin-update-request',
requestId,
playerId,
textureDataUrl,
model,
},
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingSkinUpdateRequests.delete(requestId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
const file = await dataUrlToFile(textureDataUrl, `${player.name}.png`)
const result = await skinSiteUploadTexture(playerId, file, 'SKIN', { model })
if (!result.success) throw new Error(result.errorMessage)
const refreshed = await skinSitePlayerSkin(playerId)
if (refreshed.success && refreshed.payload?.skin) {
const updated = await buildPlayer({
uuid: player.uuid,
name: player.name,
isMojang: player.isMojang,
})
players.value = players.value.map((entry) => (entry.uuid === playerId ? updated : entry))
}
}
export function requestSkinSitePlayers() {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before requesting players.'))
}
playersStatus.value = 'checking'
const requestId = `skin-players-${Date.now()}-${++playersRequestSequence}`
return new Promise<SkinSitePlayer[]>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingPlayersRequests.delete(requestId)
playersStatus.value = 'error'
reject(new Error('The skin site player request timed out.'))
}, 30_000)
pendingPlayersRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-skin-players-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingPlayersRequests.delete(requestId)
playersStatus.value = 'error'
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function requestSkinSiteLuck() {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before requesting luck.'))
}
const requestId = `skin-luck-${Date.now()}-${++luckRequestSequence}`
return new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingLuckRequests.delete(requestId)
reject(new Error('The skin site luck request timed out.'))
}, 12_000)
pendingLuckRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-skin-luck-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingLuckRequests.delete(requestId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null) {
if (!frame || event.source !== frame || event.origin !== SKIN_SITE_ORIGIN) return false
const data = event.data
if (!data || typeof data !== 'object') return false
if (data.type === 'starlight-pack-token-result') {
const pending = pendingPackTokens.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingPackTokens.delete(data.requestId)
if (
status.value === 'signed-in' &&
typeof data.token === 'string' &&
data.token.length > 0 &&
data.token.length <= 16_384 &&
!/\s/.test(data.token)
) {
pending.resolve(data.token)
} else pending.reject(new Error('StarLight 登录凭据不可用,请重新登录。'))
return true
}
if (data.type === 'starlight-skin-players-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingPlayersRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingPlayersRequests.delete(data.requestId)
if (data.ok === true && Array.isArray(data.players)) {
const nextPlayers = data.players.filter(
(player: unknown): player is SkinSitePlayer =>
typeof player === 'object' &&
player !== null &&
typeof (player as SkinSitePlayer).uuid === 'string' &&
/^[0-9a-f-]{32,36}$/i.test((player as SkinSitePlayer).uuid) &&
typeof (player as SkinSitePlayer).name === 'string' &&
(player as SkinSitePlayer).name.length > 0 &&
(player as SkinSitePlayer).name.length <= 64 &&
typeof (player as SkinSitePlayer).isMojang === 'boolean' &&
hasSafeSkinHead(player as SkinSitePlayer),
)
const previousPlayers = new Map(players.value.map((player) => [player.uuid, player]))
players.value = nextPlayers.map((player) => {
const previous = previousPlayers.get(player.uuid)
return player.skinState === 'error' && previous?.skinState === 'ready'
? { ...player, ...previous, name: player.name, isMojang: player.isMojang }
: player
})
if (
selectedPlayerId.value &&
!nextPlayers.some((player) => player.uuid === selectedPlayerId.value)
) {
selectedPlayerId.value = null
}
playersStatus.value = 'ready'
pending.resolve(players.value)
} else {
playersStatus.value = 'error'
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site returned an invalid player list.',
),
)
}
return true
}
if (data.type === 'starlight-skin-luck-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingLuckRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingLuckRequests.delete(data.requestId)
if (
data.ok === true &&
typeof data.luck === 'number' &&
Number.isFinite(data.luck) &&
data.luck >= 0 &&
data.luck <= 100
) {
pending.resolve(data.luck)
} else {
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site returned an invalid luck result.',
),
)
}
return true
}
if (data.type === 'starlight-skin-update-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingSkinUpdateRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingSkinUpdateRequests.delete(data.requestId)
if (data.ok === true) pending.resolve()
else {
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site rejected the skin update.',
),
)
}
return true
}
if (data.type !== 'starlight-skin-session') return false
if (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return false
let refreshPlayers = false
if (data.status === 'signed-in') {
if (
!data.user ||
typeof data.user.uuid !== 'string' ||
typeof data.user.username !== 'string' ||
!data.user.uuid ||
!data.user.username ||
data.user.username.length > 256
)
return false
if (user.value?.uuid && user.value.uuid !== data.user.uuid) {
rejectPendingPackTokens()
rejectPendingPlayersRequests('The skin site account changed.')
rejectPendingSkinUpdateRequests('The skin site account changed.')
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
user.value = { uuid: data.user.uuid, username: data.user.username }
if ([`${SKIN_SITE_ORIGIN}/`, `${SKIN_SITE_ORIGIN}/login`].includes(frameUrl.value)) {
frameUrl.value = `${SKIN_SITE_ORIGIN}/profile`
}
refreshPlayers = true
} else {
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site session is unavailable.')
rejectPendingPlayersRequests('The skin site session is unavailable.')
rejectPendingSkinUpdateRequests('The skin site session is unavailable.')
user.value = null
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
status.value = data.status
if (refreshPlayers) void requestSkinSitePlayers().catch(() => {})
return true
async function dataUrlToFile(dataUrl: string, filename: string): Promise<File> {
const response = await fetch(dataUrl)
const blob = await response.blob()
return new File([blob], filename, { type: 'image/png' })
}
@@ -0,0 +1,215 @@
import {
type KeyBinding,
bindingMatchesKeyboardEvent,
bindingMatchesMouseEvent,
bindingMatchesWheelEvent,
useModalStack,
} from '@modrinth/ui'
import { computed, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import {
SCROLL_ACTIONS,
SHORTCUT_ACTIONS,
type ShortcutAction,
} from '@/helpers/shortcut-actions'
import { resolveAllBindings } from '@/helpers/shortcut-bindings'
import { getNavShortcutEnabled } from '@/helpers/nav-shortcut-state'
import { getQuickScrollEnabled } from '@/helpers/scroll-top-state'
import { useTheming } from '@/store/theme'
/** Editing surfaces keep these keys for themselves. */
function isEditableTarget(target: EventTarget | null) {
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
)
}
function scrollsAtOwnLevel(element: Element) {
const { overflowY } = getComputedStyle(element)
return /(auto|scroll|overlay)/.test(overflowY) && element.scrollHeight > element.clientHeight
}
/** A container that scrolls the page itself rather than a widget on it. */
function isPageScroller(element: Element) {
return scrollsAtOwnLevel(element) && element.clientHeight >= window.innerHeight / 2
}
/** Nearest scrolling ancestor, whatever its size. */
function nearestScrollContainer(target: EventTarget | null) {
let element = target instanceof Element ? target : null
while (element) {
if (scrollsAtOwnLevel(element)) return element
element = element.parentElement
}
return null
}
/**
* The page's own scroller. Most pages scroll inside `.app-viewport`, but some
* screens (settings, consoles, studios) turn that element's scrolling off and
* host a full-height container inside it, so fall back to whatever fills the
* middle of the viewport.
*/
function resolvePageScroller() {
const viewport = document.querySelector<HTMLElement>('.app-viewport')
if (!viewport) return null
if (isPageScroller(viewport)) return viewport
const bounds = viewport.getBoundingClientRect()
let element: Element | null = document.elementFromPoint(
bounds.left + bounds.width / 2,
bounds.top + bounds.height / 2,
)
while (element) {
if (isPageScroller(element)) return element
element = element.parentElement
}
return viewport
}
export function setupKeyboardShortcuts() {
const themeStore = useTheming()
const { hasModal } = useModalStack()
const route = useRoute()
const router = useRouter()
const { offline } = useNetworkStatus()
themeStore.quickScrollEnabled = getQuickScrollEnabled()
for (const shortcut of SHORTCUT_ACTIONS) {
if (shortcut.group === 'nav') themeStore[shortcut.enabledField] = getNavShortcutEnabled(shortcut.id)
}
themeStore.shortcutBindings = resolveAllBindings()
/** The combination an action answers to now: recorded if there is one, default otherwise. */
function bindingFor(action: ShortcutAction): KeyBinding {
return themeStore.shortcutBindings[action.id] ?? action.defaultBinding
}
/** Whether an action is switched on and reachable in the current state. */
function shortcutIsAvailable(action: ShortcutAction): boolean {
if (!themeStore[action.enabledField]) return false
return !action.unavailable?.({
worldsTabEnabled: themeStore.featureFlags.worlds_tab,
offline: offline.value,
})
}
function findShortcut<E extends Event>(
event: E,
matches: (binding: KeyBinding, event: E) => boolean,
{ includeDisabled = false }: { includeDisabled?: boolean } = {},
): ShortcutAction | null {
if (hasModal.value) return null
if (isEditableTarget(event.target)) return null
for (const action of SHORTCUT_ACTIONS) {
if (!includeDisabled && !shortcutIsAvailable(action)) continue
if (matches(bindingFor(action), event)) return action
}
return null
}
/** Does what an action is for. Scrolling actions move the page's own scroller. */
function runShortcut(action: ShortcutAction, event: Event) {
if (action.target) {
router.push(action.target(route))
return
}
const focusedContainer = nearestScrollContainer(event.target)
if (focusedContainer && !isPageScroller(focusedContainer)) return
const scroller = resolvePageScroller()
if (!scroller) return
action.applyScroll?.(scroller)
}
function handleScrollShortcutKey(event: KeyboardEvent) {
if (event.isComposing) return
const match = findShortcut(event, bindingMatchesKeyboardEvent, { includeDisabled: true })
if (match?.group !== 'scroll') return
const focusedContainer = nearestScrollContainer(event.target)
if (focusedContainer && !isPageScroller(focusedContainer)) return
const scroller = resolvePageScroller()
if (!scroller) return
event.preventDefault()
if (!shortcutIsAvailable(match)) return
match.applyScroll?.(scroller)
}
function handleNavShortcutKey(event: KeyboardEvent) {
if (event.isComposing) return
const match = findShortcut(event, bindingMatchesKeyboardEvent)
if (match?.group !== 'nav') return
event.preventDefault()
runShortcut(match, event)
}
const hasPointerBindings = computed(() =>
SHORTCUT_ACTIONS.some((action) => bindingFor(action).device === 'mouse'),
)
function handleShortcutMouse(event: MouseEvent) {
if (!hasPointerBindings.value) return
const match = findShortcut(event, bindingMatchesMouseEvent)
if (!match) return
event.preventDefault()
event.stopPropagation()
runShortcut(match, event)
}
function handleShortcutWheel(event: WheelEvent) {
if (!hasPointerBindings.value) return
const match = findShortcut(event, bindingMatchesWheelEvent)
if (!match) return
event.preventDefault()
event.stopPropagation()
runShortcut(match, event)
}
function handleGlobalKeydown(event: KeyboardEvent) {
const key = event.key.toLowerCase()
const isFindShortcut = key === 'f' && (event.ctrlKey || event.metaKey)
const isBlockedDevtoolsShortcut = event.key === 'F12' && !themeStore.devMode
if (isFindShortcut || isBlockedDevtoolsShortcut) {
event.preventDefault()
event.stopPropagation()
}
handleScrollShortcutKey(event)
handleNavShortcutKey(event)
}
window.addEventListener('keydown', handleGlobalKeydown, true)
window.addEventListener('mousedown', handleShortcutMouse, true)
// Not passive: a bound wheel direction has to keep the page from scrolling.
window.addEventListener('wheel', handleShortcutWheel, { capture: true, passive: false })
onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalKeydown, true)
window.removeEventListener('mousedown', handleShortcutMouse, true)
window.removeEventListener('wheel', handleShortcutWheel, { capture: true, passive: false })
})
return { bindingFor, shortcutIsAvailable, SCROLL_ACTIONS }
}
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { shouldDelayAppUpdate } from './app-update-policy.ts'
const now = Date.parse('2026-10-21T08:00:00Z')
const recent = { publishedAt: '2026-10-21T07:00:00Z' }
test('administrator rollbacks bypass the normal release waiting period', () => {
assert.equal(shouldDelayAppUpdate({ ...recent, rollback: true }, 'release', false, now), false)
assert.equal(shouldDelayAppUpdate({ rollback: true }, 'release', false, now), false)
})
test('ordinary releases retain the 24-hour delay and missing dates remain delayed', () => {
assert.equal(shouldDelayAppUpdate(recent, 'release', false, now), true)
assert.equal(shouldDelayAppUpdate({}, 'release', false, now), true)
assert.equal(
shouldDelayAppUpdate({ publishedAt: '2026-10-20T08:00:00Z' }, 'release', false, now),
false,
)
})
test('beta, forced updates and immediate fetching still bypass the delay', () => {
assert.equal(shouldDelayAppUpdate(recent, 'beta', false, now), false)
assert.equal(shouldDelayAppUpdate({ ...recent, forceUpdate: true }, 'release', false, now), false)
assert.equal(shouldDelayAppUpdate(recent, 'release', true, now), false)
})
@@ -0,0 +1,11 @@
export function shouldDelayAppUpdate(
update: { publishedAt?: string; forceUpdate?: boolean; rollback?: boolean },
channel: string,
immediateUpdateFetch: boolean,
now = Date.now(),
): boolean {
if (channel !== 'release' || immediateUpdateFetch || update.forceUpdate || update.rollback)
return false
const publishedAt = Date.parse(update.publishedAt ?? '')
return !Number.isFinite(publishedAt) || now < publishedAt + 24 * 60 * 60 * 1000
}
+4
View File
@@ -160,6 +160,10 @@ export async function notification_listener(callback) {
return await listen('notification', (event) => callback(event.payload))
}
export async function logshare_ai_listener(callback) {
return await listen('logshare_ai', (event) => callback(event.payload))
}
/// Payload for the 'log' event
/*
LogPayload {
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
hostedDownloadActions,
hostedDownloadState,
runHostedDownloadAction,
runHostedDownloadControl,
} from './hosted-download-controls.ts'
test('only a controllable parent hosted task offers pause, resume and stop', () => {
const bar = {
bar_type: {
type: 'hosted_pack_sync',
instance_id: 'one',
state: 'running' as const,
can_pause: true,
can_cancel: true,
},
}
assert.deepEqual(hostedDownloadActions(bar), ['pause', 'cancel'])
assert.deepEqual(hostedDownloadActions({ bar_type: { ...bar.bar_type, state: 'paused' } }), [
'resume',
'cancel',
])
for (const state of ['finalizing', 'canceling'] as const) {
assert.deepEqual(hostedDownloadActions({ bar_type: { ...bar.bar_type, state } }), [])
}
assert.deepEqual(
hostedDownloadActions({ bar_type: { ...bar.bar_type, type: 'hosted_mod_download' } }),
[],
)
assert.deepEqual(
hostedDownloadActions({ bar_type: { type: 'hosted_pack_sync', instance_id: 'one' } }),
[],
)
})
test('failed and canceled hosted tasks offer direct retry, preserving terminal state', () => {
for (const state of ['failed', 'canceled'] as const) {
const bar = {
bar_type: { type: 'hosted_pack_sync', instance_id: 'one', state, error: 'failure' },
}
assert.deepEqual(hostedDownloadActions(bar), ['retry'])
assert.equal(hostedDownloadState(bar), state)
}
assert.equal(hostedDownloadState({ bar_type: { error: 'old backend failure' } }), 'failed')
})
test('resume refreshes the session before invoking controls; pause and stop need no session', async () => {
const calls: string[] = []
const deps = {
prepareSession: async () => {
calls.push('session')
},
invoke: async (command: string, args: { instanceId: string }) => {
calls.push(`${command}:${args.instanceId}`)
},
}
await runHostedDownloadControl('resume', 'one', deps)
await runHostedDownloadControl('pause', 'one', deps)
await runHostedDownloadControl('cancel', 'one', deps)
assert.deepEqual(calls, [
'session',
'plugin:install|hosted_resume:one',
'plugin:install|hosted_pause:one',
'plugin:install|hosted_cancel:one',
])
})
test('session refresh failures prevent resume', async () => {
await assert.rejects(
runHostedDownloadControl('resume', 'one', {
prepareSession: async () => {
throw new Error('signed out')
},
invoke: async () => {
assert.fail('resume must not run')
},
}),
/signed out/,
)
})
test('retry immediately restarts the existing hosted instance through the manager', async () => {
const calls: string[] = []
await runHostedDownloadAction('retry', 'existing-instance', {
retryHosted: async (instanceId) => {
calls.push(instanceId)
},
control: async () => {
assert.fail('retry must use the full hosted transaction')
},
})
assert.deepEqual(calls, ['existing-instance'])
})
@@ -0,0 +1,45 @@
import type { LoadingBar } from './state.ts'
export type HostedDownloadControl = 'pause' | 'resume' | 'cancel'
export type HostedDownloadAction = HostedDownloadControl | 'retry'
export function runHostedDownloadAction(
action: HostedDownloadAction,
instanceId: string,
operations: {
retryHosted: (instanceId: string) => Promise<void>
control: (action: HostedDownloadControl, instanceId: string) => Promise<void>
},
) {
return action === 'retry'
? operations.retryHosted(instanceId)
: operations.control(action, instanceId)
}
export function hostedDownloadState(bar: LoadingBar) {
return bar.bar_type?.state ?? (bar.bar_type?.error ? 'failed' : 'running')
}
export function hostedDownloadActions(bar: LoadingBar): HostedDownloadAction[] {
const type = bar.bar_type
if (type?.type !== 'hosted_pack_sync' || !type.instance_id) return []
const state = hostedDownloadState(bar)
if (state === 'failed' || state === 'canceled') return ['retry']
if (state !== 'running' && state !== 'paused') return []
const actions: HostedDownloadAction[] = []
if (type.can_pause) actions.push(state === 'paused' ? 'resume' : 'pause')
if (type.can_cancel) actions.push('cancel')
return actions
}
export async function runHostedDownloadControl(
action: HostedDownloadControl,
instanceId: string,
dependencies: {
prepareSession: () => Promise<void>
invoke: (command: string, args: { instanceId: string }) => Promise<void>
},
) {
if (action === 'resume') await dependencies.prepareSession()
await dependencies.invoke(`plugin:install|hosted_${action}`, { instanceId })
}
@@ -3,6 +3,27 @@ import test from 'node:test'
import { createHostedDownloadFailures } from './hosted-download-failures.ts'
test('stopped hosted downloads retain their state and instance for direct retry', () => {
const state = createHostedDownloadFailures()
state.update({
fraction: null,
loader_uuid: 'stopped',
event: {
type: 'hosted_pack_sync',
instance_id: 'existing-instance',
state: 'canceled',
error: 'Stopped by user',
can_pause: false,
can_cancel: false,
},
})
assert.equal(state.values()[0]?.bar_type?.state, 'canceled')
assert.equal(state.values()[0]?.bar_type?.instance_id, 'existing-instance')
state.begin('existing-instance', state.values())
assert.equal(state.has('existing-instance'), false)
assert.equal(state.isRetired('stopped'), true)
})
test('retry clears only its own failure before progress arrives and ignores late old events', () => {
const state = createHostedDownloadFailures()
const failed = (instanceId: string, loader: string, error: string) => ({
@@ -8,6 +8,7 @@ import {
skinSiteUser,
waitForSkinSiteSession,
} from '../composables/skin-site-session.ts'
import { type HostedDownloadControl, runHostedDownloadControl } from './hosted-download-controls.ts'
import { authenticateInstancePlayer } from './instance-player.ts'
export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作'
@@ -146,6 +147,11 @@ export const hostedCreate = async (gameDirRoot?: string | null) => {
}
export const hostedBinding = (instanceId: string) =>
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
export const controlHostedDownload = (action: HostedDownloadControl, instanceId: string) =>
runHostedDownloadControl(action, instanceId, {
prepareSession: prepareHostedSession,
invoke: invokeHosted<void>,
})
export const hostedSync = async (instanceId: string) => {
await requireHostedGameAccount()
startHostedPackAttempt(instanceId)
@@ -1,20 +1,32 @@
import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test from 'node:test'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
} from '../composables/skin-site-session.ts'
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '@tauri-apps/plugin-http') {
return {
url: `data:text/javascript,${encodeURIComponent(`export const fetch=async url=>Response.json({payload:url.endsWith('/user/refresh')?{jwt:'fixture.jwt'}:url.endsWith('/user')?{uuid:'owner',username:'Owner',permissions:[]}:[]})`)}`,
shortCircuit: true,
}
}
if (specifier.startsWith('@/')) {
return nextResolve(new URL(`../${specifier.slice(2)}.ts`, import.meta.url).href, context)
}
return nextResolve(specifier, context)
},
})
const { initSkinSiteSession, resetSkinSiteSession, setSkinSiteFrame } =
await import('../composables/skin-site-session.ts')
hooks.deregister()
import { useNetworkStatus } from '../composables/useNetworkStatus.ts'
import {
type InstancePlayer,
const {
onInstancePlayerChanged,
prepareInstancePlayer,
registerInstancePlayerPicker,
saveInstancePlayer,
} from './instance-player.ts'
} = await import('./instance-player.ts')
import type { InstancePlayer } from './instance-player.ts'
test('instance player selection persists independently from the globally selected account', async () => {
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
@@ -84,23 +96,16 @@ test('signed-in skin site renews the saved player without prompting and only not
}
const calls: string[] = []
let failSave = false
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type === 'starlight-pack-token-request')
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'fixture.jwt',
},
} as unknown as MessageEvent,
frame,
)
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
const storage = new Map([['starlight-skin-site-token', 'old.jwt']])
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
} as unknown as Window
})
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
@@ -128,19 +133,7 @@ test('signed-in skin site renews the saved player without prompting and only not
const stop = onInstancePlayerChanged((id) => changes.push(id))
try {
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'owner', username: 'Owner' },
},
} as unknown as MessageEvent,
frame,
)
await initSkinSiteSession()
await prepareInstancePlayer('one')
assert.equal(prompts, 0)
assert.ok(calls.includes('plugin:auth|login_skin_site_player'))
@@ -149,6 +142,7 @@ test('signed-in skin site renews the saved player without prompting and only not
failSave = true
await assert.rejects(saveInstancePlayer('two', player), /Save failed/)
assert.deepEqual(changes, ['one'])
storage.clear()
resetSkinSiteSession()
await prepareInstancePlayer('one')
assert.equal(prompts, 1)
@@ -157,6 +151,8 @@ test('signed-in skin site renews the saved player without prompting and only not
unregister()
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previousStorage) Object.defineProperty(globalThis, 'localStorage', previousStorage)
else Reflect.deleteProperty(globalThis, 'localStorage')
if (previous) Object.defineProperty(globalThis, 'window', previous)
else Reflect.deleteProperty(globalThis, 'window')
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
+44
View File
@@ -95,6 +95,50 @@ export async function undo_added_mod(instanceId, filename, expectedHash) {
return await invoke('plugin:logs|logs_undo_added_mod', { instanceId, filename, expectedHash })
}
export async function get_log_share_settings() {
return await invoke('plugin:logs|logs_get_log_share_settings')
}
export async function update_log_share_settings(settings) {
return await invoke('plugin:logs|logs_update_log_share_settings', { settings })
}
export async function logshare_upload_crash(instanceId) {
return await invoke('plugin:logs|logs_logshare_upload_crash', { instanceId })
}
export async function logshare_get_insights(id) {
return await invoke('plugin:logs|logs_logshare_get_insights', { id })
}
export async function logshare_analyse_crash_direct(instanceId) {
return await invoke('plugin:logs|logs_logshare_analyse_crash_direct', { instanceId })
}
export async function logshare_ai_analyze_stored(instanceId, id) {
return await invoke('plugin:logs|logs_logshare_ai_analyze_stored', { instanceId, id })
}
export async function logshare_ai_analyze_direct(instanceId) {
return await invoke('plugin:logs|logs_logshare_ai_analyze_direct', { instanceId })
}
export async function logshare_delete(id, token) {
return await invoke('plugin:logs|logs_logshare_delete', { id, token })
}
export async function list_shared_logs() {
return await invoke('plugin:logs|logs_list_shared_logs')
}
export async function record_shared_log(log) {
return await invoke('plugin:logs|logs_record_shared_log', { log })
}
export async function delete_shared_log(id, token) {
return await invoke('plugin:logs|logs_delete_shared_log', { id, token })
}
/// Export the censored files and local analysis from an instance's latest run.
export async function export_crash_context(instanceId, instanceName) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
@@ -0,0 +1,14 @@
const NAV_SHORTCUT_STORAGE_PREFIX = 'starlight-shortcut-nav-'
/**
* Whether a navigation shortcut is enabled. Shortcuts are off by default;
* each one is turned on individually from the Shortcut settings page.
*/
export function getNavShortcutEnabled(id: string): boolean {
const value = localStorage.getItem(NAV_SHORTCUT_STORAGE_PREFIX + id)
return value === 'true'
}
export function setNavShortcutEnabled(id: string, enabled: boolean) {
localStorage.setItem(NAV_SHORTCUT_STORAGE_PREFIX + id, String(enabled))
}
@@ -72,7 +72,7 @@ test('notice query uses the target instance value and registered Tauri command',
)
const helperSource = readFileSync(new URL('./instance.ts', import.meta.url), 'utf8')
const tauriSource = readFileSync(
new URL('../../../app/src/api/instance.rs', import.meta.url),
new URL('../../../app/src/api/instance/mod.rs', import.meta.url),
'utf8',
)
assert.match(querySource, /\['post-upgrade-notice', instanceId\]/)
@@ -0,0 +1,20 @@
const SHOW_SCROLL_TOP_STORAGE_KEY = 'starlight-show-scroll-top'
const QUICK_SCROLL_ENABLED_STORAGE_KEY = 'starlight-quick-scroll-enabled'
export function getShowScrollTop(): boolean {
const value = localStorage.getItem(SHOW_SCROLL_TOP_STORAGE_KEY)
return value !== 'false'
}
export function setShowScrollTop(show: boolean) {
localStorage.setItem(SHOW_SCROLL_TOP_STORAGE_KEY, String(show))
}
export function getQuickScrollEnabled(): boolean {
const value = localStorage.getItem(QUICK_SCROLL_ENABLED_STORAGE_KEY)
return value === 'true'
}
export function setQuickScrollEnabled(enabled: boolean) {
localStorage.setItem(QUICK_SCROLL_ENABLED_STORAGE_KEY, String(enabled))
}
@@ -0,0 +1,309 @@
import { defineMessages, type KeyBinding, type MessageDescriptor } from '@modrinth/ui'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { getLastBrowseContentProjectType, isBrowseContentProjectType } from '@/helpers/settings'
/** Which half of the settings page an action belongs to. */
export type ShortcutGroupId = 'scroll' | 'nav'
/** Theme store fields that switch an action on. */
export type ShortcutEnabledField =
| 'quickScrollEnabled'
| 'shortcutNavHome'
| 'shortcutNavWorlds'
| 'shortcutNavDiscover'
| 'shortcutNavSkins'
| 'shortcutNavMultiplayer'
| 'shortcutNavLibrary'
| 'shortcutNavLab'
| 'shortcutNavDownloads'
| 'shortcutNavCreate'
| 'shortcutNavSettings'
export interface ShortcutContext {
worldsTabEnabled: boolean
offline: boolean
}
export interface ShortcutAction {
/** Stable id, used for the binding, the enable flag and the settings row. */
id: string
group: ShortcutGroupId
enabledField: ShortcutEnabledField
defaultBinding: KeyBinding
label: MessageDescriptor
description: MessageDescriptor
/** Navigation actions know where they lead. */
target?: (route: RouteLocationNormalizedLoaded) => string
/** Navigation actions that are not reachable right now. */
unavailable?: (context: ShortcutContext) => boolean
/** Scrolling actions move the page's own scroller. */
applyScroll?: (scroller: HTMLElement) => void
}
/**
* Declared here rather than assembled from the action ids, so the extractor can
* see them; an id built at runtime is one a translation pass would drop.
*/
const messages = defineMessages({
scrollTop: {
id: 'app.shortcut-settings.home-action',
defaultMessage: 'Scroll to the top',
},
scrollTopDescription: {
id: 'app.shortcut-settings.home-action-description',
defaultMessage: 'Moves the page you are reading to the top.',
},
scrollBottom: {
id: 'app.shortcut-settings.end-action',
defaultMessage: 'Scroll to the bottom',
},
scrollBottomDescription: {
id: 'app.shortcut-settings.end-action-description',
defaultMessage: 'Moves the page you are reading to the bottom.',
},
scrollUp: {
id: 'app.shortcut-settings.page-up-action',
defaultMessage: 'Scroll up one screen',
},
scrollUpDescription: {
id: 'app.shortcut-settings.page-up-action-description',
defaultMessage: 'Moves the page you are reading up by one screen.',
},
scrollDown: {
id: 'app.shortcut-settings.page-down-action',
defaultMessage: 'Scroll down one screen',
},
scrollDownDescription: {
id: 'app.shortcut-settings.page-down-action-description',
defaultMessage: 'Moves the page you are reading down by one screen.',
},
navHome: { id: 'app.shortcut-settings.nav-home', defaultMessage: 'Home' },
navHomeDescription: {
id: 'app.shortcut-settings.nav-home-description',
defaultMessage: 'Jump to the Home page.',
},
navWorlds: { id: 'app.shortcut-settings.nav-worlds', defaultMessage: 'Worlds' },
navWorldsDescription: {
id: 'app.shortcut-settings.nav-worlds-description',
defaultMessage: 'Jump to your worlds.',
},
navDiscover: {
id: 'app.shortcut-settings.nav-discover',
defaultMessage: 'Discover content',
},
navDiscoverDescription: {
id: 'app.shortcut-settings.nav-discover-description',
defaultMessage: 'Jump to browsing content.',
},
navSkins: { id: 'app.shortcut-settings.nav-skins', defaultMessage: 'Skin selector' },
navSkinsDescription: {
id: 'app.shortcut-settings.nav-skins-description',
defaultMessage: 'Jump to the skin selector.',
},
navMultiplayer: { id: 'app.shortcut-settings.nav-multiplayer', defaultMessage: 'Multiplayer' },
navMultiplayerDescription: {
id: 'app.shortcut-settings.nav-multiplayer-description',
defaultMessage: 'Jump to multiplayer.',
},
navLibrary: { id: 'app.shortcut-settings.nav-library', defaultMessage: 'Library' },
navLibraryDescription: {
id: 'app.shortcut-settings.nav-library-description',
defaultMessage: 'Jump to your library.',
},
navLab: { id: 'app.shortcut-settings.nav-lab', defaultMessage: 'Lab' },
navLabDescription: {
id: 'app.shortcut-settings.nav-lab-description',
defaultMessage: 'Jump to the Lab.',
},
navDownloads: { id: 'app.shortcut-settings.nav-downloads', defaultMessage: 'Downloads' },
navDownloadsDescription: {
id: 'app.shortcut-settings.nav-downloads-description',
defaultMessage: 'Jump to downloads.',
},
navCreate: { id: 'app.shortcut-settings.nav-create', defaultMessage: 'Create new instance' },
navCreateDescription: {
id: 'app.shortcut-settings.nav-create-description',
defaultMessage: 'Jump to creating a new instance.',
},
navSettings: { id: 'app.shortcut-settings.nav-settings', defaultMessage: 'Settings' },
navSettingsDescription: {
id: 'app.shortcut-settings.nav-settings-description',
defaultMessage: 'Jump to the settings.',
},
})
function keyboard(code: string, mod = false, alt = false, shift = false): KeyBinding {
return { device: 'keyboard', code, mod, alt, shift }
}
/**
* Resolves the Discover content target from the current route, so the nav
* button and the keyboard shortcut share one source of truth.
*/
export function discoverContentTarget(route: RouteLocationNormalizedLoaded): string {
const projectType = route.params.projectType
if (
!route.query.i &&
!route.query.sid &&
!route.query.wid &&
typeof projectType === 'string' &&
isBrowseContentProjectType(projectType)
) {
return `/browse/${projectType}`
}
return `/browse/${getLastBrowseContentProjectType()}`
}
/**
* Every shortcut the launcher answers to. The binding here is the one a fresh
* install uses; a recorded binding is stored separately and wins over it.
*/
export const SHORTCUT_ACTIONS: ShortcutAction[] = [
{
id: 'shortcutScrollTop',
group: 'scroll',
enabledField: 'quickScrollEnabled',
defaultBinding: keyboard('Home'),
label: messages.scrollTop,
description: messages.scrollTopDescription,
applyScroll: (scroller) => scroller.scrollTo({ top: 0, behavior: 'smooth' }),
},
{
id: 'shortcutScrollBottom',
group: 'scroll',
enabledField: 'quickScrollEnabled',
defaultBinding: keyboard('End'),
label: messages.scrollBottom,
description: messages.scrollBottomDescription,
applyScroll: (scroller) =>
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' }),
},
{
id: 'shortcutScrollUp',
group: 'scroll',
enabledField: 'quickScrollEnabled',
defaultBinding: keyboard('PageUp'),
label: messages.scrollUp,
description: messages.scrollUpDescription,
applyScroll: (scroller) => {
// Immediate scrolling keeps rapid key repeats responsive.
scroller.scrollTop = Math.max(0, scroller.scrollTop - scroller.clientHeight * 0.9)
},
},
{
id: 'shortcutScrollDown',
group: 'scroll',
enabledField: 'quickScrollEnabled',
defaultBinding: keyboard('PageDown'),
label: messages.scrollDown,
description: messages.scrollDownDescription,
applyScroll: (scroller) => {
scroller.scrollTop = Math.min(
scroller.scrollHeight,
scroller.scrollTop + scroller.clientHeight * 0.9,
)
},
},
{
id: 'shortcutNavHome',
group: 'nav',
enabledField: 'shortcutNavHome',
defaultBinding: keyboard('Digit1', true),
label: messages.navHome,
description: messages.navHomeDescription,
target: () => '/',
},
{
id: 'shortcutNavWorlds',
group: 'nav',
enabledField: 'shortcutNavWorlds',
defaultBinding: keyboard('Digit2', true),
label: messages.navWorlds,
description: messages.navWorldsDescription,
target: () => '/worlds',
unavailable: (context) => !context.worldsTabEnabled,
},
{
id: 'shortcutNavDiscover',
group: 'nav',
enabledField: 'shortcutNavDiscover',
defaultBinding: keyboard('Digit3', true),
label: messages.navDiscover,
description: messages.navDiscoverDescription,
target: (route) => discoverContentTarget(route),
unavailable: (context) => context.offline,
},
{
id: 'shortcutNavSkins',
group: 'nav',
enabledField: 'shortcutNavSkins',
defaultBinding: keyboard('Digit4', true),
label: messages.navSkins,
description: messages.navSkinsDescription,
target: () => '/skins',
},
{
id: 'shortcutNavMultiplayer',
group: 'nav',
enabledField: 'shortcutNavMultiplayer',
defaultBinding: keyboard('Digit5', true),
label: messages.navMultiplayer,
description: messages.navMultiplayerDescription,
target: () => '/multiplayer',
},
{
id: 'shortcutNavLibrary',
group: 'nav',
enabledField: 'shortcutNavLibrary',
defaultBinding: keyboard('Digit6', true),
label: messages.navLibrary,
description: messages.navLibraryDescription,
target: () => '/library',
},
{
id: 'shortcutNavLab',
group: 'nav',
enabledField: 'shortcutNavLab',
defaultBinding: keyboard('Digit7', true),
label: messages.navLab,
description: messages.navLabDescription,
target: () => '/lab',
},
{
id: 'shortcutNavDownloads',
group: 'nav',
enabledField: 'shortcutNavDownloads',
defaultBinding: keyboard('Digit8', true),
label: messages.navDownloads,
description: messages.navDownloadsDescription,
target: () => '/downloads',
},
{
id: 'shortcutNavCreate',
group: 'nav',
enabledField: 'shortcutNavCreate',
defaultBinding: keyboard('Digit9', true),
label: messages.navCreate,
description: messages.navCreateDescription,
target: () => '/create',
unavailable: (context) => context.offline,
},
{
id: 'shortcutNavSettings',
group: 'nav',
enabledField: 'shortcutNavSettings',
defaultBinding: keyboard('Comma', true),
label: messages.navSettings,
description: messages.navSettingsDescription,
target: () => '/settings',
},
]
export const SCROLL_ACTIONS = SHORTCUT_ACTIONS.filter((action) => action.group === 'scroll')
export const NAV_ACTIONS = SHORTCUT_ACTIONS.filter((action) => action.group === 'nav')
export function shortcutAction(id: string): ShortcutAction | undefined {
return SHORTCUT_ACTIONS.find((action) => action.id === id)
}
@@ -0,0 +1,120 @@
import type { BindingConflict, KeyBinding } from '@modrinth/ui'
import { SHORTCUT_ACTIONS, shortcutAction } from '@/helpers/shortcut-actions'
const BINDING_STORAGE_PREFIX = 'starlight-shortcut-binding-'
/**
* Combinations the shell or the operating system answers to before the launcher
* sees them. Saved shortcuts may not take these over.
*/
export const RESERVED_BINDINGS: BindingConflict[] = [
{
id: 'system-find',
label: 'Find in page (Ctrl/Cmd + F)',
binding: keyboard('KeyF', true),
reserved: true,
},
{
id: 'system-devtools',
label: 'Developer tools (F12)',
binding: keyboard('F12'),
reserved: true,
},
{
id: 'system-close',
label: 'Close window (Ctrl/Cmd + W)',
binding: keyboard('KeyW', true),
reserved: true,
},
{
id: 'system-quit',
label: 'Quit (Ctrl/Cmd + Q)',
binding: keyboard('KeyQ', true),
reserved: true,
},
{
id: 'system-reload',
label: 'Reload (Ctrl/Cmd + R)',
binding: keyboard('KeyR', true),
reserved: true,
},
{ id: 'system-refresh', label: 'Refresh (F5)', binding: keyboard('F5'), reserved: true },
{
id: 'system-exit',
label: 'Close window (Alt + F4)',
binding: keyboard('F4', false, true),
reserved: true,
},
]
function keyboard(code: string, mod = false, alt = false, shift = false): KeyBinding {
return { device: 'keyboard', code, mod, alt, shift }
}
function isKeyBinding(value: unknown): value is KeyBinding {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<KeyBinding>
return (
(candidate.device === 'keyboard' || candidate.device === 'mouse') &&
typeof candidate.code === 'string' &&
candidate.code.length > 0 &&
typeof candidate.mod === 'boolean' &&
typeof candidate.shift === 'boolean' &&
typeof candidate.alt === 'boolean'
)
}
/** The combination recorded for an action, if it has one. */
export function readStoredBinding(id: string): KeyBinding | null {
const raw = localStorage.getItem(BINDING_STORAGE_PREFIX + id)
if (!raw) return null
try {
const parsed = JSON.parse(raw)
return isKeyBinding(parsed) ? parsed : null
} catch {
// A value that cannot be read is treated as unset, so the action keeps
// working with its default rather than becoming unreachable.
return null
}
}
export function storeBinding(id: string, binding: KeyBinding) {
localStorage.setItem(BINDING_STORAGE_PREFIX + id, JSON.stringify(binding))
}
/** Drops the recorded combination, putting the action back on its default. */
export function clearStoredBinding(id: string) {
if (readStoredBinding(id)) localStorage.removeItem(BINDING_STORAGE_PREFIX + id)
}
/** The combination an action answers to now: the recorded one, or its default. */
export function resolveBinding(id: string): KeyBinding | null {
const action = shortcutAction(id)
if (!action) return null
return readStoredBinding(id) ?? action.defaultBinding
}
export function resolveAllBindings(): Record<string, KeyBinding> {
const bindings: Record<string, KeyBinding> = {}
for (const action of SHORTCUT_ACTIONS) {
bindings[action.id] = resolveBinding(action.id) ?? action.defaultBinding
}
return bindings
}
/**
* Everything a new combination would collide with: the other actions, plus the
* combinations the shell owns. `label` is what the dialog names in the error.
*/
export function buildConflicts(exceptId: string, label: (id: string) => string): BindingConflict[] {
const conflicts = SHORTCUT_ACTIONS.filter((action) => action.id !== exceptId).flatMap(
(action) => {
const binding = resolveBinding(action.id)
return binding ? [{ id: action.id, label: label(action.id), binding }] : []
},
)
return [...conflicts, ...RESERVED_BINDINGS]
}
@@ -1,7 +1,23 @@
import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test from 'node:test'
import { loadSkinArmorPreview, saveSkinArmorPreview } from './skin-armor-preview.ts'
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '@modrinth/ui') {
return nextResolve(
new URL(
'../../../../packages/ui/src/composables/skin-rendering/armor-preview-types.ts',
import.meta.url,
).href,
context,
)
}
return nextResolve(specifier, context)
},
})
const { loadSkinArmorPreview, saveSkinArmorPreview } = await import('./skin-armor-preview.ts')
hooks.deregister()
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test from 'node:test'
const testGlobal = globalThis as unknown as {
skinImageTestFetch: (url: string) => Promise<Response>
}
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '@tauri-apps/plugin-http') {
return {
url: 'data:text/javascript,export const fetch=(...args)=>globalThis.skinImageTestFetch(...args)',
shortCircuit: true,
}
}
return nextResolve(specifier, context)
},
})
const { fetchImageAsDataUrl } = await import('./skin-site-render.ts')
hooks.deregister()
test.beforeEach((t) => {
t.mock.method(globalThis, 'fetch', async () => {
throw new TypeError('Cross-origin image request blocked')
})
const previousReader = Object.getOwnPropertyDescriptor(globalThis, 'FileReader')
Object.defineProperty(globalThis, 'FileReader', {
configurable: true,
value: class {
result: string | null = null
onload?: () => void
onerror?: () => void
readAsDataURL(blob: Blob) {
void blob.arrayBuffer().then(
(bytes) => {
this.result = `data:${blob.type};base64,${Buffer.from(bytes).toString('base64')}`
this.onload?.()
},
() => this.onerror?.(),
)
}
},
})
t.after(() => {
if (previousReader) Object.defineProperty(globalThis, 'FileReader', previousReader)
else Reflect.deleteProperty(globalThis, 'FileReader')
})
})
test('skin textures download through the native client when browser CORS blocks the site', async () => {
testGlobal.skinImageTestFetch = async () =>
new Response(new Uint8Array([137, 80, 78, 71]), { headers: { 'Content-Type': 'image/png' } })
assert.equal(
await fetchImageAsDataUrl('https://skin.starlight.cool/yggdrasil/textures/test'),
'data:image/png;base64,iVBORw==',
)
})
test('failed image downloads report the HTTP error instead of accepting the response as a texture', async () => {
testGlobal.skinImageTestFetch = async () => new Response('Unavailable', { status: 503 })
await assert.rejects(
fetchImageAsDataUrl('https://skin.starlight.cool/yggdrasil/textures/test'),
/HTTP 503/,
)
})
@@ -0,0 +1,88 @@
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
/**
* Renders a player's skin into a base64 head image, mirroring the skin site's
* own head cropper (front face at 8,8 plus the hat overlay at 40,8).
*
* The skin image is loaded through the Tauri HTTP client first so cross-origin
* textures do not taint the canvas, then drawn once as a data URL.
*/
const HEAD_BUFFER_SIZE = 18
const HEAD_FACE = { sx: 8, sy: 8, sw: 8, sh: 8, dx: 1, dy: 1, dw: 16, dh: 16 }
const HEAD_HAT = { sx: 40, sy: 8, sw: 8, sh: 8, dx: 0, dy: 0, dw: 18, dh: 18 }
let buffer: HTMLCanvasElement | null = null
function getBuffer(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
if (!buffer) buffer = document.createElement('canvas')
const ctx = buffer.getContext('2d')
if (!ctx) throw new Error('无法创建皮肤渲染画布')
return { canvas: buffer, ctx }
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('无法加载皮肤图片'))
img.src = src
})
}
/** Crop the head (and hat overlay) from a locally downloaded skin data URL. */
export async function renderSkinHead(skinDataUrl: string, scale = 2): Promise<string> {
const img = await loadImage(skinDataUrl)
const { canvas, ctx } = getBuffer()
canvas.width = HEAD_BUFFER_SIZE
canvas.height = HEAD_BUFFER_SIZE
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.imageSmoothingEnabled = false
ctx.drawImage(
img,
HEAD_FACE.sx,
HEAD_FACE.sy,
HEAD_FACE.sw,
HEAD_FACE.sh,
HEAD_FACE.dx,
HEAD_FACE.dy,
HEAD_FACE.dw,
HEAD_FACE.dh,
)
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(
img,
HEAD_HAT.sx,
HEAD_HAT.sy,
HEAD_HAT.sw,
HEAD_HAT.sh,
HEAD_HAT.dx,
HEAD_HAT.dy,
HEAD_HAT.dw,
HEAD_HAT.dh,
)
if (scale <= 1) return canvas.toDataURL('image/png')
const small = await createImageBitmap(canvas)
canvas.width = Math.floor(HEAD_BUFFER_SIZE * scale)
canvas.height = Math.floor(HEAD_BUFFER_SIZE * scale)
ctx.imageSmoothingEnabled = false
ctx.drawImage(small, 0, 0, HEAD_BUFFER_SIZE, HEAD_BUFFER_SIZE, 0, 0, canvas.width, canvas.height)
return canvas.toDataURL('image/png')
}
/** Fetch an image URL as a data URL (used to keep full skin textures local). */
export async function fetchImageAsDataUrl(url: string): Promise<string> {
const response = await tauriFetch(url)
if (!response.ok) throw new Error(`无法加载图片(HTTP ${response.status})`)
const blob = await response.blob()
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(new Error('无法读取图片数据'))
reader.readAsDataURL(blob)
})
}
+9 -1
View File
@@ -9,6 +9,9 @@ export interface LoadingBarType {
batch_id?: string
file_name?: string
error?: string | null
state?: 'running' | 'paused' | 'canceling' | 'canceled' | 'failed' | 'finalizing'
can_pause?: boolean
can_cancel?: boolean
type?: string
version?: string
instance_id?: string
@@ -28,7 +31,12 @@ export interface LoadingBar {
}
export type OpeningCommandEvent =
'RunMRPack' | 'InstallServer' | 'InstallVersion' | 'InstallMod' | 'InstallModpack' | string
| 'RunMRPack'
| 'InstallServer'
| 'InstallVersion'
| 'InstallMod'
| 'InstallModpack'
| string
export interface OpeningCommand {
event: OpeningCommandEvent
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+66 -18
View File
@@ -84,7 +84,7 @@
</TagItem>
<Badge
:color="bar.bar_type?.error ? 'red' : 'orange'"
:type="statusLabel(bar.bar_type?.error ? 'failed' : 'running')"
:type="statusLabel(hostedDownloadState(bar))"
/>
</div>
<ProgressBar
@@ -94,16 +94,35 @@
:progress="legacyPercent(bar)"
:max="100"
:label="formatMessage(messages.progress)"
:waiting="!bar.total"
:waiting="!bar.total && hostedDownloadState(bar) !== 'paused'"
:show-progress="Boolean(bar.total)"
/>
<RouterLink
v-else
:to="hostedRetryRoute(bar.bar_type?.instance_id ?? '')"
class="mt-3 inline-block text-brand hover:underline"
<div
v-if="hostedDownloadActions(bar).length"
class="mt-3 flex flex-wrap items-center gap-2"
>
{{ formatMessage(messages.hostedRetry) }}
</RouterLink>
<ButtonStyled
v-for="action in hostedDownloadActions(bar)"
:key="action"
:color="action === 'retry' ? 'brand' : action === 'cancel' ? 'red' : 'standard'"
:type="action === 'retry' ? 'standard' : 'outlined'"
size="small"
>
<button
:disabled="busy.has(hostedBusyKey(bar, action))"
@click="hostedAction(bar, action)"
>
{{ formatMessage(hostedActionMessages[action]) }}
</button>
</ButtonStyled>
<RouterLink
v-if="bar.bar_type?.error"
:to="hostedRetryRoute(bar.bar_type.instance_id ?? '')"
class="text-brand hover:underline"
>
{{ formatMessage(messages.openInstance) }}
</RouterLink>
</div>
</Card>
<Card
@@ -471,7 +490,14 @@ import { useRoute, useRouter } from 'vue-router'
import MissingModpackContentModal from '@/components/ui/modal/MissingModpackContentModal.vue'
import { listPendingCurseForgeManualDownloads } from '@/helpers/curseforge'
import type { CurseForgeManualDownloadItem } from '@/helpers/curseforge-manual'
import {
type HostedDownloadAction,
hostedDownloadActions,
hostedDownloadState,
runHostedDownloadAction,
} from '@/helpers/hosted-download-controls'
import { hostedRetryRoute } from '@/helpers/hosted-install-retry'
import { controlHostedDownload } from '@/helpers/hosted-packs'
import {
download_job_support_details,
type InstallJobSnapshot,
@@ -517,10 +543,6 @@ const focusedJobId = computed(() => focusedDownloadJobId(route.query.job))
const focusState = ref(createDownloadFocusState(focusedJobId.value))
const messages = defineMessages({
hostedRetry: {
id: 'app.hosted-packs.progress.retry',
defaultMessage: 'Return to the instance to retry',
},
newDownload: { id: 'app.downloads.new-download', defaultMessage: 'New download' },
inProgress: { id: 'app.downloads.in-progress', defaultMessage: 'In progress' },
history: { id: 'app.downloads.history', defaultMessage: 'History' },
@@ -653,7 +675,15 @@ const messages = defineMessages({
unknownPhase: { id: 'app.downloads.phase.unknown', defaultMessage: 'Working' },
})
const hostedActionMessages = defineMessages({
pause: { id: 'app.hosted-packs.progress.pause', defaultMessage: 'Pause' },
resume: { id: 'app.hosted-packs.progress.resume', defaultMessage: 'Resume' },
cancel: { id: 'app.hosted-packs.progress.stop', defaultMessage: 'Stop' },
retry: { id: 'app.downloads.retry', defaultMessage: 'Retry' },
})
const statusMessages = defineMessages({
paused: { id: 'app.downloads.status.paused', defaultMessage: 'Paused' },
queued: { id: 'app.downloads.status.queued', defaultMessage: 'Queued' },
running: { id: 'app.downloads.status.running', defaultMessage: 'Running' },
canceling: { id: 'app.downloads.status.canceling', defaultMessage: 'Canceling' },
@@ -1262,6 +1292,7 @@ function toggleExpanded(jobId: string) {
}
async function withBusy(jobId: string, action: () => Promise<void>) {
if (busy.value.has(jobId)) return
busy.value = new Set([...busy.value, jobId])
try {
await action()
@@ -1278,6 +1309,21 @@ async function cancel(job: InstallJobSnapshot) {
await withBusy(job.job_id, () => manager.cancel(job.job_id))
}
function hostedBusyKey(bar: LoadingBar, action: HostedDownloadAction) {
return `hosted-${action === 'retry' ? 'retry' : 'control'}:${bar.bar_type?.instance_id}`
}
async function hostedAction(bar: LoadingBar, action: HostedDownloadAction) {
const instanceId = bar.bar_type?.instance_id
if (!instanceId || !hostedDownloadActions(bar).includes(action)) return
await withBusy(hostedBusyKey(bar, action), () =>
runHostedDownloadAction(action, instanceId, {
retryHosted: manager.retryHosted,
control: controlHostedDownload,
}),
)
}
async function retry(job: InstallJobSnapshot) {
await withBusy(job.job_id, () => manager.retry(job.job_id))
}
@@ -1293,12 +1339,14 @@ async function resolveMissing(job: InstallJobSnapshot) {
(item) =>
item.status === 'skipped' && item.manual_url && item.project_id && item.version_id,
)
.map((item): CurseForgeManualDownloadItem => ({
projectId: Number(item.project_id),
fileId: Number(item.version_id),
fileName: item.name,
websiteUrl: item.manual_url ?? undefined,
}))
.map(
(item): CurseForgeManualDownloadItem => ({
projectId: Number(item.project_id),
fileId: Number(item.version_id),
fileName: item.name,
websiteUrl: item.manual_url ?? undefined,
}),
)
const instanceId = job.instance_id
const hasGeneralMissingItems = job.items.some((item) => item.status === 'failed')
if (instanceId && (job.provider === 'curse_forge' || fallbackCurseForgeItems.length > 0)) {
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff. Show more