Compare commits

..
102 Commits
Author SHA1 Message Date
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 / 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
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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
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
2026-10-19 21:48:11 +08:00
AxTps aaf394f790 feat: 发版时自动同步到皮肤站
Sync source to CNB / Sync Git ref (push) Skipped
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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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 / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (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 / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (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 / 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
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 / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Starlight release / Build (Windows x64) (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
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 / 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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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 / 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 / guardrails (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
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 / 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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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
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
Prepare pnpm cache / prepare (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
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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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 / 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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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 / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
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 / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (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
Prepare pnpm cache / prepare (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
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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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 / 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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (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 / 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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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 / 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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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 / 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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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
Repository checks / typos (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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
fix: 修复远端公告不刷新与盔甲面板未翻译
2026-10-11 10:32:49 +08:00
AxTps d6fdb4f453 Merge remote-tracking branch 'origin/main' into fix/ui
Sync source to CNB / Sync Git ref (push) Skipped
# Conflicts:
#	apps/app-frontend/src/locales/en-US/index.json
#	apps/app-frontend/src/locales/zh-CN/index.json
2026-10-11 10:28:52 +08:00
AxTps 4ba1ed279f Merge pull request #14 from ApTx2012/feat/instance
Sync source to CNB / Sync Git ref (push) Skipped
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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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
feat: 实例分组增强(后端 JSON 存储 + 前端拖拽分组 UI)
2026-10-11 09:39:14 +08:00
AxTps afdd091971 fix: 修复远端公告不刷新与盔甲面板未翻译
Sync source to CNB / Sync Git ref (push) Skipped
Bug1 远端公告:公告只在启动时拉取一次,且点铃铛不触发刷新。
- openCenter 时补调 refresh(),点铃铛即拉最新
- watch skinSiteStatus,登录皮肤站后自动重试拉取

Bug2 盔甲翻译:补 7 个缺失的 app.skins.armor.* i18n 键
(title/save/close/description/unsaved/reset-to-saved/restore-defaults),
ui 包的 skin.preview.armor.* 46 个键已存在无需补。
2026-10-11 08:58:03 +08:00
AxTps 99e0aa9d14 Merge pull request #13 from ApTx2012/feat/backend
Sync source to CNB / Sync Git ref (push) Skipped
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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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
feat: 导出启动器日志
2026-10-11 08:35:07 +08:00
AxTps 22bc631293 style: 对 emit.rs 的 import 补跑 cargo fmt
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-11 08:15:28 +08:00
AxTps 3d3682023e fix: 补回 emit.rs 缺失的 futures/serde_json import
Sync source to CNB / Sync Git ref (push) Skipped
阶段1搬运时漏了 `use futures::prelude::*;` 和 `use serde_json::Value;`,
导致 cargo test 编译 theseus lib test 时报 E0425(Value) / E0405(TryStream)。
cargo check 不编 test 路径故未暴露。
2026-10-11 08:08:40 +08:00
AxTps f253d5529b feat: 实例分组增强前端(拖拽分组 UI)
Sync source to CNB / Sync Git ref (push) Skipped
移植 Axolotl 的拖拽式实例分组界面到 Starlight,接入阶段1的规范化分组后端:

- 新增 helpers/analytics.ts(trackEvent 遥测桩)
- 补全 helpers/instance-groups.ts(6 个 invoke 包装)
- 新增 composables/useInstanceGroups.ts(分组状态 + 事件监听)
- 升级 composables/useGridGrouping.ts(排序方向 isSortAscending/toggleSortDirection)
- helpers/events.js 补 instance_groups_listener(对接 instance_groups_changed 事件)
- 新增 components/ui/library/DraggableInstanceCard.vue
- 新增 components/ui/library/InstanceGroup.vue(并补 group-drag-handle 修复组拖拽重排)
- 新增 components/ui/modal/InstanceGroupModal.vue
- GridDisplay.vue 替换为 Axolotl 拖拽版(DragDropProvider + vuedraggable)
- 补 en-US/zh-CN i18n 键各 39 条
2026-10-11 07:54:48 +08:00
AxTps 253141b6ff style: 补实例分组阶段1的 cargo fmt 格式
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-11 07:17:42 +08:00
AxTps 946a477397 feat: 实例分组增强阶段1(后端 JSON 存储与命令)
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-11 07:12:15 +08:00
AxTps eabac1f11e docs: 补充 1.0.2-beta5 公告
Sync source to CNB / Sync Git ref (push) Skipped
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
Prepare pnpm cache / prepare (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (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 / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Starlight release / Build (Windows x64) (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
Rust checks / shear (push) Canceled after 0s
2026-10-11 07:06:54 +08:00
AxTps c9a2eeb53d Merge branch 'main' into feat/backend
Sync source to CNB / Sync Git ref (push) Skipped
解决 zh-CN locale 末尾追加冲突:保留日志导出键。
2026-10-11 06:14:25 +08:00
Ax_Tps b776b99f26 Merge pull request #12 from ApTx2012/feat/ui
Sync source to CNB / Sync Git ref (push) Skipped
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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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
feat: 接入皮肤站公告 API
2026-10-11 06:13:00 +08:00
Ax_Tps daeedc4951 Merge pull request #10 from ApTx2012/feat/launch-customization
Sync source to CNB / Sync Git ref (push) Skipped
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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
feat: 支持自定义 Minecraft 窗口标题
2026-10-11 06:12:25 +08:00
AxTps 79addd4aba feat: 移植导出启动器日志
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-11 06:12:05 +08:00
AxTps cadb7bbdcd feat: 接入皮肤站公告 API
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 22:34:00 +08:00
AxTps 7f3ab9d79b fix: 补 EditInstance DTO 缺失的 window_title 字段与透传
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 22:23:49 +08:00
AxTps 037bff2db3 Merge branch 'main' into feat/launch-customization
Sync source to CNB / Sync Git ref (push) Skipped
解决 en-US/zh-CN locale JSON 的末尾追加冲突:窗口标题键与公告中心键均保留。
2026-10-10 22:07:19 +08:00
AxTps 1406f7ded3 fix: 补 ProcessMetadata 测试构造点缺失的 window_title
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 22:01:42 +08:00
Ap_Tx c54acd2e36 fix: 补齐盔甲预览的中文翻译 (#11)
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
皮肤编辑器盔甲面板的 46 条文案此前只有英文,补上 packages/ui 的中英对照。
2026-10-10 21:54:50 +08:00
AxTps 1b6534ad8c fix: changelog 评论 workflow 补 pull-requests write 权限
Sync source to CNB / Sync Git ref (push) Skipped
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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
2026-10-10 21:53:05 +08:00
AxTps 2477fc62ca fix: 补窗口标题字段缺失的构造点
Sync source to CNB / Sync Git ref (push) Skipped
修 E0063:InstanceLaunchOverridesPatch 和 InstanceLaunchOverrides 的两处完整构造漏了 window_title。
2026-10-10 21:48:20 +08:00
Ap_Tx 3f6678ac2f feat: 移植远程公告中心 (#9)
Sync source to CNB / Sync Git ref (push) Skipped
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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
从 Axolotl 搬 RemoteAnnouncements.vue,顶栏加公告铃铛。远程拉取先禁用,等接口。
2026-10-10 21:32:11 +08:00
AxTps e36329ff0e style: 窗口标题改动的 cargo fmt 格式修正
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-10 21:30:15 +08:00
AxTps c718d75774 feat: 支持自定义 Minecraft 窗口标题
Sync source to CNB / Sync Git ref (push) Skipped
全局开关 + 默认标题,实例可覆盖;Win32 重命名窗口。前端设置页同步。
2026-10-10 21:02:34 +08:00
AxTps 544611738d docs: 1.0.2-beta4 公告并入盔甲预览
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Repository checks / typos (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 / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Starlight release / Build (Windows x64) (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
Repository checks / tombi (push) Canceled after 0s
beta4 此前未打 tag,本次发布将其与皮肤编辑器盔甲预览
移植(PR #8)合并发布,故在 beta4 公告条目补充:
- 皮肤预览新增盔甲与盔甲纹饰渲染
- 盔甲预览选择的本地持久化
2026-10-10 19:25:39 +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
AxTps 403a883e0b fix: CI 钉住 cargo-shear 版本为 1.13.4
Sync source to CNB / Sync Git ref (push) Skipped
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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (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
check-rust.yml 的 shear 任务在 Rust 1.95 工具链下装最新
cargo-shear(1.14.0) 失败:其依赖 ra_ap_syntax 0.0.352 要求
rustc >= 1.98。且 binstall 走 GitHub release 下载时遭遇
api.github.com 403,回退源码编译后同样因版本过高而失败。

钉到 cargo-shear@1.13.4(依赖 ra_ap_syntax 0.0.347,要求
rustc 1.95),与 CI 的 stable 工具链兼容。
2026-10-09 22:20:48 +08:00
AxTps 53c749aaea fix: 补齐 skin-rendering barrel 的盔甲导出
Sync source to CNB / Sync Git ref (push) Skipped
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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
PR #8 合并后 CI 打包报 MISSING_EXPORT:ArmorTrimPanel.vue 与
SkinPreviewRenderer.vue 从 #ui/composables/skin-rendering 导入
ARMOR_SLOTS / ARMOR_TRIM_MATERIALS / ARMOR_TRIM_PATTERNS /
armorMaterialsForSlot / createDefaultArmorPreviewConfig /
useArmorPreview,但该 barrel 未导出这些符号。

补上 armor-preview-types 与 use-armor-preview 的导出。
本地 pnpm --filter @modrinth/app-frontend build 已通过。
2026-10-09 22:12:41 +08:00
Ap_Tx addc31c7c1 Merge pull request #8 from ApTx2012/feat/axolotl-port
feat: 移植 Axolotl 皮肤盔甲预览(Armor Preview)
2026-10-09 21:50:24 +08:00
AxTps 25978bcd8f Merge remote-tracking branch 'origin2/main'
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (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 / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
2026-10-09 21:46:12 +08:00
AxTps 117eaad9ff style: 对分组修复涉及的 Rust 文件补 cargo fmt 2026-10-09 21:44:22 +08:00
AxTps ae2992d7f8 feat: 移植 Axolotl 皮肤盔甲预览(Armor Preview)
Sync source to CNB / Sync Git ref (push) Canceled after 0s
从 Axolotl 1.9.7 移植皮肤编辑器的盔甲/盔甲纹饰预览功能,
让玩家在皮肤选择页可试穿盔甲并调整纹饰。

新增:
- 前端组件 ArmorTrimTab / SkinListSkeleton 及 skin-armor-preview helper
- packages/ui 组件 ArmorPreviewControls / ArmorTrimPanel
- packages/ui armor 预览 composable(armor-preview-assets/types、use-armor-preview)
- packages/ui webgl 盔甲渲染(armor-geometry / armor-preview-object / armor-rendering)
- packages/ui/src/assets/minecraft-armor 盔甲素材

改动:
- SkinPreviewRenderer 增加 armorPreview/armorControls props、
  armorConfig model 与 useArmorPreview 集成
- Skins.vue 增加盔甲 tab、状态逻辑与模板接入
- 补齐 skin/composables/utils 的导出

适配说明:ArmorPreviewControls 原版依赖 reka-ui 的 popover,
Starlight 无该依赖且只用 external 模式,故改写为 external-only,
去除 reka-ui 与 overlays.css 依赖。
2026-10-07 20:01:19 +08:00
305 changed files with 32103 additions and 2036 deletions

No files matched your search

+38
View File
@@ -374,6 +374,44 @@ jobs:
)
gh release upload "${GITHUB_REF_NAME}" "release-check/${bundle_name}" --clobber
- name: Publish to skin site
continue-on-error: true
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
if [ -z "${SKIN_SITE_TOKEN:-}" ]; then
echo "::warning::SKIN_SITE_CI_UPLOAD_TOKEN not configured, skipping skin site publish"
exit 0
fi
version="${GITHUB_REF_NAME#v}"
bundle="release-check/Starlight_Launcher_${version}_skin-site-update.zip"
if [ ! -f "${bundle}" ]; then
echo "::warning::Bundle ${bundle} not found, skipping skin site publish"
exit 0
fi
upload_response=$(curl -fsS -X POST \
-H "Authorization: Bearer ${SKIN_SITE_TOKEN}" \
-F "archive=@${bundle};type=application/zip" \
"${SKIN_SITE_URL}/starlight/launcher/admin/upload?forceUpdate=false")
release_id=$(printf '%s' "${upload_response}" | jq -r '.payload.id // empty')
if [ -z "${release_id}" ]; then
echo "::warning::Skin site upload did not return a release id: ${upload_response}"
exit 0
fi
echo "Uploaded release id: ${release_id}"
curl -fsS -X POST \
-H "Authorization: Bearer ${SKIN_SITE_TOKEN}" \
"${SKIN_SITE_URL}/starlight/launcher/admin/${release_id}/activate"
echo "Skin site release ${version} published"
- name: Notify Update Server to publish release assets
if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }}
env:
+2 -2
View File
@@ -12,7 +12,7 @@ on:
permissions:
issues: write
pull-requests: read
pull-requests: write
jobs:
comment:
@@ -21,7 +21,7 @@ jobs:
permissions:
issues: write
pull-requests: read
pull-requests: write
contents: read
steps:
+1 -1
View File
@@ -22,5 +22,5 @@ jobs:
node-version-file: .nvmrc
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- uses: cargo-bins/cargo-binstall@dc19f1e48450eefe5a29b8da6c6b00a87d730b37 # v1.18.1
- run: cargo binstall --no-confirm cargo-shear
- run: cargo binstall --no-confirm cargo-shear@1.13.4
- run: cargo shear
Generated
+2 -2
View File
@@ -6841,7 +6841,7 @@ dependencies = [
[[package]]
name = "theseus"
version = "1.0.2-beta4"
version = "1.0.2-beta7"
dependencies = [
"ariadne",
"async-minecraft-ping",
@@ -6945,7 +6945,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "1.0.2-beta4"
version = "1.0.2-beta7"
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-beta9",
"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",
+9 -4
View File
@@ -94,12 +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 StartupAnnouncementModal from '@/components/ui/StartupAnnouncementModal.vue'
import RemoteAnnouncements from '@/components/ui/RemoteAnnouncements.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'
@@ -131,6 +133,7 @@ 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 {
areUpdatesEnabled,
backupAppDbForUpdate,
@@ -144,7 +147,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 {
@@ -183,6 +185,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 (
@@ -1155,6 +1159,7 @@ async function setupApp() {
stateInitialized.value = true
showOnboarding.value = !onboarded
void reconcileMojangAuthSourceAtStartup().catch(handleError)
void initSkinSiteSession().catch(handleError)
isMaximized.value = await getCurrentWindow().isMaximized()
@@ -2224,9 +2229,9 @@ 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" />
<div id="teleports"></div>
<div
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
@@ -32,6 +32,197 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
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',
publishedAt: '2026-10-11',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta5',
'zh-CN': 'Starlight Launcher 1.0.2-beta5',
},
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 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: [
{
'en-US': 'Completed the Chinese translations for the armor preview feature.',
'zh-CN': '补齐盔甲预览功能的中文翻译。',
},
],
},
},
{
id: 'launcher-1.0.2-beta4',
version: '1.0.2-beta4',
@@ -41,6 +232,16 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'zh-CN': 'Starlight Launcher 1.0.2-beta4',
},
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': '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.',
File diff suppressed because it is too large. Load diff
@@ -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>
@@ -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)
@@ -67,6 +67,23 @@
</div>
</template>
</Dropdown>
<ButtonStyled
v-tooltip="formatMessage(messages.announcements)"
type="transparent"
circular
>
<button
:aria-label="formatMessage(messages.announcements)"
class="relative"
@click="openAnnouncementCenter"
>
<NewspaperIcon />
<span
v-if="announcementUnreadCount"
class="absolute right-0 top-0 size-2 rounded-full bg-red ring-2 ring-bg-raised"
/>
</button>
</ButtonStyled>
<ButtonStyled
v-if="!isDownloadsPage && hasActiveDownloads && !hasVisibleActiveDownloadToasts"
color="brand"
@@ -190,6 +207,7 @@
<script setup lang="ts">
import {
BellIcon,
NewspaperIcon,
DownloadIcon,
DropdownIcon,
OnlineIndicatorIcon,
@@ -211,7 +229,7 @@ import {
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Dropdown } from 'floating-vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
@@ -222,6 +240,10 @@ import { get_many as getInstances } from '@/helpers/instance'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import type { LoadingBar } from '@/helpers/state'
import { progress_bars_list } from '@/helpers/state'
import {
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
} from '@/helpers/remote-announcements'
import type { GameInstance } from '@/helpers/types'
import { downloadBarTypes, injectDownloadManager } from '@/providers/download-manager'
@@ -231,6 +253,25 @@ const popupNotificationManager = injectPopupNotificationManager()
const downloadManager = injectDownloadManager()
const { formatMessage } = useVIntl()
const announcementUnreadCount = ref(0)
function openAnnouncementCenter() {
window.dispatchEvent(new CustomEvent(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT))
}
function updateAnnouncementCount(event: Event) {
const detail = (event as CustomEvent<{ unreadKeys?: string[] }>).detail
announcementUnreadCount.value = detail?.unreadKeys?.length ?? 0
}
onMounted(() => {
window.addEventListener(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, updateAnnouncementCount)
})
onBeforeUnmount(() => {
window.removeEventListener(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, updateAnnouncementCount)
})
type NotificationHistoryItem = {
key: string
createdAt?: number
@@ -370,6 +411,10 @@ const messages = defineMessages({
id: 'app.action-bar.notifications',
defaultMessage: 'Notifications',
},
announcements: {
id: 'app.action-bar.announcements',
defaultMessage: 'Announcements',
},
clearNotifications: {
id: 'app.action-bar.notifications.clear',
defaultMessage: 'Clear all',
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,425 @@
<script setup lang="ts">
import {
Button,
commonMessages,
defineMessages,
NewModal,
useModalStack,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import {
announcementKey,
isAnnouncementActive,
mapBroadcastAnnouncements,
OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT,
parseAnnouncements,
REMOTE_ANNOUNCEMENTS_UPDATED_EVENT,
type RemoteAnnouncement,
safeAnnouncementUrl,
} from '@/helpers/remote-announcements'
import {
requestSkinSiteDownloadToken,
skinSiteStatus,
waitForSkinSiteSession,
} from '@/composables/skin-site-session.ts'
const props = defineProps<{ ready: boolean; previewOnly?: boolean }>()
const { formatMessage } = useVIntl()
const { hasModal } = useModalStack()
const messages = defineMessages({
view: { id: 'app.remote-announcements.view', defaultMessage: 'View announcement' },
unread: { id: 'app.remote-announcements.unread', defaultMessage: 'Unread' },
readAll: {
id: 'app.remote-announcements.read-all',
defaultMessage: 'Mark all announcements as read',
},
centerTitle: { id: 'app.remote-announcements.center-title', defaultMessage: 'Announcements' },
empty: { id: 'app.remote-announcements.empty', defaultMessage: 'No announcements' },
previewTitle: {
id: 'app.remote-announcements.preview-title',
defaultMessage: 'Announcement style preview',
},
previewSummary: {
id: 'app.remote-announcements.preview-summary',
defaultMessage:
'This is a local preview. Select View announcement to preview the full Markdown content.',
},
previewContent: {
id: 'app.remote-announcements.preview-content',
defaultMessage:
'## Announcement preview\n\nThis is **sample content**, not a published announcement.\n\n- Supports headings, lists, and links\n- Close buttons are always available\n\n> Previewing does not change real announcement read status.\n\n| Type | Display |\n| --- | --- |\n| Modal | Full Markdown content |\n| Popup | Summary, then full content |\n\n[Visit the website](https://skin.starlight.cool/)',
},
previewAction: { id: 'app.remote-announcements.preview-action', defaultMessage: 'Visit website' },
})
const modal = ref<InstanceType<typeof NewModal>>()
const selected = ref<RemoteAnnouncement | null>(null)
const active = ref(false)
const centerOpen = ref(false)
const startupNotice = ref<RemoteAnnouncement | null>(null)
const html = computed(() => renderString(selected.value?.content ?? ''))
const stateKey = 'starlight-remote-announcements-v2'
const read = reactive(new Set<string>())
const queuedThisSession = new Set<string>()
const startupNotified = new Set<string>()
let items: RemoteAnnouncement[] = []
let pending: RemoteAnnouncement[] = []
let cacheKey = 'starlight-remote-announcements-cache-v1'
let cacheLoaded = false
let disposed = false
let interval: ReturnType<typeof setInterval> | undefined
let advanceTimer: ReturnType<typeof setTimeout> | undefined
function persist() {
if (props.previewOnly) return
try {
localStorage.setItem(
stateKey,
JSON.stringify({
read: [...read].slice(-1000),
}),
)
} catch {
// localStorage may be unavailable (private mode / quota)
}
}
function emitState() {
window.dispatchEvent(
new CustomEvent(REMOTE_ANNOUNCEMENTS_UPDATED_EVENT, {
detail: {
items,
unreadKeys: items.filter((item) => !read.has(announcementKey(item))).map(announcementKey),
},
}),
)
}
function openCenter(item?: RemoteAnnouncement) {
if (!props.ready || disposed) return
void refresh()
centerOpen.value = true
if (item) {
startupNotice.value = null
selectAnnouncement(item)
} else if (!selected.value && items.length) {
selectAnnouncement(items[0])
}
void nextTick(() => modal.value?.show())
}
function handleOpenCenter() {
if (props.previewOnly) return
openCenter()
}
async function show(item: RemoteAnnouncement) {
if (
disposed ||
!props.ready ||
!isAnnouncementActive(item) ||
(hasModal.value && !active.value && !centerOpen.value)
)
return
centerOpen.value = true
active.value = true
selectAnnouncement(item)
await nextTick()
if (!disposed) modal.value?.show()
}
function selectAnnouncement(item: RemoteAnnouncement) {
selected.value = item
const key = announcementKey(item)
read.add(key)
pending = pending.filter((entry) => announcementKey(entry) !== key)
persist()
emitState()
}
function markAllRead() {
for (const item of items) {
read.add(announcementKey(item))
}
pending = []
persist()
emitState()
}
function advance() {
if (disposed || !props.ready || hasModal.value || active.value) return
const next = pending.shift()
if (next) {
void show(next)
return
}
for (const item of items) {
const key = announcementKey(item)
if (
item.type === 'notification' &&
!startupNotified.has(key) &&
!read.has(key) &&
isAnnouncementActive(item)
) {
startupNotice.value = item
startupNotified.add(key)
setTimeout(() => {
if (startupNotice.value === item) {
startupNotice.value = null
advance()
}
}, 8000)
break
}
}
}
function closed() {
active.value = false
centerOpen.value = false
if (advanceTimer) clearTimeout(advanceTimer)
advanceTimer = setTimeout(advance, 350)
}
function sync(next: RemoteAnnouncement[], fresh: boolean) {
items = next.filter((item) => isAnnouncementActive(item))
const keys = new Set(items.map(announcementKey))
pending = pending.filter((item) => keys.has(announcementKey(item)))
if (selected.value && !keys.has(announcementKey(selected.value))) selected.value = null
emitState()
if (fresh) {
for (const item of items) {
const key = announcementKey(item)
if (
item.type === 'modal' &&
!queuedThisSession.has(key) &&
(!read.has(key) || item.priority === 'critical')
) {
queuedThisSession.add(key)
pending.push(item)
}
}
advance()
}
}
function loadCache() {
if (cacheLoaded || !cacheKey) return
cacheLoaded = true
try {
const cached = JSON.parse(localStorage.getItem(cacheKey) ?? 'null')
if (cached && typeof cached.savedAt === 'number' && Date.now() - cached.savedAt < 86400000) {
const parsed = parseAnnouncements(cached.items)
if (parsed) sync(parsed, false)
}
} catch {
// Ignore malformed or expired cache payloads
}
}
function saveCache(next: RemoteAnnouncement[]) {
if (!cacheKey) return
try {
localStorage.setItem(cacheKey, JSON.stringify({ savedAt: Date.now(), items: next }))
} catch {
// localStorage may be unavailable (private mode / quota)
}
}
async function refresh() {
if (disposed) return
loadCache()
if (skinSiteStatus.value !== 'signed-in') return
try {
await waitForSkinSiteSession()
const token = await requestSkinSiteDownloadToken()
const response = await tauriFetch('https://skin.starlight.cool/starlight/skin/broadcast/all', {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) return
const data = await response.json()
const parsed = mapBroadcastAnnouncements((data as { payload?: unknown } | null)?.payload)
if (parsed && !disposed) {
sync(parsed, true)
saveCache(parsed)
}
} catch {
// Remote fetch failed; previously cached items remain displayed
}
}
async function openLink(value: unknown) {
const url = safeAnnouncementUrl(value)
if (!url) return
try {
await openUrl(url)
} catch {
// Opener may reject unknown schemes
}
}
function contentClick(event: MouseEvent) {
const link = event.target instanceof Element ? event.target.closest('a') : null
if (!link) return
event.preventDefault()
event.stopPropagation()
void openLink(link.getAttribute('href'))
}
function preview(type: RemoteAnnouncement['type'], withAction = false) {
if (!props.previewOnly || !props.ready || hasModal.value || disposed) return
const now = new Date().toISOString()
const item: RemoteAnnouncement = {
id: 'local-preview-' + type,
title: formatMessage(messages.previewTitle),
summary: formatMessage(messages.previewSummary),
content: formatMessage(messages.previewContent),
type,
priority: 'normal',
starts_at: now,
ends_at: null,
published_at: now,
action_label: withAction ? formatMessage(messages.previewAction) : null,
action_url: withAction ? 'https://skin.starlight.cool/' : null,
}
read.clear()
queuedThisSession.clear()
sync([item], true)
}
defineExpose({ preview })
onMounted(() => {
if (props.previewOnly) return
try {
const saved = JSON.parse(localStorage.getItem(stateKey) ?? 'null')
if (saved && Array.isArray(saved.read))
for (const key of saved.read) if (typeof key === 'string') read.add(key)
} catch {
// Ignore malformed local read-state
}
window.addEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
const start = () => {
if (disposed) return
void refresh()
interval = setInterval(() => {
sync(items, false)
advance()
}, 15000)
}
// Defer the startup request until after first paint.
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(start, { timeout: 2000 })
} else {
setTimeout(start, 500)
}
})
watch(skinSiteStatus, (status) => {
if (disposed || props.previewOnly) return
if (status === 'signed-in') void refresh()
})
watch([() => props.ready, hasModal], () => {
if (advanceTimer) clearTimeout(advanceTimer)
advanceTimer = setTimeout(advance, 350)
})
onUnmounted(() => {
disposed = true
if (interval) clearInterval(interval)
if (advanceTimer) clearTimeout(advanceTimer)
window.removeEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
})
</script>
<template>
<div
v-if="startupNotice"
class="fixed right-4 top-16 z-50 w-[22rem] max-w-[calc(100vw-2rem)] rounded-xl border border-surface-5 bg-surface-3 p-3 shadow-lg"
>
<button class="w-full text-left" @click="startupNotice && openCenter(startupNotice)">
<div class="mb-1 text-xs font-semibold uppercase text-brand">
{{ formatMessage(messages.unread) }}
</div>
<div class="font-semibold text-[var(--color-text-primary)]">{{ startupNotice.title }}</div>
<div
v-if="startupNotice.summary"
class="mt-1 line-clamp-2 text-sm text-[var(--color-text-tertiary)]"
>
{{ startupNotice.summary }}
</div>
</button>
</div>
<NewModal
ref="modal"
:header="formatMessage(messages.centerTitle)"
:on-hide="closed"
max-width="80vw"
width="80vw"
max-content-height="calc(80vh - 8rem)"
scrollable
>
<div class="flex h-[calc(80vh-8rem)] min-h-0 gap-3">
<aside class="flex min-h-0 w-[32%] min-w-[14rem] flex-col border-r border-surface-5 pr-3">
<div class="mb-2 flex items-center justify-between">
<span class="font-semibold text-[var(--color-text-primary)]">{{
formatMessage(messages.centerTitle)
}}</span>
<Button
v-if="items.some((item) => !read.has(announcementKey(item)))"
@click="markAllRead"
>{{ formatMessage(messages.readAll) }}</Button
>
</div>
<div
v-if="!items.length"
class="py-8 text-center text-sm text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.empty) }}
</div>
<div v-else class="flex min-h-0 flex-col gap-1 overflow-auto">
<button
v-for="item in items"
:key="announcementKey(item)"
class="flex items-start gap-2 rounded-lg p-2 text-left hover:bg-surface-4"
:class="{
'bg-surface-4': selected && announcementKey(selected) === announcementKey(item),
}"
@click="selectAnnouncement(item)"
>
<span
class="mt-1.5 size-2 shrink-0 rounded-full"
:class="read.has(announcementKey(item)) ? 'opacity-0' : 'bg-red'"
/>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium text-[var(--color-text-primary)]">{{
item.title
}}</span>
<span class="block line-clamp-2 text-xs text-[var(--color-text-tertiary)]">{{
item.summary || item.content
}}</span>
</span>
</button>
</div>
</aside>
<section v-if="selected" class="min-h-0 min-w-0 flex-1 overflow-auto px-1">
<h2 class="mb-4 text-xl font-semibold text-[var(--color-text-primary)]">
{{ selected.title }}
</h2>
<div
class="markdown-body break-words"
@click="contentClick"
@auxclick="contentClick"
v-html="html"
/>
<div class="mt-4 flex justify-end">
<Button
v-if="selected.action_url && selected.action_label"
type="colored"
color="brand"
@click="openLink(selected.action_url)"
>{{ selected.action_label }}</Button
>
</div>
</section>
<div
v-else
class="flex flex-1 items-center justify-center text-sm text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.empty) }}
</div>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<Button @click="modal?.hide()">{{ formatMessage(commonMessages.closeButton) }} </Button>
</div>
</template>
</NewModal>
</template>
@@ -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>
@@ -36,24 +36,29 @@ const fullscreenSetting: Ref<boolean> = ref(
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
)
const maximizeWindowSetting = ref(instance.value.maximize_window ?? globalSettings.maximize_window)
const windowTitle = ref(instance.value.window_title ?? '')
const editInstanceObject = computed(() => {
const title = windowTitle.value.trim() ? windowTitle.value.trim() : null
if (!overrideWindowSettings.value) {
return {
force_fullscreen: null,
maximize_window: null,
game_resolution: null,
// The title is independent of the window-size override checkbox.
window_title: title,
}
}
return {
force_fullscreen: fullscreenSetting.value,
maximize_window: maximizeWindowSetting.value,
game_resolution: fullscreenSetting.value ? null : resolution.value,
window_title: title,
}
})
watch(
[overrideWindowSettings, resolution, fullscreenSetting, maximizeWindowSetting],
[overrideWindowSettings, resolution, fullscreenSetting, maximizeWindowSetting, windowTitle],
async () => {
await edit(instance.value.id, editInstanceObject.value)
},
@@ -109,6 +114,18 @@ const messages = defineMessages({
id: 'instance.settings.tabs.window.height.enter',
defaultMessage: 'Enter height...',
},
windowTitle: {
id: 'instance.settings.tabs.window.window-title',
defaultMessage: 'Window title',
},
windowTitleDescription: {
id: 'instance.settings.tabs.window.window-title.description',
defaultMessage: 'Customize the Minecraft window title. Leave empty to keep the default.',
},
enterWindowTitle: {
id: 'instance.settings.tabs.window.window-title.enter',
defaultMessage: 'Enter window title...',
},
})
</script>
@@ -200,5 +217,25 @@ const messages = defineMessages({
:placeholder="formatMessage(messages.enterHeight)"
/>
</div>
<div
v-if="globalSettings.custom_window_title_enabled"
class="flex items-center gap-4 justify-between"
>
<div class="flex flex-col gap-1">
<h2 class="m-0 inline-flex items-center gap-2 text-lg font-semibold text-contrast">
{{ formatMessage(messages.windowTitle) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.windowTitleDescription) }}
</p>
</div>
<StyledInput
id="window-title"
v-model="windowTitle"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.enterWindowTitle)"
/>
</div>
</div>
</template>
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
import { useEventListener } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { GameInstance } from '@/helpers/types'
const props = defineProps<{
instance: GameInstance
instanceGroupId: string
disabled: boolean
}>()
const emit = defineEmits<{
(e: 'contextmenu' | 'card-click', event: MouseEvent): void
(e: 'start-long-press' | 'cancel-long-press'): void
}>()
const cardElement = ref<HTMLElement>()
const instanceCardSensors = [
PointerSensor.configure({
preventActivation: () => false,
}),
KeyboardSensor,
]
const dragDisabled = computed(() => props.disabled)
const { isDragging } = useDraggable({
id: computed(() => `instance:${props.instanceGroupId}:${props.instance.id}`),
element: cardElement,
disabled: dragDisabled,
sensors: instanceCardSensors,
data: computed(() => ({
instanceId: props.instance.id,
fromGroup: props.instanceGroupId,
})),
})
let wantsClick = false
useEventListener(window, 'pointerdown', () => {
wantsClick = true
})
useEventListener(window, 'pointerup', () => {
if (isDragging.value) {
wantsClick = false
}
})
useEventListener(window, 'blur', () => {
wantsClick = false
})
function onCardClick(event: MouseEvent) {
if (!wantsClick || isDragging.value) return
wantsClick = false
emit('card-click', event)
}
function onContextMenu(event: MouseEvent) {
emit('contextmenu', event)
}
function onMouseDown() {
if (!dragDisabled.value) {
emit('start-long-press')
}
}
function onMouseUp() {
emit('cancel-long-press')
}
</script>
<template>
<div
ref="cardElement"
class="group relative min-w-0 w-full"
@click="onCardClick"
@mousedown="onMouseDown"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
@touchstart="!dragDisabled && onMouseDown()"
@touchend="onMouseUp"
@touchcancel="onMouseUp"
@contextmenu.prevent.stop="onContextMenu"
>
<slot :is-dragging="isDragging" />
<slot v-if="!isDragging" name="overlay" />
</div>
</template>
@@ -0,0 +1,521 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import {
ArrowDownIcon,
ArrowUpIcon,
DropdownIcon,
EditIcon,
PlusIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import {
Button,
Checkbox,
commonMessages,
defineMessages,
IconButton,
InlineEditableText,
NewModal,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { computed, nextTick, ref, watch } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/Instance.vue'
import DraggableInstanceCard from '@/components/ui/library/DraggableInstanceCard.vue'
import { UNGROUPED_GROUP_KEY } from '@/composables/useGridGrouping'
import { FAVORITES_GROUP_ID } from '@/composables/useInstanceGroups'
import type { GameInstance } from '@/helpers/types'
const MAX_GROUP_NAME_LENGTH = 256
const { formatMessage } = useVIntl()
const messages = defineMessages({
ungrouped: { id: 'app.instances.group.ungrouped', defaultMessage: 'No group' },
pinned: { id: 'app.instances.group.pinned', defaultMessage: 'Pinned' },
emptyGroup: {
id: 'app.instances.group.empty',
defaultMessage: 'Drag and drop to add instances.',
},
editGroupName: {
id: 'app.instances.group.edit-name',
defaultMessage: 'Edit group name',
},
deleteGroup: {
id: 'app.instances.group.delete-group',
defaultMessage: 'Delete group',
},
deleteGroupDescription: {
id: 'app.instances.group.delete-description',
defaultMessage: 'Instances in this group will be ungrouped.',
},
moveGroupUp: {
id: 'app.instances.group.move-up',
defaultMessage: 'Move group up',
},
moveGroupDown: {
id: 'app.instances.group.move-down',
defaultMessage: 'Move group down',
},
addToGroup: {
id: 'app.instances.group.add-to-group',
defaultMessage: 'Add to group',
},
newInstance: {
id: 'app.instances.group.new-instance',
defaultMessage: 'New instance',
},
collapseGroup: {
id: 'app.instances.group.collapse',
defaultMessage: 'Collapse group',
},
expandGroup: {
id: 'app.instances.group.expand',
defaultMessage: 'Expand group',
},
renameGroup: {
id: 'app.instances.group.rename-group',
defaultMessage: 'Rename group',
},
groupNameEmpty: {
id: 'app.instances.group.name-empty',
defaultMessage: 'Group names cannot be empty.',
},
groupNameTooLong: {
id: 'app.instances.group.name-too-long',
defaultMessage: 'Group names cannot be longer than {maxLength} characters.',
},
})
const props = defineProps<{
sectionKey: string
groupName?: string
instances: GameInstance[]
grouping: string
isCollapsed: boolean
displayMode: string
selectMode: boolean
selectedInstanceIds: Set<string>
isSortAscending?: boolean
hideHeader?: boolean
canMoveUp?: boolean
canMoveDown?: boolean
groupPendingNameEdit?: string | null
}>()
// Events are grouped only where the payload shapes genuinely match; the
// per-event names are documentation, not part of the type.
const emit = defineEmits<{
(e: 'toggleCollapse' | 'deleteGroup' | 'addToGroup' | 'startLongPress', id: string): void
(e: 'handleCheckboxClick' | 'handleCardClick', instanceId: string, event: MouseEvent): void
(e: 'handleContextMenu', event: MouseEvent, instanceId: string, sectionKey: string): void
(e: 'renameGroup', oldKey: string, newKey: string): void
(e: 'cancelLongPress' | 'newInstance' | 'renameComplete'): void
(e: 'moveGroup', groupKey: string, direction: -1 | 1): void
}>()
const groupOptions = ref<InstanceType<typeof ContextMenu>>()
const groupContainerRef = ref<HTMLElement>()
const groupNameInput = ref<InstanceType<typeof InlineEditableText>>()
const deletingGroup = ref(false)
const confirmDeleteGroupModal = ref<InstanceType<typeof NewModal>>()
const groupName = ref(props.groupName ?? props.sectionKey)
const isUngrouped = computed(() => props.sectionKey === UNGROUPED_GROUP_KEY)
const isFavorites = computed(() => props.sectionKey === FAVORITES_GROUP_ID)
const isCustomGroup = computed(
() => props.grouping === 'Group' && !isUngrouped.value && !isFavorites.value,
)
const isGroupDragActive = computed(() => props.grouping === 'Group')
const effectiveCollapsed = computed(() => (props.hideHeader ? false : props.isCollapsed))
const { isDropTarget } = useDroppable({
id: computed(() => `instance-group:${props.sectionKey}`),
element: groupContainerRef,
disabled: computed(() => props.hideHeader || !isGroupDragActive.value),
data: computed(() => ({
groupId: props.sectionKey,
})),
})
watch(
() => [props.sectionKey, props.groupName],
() => {
groupName.value = props.groupName ?? props.sectionKey
},
)
watch(
() => props.groupPendingNameEdit,
async (pendingId) => {
if (pendingId !== props.sectionKey) return
await nextTick()
groupContainerRef.value?.scrollIntoView({ block: 'nearest' })
await groupNameInput.value?.startEditing()
emit('renameComplete')
},
{ immediate: true, flush: 'post' },
)
function validateGroupName(value: string) {
const normalized = value.trim()
if (normalized.length === 0) return false
if (normalized.length > MAX_GROUP_NAME_LENGTH) return false
return true
}
async function updateGroupName(value: string) {
const newName = value.trim()
if (!newName || newName === groupName.value) return false
emit('renameGroup', props.sectionKey, newName)
return true
}
function openGroupContextMenu(event: MouseEvent) {
groupOptions.value?.showMenu(
event,
props.sectionKey,
isCustomGroup.value
? [
{ name: 'new_instance' },
{ name: 'add_instances' },
{ name: 'edit_name' },
{ type: 'divider' },
{ name: 'delete_group', color: 'danger' },
]
: [{ name: 'new_instance' }],
)
}
async function handleGroupOption({ option }: { option: string }) {
if (option === 'new_instance') {
emit('newInstance')
} else if (option === 'add_instances') {
emit('addToGroup', props.sectionKey)
} else if (option === 'edit_name') {
groupNameInput.value?.startEditing()
} else if (option === 'delete_group') {
requestGroupDeletion()
}
}
function requestGroupDeletion() {
if (isUngrouped.value || isFavorites.value) return
if (props.instances.length > 0) {
confirmDeleteGroupModal.value?.show()
} else {
void removeGroup()
}
}
async function removeGroup() {
if (deletingGroup.value) return
deletingGroup.value = true
emit('deleteGroup', props.sectionKey)
setTimeout(() => {
deletingGroup.value = false
confirmDeleteGroupModal.value?.hide()
}, 300)
}
function onCardClick(instanceId: string, event: MouseEvent) {
emit('handleCardClick', instanceId, event)
}
function onStartLongPress(instanceId: string) {
emit('startLongPress', instanceId)
}
</script>
<template>
<div
ref="groupContainerRef"
class="instance-group group/instance-container relative select-none pb-3"
@contextmenu.prevent.stop="openGroupContextMenu"
>
<div
v-if="!hideHeader && isDropTarget && isGroupDragActive"
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 border-dashed border-contrast opacity-40 bg-transparent transition-opacity duration-150"
/>
<div
v-if="!hideHeader && (grouping === 'Group' || sectionKey !== UNGROUPED_GROUP_KEY)"
class="group/header flex h-10 w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
:class="{ 'group-drag-handle cursor-grab': isCustomGroup }"
>
<div
class="flex min-w-0 cursor-pointer items-center gap-2"
@click="emit('toggleCollapse', sectionKey)"
>
<button
class="flex shrink-0 items-center border-0 bg-transparent p-0 cursor-pointer"
type="button"
:aria-expanded="!effectiveCollapsed"
:aria-label="
formatMessage(effectiveCollapsed ? messages.expandGroup : messages.collapseGroup)
"
@click.stop="emit('toggleCollapse', sectionKey)"
>
<DropdownIcon
class="size-5 shrink-0 text-[var(--color-text-tertiary)] transition-transform duration-300"
:class="{ 'rotate-180': !effectiveCollapsed }"
/>
</button>
<InlineEditableText
v-if="!isUngrouped && !isFavorites"
ref="groupNameInput"
v-model="groupName"
activation-mode="manual"
class="text-base font-semibold !h-10 text-[var(--color-text-default)] select-none"
max-width="24rem"
:max-length="MAX_GROUP_NAME_LENGTH"
:on-change="updateGroupName"
:validate="validateGroupName"
/>
<span v-else class="text-base font-semibold text-[var(--color-text-default)] select-none">
{{ isFavorites ? formatMessage(messages.pinned) : formatMessage(messages.ungrouped) }}
</span>
<TagItem v-if="instances.length" class="shrink-0 border-surface-3 bg-surface-2">
{{ instances.length }}
</TagItem>
</div>
<div class="min-w-0 flex-1" />
<div
v-if="isCustomGroup"
class="flex shrink-0 items-center opacity-0 transition-opacity duration-250 group-hover/header:opacity-100 focus-within:opacity-100"
>
<IconButton
v-tooltip="formatMessage(messages.moveGroupUp)"
:label="formatMessage(messages.moveGroupUp)"
type="quiet"
size="sm"
:disabled="!canMoveUp"
@click.stop="emit('moveGroup', sectionKey, -1)"
>
<ArrowUpIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.moveGroupDown)"
:label="formatMessage(messages.moveGroupDown)"
type="quiet"
size="sm"
:disabled="!canMoveDown"
@click.stop="emit('moveGroup', sectionKey, 1)"
>
<ArrowDownIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.editGroupName)"
:label="formatMessage(messages.editGroupName)"
type="quiet"
size="sm"
@click.stop="groupNameInput?.startEditing()"
>
<EditIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.addToGroup)"
:label="formatMessage(messages.addToGroup)"
type="quiet"
size="sm"
@click.stop="emit('addToGroup', sectionKey)"
>
<PlusIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.deleteGroup)"
:label="formatMessage(messages.deleteGroup)"
type="quiet"
size="sm"
:disabled="deletingGroup"
@click.stop="handleGroupOption({ option: 'delete_group' })"
>
<TrashIcon />
</IconButton>
</div>
</div>
<div v-if="!effectiveCollapsed" class="mt-2.5">
<section
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] w-full gap-3 mr-auto scroll-smooth overflow-y-auto"
:class="{
'grid-cols-[repeat(auto-fill,minmax(13rem,1fr))] gap-4': displayMode === 'cards',
}"
>
<template v-for="instance in instances" :key="instance.id">
<DraggableInstanceCard
v-if="isGroupDragActive"
:instance="instance"
:instance-group-id="sectionKey"
:disabled="selectMode"
@card-click="(event: MouseEvent) => onCardClick(instance.id, event)"
@contextmenu="
(event: MouseEvent) => emit('handleContextMenu', event, instance.id, props.sectionKey)
"
@start-long-press="onStartLongPress(instance.id)"
@cancel-long-press="emit('cancelLongPress')"
>
<template #default="{ isDragging }">
<div
:class="{
'pointer-events-none': selectMode,
'relative cursor-pointer select-none rounded-lg transition-all hover:brightness-90 active:scale-[0.98]': true,
}"
@click.capture="
(event: MouseEvent) => {
if (event.shiftKey && !selectMode) {
event.stopPropagation()
emit('handleCardClick', instance.id, event)
}
}
"
>
<Instance
:instance="instance"
:disabled="selectMode || isDragging"
:variant="displayMode === 'cards' ? 'library' : 'standard'"
:class="{
'opacity-50': selectMode && !selectedInstanceIds.has(instance.id),
}"
/>
</div>
</template>
<template #overlay>
<div
class="absolute right-2 bottom-2 z-10 transition-opacity"
:class="
selectMode && selectedInstanceIds.has(instance.id)
? ''
: 'opacity-0 group-hover:opacity-100'
"
@click.stop="emit('handleCheckboxClick', instance.id, $event)"
>
<Checkbox :model-value="selectedInstanceIds.has(instance.id)" />
</div>
<div
v-if="!selectMode"
class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop="
emit('handleContextMenu', $event as MouseEvent, instance.id, props.sectionKey)
"
>
<Button type="quiet" size="2xs" circular icon-only
><slot name="moreIcon" />
</Button>
</div>
</template>
</DraggableInstanceCard>
<div v-else class="group relative">
<div
class="relative cursor-pointer select-none rounded-lg transition-all hover:brightness-90 active:scale-[0.98]"
@click.capture="
(event: MouseEvent) => {
if (event.shiftKey && !selectMode) {
event.stopPropagation()
emit('handleCardClick', instance.id, event)
}
}
"
@click="emit('handleCardClick', instance.id, $event as MouseEvent)"
@mousedown="!selectMode && emit('startLongPress', instance.id)"
@mouseup="emit('cancelLongPress')"
@mouseleave="emit('cancelLongPress')"
@touchstart="!selectMode && emit('startLongPress', instance.id)"
@touchend="emit('cancelLongPress')"
@touchcancel="emit('cancelLongPress')"
>
<div :class="{ 'pointer-events-none': selectMode }">
<Instance
:instance="instance"
:disabled="selectMode"
:variant="displayMode === 'cards' ? 'library' : 'standard'"
:class="{
'opacity-50': selectMode && !selectedInstanceIds.has(instance.id),
}"
@contextmenu.prevent.stop="
(event: MouseEvent) =>
emit('handleContextMenu', event, instance.id, props.sectionKey)
"
/>
</div>
</div>
<div
class="absolute right-2 bottom-2 z-10 transition-opacity"
:class="
selectMode && selectedInstanceIds.has(instance.id)
? ''
: 'opacity-0 group-hover:opacity-100'
"
@click.stop="emit('handleCheckboxClick', instance.id, $event)"
>
<Checkbox :model-value="selectedInstanceIds.has(instance.id)" />
</div>
<div
v-if="!selectMode"
class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop="
emit('handleContextMenu', $event as MouseEvent, instance.id, props.sectionKey)
"
>
<Button type="quiet" size="2xs" circular icon-only><slot name="moreIcon" /> </Button>
</div>
</div>
</template>
<p
v-if="instances.length === 0"
class="col-span-full m-0 pt-1 pl-0.5 text-base font-base text-[var(--color-text-tertiary)] opacity-80"
>
{{ formatMessage(messages.emptyGroup) }}
</p>
</section>
</div>
</div>
<ContextMenu ref="groupOptions" @option-clicked="handleGroupOption">
<template #new_instance>
<PlusIcon />
{{ formatMessage(messages.newInstance) }}
</template>
<template #add_instances>
<PlusIcon />
{{ formatMessage(messages.addToGroup) }}
</template>
<template #edit_name>
<EditIcon />
{{ formatMessage(messages.editGroupName) }}
</template>
<template #delete_group>
<TrashIcon />
{{ formatMessage(messages.deleteGroup) }}
</template>
</ContextMenu>
<NewModal
ref="confirmDeleteGroupModal"
:header="formatMessage(messages.deleteGroup)"
fade="danger"
width="500px"
>
<p class="m-0 text-base text-[var(--color-text-default)]">
{{ formatMessage(messages.deleteGroupDescription) }}
</p>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" @click="confirmDeleteGroupModal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button type="colored" color="red" :disabled="deletingGroup" @click="removeGroup">
<TrashIcon />
{{ formatMessage(messages.deleteGroup) }}
</Button>
</div>
</template>
</NewModal>
</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')
}
@@ -0,0 +1,313 @@
<template>
<NewModal ref="modal" no-padding scrollable actions-divider max-width="560px" width="560px">
<template #title>
<span class="text-2xl font-semibold text-[var(--color-text-primary)]">
{{
existingGroupName
? formatMessage(messages.addToGroupTitle, { groupName: existingGroupName })
: formatMessage(messages.header)
}}
</span>
</template>
<template v-if="!existingGroupName">
<div class="flex flex-col gap-2.5 p-6">
<label for="new-group-name" class="font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.groupName) }}
</label>
<StyledInput
id="new-group-name"
ref="groupNameInput"
v-model="newGroupName"
:placeholder="formatMessage(messages.groupNamePlaceholder)"
:maxlength="32"
@click="groupNameInput?.select()"
/>
</div>
<div class="h-px bg-divider" />
</template>
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
<div class="px-6">
<StyledInput
v-model="newGroupSearch"
:icon="SearchIcon"
:placeholder="formatMessage(messages.searchInstance)"
class="w-full"
/>
</div>
<div
v-if="newGroupInstances.length === 0"
class="flex items-center justify-center py-12 text-[var(--color-text-tertiary)]"
>
{{ formatMessage(messages.noInstancesFound) }}
</div>
<div v-else class="flex flex-col gap-1">
<div
v-for="instance in newGroupInstances"
:key="instance.id"
class="flex items-center justify-between gap-4 px-6 py-1.5 hover:bg-surface-3"
:class="{ 'opacity-60': selectedNewGroupInstanceIds.has(instance.id) }"
>
<div class="flex min-w-0 items-center gap-2.5">
<InstanceIcon
:icon-path="instance.icon_path"
:instance-id="instance.id"
:loader="instance.loader"
class="!size-[2rem] !rounded-md"
/>
<div class="flex min-w-0 items-center gap-2">
<span class="truncate font-semibold text-[var(--color-text-primary)]">{{
instance.name
}}</span>
<TagItem v-if="instance.groups && instance.groups.length > 0" class="shrink-0">
{{ getGroupName(instance.groups[0]) }}
</TagItem>
</div>
</div>
<Button
:type="selectedNewGroupInstanceIds.has(instance.id) ? 'outlined' : 'base'"
@click="toggleNewGroupInstance(instance.id)"
>
<CheckIcon v-if="selectedNewGroupInstanceIds.has(instance.id)" />
{{
formatMessage(
selectedNewGroupInstanceIds.has(instance.id) ? messages.added : messages.add,
)
}}
</Button>
</div>
</div>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<Button type="outlined" @click="modal?.hide()">
<XIcon />
{{ formatMessage(messages.cancel) }}
</Button>
<Button
type="colored"
color="brand"
:disabled="existingGroupName ? !canSaveGroups : !canCreateGroup"
@click="existingGroupName ? handleSaveGroups() : handleCreateGroup()"
>
<SpinnerIcon v-if="creatingGroup" class="animate-spin" />
<CheckIcon v-else-if="existingGroupName" />
<PlusIcon v-else />
{{ formatMessage(existingGroupName ? messages.saveChanges : messages.createGroup) }}
</Button>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { Button, defineMessages, NewModal, StyledInput, TagItem, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { FAVORITES_GROUP_ID } from '@/composables/useInstanceGroups'
import { list } from '@/helpers/instance'
import { create_group, list_groups, set_group_memberships } from '@/helpers/instance-groups'
import type { GameInstance } from '@/helpers/types'
const { formatMessage } = useVIntl()
const props = defineProps<{
instanceIds: string[]
existingGroupName?: string
existingGroupId?: string
}>()
const emit = defineEmits<{
(e: 'applied'): void
}>()
const messages = defineMessages({
header: {
id: 'app.instances.batch-edit-groups.header',
defaultMessage: 'Create group',
},
addToGroupTitle: {
id: 'app.instances.batch-edit-groups.add-to-group-title',
defaultMessage: 'Add instances to "{groupName}"',
},
groupName: {
id: 'app.instances.batch-edit-groups.group-name',
defaultMessage: 'Group name',
},
groupNamePlaceholder: {
id: 'app.instances.batch-edit-groups.group-name-placeholder',
defaultMessage: 'Enter group name',
},
searchInstance: {
id: 'app.instances.batch-edit-groups.search-instance',
defaultMessage: 'Search instances',
},
noInstancesFound: {
id: 'app.instances.batch-edit-groups.no-instances-found',
defaultMessage: 'No instances found',
},
add: {
id: 'app.instances.batch-edit-groups.add',
defaultMessage: 'Add',
},
added: {
id: 'app.instances.batch-edit-groups.added',
defaultMessage: 'Added',
},
cancel: {
id: 'app.instances.batch-edit-groups.cancel',
defaultMessage: 'Cancel',
},
createGroup: {
id: 'app.instances.batch-edit-groups.create',
defaultMessage: 'Create group',
},
pinnedGroupName: {
id: 'app.instances.group.pinned',
defaultMessage: 'Pinned',
},
saveChanges: {
id: 'app.instances.batch-edit-groups.save-changes',
defaultMessage: 'Save',
},
})
const modal = ref<InstanceType<typeof NewModal>>()
const groupNameInput = ref<InstanceType<typeof StyledInput>>()
const newGroupName = ref('')
const newGroupSearch = ref('')
const creatingGroup = ref(false)
const allInstances = ref<GameInstance[]>([])
const groupNameMap = ref(new Map<string, string>())
const selectedNewGroupInstanceIds = ref(new Set<string>())
const canCreateGroup = computed(() => newGroupName.value.trim().length > 0)
const canSaveGroups = computed(() => {
if (!props.existingGroupName) return false
const groupId = props.existingGroupId
if (!groupId) return false
return allInstances.value.some((instance) => {
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
const hasGroup = (instance.groups || []).includes(groupId)
return isSelected !== hasGroup
})
})
const newGroupInstances = computed(() => {
const search = newGroupSearch.value.toLowerCase()
return allInstances.value.filter((instance) => {
if (!search) return true
return instance.name.toLowerCase().includes(search)
})
})
function getGroupName(groupId: string) {
if (groupId === FAVORITES_GROUP_ID) {
return formatMessage(messages.pinnedGroupName)
}
return groupNameMap.value.get(groupId) || groupId
}
function show(ids?: string[]) {
newGroupSearch.value = ''
creatingGroup.value = false
Promise.all([list(), list_groups()]).then(([instances, groups]) => {
allInstances.value = instances as GameInstance[]
const map = new Map<string, string>()
for (const g of groups) {
map.set(g.id, g.name)
}
groupNameMap.value = map
if (!props.existingGroupName) {
const customGroups = groups.filter((g: { id: string }) => g.id !== 'group:favorites')
let groupNumber = customGroups.length + 1
const existingNames = new Set(groups.map((g: { name: string }) => g.name.toLowerCase()))
while (existingNames.has(`group ${groupNumber}`)) {
groupNumber++
}
newGroupName.value = `Group ${groupNumber}`
}
if (props.existingGroupName) {
const groupId = props.existingGroupId
selectedNewGroupInstanceIds.value = new Set(
allInstances.value.filter((i) => (i.groups || []).includes(groupId || '')).map((i) => i.id),
)
} else {
selectedNewGroupInstanceIds.value = new Set(ids ?? props.instanceIds)
}
})
modal.value?.show()
}
function toggleNewGroupInstance(instanceId: string) {
const next = new Set(selectedNewGroupInstanceIds.value)
if (next.has(instanceId)) {
next.delete(instanceId)
} else {
next.add(instanceId)
}
selectedNewGroupInstanceIds.value = next
}
async function handleCreateGroup() {
const name = newGroupName.value.trim()
if (!name || selectedNewGroupInstanceIds.value.size === 0) return
creatingGroup.value = true
const group = await create_group(name).catch(() => null)
if (!group) {
creatingGroup.value = false
return
}
if (selectedNewGroupInstanceIds.value.size > 0) {
const updates = [...selectedNewGroupInstanceIds.value].map((instanceId) => ({
instance_id: instanceId,
group_ids: [group.id],
}))
await set_group_memberships(updates).catch(() => {})
}
creatingGroup.value = false
modal.value?.hide()
emit('applied')
}
async function handleSaveGroups() {
if (!props.existingGroupName) return
creatingGroup.value = true
const groupId = props.existingGroupId
const changedInstances = allInstances.value.filter((instance) => {
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
const hasGroup = (instance.groups || []).includes(groupId || '')
return isSelected !== hasGroup
})
const updates = changedInstances.map((instance) => {
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
return {
instance_id: instance.id,
group_ids: isSelected ? [groupId!] : [],
}
})
await set_group_memberships(updates).catch(() => {})
creatingGroup.value = false
modal.value?.hide()
emit('applied')
}
defineExpose({ show })
</script>
@@ -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'
@@ -38,6 +40,26 @@ const messages = defineMessages({
id: 'app.settings.defaults.maximize-window.unsupported',
defaultMessage: 'Not supported on this operating system.',
},
windowTitleEnabled: {
id: 'app.settings.defaults.window-title.enabled',
defaultMessage: 'Custom Minecraft window title',
},
windowTitleEnabledDescription: {
id: 'app.settings.defaults.window-title.enabled-description',
defaultMessage: 'Allow instances to customize the Minecraft window title.',
},
defaultWindowTitle: {
id: 'app.settings.defaults.window-title.default',
defaultMessage: 'Default window title',
},
defaultWindowTitleDescription: {
id: 'app.settings.defaults.window-title.default-description',
defaultMessage: 'Used when an instance leaves its window title empty.',
},
defaultWindowTitlePlaceholder: {
id: 'app.settings.defaults.window-title.default-placeholder',
defaultMessage: 'Enter default window title...',
},
width: { id: 'app.settings.defaults.width', defaultMessage: 'Width' },
widthDescription: {
id: 'app.settings.defaults.width-description',
@@ -212,6 +234,36 @@ watch(
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-defaults-window-title" tabindex="-1">
{{ formatMessage(messages.windowTitleEnabled) }}
</span>
</template>
<template #description>{{
formatMessage(messages.windowTitleEnabledDescription)
}}</template>
<template #control>
<Toggle id="custom-window-title-enabled" v-model="settings.custom_window_title_enabled" />
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>{{ formatMessage(messages.defaultWindowTitle) }}</template>
<template #description>{{
formatMessage(messages.defaultWindowTitleDescription)
}}</template>
<template #control>
<StyledInput
id="default-window-title"
v-model="settings.default_window_title"
:disabled="!settings.custom_window_title_enabled"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.defaultWindowTitlePlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
@@ -341,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,192 @@
<script setup lang="ts">
import { DownloadIcon, FolderOpenIcon, XIcon } from '@modrinth/assets'
import {
Button,
Checkbox,
Combobox,
commonMessages,
defineMessages,
injectNotificationManager,
injectPopupNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { exportLauncherLogs, highlightInFolder } from '@/helpers/utils'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const popupNotificationManager = injectPopupNotificationManager()
const modal = ref<InstanceType<typeof NewModal>>()
const exporting = ref(false)
const selectedRange = ref<'last30Minutes' | 'last2Hours' | 'all'>('last2Hours')
const selectedLevel = ref<'all' | 'debug' | 'info'>('all')
const includeSystemInfo = ref(true)
const includeInstanceLogs = ref(false)
const includeCrashAnalysis = ref(false)
const messages = defineMessages({
title: { id: 'app.settings.logs.export.modal.title', defaultMessage: 'Export logs' },
description: {
id: 'app.settings.logs.export.modal.description',
defaultMessage:
'Choose how much detail to include. Exported logs have access tokens, Minecraft tokens, and IP addresses replaced with placeholders.',
},
rangeLabel: { id: 'app.settings.logs.export.modal.range', defaultMessage: 'Time range' },
rangeLast30Minutes: {
id: 'app.settings.logs.export.modal.range.30-minutes',
defaultMessage: 'Last 30 minutes',
},
rangeLast2Hours: {
id: 'app.settings.logs.export.modal.range.2-hours',
defaultMessage: 'Last 2 hours',
},
rangeAll: { id: 'app.settings.logs.export.modal.range.all', defaultMessage: 'All sessions' },
levelLabel: { id: 'app.settings.logs.export.modal.level', defaultMessage: 'Log level' },
levelAll: { id: 'app.settings.logs.export.modal.level.all', defaultMessage: 'All levels' },
levelDebug: {
id: 'app.settings.logs.export.modal.level.debug',
defaultMessage: 'Debug and above',
},
levelInfo: { id: 'app.settings.logs.export.modal.level.info', defaultMessage: 'Info and above' },
extrasLabel: {
id: 'app.settings.logs.export.modal.extras',
defaultMessage: 'Additional information',
},
systemInfo: {
id: 'app.settings.logs.export.modal.system-info',
defaultMessage: 'System and environment summary',
},
instanceLogs: {
id: 'app.settings.logs.export.modal.instance-logs',
defaultMessage: 'Most recent instance log',
},
crashAnalysis: {
id: 'app.settings.logs.export.modal.crash-analysis',
defaultMessage: 'Crash analysis (when a crash is detected)',
},
cancel: { id: 'app.settings.logs.export.modal.cancel', defaultMessage: 'Cancel' },
confirm: { id: 'app.settings.logs.export.modal.confirm', defaultMessage: 'Export' },
exportedTitle: {
id: 'app.settings.logs.export.notification-title',
defaultMessage: 'Logs exported',
},
exportedText: {
id: 'app.settings.logs.export.notification-text',
defaultMessage: 'The log archive was saved to {path}',
},
})
const rangeOptions = computed(() => [
{ value: 'last30Minutes', label: formatMessage(messages.rangeLast30Minutes) },
{ value: 'last2Hours', label: formatMessage(messages.rangeLast2Hours) },
{ value: 'all', label: formatMessage(messages.rangeAll) },
])
const levelOptions = computed(() => [
{ value: 'all', label: formatMessage(messages.levelAll) },
{ value: 'debug', label: formatMessage(messages.levelDebug) },
{ value: 'info', label: formatMessage(messages.levelInfo) },
])
async function exportLogs() {
exporting.value = true
try {
const path = await exportLauncherLogs({
range: selectedRange.value,
level: selectedLevel.value,
includeSystemInfo: includeSystemInfo.value,
includeInstanceLogs: includeInstanceLogs.value,
includeCrashAnalysis: includeCrashAnalysis.value,
})
if (path) {
modal.value?.hide()
popupNotificationManager.addPopupNotification({
title: formatMessage(messages.exportedTitle),
text: formatMessage(messages.exportedText, { path }),
type: 'success',
buttons: [
{
label: formatMessage(commonMessages.openInFolderButton),
icon: FolderOpenIcon,
action: () => highlightInFolder(path).catch(handleError),
},
],
})
}
} catch (error) {
handleError(error)
} finally {
exporting.value = false
}
}
defineExpose({
show: () => modal.value?.show(),
hide: () => modal.value?.hide(),
})
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
:closable="true"
:close-on-click-outside="true"
width="40rem"
scrollable
>
<div class="flex flex-col gap-4">
<p class="m-0">{{ formatMessage(messages.description) }}</p>
<div class="flex flex-col gap-2">
<span class="font-semibold text-[var(--color-text-primary)]">{{
formatMessage(messages.rangeLabel)
}}</span>
<Combobox
id="log-export-range"
v-model="selectedRange"
:name="formatMessage(messages.rangeLabel)"
:options="rangeOptions"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-[var(--color-text-primary)]">{{
formatMessage(messages.levelLabel)
}}</span>
<Combobox
id="log-export-level"
v-model="selectedLevel"
:name="formatMessage(messages.levelLabel)"
:options="levelOptions"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-[var(--color-text-primary)]">{{
formatMessage(messages.extrasLabel)
}}</span>
<Checkbox v-model="includeSystemInfo" :label="formatMessage(messages.systemInfo)" />
<Checkbox v-model="includeInstanceLogs" :label="formatMessage(messages.instanceLogs)" />
<Checkbox v-model="includeCrashAnalysis" :label="formatMessage(messages.crashAnalysis)" />
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<Button type="outlined" :disabled="exporting" @click="modal?.hide()"
><XIcon />
{{ formatMessage(messages.cancel) }}
</Button>
<Button type="colored" color="brand" :disabled="exporting" @click="exportLogs"
><DownloadIcon />
{{ formatMessage(messages.confirm) }}
</Button>
</div>
</template>
</NewModal>
</template>
@@ -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,66 @@
<script setup lang="ts">
import { FolderOpenIcon, UploadIcon } from '@modrinth/assets'
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import { showLauncherLogsFolder } from '@/helpers/utils'
import LogExportModal from './LogExportModal.vue'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const exportModal = ref<InstanceType<typeof LogExportModal>>()
const messages = defineMessages({
exportTitle: { id: 'app.settings.logs.export.title', defaultMessage: 'Export logs' },
exportDescription: {
id: 'app.settings.logs.export.description',
defaultMessage:
'Package launcher logs with an optional environment summary, instance log, and crash analysis.',
},
exportButton: { id: 'app.settings.logs.export.button', defaultMessage: 'Export logs…' },
openFolder: { id: 'app.settings.logs.open-folder', defaultMessage: 'Open logs folder' },
})
async function openLogsFolder() {
try {
await showLauncherLogsFolder()
} catch (error) {
handleError(error)
}
}
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsSection
:title="formatMessage(messages.exportTitle)"
:description="formatMessage(messages.exportDescription)"
title-id="settings-target-logs-export"
>
<SettingsRow stacked>
<template #label>
<span>{{ formatMessage(messages.exportTitle) }}</span>
</template>
<template #description>{{ formatMessage(messages.exportDescription) }}</template>
<template #control>
<div class="flex flex-wrap gap-2">
<Button type="colored" color="brand" @click="exportModal?.show()"
><UploadIcon />
{{ formatMessage(messages.exportButton) }}
</Button>
<Button @click="openLogsFolder"
><FolderOpenIcon />
{{ formatMessage(messages.openFolder) }}
</Button>
</div>
</template>
</SettingsRow>
</SettingsSection>
<LogExportModal ref="exportModal" />
</div>
</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'
@@ -13,6 +14,7 @@ export type SettingsCategoryId =
| 'updates'
| 'about'
| 'feature-flags'
| 'logs'
export type SettingsGroupId = 'launcher' | 'game' | 'data-privacy' | 'support' | 'developer'
@@ -44,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: {
@@ -116,6 +127,11 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
name: { id: 'app.settings.tabs.about', defaultMessage: 'About' },
group: 'support',
},
{
id: 'logs',
name: { id: 'app.settings.tabs.logs', defaultMessage: 'Logs' },
group: 'support',
},
{
id: 'feature-flags',
name: {
@@ -3,9 +3,11 @@ import {
BotIcon,
CoffeeIcon,
CpuIcon,
FileTextIcon,
GameIcon,
GaugeIcon,
InfoIcon,
KeyboardIcon,
LanguagesIcon,
LayoutTemplateIcon,
PaintbrushIcon,
@@ -48,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')),
@@ -78,6 +84,10 @@ const categoryContent: Record<SettingsCategoryId, Pick<SettingsCategory, 'icon'
content: defineAsyncComponent(() => import('./UpdateSettings.vue')),
},
about: { icon: InfoIcon, content: defineAsyncComponent(() => import('./AboutSettings.vue')) },
logs: {
icon: FileTextIcon,
content: defineAsyncComponent(() => import('./LogsSettings.vue')),
},
'feature-flags': {
icon: ToggleRightIcon,
content: defineAsyncComponent(() => import('./FeatureFlagSettings.vue')),
@@ -384,6 +384,16 @@ export const settingsSearchEntries: SettingsSearchEntry[] = [
categoryId: 'feature-flags',
label: message('settings.feature-flags.title', 'Feature flags'),
},
{
id: 'logs-export',
categoryId: 'logs',
targetId: 'settings-target-logs-export',
label: message('app.settings.logs.export.title', 'Export logs'),
description: message(
'app.settings.logs.export.description',
'Package launcher logs with an optional environment summary, instance log, and crash analysis.',
),
},
]
export function validateSettingsSearchEntries(entries = settingsSearchEntries): string[] {
@@ -0,0 +1,290 @@
<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' },
})
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'"
>
{{ preview.banned ? 'Banned' : 'OK' }}
</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">Email</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">Registered</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,90 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
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.',
},
})
const announcements = ref<SkinSiteAnnouncement[]>([])
const loading = ref(true)
const failed = ref(false)
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleDateString()
} catch {
return ''
}
}
onMounted(async () => {
try {
const result = await skinSiteAnnouncements()
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 {
failed.value = true
} finally {
loading.value = false
}
})
</script>
<template>
<div class="flex flex-col gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</h3>
<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="!announcements.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 announcements"
:key="item.id"
class="flex flex-col gap-1 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex items-start gap-2">
<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>
<span v-if="item.subtitle" class="truncate text-xs text-secondary">{{
item.subtitle
}}</span>
</div>
</div>
</div>
</template>
@@ -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,155 @@
<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="size-44 overflow-hidden rounded-full border-4 border-solid border-bg-raised bg-button-bg"
>
<img
v-if="image"
:src="image"
alt=""
class="size-full object-cover transition-transform duration-75"
:style="{ transform: `rotate(${previewRotation})` }"
draggable="false"
/>
</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 justify-between text-xs text-secondary">
<span>0°</span>
<span>90°</span>
<span>180°</span>
<span>270°</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,61 @@
<script setup lang="ts">
import { 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'
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)
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>
<div
class="flex items-center gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised px-4 py-3"
>
<HeartIcon class="size-5 shrink-0 text-brand" />
<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.total) }}: {{ summary.totalAmount }} ·
{{ formatMessage(messages.donors, { count: summary.donorCount }) }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
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' },
})
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 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
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>
<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="!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,154 @@
<script setup lang="ts">
import { CopyIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import SkinSiteAccountCard from './SkinSiteAccountCard.vue'
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 SkinSiteLoginModal from './SkinSiteLoginModal.vue'
import SkinSiteMapView from './SkinSiteMapView.vue'
import SkinSiteNavCards from './SkinSiteNavCards.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 { addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.home.title', defaultMessage: 'StarLight Skin Site' },
subtitle: {
id: 'app.skin-site.home.subtitle',
defaultMessage: 'The wardrobe for StarLight Server players.',
},
address: { id: 'app.skin-site.home.address', defaultMessage: 'Skin site address' },
copied: { id: 'app.skin-site.home.copied', defaultMessage: 'Address copied.' },
})
const YGGDRASIL_URL = 'https://skin.starlight.cool/yggdrasil'
const loginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
const profileModal = ref<InstanceType<typeof SkinSiteProfileModal>>()
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)
}
async function copyAddress() {
try {
await navigator.clipboard.writeText(YGGDRASIL_URL)
addNotification({ type: 'success', title: formatMessage(messages.copied) })
} catch {
// Clipboard may be unavailable; the address stays selectable.
}
}
</script>
<template>
<div class="flex h-full min-h-0 w-full flex-col overflow-y-auto">
<div class="mx-auto flex w-full max-w-5xl flex-col gap-6 p-6">
<SkinSiteProfileView v-if="view === 'players'" @back="view = 'home'" />
<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>
<header class="flex flex-col items-center gap-1 text-center">
<h1 class="m-0 text-2xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
</header>
<SkinSiteAccountCard
@sign-in="loginModal?.show()"
@open-settings="profileModal?.show()"
@open-players="view = 'players'"
@open-admin="view = 'admin'"
/>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SkinSiteNavCards @navigate="(v) => (view = v)" />
<SkinSiteAnnouncements />
</div>
<SkinSiteDonationCard />
<div
class="flex flex-wrap items-center gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised px-4 py-3"
>
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.address)
}}</span>
<code class="min-w-0 flex-1 truncate text-xs text-secondary">{{ YGGDRASIL_URL }}</code>
<button
type="button"
class="flex shrink-0 items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="copyAddress"
>
<CopyIcon class="size-3.5" />
{{ formatMessage({ id: 'app.skin-site.home.copy', defaultMessage: 'Copy' }) }}
</button>
</div>
</template>
</div>
<SkinSiteLoginModal ref="loginModal" />
<SkinSiteProfileModal ref="profileModal" />
<SkinSiteBugCreateModal ref="bugCreateModal" @created="onBugCreated" />
</div>
</template>
@@ -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,145 @@
<script setup lang="ts">
import {
BookIcon,
BugIcon,
DownloadIcon,
MapIcon,
ShieldCheckIcon,
UserIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import type { Component } from 'vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'navigate', view: 'downloads' | 'review' | 'bugs' | 'map' | 'wiki'): 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.',
},
})
interface NavItem {
id: string
title: string
description: string
icon: Component
href: string
}
const SKIN_SITE = 'https://skin.starlight.cool'
const items: NavItem[] = [
{
id: 'download',
title: formatMessage(messages.download),
description: formatMessage(messages.downloadDesc),
icon: DownloadIcon,
href: `${SKIN_SITE}/download`,
},
{
id: 'review',
title: formatMessage(messages.review),
description: formatMessage(messages.reviewDesc),
icon: ShieldCheckIcon,
href: `${SKIN_SITE}/review`,
},
{
id: 'wiki',
title: formatMessage(messages.wiki),
description: formatMessage(messages.wikiDesc),
icon: BookIcon,
href: `${SKIN_SITE}/wiki`,
},
{
id: 'bugs',
title: formatMessage(messages.bugs),
description: formatMessage(messages.bugsDesc),
icon: BugIcon,
href: `${SKIN_SITE}/bugs`,
},
{
id: 'map',
title: formatMessage(messages.map),
description: formatMessage(messages.mapDesc),
icon: MapIcon,
href: `${SKIN_SITE}/map`,
},
{
id: 'profile',
title: formatMessage(messages.profile),
description: formatMessage(messages.profileDesc),
icon: UserIcon,
href: `${SKIN_SITE}/profile`,
},
]
function open(item: NavItem) {
if (
item.id === 'download' ||
item.id === 'review' ||
item.id === 'bugs' ||
item.id === 'map' ||
item.id === 'wiki'
) {
emit(
'navigate',
item.id === 'download'
? 'downloads'
: (item.id as 'review' | 'bugs' | 'map' | 'wiki'),
)
return
}
void import('@tauri-apps/plugin-opener').then(({ openUrl }) => openUrl(item.href))
}
</script>
<template>
<div class="flex flex-col gap-3">
<h3 class="m-0 text-base font-semibold text-contrast">{{ formatMessage(messages.title) }}</h3>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<button
v-for="item in items"
:key="item.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="open(item)"
>
<component :is="item.icon" class="size-5 text-brand" />
<span class="text-sm font-semibold text-contrast">{{ item.title }}</span>
<span class="text-xs text-secondary">{{ item.description }}</span>
</button>
</div>
</div>
</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,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,283 @@
<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 {
skinSiteDeleteTexture,
type SkinSitePlayerSkin,
skinSitePlayerSkin,
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' },
})
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 = {}
})
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(() => {})
})
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"
>
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</h3>
<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>
<button
v-for="player in players"
:key="player.uuid"
type="button"
class="flex items-center gap-2 rounded-xl px-2 py-1.5 text-left 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="player.isMojang" class="shrink-0 text-[10px] uppercase text-secondary"
>Mojang</span
>
</button>
</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 items-center gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">{{ selectedPlayer.name }}</h3>
<span v-if="selectedPlayer.isMojang" class="text-xs text-secondary">Mojang</span>
</div>
<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,192 @@
<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,
} from '@/helpers/skin-site-api'
import WikiNodeRenderer from './wiki/WikiNodeRenderer.vue'
const { formatMessage } = useVIntl()
const { handleError } = 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' },
})
const titleInput = ref(props.initialTitle)
const page = ref<SkinSiteWikiPageDetail | null>(null)
const loading = ref(false)
const notFound = ref(false)
const history = 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 && history.value[history.value.length - 1] !== currentTitle.value) {
history.value.push(currentTitle.value)
}
} catch (error) {
handleError(error)
notFound.value = true
page.value = null
} finally {
loading.value = false
}
}
function goBack() {
const prev = history.value.pop()
if (prev) void openPage(prev)
}
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
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="history.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>
</div>
<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>
<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>
<span class="text-[11px] text-secondary">
{{ formatMessage(messages.revision, { id: page.revisionId }) }}
</span>
</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>
</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,277 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteActivateLauncherRelease,
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.' },
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 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 (activatingId.value !== null) 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
}
}
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 || uploading.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="uploading"
@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="activatingId !== null"
@click="activate(release)"
>
{{ formatMessage(messages.activate) }}
</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,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,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 '#'
}
}
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { CheckIcon, RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
import {
type ArmorPreviewConfig,
ArmorTrimPanel,
Button,
defineMessages,
useVIntl,
} from '@modrinth/ui'
import { computed, onMounted, onUnmounted } from 'vue'
const messages = defineMessages({
title: { id: 'app.skins.armor.title', defaultMessage: 'Armour and trims' },
description: {
id: 'app.skins.armor.description',
defaultMessage: 'Try armour and trims on your skin. Nothing is stored until you save it.',
},
unsaved: { id: 'app.skins.armor.unsaved', defaultMessage: 'Unsaved changes' },
resetToSaved: { id: 'app.skins.armor.reset-to-saved', defaultMessage: 'Reset to saved' },
restoreDefaults: {
id: 'app.skins.armor.restore-defaults',
defaultMessage: 'Restore defaults',
},
save: { id: 'app.skins.armor.save', defaultMessage: 'Save' },
close: { id: 'app.skins.armor.close', defaultMessage: 'Close' },
})
const draft = defineModel<ArmorPreviewConfig>({ required: true })
const props = defineProps<{
saved: ArmorPreviewConfig
/** Stable id shared with the toggle so it can point `aria-controls` here. */
panelId: string
}>()
const emit = defineEmits<{
save: []
reset: []
defaults: []
close: []
}>()
const { formatMessage } = useVIntl()
/**
* The tab is mounted only while it is showing, so this listens exactly for that
* window. It listens in the capture phase because the app's own global
* shortcuts listen on `window` the same way, and a bubble-phase listener here
* would be skipped as soon as anything closer stopped the event. A modal on top
* of the page owns Escape while it is up, and focus sits inside it, so the key
* is left to it.
*/
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape') return
if (
event.target instanceof Element &&
event.target.closest('[role="dialog"], [role="alertdialog"], [aria-modal="true"]')
) {
return
}
emit('close')
}
onMounted(() => window.addEventListener('keydown', handleKeydown, true))
onUnmounted(() => window.removeEventListener('keydown', handleKeydown, true))
const isDirty = computed(() =>
Object.entries(draft.value).some(([slot, piece]) => {
const applied = props.saved[slot as keyof ArmorPreviewConfig]
return (
!applied ||
applied.material !== piece.material ||
applied.trimPattern !== piece.trimPattern ||
applied.trimMaterial !== piece.trimMaterial
)
}),
)
</script>
<template>
<div
:id="panelId"
role="region"
:aria-label="formatMessage(messages.title)"
class="flex min-h-[24rem] flex-col gap-4 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-4"
>
<header class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<h2 class="m-0 text-lg font-bold text-[var(--color-text-primary)]">
{{ formatMessage(messages.title) }}
</h2>
<p class="m-0 mt-1 text-sm leading-6 text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.description) }}
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2">
<span v-if="isDirty" class="text-sm font-semibold text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.unsaved) }}
</span>
<Button type="outlined" @click="emit('close')"
><XIcon />{{ formatMessage(messages.close) }}</Button
>
</div>
</header>
<ArmorTrimPanel v-model="draft" />
<footer
class="mt-auto flex flex-wrap items-center justify-between gap-2 border-0 border-t border-solid border-surface-5 pt-3"
>
<div class="flex flex-wrap items-center gap-2">
<Button type="outlined" :disabled="!isDirty" @click="emit('reset')"
><RotateCounterClockwiseIcon />{{ formatMessage(messages.resetToSaved) }}</Button
>
<Button type="outlined" @click="emit('defaults')">{{
formatMessage(messages.restoreDefaults)
}}</Button>
</div>
<Button type="colored" color="brand" :disabled="!isDirty" @click="emit('save')"
><CheckIcon />{{ formatMessage(messages.save) }}</Button
>
</footer>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
const { sections = 2, cards = 12 } = defineProps<{
sections?: number
cards?: number
}>()
</script>
<template>
<!--
Shaped after VirtualSkinSectionList: the same grid breakpoints and card
ratio, so the swap to real content does not move anything. Decorative, so
it stays out of the accessibility tree.
-->
<div class="flex flex-col gap-8" aria-hidden="true">
<div v-for="section in sections" :key="section" class="flex flex-col gap-4">
<div class="h-7 w-44 animate-pulse rounded-lg bg-surface-4" />
<div
class="grid w-full grid-cols-3 gap-3 min-[1300px]:grid-cols-4 min-[1750px]:grid-cols-5 min-[2050px]:grid-cols-6"
>
<div
v-for="card in cards"
:key="card"
class="aspect-[31/40] w-full animate-pulse rounded-[20px] bg-surface-4"
/>
</div>
</div>
</div>
</template>
@@ -1,714 +0,0 @@
import assert from 'node:assert/strict'
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()
})
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', {
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
},
},
},
})
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')
}
})
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('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',
},
])
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()
})
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('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', {
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
},
},
},
})
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
}
})
@@ -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,227 @@ 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()
})
const timer = setTimeout(() => {
stop()
reject(new Error('皮肤站登录状态仍在检查,请稍后重试。'))
}, 10_000)
})
}
function rejectPendingLuckRequests(message: string) {
for (const request of pendingLuckRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
function toUser(raw: ApiSkinSiteUser): SkinSiteUser {
return {
uuid: raw.uuid,
username: raw.username,
permissions: Array.isArray(raw.permissions) ? raw.permissions : [],
}
pendingLuckRequests.clear()
}
function rejectPendingPlayersRequests(message: string) {
for (const request of pendingPlayersRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
function clearSessionState() {
user.value = null
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
let initialized: Promise<void> | null = null
/**
* Resolve the current session from the stored JWT. 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
initialized = (async () => {
const token = getSkinSiteToken()
if (!token) {
status.value = 'signed-out'
return
}
status.value = 'checking'
const result = await skinSiteCurrentUser()
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'
}
})()
return initialized
}
/** Force a re-check (e.g. after the user signs in from another view). */
export function reloadSkinSiteSession(): Promise<void> {
initialized = null
return initSkinSiteSession()
}
export async function signInSkinSite(payload: {
username: string
password: string
clientId: string
}): Promise<SkinSiteResponse<{ username: string; jwt: string }>> {
const result = await skinSiteLogin(payload)
if (result.success) {
setSkinSiteToken(result.payload.jwt)
const current = await skinSiteCurrentUser()
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'
}
}
pendingPlayersRequests.clear()
return result
}
function rejectPendingSkinUpdateRequests(message: string) {
for (const request of pendingSkinUpdateRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
export function signOutSkinSite() {
clearSkinSiteToken()
clearSessionState()
status.value = 'signed-out'
initialized = null
}
/** Refresh the JWT; returns true when a new token was stored. */
export async function refreshSkinSiteSession(): Promise<boolean> {
if (status.value !== 'signed-in') return false
const result = await skinSiteRefresh()
if (result.success) {
setSkinSiteToken(result.payload.jwt)
return true
}
pendingSkinUpdateRequests.clear()
if (result.errorCode === 401 || result.errorCode === 403) signOutSkinSite()
return false
}
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
// ---------------------------------------------------------------------------
// 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() {
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()
}
if (status.value !== 'signed-in') {
throw new Error('请先在启动器中登录 StarLight 皮肤站,再下载整合包。')
}
let token = getSkinSiteToken()
if (!token) {
if (await refreshSkinSiteSession()) token = getSkinSiteToken()
}
if (!token) throw new Error('StarLight 登录凭据不可用,请重新登录。')
return token
}
// ---------------------------------------------------------------------------
// 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 [headDataUrl, skinDataUrl] = await Promise.all([
renderSkinHead(texture.skin),
fetchImageAsDataUrl(texture.skin),
])
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) {
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
}
export function selectSkinSitePlayer(playerId: string | null) {
@@ -171,267 +264,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' })
}
@@ -7,15 +7,22 @@ export const UNGROUPED_GROUP_KEY = '__ungrouped__'
export type GridGroupingOption = 'Group' | 'Loader' | 'Game version' | 'None'
export type GridSortOption =
| 'Name'
| 'Last played'
| 'Date created'
| 'Date modified'
| 'Game version'
'Name' | 'Last played' | 'Date created' | 'Date modified' | 'Game version'
export type GridSortDirection = 'asc' | 'desc'
export const defaultSortDirections: Record<GridSortOption, GridSortDirection> = {
Name: 'asc',
'Last played': 'desc',
'Date created': 'desc',
'Date modified': 'desc',
'Game version': 'asc',
}
export interface GridDisplayState {
group: GridGroupingOption
sortBy: GridSortOption
sortDirections: Partial<Record<GridSortOption, GridSortDirection>>
collapsedGroups: string[]
}
@@ -38,6 +45,7 @@ export function useGridGrouping<T extends Record<string, unknown>>(
{
group: 'Group',
sortBy: 'Name',
sortDirections: {},
collapsedGroups: [],
},
localStorage,
@@ -66,7 +74,7 @@ export function useGridGrouping<T extends Record<string, unknown>>(
state.value.collapsedGroups = [...collapsedSections]
}
function sortInstances(instances: T[], sortBy: GridSortOption): T[] {
function sortInstances(instances: T[], sortBy: GridSortOption, ascending: boolean): T[] {
const sorted = [...instances]
const getGameVersion = options.getGameVersion ?? ((i: T) => i.game_version ?? '')
const getLastPlayed = options.getLastPlayed ?? ((i: T) => i.last_played ?? 0)
@@ -83,16 +91,19 @@ export function useGridGrouping<T extends Record<string, unknown>>(
)
break
case 'Last played':
sorted.sort((a, b) => dayjs(getLastPlayed(b)).diff(dayjs(getLastPlayed(a))))
sorted.sort((a, b) => dayjs(getLastPlayed(a)).diff(dayjs(getLastPlayed(b))))
break
case 'Date created':
sorted.sort((a, b) => dayjs(getDateCreated(b)).diff(dayjs(getDateCreated(a))))
sorted.sort((a, b) => dayjs(getDateCreated(a)).diff(dayjs(getDateCreated(b))))
break
case 'Date modified':
sorted.sort((a, b) => dayjs(getDateModified(b)).diff(dayjs(getDateModified(a))))
sorted.sort((a, b) => dayjs(getDateModified(a)).diff(dayjs(getDateModified(b))))
break
}
if (!ascending) {
sorted.reverse()
}
return sorted
}
@@ -148,11 +159,25 @@ export function useGridGrouping<T extends Record<string, unknown>>(
function sortSections(
instanceMap: Map<string, T[]>,
group: GridGroupingOption,
sortBy: GridSortOption,
): Map<string, T[]> {
if (group !== 'Group' && group !== 'Game version') {
return instanceMap
}
if (group === 'Group' && sortBy !== 'Name') {
if (instanceMap.has(UNGROUPED_GROUP_KEY) && instanceMap.size > 1) {
const ungroupedInstances = instanceMap.get(UNGROUPED_GROUP_KEY)!
instanceMap.delete(UNGROUPED_GROUP_KEY)
const newMap = new Map<string, T[]>([[UNGROUPED_GROUP_KEY, ungroupedInstances]])
for (const [key, instances] of instanceMap) {
newMap.set(key, instances)
}
return newMap
}
return instanceMap
}
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
if (group === 'Group') {
if (a[0] === UNGROUPED_GROUP_KEY && b[0] !== UNGROUPED_GROUP_KEY) return -1
@@ -166,12 +191,30 @@ export function useGridGrouping<T extends Record<string, unknown>>(
return instanceMap
}
const sortDirection = computed<GridSortDirection>(() => {
return (
state.value.sortDirections[state.value.sortBy] ?? defaultSortDirections[state.value.sortBy]
)
})
const isSortAscending = computed(() => sortDirection.value === 'asc')
const toggleSortDirection = () => {
const current = sortDirection.value
const next: GridSortDirection = current === 'asc' ? 'desc' : 'asc'
state.value.sortDirections = {
...state.value.sortDirections,
[state.value.sortBy]: next,
}
}
const filteredResults = computed(() => {
const { group = 'Group', sortBy = 'Name' } = state.value
const ascending = isSortAscending.value
const sorted = sortInstances(instances.value, sortBy)
const sorted = sortInstances(instances.value, sortBy, ascending)
const grouped = groupInstances(sorted, group)
return sortSections(grouped, group)
return sortSections(grouped, group, sortBy)
})
return {
@@ -180,6 +223,9 @@ export function useGridGrouping<T extends Record<string, unknown>>(
filteredResults,
isSectionCollapsed,
setSectionCollapsed,
sortDirection,
isSortAscending,
toggleSortDirection,
UNGROUPED_GROUP_KEY,
}
}
}
@@ -0,0 +1,182 @@
import { computed, type Ref, ref, watch } from 'vue'
import { instance_groups_listener } from '@/helpers/events'
import {
create_group,
delete_group,
type InstanceGroupDefinition,
type InstanceGroupMembershipUpdate,
list_groups,
MAX_INSTANCE_GROUP_NAME_LENGTH,
rename_group,
set_group_memberships,
set_group_order,
} from '@/helpers/instance-groups'
export const FAVORITES_GROUP_ID = 'group:favorites'
export const UNGROUPED_GROUP_ID = 'group:none'
export { MAX_INSTANCE_GROUP_NAME_LENGTH }
export { type InstanceGroupDefinition, type InstanceGroupMembershipUpdate }
export interface InstanceGroupWithMembership {
key: string
name: string
instances: string[]
}
export function useInstanceGroups(instances: Ref<{ id: string; groups: string[] }[]>) {
const libraryGroups = ref<InstanceGroupDefinition[]>([])
const libraryGroupsLoaded = ref(false)
async function fetchGroups() {
libraryGroups.value = await list_groups()
libraryGroupsLoaded.value = true
}
fetchGroups()
instance_groups_listener(() => {
fetchGroups()
})
const orderedLibraryGroupIds = ref<string[]>([])
watch(
libraryGroups,
(groups) => {
orderedLibraryGroupIds.value = groups.map((g) => g.id)
},
{ immediate: true },
)
const instanceGroups = computed(() => {
if (!libraryGroupsLoaded.value) {
return []
}
const instanceGroupMap = new Map<string, string[]>()
for (const instance of instances.value) {
const groups = instance.groups ?? []
for (const groupId of groups) {
if (!instanceGroupMap.has(groupId)) {
instanceGroupMap.set(groupId, [])
}
instanceGroupMap.get(groupId)!.push(instance.id)
}
}
const allGroupIds = new Set<string>()
const groupMap = new Map<string, string[]>()
for (const groupId of orderedLibraryGroupIds.value) {
allGroupIds.add(groupId)
groupMap.set(groupId, instanceGroupMap.get(groupId) ?? [])
}
const ungroupedIds: string[] = []
for (const instance of instances.value) {
const instanceGroups = instance.groups ?? []
const isInAnyGroup = instanceGroups.some((g) => allGroupIds.has(g))
if (!isInAnyGroup) {
ungroupedIds.push(instance.id)
}
}
if (ungroupedIds.length > 0) {
groupMap.set(UNGROUPED_GROUP_ID, ungroupedIds)
}
return [...groupMap.entries()].map(([key, instances]) => ({
key,
name:
key === UNGROUPED_GROUP_ID
? 'Ungrouped'
: (libraryGroups.value.find((g) => g.id === key)?.name ?? key),
instances,
}))
})
const creatingGroup = ref(false)
const reorderingGroups = ref(false)
async function createNewGroup(name: string): Promise<string | null> {
creatingGroup.value = true
try {
const group = await create_group(name)
libraryGroups.value = [...libraryGroups.value, group]
orderedLibraryGroupIds.value = [...orderedLibraryGroupIds.value, group.id]
return group.id
} finally {
creatingGroup.value = false
}
}
async function renameGroupById(id: string, newName: string): Promise<boolean> {
if (id === FAVORITES_GROUP_ID) return false
const result = await rename_group(id, newName)
const idx = libraryGroups.value.findIndex((g) => g.id === id)
if (idx !== -1) {
libraryGroups.value = [
...libraryGroups.value.slice(0, idx),
result,
...libraryGroups.value.slice(idx + 1),
]
}
return true
}
async function deleteGroupById(id: string): Promise<boolean> {
if (id === FAVORITES_GROUP_ID) return false
await delete_group(id)
libraryGroups.value = libraryGroups.value.filter((g) => g.id !== id)
orderedLibraryGroupIds.value = orderedLibraryGroupIds.value.filter((gid) => gid !== id)
return true
}
async function reorderGroups(groupIds: string[]): Promise<boolean> {
reorderingGroups.value = true
try {
await set_group_order(groupIds)
// Favorites is rendered separately from draggable sections. Keep it in
// the local order after a drag even though the backend accepts only the
// reorderable group ids; otherwise it appears again as an ordinary
// section until the next full group refresh.
orderedLibraryGroupIds.value = [
FAVORITES_GROUP_ID,
...groupIds.filter((id) => id !== FAVORITES_GROUP_ID),
]
return true
} finally {
reorderingGroups.value = false
}
}
async function setMemberships(updates: InstanceGroupMembershipUpdate[]): Promise<boolean> {
await set_group_memberships(updates)
return true
}
function groupNameById(id: string): string | undefined {
if (id === UNGROUPED_GROUP_ID) return 'Ungrouped'
return libraryGroups.value.find((g) => g.id === id)?.name
}
return {
libraryGroups,
libraryGroupsLoaded,
orderedLibraryGroupIds,
instanceGroups,
creatingGroup,
reorderingGroups,
createGroup: createNewGroup,
renameGroupById,
deleteGroupById,
reorderGroups,
setMemberships,
groupNameById,
}
}
@@ -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,75 @@
interface InstanceProperties {
loader: string
game_version: string
}
interface ProjectProperties extends InstanceProperties {
id: string
project_type: string
}
type AnalyticsEventMap = {
Launched: { version: string; dev: boolean; onboarded: boolean }
PageView: { path: string; fromPath: string; failed: unknown }
InstanceCreate: { source: string }
InstanceCreateStart: { source: string }
InstanceStart: InstanceProperties & { source: string }
InstanceStop: Partial<InstanceProperties> & { source?: string }
InstanceDuplicate: InstanceProperties
InstanceRepair: InstanceProperties
InstanceSetIcon: Record<string, never>
InstanceRemoveIcon: Record<string, never>
InstanceUpdateAll: InstanceProperties & { count: number; selected: boolean }
InstanceProjectUpdate: InstanceProperties & { id: string; name: string; project_type: string }
InstanceProjectDisable: InstanceProperties & {
id: string
name: string
project_type: string
disabled: boolean
}
InstanceProjectRemove: InstanceProperties & { id: string; name: string; project_type: string }
ProjectInstall: ProjectProperties & { version_id: string; title: string; source: string }
ProjectInstallStart: { source: string }
PackInstall: { id: string; version_id: string; title: string; source: string }
PackInstallStart: Record<string, never>
AccountLogIn: { source?: string }
AccountLogOut: Record<string, never>
JavaTest: { path: string; success: boolean }
JavaManualSelect: { version: string }
JavaAutoDetect: { path: string; version: string }
GalleryImageNext: { project_id: string; url: string }
GalleryImagePrevious: { project_id: string; url: unknown }
GalleryImageExpand: { project_id: string; url: string }
}
export type AnalyticsEvent = keyof AnalyticsEventMap
let optedIn = false
let debugEnabled = false
export const initAnalytics = () => {
optedIn = true
}
export const debugAnalytics = () => {
debugEnabled = true
}
export const optOutAnalytics = () => {
optedIn = false
}
export const optInAnalytics = () => {
optedIn = true
}
type OptionalArgs<T> = Record<string, never> extends T ? [properties?: T] : [properties: T]
export const trackEvent = <E extends AnalyticsEvent>(
eventName: E,
...args: OptionalArgs<AnalyticsEventMap[E]>
) => {
if (optedIn && debugEnabled) {
console.debug('[Starlight telemetry disabled]', eventName, args[0])
}
}
+14
View File
@@ -82,6 +82,16 @@ export async function instance_bulk_update_progress_listener(callback) {
return await listen('instance_bulk_update_progress', (event) => callback(event.payload))
}
/// Payload for the 'instance_groups_changed' event
/*
InstanceGroupsChangedPayload {
instance_ids: string[]
}
*/
export async function instance_groups_listener(callback) {
return await listen('instance_groups_changed', (event) => callback(event.payload))
}
export async function install_job_listener(callback) {
return await listen('install_job', (event) => callback(event.payload))
}
@@ -150,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 {
@@ -1 +1,46 @@
export const MAX_INSTANCE_GROUP_NAME_LENGTH = 256
import { invoke } from '@tauri-apps/api/core'
export const MAX_INSTANCE_GROUP_NAME_LENGTH = 256
export type InstanceGroupDefinition = {
id: string
name: string
}
export type InstanceGroupMembershipUpdate = {
instance_id: string
group_ids: string[]
}
export async function list_groups(): Promise<InstanceGroupDefinition[]> {
return await invoke('plugin:instance|instance_list_groups')
}
export async function create_group(name: string): Promise<InstanceGroupDefinition> {
return await invoke('plugin:instance|instance_create_group', { name })
}
export async function rename_group(id: string, newName: string): Promise<InstanceGroupDefinition> {
return await invoke('plugin:instance|instance_rename_group', {
id,
newName,
})
}
export async function delete_group(id: string): Promise<void> {
return await invoke('plugin:instance|instance_delete_group', { id })
}
export async function set_group_order(group_ids: string[]): Promise<void> {
return await invoke('plugin:instance|instance_set_group_order', {
groupIds: group_ids,
})
}
export async function set_group_memberships(
updates: InstanceGroupMembershipUpdate[],
): Promise<void> {
return await invoke('plugin:instance|instance_set_group_memberships', {
updates,
})
}
+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))
}
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { mapBroadcastAnnouncements } from './remote-announcements.ts'
test('maps a broadcast entry into a remote announcement', () => {
const result = mapBroadcastAnnouncements([
{
id: 1,
title: '维护通知',
subtitle: '今晚停机',
content: '服务器将于今晚维护。',
updateAt: 1791640873710,
star: true,
importantLevel: 9,
source: 'SKIN',
},
])
assert.equal(result?.length, 1)
const item = result?.[0]
assert.equal(item?.id, '1')
assert.equal(item?.title, '维护通知')
assert.equal(item?.summary, '今晚停机')
assert.equal(item?.priority, 'critical')
assert.equal(item?.pinned, true)
assert.equal(item?.type, 'modal')
assert.equal(item?.published_at, new Date(1791640873710).toISOString())
assert.equal(item?.ends_at, null)
})
test('empty or blank subtitle becomes null summary', () => {
const result = mapBroadcastAnnouncements([
{ id: 2, title: '公告', subtitle: ' ', content: '内容', updateAt: 1000 },
])
assert.equal(result?.[0]?.summary, null)
})
test('skips entries missing required fields', () => {
const result = mapBroadcastAnnouncements([
{ id: 3, title: '有效', content: '内容', updateAt: 1000 },
{ id: 4, title: '', content: '内容', updateAt: 1000 },
{ title: '无 id', content: '内容', updateAt: 1000 },
{ id: 5, title: '无时间', content: '内容' },
null,
'不是对象',
])
assert.equal(result?.length, 1)
assert.equal(result?.[0]?.id, '3')
})
test('clamps importantLevel and maps priority boundaries', () => {
const result = mapBroadcastAnnouncements([
{ id: 6, title: 'a', content: 'x', updateAt: 1000, importantLevel: 0 },
{ id: 7, title: 'b', content: 'x', updateAt: 1000, importantLevel: 3 },
{ id: 8, title: 'c', content: 'x', updateAt: 1000, importantLevel: 6 },
{ id: 9, title: 'd', content: 'x', updateAt: 1000, importantLevel: 999 },
])
const byId = new Map(result?.map((item) => [item.id, item.priority]))
assert.equal(byId.get('6'), 'low')
assert.equal(byId.get('7'), 'normal')
assert.equal(byId.get('8'), 'high')
assert.equal(byId.get('9'), 'critical')
})
test('returns null for non-array payloads', () => {
assert.equal(mapBroadcastAnnouncements(null), null)
assert.equal(mapBroadcastAnnouncements({ payload: [] }), null)
assert.equal(mapBroadcastAnnouncements('nope'), null)
})
test('pinned announcements sort first', () => {
const result = mapBroadcastAnnouncements([
{ id: 10, title: '普通', content: 'x', updateAt: 2000, importantLevel: 9 },
{ id: 11, title: '置顶', content: 'x', updateAt: 1000, star: true },
])
assert.equal(result?.[0]?.id, '11')
assert.equal(result?.[1]?.id, '10')
})
@@ -5,6 +5,7 @@ export type RemoteAnnouncement = {
content: string
type: 'modal' | 'notification'
priority: 'low' | 'normal' | 'high' | 'critical'
pinned?: boolean
starts_at: string
ends_at: string | null
published_at: string
@@ -32,18 +33,28 @@ export function parseAnnouncements(value: unknown): RemoteAnnouncement[] | null
if (!entry || typeof entry !== 'object') continue
const item = entry as Record<string, unknown>
if (
typeof item.id !== 'string' || item.id.length > 100 || ids.has(item.id) ||
typeof item.title !== 'string' || !item.title.trim() || item.title.length > 120 ||
typeof item.content !== 'string' || item.content.length > 20000 ||
typeof item.id !== 'string' ||
item.id.length > 100 ||
ids.has(item.id) ||
typeof item.title !== 'string' ||
!item.title.trim() ||
item.title.length > 120 ||
typeof item.content !== 'string' ||
item.content.length > 20000 ||
(item.summary != null && (typeof item.summary !== 'string' || item.summary.length > 300)) ||
!['modal', 'notification'].includes(String(item.type)) ||
!['low', 'normal', 'high', 'critical'].includes(String(item.priority)) ||
typeof item.starts_at !== 'string' || !Number.isFinite(Date.parse(item.starts_at)) ||
typeof item.published_at !== 'string' || !Number.isFinite(Date.parse(item.published_at)) ||
(item.ends_at != null && (typeof item.ends_at !== 'string' || !Number.isFinite(Date.parse(item.ends_at)))) ||
(item.action_label != null && (typeof item.action_label !== 'string' || item.action_label.length > 80)) ||
typeof item.starts_at !== 'string' ||
!Number.isFinite(Date.parse(item.starts_at)) ||
typeof item.published_at !== 'string' ||
!Number.isFinite(Date.parse(item.published_at)) ||
(item.ends_at != null &&
(typeof item.ends_at !== 'string' || !Number.isFinite(Date.parse(item.ends_at)))) ||
(item.action_label != null &&
(typeof item.action_label !== 'string' || item.action_label.length > 80)) ||
(item.action_url != null && !safeAnnouncementUrl(item.action_url))
) continue
)
continue
ids.add(item.id)
items.push({
...item,
@@ -54,7 +65,12 @@ export function parseAnnouncements(value: unknown): RemoteAnnouncement[] | null
} as RemoteAnnouncement)
}
const rank = { low: 0, normal: 1, high: 2, critical: 3 }
return items.sort((first, second) => rank[second.priority] - rank[first.priority] || Date.parse(second.published_at) - Date.parse(first.published_at))
return items.sort(
(first, second) =>
Number(second.pinned ?? false) - Number(first.pinned ?? false) ||
rank[second.priority] - rank[first.priority] ||
Date.parse(second.published_at) - Date.parse(first.published_at),
)
}
export function isAnnouncementActive(item: RemoteAnnouncement, now = Date.now()) {
@@ -64,3 +80,76 @@ export function isAnnouncementActive(item: RemoteAnnouncement, now = Date.now())
export function announcementKey(item: RemoteAnnouncement) {
return item.id + ':' + item.published_at
}
export const REMOTE_ANNOUNCEMENTS_UPDATED_EVENT = 'starlight:remote-announcements-updated'
export const OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT = 'starlight:open-remote-announcement-center'
type BroadcastAnnouncementEntry = {
title?: unknown
subtitle?: unknown
content?: unknown
updateAt?: unknown
star?: unknown
importantLevel?: unknown
id?: unknown
}
function broadcastPriority(level: number): RemoteAnnouncement['priority'] {
if (level >= 9) return 'critical'
if (level >= 6) return 'high'
if (level >= 3) return 'normal'
return 'low'
}
export function mapBroadcastAnnouncements(payload: unknown): RemoteAnnouncement[] | null {
if (!Array.isArray(payload) || payload.length > 200) return null
const items: RemoteAnnouncement[] = []
const seen = new Set<string>()
for (const entry of payload) {
if (!entry || typeof entry !== 'object') continue
const item = entry as BroadcastAnnouncementEntry
if (
typeof item.id !== 'number' ||
!Number.isFinite(item.id) ||
typeof item.title !== 'string' ||
!item.title.trim() ||
item.title.length > 120 ||
typeof item.content !== 'string' ||
item.content.length > 20000 ||
typeof item.updateAt !== 'number' ||
!Number.isFinite(item.updateAt) ||
(item.subtitle != null && typeof item.subtitle !== 'string')
)
continue
const id = String(item.id)
if (seen.has(id)) continue
seen.add(id)
const level =
typeof item.importantLevel === 'number' && Number.isFinite(item.importantLevel)
? Math.max(0, Math.min(10, Math.floor(item.importantLevel)))
: 0
const starred = item.star === true
const published = new Date(item.updateAt).toISOString()
const summary = typeof item.subtitle === 'string' && item.subtitle.trim() ? item.subtitle : null
items.push({
id,
title: item.title,
summary,
content: item.content,
type: starred || level >= 8 ? 'modal' : 'notification',
priority: broadcastPriority(level),
pinned: starred,
starts_at: published,
ends_at: null,
published_at: published,
action_url: null,
action_label: null,
})
}
const rank = { low: 0, normal: 1, high: 2, critical: 3 }
return items.sort(
(first, second) =>
Number(second.pinned ?? false) - Number(first.pinned ?? false) ||
rank[second.priority] - rank[first.priority] ||
Date.parse(second.published_at) - Date.parse(first.published_at),
)
}
@@ -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]
}
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { loadSkinArmorPreview, saveSkinArmorPreview } from './skin-armor-preview.ts'
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage')
function withStorage(run: (values: Map<string, string>) => void): void {
const values = new Map<string, string>()
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
},
})
try {
run(values)
} finally {
if (originalStorage) Object.defineProperty(globalThis, 'localStorage', originalStorage)
else delete (globalThis as { localStorage?: Storage }).localStorage
}
}
test('armor preview selections survive storage round trips', () => {
withStorage(() => {
const config = loadSkinArmorPreview()
config.helmet.material = 'diamond'
config.helmet.trimPattern = 'sentry'
config.helmet.trimMaterial = 'gold'
saveSkinArmorPreview(config)
assert.deepEqual(loadSkinArmorPreview(), config)
})
})
test('invalid stored armor values fall back per slot and field', () => {
withStorage((values) => {
values.set(
'axolotl:skin-armor-preview',
JSON.stringify({
helmet: { material: 'diamond', trimPattern: 'invalid', trimMaterial: 'gold' },
boots: { material: 'turtle', trimPattern: 'sentry', trimMaterial: 'invalid' },
}),
)
const config = loadSkinArmorPreview()
assert.deepEqual(config.helmet, {
material: 'diamond',
trimPattern: null,
trimMaterial: 'gold',
})
assert.deepEqual(config.boots, {
material: null,
trimPattern: 'sentry',
trimMaterial: 'iron',
})
assert.equal(config.chestplate.material, null)
})
})
@@ -0,0 +1,47 @@
import {
ARMOR_SLOTS,
ARMOR_TRIM_MATERIALS,
ARMOR_TRIM_PATTERNS,
armorMaterialsForSlot,
type ArmorPreviewConfig,
createDefaultArmorPreviewConfig,
} from '@modrinth/ui'
const STORAGE_KEY = 'axolotl:skin-armor-preview'
function includes<T extends string>(values: readonly T[], value: unknown): value is T {
return typeof value === 'string' && values.some((item) => item === value)
}
export function loadSkinArmorPreview(): ArmorPreviewConfig {
const config = createDefaultArmorPreviewConfig()
try {
const saved = JSON.parse(globalThis.localStorage?.getItem(STORAGE_KEY) ?? 'null')
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return config
for (const slot of ARMOR_SLOTS) {
const piece = saved[slot]
if (!piece || typeof piece !== 'object' || Array.isArray(piece)) continue
if (piece.material === null || includes(armorMaterialsForSlot(slot), piece.material)) {
config[slot].material = piece.material
}
if (piece.trimPattern === null || includes(ARMOR_TRIM_PATTERNS, piece.trimPattern)) {
config[slot].trimPattern = piece.trimPattern
}
if (includes(ARMOR_TRIM_MATERIALS, piece.trimMaterial)) {
config[slot].trimMaterial = piece.trimMaterial
}
}
} catch {
// Invalid or unavailable local storage falls back to a fresh preview.
}
return config
}
export function saveSkinArmorPreview(config: ArmorPreviewConfig): void {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(config))
} catch {
// The preview remains usable when local storage is unavailable.
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,86 @@
/**
* 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) out of a skin image URL. Returns a data URL. */
export async function renderSkinHead(skinUrl: string, scale = 2): Promise<string> {
const img = await loadImage(skinUrl)
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 fetch(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)
})
}
+3
View File
@@ -55,6 +55,7 @@ export type GameInstance = {
force_fullscreen?: boolean
maximize_window?: boolean
game_resolution?: [number, number]
window_title?: string | null
launch_preparation_timeout?: number | null
hooks: Hooks
}
@@ -239,6 +240,8 @@ type AppSettings = {
force_fullscreen: boolean
maximize_window: boolean
game_resolution: [number, number]
custom_window_title_enabled: boolean
default_window_title: string
hide_on_process_start: boolean
enter_lightweight_mode_on_game_launch: boolean
auto_set_java_high_performance_mode: boolean
+32
View File
@@ -81,6 +81,38 @@ export async function exportErrorLogs(
return await invoke('plugin:utils|export_error_logs', { outputPath, errorMessage })
}
export async function exportLauncherLogs({
range = 'last2Hours',
level = 'all',
includeSystemInfo = true,
includeInstanceLogs = false,
includeCrashAnalysis = false,
fileNamePrefix = 'Starlight Launcher logs',
} = {}) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const outputPath = await save({
defaultPath: `${fileNamePrefix} ${timestamp}.zip`,
filters: [
{
name: 'ZIP archive',
extensions: ['zip'],
},
],
})
if (!outputPath) return null
await invoke('plugin:utils|export_launcher_logs', {
outputPath,
range,
level,
includeSystemInfo,
includeInstanceLogs,
includeCrashAnalysis,
})
return outputPath
}
export async function createInstanceShortcut(instanceName, instanceId, options = {}) {
const outputPath = await save({
defaultPath: `Modrinth - ${instanceName}`,
File diff suppressed because it is too large. Load diff
+915 -1
View File
@@ -11351,5 +11351,919 @@
"app.settings.synced-options.game-settings.value-off": { "message": "关闭" },
"app.settings.synced-options.game-settings.value-on": { "message": "开启" },
"app.settings.synced-options.game-settings.value-unset": { "message": "选择一个值" },
"app.anniversary.banner": { "message": "Starlight {ordinal} 周年快乐!" }
"app.anniversary.banner": { "message": "Starlight {ordinal} 周年快乐!" },
"instance.settings.tabs.window.window-title": { "message": "窗口标题" },
"instance.settings.tabs.window.window-title.description": { "message": "自定义 Minecraft 窗口标题。留空则使用默认标题。" },
"instance.settings.tabs.window.window-title.enter": { "message": "输入窗口标题..." },
"app.settings.defaults.window-title.enabled": { "message": "自定义 Minecraft 窗口标题" },
"app.settings.defaults.window-title.enabled-description": { "message": "允许实例自定义 Minecraft 窗口标题。" },
"app.settings.defaults.window-title.default": { "message": "默认窗口标题" },
"app.settings.defaults.window-title.default-description": { "message": "当实例未提供自己的标题时使用。" },
"app.settings.defaults.window-title.default-placeholder": { "message": "输入默认窗口标题..." },
"app.action-bar.announcements": { "message": "公告" },
"app.remote-announcements.center-title": { "message": "公告" },
"app.remote-announcements.empty": { "message": "暂无公告" },
"app.settings.tabs.logs": { "message": "日志与诊断" },
"app.settings.logs.export.title": { "message": "导出日志" },
"app.settings.logs.export.description": { "message": "将启动器日志打包,可附带环境摘要、最近的实例日志与崩溃分析。" },
"app.settings.logs.export.button": { "message": "导出日志…" },
"app.settings.logs.open-folder": { "message": "打开日志文件夹" },
"app.settings.logs.export.modal.title": { "message": "导出日志" },
"app.settings.logs.export.modal.description": { "message": "选择要包含的详细程度。导出的日志会把访问令牌、Minecraft 令牌与 IP 地址替换为占位符。" },
"app.settings.logs.export.modal.range": { "message": "时间范围" },
"app.settings.logs.export.modal.range.30-minutes": { "message": "最近 30 分钟" },
"app.settings.logs.export.modal.range.2-hours": { "message": "最近 2 小时" },
"app.settings.logs.export.modal.range.all": { "message": "全部会话" },
"app.settings.logs.export.modal.level": { "message": "日志等级" },
"app.settings.logs.export.modal.level.all": { "message": "全部等级" },
"app.settings.logs.export.modal.level.debug": { "message": "DEBUG 及以上" },
"app.settings.logs.export.modal.level.info": { "message": "INFO 及以上" },
"app.settings.logs.export.modal.extras": { "message": "附加信息" },
"app.settings.logs.export.modal.system-info": { "message": "系统与环境摘要" },
"app.settings.logs.export.modal.instance-logs": { "message": "最近的实例日志" },
"app.settings.logs.export.modal.crash-analysis": { "message": "崩溃分析(检测到崩溃时)" },
"app.settings.logs.export.modal.cancel": { "message": "取消" },
"app.settings.logs.export.modal.confirm": { "message": "导出" },
"app.settings.logs.export.notification-title": { "message": "日志已导出" },
"app.settings.logs.export.notification-text": { "message": "日志压缩包已保存到 {path}" },
"app.skins.armor.title": {"message": "盔甲与纹饰"},
"app.skins.armor.save": {"message": "保存"},
"app.skins.armor.close": {"message": "关闭"},
"app.skins.armor.description": {"message": "在皮肤上试穿盔甲与纹饰。未保存前不会写入任何内容。"},
"app.skins.armor.unsaved": {"message": "有未保存的更改"},
"app.skins.armor.reset-to-saved": {"message": "重置为已保存"},
"app.skins.armor.restore-defaults": {"message": "恢复默认"},
"app.instances.batch-edit-groups.add": {"message": "添加"},
"app.instances.batch-edit-groups.add-to-group-title": {"message": "添加实例到「{groupName}」"},
"app.instances.batch-edit-groups.added": {"message": "已添加"},
"app.instances.batch-edit-groups.cancel": {"message": "取消"},
"app.instances.batch-edit-groups.create": {"message": "创建分组"},
"app.instances.batch-edit-groups.group-name": {"message": "分组名称"},
"app.instances.batch-edit-groups.group-name-placeholder": {"message": "输入分组名称"},
"app.instances.batch-edit-groups.no-instances-found": {"message": "未找到实例"},
"app.instances.batch-edit-groups.save-changes": {"message": "保存"},
"app.instances.batch-edit-groups.search-instance": {"message": "搜索实例"},
"app.instances.group.add-to-group": {"message": "添加到分组"},
"app.instances.group.collapse": {"message": "折叠分组"},
"app.instances.group.delete-description": {"message": "此分组中的实例将被取消分组。"},
"app.instances.group.delete-group": {"message": "删除分组"},
"app.instances.group.edit-name": {"message": "编辑分组名称"},
"app.instances.group.empty": {"message": "拖放实例到此处添加。"},
"app.instances.group.expand": {"message": "展开分组"},
"app.instances.group.move-down": {"message": "下移分组"},
"app.instances.group.move-up": {"message": "上移分组"},
"app.instances.group.name-empty": {"message": "分组名称不能为空。"},
"app.instances.group.name-too-long": {"message": "分组名称不能超过 {maxLength} 个字符。"},
"app.instances.group.new-instance": {"message": "新建实例"},
"app.instances.group.pinned": {"message": "已置顶"},
"app.instances.group.rename-group": {"message": "重命名分组"},
"app.instances.sort-by": {"message": "排序方式"},
"app.instances.sort.direction.asc-alphabetical": {"message": "A–Z 正序"},
"app.instances.sort.direction.asc-date": {"message": "最早的在前"},
"app.instances.sort.direction.asc-recency": {"message": "最早游玩在前"},
"app.instances.sort.direction.asc-version": {"message": "旧版本在前"},
"app.instances.sort.direction.desc-alphabetical": {"message": "Z–A 倒序"},
"app.instances.sort.direction.desc-date": {"message": "最新的在前"},
"app.instances.sort.direction.desc-recency": {"message": "最近游玩在前"},
"app.instances.sort.direction.desc-version": {"message": "新版本在前"},
"app.library.context-menu.pin-instance": {"message": "置顶实例"},
"app.library.context-menu.remove-from-group": {"message": "移出分组"},
"app.library.context-menu.unpin-instance": {"message": "取消置顶"},
"app.library.group.new": {"message": "新建分组"},
"app.library.selection.new-group": {"message": "新建分组"},
"app.library.selection.remove-from-group": {"message": "移出分组"},
"app.log-share.settings.title": {"message": "日志分享与 AI 分析"},
"app.log-share.settings.description": {"message": "将崩溃诊断分享到 LogShare,获取结构化摘要与可选的 AI 分析。优先使用 LogShare,不可用时回退到 mclo.gs。"},
"app.log-share.settings.share-provider": {"message": "日志分享服务"},
"app.log-share.settings.share-provider-description": {"message": "分享诊断链接时上传到的目标服务。"},
"app.log-share.settings.ai-source": {"message": "AI 分析来源"},
"app.log-share.settings.ai-source-logshare": {"message": "LogShare AI"},
"app.log-share.settings.ai-source-custom": {"message": "自定义 AI 服务商"},
"app.log-share.settings.ai-source-description": {"message": "LogShare AI 在云端分析;自定义则使用下方配置的服务商。"},
"app.log-share.settings.auto-upload": {"message": "自动上传以获取结构化摘要"},
"app.log-share.settings.auto-upload-description": {"message": "仅在 LogShare 模式下生效。"},
"app.log-share.settings.multi-file": {"message": "多文件上传"},
"app.log-share.settings.multi-file-description": {"message": "同时上传 latest.log、崩溃报告、hs_err 以及启动器日志。"},
"app.log-share.settings.no-storage": {"message": "不落盘直接分析"},
"app.log-share.settings.no-storage-description": {"message": "直接提交内容进行分析并跳过存储上传。会关闭自动摘要。"},
"app.log-share.settings.show-progress": {"message": "显示 AI 分析进度"},
"app.log-share.settings.show-progress-description": {"message": "展示 LogAgent 运行期间的思考、工具及上限状态。"},
"app.log-share.shared-logs.title": {"message": "已分享日志"},
"app.log-share.shared-logs.description": {"message": "你分享过的所有诊断都记录在这里。删除条目会将其从记录中移除,并向分享服务请求删除。"},
"app.log-share.shared-logs.empty": {"message": "暂无已分享的日志。"},
"app.log-share.shared-logs.delete": {"message": "删除"},
"app.log-share.shared-logs.deleting": {"message": "删除中…"},
"app.log-share.shared-logs.deleted": {"message": "已删除共享日志。"},
"app.log-share.shared-logs.delete-failed": {"message": "删除已分享日志失败。"},
"app.log-share.shared-logs.truncated": {"message": "已截断"},
"app.minecraft-crash.share-fallback": {"message": "LogShare 不可用,已改用 mclo.gs 分享诊断信息。"},
"app.log-share.ai.action": {"message": "AI 深度分析"},
"app.log-share.summary.title": {"message": "LogShare 分析"},
"app.log-share.summary.loading": {"message": "正在加载结构化摘要…"},
"app.log-share.summary.empty": {"message": "LogShare 未返回这些日志的结构化摘要。"},
"app.log-share.summary.no-storage": {"message": "启用不落盘模式时无法获取结构化摘要。"},
"app.log-share.summary.failed": {"message": "无法加载 LogShare 摘要:{message}"},
"app.log-share.summary.problems": {"message": "问题"},
"app.log-share.summary.information": {"message": "信息"},
"app.minecraft-crash.logs.loading": {"message": "正在加载崩溃日志…"},
"app.minecraft-crash.logs.empty": {"message": "未找到本次启动产生的日志。"},
"app.minecraft-crash.logs.failed": {"message": "无法加载崩溃日志:{message}"},
"app.minecraft-crash.log-file.loading": {"message": "正在加载日志文件…"},
"app.minecraft-crash.log-file.failed": {"message": "无法加载此日志文件:{message}"},
"app.log-share.ai.tab": {"message": "LogAgent 深度分析"},
"app.log-share.ai.root-cause": {"message": "核心根因"},
"app.log-share.ai.confidence": {"message": "诊断置信度"},
"app.log-share.ai.steps": {"message": "推荐排障步骤"},
"app.log-share.ai.evidence": {"message": "支撑证据链"},
"app.log-share.ai.thinking": {"message": "思考中…"},
"app.log-share.ai.queued": {"message": "排队中,前方约 {position} 个任务"},
"app.log-share.ai.queued-front": {"message": "排队中,正在等待空闲槽位"},
"app.log-share.ai.tool": {"message": "正在使用工具:{name}"},
"app.log-share.ai.unknown-tool": {"message": "工具"},
"app.log-share.ai.tool-result": {"message": "已收到工具结果"},
"app.log-share.ai.limit": {"message": "已达到工具调用上限"},
"app.log-share.ai.working": {"message": "正在使用 LogAgent 分析…"},
"app.log-share.ai.failed": {"message": "LogAgent 分析失败:{message}"},
"app.shortcut-settings.change-shortcut": {"message": "更改「{action}」的快捷键"},
"app.shortcut-settings.enable": {"message": "启用快捷滚动"},
"app.shortcut-settings.end-action": {"message": "滚动到页面底部"},
"app.shortcut-settings.end-action-description": {"message": "将当前页面移动到底部。"},
"app.shortcut-settings.home-action": {"message": "滚动到页面顶部"},
"app.shortcut-settings.home-action-description": {"message": "将当前页面移动到顶部。"},
"app.shortcut-settings.nav-create": {"message": "新建实例"},
"app.shortcut-settings.nav-create-description": {"message": "前往新建实例。"},
"app.shortcut-settings.nav-description": {"message": "使用各自的快捷键跳转到对应菜单项。每个快捷键默认关闭,需逐个启用。"},
"app.shortcut-settings.nav-discover": {"message": "发现内容"},
"app.shortcut-settings.nav-discover-description": {"message": "前往浏览内容。"},
"app.shortcut-settings.nav-downloads": {"message": "下载"},
"app.shortcut-settings.nav-downloads-description": {"message": "前往下载。"},
"app.shortcut-settings.nav-home": {"message": "首页"},
"app.shortcut-settings.nav-home-description": {"message": "前往首页。"},
"app.shortcut-settings.nav-lab": {"message": "实验室"},
"app.shortcut-settings.nav-lab-description": {"message": "前往实验室。"},
"app.shortcut-settings.nav-library": {"message": "内容库"},
"app.shortcut-settings.nav-library-description": {"message": "前往内容库。"},
"app.shortcut-settings.nav-multiplayer": {"message": "多人游戏"},
"app.shortcut-settings.nav-multiplayer-description": {"message": "前往多人游戏。"},
"app.shortcut-settings.nav-settings": {"message": "设置"},
"app.shortcut-settings.nav-settings-description": {"message": "前往设置。"},
"app.shortcut-settings.nav-skins": {"message": "皮肤选择器"},
"app.shortcut-settings.nav-skins-description": {"message": "前往皮肤选择器。"},
"app.shortcut-settings.nav-title": {"message": "导航快捷键"},
"app.shortcut-settings.nav-worlds": {"message": "世界"},
"app.shortcut-settings.nav-worlds-description": {"message": "前往你的世界。"},
"app.shortcut-settings.page-down-action": {"message": "向下滚动一屏"},
"app.shortcut-settings.page-down-action-description": {"message": "将当前页面向下移动一屏。"},
"app.shortcut-settings.page-up-action": {"message": "向上滚动一屏"},
"app.shortcut-settings.page-up-action-description": {"message": "将当前页面向上移动一屏。"},
"app.shortcut-settings.scroll-description": {"message": "使用键盘在长页面中快速滚动。"},
"app.shortcut-settings.scroll-title": {"message": "快速滚动"},
"app.settings.tabs.shortcut-settings": {"message": "键盘快捷键"},
"app.skin-site.bugs.title": {"message": "问题与建议"},
"app.skin-site.bugs.back": {"message": "返回主页"},
"app.skin-site.bugs.subtitle": {"message": "反馈问题并跟踪处理进度。"},
"app.skin-site.bugs.loading": {"message": "加载中…"},
"app.skin-site.bugs.failed": {"message": "无法加载问题列表。"},
"app.skin-site.bugs.empty": {"message": "没有符合筛选条件的问题。"},
"app.skin-site.bugs.all": {"message": "全部"},
"app.skin-site.bugs.more": {"message": "加载更多"},
"app.skin-site.bugs.open-only": {"message": "仅显示未关闭"},
"app.skin-site.bugs.comments": {"message": "{count} 条评论"},
"app.skin-site.bugs.type.bug": {"message": "缺陷"},
"app.skin-site.bugs.type.feature": {"message": "功能建议"},
"app.skin-site.bugs.type.improvement": {"message": "改进"},
"app.skin-site.bugs.status.pending": {"message": "待处理"},
"app.skin-site.bugs.status.confirmed": {"message": "已确认"},
"app.skin-site.bugs.status.needs-info": {"message": "待补充信息"},
"app.skin-site.bugs.status.in-progress": {"message": "处理中"},
"app.skin-site.bugs.status.verifying": {"message": "验证中"},
"app.skin-site.bugs.status.closed": {"message": "已关闭"},
"app.skin-site.bugs.priority.critical": {"message": "紧急"},
"app.skin-site.bugs.priority.high": {"message": "高"},
"app.skin-site.bugs.priority.normal": {"message": "普通"},
"app.skin-site.bugs.priority.low": {"message": "低"},
"app.skin-site.bugs.resolution.fixed": {"message": "已修复"},
"app.skin-site.bugs.resolution.wont-fix": {"message": "不予修复"},
"app.skin-site.bugs.resolution.by-design": {"message": "设计如此"},
"app.skin-site.bugs.resolution.duplicate": {"message": "重复问题"},
"app.skin-site.bugs.resolution.cannot-reproduce": {"message": "无法复现"},
"app.skin-site.bugs.resolution.invalid": {"message": "无效"},
"app.skin-site.bug-detail.back": {"message": "返回列表"},
"app.skin-site.bug-detail.loading": {"message": "加载中…"},
"app.skin-site.bug-detail.failed": {"message": "无法加载该问题。"},
"app.skin-site.bug-detail.issue-no": {"message": "问题 #{id}"},
"app.skin-site.bug-detail.by": {"message": "由 {name} 提交"},
"app.skin-site.bug-detail.assignee": {"message": "负责人:{name}"},
"app.skin-site.bug-detail.unassigned": {"message": "未分配"},
"app.skin-site.bug-detail.description-empty": {"message": "未提供描述。"},
"app.skin-site.bug-detail.resolution": {"message": "处理结果:{name}"},
"app.skin-site.bug-detail.comments-title": {"message": "评论"},
"app.skin-site.bug-detail.comments-empty": {"message": "还没有评论,来抢沙发吧。"},
"app.skin-site.bug-detail.comments-loading": {"message": "加载评论中…"},
"app.skin-site.bug-detail.comment-placeholder": {"message": "写下你的评论…"},
"app.skin-site.bug-detail.comment-submit": {"message": "发表评论"},
"app.skin-site.bug-detail.comment-sending": {"message": "发送中…"},
"app.skin-site.bug-detail.comment-empty-error": {"message": "评论内容不能为空。"},
"app.skin-site.bug-detail.comment-posted": {"message": "评论已发表。"},
"app.skin-site.bug-detail.comment-deleted": {"message": "评论已删除。"},
"app.skin-site.bug-detail.delete": {"message": "删除"},
"app.skin-site.bug-detail.deleting": {"message": "删除中…"},
"app.skin-site.bug-detail.system": {"message": "系统"},
"app.skin-site.bug-create.title": {"message": "新建问题"},
"app.skin-site.bug-create.type": {"message": "类型"},
"app.skin-site.bug-create.title-field": {"message": "标题"},
"app.skin-site.bug-create.title-placeholder": {"message": "简要描述问题"},
"app.skin-site.bug-create.description": {"message": "描述"},
"app.skin-site.bug-create.description-placeholder": {"message": "发生了什么?如何复现?"},
"app.skin-site.bug-create.private": {"message": "私密问题"},
"app.skin-site.bug-create.private-hint": {"message": "仅你和维护者可见。"},
"app.skin-site.bug-create.images": {"message": "图片"},
"app.skin-site.bug-create.images-hint": {"message": "可选,最多 5 张图片。"},
"app.skin-site.bug-create.submit": {"message": "创建问题"},
"app.skin-site.bug-create.submitting": {"message": "创建中…"},
"app.skin-site.bug-create.created": {"message": "问题已创建。"},
"app.skin-site.bug-create.cancel": {"message": "取消"},
"app.skin-site.bug-notifications.title": {"message": "通知"},
"app.skin-site.bug-notifications.back": {"message": "返回主页"},
"app.skin-site.bug-notifications.subtitle": {"message": "你提交或关注的问题动态。"},
"app.skin-site.bug-notifications.loading": {"message": "加载中…"},
"app.skin-site.bug-notifications.failed": {"message": "无法加载通知。"},
"app.skin-site.bug-notifications.empty": {"message": "暂无新通知。"},
"app.skin-site.bug-notifications.item": {"message": "{name} 更新了问题 #{id}"},
"app.skin-site.bug-notifications.mark-all": {"message": "全部标为已读"},
"app.skin-site.bug-notifications.marked-all": {"message": "已将全部通知标为已读。"},
"app.skin-site.bugs.create": {"message": "新建问题"},
"app.skin-site.bugs.notifications": {"message": "通知"},
"app.skin-site.home.title": {"message": "星光皮肤站"},
"app.skin-site.home.subtitle": {"message": "星光服务器玩家的衣柜。"},
"app.skin-site.home.address": {"message": "皮肤站地址"},
"app.skin-site.home.copied": {"message": "地址已复制。"},
"app.skin-site.home.copy": {"message": "复制"},
"app.skin-site.admin.title": {"message": "管理控制台"},
"app.skin-site.admin.back": {"message": "返回主页"},
"app.skin-site.admin.no-access": {"message": "你没有访问权限。"},
"app.skin-site.admin.no-access-hint": {"message": "请使用管理员账号登录后使用本控制台。"},
"app.skin-site.admin.group.security": {"message": "系统安全"},
"app.skin-site.admin.group.users": {"message": "用户管理"},
"app.skin-site.admin.group.community": {"message": "社区管理"},
"app.skin-site.admin.group.resources": {"message": "资源分发"},
"app.skin-site.admin.item.servers": {"message": "服务器管理"},
"app.skin-site.admin.item.summary": {"message": "状态概览"},
"app.skin-site.admin.item.access-audit": {"message": "登录与认证审计"},
"app.skin-site.admin.item.users": {"message": "用户信息"},
"app.skin-site.admin.item.players": {"message": "玩家信息"},
"app.skin-site.admin.item.review": {"message": "玩家审核"},
"app.skin-site.admin.item.broadcast": {"message": "公告操作"},
"app.skin-site.admin.item.chat": {"message": "聊天室管理"},
"app.skin-site.admin.item.wiki": {"message": "维基管理"},
"app.skin-site.admin.item.donations": {"message": "捐赠管理"},
"app.skin-site.admin.item.mods": {"message": "Mod 管理"},
"app.skin-site.admin.item.launcher": {"message": "启动器管理"},
"app.skin-site.admin.item.downloads": {"message": "下载管理"},
"app.skin-site.home.admin": {"message": "管理"},
"app.skin-site.home.settings": {"message": "账号"},
"app.skin-site.home.sign-in": {"message": "登录"},
"app.skin-site.home.players": {"message": "角色"},
"app.skin-site.home.my-players": {"message": "我的角色"},
"app.skin-site.home.no-players": {"message": "尚未绑定角色。"},
"app.skin-site.home.loading": {"message": "加载中…"},
"app.skin-site.home.error": {"message": "加载失败。"},
"app.skin-site.home.luck": {"message": "幸运值"},
"app.skin-site.home.luck-unavailable": {"message": "不可用"},
"app.skin-site.home.signed-out-title": {"message": "未登录"},
"app.skin-site.home.signed-out-body": {"message": "登录后可管理你的账号与皮肤。"},
"app.skin-site.admin.summary.title": {"message": "系统概况"},
"app.skin-site.admin.summary.loading": {"message": "加载中…"},
"app.skin-site.admin.summary.failed": {"message": "无法加载状态。"},
"app.skin-site.admin.summary.uptime": {"message": "运行时长"},
"app.skin-site.admin.summary.memory": {"message": "内存占用"},
"app.skin-site.admin.summary.users": {"message": "用户数量"},
"app.skin-site.admin.summary.players": {"message": "玩家数量"},
"app.skin-site.admin.summary.realtime-hint": {"message": "实时 CPU 与 GC 图表依赖管理端 WebSocket,目前尚未接入。"},
"app.skin-site.admin.broadcast.title": {"message": "公告"},
"app.skin-site.admin.broadcast.loading": {"message": "加载中…"},
"app.skin-site.admin.broadcast.failed": {"message": "加载失败。"},
"app.skin-site.admin.broadcast.empty": {"message": "尚未发布过任何公告。"},
"app.skin-site.admin.broadcast.create": {"message": "发布新公告"},
"app.skin-site.admin.broadcast.save": {"message": "保存"},
"app.skin-site.admin.broadcast.cancel": {"message": "取消"},
"app.skin-site.admin.broadcast.edit": {"message": "编辑"},
"app.skin-site.admin.broadcast.remove": {"message": "删除"},
"app.skin-site.admin.broadcast.title-field": {"message": "标题"},
"app.skin-site.admin.broadcast.subtitle-field": {"message": "副标题"},
"app.skin-site.admin.broadcast.content-field": {"message": "内容"},
"app.skin-site.admin.broadcast.expire-at": {"message": "过期时间"},
"app.skin-site.admin.broadcast.star": {"message": "标星"},
"app.skin-site.admin.broadcast.level": {"message": "重要程度"},
"app.skin-site.admin.broadcast.image": {"message": "图片"},
"app.skin-site.admin.broadcast.remove-image": {"message": "移除图片"},
"app.skin-site.admin.broadcast.created": {"message": "公告已发布。"},
"app.skin-site.admin.broadcast.updated": {"message": "公告已更新。"},
"app.skin-site.admin.broadcast.deleted": {"message": "公告已删除。"},
"app.skin-site.admin.broadcast.confirm-delete": {"message": "确定删除「{title}」吗?此操作不可撤销。"},
"app.skin-site.admin.broadcast.uploader": {"message": "由 {name} 发布"},
"app.skin-site.admin.players.title": {"message": "玩家"},
"app.skin-site.admin.players.loading": {"message": "加载中…"},
"app.skin-site.admin.players.failed": {"message": "无法加载玩家列表。"},
"app.skin-site.admin.players.empty": {"message": "没有找到玩家。"},
"app.skin-site.admin.players.search": {"message": "搜索"},
"app.skin-site.admin.players.search-name": {"message": "名称"},
"app.skin-site.admin.players.search-owner": {"message": "所有者"},
"app.skin-site.admin.players.search-uuid": {"message": "UUID"},
"app.skin-site.admin.players.total": {"message": "共 {count} 名玩家"},
"app.skin-site.admin.players.prev": {"message": "上一页"},
"app.skin-site.admin.players.next": {"message": "下一页"},
"app.skin-site.admin.players.page": {"message": "第 {page} 页"},
"app.skin-site.admin.players.name": {"message": "名称"},
"app.skin-site.admin.players.owner": {"message": "所有者"},
"app.skin-site.admin.players.source": {"message": "来源"},
"app.skin-site.admin.players.legacy": {"message": "旧版适配"},
"app.skin-site.admin.players.actions": {"message": "操作"},
"app.skin-site.admin.players.edit": {"message": "编辑"},
"app.skin-site.admin.players.save": {"message": "保存"},
"app.skin-site.admin.players.cancel": {"message": "取消"},
"app.skin-site.admin.players.remove": {"message": "删除"},
"app.skin-site.admin.players.updated": {"message": "玩家已更新。"},
"app.skin-site.admin.players.deleted": {"message": "玩家已删除。"},
"app.skin-site.admin.players.confirm-delete": {"message": "确定删除玩家「{name}」吗?此操作不可撤销。"},
"app.skin-site.admin.players.mojang": {"message": "正版"},
"app.skin-site.admin.players.custom": {"message": "皮肤站"},
"app.skin-site.admin.users.title": {"message": "用户"},
"app.skin-site.admin.users.loading": {"message": "加载中…"},
"app.skin-site.admin.users.failed": {"message": "无法加载用户列表。"},
"app.skin-site.admin.users.empty": {"message": "没有找到用户。"},
"app.skin-site.admin.users.search": {"message": "搜索"},
"app.skin-site.admin.users.search-name": {"message": "用户名"},
"app.skin-site.admin.users.search-email": {"message": "邮箱"},
"app.skin-site.admin.users.search-uuid": {"message": "UUID"},
"app.skin-site.admin.users.total": {"message": "共 {count} 名用户"},
"app.skin-site.admin.users.prev": {"message": "上一页"},
"app.skin-site.admin.users.next": {"message": "下一页"},
"app.skin-site.admin.users.page": {"message": "第 {page} 页"},
"app.skin-site.admin.users.edit": {"message": "编辑"},
"app.skin-site.admin.users.save": {"message": "保存"},
"app.skin-site.admin.users.cancel": {"message": "取消"},
"app.skin-site.admin.users.remove": {"message": "删除"},
"app.skin-site.admin.users.players": {"message": "角色"},
"app.skin-site.admin.users.updated": {"message": "用户已更新。"},
"app.skin-site.admin.users.deleted": {"message": "用户已删除。"},
"app.skin-site.admin.users.confirm-delete": {"message": "确定删除用户「{name}」吗?此操作不可撤销。"},
"app.skin-site.admin.users.username": {"message": "用户名"},
"app.skin-site.admin.users.email": {"message": "邮箱"},
"app.skin-site.admin.users.qq": {"message": "QQ"},
"app.skin-site.admin.users.password": {"message": "新密码"},
"app.skin-site.admin.users.password-hint": {"message": "留空则保持原密码不变。"},
"app.skin-site.admin.users.banned": {"message": "封禁"},
"app.skin-site.admin.users.permissions": {"message": "权限"},
"app.skin-site.admin.users.permissions-hint": {"message": "以逗号分隔,例如 WEB_ADMIN, SERVER_ADMIN。"},
"app.skin-site.admin.users.no-players": {"message": "没有绑定的角色。"},
"app.skin-site.admin.users.registered": {"message": "注册时间"},
"app.skin-site.admin.review.title": {"message": "玩家审核"},
"app.skin-site.admin.review.loading": {"message": "加载中…"},
"app.skin-site.admin.review.failed": {"message": "无法加载申请列表。"},
"app.skin-site.admin.review.empty": {"message": "没有找到申请。"},
"app.skin-site.admin.review.total": {"message": "共 {count} 份申请"},
"app.skin-site.admin.review.filter-all": {"message": "全部"},
"app.skin-site.admin.review.filter-pending": {"message": "待处理"},
"app.skin-site.admin.review.filter-passed": {"message": "已通过"},
"app.skin-site.admin.review.filter-rejected": {"message": "已拒绝"},
"app.skin-site.admin.review.result-pending": {"message": "待处理"},
"app.skin-site.admin.review.result-passed": {"message": "已通过"},
"app.skin-site.admin.review.result-rejected": {"message": "已拒绝"},
"app.skin-site.admin.review.mode-auto": {"message": "自动"},
"app.skin-site.admin.review.mode-manual": {"message": "人工"},
"app.skin-site.admin.review.applicant": {"message": "申请人"},
"app.skin-site.admin.review.submitted-at": {"message": "提交时间"},
"app.skin-site.admin.review.score": {"message": "得分"},
"app.skin-site.admin.review.reviewer": {"message": "审核人"},
"app.skin-site.admin.review.details": {"message": "详情"},
"app.skin-site.admin.review.hide": {"message": "收起"},
"app.skin-site.admin.review.answers": {"message": "作答内容"},
"app.skin-site.admin.review.no-answers": {"message": "没有作答记录。"},
"app.skin-site.admin.review.auto-checks": {"message": "自动检查"},
"app.skin-site.admin.review.context": {"message": "提交环境"},
"app.skin-site.admin.review.ip": {"message": "IP"},
"app.skin-site.admin.review.user-agent": {"message": "浏览器标识"},
"app.skin-site.admin.review.activity": {"message": "行为记录"},
"app.skin-site.admin.review.duration": {"message": "时长"},
"app.skin-site.admin.review.hidden-time": {"message": "切出时长"},
"app.skin-site.admin.review.screen-switches": {"message": "切屏次数"},
"app.skin-site.admin.review.history": {"message": "审核记录"},
"app.skin-site.admin.review.accept": {"message": "通过"},
"app.skin-site.admin.review.reject": {"message": "拒绝"},
"app.skin-site.admin.review.reason": {"message": "理由"},
"app.skin-site.admin.review.accept-done": {"message": "申请已通过。"},
"app.skin-site.admin.review.reject-done": {"message": "申请已拒绝。"},
"app.skin-site.admin.review.regen-invite": {"message": "重发邀请码"},
"app.skin-site.admin.review.invite-regenerated": {"message": "邀请码已重新生成。"},
"app.skin-site.admin.review.invite-sent": {"message": "邮件已发送。"},
"app.skin-site.admin.review.invite-not-sent": {"message": "邮件未发送。"},
"app.skin-site.admin.review.prev": {"message": "上一页"},
"app.skin-site.admin.review.next": {"message": "下一页"},
"app.skin-site.admin.review.page": {"message": "第 {page} 页"},
"app.skin-site.admin.launcher.title": {"message": "启动器版本"},
"app.skin-site.admin.launcher.loading": {"message": "加载中…"},
"app.skin-site.admin.launcher.failed": {"message": "无法加载版本列表。"},
"app.skin-site.admin.launcher.empty": {"message": "尚未上传任何版本。"},
"app.skin-site.admin.launcher.latest-url": {"message": "最新版本地址"},
"app.skin-site.admin.launcher.version": {"message": "版本"},
"app.skin-site.admin.launcher.channel": {"message": "渠道"},
"app.skin-site.admin.launcher.uploader": {"message": "上传者"},
"app.skin-site.admin.launcher.uploaded-at": {"message": "上传时间"},
"app.skin-site.admin.launcher.published-at": {"message": "发布时间"},
"app.skin-site.admin.launcher.force-update": {"message": "强制更新"},
"app.skin-site.admin.launcher.active": {"message": "当前版本"},
"app.skin-site.admin.launcher.activate": {"message": "设为当前"},
"app.skin-site.admin.launcher.activated": {"message": "已设为当前版本。"},
"app.skin-site.admin.launcher.upload": {"message": "上传版本"},
"app.skin-site.admin.launcher.upload-hint": {"message": "选择发布包 ZIP(最大 4 GiB)。"},
"app.skin-site.admin.launcher.uploading": {"message": "上传中…"},
"app.skin-site.admin.launcher.upload-done": {"message": "版本已上传。"},
"app.skin-site.admin.launcher.force-flag": {"message": "强制更新"},
"app.skin-site.admin.launcher.prev": {"message": "上一页"},
"app.skin-site.admin.launcher.next": {"message": "下一页"},
"app.skin-site.admin.launcher.page": {"message": "第 {page} 页"},
"app.skin-site.admin.launcher.total": {"message": "共 {count} 个版本"},
"app.skin-site.admin.downloads.title": {"message": "资源下载"},
"app.skin-site.admin.downloads.loading": {"message": "加载中…"},
"app.skin-site.admin.downloads.failed": {"message": "无法加载资源列表。"},
"app.skin-site.admin.downloads.empty": {"message": "没有下载条目。"},
"app.skin-site.admin.downloads.categories": {"message": "分类"},
"app.skin-site.admin.downloads.all": {"message": "全部条目"},
"app.skin-site.admin.downloads.add-category": {"message": "添加"},
"app.skin-site.admin.downloads.category-name": {"message": "分类名称"},
"app.skin-site.admin.downloads.remove-category": {"message": "移除"},
"app.skin-site.admin.downloads.total": {"message": "共 {count} 个条目"},
"app.skin-site.admin.downloads.add-item": {"message": "新建条目"},
"app.skin-site.admin.downloads.save": {"message": "保存"},
"app.skin-site.admin.downloads.cancel": {"message": "取消"},
"app.skin-site.admin.downloads.edit": {"message": "编辑"},
"app.skin-site.admin.downloads.remove": {"message": "删除"},
"app.skin-site.admin.downloads.filename": {"message": "文件名"},
"app.skin-site.admin.downloads.description": {"message": "描述"},
"app.skin-site.admin.downloads.routes": {"message": "下载线路"},
"app.skin-site.admin.downloads.route-name": {"message": "名称"},
"app.skin-site.admin.downloads.route-url": {"message": "URL"},
"app.skin-site.admin.downloads.add-route": {"message": "添加线路"},
"app.skin-site.admin.downloads.remove-route": {"message": "移除"},
"app.skin-site.admin.downloads.statuses": {"message": "状态"},
"app.skin-site.admin.downloads.bind-category": {"message": "所属分类"},
"app.skin-site.admin.downloads.uploader": {"message": "由 {name} 上传"},
"app.skin-site.admin.downloads.created": {"message": "条目已创建。"},
"app.skin-site.admin.downloads.updated": {"message": "条目已更新。"},
"app.skin-site.admin.downloads.deleted": {"message": "条目已删除。"},
"app.skin-site.admin.downloads.category-added": {"message": "分类已添加。"},
"app.skin-site.admin.downloads.category-deleted": {"message": "分类已删除。"},
"app.skin-site.admin.downloads.confirm-delete-item": {"message": "确定删除「{name}」吗?此操作不可撤销。"},
"app.skin-site.admin.downloads.confirm-delete-category": {"message": "确定删除分类「{name}」吗?"},
"app.skin-site.admin.servers.title": {"message": "地图服务器"},
"app.skin-site.admin.servers.loading": {"message": "加载中…"},
"app.skin-site.admin.servers.failed": {"message": "无法加载服务器列表。"},
"app.skin-site.admin.servers.empty": {"message": "尚未配置地图服务器。"},
"app.skin-site.admin.servers.server-name": {"message": "服务器名称"},
"app.skin-site.admin.servers.internal": {"message": "内部"},
"app.skin-site.admin.servers.maps": {"message": "地图"},
"app.skin-site.admin.servers.map-name": {"message": "地图名称"},
"app.skin-site.admin.servers.map-url": {"message": "地图地址"},
"app.skin-site.admin.servers.add-map": {"message": "添加地图"},
"app.skin-site.admin.servers.remove-map": {"message": "移除"},
"app.skin-site.admin.servers.add-server": {"message": "添加服务器"},
"app.skin-site.admin.servers.remove-server": {"message": "删除服务器"},
"app.skin-site.admin.servers.save": {"message": "保存更改"},
"app.skin-site.admin.servers.saving": {"message": "保存中…"},
"app.skin-site.admin.servers.saved": {"message": "地图服务器已保存。"},
"app.skin-site.admin.audit.title": {"message": "登录与认证审计"},
"app.skin-site.admin.audit.loading": {"message": "加载中…"},
"app.skin-site.admin.audit.failed": {"message": "无法加载审计日志。"},
"app.skin-site.admin.audit.empty": {"message": "没有审计记录。"},
"app.skin-site.admin.audit.subject": {"message": "主体"},
"app.skin-site.admin.audit.action": {"message": "操作"},
"app.skin-site.admin.audit.source-ip": {"message": "来源 IP"},
"app.skin-site.admin.audit.reported-ip": {"message": "上报玩家 IP"},
"app.skin-site.admin.audit.count": {"message": "次数"},
"app.skin-site.admin.audit.first-at": {"message": "首次出现"},
"app.skin-site.admin.audit.last-at": {"message": "最近出现"},
"app.skin-site.admin.audit.proxy": {"message": "代理"},
"app.skin-site.admin.audit.internal": {"message": "内部"},
"app.skin-site.admin.audit.search-subject": {"message": "主体"},
"app.skin-site.admin.audit.search-ip": {"message": "IP"},
"app.skin-site.admin.audit.search-action": {"message": "操作"},
"app.skin-site.admin.audit.search": {"message": "搜索"},
"app.skin-site.admin.audit.clear": {"message": "清空"},
"app.skin-site.admin.audit.load-more": {"message": "加载更多"},
"app.skin-site.admin.audit.retention": {"message": "保留天数"},
"app.skin-site.admin.audit.record-limit": {"message": "记录上限"},
"app.skin-site.admin.audit.save-retention": {"message": "保存"},
"app.skin-site.admin.audit.retention-saved": {"message": "保留天数已更新。"},
"app.skin-site.admin.donations.title": {"message": "捐赠管理"},
"app.skin-site.admin.donations.loading": {"message": "加载中…"},
"app.skin-site.admin.donations.failed": {"message": "无法加载捐赠列表。"},
"app.skin-site.admin.donations.empty": {"message": "暂无捐赠记录。"},
"app.skin-site.admin.donations.total-amount": {"message": "总额"},
"app.skin-site.admin.donations.donor-count": {"message": "捐赠人数"},
"app.skin-site.admin.donations.donor": {"message": "捐赠者"},
"app.skin-site.admin.donations.amount": {"message": "金额"},
"app.skin-site.admin.donations.updated-at": {"message": "更新时间"},
"app.skin-site.admin.donations.external": {"message": "外部"},
"app.skin-site.admin.donations.linked": {"message": "已绑定"},
"app.skin-site.admin.donations.create": {"message": "新增捐赠"},
"app.skin-site.admin.donations.save": {"message": "保存"},
"app.skin-site.admin.donations.cancel": {"message": "取消"},
"app.skin-site.admin.donations.edit": {"message": "编辑"},
"app.skin-site.admin.donations.remove": {"message": "删除"},
"app.skin-site.admin.donations.amount-field": {"message": "金额"},
"app.skin-site.admin.donations.display-name": {"message": "显示名称"},
"app.skin-site.admin.donations.user-uuid": {"message": "用户 UUID"},
"app.skin-site.admin.donations.qq": {"message": "QQ"},
"app.skin-site.admin.donations.append-mode": {"message": "在原有金额上累加"},
"app.skin-site.admin.donations.created": {"message": "捐赠记录已创建。"},
"app.skin-site.admin.donations.updated": {"message": "捐赠记录已更新。"},
"app.skin-site.admin.donations.deleted": {"message": "捐赠记录已删除。"},
"app.skin-site.admin.donations.confirm-delete": {"message": "确定删除「{name}」的捐赠记录吗?此操作不可撤销。"},
"app.skin-site.admin.donations.load-more": {"message": "加载更多"},
"app.skin-site.admin.mods.title": {"message": "Mod 管理"},
"app.skin-site.admin.mods.tab-mods": {"message": "Mod 文件"},
"app.skin-site.admin.mods.tab-tags": {"message": "规则标签"},
"app.skin-site.admin.mods.tab-runtime": {"message": "运行时"},
"app.skin-site.admin.mods.loading": {"message": "加载中…"},
"app.skin-site.admin.mods.failed": {"message": "无法加载 Mod 列表。"},
"app.skin-site.admin.mods.empty": {"message": "没有找到 Mod。"},
"app.skin-site.admin.mods.total": {"message": "共 {count} 个 Mod"},
"app.skin-site.admin.mods.search": {"message": "按名称搜索"},
"app.skin-site.admin.mods.filter-all": {"message": "全部标签"},
"app.skin-site.admin.mods.prev": {"message": "上一页"},
"app.skin-site.admin.mods.next": {"message": "下一页"},
"app.skin-site.admin.mods.page": {"message": "第 {page} 页"},
"app.skin-site.admin.mods.name": {"message": "名称"},
"app.skin-site.admin.mods.version": {"message": "版本"},
"app.skin-site.admin.mods.mod-id": {"message": "Mod ID"},
"app.skin-site.admin.mods.size": {"message": "大小"},
"app.skin-site.admin.mods.loaders": {"message": "加载器"},
"app.skin-site.admin.mods.domain-rules": {"message": "域名规则"},
"app.skin-site.admin.mods.edit": {"message": "编辑"},
"app.skin-site.admin.mods.save": {"message": "保存"},
"app.skin-site.admin.mods.cancel": {"message": "取消"},
"app.skin-site.admin.mods.remove": {"message": "删除"},
"app.skin-site.admin.mods.updated": {"message": "Mod 已更新。"},
"app.skin-site.admin.mods.deleted": {"message": "Mod 已删除。"},
"app.skin-site.admin.mods.confirm-delete": {"message": "确定删除 Mod「{name}」吗?此操作不可撤销。"},
"app.skin-site.admin.mods.rule-tags": {"message": "规则标签"},
"app.skin-site.admin.mods.tag-name": {"message": "标签名称"},
"app.skin-site.admin.mods.tag-rules": {"message": "规则"},
"app.skin-site.admin.mods.tag-rules-hint": {"message": "每行一条规则。"},
"app.skin-site.admin.mods.new-tag": {"message": "新建标签"},
"app.skin-site.admin.mods.tag-created": {"message": "标签已创建。"},
"app.skin-site.admin.mods.tag-updated": {"message": "标签已更新。"},
"app.skin-site.admin.mods.tag-deleted": {"message": "标签已删除。"},
"app.skin-site.admin.mods.confirm-delete-tag": {"message": "确定删除标签「{name}」吗?"},
"app.skin-site.admin.mods.runtime": {"message": "运行时版本"},
"app.skin-site.admin.mods.manifest-url": {"message": "清单地址"},
"app.skin-site.admin.mods.upload-runtime": {"message": "上传运行时"},
"app.skin-site.admin.mods.runtime-version": {"message": "版本"},
"app.skin-site.admin.mods.uploading": {"message": "上传中…"},
"app.skin-site.admin.mods.runtime-uploaded": {"message": "运行时已上传。"},
"app.skin-site.admin.mods.activate": {"message": "设为当前"},
"app.skin-site.admin.mods.activated": {"message": "运行时已启用。"},
"app.skin-site.admin.mods.available": {"message": "可用"},
"app.skin-site.admin.mods.unavailable": {"message": "不可用"},
"app.skin-site.admin.mods.active": {"message": "当前版本"},
"app.skin-site.admin.mods.uploader": {"message": "由 {name} 上传"},
"app.skin-site.home.nav.title": {"message": "皮肤站"},
"app.skin-site.home.nav.download": {"message": "资源下载"},
"app.skin-site.home.nav.download-desc": {"message": "浏览服务器分享的文件。"},
"app.skin-site.home.nav.review": {"message": "玩家审核"},
"app.skin-site.home.nav.review-desc": {"message": "提交并审核玩家申请。"},
"app.skin-site.home.nav.wiki": {"message": "服务器维基"},
"app.skin-site.home.nav.wiki-desc": {"message": "服务器的指南与文档。"},
"app.skin-site.home.nav.bugs": {"message": "问题追踪"},
"app.skin-site.home.nav.bugs-desc": {"message": "反馈问题并跟踪处理进度。"},
"app.skin-site.home.nav.map": {"message": "服务器地图"},
"app.skin-site.home.nav.map-desc": {"message": "探索实时世界地图。"},
"app.skin-site.home.nav.profile": {"message": "个人资料"},
"app.skin-site.home.nav.profile-desc": {"message": "管理你的账号与皮肤。"},
"app.skin-site.admin.wiki.title": {"message": "维基管理"},
"app.skin-site.admin.wiki.tab-page": {"message": "页面"},
"app.skin-site.admin.wiki.tab-audit": {"message": "审计日志"},
"app.skin-site.admin.wiki.tab-settings": {"message": "设置"},
"app.skin-site.admin.wiki.loading": {"message": "加载中…"},
"app.skin-site.admin.wiki.page-path": {"message": "页面路径"},
"app.skin-site.admin.wiki.load-page": {"message": "加载页面"},
"app.skin-site.admin.wiki.page-not-found": {"message": "未找到该页面。"},
"app.skin-site.admin.wiki.revision": {"message": "版本"},
"app.skin-site.admin.wiki.author": {"message": "作者"},
"app.skin-site.admin.wiki.updated-at": {"message": "更新时间"},
"app.skin-site.admin.wiki.categories": {"message": "分类"},
"app.skin-site.admin.wiki.read-policy": {"message": "阅读权限"},
"app.skin-site.admin.wiki.edit-policy": {"message": "编辑权限"},
"app.skin-site.admin.wiki.save-protect": {"message": "保存权限"},
"app.skin-site.admin.wiki.protect-saved": {"message": "页面权限已更新。"},
"app.skin-site.admin.wiki.set-home": {"message": "设为首页"},
"app.skin-site.admin.wiki.home-set": {"message": "首页已更新。"},
"app.skin-site.admin.wiki.set-deleted": {"message": "标记为已删除"},
"app.skin-site.admin.wiki.set-restored": {"message": "恢复页面"},
"app.skin-site.admin.wiki.deleted-updated": {"message": "页面删除状态已更新。"},
"app.skin-site.admin.wiki.audit-empty": {"message": "没有审计记录。"},
"app.skin-site.admin.wiki.audit-target": {"message": "目标"},
"app.skin-site.admin.wiki.load-more": {"message": "加载更多"},
"app.skin-site.admin.wiki.settings-hint": {"message": "以逗号分隔域名。"},
"app.skin-site.admin.wiki.domains": {"message": "域名"},
"app.skin-site.admin.wiki.home-page": {"message": "首页"},
"app.skin-site.admin.wiki.default-skin": {"message": "默认皮肤"},
"app.skin-site.admin.wiki.max-page-bytes": {"message": "页面最大字节数"},
"app.skin-site.admin.wiki.max-attachment-bytes": {"message": "附件最大字节数"},
"app.skin-site.admin.wiki.save-settings": {"message": "保存设置"},
"app.skin-site.admin.wiki.settings-saved": {"message": "设置已保存。"},
"app.skin-site.admin.review.tab-list": {"message": "申请列表"},
"app.skin-site.admin.review.tab-papers": {"message": "试卷管理"},
"app.skin-site.admin.review.papers-empty": {"message": "还没有审核试卷。"},
"app.skin-site.admin.review.new-paper": {"message": "新建试卷"},
"app.skin-site.admin.review.edit-paper": {"message": "编辑"},
"app.skin-site.admin.review.paper-title": {"message": "试卷标题"},
"app.skin-site.admin.review.paper-annotation": {"message": "描述"},
"app.skin-site.admin.review.paper-enabled": {"message": "启用"},
"app.skin-site.admin.review.paper-mode": {"message": "阅卷模式"},
"app.skin-site.admin.review.paper-pass-score": {"message": "通过分数"},
"app.skin-site.admin.review.paper-target-permissions": {"message": "通过后获得的身份"},
"app.skin-site.admin.review.paper-questions": {"message": "题目"},
"app.skin-site.admin.review.add-question": {"message": "添加题目"},
"app.skin-site.admin.review.question-title": {"message": "题目"},
"app.skin-site.admin.review.question-type": {"message": "题型"},
"app.skin-site.admin.review.question-score": {"message": "分数"},
"app.skin-site.admin.review.question-annotation": {"message": "题目注解"},
"app.skin-site.admin.review.add-option": {"message": "添加选项"},
"app.skin-site.admin.review.option-description": {"message": "选项文本"},
"app.skin-site.admin.review.option-correct": {"message": "正确"},
"app.skin-site.admin.review.remove-question": {"message": "删除"},
"app.skin-site.admin.review.question-no": {"message": "第 {n} 题"},
"app.skin-site.admin.review.paper-created": {"message": "试卷已创建。"},
"app.skin-site.admin.review.paper-saved": {"message": "试卷已保存。"},
"app.skin-site.admin.review.mail-title": {"message": "通过邮件"},
"app.skin-site.admin.review.mail-enabled": {"message": "审核通过后发送邮件"},
"app.skin-site.admin.review.mail-subject": {"message": "邮件标题"},
"app.skin-site.admin.review.mail-message": {"message": "邮件正文"},
"app.skin-site.admin.review.mail-extra": {"message": "邮件补充说明"},
"app.skin-site.admin.review.mail-include-invite": {"message": "附带邀请码"},
"app.skin-site.admin.review.prevent-duplicate": {"message": "禁止重复作答"},
"app.skin-site.admin.review.copy-link": {"message": "复制直链"},
"app.skin-site.admin.review.link-copied": {"message": "直链已复制。"},
"app.skin-site.admin.review.move-up": {"message": "上移"},
"app.skin-site.admin.review.move-down": {"message": "下移"},
"app.skin-site.admin.review.mail-subject-required": {"message": "开启邮件后必须填写邮件标题。"},
"app.skin-site.admin.review.mail-message-required": {"message": "开启邮件后必须填写邮件正文。"},
"app.skin-site.admin.users.actions": {"message": "操作"},
"app.skin-site.admin.users.audit": {"message": "审计"},
"app.skin-site.admin.users.audit-empty": {"message": "暂无管理审计记录。"},
"app.skin-site.admin.users.audit-more": {"message": "加载更早记录"},
"app.skin-site.admin.users.audit-title": {"message": "用户管理审计"},
"app.skin-site.admin.users.bot": {"message": "机器人"},
"app.skin-site.admin.users.bot-active": {"message": "Token 有效"},
"app.skin-site.admin.users.bot-ban": {"message": "封禁 Bot 创建"},
"app.skin-site.admin.users.bot-creation-banned": {"message": "禁止创建 Token"},
"app.skin-site.admin.users.bot-exists": {"message": "已创建 Bot"},
"app.skin-site.admin.users.bot-inactive": {"message": "Token 已失效"},
"app.skin-site.admin.users.bot-invalidate": {"message": "使 Token 失效"},
"app.skin-site.admin.users.bot-not-exists": {"message": "尚未创建 Bot"},
"app.skin-site.admin.users.bot-save-username": {"message": "保存机器人用户名"},
"app.skin-site.admin.users.bot-title": {"message": "Bot 管理"},
"app.skin-site.admin.users.bot-token-hint": {"message": "Token 标识"},
"app.skin-site.admin.users.bot-unban": {"message": "解除 Bot 封禁"},
"app.skin-site.admin.users.bot-username": {"message": "机器人用户名"},
"app.skin-site.admin.users.bound-players-title": {"message": "绑定玩家"},
"app.skin-site.admin.users.frequency": {"message": "频率限制"},
"app.skin-site.admin.users.frequency-clear": {"message": "取消限制"},
"app.skin-site.admin.users.frequency-current": {"message": "当前 {percent}%"},
"app.skin-site.admin.users.frequency-hint": {"message": "以下列频率对指定用户进行丢包处理,限制其进入服务器的频率。"},
"app.skin-site.admin.users.frequency-none": {"message": "当前未启用"},
"app.skin-site.admin.users.frequency-range": {"message": "仅允许设置 10% 至 100% 的整数。"},
"app.skin-site.admin.users.frequency-save": {"message": "保存限制"},
"app.skin-site.admin.users.frequency-title": {"message": "频率限制"},
"app.skin-site.admin.users.invite": {"message": "邀请用户"},
"app.skin-site.admin.users.invite-copied": {"message": "邀请码已复制。"},
"app.skin-site.admin.users.invite-copy": {"message": "复制"},
"app.skin-site.admin.users.invite-email": {"message": "邮箱"},
"app.skin-site.admin.users.invite-email-invalid": {"message": "请输入正确格式的邮箱。"},
"app.skin-site.admin.users.invite-generate": {"message": "生成邀请码"},
"app.skin-site.admin.users.invite-title": {"message": "邀请用户"},
"app.skin-site.admin.users.legacy-adapted": {"message": "已适配旧内服"},
"app.skin-site.admin.users.registered-at": {"message": "注册时间"},
"app.skin-site.admin.users.status": {"message": "状态"},
"app.skin-site.admin.users.status-banned": {"message": "已封禁"},
"app.skin-site.admin.users.status-normal": {"message": "正常"},
"app.skin-site.admin.mods.upload": {"message": "上传"},
"app.skin-site.admin.mods.select-mode": {"message": "选择多个"},
"app.skin-site.admin.mods.exit-select": {"message": "完成选择"},
"app.skin-site.admin.mods.select-all": {"message": "全选本页"},
"app.skin-site.admin.mods.clear-selection": {"message": "取消全选"},
"app.skin-site.admin.mods.selected-count": {"message": "已选择 {count} 项"},
"app.skin-site.admin.mods.delete-selected": {"message": "删除所选"},
"app.skin-site.admin.mods.confirm-delete-multi": {"message": "确定删除选中的 {count} 个 Mod?此操作不可撤销。"},
"app.skin-site.admin.mods.deleted-multi": {"message": "已删除所选 Mod。"},
"app.skin-site.admin.mods.server-address": {"message": "服务器地址"},
"app.skin-site.admin.mods.server-address-hint": {"message": "按服务器地址过滤"},
"app.skin-site.admin.mods.apply": {"message": "应用"},
"app.skin-site.admin.mods.clear": {"message": "清除"},
"app.skin-site.admin.mods.upload-title": {"message": "上传 Mod"},
"app.skin-site.admin.mods.upload-drop": {"message": "拖拽 .jar 文件到此处,或点击选择"},
"app.skin-site.admin.mods.upload-sub": {"message": "支持多文件,单个不超过 {size} MB"},
"app.skin-site.admin.mods.upload-tags": {"message": "附加规则标签"},
"app.skin-site.admin.mods.upload-queue": {"message": "文件列表"},
"app.skin-site.admin.mods.upload-pending": {"message": "等待中"},
"app.skin-site.admin.mods.upload-checking": {"message": "检查中…"},
"app.skin-site.admin.mods.upload-uploading": {"message": "上传中…"},
"app.skin-site.admin.mods.upload-conflict": {"message": "名称已存在"},
"app.skin-site.admin.mods.upload-success": {"message": "完成"},
"app.skin-site.admin.mods.upload-error": {"message": "失败"},
"app.skin-site.admin.mods.upload-exists": {"message": "该文件已存在于服务器。"},
"app.skin-site.admin.mods.upload-name-conflict": {"message": "已存在同名 Mod"},
"app.skin-site.admin.mods.upload-name-conflict-hint": {"message": "覆盖已有 Mod,或跳过此文件。"},
"app.skin-site.admin.mods.upload-overwrite": {"message": "覆盖"},
"app.skin-site.admin.mods.upload-skip": {"message": "跳过"},
"app.skin-site.admin.mods.upload-close": {"message": "关闭"},
"app.skin-site.admin.mods.upload-all-done": {"message": "全部文件处理完成。"},
"app.skin-site.admin.mods.upload-too-large": {"message": "文件过大:{name}"},
"app.skin-site.chat.avatar.label": {"message": "房间头像"},
"app.skin-site.chat.avatar.upload": {"message": "上传头像"},
"app.skin-site.chat.avatar.replace": {"message": "更换头像"},
"app.skin-site.chat.avatar.remove": {"message": "移除头像"},
"app.skin-site.chat.avatar.hint": {"message": "支持 PNG、JPEG、WebP,单边不超过 8192 像素,文件不超过 5 MB。"},
"app.skin-site.chat.avatar.bad-format": {"message": "房间头像仅支持 PNG、JPEG 和 WebP 格式。"},
"app.skin-site.chat.avatar.too-large": {"message": "房间头像大小不能超过 5 MB。"},
"app.skin-site.chat.avatar.not-image": {"message": "房间头像必须是图片。"},
"app.skin-site.chat.admin.title": {"message": "聊天室管理"},
"app.skin-site.chat.admin.subtitle": {"message": "创建公开或私有房间,并将日常管理权限交给合适的成员。"},
"app.skin-site.chat.admin.create-room": {"message": "创建房间"},
"app.skin-site.chat.admin.loading": {"message": "加载中…"},
"app.skin-site.chat.admin.failed": {"message": "无法加载房间列表。"},
"app.skin-site.chat.admin.empty": {"message": "还没有聊天室。"},
"app.skin-site.chat.admin.edit": {"message": "编辑"},
"app.skin-site.chat.admin.remove": {"message": "删除"},
"app.skin-site.chat.admin.save": {"message": "保存"},
"app.skin-site.chat.admin.cancel": {"message": "取消"},
"app.skin-site.chat.admin.name": {"message": "房间名称"},
"app.skin-site.chat.admin.description": {"message": "房间简介"},
"app.skin-site.chat.admin.visibility": {"message": "可见性"},
"app.skin-site.chat.admin.visibility-public": {"message": "公开"},
"app.skin-site.chat.admin.visibility-private": {"message": "私有"},
"app.skin-site.chat.admin.kind": {"message": "房间类型"},
"app.skin-site.chat.admin.kind-standard": {"message": "普通房间"},
"app.skin-site.chat.admin.kind-settlement": {"message": "聚落房间"},
"app.skin-site.chat.admin.prerequisite-mode": {"message": "前置条件判定"},
"app.skin-site.chat.admin.prerequisite-any": {"message": "满足任一"},
"app.skin-site.chat.admin.prerequisite-all": {"message": "满足全部"},
"app.skin-site.chat.admin.prerequisite-permissions": {"message": "所需权限"},
"app.skin-site.chat.admin.prerequisite-hint": {"message": "以逗号分隔;留空表示无限制。"},
"app.skin-site.chat.admin.created": {"message": "房间已创建。"},
"app.skin-site.chat.admin.updated": {"message": "房间资料已更新。"},
"app.skin-site.chat.admin.deleted": {"message": "房间已删除。"},
"app.skin-site.chat.admin.confirm-delete": {"message": "确认永久删除房间“{name}”及其中全部消息吗?"},
"app.skin-site.chat.admin.manager-badge": {"message": "管理"},
"app.skin-site.chat.admin.voice-badge": {"message": "语音"},
"app.skin-site.chat.user-picker.label": {"message": "选择用户"},
"app.skin-site.chat.user-picker.placeholder": {"message": "搜索用户名、邮箱或账号 ID"},
"app.skin-site.chat.user-picker.by-user": {"message": "按用户信息"},
"app.skin-site.chat.user-picker.by-player": {"message": "按玩家 ID"},
"app.skin-site.chat.user-picker.hint": {"message": "支持按用户名、邮箱模糊搜索,也可输入账号 ID 搜索。"},
"app.skin-site.chat.user-picker.player-hint": {"message": "输入玩家名称,查找绑定该玩家 ID 的用户。"},
"app.skin-site.chat.user-picker.empty": {"message": "没有找到匹配的用户"},
"app.skin-site.chat.user-picker.banned": {"message": "该账号已被封禁,不能执行此操作。"},
"app.skin-site.chat.user-picker.guest": {"message": "游客不能成为房管、聚落主或聚落成员。"},
"app.skin-site.chat.user-picker.already-in": {"message": "该用户已经在当前房间中。"},
"app.skin-site.chat.user-picker.not-allowed": {"message": "该用户不在当前允许选择的范围内"},
"app.skin-site.chat.user-picker.select": {"message": "选择此用户"},
"app.skin-site.chat.user-picker.selected": {"message": "已选择此用户"},
"app.skin-site.chat.user-picker.id": {"message": "ID"},
"app.skin-site.chat.admin.managers": {"message": "房管"},
"app.skin-site.chat.admin.manager-title": {"message": "任命房管"},
"app.skin-site.chat.admin.manager-hint": {"message": "房管可以管理该房间的成员、消息与文件。"},
"app.skin-site.chat.admin.grant": {"message": "任命"},
"app.skin-site.chat.admin.revoke": {"message": "撤销"},
"app.skin-site.chat.admin.manager-granted": {"message": "已任命房管。"},
"app.skin-site.chat.admin.manager-revoked": {"message": "已撤销房管。"},
"app.skin-site.chat.admin.owner-title": {"message": "聚落主"},
"app.skin-site.chat.admin.owner-hint": {"message": "为该房间任命首任聚落主。"},
"app.skin-site.chat.admin.owner-assigned": {"message": "已任命聚落主。"},
"app.skin-site.chat.admin.owner-current": {"message": "当前聚落主:{name}"},
"app.skin-site.chat.admin.owner-none": {"message": "尚未设置聚落主。"},
"app.skin-site.chat.admin.audit": {"message": "审计"},
"app.skin-site.chat.admin.audit-title": {"message": "房间审计"},
"app.skin-site.chat.admin.audit-empty": {"message": "暂无审计记录。"},
"app.skin-site.chat.admin.audit-target": {"message": "对象:{name}"},
"app.skin-site.chat.admin.close": {"message": "关闭"},
"app.skin-site.chat.voice-runtime-title": {"message": "实时语音库"},
"app.skin-site.chat.voice-logs": {"message": "查看日志"},
"app.skin-site.chat.voice-config": {"message": "配置语音库"},
"app.skin-site.chat.voice-retry": {"message": "重新部署"},
"app.skin-site.chat.voice-config-title": {"message": "语音库配置"},
"app.skin-site.chat.voice-logs-title": {"message": "LiveKit 日志"},
"app.skin-site.chat.voice-capacity-title": {"message": "语音容量"},
"app.skin-site.chat.voice-capacity": {"message": "语音容量"},
"app.skin-site.chat.participant-limit": {"message": "参与人数上限"},
"app.skin-site.chat.listener-limit": {"message": "旁听人数上限"},
"app.skin-site.chat.voice-capacity-updated": {"message": "语音容量已更新。"},
"app.skin-site.chat.voice-enabled": {"message": "启用实时语音"},
"app.skin-site.chat.voice-runtime-path": {"message": "语音库目录"},
"app.skin-site.chat.voice-mirror": {"message": "镜像基础地址"},
"app.skin-site.chat.voice-public-url": {"message": "公网访问地址"},
"app.skin-site.chat.voice-bind": {"message": "监听地址"},
"app.skin-site.chat.voice-signal-port": {"message": "信令端口"},
"app.skin-site.chat.voice-rtc-tcp-port": {"message": "媒体 TCP 端口"},
"app.skin-site.chat.voice-rtc-udp-port": {"message": "媒体 UDP 端口"},
"app.skin-site.chat.voice-startup-timeout": {"message": "启动等待时间(秒)"},
"app.skin-site.chat.voice-use-external-ip": {"message": "使用外部 IP"},
"app.skin-site.chat.voice-node-ip": {"message": "节点 IP"},
"app.skin-site.chat.voice-saved": {"message": "语音库配置已保存。"},
"app.skin-site.chat.voice-logs-auto-refresh": {"message": "自动刷新"},
"app.skin-site.chat.voice-logs-refresh": {"message": "刷新"},
"app.skin-site.chat.voice-logs-empty": {"message": "暂无日志。"},
"app.skin-site.chat.admin.voice-runtime-title": {"message": "实时语音库"},
"app.skin-site.chat.admin.voice-logs": {"message": "查看日志"},
"app.skin-site.chat.admin.voice-config": {"message": "配置语音库"},
"app.skin-site.chat.admin.voice-retry": {"message": "重新部署"},
"app.skin-site.chat.admin.voice-config-title": {"message": "语音库配置"},
"app.skin-site.chat.admin.voice-logs-title": {"message": "LiveKit 日志"},
"app.skin-site.chat.admin.voice-capacity-title": {"message": "语音容量"},
"app.skin-site.chat.admin.voice-capacity": {"message": "语音容量"},
"app.skin-site.chat.admin.participant-limit": {"message": "参与人数上限"},
"app.skin-site.chat.admin.listener-limit": {"message": "旁听人数上限"},
"app.skin-site.chat.admin.voice-capacity-updated": {"message": "语音容量已更新。"},
"app.skin-site.chat.admin.voice-enabled": {"message": "启用实时语音"},
"app.skin-site.chat.admin.voice-runtime-path": {"message": "语音库目录"},
"app.skin-site.chat.admin.voice-mirror": {"message": "镜像基础地址"},
"app.skin-site.chat.admin.voice-public-url": {"message": "公网访问地址"},
"app.skin-site.chat.admin.voice-bind": {"message": "监听地址"},
"app.skin-site.chat.admin.voice-signal-port": {"message": "信令端口"},
"app.skin-site.chat.admin.voice-rtc-tcp-port": {"message": "媒体 TCP 端口"},
"app.skin-site.chat.admin.voice-rtc-udp-port": {"message": "媒体 UDP 端口"},
"app.skin-site.chat.admin.voice-startup-timeout": {"message": "启动等待时间(秒)"},
"app.skin-site.chat.admin.voice-use-external-ip": {"message": "使用外部 IP"},
"app.skin-site.chat.admin.voice-node-ip": {"message": "节点 IP"},
"app.skin-site.chat.admin.voice-saved": {"message": "语音库配置已保存。"},
"app.skin-site.chat.admin.voice-logs-auto-refresh": {"message": "自动刷新"},
"app.skin-site.chat.admin.voice-logs-refresh": {"message": "刷新"},
"app.skin-site.chat.admin.voice-logs-empty": {"message": "暂无日志。"},
"app.skin-site.admin.wiki.tab-migration": {"message": "迁移中心"},
"app.skin-site.admin.wiki.migration-title": {"message": "MediaWiki 迁移"},
"app.skin-site.admin.wiki.migration-hint": {"message": "迁移内容会先进入不可见暂存区,所有页面完成导入或排除审核后才能整批发布。"},
"app.skin-site.admin.wiki.auto-title": {"message": "从 MediaWiki API 自动读取"},
"app.skin-site.admin.wiki.api-url": {"message": "api.php 地址"},
"app.skin-site.admin.wiki.bot-username": {"message": "Bot 用户名(选填)"},
"app.skin-site.admin.wiki.bot-password": {"message": "Bot 密码(选填)"},
"app.skin-site.admin.wiki.credentials-hint": {"message": "凭据只存在于本次后台任务的内存中,携带凭据时必须使用 HTTPS。"},
"app.skin-site.admin.wiki.allow-private": {"message": "明确允许访问内网来源"},
"app.skin-site.admin.wiki.allow-private-hint": {"message": "内网来源可能访问皮肤站所在网络中的服务,请只对可信地址启用。"},
"app.skin-site.admin.wiki.start-auto": {"message": "创建自动迁移任务"},
"app.skin-site.admin.wiki.xml-title": {"message": "导入 MediaWiki XML"},
"app.skin-site.admin.wiki.xml-file": {"message": "导出文件"},
"app.skin-site.admin.wiki.start-xml": {"message": "上传并建立暂存任务"},
"app.skin-site.admin.wiki.stale-rules": {"message": "疑似过时页面筛选规则"},
"app.skin-site.admin.wiki.stale-days": {"message": "未更新天数"},
"app.skin-site.admin.wiki.maintainer-window": {"message": "维护者统计天数"},
"app.skin-site.admin.wiki.max-maintainers": {"message": "维护者人数不超过"},
"app.skin-site.admin.wiki.stale-markers": {"message": "过时分类、模板或文字标记"},
"app.skin-site.admin.wiki.stale-markers-hint": {"message": "使用逗号分隔"},
"app.skin-site.admin.wiki.jobs": {"message": "迁移任务"},
"app.skin-site.admin.wiki.refresh-jobs": {"message": "刷新"},
"app.skin-site.admin.wiki.jobs-empty": {"message": "还没有迁移任务。"},
"app.skin-site.admin.wiki.reviewed-count": {"message": "已审核 {reviewed} / {total}"},
"app.skin-site.admin.wiki.imported-excluded": {"message": "导入 {imported} · 排除 {excluded}"},
"app.skin-site.admin.wiki.view-job": {"message": "查看任务"},
"app.skin-site.admin.wiki.delete-job": {"message": "删除迁移记录"},
"app.skin-site.admin.wiki.confirm-delete-job": {"message": "确认删除迁移任务 #{id}?此操作不可恢复。"},
"app.skin-site.admin.wiki.candidates": {"message": "候选页面"},
"app.skin-site.admin.wiki.candidates-empty": {"message": "选择任务后查看其页面。"},
"app.skin-site.admin.wiki.last-updated": {"message": "更新于 {date}"},
"app.skin-site.admin.wiki.maintainer-count": {"message": "{count} 位维护者"},
"app.skin-site.admin.wiki.conflict": {"message": "标题冲突"},
"app.skin-site.admin.wiki.target-title": {"message": "目标标题"},
"app.skin-site.admin.wiki.overwrite": {"message": "覆盖"},
"app.skin-site.admin.wiki.import": {"message": "导入"},
"app.skin-site.admin.wiki.exclude": {"message": "排除"},
"app.skin-site.admin.wiki.publish": {"message": "整批发布"},
"app.skin-site.admin.wiki.rollback": {"message": "回滚"},
"app.skin-site.admin.wiki.confirm-rollback": {"message": "确认回滚本批迁移?已发布的页面将被还原。"},
"app.skin-site.admin.wiki.state-importing": {"message": "正在读取"},
"app.skin-site.admin.wiki.state-review": {"message": "等待审核"},
"app.skin-site.admin.wiki.state-publishing": {"message": "正在发布"},
"app.skin-site.admin.wiki.state-published": {"message": "已发布"},
"app.skin-site.admin.wiki.state-failed": {"message": "失败"},
"app.skin-site.admin.wiki.state-rolled-back": {"message": "已回滚"},
"app.skin-site.admin.wiki.decision-pending": {"message": "待审核"},
"app.skin-site.admin.wiki.decision-import": {"message": "导入"},
"app.skin-site.admin.wiki.decision-exclude": {"message": "排除"},
"app.skin-site.admin.wiki.migration-started": {"message": "迁移任务已创建。"},
"app.skin-site.admin.wiki.xml-staged": {"message": "XML 已解析并进入逐页审核阶段。"},
"app.skin-site.admin.wiki.published": {"message": "已发布 {imported} 个页面,覆盖 {overwritten} 个。"},
"app.skin-site.admin.wiki.rolled-back": {"message": "迁移批次已安全回滚。"},
"app.skin-site.admin.wiki.job-deleted": {"message": "迁移记录已删除。"},
"app.skin-site.admin.wiki.select-xml": {"message": "请先选择 MediaWiki XML 文件。"},
"app.skin-site.wiki.title": {"message": "StarLight 维基"},
"app.skin-site.wiki.search-placeholder": {"message": "输入页面标题"},
"app.skin-site.wiki.open": {"message": "打开"},
"app.skin-site.wiki.loading": {"message": "加载中…"},
"app.skin-site.wiki.not-found": {"message": "页面不存在。"},
"app.skin-site.wiki.empty": {"message": "在上方输入页面标题开始阅读。"},
"app.skin-site.wiki.updated-at": {"message": "最后编辑于 {date},编辑者 {author}"},
"app.skin-site.wiki.revision": {"message": "修订 #{id}"},
"app.skin-site.wiki.categories": {"message": "分类"},
"app.skin-site.wiki.back": {"message": "返回"}
}
+2 -2
View File
@@ -16,7 +16,7 @@ import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
import HomeMinimal from '@/components/home/HomeMinimal.vue'
import HomePlayInsights from '@/components/home/HomePlayInsights.vue'
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
import SkinSiteHome from '@/components/ui/skin-site/SkinSiteHome.vue'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { get_default_user, users } from '@/helpers/auth'
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
@@ -162,7 +162,7 @@ onUnmounted(() => {
@select="selectMinimalInstance"
/>
<div class="home-content" :class="{ 'is-minimal': isMinimal }">
<SkinSiteFrame v-if="!isMinimal" />
<SkinSiteHome v-if="!isMinimal" />
<HomeMinimal
v-else
+143 -29
View File
@@ -9,9 +9,13 @@ import {
SpinnerIcon,
} from '@modrinth/assets'
import {
type ArmorPreviewConfig,
ArmorPreviewControls,
ButtonStyled,
cloneArmorPreviewConfig,
commonMessages,
ConfirmModal,
createDefaultArmorPreviewConfig,
defineMessages,
injectNotificationManager,
NavTabs,
@@ -24,12 +28,14 @@ import { invoke } from '@tauri-apps/api/core'
import type { DragDropEvent } from '@tauri-apps/api/webview'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { computedAsync } from '@vueuse/core'
import type { Ref } from 'vue'
import type { ComponentPublicInstance, Ref } from 'vue'
import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import ArmorTrimTab from '@/components/ui/skin/ArmorTrimTab.vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import SkinListSkeleton from '@/components/ui/skin/SkinListSkeleton.vue'
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
import {
openSkinSiteLogin,
@@ -45,6 +51,7 @@ import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { check_reachable, get_default_user, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import { loadSkinArmorPreview, saveSkinArmorPreview } from '@/helpers/skin-armor-preview'
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
import {
determineModelType,
@@ -252,6 +259,8 @@ const editSkinModal = useTemplateRef('editSkinModal')
const addSkinFileInput = useTemplateRef<HTMLInputElement>('addSkinFileInput')
const skinSectionList = useTemplateRef<VirtualSkinSectionListExpose>('skinSectionList')
const skinPreviewArea = useTemplateRef<HTMLElement>('skinPreviewArea')
const armorTrimTab = useTemplateRef<ComponentPublicInstance>('armorTrimTab')
const armorPreviewControls = useTemplateRef<ComponentPublicInstance>('armorPreviewControls')
const { formatMessage } = useVIntl()
const router = useRouter()
@@ -391,7 +400,8 @@ const isAddSkinButtonDragActive = ref(false)
const deleteSkinModal = ref()
const skinToDelete = ref<Skin | null>(null)
const skinListTab = ref<'saved' | 'default'>('saved')
const lastSkinListTab = ref<'saved' | 'default'>('saved')
const skinListTab = ref<'saved' | 'default' | 'armor'>('saved')
const skinListTabLinks = computed(() => [
{
@@ -403,6 +413,70 @@ const skinListTabLinks = computed(() => [
label: formatMessage(messages.defaultTab),
},
])
const ARMOR_TRIM_PANEL_ID = 'skins-armor-trim-panel'
const savedArmorPreview = ref<ArmorPreviewConfig>(loadSkinArmorPreview())
const armorPreviewConfig = ref<ArmorPreviewConfig>(cloneArmorPreviewConfig(savedArmorPreview.value))
function saveArmorPreview() {
savedArmorPreview.value = cloneArmorPreviewConfig(armorPreviewConfig.value)
saveSkinArmorPreview(savedArmorPreview.value)
}
function resetArmorPreviewToSaved() {
armorPreviewConfig.value = cloneArmorPreviewConfig(savedArmorPreview.value)
}
function restoreDefaultArmorPreview() {
armorPreviewConfig.value = createDefaultArmorPreviewConfig()
}
/** A third tab behind the two list tabs, opened from the preview's action row. */
function openArmorTab() {
if (skinListTab.value !== 'armor') lastSkinListTab.value = skinListTab.value
skinListTab.value = 'armor'
}
/**
* Whether an element belongs to the armour surface: somewhere inside the open
* armour tab or the button that toggles it. Only those hold a focus ring that
* would be left behind when the tab closes.
*/
function isArmorPreviewElement(element: Element) {
const tabElement: unknown = armorTrimTab.value?.$el
const toggleElement: unknown = armorPreviewControls.value?.$el
return (
(tabElement instanceof Element && tabElement.contains(element)) ||
(toggleElement instanceof Element && toggleElement.contains(element))
)
}
function closeArmorTab() {
// Leaving the tab drops whatever was not saved on purpose.
resetArmorPreviewToSaved()
skinListTab.value = lastSkinListTab.value
// Escape and the toggle both leave the armour button holding focus, which
// keeps its focus ring drawn until something else is clicked. Only drop that
// ring when the focus sits on the armour surface, never on an unrelated
// control that happened to hold it when Escape was pressed.
const focused = document.activeElement
if (focused instanceof HTMLElement && isArmorPreviewElement(focused)) focused.blur()
}
/**
* The tab bar keeps the last list tab highlighted while the armour tab shows, so
* picking one of them leaves the armour tab. That transition has to drop the
* draft exactly like the toggle and Escape paths do, or it lingers on the model
* while the panel is gone.
*/
function selectSkinListTab(index: number) {
if (skinListTab.value === 'armor') resetArmorPreviewToSaved()
lastSkinListTab.value = index === 0 ? 'saved' : 'default'
skinListTab.value = lastSkinListTab.value
}
function confirmDeleteSkin(skin: Skin) {
if (isSkinManagementReadOnly.value) return
@@ -1211,6 +1285,9 @@ await loadSkins()
class="ml-5 mt-4 flex h-[calc(80vh-1rem)] items-center justify-center max-[700px]:h-[calc(50vh-1rem)]"
>
<SkinPreviewRenderer
v-model:armor-config="armorPreviewConfig"
armor-preview
:armor-controls="false"
:cape-src="capeTexture"
:texture-src="skinTexture || ''"
:variant="skinVariant"
@@ -1257,6 +1334,16 @@ await loadSkins()
<EditIcon />
{{ formatMessage(messages.editSkinButton) }}
</button>
<ArmorPreviewControls
v-if="!isSkinSiteProfile"
ref="armorPreviewControls"
v-model="armorPreviewConfig"
panel="external"
:panel-id="ARMOR_TRIM_PANEL_ID"
:open="skinListTab === 'armor'"
@open="openArmorTab"
@close="closeArmorTab"
/>
</template>
</SkinPreviewRenderer>
</div>
@@ -1265,14 +1352,10 @@ await loadSkins()
<div class="pt-2">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<NavTabs
:active-index="skinListTab === 'saved' ? 0 : 1"
:active-index="lastSkinListTab === 'saved' ? 0 : 1"
:links="skinListTabLinks"
mode="local"
@tab-click="
(index: number) => {
skinListTab = index === 0 ? 'saved' : 'default'
}
"
@tab-click="selectSkinListTab"
/>
<ButtonStyled v-if="!isSkinSiteProfile" color="brand">
<button @click="router.push('/lab/skin-editor')">
@@ -1281,27 +1364,45 @@ await loadSkins()
</button>
</ButtonStyled>
</div>
<VirtualSkinSectionList
ref="skinSectionList"
:active-tab="skinListTab"
:saved-skins="savedSkins"
:default-skin-sections="defaultSkinSections"
:get-baked-skin-textures="getBakedSkinTextures"
:is-skin-selected="isSkinSelected"
:is-skin-active="isSkinActive"
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
:read-only="isSkinManagementReadOnly"
:manage-saved-skins="!isSkinSiteProfile"
@select="changeSkin"
@edit="(skin, event) => !isSkinSiteProfile && editSkinModal?.show(event, skin)"
@delete="confirmDeleteSkin"
@reorder-saved-skins="reorderSavedSkins"
@add-skin="openAddSkinFileBrowser"
@add-skin-dragenter="onAddSkinDragOver"
@add-skin-dragover="onAddSkinDragOver"
@add-skin-dragleave="onAddSkinDragLeave"
@add-skin-drop="onAddSkinDrop"
/>
<Transition name="armor-tab" mode="out-in">
<div v-if="skinListTab !== 'armor'" key="list">
<SkinListSkeleton v-if="isSkinListBuffering" />
<VirtualSkinSectionList
v-show="!isSkinListBuffering"
ref="skinSectionList"
:active-tab="skinListTab"
:saved-skins="savedSkins"
:default-skin-sections="defaultSkinSections"
:get-baked-skin-textures="getBakedSkinTextures"
:is-skin-selected="isSkinSelected"
:is-skin-active="isSkinActive"
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
:read-only="isSkinManagementReadOnly"
:manage-saved-skins="!isSkinSiteProfile"
@select="changeSkin"
@edit="(skin, event) => !isSkinSiteProfile && editSkinModal?.show(event, skin)"
@delete="confirmDeleteSkin"
@reorder-saved-skins="reorderSavedSkins"
@add-skin="openAddSkinFileBrowser"
@add-skin-dragenter="onAddSkinDragOver"
@add-skin-dragover="onAddSkinDragOver"
@add-skin-dragleave="onAddSkinDragLeave"
@add-skin-drop="onAddSkinDrop"
/>
</div>
<ArmorTrimTab
v-else
ref="armorTrimTab"
key="armor"
v-model="armorPreviewConfig"
:panel-id="ARMOR_TRIM_PANEL_ID"
:saved="savedArmorPreview"
@save="saveArmorPreview"
@reset="resetArmorPreviewToSaved"
@defaults="restoreDefaultArmorPreview"
@close="closeArmorTab"
/>
</Transition>
</div>
</div>
@@ -1382,4 +1483,17 @@ await loadSkins()
grid-template-columns: 1fr;
}
}
// The armour tab crossfades with the skin list it replaces.
.armor-tab-enter-active,
.armor-tab-leave-active {
transition:
opacity 180ms ease,
transform 180ms ease;
}
.armor-tab-enter-from,
.armor-tab-leave-to {
opacity: 0;
transform: translateY(0.5rem);
}
</style>
@@ -1,9 +0,0 @@
<script setup lang="ts">
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
</script>
<template>
<div class="h-full min-h-0">
<SkinSiteFrame />
</div>
</template>
Loaded 100 of 305 files, more files were not shown because too many files have changed in this diff. Show more