Compare commits

..
83 Commits
Author SHA1 Message Date
AxTps a85e88339f chore: 发布 1.0.2-beta8
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
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / 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
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 (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / guardrails (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 (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
fix: 引导步骤切换时自动滚动到目标元素
2026-10-17 22:56:34 +08:00
AxTps 6a0d9b719c Merge pull request #19 from ApTx2012/feat/backend
fix: LogShare 采集改用轻量日志收集,修复崩溃日志上传失败
2026-10-17 22:56:15 +08:00
AxTps ba43b9d5bc feat: 新增皮肤站管理台启动器版本页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:54:43 +08:00
AxTps 1c671943b7 feat: 新增皮肤站管理台玩家审核页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:50:21 +08:00
AxTps eef7d02f45 fix: 补全皮肤站管理台 i18n 白名单
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:43:50 +08:00
AxTps 947a739672 feat: 新增皮肤站管理台用户与玩家页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:38:03 +08:00
AxTps 799d542227 feat: 新增皮肤站管理台概览与公告页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:27:41 +08:00
AxTps 77258eee9d feat: 新增皮肤站管理控制台框架
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:21:03 +08:00
AxTps a4615c7b73 feat: 新增皮肤站问题创建与通知中心
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:07:47 +08:00
AxTps 146b88afce feat: 新增皮肤站问题详情与评论
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 21:59:25 +08:00
AxTps 9bdc965cd5 feat: 新增皮肤站问题列表视图
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 21:45:01 +08:00
AxTps d6d4fcf8a0 feat: 新增皮肤站玩家审核视图
Sync source to CNB / Sync Git ref (push) Skipped
- API 层补充公开审核表单目录与提交申请接口。
- SkinSiteReviewView:表单列表、动态题目渲染(单选 / 多选 / 判断 /
  填空 / 问答 / 信息题)、必填校验,提交后展示审核状态。
- 主页导航卡的「玩家审核」改为打开该视图。
2026-10-17 21:34:38 +08:00
AxTps 8cc4782497 feat: 新增皮肤站服务器地图视图
Sync source to CNB / Sync Git ref (push) Skipped
- API 层补充地图服务器列表接口。
- SkinSiteMapView:内外服切换、选择子服与地图,用内嵌 iframe 展示,
  并提供在浏览器中打开的入口。
- 主页导航卡的「地图」改为打开该视图。
2026-10-17 21:28:04 +08:00
AxTps 77b381b8d0 feat: 新增皮肤站资源下载视图
Sync source to CNB / Sync Git ref (push) Skipped
- API 层补充下载列表、数量与分类接口。
- SkinSiteDownloadsView:按分类筛选下载项,展示文件名、描述、
  上传者与状态标签,点击后用系统浏览器打开下载地址。
- 主页导航卡的「下载」改为打开该视图,其余入口仍走浏览器。
2026-10-17 21:24:36 +08:00
AxTps 4124f26095 feat: 新增皮肤站玩家管理视图
Sync source to CNB / Sync Git ref (push) Skipped
- SkinSiteProfileView:玩家列表、选中玩家的 3D 预览,以及皮肤 /
  披风 / 鞘翅的上传与删除;Mojang 账号只读。
- 账号卡增加「My players」入口,主页在该视图与首页之间切换。
- 3D 预览复用 SkinPreviewRenderer。
2026-10-17 21:18:44 +08:00
AxTps 041d288b25 feat: 新增皮肤站账号设置弹窗
Sync source to CNB / Sync Git ref (push) Skipped
- API 层补充改名、改网站密码、改 Minecraft 密码、上传头像。
- SkinSiteProfileModal:用户名修改、头像上传(居中裁剪为 256×256)、
  网站密码修改、Minecraft 密码修改与恢复默认。
- 主页账号卡增加「Account」入口打开该弹窗。
2026-10-17 19:33:48 +08:00
AxTps 86306b8ddd refactor: 移除内嵌皮肤站页面
Sync source to CNB / Sync Git ref (push) Skipped
主页已改为原生界面,整页内嵌网页不再需要:

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

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

Index.vue 的 iframe 换成 SkinSiteHome。
2026-10-17 19:11:04 +08:00
AxTps bf175160b0 Merge remote-tracking branch 'origin/main' into feat/home 2026-10-17 18:57:46 +08:00
AxTps d17a66e44f chore: 补充 1.0.2-beta7 更新公告
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Starlight release / Build (Windows x64) (push) Canceled after 0s
Starlight release / Build (Linux x64) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
Axolotl desktop CI / guardrails (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 (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / guardrails (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 (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
Sync source to CNB / Sync Git ref (push) Skipped
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
2026-10-12 20:00:25 +08:00
AxTps 6fb73490d3 chore: 发布 1.0.2-beta6
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Starlight release / Build (Windows x64) (push) Canceled after 0s
Starlight release / Build (Linux x64) (push) Canceled after 0s
Axolotl desktop CI / guardrails (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 (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / guardrails (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 (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / guardrails (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 (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / guardrails (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 (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / guardrails (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 (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / guardrails (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 (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: 实例分组增强(后端 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 (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: 导出启动器日志
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 (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
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
Starlight release / Build (macOS universal) (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
Starlight release / Build (Windows x64) (push) Canceled after 0s
Starlight release / Build (Linux x64) (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 (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-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 (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
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 (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / guardrails (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 (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
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 (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / guardrails (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 (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
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (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 / 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
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 (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
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 (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
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 (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / guardrails (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
286 changed files with 21286 additions and 1919 deletions

No files matched your search

+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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@modrinth/app-frontend",
"private": true,
"version": "1.0.2-beta4",
"version": "1.0.2-beta8",
"type": "module",
"scripts": {
"dev": "vite",
+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,152 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
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 +187,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,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,171 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { skinSiteUser } from '@/composables/skin-site-session'
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
import AdminLauncherPage from './admin/AdminLauncherPage.vue'
import AdminPlayersPage from './admin/AdminPlayersPage.vue'
import AdminReviewPage from './admin/AdminReviewPage.vue'
import AdminSummaryPage from './admin/AdminSummaryPage.vue'
import AdminUsersPage from './admin/AdminUsersPage.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: [
{ key: 'servers', 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) },
{ key: 'donations', 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 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'" />
<AdminBroadcastPage v-else-if="current === 'broadcast'" />
<AdminUsersPage v-else-if="current === 'users'" />
<AdminPlayersPage v-else-if="current === 'players'" />
<AdminReviewPage v-else-if="current === 'review'" />
<AdminLauncherPage v-else-if="current === 'launcher'" />
<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,58 @@
<script setup lang="ts">
import { HeartIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import { type SkinSiteDonationSummary, skinSiteDonationSummary } 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<SkinSiteDonationSummary | null>(null)
const loading = ref(true)
const failed = ref(false)
onMounted(async () => {
try {
const result = await skinSiteDonationSummary()
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 {
skinSiteDownloadCategories,
type SkinSiteDownloadCategory,
type SkinSiteDownloadItem,
skinSiteDownloadList,
type SkinSiteDownloadStatus,
} 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<SkinSiteDownloadItem[]>([])
const categories = ref<SkinSiteDownloadCategory[]>([])
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: SkinSiteDownloadStatus): 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: SkinSiteDownloadItem) {
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([
skinSiteDownloadList(),
skinSiteDownloadCategories(),
])
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,150 @@
<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'
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'
>('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'" />
<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 SkinSiteMapServer, skinSiteMapServers } 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<SkinSiteMapServer[]>([])
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 skinSiteMapServers()
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,132 @@
<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'): 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') {
emit('navigate', item.id === 'download' ? 'downloads' : item.id)
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 SkinSiteReviewPaper,
type SkinSiteReviewQuestion,
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<SkinSiteReviewPaper[]>([])
const loading = ref(true)
const failed = ref(false)
const activePaper = ref<SkinSiteReviewPaper | 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: SkinSiteReviewQuestion): 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: SkinSiteReviewPaper) {
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: SkinSiteReviewQuestion, 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,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>
@@ -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,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>
@@ -0,0 +1,466 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteDecideReviewSubmission,
skinSiteRegenerateReviewInvite,
type SkinSiteReviewResult,
type SkinSiteReviewSubmission,
skinSiteReviewSubmissionCount,
skinSiteReviewSubmissions,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.review.title', defaultMessage: 'Player review' },
loading: { id: 'app.skin-site.admin.review.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.admin.review.failed',
defaultMessage: 'Could not load submissions.',
},
empty: { id: 'app.skin-site.admin.review.empty', defaultMessage: 'No submissions found.' },
total: { id: 'app.skin-site.admin.review.total', defaultMessage: '{count} submissions' },
filterAll: { id: 'app.skin-site.admin.review.filter-all', defaultMessage: 'All' },
filterPending: { id: 'app.skin-site.admin.review.filter-pending', defaultMessage: 'Pending' },
filterPassed: { id: 'app.skin-site.admin.review.filter-passed', defaultMessage: 'Passed' },
filterRejected: { id: 'app.skin-site.admin.review.filter-rejected', defaultMessage: 'Rejected' },
resultPending: { id: 'app.skin-site.admin.review.result-pending', defaultMessage: 'Pending' },
resultPassed: { id: 'app.skin-site.admin.review.result-passed', defaultMessage: 'Passed' },
resultRejected: { id: 'app.skin-site.admin.review.result-rejected', defaultMessage: 'Rejected' },
modeAuto: { id: 'app.skin-site.admin.review.mode-auto', defaultMessage: 'Auto' },
modeManual: { id: 'app.skin-site.admin.review.mode-manual', defaultMessage: 'Manual' },
applicant: { id: 'app.skin-site.admin.review.applicant', defaultMessage: 'Applicant' },
submittedAt: { id: 'app.skin-site.admin.review.submitted-at', defaultMessage: 'Submitted' },
score: { id: 'app.skin-site.admin.review.score', defaultMessage: 'Score' },
reviewer: { id: 'app.skin-site.admin.review.reviewer', defaultMessage: 'Reviewer' },
details: { id: 'app.skin-site.admin.review.details', defaultMessage: 'Details' },
hide: { id: 'app.skin-site.admin.review.hide', defaultMessage: 'Hide' },
answers: { id: 'app.skin-site.admin.review.answers', defaultMessage: 'Answers' },
noAnswers: { id: 'app.skin-site.admin.review.no-answers', defaultMessage: 'No answers.' },
autoChecks: { id: 'app.skin-site.admin.review.auto-checks', defaultMessage: 'Automatic checks' },
context: { id: 'app.skin-site.admin.review.context', defaultMessage: 'Submission context' },
ip: { id: 'app.skin-site.admin.review.ip', defaultMessage: 'IP' },
userAgent: { id: 'app.skin-site.admin.review.user-agent', defaultMessage: 'User agent' },
activity: { id: 'app.skin-site.admin.review.activity', defaultMessage: 'Activity' },
duration: { id: 'app.skin-site.admin.review.duration', defaultMessage: 'Duration' },
hiddenTime: { id: 'app.skin-site.admin.review.hidden-time', defaultMessage: 'Hidden time' },
screenSwitches: {
id: 'app.skin-site.admin.review.screen-switches',
defaultMessage: 'Screen switches',
},
history: { id: 'app.skin-site.admin.review.history', defaultMessage: 'Decision history' },
accept: { id: 'app.skin-site.admin.review.accept', defaultMessage: 'Accept' },
reject: { id: 'app.skin-site.admin.review.reject', defaultMessage: 'Reject' },
reason: { id: 'app.skin-site.admin.review.reason', defaultMessage: 'Reason' },
acceptDone: {
id: 'app.skin-site.admin.review.accept-done',
defaultMessage: 'Submission accepted.',
},
rejectDone: {
id: 'app.skin-site.admin.review.reject-done',
defaultMessage: 'Submission rejected.',
},
regenInvite: {
id: 'app.skin-site.admin.review.regen-invite',
defaultMessage: 'Regenerate invite',
},
inviteRegenerated: {
id: 'app.skin-site.admin.review.invite-regenerated',
defaultMessage: 'Invite code regenerated.',
},
inviteSent: {
id: 'app.skin-site.admin.review.invite-sent',
defaultMessage: 'Email sent.',
},
inviteNotSent: {
id: 'app.skin-site.admin.review.invite-not-sent',
defaultMessage: 'Email not sent.',
},
prev: { id: 'app.skin-site.admin.review.prev', defaultMessage: 'Previous' },
next: { id: 'app.skin-site.admin.review.next', defaultMessage: 'Next' },
page: { id: 'app.skin-site.admin.review.page', defaultMessage: 'Page {page}' },
})
const PAGE_SIZE = 20
const items = ref<SkinSiteReviewSubmission[]>([])
const total = ref(0)
const offset = ref(0)
const loading = ref(true)
const failed = ref(false)
const filter = ref<'' | SkinSiteReviewResult | 'COMPLETED'>('PENDING')
const expandedId = ref<number | null>(null)
const decisions = ref<Record<number, { reason: string; working: boolean }>>({})
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 FILTERS: Array<{ key: '' | SkinSiteReviewResult | 'COMPLETED'; label: () => string }> = [
{ key: '', label: () => formatMessage(messages.filterAll) },
{ key: 'PENDING', label: () => formatMessage(messages.filterPending) },
{ key: 'PASSED', label: () => formatMessage(messages.filterPassed) },
{ key: 'REJECTED', label: () => formatMessage(messages.filterRejected) },
]
function resultLabel(result: SkinSiteReviewResult): string {
if (result === 'PASSED') return formatMessage(messages.resultPassed)
if (result === 'REJECTED') return formatMessage(messages.resultRejected)
return formatMessage(messages.resultPending)
}
function resultClass(result: SkinSiteReviewResult): string {
if (result === 'PASSED') return 'bg-green text-contrast'
if (result === 'REJECTED') return 'bg-red text-contrast'
return 'bg-button-bg text-secondary'
}
function modeLabel(mode: string): string {
return mode === 'AUTO' ? formatMessage(messages.modeAuto) : formatMessage(messages.modeManual)
}
function formatDateTime(ts?: number | null): string {
if (!ts) return ''
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
function formatDuration(ms?: number | null): string {
if (!ms || ms < 0) return '—'
const totalSeconds = Math.floor(ms / 1000)
const m = Math.floor(totalSeconds / 60)
const s = totalSeconds % 60
return m > 0 ? m + 'm ' + s + 's' : s + 's'
}
function decisionFor(id: number) {
if (!decisions.value[id]) decisions.value[id] = { reason: '', working: false }
return decisions.value[id]
}
async function load(reset = false) {
if (reset) offset.value = 0
loading.value = true
failed.value = false
try {
const resultFilter = filter.value || undefined
const listResult = await skinSiteReviewSubmissions({
result: resultFilter,
limit: PAGE_SIZE,
offset: offset.value,
})
if (!listResult.success) throw new Error(listResult.errorMessage)
items.value = listResult.payload ?? []
const countResult = await skinSiteReviewSubmissionCount({ result: resultFilter })
if (countResult.success && typeof countResult.payload === 'number') {
total.value = countResult.payload
}
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function setFilter(key: '' | SkinSiteReviewResult | 'COMPLETED') {
filter.value = key
await load(true)
}
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 toggleDetails(id: number) {
expandedId.value = expandedId.value === id ? null : id
}
async function decide(submission: SkinSiteReviewSubmission, accepted: boolean) {
const state = decisionFor(submission.id)
if (state.working) return
state.working = true
try {
const result = await skinSiteDecideReviewSubmission(
submission.id,
accepted,
{},
submission.revision,
state.reason,
)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(accepted ? messages.acceptDone : messages.rejectDone),
})
expandedId.value = null
await load()
} catch (error) {
handleError(error)
} finally {
state.working = false
}
}
async function regenerateInvite(submission: SkinSiteReviewSubmission) {
try {
const result = await skinSiteRegenerateReviewInvite(submission.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(result.payload.emailSent ? messages.inviteSent : messages.inviteNotSent),
})
await load()
} catch (error) {
handleError(error)
}
}
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">
<button
v-for="f in FILTERS"
:key="f.key || 'all'"
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
:class="
filter === f.key
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:text-contrast'
"
@click="setFilter(f.key)"
>
{{ f.label() }}
</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="submission in items"
:key="submission.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">#{{ submission.id }}</span>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ submission.applicantName }}
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="resultClass(submission.result)"
>
{{ resultLabel(submission.result) }}
</span>
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ modeLabel(submission.mode) }}
</span>
<span
v-if="submission.score !== null && submission.score !== undefined"
class="shrink-0 text-xs font-semibold text-contrast"
>
{{ submission.score }}
</span>
<span class="shrink-0 text-xs text-secondary">
{{ formatDateTime(submission.createdAt) }}
</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="toggleDetails(submission.id)"
>
{{
expandedId === submission.id
? formatMessage(messages.hide)
: formatMessage(messages.details)
}}
</button>
</div>
<div
v-if="expandedId === submission.id"
class="flex flex-col gap-3 border-t border-solid border-surface-5 pt-3"
>
<div
v-if="submission.context"
class="flex flex-col gap-1 rounded-lg bg-bg p-3 text-xs text-secondary"
>
<span class="font-semibold text-contrast">
{{ formatMessage(messages.context) }}
</span>
<span>{{ formatMessage(messages.ip) }}: {{ submission.context.ip ?? '—' }}</span>
<span class="break-all">
{{ formatMessage(messages.userAgent) }}: {{ submission.context.userAgent }}
</span>
<span v-if="submission.context.activity">
{{ formatMessage(messages.duration) }}:
{{ formatDuration(submission.context.activity.durationMs) }} ·
{{ formatMessage(messages.hiddenTime) }}:
{{ formatDuration(submission.context.activity.hiddenMs) }} ·
{{ formatMessage(messages.screenSwitches) }}:
{{ submission.context.activity.screenSwitches }}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.answers) }}
</span>
<p
v-if="!submission.answers || !submission.answers.length"
class="m-0 text-xs text-secondary"
>
{{ formatMessage(messages.noAnswers) }}
</p>
<div v-else class="flex flex-col gap-1.5">
<div
v-for="(answer, idx) in submission.answers"
:key="answer.id || idx"
class="rounded-lg bg-bg p-2 text-xs text-secondary"
>
<span class="text-contrast">
{{ answer.text ?? (answer.values ? answer.values.join(', ') : '—') }}
</span>
<span v-if="answer.score !== null && answer.score !== undefined">
· {{ formatMessage(messages.score) }}: {{ answer.score }}
</span>
</div>
</div>
</div>
<div
v-if="Object.keys(submission.automaticResults || {}).length"
class="flex flex-col gap-1"
>
<span class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.autoChecks) }}
</span>
<div class="flex flex-wrap gap-2">
<span
v-for="(passed, key) in submission.automaticResults"
:key="key"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="passed ? 'bg-green text-contrast' : 'bg-red text-contrast'"
>
{{ key }}: {{ passed ? '✓' : '✗' }}
</span>
</div>
</div>
<div
v-if="submission.decisionHistory && submission.decisionHistory.length"
class="flex flex-col gap-1"
>
<span class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.history) }}
</span>
<div
v-for="(h, idx) in submission.decisionHistory"
:key="idx"
class="rounded-lg bg-bg p-2 text-xs text-secondary"
>
{{ h.reviewer }} · {{ resultLabel(h.result) }} ·
{{ formatDateTime(h.at) }}
<span v-if="h.reason"> · {{ h.reason }}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<input
v-model="decisionFor(submission.id).reason"
type="text"
:placeholder="formatMessage(messages.reason)"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-sm text-contrast outline-none focus:border-brand"
/>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full bg-green px-4 py-1.5 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="decisionFor(submission.id).working || submission.result === 'PASSED'"
@click="decide(submission, true)"
>
{{ formatMessage(messages.accept) }}
</button>
<button
type="button"
class="rounded-full bg-red px-4 py-1.5 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="decisionFor(submission.id).working || submission.result === 'REJECTED'"
@click="decide(submission, false)"
>
{{ formatMessage(messages.reject) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-4 py-1.5 text-xs font-semibold text-secondary hover:brightness-110"
@click="regenerateInvite(submission)"
>
{{ formatMessage(messages.regenInvite) }}
</button>
</div>
</div>
</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 }) }} / {{ 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,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>
@@ -0,0 +1,468 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteAdminDeleteUser,
type SkinSiteAdminPlayer,
skinSiteAdminUpdateUser,
type SkinSiteAdminUser,
skinSiteAdminUserCount,
skinSiteAdminUserList,
skinSiteAdminUserPlayers,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.users.title', defaultMessage: 'Users' },
loading: { id: 'app.skin-site.admin.users.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.users.failed', defaultMessage: 'Could not load users.' },
empty: { id: 'app.skin-site.admin.users.empty', defaultMessage: 'No users found.' },
search: { id: 'app.skin-site.admin.users.search', defaultMessage: 'Search' },
searchName: { id: 'app.skin-site.admin.users.search-name', defaultMessage: 'Username' },
searchEmail: { id: 'app.skin-site.admin.users.search-email', defaultMessage: 'Email' },
searchUuid: { id: 'app.skin-site.admin.users.search-uuid', defaultMessage: 'UUID' },
total: { id: 'app.skin-site.admin.users.total', defaultMessage: '{count} users' },
prev: { id: 'app.skin-site.admin.users.prev', defaultMessage: 'Previous' },
next: { id: 'app.skin-site.admin.users.next', defaultMessage: 'Next' },
page: { id: 'app.skin-site.admin.users.page', defaultMessage: 'Page {page}' },
edit: { id: 'app.skin-site.admin.users.edit', defaultMessage: 'Edit' },
save: { id: 'app.skin-site.admin.users.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.users.cancel', defaultMessage: 'Cancel' },
remove: { id: 'app.skin-site.admin.users.remove', defaultMessage: 'Delete' },
players: { id: 'app.skin-site.admin.users.players', defaultMessage: 'Players' },
updated: { id: 'app.skin-site.admin.users.updated', defaultMessage: 'User updated.' },
deleted: { id: 'app.skin-site.admin.users.deleted', defaultMessage: 'User deleted.' },
confirmDelete: {
id: 'app.skin-site.admin.users.confirm-delete',
defaultMessage: 'Delete user “{name}”? This cannot be undone.',
},
username: { id: 'app.skin-site.admin.users.username', defaultMessage: 'Username' },
email: { id: 'app.skin-site.admin.users.email', defaultMessage: 'Email' },
qq: { id: 'app.skin-site.admin.users.qq', defaultMessage: 'QQ' },
password: { id: 'app.skin-site.admin.users.password', defaultMessage: 'New password' },
passwordHint: {
id: 'app.skin-site.admin.users.password-hint',
defaultMessage: 'Leave empty to keep the current password.',
},
banned: { id: 'app.skin-site.admin.users.banned', defaultMessage: 'Banned' },
permissions: { id: 'app.skin-site.admin.users.permissions', defaultMessage: 'Permissions' },
permissionsHint: {
id: 'app.skin-site.admin.users.permissions-hint',
defaultMessage: 'Comma separated, e.g. WEB_ADMIN, SERVER_ADMIN.',
},
noPlayers: { id: 'app.skin-site.admin.users.no-players', defaultMessage: 'No linked players.' },
registered: { id: 'app.skin-site.admin.users.registered', defaultMessage: 'Registered' },
})
const PAGE_SIZE = 20
const items = ref<SkinSiteAdminUser[]>([])
const total = ref(0)
const offset = ref(0)
const loading = ref(true)
const failed = ref(false)
const keyword = ref('')
const searchField = ref<'username' | 'email' | 'uuid'>('username')
const editingUuid = ref<string | null>(null)
const editForm = ref({
username: '',
email: '',
qq: '',
password: '',
banned: false,
permissions: '',
})
const saving = ref(false)
const deletingUuid = ref<string | null>(null)
const playersFor = ref<SkinSiteAdminPlayer[] | null>(null)
const playersLoading = ref(false)
const playersUuid = 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<'username' | 'email' | 'uuid'> = ['username', 'email', 'uuid']
function searchFieldLabel(field: string): string {
if (field === 'email') return formatMessage(messages.searchEmail)
if (field === 'uuid') return formatMessage(messages.searchUuid)
return formatMessage(messages.searchName)
}
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleDateString()
} catch {
return ''
}
}
async function load(reset = false) {
if (reset) offset.value = 0
loading.value = true
failed.value = false
try {
const kw = keyword.value.trim()
const listResult = await skinSiteAdminUserList({
limit: PAGE_SIZE,
offset: offset.value,
searchField: kw ? searchField.value : undefined,
keyword: kw || undefined,
})
if (!listResult.success) throw new Error(listResult.errorMessage)
items.value = listResult.payload ?? []
const countResult = await skinSiteAdminUserCount(
kw ? { searchField: searchField.value, keyword: kw } : 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 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(user: SkinSiteAdminUser) {
editingUuid.value = user.uuid
editForm.value = {
username: user.username,
email: user.email,
qq: user.qq === undefined || user.qq === null ? '' : String(user.qq),
password: '',
banned: user.banned,
permissions: user.permissions.join(', '),
}
}
function cancelEdit() {
editingUuid.value = null
editForm.value = {
username: '',
email: '',
qq: '',
password: '',
banned: false,
permissions: '',
}
}
async function saveEdit() {
if (!editingUuid.value || saving.value) return
saving.value = true
try {
const qqText = editForm.value.qq.trim()
const permissions = editForm.value.permissions
.split(',')
.map((p) => p.trim())
.filter(Boolean)
const result = await skinSiteAdminUpdateUser({
uuid: editingUuid.value,
username: editForm.value.username.trim(),
email: editForm.value.email.trim(),
qq: qqText ? Number(qqText) : undefined,
password: editForm.value.password ? editForm.value.password : undefined,
banned: editForm.value.banned,
permissions,
})
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(user: SkinSiteAdminUser) {
if (deletingUuid.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDelete, { name: user.username }))) return
deletingUuid.value = user.uuid
try {
const result = await skinSiteAdminDeleteUser(user.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
}
}
async function togglePlayers(user: SkinSiteAdminUser) {
if (playersUuid.value === user.uuid) {
playersUuid.value = null
playersFor.value = null
return
}
playersUuid.value = user.uuid
playersLoading.value = true
playersFor.value = null
try {
const result = await skinSiteAdminUserPlayers(user.uuid)
if (result.success) {
playersFor.value = result.payload ?? []
} else {
playersFor.value = []
}
} catch (error) {
playersFor.value = []
handleError(error)
} finally {
playersLoading.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>
<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="user in items"
:key="user.uuid"
class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-3">
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-contrast">
{{ user.username }}
<span class="text-xs font-normal text-secondary">#{{ user.id }}</span>
</span>
<span class="truncate text-xs text-secondary">{{ user.email }}</span>
<span class="truncate font-mono text-[10px] text-secondary">{{ user.uuid }}</span>
</div>
<span
v-if="user.banned"
class="shrink-0 rounded-full bg-red px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.banned) }}
</span>
<span
v-for="perm in user.permissions"
:key="perm"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ perm }}
</span>
<span class="shrink-0 text-xs text-secondary">
{{ formatDate(user.registerTime) }}
</span>
</div>
<template v-if="editingUuid === user.uuid">
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.username) }}
</span>
<input
v-model="editForm.username"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.email) }}
</span>
<input
v-model="editForm.email"
type="email"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.qq) }}
</span>
<input
v-model="editForm.qq"
type="text"
inputmode="numeric"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.password) }}
</span>
<input
v-model="editForm.password"
type="password"
:placeholder="formatMessage(messages.passwordHint)"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1 sm:col-span-2">
<span class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.permissions) }}
</span>
<input
v-model="editForm.permissions"
type="text"
:placeholder="formatMessage(messages.permissionsHint)"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex items-center gap-2">
<input v-model="editForm.banned" type="checkbox" />
<span class="text-sm text-contrast">{{ formatMessage(messages.banned) }}</span>
</label>
</div>
<div class="flex justify-end 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="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="saving"
@click="saveEdit"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</template>
<template v-else>
<div class="flex flex-wrap items-center 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="startEdit(user)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="togglePlayers(user)"
>
{{ formatMessage(messages.players) }}
</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="deletingUuid !== null"
@click="remove(user)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
<div v-if="playersUuid === user.uuid" class="flex flex-wrap gap-2 pt-1">
<div v-if="playersLoading" class="flex items-center gap-2 text-xs text-secondary">
<SpinnerIcon class="size-3 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<span v-else-if="!playersFor || !playersFor.length" class="text-xs text-secondary">
{{ formatMessage(messages.noPlayers) }}
</span>
<span
v-for="p in playersFor ?? []"
:key="p.uuid"
class="rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ p.name }}
</span>
</div>
</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>
@@ -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 toUser(raw: ApiSkinSiteUser): SkinSiteUser {
return {
uuid: raw.uuid,
username: raw.username,
permissions: Array.isArray(raw.permissions) ? raw.permissions : [],
}
}
function rejectPendingLuckRequests(message: string) {
for (const request of pendingLuckRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
}
pendingLuckRequests.clear()
function clearSessionState() {
user.value = null
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
function rejectPendingPlayersRequests(message: string) {
for (const request of pendingPlayersRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
// ---------------------------------------------------------------------------
// 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
}
pendingPlayersRequests.clear()
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
}
function rejectPendingSkinUpdateRequests(message: string) {
for (const request of pendingSkinUpdateRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
}
pendingSkinUpdateRequests.clear()
/** Force a re-check (e.g. after the user signs in from another view). */
export function reloadSkinSiteSession(): Promise<void> {
initialized = null
return initSkinSiteSession()
}
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
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'
}
}
return result
}
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
}
if (result.errorCode === 401 || result.errorCode === 403) signOutSkinSite()
return false
}
// ---------------------------------------------------------------------------
// 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 @@
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}`,
+522 -1
View File
@@ -11431,5 +11431,526 @@
},
"app.anniversary.banner": {
"message": "Happy {ordinal} Anniversary, Starlight!"
}
},
"instance.settings.tabs.window.window-title": {
"message": "Window title"
},
"instance.settings.tabs.window.window-title.description": {
"message": "Customize the Minecraft window title. Leave empty to keep the default."
},
"instance.settings.tabs.window.window-title.enter": {
"message": "Enter window title..."
},
"app.settings.defaults.window-title.enabled": {
"message": "Custom Minecraft window title"
},
"app.settings.defaults.window-title.enabled-description": {
"message": "Allow instances to customize the Minecraft window title."
},
"app.settings.defaults.window-title.default": {
"message": "Default window title"
},
"app.settings.defaults.window-title.default-description": {
"message": "Used when an instance does not provide its own title."
},
"app.settings.defaults.window-title.default-placeholder": {
"message": "Enter the default window title..."
},
"app.action-bar.announcements": {
"message": "Announcements"
},
"app.remote-announcements.center-title": {
"message": "Announcements"
},
"app.remote-announcements.empty": {
"message": "No announcements"
},
"app.remote-announcements.view": {
"message": "View announcement"
},
"app.remote-announcements.unread": {
"message": "Unread"
},
"app.remote-announcements.read-all": {
"message": "Mark all announcements as read"
},
"app.remote-announcements.preview-title": {
"message": "Announcement style preview"
},
"app.remote-announcements.preview-summary": {
"message": "This is a local preview notification. Select View announcement to see the full Markdown content."
},
"app.remote-announcements.preview-content": {
"message": "## 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/)"
},
"app.remote-announcements.preview-action": {
"message": "Visit website"
},
"app.settings.tabs.logs": {
"message": "Logs & diagnostics"
},
"app.settings.logs.export.title": {
"message": "Export logs"
},
"app.settings.logs.export.description": {
"message": "Package launcher logs with an optional environment summary, instance log, and crash analysis."
},
"app.settings.logs.export.button": {
"message": "Export logs…"
},
"app.settings.logs.open-folder": {
"message": "Open logs folder"
},
"app.settings.logs.export.modal.title": {
"message": "Export logs"
},
"app.settings.logs.export.modal.description": {
"message": "Choose how much detail to include. Exported logs have access tokens, Minecraft tokens, and IP addresses replaced with placeholders."
},
"app.settings.logs.export.modal.range": {
"message": "Time range"
},
"app.settings.logs.export.modal.range.30-minutes": {
"message": "Last 30 minutes"
},
"app.settings.logs.export.modal.range.2-hours": {
"message": "Last 2 hours"
},
"app.settings.logs.export.modal.range.all": {
"message": "All sessions"
},
"app.settings.logs.export.modal.level": {
"message": "Log level"
},
"app.settings.logs.export.modal.level.all": {
"message": "All levels"
},
"app.settings.logs.export.modal.level.debug": {
"message": "Debug and above"
},
"app.settings.logs.export.modal.level.info": {
"message": "Info and above"
},
"app.settings.logs.export.modal.extras": {
"message": "Additional information"
},
"app.settings.logs.export.modal.system-info": {
"message": "System and environment summary"
},
"app.settings.logs.export.modal.instance-logs": {
"message": "Most recent instance log"
},
"app.settings.logs.export.modal.crash-analysis": {
"message": "Crash analysis (when a crash is detected)"
},
"app.settings.logs.export.modal.cancel": {
"message": "Cancel"
},
"app.settings.logs.export.modal.confirm": {
"message": "Export"
},
"app.settings.logs.export.notification-title": {
"message": "Logs exported"
},
"app.settings.logs.export.notification-text": {
"message": "The log archive was saved to {path}"
},
"app.skins.armor.title": {"message": "Armour and trims"},
"app.skins.armor.save": {"message": "Save"},
"app.skins.armor.close": {"message": "Close"},
"app.skins.armor.description": {"message": "Try armour and trims on your skin. Nothing is stored until you save it."},
"app.skins.armor.unsaved": {"message": "Unsaved changes"},
"app.skins.armor.reset-to-saved": {"message": "Reset to saved"},
"app.skins.armor.restore-defaults": {"message": "Restore defaults"},
"app.instances.batch-edit-groups.add": {"message": "Add"},
"app.instances.batch-edit-groups.add-to-group-title": {"message": "Add instances to \"{groupName}\""},
"app.instances.batch-edit-groups.added": {"message": "Added"},
"app.instances.batch-edit-groups.cancel": {"message": "Cancel"},
"app.instances.batch-edit-groups.create": {"message": "Create group"},
"app.instances.batch-edit-groups.group-name": {"message": "Group name"},
"app.instances.batch-edit-groups.group-name-placeholder": {"message": "Enter group name"},
"app.instances.batch-edit-groups.no-instances-found": {"message": "No instances found"},
"app.instances.batch-edit-groups.save-changes": {"message": "Save"},
"app.instances.batch-edit-groups.search-instance": {"message": "Search instances"},
"app.instances.group.add-to-group": {"message": "Add to group"},
"app.instances.group.collapse": {"message": "Collapse group"},
"app.instances.group.delete-description": {"message": "Instances in this group will be ungrouped."},
"app.instances.group.delete-group": {"message": "Delete group"},
"app.instances.group.edit-name": {"message": "Edit group name"},
"app.instances.group.empty": {"message": "Drag and drop to add instances."},
"app.instances.group.expand": {"message": "Expand group"},
"app.instances.group.move-down": {"message": "Move group down"},
"app.instances.group.move-up": {"message": "Move group up"},
"app.instances.group.name-empty": {"message": "Group names cannot be empty."},
"app.instances.group.name-too-long": {"message": "Group names cannot be longer than {maxLength} characters."},
"app.instances.group.new-instance": {"message": "New instance"},
"app.instances.group.pinned": {"message": "Pinned"},
"app.instances.group.rename-group": {"message": "Rename group"},
"app.instances.sort-by": {"message": "Sort by"},
"app.instances.sort.direction.asc-alphabetical": {"message": "A–Z"},
"app.instances.sort.direction.asc-date": {"message": "Oldest first"},
"app.instances.sort.direction.asc-recency": {"message": "Least recent first"},
"app.instances.sort.direction.asc-version": {"message": "Oldest version first"},
"app.instances.sort.direction.desc-alphabetical": {"message": "Z–A"},
"app.instances.sort.direction.desc-date": {"message": "Newest first"},
"app.instances.sort.direction.desc-recency": {"message": "Most recent first"},
"app.instances.sort.direction.desc-version": {"message": "Newest version first"},
"app.library.context-menu.pin-instance": {"message": "Pin instance"},
"app.library.context-menu.remove-from-group": {"message": "Remove from group"},
"app.library.context-menu.unpin-instance": {"message": "Unpin instance"},
"app.library.group.new": {"message": "New group"},
"app.library.selection.new-group": {"message": "New group"},
"app.library.selection.remove-from-group": {"message": "Remove from group"},
"app.log-share.settings.title": {"message": "Log sharing & AI analysis"},
"app.log-share.settings.description": {"message": "Share crash diagnostics with LogShare for a structured summary and optional AI analysis. LogShare is preferred; mclo.gs is used as a fallback."},
"app.log-share.settings.share-provider": {"message": "Log sharing service"},
"app.log-share.settings.share-provider-description": {"message": "Where the diagnostic link is uploaded when you share it."},
"app.log-share.settings.ai-source": {"message": "AI analysis source"},
"app.log-share.settings.ai-source-logshare": {"message": "LogShare AI"},
"app.log-share.settings.ai-source-custom": {"message": "Custom AI provider"},
"app.log-share.settings.ai-source-description": {"message": "Both sources send a sanitized and shortened crash context. AI output may be inaccurate."},
"app.log-share.settings.auto-upload": {"message": "Upload to get a structured summary automatically"},
"app.log-share.settings.auto-upload-description": {"message": "Only effective in LogShare AI mode."},
"app.log-share.settings.multi-file": {"message": "Multi-file upload"},
"app.log-share.settings.multi-file-description": {"message": "Upload latest.log, crash reports, hs_err and launcher logs together."},
"app.log-share.settings.no-storage": {"message": "Analyze without storing logs"},
"app.log-share.settings.no-storage-description": {"message": "Submit content directly for analysis and skip the stored upload. Disables the automatic summary."},
"app.log-share.settings.show-progress": {"message": "Show AI analysis progress"},
"app.log-share.settings.show-progress-description": {"message": "Display thinking, tool and limit status while LogShare AI is working."},
"app.log-share.shared-logs.title": {"message": "Shared logs"},
"app.log-share.shared-logs.description": {"message": "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."},
"app.log-share.shared-logs.empty": {"message": "No shared logs yet."},
"app.log-share.shared-logs.delete": {"message": "Delete"},
"app.log-share.shared-logs.deleting": {"message": "Deleting…"},
"app.log-share.shared-logs.deleted": {"message": "Shared log removed."},
"app.log-share.shared-logs.delete-failed": {"message": "Failed to delete the shared log."},
"app.log-share.shared-logs.truncated": {"message": "Truncated"},
"app.minecraft-crash.share-fallback": {"message": "LogShare is unavailable, so the diagnostic was shared via mclo.gs."},
"app.log-share.ai.action": {"message": "Deep AI analysis"},
"app.log-share.summary.title": {"message": "LogShare analysis"},
"app.log-share.summary.loading": {"message": "Loading structured summary..."},
"app.log-share.summary.empty": {"message": "LogShare did not return a structured summary for these logs."},
"app.log-share.summary.no-storage": {"message": "Structured summary is unavailable while no-storage mode is enabled."},
"app.log-share.summary.failed": {"message": "Could not load the LogShare summary: {message}"},
"app.log-share.summary.problems": {"message": "Problems"},
"app.log-share.summary.information": {"message": "Information"},
"app.minecraft-crash.logs.loading": {"message": "Loading crash logs..."},
"app.minecraft-crash.logs.empty": {"message": "No logs from this launch were found."},
"app.minecraft-crash.logs.failed": {"message": "Could not load the crash logs: {message}"},
"app.minecraft-crash.log-file.loading": {"message": "Loading log file..."},
"app.minecraft-crash.log-file.failed": {"message": "Could not load this log file: {message}"},
"app.log-share.ai.tab": {"message": "LogAgent detailed analysis"},
"app.log-share.ai.root-cause": {"message": "Core root cause"},
"app.log-share.ai.confidence": {"message": "Diagnostic confidence"},
"app.log-share.ai.steps": {"message": "Recommended troubleshooting steps"},
"app.log-share.ai.evidence": {"message": "Supporting evidence"},
"app.log-share.ai.thinking": {"message": "Thinking…"},
"app.log-share.ai.queued": {"message": "Queued, about {position} ahead"},
"app.log-share.ai.queued-front": {"message": "Queued, waiting for a free slot"},
"app.log-share.ai.tool": {"message": "Using tool: {name}"},
"app.log-share.ai.unknown-tool": {"message": "tool"},
"app.log-share.ai.tool-result": {"message": "Tool result received"},
"app.log-share.ai.limit": {"message": "Reached tool loop limit"},
"app.log-share.ai.working": {"message": "Analyzing with LogAgent…"},
"app.log-share.ai.failed": {"message": "LogAgent analysis failed: {message}"},
"app.shortcut-settings.change-shortcut": {"message": "Change the shortcut for {action}"},
"app.shortcut-settings.enable": {"message": "Enable quick scrolling"},
"app.shortcut-settings.end-action": {"message": "Scroll to the bottom"},
"app.shortcut-settings.end-action-description": {"message": "Moves the page you are reading to the bottom."},
"app.shortcut-settings.home-action": {"message": "Scroll to the top"},
"app.shortcut-settings.home-action-description": {"message": "Moves the page you are reading to the top."},
"app.shortcut-settings.nav-create": {"message": "Create new instance"},
"app.shortcut-settings.nav-create-description": {"message": "Jump to creating a new instance."},
"app.shortcut-settings.nav-description": {"message": "Jump to a menu item with its shortcut. Each shortcut is off until enabled."},
"app.shortcut-settings.nav-discover": {"message": "Discover content"},
"app.shortcut-settings.nav-discover-description": {"message": "Jump to browsing content."},
"app.shortcut-settings.nav-downloads": {"message": "Downloads"},
"app.shortcut-settings.nav-downloads-description": {"message": "Jump to downloads."},
"app.shortcut-settings.nav-home": {"message": "Home"},
"app.shortcut-settings.nav-home-description": {"message": "Jump to the Home page."},
"app.shortcut-settings.nav-lab": {"message": "Lab"},
"app.shortcut-settings.nav-lab-description": {"message": "Jump to the Lab."},
"app.shortcut-settings.nav-library": {"message": "Library"},
"app.shortcut-settings.nav-library-description": {"message": "Jump to your library."},
"app.shortcut-settings.nav-multiplayer": {"message": "Multiplayer"},
"app.shortcut-settings.nav-multiplayer-description": {"message": "Jump to multiplayer."},
"app.shortcut-settings.nav-settings": {"message": "Settings"},
"app.shortcut-settings.nav-settings-description": {"message": "Jump to the settings."},
"app.shortcut-settings.nav-skins": {"message": "Skin selector"},
"app.shortcut-settings.nav-skins-description": {"message": "Jump to the skin selector."},
"app.shortcut-settings.nav-title": {"message": "Navigation shortcuts"},
"app.shortcut-settings.nav-worlds": {"message": "Worlds"},
"app.shortcut-settings.nav-worlds-description": {"message": "Jump to your worlds."},
"app.shortcut-settings.page-down-action": {"message": "Scroll down one screen"},
"app.shortcut-settings.page-down-action-description": {"message": "Moves the page you are reading down by one screen."},
"app.shortcut-settings.page-up-action": {"message": "Scroll up one screen"},
"app.shortcut-settings.page-up-action-description": {"message": "Moves the page you are reading up by one screen."},
"app.shortcut-settings.scroll-description": {"message": "Move through long pages with the keyboard."},
"app.shortcut-settings.scroll-title": {"message": "Quick scrolling"},
"app.settings.tabs.shortcut-settings": {"message": "Keyboard shortcuts"},
"app.skin-site.bugs.title": {"message": "Bugs & suggestions"},
"app.skin-site.bugs.back": {"message": "Back to home"},
"app.skin-site.bugs.subtitle": {"message": "Report issues and follow their progress."},
"app.skin-site.bugs.loading": {"message": "Loading…"},
"app.skin-site.bugs.failed": {"message": "Could not load issues."},
"app.skin-site.bugs.empty": {"message": "No issues match these filters."},
"app.skin-site.bugs.all": {"message": "All"},
"app.skin-site.bugs.more": {"message": "Load more"},
"app.skin-site.bugs.open-only": {"message": "Open only"},
"app.skin-site.bugs.comments": {"message": "{count} comments"},
"app.skin-site.bugs.type.bug": {"message": "Bug"},
"app.skin-site.bugs.type.feature": {"message": "Feature"},
"app.skin-site.bugs.type.improvement": {"message": "Improvement"},
"app.skin-site.bugs.status.pending": {"message": "Pending"},
"app.skin-site.bugs.status.confirmed": {"message": "Confirmed"},
"app.skin-site.bugs.status.needs-info": {"message": "Needs info"},
"app.skin-site.bugs.status.in-progress": {"message": "In progress"},
"app.skin-site.bugs.status.verifying": {"message": "Verifying"},
"app.skin-site.bugs.status.closed": {"message": "Closed"},
"app.skin-site.bugs.priority.critical": {"message": "Critical"},
"app.skin-site.bugs.priority.high": {"message": "High"},
"app.skin-site.bugs.priority.normal": {"message": "Normal"},
"app.skin-site.bugs.priority.low": {"message": "Low"},
"app.skin-site.bugs.resolution.fixed": {"message": "Fixed"},
"app.skin-site.bugs.resolution.wont-fix": {"message": "Won’t fix"},
"app.skin-site.bugs.resolution.by-design": {"message": "By design"},
"app.skin-site.bugs.resolution.duplicate": {"message": "Duplicate"},
"app.skin-site.bugs.resolution.cannot-reproduce": {"message": "Cannot reproduce"},
"app.skin-site.bugs.resolution.invalid": {"message": "Invalid"},
"app.skin-site.bug-detail.back": {"message": "Back to list"},
"app.skin-site.bug-detail.loading": {"message": "Loading…"},
"app.skin-site.bug-detail.failed": {"message": "Could not load this issue."},
"app.skin-site.bug-detail.issue-no": {"message": "Issue #{id}"},
"app.skin-site.bug-detail.by": {"message": "by {name}"},
"app.skin-site.bug-detail.assignee": {"message": "Assignee: {name}"},
"app.skin-site.bug-detail.unassigned": {"message": "Unassigned"},
"app.skin-site.bug-detail.description-empty": {"message": "No description provided."},
"app.skin-site.bug-detail.resolution": {"message": "Resolution: {name}"},
"app.skin-site.bug-detail.comments-title": {"message": "Comments"},
"app.skin-site.bug-detail.comments-empty": {"message": "No comments yet. Be the first to reply."},
"app.skin-site.bug-detail.comments-loading": {"message": "Loading comments…"},
"app.skin-site.bug-detail.comment-placeholder": {"message": "Write a comment…"},
"app.skin-site.bug-detail.comment-submit": {"message": "Comment"},
"app.skin-site.bug-detail.comment-sending": {"message": "Sending…"},
"app.skin-site.bug-detail.comment-empty-error": {"message": "Comment cannot be empty."},
"app.skin-site.bug-detail.comment-posted": {"message": "Comment posted."},
"app.skin-site.bug-detail.comment-deleted": {"message": "Comment deleted."},
"app.skin-site.bug-detail.delete": {"message": "Delete"},
"app.skin-site.bug-detail.deleting": {"message": "Deleting…"},
"app.skin-site.bug-detail.system": {"message": "System"},
"app.skin-site.bug-create.title": {"message": "New issue"},
"app.skin-site.bug-create.type": {"message": "Type"},
"app.skin-site.bug-create.title-field": {"message": "Title"},
"app.skin-site.bug-create.title-placeholder": {"message": "A short summary of the problem"},
"app.skin-site.bug-create.description": {"message": "Description"},
"app.skin-site.bug-create.description-placeholder": {"message": "What happened, and how can it be reproduced?"},
"app.skin-site.bug-create.private": {"message": "Private issue"},
"app.skin-site.bug-create.private-hint": {"message": "Only you and the maintainers can see it."},
"app.skin-site.bug-create.images": {"message": "Images"},
"app.skin-site.bug-create.images-hint": {"message": "Optional, up to 5 images."},
"app.skin-site.bug-create.submit": {"message": "Create issue"},
"app.skin-site.bug-create.submitting": {"message": "Creating…"},
"app.skin-site.bug-create.created": {"message": "Issue created."},
"app.skin-site.bug-create.cancel": {"message": "Cancel"},
"app.skin-site.bug-notifications.title": {"message": "Notifications"},
"app.skin-site.bug-notifications.back": {"message": "Back to home"},
"app.skin-site.bug-notifications.subtitle": {"message": "Activity on issues you reported or follow."},
"app.skin-site.bug-notifications.loading": {"message": "Loading…"},
"app.skin-site.bug-notifications.failed": {"message": "Could not load notifications."},
"app.skin-site.bug-notifications.empty": {"message": "No new notifications."},
"app.skin-site.bug-notifications.item": {"message": "{name} updated issue #{id}"},
"app.skin-site.bug-notifications.mark-all": {"message": "Mark all as read"},
"app.skin-site.bug-notifications.marked-all": {"message": "All notifications marked as read."},
"app.skin-site.bugs.create": {"message": "New issue"},
"app.skin-site.bugs.notifications": {"message": "Notifications"},
"app.skin-site.home.title": {"message": "StarLight Skin Site"},
"app.skin-site.home.subtitle": {"message": "The wardrobe for StarLight Server players."},
"app.skin-site.home.address": {"message": "Skin site address"},
"app.skin-site.home.copied": {"message": "Address copied."},
"app.skin-site.home.copy": {"message": "Copy"},
"app.skin-site.admin.title": {"message": "Admin console"},
"app.skin-site.admin.back": {"message": "Back to home"},
"app.skin-site.admin.no-access": {"message": "You do not have access."},
"app.skin-site.admin.no-access-hint": {"message": "Sign in with an administrator account to use this console."},
"app.skin-site.admin.group.security": {"message": "Security"},
"app.skin-site.admin.group.users": {"message": "Users"},
"app.skin-site.admin.group.community": {"message": "Community"},
"app.skin-site.admin.group.resources": {"message": "Resources"},
"app.skin-site.admin.item.servers": {"message": "Servers"},
"app.skin-site.admin.item.summary": {"message": "Summary"},
"app.skin-site.admin.item.access-audit": {"message": "Access audit"},
"app.skin-site.admin.item.users": {"message": "Users"},
"app.skin-site.admin.item.players": {"message": "Players"},
"app.skin-site.admin.item.review": {"message": "Player review"},
"app.skin-site.admin.item.broadcast": {"message": "Broadcast"},
"app.skin-site.admin.item.chat": {"message": "Chat rooms"},
"app.skin-site.admin.item.wiki": {"message": "Wiki"},
"app.skin-site.admin.item.donations": {"message": "Donations"},
"app.skin-site.admin.item.mods": {"message": "Mods"},
"app.skin-site.admin.item.launcher": {"message": "Launcher"},
"app.skin-site.admin.item.downloads": {"message": "Downloads"},
"app.skin-site.home.admin": {"message": "Admin"},
"app.skin-site.home.settings": {"message": "Account"},
"app.skin-site.home.sign-in": {"message": "Sign in"},
"app.skin-site.home.players": {"message": "Players"},
"app.skin-site.home.my-players": {"message": "My players"},
"app.skin-site.home.no-players": {"message": "No players linked yet."},
"app.skin-site.home.loading": {"message": "Loading…"},
"app.skin-site.home.error": {"message": "Could not load."},
"app.skin-site.home.luck": {"message": "Luck"},
"app.skin-site.home.luck-unavailable": {"message": "Unavailable"},
"app.skin-site.home.signed-out-title": {"message": "Not signed in"},
"app.skin-site.home.signed-out-body": {"message": "Sign in to manage your account and textures."},
"app.skin-site.admin.summary.title": {"message": "System overview"},
"app.skin-site.admin.summary.loading": {"message": "Loading…"},
"app.skin-site.admin.summary.failed": {"message": "Could not load status."},
"app.skin-site.admin.summary.uptime": {"message": "Uptime"},
"app.skin-site.admin.summary.memory": {"message": "Memory usage"},
"app.skin-site.admin.summary.users": {"message": "Users"},
"app.skin-site.admin.summary.players": {"message": "Players"},
"app.skin-site.admin.summary.realtime-hint": {"message": "Live CPU and GC charts require the admin WebSocket, which is not connected yet."},
"app.skin-site.admin.broadcast.title": {"message": "Announcements"},
"app.skin-site.admin.broadcast.loading": {"message": "Loading…"},
"app.skin-site.admin.broadcast.failed": {"message": "Could not load."},
"app.skin-site.admin.broadcast.empty": {"message": "No announcements yet."},
"app.skin-site.admin.broadcast.create": {"message": "New announcement"},
"app.skin-site.admin.broadcast.save": {"message": "Save"},
"app.skin-site.admin.broadcast.cancel": {"message": "Cancel"},
"app.skin-site.admin.broadcast.edit": {"message": "Edit"},
"app.skin-site.admin.broadcast.remove": {"message": "Delete"},
"app.skin-site.admin.broadcast.title-field": {"message": "Title"},
"app.skin-site.admin.broadcast.subtitle-field": {"message": "Subtitle"},
"app.skin-site.admin.broadcast.content-field": {"message": "Content"},
"app.skin-site.admin.broadcast.expire-at": {"message": "Expires at"},
"app.skin-site.admin.broadcast.star": {"message": "Highlight"},
"app.skin-site.admin.broadcast.level": {"message": "Importance"},
"app.skin-site.admin.broadcast.image": {"message": "Image"},
"app.skin-site.admin.broadcast.remove-image": {"message": "Remove image"},
"app.skin-site.admin.broadcast.created": {"message": "Announcement created."},
"app.skin-site.admin.broadcast.updated": {"message": "Announcement updated."},
"app.skin-site.admin.broadcast.deleted": {"message": "Announcement deleted."},
"app.skin-site.admin.broadcast.confirm-delete": {"message": "Delete “{title}”? This cannot be undone."},
"app.skin-site.admin.broadcast.uploader": {"message": "by {name}"},
"app.skin-site.admin.players.title": {"message": "Players"},
"app.skin-site.admin.players.loading": {"message": "Loading…"},
"app.skin-site.admin.players.failed": {"message": "Could not load players."},
"app.skin-site.admin.players.empty": {"message": "No players found."},
"app.skin-site.admin.players.search": {"message": "Search"},
"app.skin-site.admin.players.search-name": {"message": "Name"},
"app.skin-site.admin.players.search-owner": {"message": "Owner"},
"app.skin-site.admin.players.search-uuid": {"message": "UUID"},
"app.skin-site.admin.players.total": {"message": "{count} players"},
"app.skin-site.admin.players.prev": {"message": "Previous"},
"app.skin-site.admin.players.next": {"message": "Next"},
"app.skin-site.admin.players.page": {"message": "Page {page}"},
"app.skin-site.admin.players.name": {"message": "Name"},
"app.skin-site.admin.players.owner": {"message": "Owner"},
"app.skin-site.admin.players.source": {"message": "Source"},
"app.skin-site.admin.players.legacy": {"message": "Legacy adapted"},
"app.skin-site.admin.players.actions": {"message": "Actions"},
"app.skin-site.admin.players.edit": {"message": "Edit"},
"app.skin-site.admin.players.save": {"message": "Save"},
"app.skin-site.admin.players.cancel": {"message": "Cancel"},
"app.skin-site.admin.players.remove": {"message": "Delete"},
"app.skin-site.admin.players.updated": {"message": "Player updated."},
"app.skin-site.admin.players.deleted": {"message": "Player deleted."},
"app.skin-site.admin.players.confirm-delete": {"message": "Delete player “{name}”? This cannot be undone."},
"app.skin-site.admin.players.mojang": {"message": "Mojang"},
"app.skin-site.admin.players.custom": {"message": "Skin site"},
"app.skin-site.admin.users.title": {"message": "Users"},
"app.skin-site.admin.users.loading": {"message": "Loading…"},
"app.skin-site.admin.users.failed": {"message": "Could not load users."},
"app.skin-site.admin.users.empty": {"message": "No users found."},
"app.skin-site.admin.users.search": {"message": "Search"},
"app.skin-site.admin.users.search-name": {"message": "Username"},
"app.skin-site.admin.users.search-email": {"message": "Email"},
"app.skin-site.admin.users.search-uuid": {"message": "UUID"},
"app.skin-site.admin.users.total": {"message": "{count} users"},
"app.skin-site.admin.users.prev": {"message": "Previous"},
"app.skin-site.admin.users.next": {"message": "Next"},
"app.skin-site.admin.users.page": {"message": "Page {page}"},
"app.skin-site.admin.users.edit": {"message": "Edit"},
"app.skin-site.admin.users.save": {"message": "Save"},
"app.skin-site.admin.users.cancel": {"message": "Cancel"},
"app.skin-site.admin.users.remove": {"message": "Delete"},
"app.skin-site.admin.users.players": {"message": "Players"},
"app.skin-site.admin.users.updated": {"message": "User updated."},
"app.skin-site.admin.users.deleted": {"message": "User deleted."},
"app.skin-site.admin.users.confirm-delete": {"message": "Delete user “{name}”? This cannot be undone."},
"app.skin-site.admin.users.username": {"message": "Username"},
"app.skin-site.admin.users.email": {"message": "Email"},
"app.skin-site.admin.users.qq": {"message": "QQ"},
"app.skin-site.admin.users.password": {"message": "New password"},
"app.skin-site.admin.users.password-hint": {"message": "Leave empty to keep the current password."},
"app.skin-site.admin.users.banned": {"message": "Banned"},
"app.skin-site.admin.users.permissions": {"message": "Permissions"},
"app.skin-site.admin.users.permissions-hint": {"message": "Comma separated, e.g. WEB_ADMIN, SERVER_ADMIN."},
"app.skin-site.admin.users.no-players": {"message": "No linked players."},
"app.skin-site.admin.users.registered": {"message": "Registered"},
"app.skin-site.admin.review.title": {"message": "Player review"},
"app.skin-site.admin.review.loading": {"message": "Loading…"},
"app.skin-site.admin.review.failed": {"message": "Could not load submissions."},
"app.skin-site.admin.review.empty": {"message": "No submissions found."},
"app.skin-site.admin.review.total": {"message": "{count} submissions"},
"app.skin-site.admin.review.filter-all": {"message": "All"},
"app.skin-site.admin.review.filter-pending": {"message": "Pending"},
"app.skin-site.admin.review.filter-passed": {"message": "Passed"},
"app.skin-site.admin.review.filter-rejected": {"message": "Rejected"},
"app.skin-site.admin.review.result-pending": {"message": "Pending"},
"app.skin-site.admin.review.result-passed": {"message": "Passed"},
"app.skin-site.admin.review.result-rejected": {"message": "Rejected"},
"app.skin-site.admin.review.mode-auto": {"message": "Auto"},
"app.skin-site.admin.review.mode-manual": {"message": "Manual"},
"app.skin-site.admin.review.applicant": {"message": "Applicant"},
"app.skin-site.admin.review.submitted-at": {"message": "Submitted"},
"app.skin-site.admin.review.score": {"message": "Score"},
"app.skin-site.admin.review.reviewer": {"message": "Reviewer"},
"app.skin-site.admin.review.details": {"message": "Details"},
"app.skin-site.admin.review.hide": {"message": "Hide"},
"app.skin-site.admin.review.answers": {"message": "Answers"},
"app.skin-site.admin.review.no-answers": {"message": "No answers."},
"app.skin-site.admin.review.auto-checks": {"message": "Automatic checks"},
"app.skin-site.admin.review.context": {"message": "Submission context"},
"app.skin-site.admin.review.ip": {"message": "IP"},
"app.skin-site.admin.review.user-agent": {"message": "User agent"},
"app.skin-site.admin.review.activity": {"message": "Activity"},
"app.skin-site.admin.review.duration": {"message": "Duration"},
"app.skin-site.admin.review.hidden-time": {"message": "Hidden time"},
"app.skin-site.admin.review.screen-switches": {"message": "Screen switches"},
"app.skin-site.admin.review.history": {"message": "Decision history"},
"app.skin-site.admin.review.accept": {"message": "Accept"},
"app.skin-site.admin.review.reject": {"message": "Reject"},
"app.skin-site.admin.review.reason": {"message": "Reason"},
"app.skin-site.admin.review.accept-done": {"message": "Submission accepted."},
"app.skin-site.admin.review.reject-done": {"message": "Submission rejected."},
"app.skin-site.admin.review.regen-invite": {"message": "Regenerate invite"},
"app.skin-site.admin.review.invite-regenerated": {"message": "Invite code regenerated."},
"app.skin-site.admin.review.invite-sent": {"message": "Email sent."},
"app.skin-site.admin.review.invite-not-sent": {"message": "Email not sent."},
"app.skin-site.admin.review.prev": {"message": "Previous"},
"app.skin-site.admin.review.next": {"message": "Next"},
"app.skin-site.admin.review.page": {"message": "Page {page}"},
"app.skin-site.admin.launcher.title": {"message": "Launcher releases"},
"app.skin-site.admin.launcher.loading": {"message": "Loading…"},
"app.skin-site.admin.launcher.failed": {"message": "Could not load releases."},
"app.skin-site.admin.launcher.empty": {"message": "No releases uploaded yet."},
"app.skin-site.admin.launcher.latest-url": {"message": "Latest URL"},
"app.skin-site.admin.launcher.version": {"message": "Version"},
"app.skin-site.admin.launcher.channel": {"message": "Channel"},
"app.skin-site.admin.launcher.uploader": {"message": "Uploaded by"},
"app.skin-site.admin.launcher.uploaded-at": {"message": "Uploaded"},
"app.skin-site.admin.launcher.published-at": {"message": "Published"},
"app.skin-site.admin.launcher.force-update": {"message": "Forced"},
"app.skin-site.admin.launcher.active": {"message": "Active"},
"app.skin-site.admin.launcher.activate": {"message": "Activate"},
"app.skin-site.admin.launcher.activated": {"message": "Release activated."},
"app.skin-site.admin.launcher.upload": {"message": "Upload release"},
"app.skin-site.admin.launcher.upload-hint": {"message": "Choose a release ZIP (max 4 GiB)."},
"app.skin-site.admin.launcher.uploading": {"message": "Uploading…"},
"app.skin-site.admin.launcher.upload-done": {"message": "Release uploaded."},
"app.skin-site.admin.launcher.force-flag": {"message": "Force update"},
"app.skin-site.admin.launcher.prev": {"message": "Previous"},
"app.skin-site.admin.launcher.next": {"message": "Next"},
"app.skin-site.admin.launcher.page": {"message": "Page {page}"},
"app.skin-site.admin.launcher.total": {"message": "{count} releases"}
}
+433 -1
View File
@@ -11351,5 +11351,437 @@
"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} 个版本"}
}
+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
+122 -8
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,7 +1364,11 @@ await loadSkins()
</button>
</ButtonStyled>
</div>
<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"
@@ -1303,6 +1390,20 @@ await loadSkins()
@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>
<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>
-8
View File
@@ -93,14 +93,6 @@ export default new createRouter({
discordActivity: 'Changing skins...',
},
},
{
path: '/starlight-skin',
name: 'Starlight skin',
component: () => import('@/pages/StarlightSkin.vue'),
meta: {
breadcrumb: [{ name: 'Starlight skin' }],
},
},
{
path: '/screenshots',
name: 'Screenshots',
+51 -1
View File
@@ -6,6 +6,18 @@ import {
resolveFontFamily,
} from '@/helpers/font-family.ts'
/**
* Mirrors `KeyBinding` from `@modrinth/ui`. Declared here so this module stays
* loadable without the UI package.
*/
export interface ShortcutBinding {
device: 'keyboard' | 'mouse'
code: string
mod: boolean
shift: boolean
alt: boolean
}
let systemThemeMq: MediaQueryList | null = null
export const DEFAULT_FEATURE_FLAGS = {
@@ -135,8 +147,30 @@ export type ThemeStore = {
minimalHomeInstanceId: string | null
closeBehavior: CloseBehavior
/** Whether the floating "back to top" button is shown on scrollable pages. */
showScrollTop: boolean
/** Whether quick scrolling shortcuts (Home/End/PageUp/PageDown) are active. */
quickScrollEnabled: boolean
/** Per-nav-item quick jump shortcuts (Ctrl/Cmd + number), off by default. */
shortcutNavHome: boolean
shortcutNavWorlds: boolean
shortcutNavDiscover: boolean
shortcutNavSkins: boolean
shortcutNavMultiplayer: boolean
shortcutNavLibrary: boolean
shortcutNavLab: boolean
shortcutNavDownloads: boolean
shortcutNavCreate: boolean
shortcutNavSettings: boolean
/**
* The combination every shortcut answers to, keyed by action id. Kept here so
* pages re-render when one is recorded; the value itself lives in local
* storage, next to the enable flags.
*/
shortcutBindings: Record<string, ShortcutBinding>
devMode: boolean
featureFlags: FeatureFlags
uiFont: string | null
monoFont: string | null
@@ -162,6 +196,22 @@ export const DEFAULT_THEME_STORE: ThemeStore = {
minimalHomeInstanceId: null,
closeBehavior: 'ask',
showScrollTop: true,
quickScrollEnabled: false,
shortcutNavHome: false,
shortcutNavWorlds: false,
shortcutNavDiscover: false,
shortcutNavSkins: false,
shortcutNavMultiplayer: false,
shortcutNavLibrary: false,
shortcutNavLab: false,
shortcutNavDownloads: false,
shortcutNavCreate: false,
shortcutNavSettings: false,
shortcutBindings: {},
devMode: false,
featureFlags: DEFAULT_FEATURE_FLAGS,
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "theseus_gui"
# The actual version is set by the theseus-build workflow on tagging
version = "1.0.2-beta4"
version = "1.0.2-beta7"
edition.workspace = true
description = "Axolotl Launcher is a cross-platform Minecraft launcher"
homepage = "https://www.ghs.red"
+18
View File
@@ -300,6 +300,17 @@ fn main() {
"logs_explain_crash_with_ai",
"logs_undo_added_mod",
"logs_export_crash_context",
"logs_get_log_share_settings",
"logs_update_log_share_settings",
"logs_logshare_upload_crash",
"logs_logshare_get_insights",
"logs_logshare_analyse_crash_direct",
"logs_logshare_ai_analyze_stored",
"logs_logshare_ai_analyze_direct",
"logs_logshare_delete",
"logs_list_shared_logs",
"logs_record_shared_log",
"logs_delete_shared_log",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
@@ -534,6 +545,12 @@ fn main() {
"instance_delete_screenshot_group",
"instance_set_screenshot_group_memberships",
"instance_import_screenshot_groups",
"instance_list_groups",
"instance_create_group",
"instance_rename_group",
"instance_delete_group",
"instance_set_group_order",
"instance_set_group_memberships",
"instance_delete_screenshots",
"instance_export_screenshots",
"instance_move_screenshots",
@@ -694,6 +711,7 @@ fn main() {
"open_path",
"show_launcher_logs_folder",
"export_error_logs",
"export_launcher_logs",
"show_app_db_backups_folder",
"progress_bars_list",
"get_opening_command",
+53 -1
View File
@@ -131,6 +131,12 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
instance_delete_screenshot_group,
instance_set_screenshot_group_memberships,
instance_import_screenshot_groups,
instance_list_groups,
instance_create_group,
instance_rename_group,
instance_delete_group,
instance_set_group_order,
instance_set_group_memberships,
instance_delete_screenshots,
instance_export_screenshots,
instance_move_screenshots,
@@ -333,6 +339,12 @@ pub struct EditInstance {
)]
pub launch_preparation_timeout: Option<Option<u64>>,
pub hooks: Option<Hooks>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub window_title: Option<Option<String>>,
#[serde(
default,
@@ -531,7 +543,8 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
|| edit_instance.maximize_window.is_some()
|| edit_instance.game_resolution.is_some()
|| edit_instance.launch_preparation_timeout.is_some()
|| edit_instance.hooks.is_some();
|| edit_instance.hooks.is_some()
|| edit_instance.window_title.is_some();
if has_launch_change {
Some(InstanceLaunchOverridesPatch {
player: None,
@@ -546,6 +559,7 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
launch_preparation_timeout: edit_instance
.launch_preparation_timeout,
hooks: edit_instance.hooks,
window_title: edit_instance.window_title,
})
} else {
None
@@ -1709,6 +1723,44 @@ pub async fn instance_import_screenshot_groups(
Ok(theseus::instance::import_screenshot_groups(groups).await?)
}
#[tauri::command]
pub async fn instance_list_groups()
-> Result<Vec<theseus::instance::InstanceGroup>> {
Ok(theseus::instance::list_groups().await?)
}
#[tauri::command]
pub async fn instance_create_group(
name: String,
) -> Result<theseus::instance::InstanceGroup> {
Ok(theseus::instance::create_group(name).await?)
}
#[tauri::command]
pub async fn instance_rename_group(
id: String,
new_name: String,
) -> Result<theseus::instance::InstanceGroup> {
Ok(theseus::instance::rename_group(id, new_name).await?)
}
#[tauri::command]
pub async fn instance_delete_group(id: String) -> Result<()> {
Ok(theseus::instance::delete_group(id).await?)
}
#[tauri::command]
pub async fn instance_set_group_order(group_ids: Vec<String>) -> Result<()> {
Ok(theseus::instance::set_group_order(group_ids).await?)
}
#[tauri::command]
pub async fn instance_set_group_memberships(
updates: Vec<theseus::instance::InstanceGroupMembershipUpdate>,
) -> Result<()> {
Ok(theseus::instance::set_group_memberships(updates).await?)
}
#[tauri::command]
pub async fn instance_delete_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
+91 -1
View File
@@ -4,7 +4,8 @@ use std::path::PathBuf;
use theseus::logs::LogType;
use theseus::logs::{
self, CensoredString, CrashAnalysis, CrashAnalysisAiExplanation,
CrashAnalysisAiSettings, LatestLogCursor, LiveLogSnapshot, Logs,
CrashAnalysisAiSettings, LatestLogCursor, LiveLogSnapshot,
LogShareSettings, LogShareUploadResponse, Logs, SharedLog,
};
/*
@@ -34,6 +35,17 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
logs_explain_crash_with_ai,
logs_undo_added_mod,
logs_export_crash_context,
logs_get_log_share_settings,
logs_update_log_share_settings,
logs_logshare_upload_crash,
logs_logshare_get_insights,
logs_logshare_analyse_crash_direct,
logs_logshare_ai_analyze_stored,
logs_logshare_ai_analyze_direct,
logs_logshare_delete,
logs_list_shared_logs,
logs_record_shared_log,
logs_delete_shared_log,
])
.build()
}
@@ -172,3 +184,81 @@ pub async fn logs_export_crash_context(
writer.close().await.map_err(crate::api::utils::zip_error)?;
Ok(())
}
/// Get the log share settings.
#[tauri::command]
pub async fn logs_get_log_share_settings() -> Result<LogShareSettings> {
Ok(logs::get_log_share_settings().await?)
}
/// Update the log share settings.
#[tauri::command]
pub async fn logs_update_log_share_settings(
settings: LogShareSettings,
) -> Result<()> {
Ok(logs::update_log_share_settings(settings).await?)
}
/// Upload the latest run's crash logs to LogShare.
#[tauri::command]
pub async fn logs_logshare_upload_crash(
instance_id: &str,
) -> Result<LogShareUploadResponse> {
Ok(logs::upload_crash(instance_id).await?)
}
/// Fetch structured insights for a shared log.
#[tauri::command]
pub async fn logs_logshare_get_insights(id: &str) -> Result<serde_json::Value> {
Ok(logs::get_insights(id).await?)
}
/// Analyse the latest run's crash logs directly.
#[tauri::command]
pub async fn logs_logshare_analyse_crash_direct(
instance_id: &str,
) -> Result<serde_json::Value> {
Ok(logs::analyse_crash_direct(instance_id).await?)
}
/// Stream an AI analysis for a stored shared log.
#[tauri::command]
pub async fn logs_logshare_ai_analyze_stored(
instance_id: &str,
id: &str,
) -> Result<String> {
Ok(logs::ai_analyze_stored(instance_id, id).await?)
}
/// Stream an AI analysis for the latest run's crash logs.
#[tauri::command]
pub async fn logs_logshare_ai_analyze_direct(
instance_id: &str,
) -> Result<String> {
Ok(logs::ai_analyze_direct(instance_id).await?)
}
/// Delete a shared log remotely and locally.
#[tauri::command]
pub async fn logs_logshare_delete(id: &str, token: &str) -> Result<()> {
logs::delete_log(id, token).await?;
Ok(())
}
/// List locally recorded shared logs.
#[tauri::command]
pub async fn logs_list_shared_logs() -> Result<Vec<SharedLog>> {
Ok(logs::list_shared_logs().await?)
}
/// Record a shared log locally.
#[tauri::command]
pub async fn logs_record_shared_log(log: SharedLog) -> Result<()> {
Ok(logs::record_shared_log(log).await?)
}
/// Remove a shared log record locally and remotely.
#[tauri::command]
pub async fn logs_delete_shared_log(id: String, token: String) -> Result<()> {
Ok(logs::delete_shared_log(id, token).await?)
}
+307
View File
@@ -25,6 +25,7 @@ pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
open_path,
show_launcher_logs_folder,
export_error_logs,
export_launcher_logs,
show_app_db_backups_folder,
progress_bars_list,
get_opening_command,
@@ -164,6 +165,312 @@ pub async fn export_error_logs(
Ok(())
}
/// Time window included in a launcher log export.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LogExportRange {
Last30Minutes,
Last2Hours,
All,
}
impl LogExportRange {
fn max_age(self) -> Option<std::time::Duration> {
match self {
Self::Last30Minutes => {
Some(std::time::Duration::from_secs(30 * 60))
}
Self::Last2Hours => {
Some(std::time::Duration::from_secs(2 * 60 * 60))
}
Self::All => None,
}
}
fn label(self) -> &'static str {
match self {
Self::Last30Minutes => "last 30 minutes",
Self::Last2Hours => "last 2 hours",
Self::All => "all sessions",
}
}
}
/// Lowest level included in a launcher log export.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LogExportLevel {
All,
Debug,
Info,
}
impl LogExportLevel {
fn minimum(self) -> Option<&'static str> {
match self {
Self::All => None,
Self::Debug => Some("debug"),
Self::Info => Some("info"),
}
}
fn label(self) -> &'static str {
match self {
Self::All => "all levels",
Self::Debug => "debug and above",
Self::Info => "info and above",
}
}
}
/// Instance log tail included in an export, in bytes.
const LOG_EXPORT_INSTANCE_LOG_TAIL_BYTES: u64 = 512 * 1024;
/// Export launcher logs (filtered by time range and level) plus optional
/// system information, the most recent instance log, and a crash analysis
/// summary into a ZIP archive.
#[tauri::command]
pub async fn export_launcher_logs(
output_path: PathBuf,
range: LogExportRange,
level: LogExportLevel,
include_system_info: bool,
include_instance_logs: bool,
include_crash_analysis: bool,
) -> Result<()> {
// Censoring needs the app state (credentials, IPs). Without it an export
// would silently ship secrets, so fail rather than degrade.
let state = theseus::State::get().await?;
let archive = tokio::fs::File::create(&output_path).await?;
let mut writer = ZipFileWriter::with_tokio(archive);
let (exported_logs, skipped_logs) =
write_exported_logs(&mut writer, range, level, &state).await?;
let mut manifest = format!(
"Starlight Launcher log export\n\
Exported at: {}\n\
App version: {}\n\
Time range: {}\n\
Level filter: {}\n\
System information: {}\n\
Recent instance log: {}\n\
Crash analysis: {}\n\
Log files: {exported_logs}\n\n\
Access tokens, Minecraft tokens, and IP addresses are replaced with placeholders.\n",
chrono::Local::now().to_rfc3339(),
env!("CARGO_PKG_VERSION"),
range.label(),
level.label(),
if include_system_info {
"included"
} else {
"omitted"
},
if include_instance_logs {
"included"
} else {
"omitted"
},
if include_crash_analysis {
"included"
} else {
"omitted"
},
);
if !skipped_logs.is_empty() {
manifest.push_str("\nSkipped unreadable log files:\n");
for name in &skipped_logs {
manifest.push_str(&format!("- {name}\n"));
}
}
write_zip_entry(&mut writer, "manifest.txt", manifest.as_bytes()).await?;
if include_system_info {
let environment = build_environment_report();
let environment = censor_export_text(environment, &state).await?;
write_zip_entry(
&mut writer,
"system-information.txt",
environment.as_bytes(),
)
.await?;
}
let recent_instance = if include_instance_logs || include_crash_analysis {
newest_instance_log(&state).await
} else {
None
};
if include_instance_logs
&& let Some((instance_id, path)) = recent_instance.as_ref()
{
let tail = read_file_tail(path, LOG_EXPORT_INSTANCE_LOG_TAIL_BYTES)?;
let tail = censor_export_text(tail, &state).await?;
write_zip_entry(
&mut writer,
&format!("minecraft/{instance_id}/latest.log"),
tail.as_bytes(),
)
.await?;
}
if include_crash_analysis
&& let Some((instance_id, _)) = recent_instance.as_ref()
&& let Ok(analysis) = theseus::logs::analyze_crash(instance_id).await
&& (analysis.crashed || !analysis.findings.is_empty())
&& let Ok(report) = serde_json::to_vec_pretty(&analysis)
{
// The analysis carries raw log text and Windows event messages, so it
// goes through the same censoring as the exported logs.
let report = censor_export_text(
String::from_utf8_lossy(&report).into_owned(),
&state,
)
.await?;
write_zip_entry(
&mut writer,
&format!("minecraft/{instance_id}/crash-analysis.json"),
report.as_bytes(),
)
.await?;
}
writer.close().await.map_err(zip_error)?;
Ok(())
}
/// Writes the matching launcher logs and reports how many were written plus
/// the names of any files that could not be read.
async fn write_exported_logs(
writer: &mut ZipFileWriter<tokio::fs::File>,
range: LogExportRange,
level: LogExportLevel,
state: &theseus::State,
) -> Result<(usize, Vec<String>)> {
let Some(directories) = DirectoryInfo::global_handle_if_ready() else {
return Ok((0, Vec::new()));
};
let Some(logs_dir) = directories.launcher_logs_dir() else {
return Ok((0, Vec::new()));
};
if !tokio::fs::try_exists(&logs_dir).await? {
return Ok((0, Vec::new()));
}
let mut entries = tokio::fs::read_dir(&logs_dir).await?;
let mut paths = Vec::new();
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let is_session_log =
path.file_name().and_then(|name| name.to_str()).is_some_and(
|name| name.starts_with("session_") && name.ends_with(".log"),
);
if is_session_log && entry.file_type().await?.is_file() {
paths.push(path);
}
}
paths.sort();
let mut exported = 0;
let mut skipped = Vec::new();
for path in paths {
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "session.log".to_string());
// A single unreadable file must not lose the whole report.
let Ok(contents) = tokio::fs::read(&path).await else {
skipped.push(file_name);
continue;
};
let filtered = theseus::filter_log_contents(
&contents,
range.max_age(),
level.minimum(),
);
let text = String::from_utf8_lossy(&filtered).into_owned();
let censored = censor_export_text(text, state).await?;
write_zip_entry(
writer,
&format!("launcher_logs/{file_name}"),
censored.as_bytes(),
)
.await?;
exported += 1;
}
Ok((exported, skipped))
}
/// Replaces credentials and IP addresses with placeholders. Censoring failures
/// propagate so an export never ships unredacted content.
async fn censor_export_text(
text: String,
state: &theseus::State,
) -> Result<String> {
Ok(theseus::install::censor_shared_text(text, state).await?)
}
fn build_environment_report() -> String {
format!(
"Starlight Launcher environment\n\
App version: {}\n\
Operating system: {}\n\
Architecture: {}\n",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH,
)
}
/// The instance whose log was written most recently, if any.
async fn newest_instance_log(
state: &theseus::State,
) -> Option<(String, PathBuf)> {
let instances_dir = state.directories.instances_dir();
let mut entries = tokio::fs::read_dir(&instances_dir).await.ok()?;
let mut newest: Option<(std::time::SystemTime, String, PathBuf)> = None;
while let Some(entry) = entries.next_entry().await.ok()? {
if !entry.file_type().await.ok()?.is_dir() {
continue;
}
let log_path = entry.path().join("logs").join("latest.log");
let Ok(metadata) = tokio::fs::metadata(&log_path).await else {
continue;
};
let Ok(modified) = metadata.modified() else {
continue;
};
let is_newer = newest
.as_ref()
.is_none_or(|(newest_modified, ..)| modified > *newest_modified);
if is_newer {
let id = entry.file_name().to_string_lossy().into_owned();
newest = Some((modified, id, log_path));
}
}
newest.map(|(_, id, path)| (id, path))
}
fn read_file_tail(path: &Path, max_bytes: u64) -> Result<String> {
use std::io::{Read, Seek, SeekFrom};
let mut file = std::fs::File::open(path)?;
let length = file.metadata()?.len();
if length > max_bytes {
file.seek(SeekFrom::Start(length - max_bytes))?;
}
let mut contents = Vec::new();
file.read_to_end(&mut contents)?;
Ok(String::from_utf8_lossy(&contents).into_owned())
}
async fn write_zip_file(
writer: &mut ZipFileWriter<tokio::fs::File>,
filename: &str,
+79 -1
View File
@@ -253,6 +253,14 @@ impl LightweightMode {
if payload.maximize_window {
maximize_minecraft_window(payload.pid).await;
}
if let Some(title) = payload
.window_title
.as_ref()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
{
rename_minecraft_window(payload.pid, title).await;
}
let settings = match theseus::settings::get().await {
Ok(settings) => settings,
Err(error) => {
@@ -332,6 +340,8 @@ struct ProcessEventPayload {
pid: u32,
#[serde(default)]
maximize_window: bool,
#[serde(default)]
window_title: Option<String>,
event: String,
crashed: Option<bool>,
#[serde(default)]
@@ -339,7 +349,7 @@ struct ProcessEventPayload {
}
#[cfg(target_os = "windows")]
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicU32, Ordering};
#[cfg(target_os = "windows")]
static MAXIMIZE_PROCESS_ID: AtomicU32 = AtomicU32::new(0);
@@ -400,6 +410,74 @@ async fn maximize_minecraft_window(pid: u32) {
#[cfg(not(target_os = "windows"))]
async fn maximize_minecraft_window(_pid: u32) {}
#[cfg(target_os = "windows")]
static RENAME_WINDOW_HANDLE: AtomicIsize = AtomicIsize::new(0);
#[cfg(target_os = "windows")]
unsafe extern "system" fn rename_if_owned_by_process(
hwnd: windows::Win32::Foundation::HWND,
_: windows::Win32::Foundation::LPARAM,
) -> windows::core::BOOL {
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowThreadProcessId, IsWindowVisible,
};
use windows::core::BOOL;
let mut window_pid = 0;
unsafe { GetWindowThreadProcessId(hwnd, Some(&mut window_pid)) };
if window_pid == MAXIMIZE_PROCESS_ID.load(Ordering::Relaxed)
&& unsafe { IsWindowVisible(hwnd).as_bool() }
{
RENAME_WINDOW_HANDLE.store(hwnd.0 as isize, Ordering::Relaxed);
return BOOL(0);
}
BOOL(1)
}
#[cfg(target_os = "windows")]
async fn rename_minecraft_window(pid: u32, title: &str) {
if pid == 0 || title.is_empty() {
return;
}
// PCL keeps re-applying the title: FML/Quilt and some mods reset it
// while the game boots, so a one-shot rename is not enough.
let deadline =
tokio::time::Instant::now() + std::time::Duration::from_secs(45);
let mut applied = false;
loop {
let found = {
let _guard = MAXIMIZE_WINDOW_ENUMERATION.lock();
MAXIMIZE_PROCESS_ID.store(pid, Ordering::Relaxed);
RENAME_WINDOW_HANDLE.store(0, Ordering::Relaxed);
unsafe {
use windows::Win32::Foundation::LPARAM;
use windows::Win32::UI::WindowsAndMessaging::EnumWindows;
let _ =
EnumWindows(Some(rename_if_owned_by_process), LPARAM(0));
}
RENAME_WINDOW_HANDLE.load(Ordering::Relaxed)
};
if found != 0 {
let hwnd = windows::Win32::Foundation::HWND(found as *mut _);
let wide: Vec<u16> = title.encode_utf16().chain(Some(0)).collect();
unsafe {
use windows::Win32::UI::WindowsAndMessaging::SetWindowTextW;
let _ =
SetWindowTextW(hwnd, windows::core::PCWSTR(wide.as_ptr()));
}
applied = true;
}
if tokio::time::Instant::now() >= deadline {
return;
}
let _ = applied;
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
}
}
#[cfg(not(target_os = "windows"))]
async fn rename_minecraft_window(_pid: u32, _title: &str) {}
#[tauri::command]
pub fn lightweight_mode_frontend_ready(
+3 -6
View File
@@ -324,12 +324,9 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
/// external game-directory root for the one-click StarLight install flow.
#[tauri::command]
fn get_launcher_root_dir() -> api::Result<String> {
let exe = std::env::current_exe().map_err(|error| {
theseus::Error::from(theseus::ErrorKind::FSError(error.to_string()))
})?;
let dir = exe.parent().ok_or_else(|| {
let dir = theseus::default_game_dir_root().ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::FSError(
"Launcher executable has no parent directory".to_string(),
"Could not determine a default game directory root".to_string(),
))
})?;
Ok(dir.to_string_lossy().into_owned())
@@ -339,7 +336,7 @@ fn get_launcher_root_dir() -> api::Result<String> {
fn get_update_channel(app: tauri::AppHandle) -> api::Result<String> {
let channel = read_update_channel_state(&app)?
.active_channel
.unwrap_or_else(|| "release".to_string());
.unwrap_or_else(|| theseus::default_update_channel().to_string());
match channel.as_str() {
"release" | "beta" => Ok(channel),
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "theseus"
# The actual version is set by the theseus-build workflow on tagging
version = "1.0.2-beta4"
version = "1.0.2-beta7"
edition.workspace = true
[dependencies]
@@ -0,0 +1,2 @@
ALTER TABLE settings ADD COLUMN custom_window_title_enabled INTEGER NOT NULL DEFAULT FALSE;
ALTER TABLE settings ADD COLUMN default_window_title TEXT NOT NULL DEFAULT 'Minecraft';
@@ -0,0 +1,26 @@
CREATE TABLE log_share_settings (
id INTEGER NOT NULL CHECK (id = 0),
share_provider TEXT NOT NULL DEFAULT 'logshare',
ai_source TEXT NOT NULL DEFAULT 'logshare',
auto_upload INTEGER NOT NULL DEFAULT TRUE,
multi_file INTEGER NOT NULL DEFAULT TRUE,
no_storage INTEGER NOT NULL DEFAULT FALSE,
show_progress INTEGER NOT NULL DEFAULT TRUE,
PRIMARY KEY (id)
);
INSERT INTO log_share_settings (id) VALUES (0);
CREATE TABLE shared_logs (
id TEXT NOT NULL PRIMARY KEY,
url TEXT NOT NULL,
raw TEXT NOT NULL,
token TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'logshare',
instance_id TEXT NULL,
instance_name TEXT NULL,
truncated INTEGER NOT NULL DEFAULT FALSE,
created_at INTEGER NOT NULL
);
CREATE INDEX shared_logs_created_at_idx ON shared_logs (created_at DESC);
+1 -1
View File
@@ -19,7 +19,7 @@ use uuid::Uuid;
use crate::{ErrorKind, State};
const CATALOG_SOURCE: &str = "LobeHub 906b10e03029648655e0257bda4f785a9e0973f0";
const CATALOG_SOURCE: &str = "LobeHub 6c7a6fb033e443bc27bf34cbd5509ab3db7f22f3";
const KEYRING_PREFIX: &str = "ai-provider";
const OAUTH_CREDENTIAL: &str = "oauth";
const API_KEY_CREDENTIAL: &str = "api-key";
+261
View File
@@ -0,0 +1,261 @@
use crate::event::emit::emit_instance_groups_changed;
use crate::state::instance_groups;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use uuid::Uuid;
const MAX_GROUP_NAME_LENGTH: usize = 256;
pub const FAVORITES_GROUP_ID: &str = instance_groups::FAVORITES_GROUP_ID;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InstanceGroup {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InstanceGroupMembershipUpdate {
pub instance_id: String,
pub group_ids: Vec<String>,
}
fn validate_group_name(name: &str) -> crate::Result<&str> {
let name = name.trim();
if name.is_empty() {
return Err(crate::ErrorKind::InputError(
"Group name cannot be empty".to_string(),
)
.into());
}
if name.chars().count() > MAX_GROUP_NAME_LENGTH {
return Err(crate::ErrorKind::InputError(format!(
"Group name cannot exceed {MAX_GROUP_NAME_LENGTH} characters"
))
.into());
}
if name.eq_ignore_ascii_case("none") {
return Err(crate::ErrorKind::InputError(
"Group name cannot be None".to_string(),
)
.into());
}
Ok(name)
}
fn normalize_membership_updates(
mut updates: Vec<InstanceGroupMembershipUpdate>,
) -> crate::Result<Vec<InstanceGroupMembershipUpdate>> {
let mut instance_ids = HashSet::new();
for update in &mut updates {
if !instance_ids.insert(update.instance_id.clone()) {
return Err(crate::ErrorKind::InputError(format!(
"Duplicate instance {} in group membership update",
update.instance_id
))
.into());
}
let mut group_ids = HashSet::new();
update
.group_ids
.retain(|group_id| group_ids.insert(group_id.clone()));
}
Ok(updates)
}
pub async fn list_groups() -> crate::Result<Vec<InstanceGroup>> {
Ok(instance_groups::list()
.into_iter()
.map(|group| InstanceGroup {
id: group.id,
name: group.name,
})
.collect())
}
pub async fn create_group(name: String) -> crate::Result<InstanceGroup> {
let name = validate_group_name(&name)?;
let id = Uuid::new_v4().to_string();
instance_groups::create(&id, name);
Ok(InstanceGroup {
id,
name: name.to_string(),
})
}
pub async fn set_group_order(group_ids: Vec<String>) -> crate::Result<()> {
instance_groups::set_order(&group_ids);
Ok(())
}
pub async fn set_group_memberships(
updates: Vec<InstanceGroupMembershipUpdate>,
) -> crate::Result<()> {
if updates.is_empty() {
return Ok(());
}
let updates = normalize_membership_updates(updates)?;
// Note: the instances themselves are deliberately not validated here.
// JSON-backed instances (`local:` ids) have no row in the `instances` table,
// and requiring one is what used to make grouping them fail outright.
let unique_group_ids = updates
.iter()
.flat_map(|update| update.group_ids.iter())
.collect::<HashSet<_>>();
for group_id in unique_group_ids {
if !instance_groups::group_exists(group_id) {
return Err(crate::ErrorKind::InputError(format!(
"Unknown instance group {group_id}"
))
.into());
}
}
instance_groups::set_memberships(
&updates
.iter()
.map(|update| {
(update.instance_id.clone(), update.group_ids.clone())
})
.collect::<Vec<_>>(),
);
let instance_ids = updates
.into_iter()
.map(|update| update.instance_id)
.collect::<Vec<_>>();
emit_instance_groups_changed(&instance_ids).await?;
Ok(())
}
pub async fn rename_group(
id: String,
new_name: String,
) -> crate::Result<InstanceGroup> {
if id == FAVORITES_GROUP_ID {
return Err(crate::ErrorKind::InputError(
"Favorites cannot be renamed".to_string(),
)
.into());
}
let new_name = validate_group_name(&new_name)?;
let Some(instance_ids) = instance_groups::rename(&id, new_name) else {
return Err(crate::ErrorKind::InputError(format!(
"Unknown instance group {id}"
))
.into());
};
emit_instance_groups_changed(&instance_ids).await?;
Ok(InstanceGroup {
id,
name: new_name.to_string(),
})
}
pub async fn delete_group(id: String) -> crate::Result<()> {
if id == FAVORITES_GROUP_ID {
return Err(crate::ErrorKind::InputError(
"Favorites cannot be deleted".to_string(),
)
.into());
}
let Some(instance_ids) = instance_groups::delete(&id) else {
return Err(crate::ErrorKind::InputError(format!(
"Unknown instance group {id}"
))
.into());
};
emit_instance_groups_changed(&instance_ids).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
FAVORITES_GROUP_ID, InstanceGroupMembershipUpdate,
MAX_GROUP_NAME_LENGTH, normalize_membership_updates,
validate_group_name,
};
#[test]
fn group_name_validation_trims_valid_names() {
assert_eq!(validate_group_name(" My group ").unwrap(), "My group");
}
#[test]
fn group_name_validation_rejects_empty_names() {
assert!(validate_group_name(" ").is_err());
}
#[test]
fn group_name_validation_rejects_reserved_name() {
assert!(validate_group_name("NoNe").is_err());
}
#[test]
fn group_name_validation_allows_favorites() {
assert_eq!(validate_group_name("Favorites").unwrap(), "Favorites");
}
#[test]
fn group_name_validation_rejects_long_names() {
assert!(
validate_group_name(&"a".repeat(MAX_GROUP_NAME_LENGTH + 1))
.is_err()
);
}
#[test]
fn favorites_group_id_is_stable() {
assert_eq!(FAVORITES_GROUP_ID, "group:favorites");
}
#[test]
fn membership_updates_reject_duplicate_instances() {
let updates = vec![
InstanceGroupMembershipUpdate {
instance_id: "instance".to_string(),
group_ids: vec!["first".to_string()],
},
InstanceGroupMembershipUpdate {
instance_id: "instance".to_string(),
group_ids: vec!["second".to_string()],
},
];
assert!(normalize_membership_updates(updates).is_err());
}
#[test]
fn membership_updates_deduplicate_groups() {
let updates =
normalize_membership_updates(vec![InstanceGroupMembershipUpdate {
instance_id: "instance".to_string(),
group_ids: vec![
"a".to_string(),
"b".to_string(),
"a".to_string(),
"b".to_string(),
],
}])
.unwrap();
assert_eq!(updates[0].group_ids, &["a", "b"]);
}
}
+6
View File
@@ -5,6 +5,7 @@ mod content;
mod core_components;
mod export_mrpack;
mod get;
mod groups;
mod home;
mod install;
mod lifecycle;
@@ -72,6 +73,11 @@ pub use self::export_mrpack::{
create_mrpack_json, export_mrpack, get_pack_export_candidates,
};
pub use self::get::{get, get_many, list};
pub use self::groups::{
FAVORITES_GROUP_ID, InstanceGroup, InstanceGroupMembershipUpdate,
create_group, delete_group, list_groups, rename_group,
set_group_memberships, set_group_order,
};
pub use self::home::{
get_daily_playtime, get_daily_playtime_details, set_pinned,
};
+12
View File
@@ -250,6 +250,17 @@ async fn run_credentials(
.launch_overrides
.maximize_window
.unwrap_or(settings.maximize_window);
let window_title = settings
.custom_window_title_enabled
.then(|| {
context
.launch_overrides
.window_title
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| settings.default_window_title.clone())
})
.filter(|value| !value.trim().is_empty());
let env_args = context
.launch_overrides
.custom_env_vars
@@ -319,6 +330,7 @@ async fn run_credentials(
&memory,
&resolution,
maximize_window,
window_title,
credentials,
post_exit_hook,
&context,
+270 -240
View File
@@ -1,5 +1,5 @@
{
"source": "LobeHub 906b10e03029648655e0257bda4f785a9e0973f0 model-bank chat models",
"source": "LobeHub 6c7a6fb033e443bc27bf34cbd5509ab3db7f22f3 model-bank chat models",
"providers": {
"ai21": [
{
@@ -203,6 +203,16 @@
}
],
"aihubmix": [
{
"id": "gpt-6-sol",
"name": "GPT-6 Sol",
"enabled": true
},
{
"id": "gpt-6-luna",
"name": "GPT-6 Luna",
"enabled": true
},
{
"id": "gpt-5.5",
"name": "GPT-5.5",
@@ -343,6 +353,11 @@
"name": "ChatGPT-4o",
"enabled": false
},
{
"id": "grok-4.7",
"name": "Grok 4.7",
"enabled": true
},
{
"id": "grok-4-20-non-reasoning",
"name": "Grok 4.20 (Non-Reasoning)",
@@ -368,6 +383,11 @@
"name": "Grok 3 Mini",
"enabled": false
},
{
"id": "claude-opus-5-5",
"name": "Claude Opus 5.5",
"enabled": true
},
{
"id": "claude-opus-4-7",
"name": "Claude Opus 4.7",
@@ -383,11 +403,6 @@
"name": "Claude Opus 4.5",
"enabled": false
},
{
"id": "claude-opus-4-1-20250805",
"name": "Claude Opus 4.1",
"enabled": false
},
{
"id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",
@@ -403,6 +418,21 @@
"name": "Claude Haiku 4.5",
"enabled": true
},
{
"id": "mimo-v2.6-pro",
"name": "MiMo-V2.6 Pro",
"enabled": true
},
{
"id": "mimo-v2.6-pro-ultraspeed",
"name": "MiMo-V2.6 Pro UltraSpeed",
"enabled": true
},
{
"id": "mimo-v2.6-flash",
"name": "MiMo-V2.6 Flash",
"enabled": true
},
{
"id": "DeepSeek-V3.1-Think",
"name": "DeepSeek V3.1 (Think)",
@@ -542,11 +572,6 @@
"name": "Ring-2.6-1T",
"enabled": true
},
{
"id": "Ring-2.5-1T",
"name": "Ring-2.5-1T",
"enabled": false
},
{
"id": "Ling-3.0-flash",
"name": "Ling-3.0-flash",
@@ -561,14 +586,14 @@
"id": "Ling-2.6-flash",
"name": "Ling-2.6-flash",
"enabled": false
},
{
"id": "Ling-2.5-1T",
"name": "Ling-2.5-1T",
"enabled": false
}
],
"anthropic": [
{
"id": "claude-opus-5-5",
"name": "Claude Opus 5.5",
"enabled": true
},
{
"id": "claude-fable-5-1",
"name": "Claude Fable 5.1",
@@ -623,14 +648,24 @@
"id": "claude-haiku-4-5-20251001",
"name": "Claude Haiku 4.5",
"enabled": true
},
{
"id": "claude-opus-4-1-20250805",
"name": "Claude Opus 4.1",
"enabled": false
}
],
"azure": [
{
"id": "gpt-6-astra",
"name": "GPT-6 Astra",
"enabled": true
},
{
"id": "gpt-6-sol",
"name": "GPT-6 Sol",
"enabled": true
},
{
"id": "gpt-6-luna",
"name": "GPT-6 Luna",
"enabled": true
},
{
"id": "gpt-5.4",
"name": "GPT-5.4",
@@ -914,6 +949,11 @@
}
],
"bedrock": [
{
"id": "global.anthropic.claude-opus-5-5",
"name": "Claude Opus 5.5",
"enabled": true
},
{
"id": "global.anthropic.claude-opus-4-7",
"name": "Claude Opus 4.7",
@@ -1030,14 +1070,29 @@
"id": "gpt-oss-120b",
"name": "GPT OSS 120B",
"enabled": false
},
{
"id": "zai-glm-4.7",
"name": "GLM-4.7",
"enabled": false
}
],
"chatgpt": [
{
"id": "gpt-6.1-sol",
"name": "GPT-6.1 Sol",
"enabled": true
},
{
"id": "gpt-6-astra",
"name": "GPT-6 Astra",
"enabled": true
},
{
"id": "gpt-6-sol",
"name": "GPT-6 Sol",
"enabled": true
},
{
"id": "gpt-6-luna",
"name": "GPT-6 Luna",
"enabled": true
},
{
"id": "gpt-5.6-sol",
"name": "GPT-5.6 Sol",
@@ -1056,7 +1111,7 @@
{
"id": "gpt-5.5",
"name": "GPT-5.5",
"enabled": true
"enabled": false
}
],
"cloudflare": [
@@ -1883,10 +1938,15 @@
"name": "Gemini Flash-Lite Latest",
"enabled": false
},
{
"id": "gemini-3.8-flash",
"name": "Gemini 3.8 Flash",
"enabled": true
},
{
"id": "gemini-3.7-flash",
"name": "Gemini 3.7 Flash",
"enabled": true
"enabled": false
},
{
"id": "gemini-3.6-flash",
@@ -1972,26 +2032,6 @@
"id": "gemini-2.5-flash-lite",
"name": "Gemini 2.5 Flash-Lite",
"enabled": false
},
{
"id": "gemini-2.0-flash",
"name": "Gemini 2.0 Flash",
"enabled": false
},
{
"id": "gemini-2.0-flash-001",
"name": "Gemini 2.0 Flash 001",
"enabled": false
},
{
"id": "gemini-2.0-flash-lite",
"name": "Gemini 2.0 Flash-Lite",
"enabled": false
},
{
"id": "gemini-2.0-flash-lite-001",
"name": "Gemini 2.0 Flash-Lite 001",
"enabled": false
}
],
"groq": [
@@ -2005,10 +2045,15 @@
"name": "Compound Mini",
"enabled": false
},
{
"id": "qwen/qwen3.8-27b",
"name": "Qwen3.8 27B",
"enabled": true
},
{
"id": "qwen/qwen3.6-27b",
"name": "Qwen3.6 27B",
"enabled": true
"enabled": false
},
{
"id": "minimaxai/minimax-m2.7",
@@ -2025,16 +2070,6 @@
"name": "GPT OSS 20B",
"enabled": false
},
{
"id": "llama-3.1-8b-instant",
"name": "Llama 3.1 8B Instant",
"enabled": false
},
{
"id": "llama-3.3-70b-versatile",
"name": "Llama 3.3 70B Versatile",
"enabled": false
},
{
"id": "meta-llama/llama-prompt-guard-2-22m",
"name": "Llama Prompt Guard 2 22M",
@@ -3575,13 +3610,13 @@
],
"mistral": [
{
"id": "mistral-medium-3.5",
"name": "Mistral Medium 3.5",
"enabled": true
"id": "zai-glm-5-2",
"name": "Z.ai GLM 5.2",
"enabled": false
},
{
"id": "devstral-2512",
"name": "Devstral 2",
"id": "mistral-medium-3.5",
"name": "Mistral Medium 3.5",
"enabled": true
},
{
@@ -3589,61 +3624,21 @@
"name": "Devstral Small 2",
"enabled": false
},
{
"id": "mistral-medium-2508",
"name": "Mistral Medium 3.1",
"enabled": false
},
{
"id": "magistral-medium-2509",
"name": "Magistral Medium 1.2",
"enabled": false
},
{
"id": "magistral-small-2509",
"name": "Magistral Small 1.2",
"enabled": false
},
{
"id": "open-mistral-nemo",
"name": "Mistral Nemo",
"enabled": false
},
{
"id": "mistral-small-2603",
"name": "Mistral Small 4",
"enabled": false
},
{
"id": "mistral-small-2506",
"name": "Mistral Small 3.2",
"enabled": false
},
{
"id": "mistral-large-2512",
"name": "Mistral Large 3",
"enabled": true
},
{
"id": "mistral-large-2411",
"name": "Mistral Large 2.1",
"enabled": false
},
{
"id": "codestral-latest",
"name": "Codestral 2508",
"enabled": false
},
{
"id": "pixtral-large-latest",
"name": "Pixtral Large",
"enabled": false
},
{
"id": "pixtral-12b-2409",
"name": "Pixtral 12B",
"enabled": false
},
{
"id": "ministral-3b-latest",
"name": "Ministral 3B",
@@ -3653,16 +3648,6 @@
"id": "ministral-8b-latest",
"name": "Ministral 8B",
"enabled": false
},
{
"id": "open-codestral-mamba",
"name": "Codestral Mamba",
"enabled": false
},
{
"id": "labs-leanstral-2603",
"name": "Leanstral",
"enabled": false
}
],
"modelscope": [
@@ -3676,30 +3661,10 @@
"name": "Qwen3 Next 80B A3B Instruct",
"enabled": false
},
{
"id": "deepseek-ai/DeepSeek-V3.2",
"name": "DeepSeek V3.2",
"enabled": true
},
{
"id": "deepseek-ai/DeepSeek-V3.2-Exp",
"name": "DeepSeek V3.2 Exp",
"enabled": false
},
{
"id": "deepseek-ai/DeepSeek-V3.1",
"name": "DeepSeek V3.1",
"enabled": false
},
{
"id": "Qwen/Qwen3-235B-A22B",
"name": "Qwen3 235B A22B",
"enabled": false
},
{
"id": "Qwen/Qwen3-32B",
"name": "Qwen3 32B",
"enabled": false
}
],
"moonshot": [
@@ -3722,41 +3687,6 @@
"id": "kimi-k2.6",
"name": "Kimi K2.6",
"enabled": false
},
{
"id": "kimi-k2.5",
"name": "Kimi K2.5",
"enabled": false
},
{
"id": "moonshot-v1-8k",
"name": "Moonshot V1 8K",
"enabled": false
},
{
"id": "moonshot-v1-32k",
"name": "Moonshot V1 32K",
"enabled": false
},
{
"id": "moonshot-v1-128k",
"name": "Moonshot V1 128K",
"enabled": false
},
{
"id": "moonshot-v1-8k-vision-preview",
"name": "Moonshot V1 8K Vision Preview",
"enabled": false
},
{
"id": "moonshot-v1-32k-vision-preview",
"name": "Moonshot V1 32K Vision Preview",
"enabled": false
},
{
"id": "moonshot-v1-128k-vision-preview",
"name": "Moonshot V1 128K Vision Preview",
"enabled": false
}
],
"nebius": [
@@ -4424,16 +4354,6 @@
"name": "Qwen3.5 397B A17B",
"enabled": false
},
{
"id": "minimax-m2.5",
"name": "MiniMax M2.5",
"enabled": false
},
{
"id": "kimi-k2.5",
"name": "Kimi K2.5",
"enabled": false
},
{
"id": "gpt-oss:20b",
"name": "GPT-OSS 20B",
@@ -4451,6 +4371,26 @@
}
],
"openai": [
{
"id": "gpt-6.1-sol",
"name": "GPT-6.1 Sol",
"enabled": true
},
{
"id": "gpt-6-astra",
"name": "GPT-6 Astra",
"enabled": true
},
{
"id": "gpt-6-sol",
"name": "GPT-6 Sol",
"enabled": true
},
{
"id": "gpt-6-luna",
"name": "GPT-6 Luna",
"enabled": true
},
{
"id": "chat-latest",
"name": "Chat Latest",
@@ -4474,7 +4414,7 @@
{
"id": "gpt-5.5",
"name": "GPT-5.5",
"enabled": true
"enabled": false
},
{
"id": "gpt-5.5-pro",
@@ -4484,7 +4424,7 @@
{
"id": "gpt-5.4",
"name": "GPT-5.4",
"enabled": true
"enabled": false
},
{
"id": "gpt-5.4-pro",
@@ -4494,7 +4434,7 @@
{
"id": "gpt-5.4-mini",
"name": "GPT-5.4 mini",
"enabled": true
"enabled": false
},
{
"id": "gpt-5.4-nano",
@@ -4708,6 +4648,16 @@
"name": "MiniMax M3",
"enabled": false
},
{
"id": "muse-spark-1.2-contributor",
"name": "Muse Spark 1.2 Contributor",
"enabled": false
},
{
"id": "muse-spark-1.3-contributor",
"name": "Muse Spark 1.3 Contributor",
"enabled": true
},
{
"id": "qwen3.6-plus",
"name": "Qwen3.6 Plus",
@@ -4912,6 +4862,41 @@
"name": "Auto (best for prompt)",
"enabled": true
},
{
"id": "anthropic/claude-opus-5.5",
"name": "Claude Opus 5.5",
"enabled": true
},
{
"id": "openai/gpt-6-sol",
"name": "GPT-6 Sol",
"enabled": true
},
{
"id": "openai/gpt-6-luna",
"name": "GPT-6 Luna",
"enabled": true
},
{
"id": "x-ai/grok-4.7",
"name": "Grok 4.7",
"enabled": true
},
{
"id": "xiaomi/mimo-v2.6-pro",
"name": "MiMo-V2.6 Pro",
"enabled": true
},
{
"id": "xiaomi/mimo-v2.6-pro-ultraspeed",
"name": "MiMo-V2.6 Pro UltraSpeed",
"enabled": true
},
{
"id": "xiaomi/mimo-v2.6-flash",
"name": "MiMo-V2.6 Flash",
"enabled": true
},
{
"id": "deepseek/deepseek-chat-v3.1",
"name": "DeepSeek V3.1",
@@ -5260,6 +5245,26 @@
}
],
"qwen": [
{
"id": "deepseek-v4-pro-0813",
"name": "DeepSeek V4 Pro 0813",
"enabled": false
},
{
"id": "deepseek-v4-flash-0731",
"name": "DeepSeek V4 Flash 0731",
"enabled": false
},
{
"id": "qwen3.8-max-0902",
"name": "Qwen3.8 Max 0902",
"enabled": true
},
{
"id": "qwen3.8-max",
"name": "Qwen3.8 Max",
"enabled": true
},
{
"id": "kimi-k2.6",
"name": "Kimi K2.6",
@@ -5490,10 +5495,15 @@
"name": "QwQ Plus",
"enabled": false
},
{
"id": "qwen3.8-flash",
"name": "Qwen3.8 Flash",
"enabled": true
},
{
"id": "qwen3.7-flash",
"name": "Qwen3.7 Flash",
"enabled": true
"enabled": false
},
{
"id": "qwen3.6-flash",
@@ -5802,75 +5812,40 @@
}
],
"sambanova": [
{
"id": "MiniMax-M2.7",
"name": "MiniMax M2.7",
"enabled": false
},
{
"id": "DeepSeek-V3.1",
"name": "DeepSeek V3.1",
"enabled": false
},
{
"id": "Meta-Llama-3.3-70B-Instruct",
"name": "Meta Llama 3.3 70B Instruct",
"enabled": true
},
{
"id": "Meta-Llama-3.2-1B-Instruct",
"name": "Meta Llama 3.2 1B Instruct",
"enabled": false
},
{
"id": "Meta-Llama-3.2-3B-Instruct",
"name": "Meta Llama 3.2 3B Instruct",
"enabled": false
},
{
"id": "Llama-3.2-11B-Vision-Instruct",
"name": "Meta Llama 3.2 11B Vision Instruct",
"id": "gpt-oss-120b",
"name": "GPT OSS 120B",
"enabled": true
},
{
"id": "Llama-3.2-90B-Vision-Instruct",
"name": "Meta Llama 3.2 90B Vision Instruct",
"id": "MiniMax-M3",
"name": "MiniMax M3",
"enabled": true
},
{
"id": "Meta-Llama-3.1-8B-Instruct",
"name": "Meta Llama 3.1 8B Instruct",
"id": "DeepSeek-V3.2",
"name": "DeepSeek V3.2",
"enabled": false
},
{
"id": "Meta-Llama-3.1-70B-Instruct",
"name": "Meta Llama 3.1 70B Instruct",
"id": "gemma-4-31B-it",
"name": "Gemma 4 31B IT",
"enabled": false
},
{
"id": "Meta-Llama-3.1-405B-Instruct",
"name": "Meta Llama 3.1 405B Instruct",
"enabled": false
},
{
"id": "Llama-3.1-Tulu-3-405B",
"name": "Llama 3.1 Tulu 3 405B",
"enabled": false
},
{
"id": "DeepSeek-R1",
"name": "DeepSeek R1",
"enabled": false
},
{
"id": "DeepSeek-R1-Distill-Llama-70B",
"name": "DeepSeek R1 Distill Llama 70B",
"enabled": true
},
{
"id": "QwQ-32B-Preview",
"name": "QwQ 32B Preview",
"enabled": true
},
{
"id": "Qwen2.5-72B-Instruct",
"name": "Qwen2.5 72B Instruct",
"enabled": true
},
{
"id": "Qwen2.5-Coder-32B-Instruct",
"name": "Qwen2.5 Coder 32B Instruct",
"enabled": true
}
],
"search1api": [
@@ -6262,6 +6237,11 @@
}
],
"stepfun": [
{
"id": "step-5-preview",
"name": "Step 5 Preview",
"enabled": true
},
{
"id": "step-3.7-flash",
"name": "Step 3.7 Flash",
@@ -6407,6 +6387,16 @@
}
],
"supergrok": [
{
"id": "grok-4.7",
"name": "Grok 4.7",
"enabled": true
},
{
"id": "grok-4.6",
"name": "Grok 4.6",
"enabled": true
},
{
"id": "grok-4.5",
"name": "Grok 4.5",
@@ -7166,10 +7156,15 @@
}
],
"vertexai": [
{
"id": "gemini-3.8-flash",
"name": "Gemini 3.8 Flash",
"enabled": true
},
{
"id": "gemini-3.7-flash",
"name": "Gemini 3.7 Flash",
"enabled": true
"enabled": false
},
{
"id": "gemini-3.6-flash",
@@ -7345,20 +7340,30 @@
"name": "Doubao Seed 2.1 Turbo",
"enabled": true
},
{
"id": "deepseek-v4-pro-ga",
"name": "DeepSeek V4 Pro 0813",
"enabled": false
},
{
"id": "deepseek-v4-flash-ga",
"name": "DeepSeek V4 Flash 0731",
"enabled": false
},
{
"id": "glm-5-2",
"name": "GLM-5.2",
"enabled": true
"enabled": false
},
{
"id": "deepseek-v4-pro",
"name": "DeepSeek V4 Pro",
"enabled": true
"enabled": false
},
{
"id": "deepseek-v4-flash",
"name": "DeepSeek V4 Flash",
"enabled": true
"enabled": false
},
{
"id": "doubao-seed-2.0-pro",
@@ -7701,6 +7706,16 @@
}
],
"xai": [
{
"id": "grok-4.7",
"name": "Grok 4.7",
"enabled": true
},
{
"id": "grok-4.6",
"name": "Grok 4.6",
"enabled": true
},
{
"id": "grok-4.5",
"name": "Grok 4.5",
@@ -7728,6 +7743,21 @@
}
],
"xiaomimimo": [
{
"id": "mimo-v2.6-pro",
"name": "MiMo-V2.6 Pro",
"enabled": true
},
{
"id": "mimo-v2.6-pro-ultraspeed",
"name": "MiMo-V2.6 Pro UltraSpeed",
"enabled": true
},
{
"id": "mimo-v2.6-flash",
"name": "MiMo-V2.6 Flash",
"enabled": true
},
{
"id": "mimo-v2.5-pro",
"name": "MiMo-V2.5 Pro",
+109 -31
View File
@@ -1,6 +1,6 @@
use std::fmt::Write as _;
use std::io::{BufRead, Read, SeekFrom};
use std::path::Path;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use futures::TryFutureExt;
@@ -27,6 +27,15 @@ pub use crash_analysis::{
update_crash_analysis_ai_settings,
};
mod logshare;
pub use logshare::{
LogShareDeleteResponse, LogShareSettings, LogShareUploadResponse,
SharedLog, ai_analyze_direct, ai_analyze_stored, analyse_crash_direct,
delete_log, delete_shared_log, get_insights, get_log_share_settings,
list_shared_logs, record_shared_log, update_log_share_settings,
upload_crash,
};
#[derive(Serialize, Debug)]
pub struct Logs {
pub log_type: LogType,
@@ -39,6 +48,54 @@ pub struct Logs {
pub enum LogType {
InfoLog,
CrashReport,
JvmCrash,
LauncherLog,
}
fn isolated_minecraft_root(instance_root: &Path) -> Option<PathBuf> {
let versions_dir = instance_root.parent()?;
if versions_dir
.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case("versions"))
{
versions_dir.parent().map(Path::to_path_buf)
} else {
None
}
}
fn log_directories_for_type(
state: &State,
game_dir: &Path,
log_type: LogType,
) -> Vec<PathBuf> {
match log_type {
LogType::InfoLog => vec![state.directories.game_logs_dir(game_dir)],
LogType::CrashReport => {
vec![state.directories.game_crash_reports_dir(game_dir)]
}
LogType::JvmCrash | LogType::LauncherLog => {
let mut directories = vec![game_dir.to_path_buf()];
if let Some(root) = isolated_minecraft_root(game_dir) {
directories.push(root);
}
directories
}
}
}
fn is_log_file_for_type(log_type: LogType, file_name: &str) -> bool {
match log_type {
LogType::JvmCrash => {
let lower = file_name.to_ascii_lowercase();
lower.starts_with("hs_err") && lower.ends_with(".log")
}
LogType::LauncherLog => matches!(
file_name.to_ascii_lowercase().as_str(),
"launcher_log.txt" | "latest_stdout.log"
),
_ => true,
}
}
const LOG_COMPACTION_THRESHOLD: usize = 20;
@@ -448,14 +505,11 @@ pub async fn get_logs_from_type(
.directories
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
let logs_folder = match log_type {
LogType::InfoLog => state.directories.game_logs_dir(&game_dir),
LogType::CrashReport => {
state.directories.game_crash_reports_dir(&game_dir)
let mut seen_files = std::collections::HashSet::new();
for logs_folder in log_directories_for_type(&state, &game_dir, log_type) {
if !logs_folder.exists() {
continue;
}
};
if logs_folder.exists() {
for entry in std::fs::read_dir(&logs_folder)
.map_err(|e| IOError::with_path(e, &logs_folder))?
{
@@ -471,6 +525,12 @@ pub async fn get_logs_from_type(
}
if let Some(file_name) = path.file_name() {
let file_name = file_name.to_string_lossy().to_string();
if !is_log_file_for_type(log_type, &file_name) {
continue;
}
if !seen_files.insert(file_name.clone()) {
continue;
}
logs.push(
Logs::build(
log_type,
@@ -507,6 +567,20 @@ pub async fn get_logs(
&mut logs,
)
.await?;
get_logs_from_type(
instance_id,
LogType::JvmCrash,
clear_contents,
&mut logs,
)
.await?;
get_logs_from_type(
instance_id,
LogType::LauncherLog,
clear_contents,
&mut logs,
)
.await?;
let mut logs = logs.into_iter().collect::<crate::Result<Vec<Logs>>>()?;
logs.sort_by(|a, b| b.age.cmp(&a.age).then(b.filename.cmp(&a.filename)));
@@ -526,13 +600,15 @@ pub async fn get_logs_by_filename(
.directories
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
let path = match log_type {
LogType::InfoLog => state.directories.game_logs_dir(&game_dir),
LogType::CrashReport => {
state.directories.game_crash_reports_dir(&game_dir)
}
}
.join(&filename);
let path = log_directories_for_type(&state, &game_dir, log_type)
.into_iter()
.map(|directory| directory.join(&filename))
.find(|path| path.is_file())
.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Log file not found: {filename}"
))
})?;
let metadata = std::fs::metadata(&path)?;
let age = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
@@ -546,14 +622,15 @@ async fn get_output_by_filename_from_path(
log_type: LogType,
file_name: &str,
) -> crate::Result<CensoredString> {
let logs_folder = match log_type {
LogType::InfoLog => state.directories.game_logs_dir(game_dir),
LogType::CrashReport => {
state.directories.game_crash_reports_dir(game_dir)
}
};
let path = logs_folder.join(file_name);
let path = log_directories_for_type(state, game_dir, log_type)
.into_iter()
.map(|directory| directory.join(file_name))
.find(|path| path.is_file())
.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Log file not found: {file_name}"
))
})?;
let credentials = Credentials::get_all(&state.pool)
.await?
@@ -650,14 +727,15 @@ pub async fn delete_logs_by_filename(
.directories
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
let logs_folder = match log_type {
LogType::InfoLog => state.directories.game_logs_dir(&game_dir),
LogType::CrashReport => {
state.directories.game_crash_reports_dir(&game_dir)
}
};
let path = logs_folder.join(filename);
let path = log_directories_for_type(&state, &game_dir, log_type)
.into_iter()
.map(|directory| directory.join(filename))
.find(|path| path.is_file())
.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Log file not found: {filename}"
))
})?;
io::remove_file(&path).await?;
Ok(())
}
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff. Show more