Compare commits
28 Commits
357dc061d6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 82f08c1684 | |||
| df78685dc3 | |||
| cc440ea949 | |||
| 5ef1f4ecc9 | |||
| 4dc73d42cb | |||
| fa656a8d02 | |||
| f26cfdc830 | |||
| 71b35b9cc6 | |||
| 70f7a73e6d | |||
| f92e97b747 | |||
| d66124862a | |||
| f7986ca520 | |||
| 425f2f4d5f | |||
| 35ee164794 | |||
| cf1eb6965e | |||
| 337a6bda1f | |||
| fd1f3ef636 | |||
| afed409127 | |||
| 6e8edbbb09 | |||
| c821e9ebba | |||
| 91a001e3f9 | |||
| 22016365d8 | |||
| 5b22d788d7 | |||
| 0e2743b469 | |||
| 5a83a73108 | |||
| 839a24566b | |||
| 9312156a3c | |||
| 8ee29e9549 |
15
.env.example
15
.env.example
@@ -7,8 +7,9 @@ TRUST_PROXY=false
|
|||||||
FRONTEND_ORIGIN=http://localhost:20015
|
FRONTEND_ORIGIN=http://localhost:20015
|
||||||
APP_ORIGIN=http://localhost:20015
|
APP_ORIGIN=http://localhost:20015
|
||||||
BACKEND_PUBLIC_ORIGIN=http://localhost:20016
|
BACKEND_PUBLIC_ORIGIN=http://localhost:20016
|
||||||
VITE_API_BASE_URL=http://localhost:20016
|
NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
|
||||||
VITE_SITE_URL=https://pokopiawiki.tootaio.com
|
NUXT_SERVER_API_BASE_URL=http://localhost:3001
|
||||||
|
NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
|
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
|
||||||
RESEND_DAILY_QUOTA_LIMIT=100
|
RESEND_DAILY_QUOTA_LIMIT=100
|
||||||
@@ -17,8 +18,16 @@ RESEND_QUOTA_RESERVE=5
|
|||||||
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
|
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
|
||||||
AI_MODERATION_API_KEY=
|
AI_MODERATION_API_KEY=
|
||||||
|
|
||||||
|
# Local Docker debug defaults:
|
||||||
|
# docker compose -f docker-compose.debug.yml up --build
|
||||||
|
# NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
|
||||||
|
# NUXT_SERVER_API_BASE_URL=http://backend:3001
|
||||||
|
# NUXT_PUBLIC_SITE_URL=http://localhost:20015
|
||||||
|
|
||||||
# Cloudflared tunnel deployment example:
|
# Cloudflared tunnel deployment example:
|
||||||
# FRONTEND_ORIGIN=https://pokopiawiki.tootaio.com,http://localhost:20015
|
# FRONTEND_ORIGIN=https://pokopiawiki.tootaio.com,http://localhost:20015
|
||||||
# APP_ORIGIN=https://pokopiawiki.tootaio.com
|
# APP_ORIGIN=https://pokopiawiki.tootaio.com
|
||||||
# BACKEND_PUBLIC_ORIGIN=https://api-pokopiawiki.tootaio.com
|
# BACKEND_PUBLIC_ORIGIN=https://api-pokopiawiki.tootaio.com
|
||||||
# VITE_API_BASE_URL=https://api-pokopiawiki.tootaio.com
|
# NUXT_PUBLIC_API_BASE_URL=https://api-pokopiawiki.tootaio.com
|
||||||
|
# NUXT_SERVER_API_BASE_URL=http://backend:3001
|
||||||
|
# NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,8 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
dist/
|
dist/
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
@@ -8,4 +10,4 @@ coverage/
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.agents/
|
.agents/
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
|
|||||||
1
.repomixignore
Normal file
1
.repomixignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
data/**/*.csv
|
||||||
23
AGENTS.md
23
AGENTS.md
@@ -15,11 +15,12 @@
|
|||||||
For any non-trivial task:
|
For any non-trivial task:
|
||||||
|
|
||||||
1. **Read `DESIGN.md`**
|
1. **Read `DESIGN.md`**
|
||||||
2. For UI, component, layout, or styling tasks, **also read `DesignGuidelines.html`**
|
2. While `SSR_MIGRATION_TASKLIST.md` exists, **also read `SSR_MIGRATION_TASKLIST.md`** and keep SSR migration work aligned with it.
|
||||||
3. **Produce a short plan (no code)**
|
3. For UI, component, layout, or styling tasks, **also read `DesignGuidelines.html`**
|
||||||
4. Wait for approval
|
4. **Produce a short plan (no code)**
|
||||||
5. Implement in small steps
|
5. Wait for approval
|
||||||
6. Run lightweight validation when practical (lint/typecheck). Do not run tests in WSL.
|
6. Implement in small steps
|
||||||
|
7. Run lightweight validation when practical (lint/typecheck). Do not run tests in WSL.
|
||||||
|
|
||||||
Do NOT skip planning.
|
Do NOT skip planning.
|
||||||
|
|
||||||
@@ -27,6 +28,16 @@ For documentation-only tasks, still follow the planning workflow, but do not run
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Temporary SSR Migration Workflow
|
||||||
|
|
||||||
|
* `SSR_MIGRATION_TASKLIST.md` is the active task list for completing the Nuxt SSR migration.
|
||||||
|
* Until that migration is fully implemented and validated, every task that touches frontend routing, auth, API fetching, i18n, SEO, Docker frontend deployment, Nuxt config, or SSR/client runtime behavior must read and follow `SSR_MIGRATION_TASKLIST.md`.
|
||||||
|
* Update task checkboxes in `SSR_MIGRATION_TASKLIST.md` only when the corresponding implementation is actually complete and validated.
|
||||||
|
* Do not delete `SSR_MIGRATION_TASKLIST.md` early. Delete it only after the project is fully migrated to the final SSR deployment model, validation is complete, and `DESIGN.md` reflects the final behavior.
|
||||||
|
* When deleting `SSR_MIGRATION_TASKLIST.md`, also remove this Temporary SSR Migration Workflow section and the mandatory workflow step that requires reading the task list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Project Context
|
## Project Context
|
||||||
|
|
||||||
* Goal: Pokopia Wiki, a community-editable game wiki.
|
* Goal: Pokopia Wiki, a community-editable game wiki.
|
||||||
@@ -34,8 +45,8 @@ For documentation-only tasks, still follow the planning workflow, but do not run
|
|||||||
* Runtime baseline: Node.js >= 22.
|
* Runtime baseline: Node.js >= 22.
|
||||||
* Frontend:
|
* Frontend:
|
||||||
|
|
||||||
|
* Nuxt SPA mode currently (`ssr: false`), with SSR migration tracked in `SSR_MIGRATION_TASKLIST.md`
|
||||||
* Vue
|
* Vue
|
||||||
* Vite
|
|
||||||
* Vue Router
|
* Vue Router
|
||||||
* Vue I18n
|
* Vue I18n
|
||||||
* Iconify
|
* Iconify
|
||||||
|
|||||||
120
DESIGN.md
120
DESIGN.md
@@ -15,7 +15,7 @@
|
|||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- Monorepo:pnpm workspace,Node.js >= 22,TypeScript。
|
- Monorepo:pnpm workspace,Node.js >= 22,TypeScript。
|
||||||
- 前端:Vue、Vite、Vue Router、Vue I18n、Iconify。
|
- 前端:Nuxt(`ssr: true`)、Vue、Vue Router、Vue I18n、Iconify。
|
||||||
- 后端:Node.js、Fastify、pg、PostgreSQL。
|
- 后端:Node.js、Fastify、pg、PostgreSQL。
|
||||||
- 运维:Docker / docker compose。
|
- 运维:Docker / docker compose。
|
||||||
- 依赖版本遵循现有 `package.json`,新增依赖时优先使用当前主流稳定版本,并保持项目结构简单。
|
- 依赖版本遵循现有 `package.json`,新增依赖时优先使用当前主流稳定版本,并保持项目结构简单。
|
||||||
@@ -27,12 +27,13 @@
|
|||||||
- 全局搜索 API 只返回公开浏览所需的最小结果字段:结果类型、ID、展示标题、目标 URL、可选摘要和可选图片;用户搜索结果只使用公开 Profile 所需的 `id`、`displayName` 和目标 URL,不返回邮箱、角色、权限、Referral、编辑审计、审核原因、token/hash、内部字段或调试信息。
|
- 全局搜索 API 只返回公开浏览所需的最小结果字段:结果类型、ID、展示标题、目标 URL、可选摘要和可选图片;用户搜索结果只使用公开 Profile 所需的 `id`、`displayName` 和目标 URL,不返回邮箱、角色、权限、Referral、编辑审计、审核原因、token/hash、内部字段或调试信息。
|
||||||
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
|
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
|
||||||
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
|
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
|
||||||
- 列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序。
|
- 除 Pokemon 外,列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序;Pokemon 列表按内部 `id` 升序展示,不提供手动排序。
|
||||||
|
|
||||||
## 国际化
|
## 国际化
|
||||||
|
|
||||||
- 前端使用 Vue I18n 管理界面文案,并通过 `X-Locale` 请求头告知后端当前语言。
|
- 前端使用 Vue I18n 管理界面文案,并通过 `X-Locale` 请求头告知后端当前语言。
|
||||||
- 前端当前语言保存在 `localStorage` 的 `pokopia_locale`。
|
- 前端当前语言保存在 `localStorage` 的 `pokopia_locale`。
|
||||||
|
- Nuxt SSR 运行时每个 Nuxt app/request 创建独立 Vue I18n 实例,避免跨请求共享 locale 或系统文案状态;服务端默认使用 `en`,客户端 hydration 后按 `pokopia_locale` 恢复用户语言。
|
||||||
- 后端默认语言为 `en`。
|
- 后端默认语言为 `en`。
|
||||||
- 语言配置存储在 `languages`:
|
- 语言配置存储在 `languages`:
|
||||||
- `code`
|
- `code`
|
||||||
@@ -58,8 +59,7 @@
|
|||||||
- 喜欢的环境
|
- 喜欢的环境
|
||||||
- 喜欢的东西 / 标签
|
- 喜欢的东西 / 标签
|
||||||
- 入手方式
|
- 入手方式
|
||||||
- 物品
|
- 物品(包含 Ancient Artifacts 视图中的物品)
|
||||||
- Ancient Artifacts
|
|
||||||
- 地图
|
- 地图
|
||||||
- 栖息地
|
- 栖息地
|
||||||
- 每日 CheckList Task
|
- 每日 CheckList Task
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
- 支持翻译的字段:
|
- 支持翻译的字段:
|
||||||
- `name`
|
- `name`
|
||||||
- `title`
|
- `title`
|
||||||
- `details`:Pokemon、物品和 Ancient Artifacts 的介绍 / 说明
|
- `details`:Pokemon 和物品的介绍 / 说明
|
||||||
- `genus`:仅 Pokemon Genus 使用
|
- `genus`:仅 Pokemon Genus 使用
|
||||||
- `effect`:Dish Category 的吃后效果
|
- `effect`:Dish Category 的吃后效果
|
||||||
- `mosslaxEffect`:Dish 给 Mosslax 吃之后的效果
|
- `mosslaxEffect`:Dish 给 Mosslax 吃之后的效果
|
||||||
@@ -121,10 +121,15 @@
|
|||||||
- 重置 token 只保存 hash,并带过期时间和使用状态。
|
- 重置 token 只保存 hash,并带过期时间和使用状态。
|
||||||
- 密码重置成功后不自动登录,并删除该用户已有 session。
|
- 密码重置成功后不自动登录,并删除该用户已有 session。
|
||||||
- 登录页提供 Remember me:
|
- 登录页提供 Remember me:
|
||||||
- 未勾选时前端将登录 token 保存在 `sessionStorage` 的 `pokopia_auth_token`,服务端 session 有效期为 1 天。
|
- 未勾选时 session 有效期为 1 天。
|
||||||
- 勾选时前端将登录 token 保存在 `localStorage` 的 `pokopia_auth_token`,服务端 session 有效期为 30 天。
|
- 勾选时 session 有效期为 30 天。
|
||||||
- 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。
|
- SSR 认证使用 HTTP-only cookie session:
|
||||||
- 用户可退出登录,退出时删除对应 session。
|
- 登录成功后后端设置 HTTP-only `pokopia_session` cookie;cookie 只保存明文 session token,数据库只保存 session token hash。
|
||||||
|
- 登录响应只返回当前用户必要字段,不返回明文 session token、session token hash 或内部 session 元数据。
|
||||||
|
- Remember me 通过 HTTP-only session cookie 有效期实现:未勾选时有效期为 1 天,勾选时有效期为 30 天。
|
||||||
|
- 受保护 API 只接受 HTTP-only cookie session,不接受前端 JavaScript 保存的 legacy Bearer token。
|
||||||
|
- 前端 API 请求携带 credentials,以便浏览器自动发送 HTTP-only session cookie;JavaScript 不读取该 cookie。
|
||||||
|
- 用户可退出登录,退出时删除对应 session 并清除 HTTP-only session cookie。
|
||||||
- 对外用户字段只包含必要信息:
|
- 对外用户字段只包含必要信息:
|
||||||
- 当前用户:`id`、`email`、`displayName`、`emailVerified`
|
- 当前用户:`id`、`email`、`displayName`、`emailVerified`
|
||||||
- 编辑署名:`id`、`displayName`
|
- 编辑署名:`id`、`displayName`
|
||||||
@@ -215,10 +220,10 @@
|
|||||||
- Items 与 Recipes 存在依赖关系;选择 Items 进行导出、导入或 Wipe 时,系统必须自动同时纳入 Recipes,前端确认内容也必须显示 Recipes。
|
- Items 与 Recipes 存在依赖关系;选择 Items 进行导出、导入或 Wipe 时,系统必须自动同时纳入 Recipes,前端确认内容也必须显示 Recipes。
|
||||||
- Wipe 行为:
|
- Wipe 行为:
|
||||||
- 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
|
- 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
|
||||||
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
|
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联 / Trading 观察,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
|
||||||
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
|
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
|
||||||
- Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项和 Pokemon 掉落关联。
|
- Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项、Pokemon 掉落关联和 Trading 观察。
|
||||||
- Wipe Ancient Artifacts 会删除 Ancient Artifacts、标签关联、实体翻译、编辑历史和实体讨论评论。
|
- Wipe Ancient Artifacts 会清空物品上的 Ancient Artifact 分类并删除对应 Ancient Artifact 讨论;物品本身仍保留在 Items / Event Items 中。
|
||||||
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
|
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
|
||||||
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
|
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
|
||||||
- 对被清空的 identity 主表重置自增 ID;Pokemon 内部 ID 不是 identity,未关联官方 data 的自定义 Pokemon 系统分配区间仍按当前数据库最大值继续。
|
- 对被清空的 identity 主表重置自增 ID;Pokemon 内部 ID 不是 identity,未关联官方 data 的自定义 Pokemon 系统分配区间仍按当前数据库最大值继续。
|
||||||
@@ -226,14 +231,23 @@
|
|||||||
- 导出为版本化 JSON bundle,包含 `version`、`exportedAt`、`scopes` 和对应范围数据。
|
- 导出为版本化 JSON bundle,包含 `version`、`exportedAt`、`scopes` 和对应范围数据。
|
||||||
- JSON bundle 用于系统导入,不作为前台展示内容。
|
- JSON bundle 用于系统导入,不作为前台展示内容。
|
||||||
- 导出包含所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
|
- 导出包含所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
|
||||||
- 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。
|
- 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落、Trading 观察和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。
|
||||||
- JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。
|
- JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。
|
||||||
- Import 行为:
|
- Import 行为:
|
||||||
- 当前只支持 Replace selected scopes:导入前先 Wipe bundle 中包含的范围,再在同一事务中还原 bundle 数据。
|
- 当前只支持 Replace selected scopes:导入前先 Wipe bundle 中包含的范围,再在同一事务中还原 bundle 数据。
|
||||||
- Import 不自动覆盖系统配置、语言、用户、角色、权限、系统文案或 Life 内容。
|
- Import 不自动覆盖系统配置、语言、用户、角色、权限、系统文案或 Life 内容。
|
||||||
- 导入数据引用的 System config、Languages、Users 或上传文件路径必须已存在;缺失依赖会导致导入失败并回滚。
|
- 导入数据引用的 System config、Languages、Users 或上传文件路径必须已存在;缺失依赖会导致导入失败并回滚。
|
||||||
- Import 完成后重置相关 identity sequence 到当前最大 ID 之后。
|
- Import 完成后重置相关 identity sequence 到当前最大 ID 之后。
|
||||||
- 前端导入和 Wipe 必须使用确认 Modal,并要求输入固定确认词后才能执行。
|
- Data Tools 额外支持 Items CSV 导入,用于在 Wipe Items 后按 CSV 顺序批量新增普通 Items;CSV 导入只新增 Items,不自动 Wipe,不创建 Recipes、入手方式、标签或翻译。
|
||||||
|
- Items CSV 必须包含 `name`、`category`、`description`、`image_file_name`、`not_registered_in_collection`、`cannot_grow_again_today` 列。
|
||||||
|
- Items CSV 的 `category` 必须匹配系统固定物品分类;支持 `Misc.` 匹配内置 `Misc`,其他值按固定分类英文名匹配。
|
||||||
|
- Items CSV 导入时,`description` 写入物品介绍;若 `not_registered_in_collection` 为 true,追加 `Note: Not registered in collection`;若 `cannot_grow_again_today` 为 true,追加 `Note: Cannot have Grow used on it again today`;原介绍非空时 Note 前使用换行分隔。
|
||||||
|
- Items CSV 导入时,图片路径保存为 `/pokopia/items/{image_file_name}`,API 对外图片 URL 解析为 `https://pokesprite.tootaio.com/pokopia/items/{image_file_name}`。
|
||||||
|
- Data Tools 额外支持 Habitats CSV 导入,用于在 Wipe Habitats 后按 CSV 顺序批量新增 Habitats;CSV 导入只新增 Habitats,不自动 Wipe,不创建配方项、Pokemon 出现配置或翻译。
|
||||||
|
- Habitats CSV 必须包含 `id`、`name`、`image_file_name` 列。
|
||||||
|
- Habitats CSV 的 `id` 仅用于识别导入行与 Event 标记,不写入数据库主键;`id` 前缀为 `E` 或 `E-` 时导入为 Event Habitat,否则导入为 Main Game Habitat。
|
||||||
|
- Habitats CSV 导入时,图片路径保存为 `/pokopia/habitats/{image_file_name}`,API 对外图片 URL 解析为 `https://pokesprite.tootaio.com/pokopia/habitats/{image_file_name}`。
|
||||||
|
- 前端 JSON bundle Import 和 Wipe 必须使用确认 Modal,并要求输入固定确认词后才能执行;Items CSV 和 Habitats CSV 导入只新增对应内容,不执行删除,可直接从 CSV 文件选择触发。
|
||||||
|
|
||||||
## Referral
|
## Referral
|
||||||
|
|
||||||
@@ -344,6 +358,7 @@
|
|||||||
- `created_at`
|
- `created_at`
|
||||||
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
|
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
|
||||||
- 编辑历史中的用户信息只展示必要署名,不暴露邮箱、token、hash 或内部元数据。
|
- 编辑历史中的用户信息只展示必要署名,不暴露邮箱、token、hash 或内部元数据。
|
||||||
|
- 非 Pokemon 列表排序操作仍更新列表顺序、最后编辑者和最后编辑时间,但 `sort_order` / Sort order 字段变更不写入或展示在详情页编辑历史面板中。
|
||||||
- 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。
|
- 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。
|
||||||
|
|
||||||
## Wiki 图片上传
|
## Wiki 图片上传
|
||||||
@@ -435,8 +450,10 @@
|
|||||||
|
|
||||||
- 名称
|
- 名称
|
||||||
- 是否有掉落物:`has_item_drop`
|
- 是否有掉落物:`has_item_drop`
|
||||||
|
- 是否支持 Trading:`has_trading`
|
||||||
- 已移除 `subcategory` 字段。
|
- 已移除 `subcategory` 字段。
|
||||||
- 当特长允许掉落物时,Pokemon 编辑中可为该 Pokemon + 特长配置一个掉落物品。
|
- 当特长允许掉落物时,Pokemon 编辑中可为该 Pokemon + 特长配置一个掉落物品。
|
||||||
|
- 当 Pokemon 选择了至少一个支持 Trading 的特长时,Pokemon 详情页可直接维护该 Pokemon 对物品的 Trading 偏好观察。
|
||||||
|
|
||||||
### Pokemon Types
|
### Pokemon Types
|
||||||
|
|
||||||
@@ -499,6 +516,10 @@ Pokemon 可配置:
|
|||||||
- 特长:可多选,最多 2 个
|
- 特长:可多选,最多 2 个
|
||||||
- 特长掉落物品:按 Pokemon + 特长配置,单选物品
|
- 特长掉落物品:按 Pokemon + 特长配置,单选物品
|
||||||
- 喜欢的东西:可多选,最多 6 个
|
- 喜欢的东西:可多选,最多 6 个
|
||||||
|
- Trading:由所选特长是否支持 Trading 决定;当至少一个所选特长支持 Trading 时,可维护该 Pokemon 对物品的 Trading 偏好观察,分为 Likes 与 Neutral
|
||||||
|
- Likes:该 Pokemon 喜欢交易该物品,交易价格触发 1.5x 加成;用于物品隐藏标签推断的正向证据
|
||||||
|
- Neutral:该 Pokemon 对交易该物品无加成;用于物品隐藏标签推断的硬排除证据
|
||||||
|
- 每个物品在同一个 Pokemon 的 Trading 列表中只能出现一次,只能属于 Likes 或 Neutral 其中一组
|
||||||
- 六维:
|
- 六维:
|
||||||
- HP
|
- HP
|
||||||
- Attack
|
- Attack
|
||||||
@@ -508,7 +529,6 @@ Pokemon 可配置:
|
|||||||
- Speed
|
- Speed
|
||||||
- 出现的栖息地:由栖息地出现配置反向展示
|
- 出现的栖息地:由栖息地出现配置反向展示
|
||||||
- 翻译
|
- 翻译
|
||||||
- 排序
|
|
||||||
|
|
||||||
普通 Pokemon 与 Event Pokemon 分开展示:
|
普通 Pokemon 与 Event Pokemon 分开展示:
|
||||||
|
|
||||||
@@ -546,6 +566,7 @@ Pokemon 编辑表单使用标签页组织字段:
|
|||||||
- 第二行:喜欢的环境、特长
|
- 第二行:喜欢的环境、特长
|
||||||
- 第三行:喜欢的东西
|
- 第三行:喜欢的东西
|
||||||
- 特长掉落物品随已选择且支持掉落物的特长显示
|
- 特长掉落物品随已选择且支持掉落物的特长显示
|
||||||
|
- 编辑表单不直接维护 Trading 观察;Trading 由详情页的 Manage Trading 入口维护
|
||||||
- Pokemon 图片选择区
|
- Pokemon 图片选择区
|
||||||
- Advance 标签页:
|
- Advance 标签页:
|
||||||
- 第一行:Genus
|
- 第一行:Genus
|
||||||
@@ -564,7 +585,8 @@ Pokemon 列表功能:
|
|||||||
- 按喜欢的东西筛选:
|
- 按喜欢的东西筛选:
|
||||||
- 满足任意条件
|
- 满足任意条件
|
||||||
- 满足全部条件
|
- 满足全部条件
|
||||||
- 按自定义排序展示
|
- 按 Pokemon 内部 `id` 升序展示
|
||||||
|
- 列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Pokemon。
|
||||||
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
|
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
|
||||||
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
|
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
|
||||||
- Event Pokemon 列表功能与 Pokemon 列表相同,但只展示 `is_event_item = true` 的 Pokemon;Pokemon 列表只展示 `is_event_item = false` 的 Pokemon。
|
- Event Pokemon 列表功能与 Pokemon 列表相同,但只展示 `is_event_item = true` 的 Pokemon;Pokemon 列表只展示 `is_event_item = false` 的 Pokemon。
|
||||||
@@ -579,7 +601,9 @@ Pokemon 详情页展示:
|
|||||||
- 右侧:六维 Stats;图片或默认占位符展示在 Stats 右侧
|
- 右侧:六维 Stats;图片或默认占位符展示在 Stats 右侧
|
||||||
- 六维使用 ProgressBar 展示,最大值按 150 计算。
|
- 六维使用 ProgressBar 展示,最大值按 150 计算。
|
||||||
- 特长
|
- 特长
|
||||||
- 特长掉落物品:展示掉落物品图标;未配置图标时显示默认物品标记占位符
|
- 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标,未配置时展示空状态
|
||||||
|
- Trading:当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品,Likes 表示交易价格 1.5x,Neutral 表示无加成,未配置观察时展示空状态;详情页双列列表高度受限,内容较多时每列内部独立滚动,避免 Section 被大量物品无限拉长
|
||||||
|
- Trading 可在详情页通过 Manage Trading Modal 维护;Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;搜索结果优先展示名称精确匹配、名称开头匹配和单词匹配的物品,再展示名称包含、分类或用途包含的物品;搜索框支持上下键切换当前结果、左右键切换默认加入组、Enter 将当前结果加入默认目标组;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动
|
||||||
- 喜欢的环境
|
- 喜欢的环境
|
||||||
- 喜欢的东西
|
- 喜欢的东西
|
||||||
- 相关 Pokemon:与关联喜欢的东西的物品在桌面端左右并排展示;按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
|
- 相关 Pokemon:与关联喜欢的东西的物品在桌面端左右并排展示;按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
|
||||||
@@ -595,6 +619,11 @@ Pokemon 详情页展示:
|
|||||||
|
|
||||||
- 名称
|
- 名称
|
||||||
- 介绍
|
- 介绍
|
||||||
|
- Base Price:可为空
|
||||||
|
- Ancient Artifact:可为空,Items Edit 使用单选框维护;`No` 表示普通物品,其他值使用系统固定列表:
|
||||||
|
- Lost Relics (L)
|
||||||
|
- Lost Relics (S)
|
||||||
|
- Fossils
|
||||||
- 是否为 Event Item:`is_event_item`
|
- 是否为 Event Item:`is_event_item`
|
||||||
- 分类:必填,使用系统固定列表,不在管理端配置:
|
- 分类:必填,使用系统固定列表,不在管理端配置:
|
||||||
- Furniture
|
- Furniture
|
||||||
@@ -622,6 +651,7 @@ Pokemon 详情页展示:
|
|||||||
- 无材料单:`no_recipe`
|
- 无材料单:`no_recipe`
|
||||||
- 标签:使用喜欢的东西配置,可多选
|
- 标签:使用喜欢的东西配置,可多选
|
||||||
- 图标图片:通过通用 Wiki 图片上传维护当前图标和历史上传记录
|
- 图标图片:通过通用 Wiki 图片上传维护当前图标和历史上传记录
|
||||||
|
- Data Tools 的 Items CSV 导入可为物品写入静态图标路径 `/pokopia/items/{image_file_name}`;静态图标展示 URL 为 `https://pokesprite.tootaio.com/pokopia/items/{image_file_name}`,用户后续仍可在编辑页切换为社区上传图片
|
||||||
- 翻译
|
- 翻译
|
||||||
- 排序
|
- 排序
|
||||||
|
|
||||||
@@ -630,6 +660,7 @@ Items 与 Event Items 使用相同数据模型:
|
|||||||
- Items 列表只展示 `is_event_item = false` 的物品。
|
- Items 列表只展示 `is_event_item = false` 的物品。
|
||||||
- Event Items 列表只展示 `is_event_item = true` 的物品。
|
- Event Items 列表只展示 `is_event_item = true` 的物品。
|
||||||
- Event Items 与 Items 共用物品分类、用途、入手方式、标签、图片、翻译和材料单逻辑。
|
- Event Items 与 Items 共用物品分类、用途、入手方式、标签、图片、翻译和材料单逻辑。
|
||||||
|
- 已选择 Ancient Artifact 分类的物品仍显示在 Items / Event Items 列表中,并额外进入 Ancient Artifacts 对应分类列表。
|
||||||
|
|
||||||
物品列表功能:
|
物品列表功能:
|
||||||
|
|
||||||
@@ -638,8 +669,9 @@ Items 与 Event Items 使用相同数据模型:
|
|||||||
- 按用途筛选
|
- 按用途筛选
|
||||||
- 按标签筛选
|
- 按标签筛选
|
||||||
- 按自定义排序展示
|
- 按自定义排序展示
|
||||||
|
- 公开列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Items 或 Event Items。
|
||||||
- All 视图在满足写入权限时支持对 Grid Item 右键插入新物品到前/后,并支持直接拖曳 Item 调整排序;插入与拖曳只作用于当前展示的 Items 列表,不影响 Event Items 入口。
|
- All 视图在满足写入权限时支持对 Grid Item 右键插入新物品到前/后,并支持直接拖曳 Item 调整排序;插入与拖曳只作用于当前展示的 Items 列表,不影响 Event Items 入口。
|
||||||
- 新增物品入口支持当前浏览器 Session 的默认值菜单;用户可为新建物品预设分类、客制化勾选项和入手方式。默认值只影响 `/items/new` 与 `/event-items/new` 的新建表单初始值,不影响编辑已有物品,不改变 API、数据库模型、权限或审计行为;Event Items 仍由 `/event-items/new` 入口决定 `is_event_item`。
|
- 新增物品入口支持当前浏览器 Session 的默认值菜单;用户可为新建物品预设分类、用途、客制化勾选项和入手方式。默认值只影响 `/items/new` 与 `/event-items/new` 的新建表单初始值,不影响编辑已有物品,不改变 API、数据库模型、权限或审计行为;Event Items 仍由 `/event-items/new` 入口决定 `is_event_item`。
|
||||||
- 物品列表桌面端使用 12 列紧凑 Grid,每个格子只展示物品图标;有用途的物品在卡片左上角以斜 Ribbon 展示用途名称;物品名称通过 hover / focus Tooltip 展示。
|
- 物品列表桌面端使用 12 列紧凑 Grid,每个格子只展示物品图标;有用途的物品在卡片左上角以斜 Ribbon 展示用途名称;物品名称通过 hover / focus Tooltip 展示。
|
||||||
- 物品列表移动端保持常规卡片布局,展示物品图标、名称和分类。
|
- 物品列表移动端保持常规卡片布局,展示物品图标、名称和分类。
|
||||||
- 物品列表不展示标签、入手方式或编辑元信息。
|
- 物品列表不展示标签、入手方式或编辑元信息。
|
||||||
@@ -651,11 +683,20 @@ Items 与 Event Items 使用相同数据模型:
|
|||||||
- 当前图标图片;未配置图标时展示默认物品标记占位符
|
- 当前图标图片;未配置图标时展示默认物品标记占位符
|
||||||
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
|
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
|
||||||
- 介绍
|
- 介绍
|
||||||
|
- Base Price
|
||||||
|
- Ancient Artifact 分类:仅在物品已配置 Ancient Artifact 分类时展示
|
||||||
- 分类
|
- 分类
|
||||||
- 用途
|
- 用途
|
||||||
- 入手方式
|
- 入手方式
|
||||||
- 客制化
|
- 客制化
|
||||||
- 标签
|
- 标签
|
||||||
|
- Possible Tags:根据所有拥有支持 Trading 特长的 Pokemon Trading 观察推断该物品可能包含的隐藏标签
|
||||||
|
- 每个 Pokemon 的“喜欢的东西”视为该 Pokemon 已知的 6 个隐藏标签集合;不完整数据仍参与展示,但不会强行补足缺失标签
|
||||||
|
- 若物品被 Pokemon 标记为 Likes,则该物品至少包含该 Pokemon 标签集合中的一个标签,属于 OR 正向证据
|
||||||
|
- 若物品被 Pokemon 标记为 Neutral,则该物品不包含该 Pokemon 标签集合中的任何标签,属于硬排除证据;Neutral 排除优先于 Likes 正向证据
|
||||||
|
- 推断流程必须确定性执行:从所有“喜欢的东西 / 标签”开始,先移除所有 Neutral Pokemon 提供的标签,再用 Likes Pokemon 的标签集合收窄候选;多个 Likes 观察的共同候选归为 Highly likely,其余正向候选归为 Possible,被排除或被约束移出的标签归为 Excluded
|
||||||
|
- 没有可用 Likes 观察时,未被 Neutral 排除的标签保持 Possible;没有任何观察时,所有标签保持 Possible
|
||||||
|
- Possible Tags 区块必须展示 Likes 与 Neutral 证据来源,包含贡献 Pokemon 及其已知标签,不展示内部字段、调试信息或推断中间状态
|
||||||
- 关联材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符
|
- 关联材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符
|
||||||
- 作为材料出现的材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符
|
- 作为材料出现的材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符
|
||||||
- 相关栖息地:展示栖息地图片或默认栖息地标记占位符,并展示配方材料物品图标
|
- 相关栖息地:展示栖息地图片或默认栖息地标记占位符,并展示配方材料物品图标
|
||||||
@@ -666,12 +707,12 @@ Items 与 Event Items 使用相同数据模型:
|
|||||||
|
|
||||||
## Ancient Artifacts
|
## Ancient Artifacts
|
||||||
|
|
||||||
Ancient Artifacts 是独立 Wiki 内容类型,可配置:
|
Ancient Artifacts 是 Items 的可选分类视图,不再维护独立主数据结构或独立表;列表、详情和排序从 `items.ancient_artifact_category_key IS NOT NULL` 的物品获取。已配置 Ancient Artifact 分类的物品仍保留在 Items / Event Items 列表中,并额外出现在 Ancient Artifacts 对应分类列表。Ancient Artifact 路由继续保留,用于浏览、编辑和导航对应的物品记录。
|
||||||
|
|
||||||
- 名称
|
- 名称
|
||||||
- 介绍
|
- 介绍
|
||||||
- 图片:使用 Ancient Artifacts 上传目录,支持图片历史
|
- 图片:使用 Items 编辑器和上传目录,支持图片历史
|
||||||
- 分类:必填,使用系统固定列表,不在管理端配置:
|
- 分类:在 Items Edit 的 Ancient Artifact 单选框中维护;`No` 表示不进入 Ancient Artifacts 列表,其他选项使用系统固定列表,不在管理端配置:
|
||||||
- Lost Relics (L)
|
- Lost Relics (L)
|
||||||
- Lost Relics (S)
|
- Lost Relics (S)
|
||||||
- Fossils
|
- Fossils
|
||||||
@@ -689,16 +730,7 @@ Ancient Artifacts 列表功能:
|
|||||||
- 列表移动端保持常规卡片布局,展示图片 / 默认 Ancient Artifact 标记、名称和分类。
|
- 列表移动端保持常规卡片布局,展示图片 / 默认 Ancient Artifact 标记、名称和分类。
|
||||||
- 列表不展示编辑元信息。
|
- 列表不展示编辑元信息。
|
||||||
|
|
||||||
Ancient Artifacts 详情页展示:
|
Ancient Artifacts 详情页使用同一套 Item Details 视图展示同一条 `items` 记录;顶部、图片、基础信息、Base Price、物品分类、用途、入手方式、客制化、标签、材料单关联、讨论和编辑历史均按物品详情页规则展示,并额外展示 Ancient Artifact 分类。通过 `/ancient-artifacts/:id` 打开的普通非 Ancient Artifact 物品会回到对应 `/items/:id`。
|
||||||
|
|
||||||
- 名称
|
|
||||||
- 图片;未配置图片时展示默认 Ancient Artifact 标记
|
|
||||||
- 介绍
|
|
||||||
- 分类
|
|
||||||
- 标签
|
|
||||||
- 最后编辑信息
|
|
||||||
- 讨论
|
|
||||||
- 编辑历史
|
|
||||||
|
|
||||||
## 材料单
|
## 材料单
|
||||||
|
|
||||||
@@ -972,7 +1004,7 @@ API 暴露边界:
|
|||||||
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
|
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
|
||||||
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
|
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
|
||||||
- 配置:System config。
|
- 配置:System config。
|
||||||
- 内容:Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口,以及 Data Tools。
|
- 内容:Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口,以及 Data Tools;Pokemon 在 Admin 中可删除但不提供手动排序。
|
||||||
- 内容管理包含 Items、Event Items 与 Ancient Artifacts;Items / Event Items 使用同一物品数据模型,通过 `is_event_item` 拆分入口。
|
- 内容管理包含 Items、Event Items 与 Ancient Artifacts;Items / Event Items 使用同一物品数据模型,通过 `is_event_item` 拆分入口。
|
||||||
- 本地化:Languages、System wordings。
|
- 本地化:Languages、System wordings。
|
||||||
- 访问权限:Users、Roles、Permissions、Rate limits。
|
- 访问权限:Users、Roles、Permissions、Rate limits。
|
||||||
@@ -997,6 +1029,7 @@ API 暴露边界:
|
|||||||
- `/ancient-artifacts/:id/edit`
|
- `/ancient-artifacts/:id/edit`
|
||||||
- `/recipes/new`
|
- `/recipes/new`
|
||||||
- `/recipes/:id/edit`
|
- `/recipes/:id/edit`
|
||||||
|
- `/ancient-artifacts/new` 和 `/ancient-artifacts/:id/edit` 使用 Items 编辑器与 Items create/update 权限;保存的是同一条 `items` 记录。
|
||||||
- Life 使用信息流顶部 New Post / 编辑按钮打开普通 Modal 发布与编辑,不使用路由驱动 Modal。
|
- Life 使用信息流顶部 New Post / 编辑按钮打开普通 Modal 发布与编辑,不使用路由驱动 Modal。
|
||||||
- 进入或关闭编辑 Modal 时应保留底层页面上下文,不进行不必要的滚动跳转。
|
- 进入或关闭编辑 Modal 时应保留底层页面上下文,不进行不必要的滚动跳转。
|
||||||
- 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。
|
- 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。
|
||||||
@@ -1008,8 +1041,8 @@ API 暴露边界:
|
|||||||
- `favicon.ico`
|
- `favicon.ico`
|
||||||
- 默认社交分享图
|
- 默认社交分享图
|
||||||
- 品牌 Logo 素材
|
- 品牌 Logo 素材
|
||||||
- `VITE_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`。
|
- `NUXT_PUBLIC_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`。
|
||||||
- 前端入口 `index.html` 提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon;客户端路由切换后根据当前路由更新页面 metadata。
|
- 前端 Nuxt app head 配置提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon;路由 metadata 与详情页公开业务数据通过 Nuxt `useHead` 更新页面 metadata,避免直接操作 `document.head`。
|
||||||
- 主要公开浏览入口可索引:
|
- 主要公开浏览入口可索引:
|
||||||
- `/pokemon`
|
- `/pokemon`
|
||||||
- `/event-pokemon`
|
- `/event-pokemon`
|
||||||
@@ -1033,9 +1066,13 @@ API 暴露边界:
|
|||||||
## 部署与升级维护
|
## 部署与升级维护
|
||||||
|
|
||||||
- Docker 部署时公开前端端口由 `frontend_gateway` 承载,正常流量代理到 `frontend` 服务。
|
- Docker 部署时公开前端端口由 `frontend_gateway` 承载,正常流量代理到 `frontend` 服务。
|
||||||
|
- 前端浏览器 API base URL 由 `NUXT_PUBLIC_API_BASE_URL` 提供。
|
||||||
|
- Nuxt 服务端 API base URL 由 `NUXT_SERVER_API_BASE_URL` 提供;在 Docker 内默认使用 `http://backend:3001`,本地非 Docker 运行可使用 `http://localhost:3001`。服务端公开数据读取使用该内部地址,浏览器请求继续使用 `NUXT_PUBLIC_API_BASE_URL`。
|
||||||
|
- 前端 Docker 构建使用 Nuxt server output,`frontend` 服务通过 Node 运行 `.output/server/index.mjs`;Nuxt SSR server 监听容器内 `0.0.0.0:20015`,公开流量仍由 `frontend_gateway` 代理。
|
||||||
- `frontend` 因 `docker compose up -d --build` 重建、启动中或临时不可达时,`frontend_gateway` 返回静态升级维护页并保持公开端口可访问;后端 `/health` 不可用时,前端网关也返回同一维护页,避免用户看到静态页面后遇到 API 不可用。
|
- `frontend` 因 `docker compose up -d --build` 重建、启动中或临时不可达时,`frontend_gateway` 返回静态升级维护页并保持公开端口可访问;后端 `/health` 不可用时,前端网关也返回同一维护页,避免用户看到静态页面后遇到 API 不可用。
|
||||||
- 升级维护页是基础设施级静态 fallback,不依赖 Vue、Vue I18n、后端 API 或数据库;页面只展示正式用户文案和品牌视觉,不展示构建日志、调试信息、内部字段或实现说明。
|
- 升级维护页是基础设施级静态 fallback,不依赖 Vue、Vue I18n、后端 API 或数据库;页面只展示正式用户文案和品牌视觉,不展示构建日志、调试信息、内部字段或实现说明。
|
||||||
- 升级维护页使用 `503`、`Retry-After: 300`、`Cache-Control: no-store` 和 `noindex`,提示用户 Pokopia Wiki 正在升级并将在约 5 分钟内恢复。
|
- 升级维护页使用 `503`、`Retry-After: 300`、`Cache-Control: no-store` 和 `noindex`,提示用户 Pokopia Wiki 正在升级并将在约 5 分钟内恢复。
|
||||||
|
- 本地 Docker 调试使用 `docker-compose.debug.yml`,通过 bind mount 运行 Nuxt dev server 与 backend `tsx watch`,支持前后端热重载;该调试入口不经过 `frontend_gateway` 维护页,不代表生产部署行为。
|
||||||
|
|
||||||
## API 概览
|
## API 概览
|
||||||
|
|
||||||
@@ -1045,16 +1082,16 @@ API 暴露边界:
|
|||||||
- `GET /api/system-wordings`
|
- `GET /api/system-wordings`
|
||||||
- `GET /api/options`
|
- `GET /api/options`
|
||||||
- `GET /api/project-updates`:读取站点项目公开更新信息;支持 `cursor` / `limit` 分页读取最近提交;仅返回净化后的仓库、最近提交和发布版本展示字段。
|
- `GET /api/project-updates`:读取站点项目公开更新信息;支持 `cursor` / `limit` 分页读取最近提交;仅返回净化后的仓库、最近提交和发布版本展示字段。
|
||||||
- `GET /api/daily-checklist`
|
- `GET /api/daily-checklist`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容管理端排序。
|
||||||
- `GET /api/pokemon`:支持 `isEventItem=true|false` 按普通 Pokemon / Event Pokemon 拆分列表;未传时返回全部 Pokemon 以兼容管理端和实体选择器
|
- `GET /api/pokemon`:支持 `isEventItem=true|false` 按普通 Pokemon / Event Pokemon 拆分列表;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回全部 Pokemon 以兼容管理端和实体选择器。
|
||||||
- `GET /api/pokemon/:id`
|
- `GET /api/pokemon/:id`
|
||||||
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;未传时返回全部栖息地以兼容管理端和实体选择器
|
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回全部栖息地以兼容管理端和实体选择器。
|
||||||
- `GET /api/habitats/:id`
|
- `GET /api/habitats/:id`
|
||||||
- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;未传时返回全部 Items 以兼容管理端和实体选择器
|
- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;默认返回所有物品,包括已配置 Ancient Artifact 分类的物品;传入 `ancientArtifactCategoryId` 时可额外筛选对应 Ancient Artifact 分类下的物品;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容管理端、实体选择器和排序。
|
||||||
- `GET /api/items/:id`
|
- `GET /api/items/:id`
|
||||||
- `GET /api/ancient-artifacts`:支持 `search`、`categoryId` 和 `tagIds` 筛选
|
- `GET /api/ancient-artifacts`:支持 `search`、`categoryId` 和 `tagIds` 筛选;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
|
||||||
- `GET /api/ancient-artifacts/:id`
|
- `GET /api/ancient-artifacts/:id`
|
||||||
- `GET /api/recipes`
|
- `GET /api/recipes`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
|
||||||
- `GET /api/recipes/:id`
|
- `GET /api/recipes/:id`
|
||||||
- `GET /api/dish`
|
- `GET /api/dish`
|
||||||
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort` 为 `latest`、`oldest` 或 `top-rated`。
|
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort` 为 `latest`、`oldest` 或 `top-rated`。
|
||||||
@@ -1152,7 +1189,7 @@ API 暴露边界:
|
|||||||
- `GET /api/admin/ai-moderation`
|
- `GET /api/admin/ai-moderation`
|
||||||
- `PUT /api/admin/ai-moderation`
|
- `PUT /api/admin/ai-moderation`
|
||||||
- `PUT /api/admin/system-wordings/:key`
|
- `PUT /api/admin/system-wordings/:key`
|
||||||
- Pokemon、物品、材料单、栖息地的列表排序需要对应实体的 `order` 权限。
|
- 物品、材料单、栖息地的列表排序需要对应实体的 `order` 权限;Pokemon 按内部 `id` 排序,不提供列表排序 API 或 Admin 手动排序入口。
|
||||||
|
|
||||||
## 开发与验证
|
## 开发与验证
|
||||||
|
|
||||||
@@ -1162,3 +1199,4 @@ API 暴露边界:
|
|||||||
- `pnpm typecheck`
|
- `pnpm typecheck`
|
||||||
- 不在 WSL 中运行测试作为完成任务的前置条件。
|
- 不在 WSL 中运行测试作为完成任务的前置条件。
|
||||||
- Docker 运行问题以用户提供的 `docker compose up --build` 输出为准进行后续修复。
|
- Docker 运行问题以用户提供的 `docker compose up --build` 输出为准进行后续修复。
|
||||||
|
- 本地热重载调试可运行 `pnpm docker:debug` 或 `docker compose -f docker-compose.debug.yml up --build`;生产 SSR runtime 验证仍使用 `pnpm docker:prod` 或 `docker compose up --build`。
|
||||||
|
|||||||
60
SSR_MIGRATION_TASKLIST.md
Normal file
60
SSR_MIGRATION_TASKLIST.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# SSR Migration Remaining Tasks
|
||||||
|
|
||||||
|
This temporary file tracks only the work still required before the Nuxt SSR migration can be considered complete.
|
||||||
|
|
||||||
|
Delete this file only after all items below are complete and `AGENTS.md` no longer needs the temporary SSR migration workflow.
|
||||||
|
|
||||||
|
## Remaining Work
|
||||||
|
|
||||||
|
- [ ] Run production Docker validation with `docker compose up --build`.
|
||||||
|
- [ ] Fix any Docker runtime errors from the production SSR container, frontend gateway, backend API, or SSR server-to-backend API connection.
|
||||||
|
- [ ] Verify anonymous SSR HTML for public routes contains meaningful public business content and route/detail metadata:
|
||||||
|
- `/`
|
||||||
|
- `/pokemon`
|
||||||
|
- `/event-pokemon`
|
||||||
|
- `/habitats`
|
||||||
|
- `/event-habitats`
|
||||||
|
- `/items`
|
||||||
|
- `/event-items`
|
||||||
|
- `/ancient-artifacts`
|
||||||
|
- `/recipes`
|
||||||
|
- `/checklist`
|
||||||
|
- `/dish`
|
||||||
|
- `/life`
|
||||||
|
- `/life/:id`
|
||||||
|
- `/profile/:id`
|
||||||
|
- `/project-updates`
|
||||||
|
- [ ] Verify generated HTML, Nuxt payloads, API responses used by SSR, metadata, and logs do not expose password hashes, session token hashes, verification/reset token hashes, private current-user data on public pages, role internals, permission internals, internal audit payloads, debug fields, stack traces, or implementation notes.
|
||||||
|
- [ ] Verify localized SSR reads and metadata follow the `DESIGN.md` fallback order: requested locale, default-language translation, then base field.
|
||||||
|
- [ ] Verify auth and permission route behavior with SSR enabled:
|
||||||
|
- anonymous users redirect from protected routes to login
|
||||||
|
- unverified users cannot access verified-only write flows
|
||||||
|
- users missing permissions cannot access permissioned routes
|
||||||
|
- current-user reads expose only fields allowed by `DESIGN.md`
|
||||||
|
- [ ] Verify hydrated logged-in flows still work:
|
||||||
|
- login
|
||||||
|
- logout
|
||||||
|
- Remember me
|
||||||
|
- `/profile`
|
||||||
|
- notifications
|
||||||
|
- route-backed create/edit modals
|
||||||
|
- uploads
|
||||||
|
- Life comments/reactions
|
||||||
|
- entity discussion comments
|
||||||
|
- admin access
|
||||||
|
- [ ] Verify browser-only UI behavior runs only on the client and remains stable after hydration:
|
||||||
|
- modal focus and body locking
|
||||||
|
- dropdown positioning
|
||||||
|
- scroll/resize listeners
|
||||||
|
- infinite-scroll sentinels
|
||||||
|
- clipboard actions
|
||||||
|
- `window.confirm` actions
|
||||||
|
- notification WebSocket
|
||||||
|
- upload file APIs
|
||||||
|
- [ ] Verify route-backed modal pages preserve underlying page context and avoid unwanted scroll jumps.
|
||||||
|
- [ ] Verify `robots.txt`, `sitemap.xml`, canonical URLs, `noindex` routes, Open Graph, Twitter card, and public detail metadata in the production runtime.
|
||||||
|
- [x] Remove legacy SPA-only compatibility paths once SSR behavior is stable.
|
||||||
|
- [x] Remove obsolete `VITE_*` fallback support after deployment has fully moved to documented `NUXT_*` variables.
|
||||||
|
- [x] Update `DESIGN.md` if final behavior differs from the current documented SSR deployment, auth, SEO, or environment-variable model.
|
||||||
|
- [ ] Update `AGENTS.md` to remove the temporary SSR migration workflow and the requirement to read this task list.
|
||||||
|
- [ ] Delete `SSR_MIGRATION_TASKLIST.md`.
|
||||||
@@ -26,8 +26,6 @@ CREATE TABLE IF NOT EXISTS entity_translations (
|
|||||||
'skills',
|
'skills',
|
||||||
'environments',
|
'environments',
|
||||||
'favorite-things',
|
'favorite-things',
|
||||||
'item-categories',
|
|
||||||
'item-usages',
|
|
||||||
'acquisition-methods',
|
'acquisition-methods',
|
||||||
'items',
|
'items',
|
||||||
'ancient-artifacts',
|
'ancient-artifacts',
|
||||||
@@ -51,41 +49,6 @@ CREATE TABLE IF NOT EXISTS entity_translations (
|
|||||||
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
|
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
|
||||||
ON entity_translations (entity_type, entity_id, field_name, locale);
|
ON entity_translations (entity_type, entity_id, field_name, locale);
|
||||||
|
|
||||||
ALTER TABLE entity_translations
|
|
||||||
DROP CONSTRAINT IF EXISTS entity_translations_entity_type_check;
|
|
||||||
|
|
||||||
ALTER TABLE entity_translations
|
|
||||||
ADD CONSTRAINT entity_translations_entity_type_check CHECK (
|
|
||||||
entity_type IN (
|
|
||||||
'pokemon',
|
|
||||||
'pokemon-types',
|
|
||||||
'skills',
|
|
||||||
'environments',
|
|
||||||
'favorite-things',
|
|
||||||
'item-categories',
|
|
||||||
'item-usages',
|
|
||||||
'acquisition-methods',
|
|
||||||
'items',
|
|
||||||
'ancient-artifacts',
|
|
||||||
'maps',
|
|
||||||
'habitats',
|
|
||||||
'daily-checklist-items',
|
|
||||||
'life-tags',
|
|
||||||
'game-versions',
|
|
||||||
'dish-categories',
|
|
||||||
'dish-flavors',
|
|
||||||
'dishes'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE entity_translations
|
|
||||||
DROP CONSTRAINT IF EXISTS entity_translations_field_name_check;
|
|
||||||
|
|
||||||
ALTER TABLE entity_translations
|
|
||||||
ADD CONSTRAINT entity_translations_field_name_check CHECK (
|
|
||||||
field_name IN ('name', 'title', 'details', 'genus', 'effect', 'mosslaxEffect')
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
email text NOT NULL UNIQUE,
|
email text NOT NULL UNIQUE,
|
||||||
@@ -201,22 +164,10 @@ CREATE TABLE IF NOT EXISTS ai_moderation_settings (
|
|||||||
CHECK (length(model) BETWEEN 1 AND 120)
|
CHECK (length(model) BETWEEN 1 AND 120)
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE ai_moderation_settings
|
|
||||||
ADD COLUMN IF NOT EXISTS api_format text NOT NULL DEFAULT 'gemini-generate-content' CHECK (api_format IN ('gemini-generate-content', 'openai-chat-completions')),
|
|
||||||
ADD COLUMN IF NOT EXISTS auth_mode text NOT NULL DEFAULT 'bearer-token' CHECK (auth_mode IN ('query-key', 'bearer-token'));
|
|
||||||
|
|
||||||
INSERT INTO ai_moderation_settings (id)
|
INSERT INTO ai_moderation_settings (id)
|
||||||
VALUES (true)
|
VALUES (true)
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
UPDATE ai_moderation_settings
|
|
||||||
SET api_format = 'gemini-generate-content',
|
|
||||||
auth_mode = 'bearer-token',
|
|
||||||
updated_at = now()
|
|
||||||
WHERE api_format = 'openai-chat-completions'
|
|
||||||
AND auth_mode = 'query-key'
|
|
||||||
AND endpoint ~* '/v1beta/?$';
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ai_moderation_cache (
|
CREATE TABLE IF NOT EXISTS ai_moderation_cache (
|
||||||
content_hash text NOT NULL,
|
content_hash text NOT NULL,
|
||||||
model text NOT NULL,
|
model text NOT NULL,
|
||||||
@@ -229,9 +180,6 @@ CREATE TABLE IF NOT EXISTS ai_moderation_cache (
|
|||||||
CHECK (length(model) BETWEEN 1 AND 120)
|
CHECK (length(model) BETWEEN 1 AND 120)
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE ai_moderation_cache
|
|
||||||
ADD COLUMN IF NOT EXISTS reason text;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS rate_limit_settings (
|
CREATE TABLE IF NOT EXISTS rate_limit_settings (
|
||||||
id boolean PRIMARY KEY DEFAULT true CHECK (id = true),
|
id boolean PRIMARY KEY DEFAULT true CHECK (id = true),
|
||||||
settings jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(settings) = 'object'),
|
settings jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(settings) = 'object'),
|
||||||
@@ -283,7 +231,6 @@ VALUES
|
|||||||
('pokemon.create', 'Create Pokemon', 'Create Pokemon records.', 'Pokemon', true),
|
('pokemon.create', 'Create Pokemon', 'Create Pokemon records.', 'Pokemon', true),
|
||||||
('pokemon.update', 'Update Pokemon', 'Edit Pokemon records.', 'Pokemon', true),
|
('pokemon.update', 'Update Pokemon', 'Edit Pokemon records.', 'Pokemon', true),
|
||||||
('pokemon.delete', 'Delete Pokemon', 'Delete Pokemon records.', 'Pokemon', true),
|
('pokemon.delete', 'Delete Pokemon', 'Delete Pokemon records.', 'Pokemon', true),
|
||||||
('pokemon.order', 'Order Pokemon', 'Reorder Pokemon records.', 'Pokemon', true),
|
|
||||||
('pokemon.fetch', 'Fetch Pokemon data', 'Fetch Pokemon data and sprite candidates.', 'Pokemon', true),
|
('pokemon.fetch', 'Fetch Pokemon data', 'Fetch Pokemon data and sprite candidates.', 'Pokemon', true),
|
||||||
('pokemon.upload', 'Upload Pokemon images', 'Upload Pokemon images.', 'Pokemon', true),
|
('pokemon.upload', 'Upload Pokemon images', 'Upload Pokemon images.', 'Pokemon', true),
|
||||||
('habitats.create', 'Create habitats', 'Create habitat records.', 'Habitats', true),
|
('habitats.create', 'Create habitats', 'Create habitat records.', 'Habitats', true),
|
||||||
@@ -327,6 +274,9 @@ VALUES
|
|||||||
('discussions.comments.like', 'Like discussion comments', 'Like and unlike entity discussion comments.', 'Discussions', true)
|
('discussions.comments.like', 'Like discussion comments', 'Like and unlike entity discussion comments.', 'Discussions', true)
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
DELETE FROM permissions
|
||||||
|
WHERE key = 'pokemon.order';
|
||||||
|
|
||||||
INSERT INTO roles (key, name, description, level, enabled, system_role)
|
INSERT INTO roles (key, name, description, level, enabled, system_role)
|
||||||
VALUES
|
VALUES
|
||||||
('owner', 'Owner', 'Highest-level system owner with all permissions.', 1000, true, true),
|
('owner', 'Owner', 'Highest-level system owner with all permissions.', 1000, true, true),
|
||||||
@@ -381,7 +331,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
|||||||
'pokemon.create',
|
'pokemon.create',
|
||||||
'pokemon.update',
|
'pokemon.update',
|
||||||
'pokemon.delete',
|
'pokemon.delete',
|
||||||
'pokemon.order',
|
|
||||||
'pokemon.fetch',
|
'pokemon.fetch',
|
||||||
'pokemon.upload',
|
'pokemon.upload',
|
||||||
'habitats.create',
|
'habitats.create',
|
||||||
@@ -463,7 +412,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
|||||||
'checklist.order',
|
'checklist.order',
|
||||||
'pokemon.create',
|
'pokemon.create',
|
||||||
'pokemon.update',
|
'pokemon.update',
|
||||||
'pokemon.order',
|
|
||||||
'pokemon.fetch',
|
'pokemon.fetch',
|
||||||
'pokemon.upload',
|
'pokemon.upload',
|
||||||
'habitats.create',
|
'habitats.create',
|
||||||
@@ -861,6 +809,7 @@ CREATE TABLE IF NOT EXISTS skills (
|
|||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
has_item_drop boolean NOT NULL DEFAULT false,
|
has_item_drop boolean NOT NULL DEFAULT false,
|
||||||
|
has_trading boolean NOT NULL DEFAULT false,
|
||||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
@@ -918,10 +867,6 @@ CREATE TABLE IF NOT EXISTS pokemon (
|
|||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE pokemon
|
|
||||||
ADD COLUMN IF NOT EXISTS data_id integer CHECK (data_id > 0),
|
|
||||||
ADD COLUMN IF NOT EXISTS data_identifier text NOT NULL DEFAULT '';
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_pokemon_types (
|
CREATE TABLE IF NOT EXISTS pokemon_pokemon_types (
|
||||||
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
||||||
type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE,
|
type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE,
|
||||||
@@ -942,26 +887,6 @@ CREATE TABLE IF NOT EXISTS pokemon_favorite_things (
|
|||||||
PRIMARY KEY (pokemon_id, favorite_thing_id)
|
PRIMARY KEY (pokemon_id, favorite_thing_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS item_categories (
|
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
||||||
name text NOT NULL UNIQUE,
|
|
||||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
|
||||||
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS item_usages (
|
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
||||||
name text NOT NULL UNIQUE,
|
|
||||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
|
||||||
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS acquisition_methods (
|
CREATE TABLE IF NOT EXISTS acquisition_methods (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
@@ -976,10 +901,10 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
details text NOT NULL DEFAULT '',
|
details text NOT NULL DEFAULT '',
|
||||||
|
base_price integer,
|
||||||
|
ancient_artifact_category_key text,
|
||||||
category_key text NOT NULL DEFAULT 'other',
|
category_key text NOT NULL DEFAULT 'other',
|
||||||
usage_key text,
|
usage_key text,
|
||||||
category_id integer REFERENCES item_categories(id),
|
|
||||||
usage_id integer REFERENCES item_usages(id),
|
|
||||||
dyeable boolean NOT NULL DEFAULT false,
|
dyeable boolean NOT NULL DEFAULT false,
|
||||||
dual_dyeable boolean NOT NULL DEFAULT false,
|
dual_dyeable boolean NOT NULL DEFAULT false,
|
||||||
pattern_editable boolean NOT NULL DEFAULT false,
|
pattern_editable boolean NOT NULL DEFAULT false,
|
||||||
@@ -1005,22 +930,13 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
'key-items',
|
'key-items',
|
||||||
'other'
|
'other'
|
||||||
)),
|
)),
|
||||||
|
CHECK (
|
||||||
|
ancient_artifact_category_key IS NULL
|
||||||
|
OR ancient_artifact_category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils')
|
||||||
|
),
|
||||||
CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'))
|
CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ancient_artifacts (
|
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
||||||
name text NOT NULL UNIQUE,
|
|
||||||
details text NOT NULL DEFAULT '',
|
|
||||||
category_key text NOT NULL CHECK (category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils')),
|
|
||||||
image_path text NOT NULL DEFAULT '',
|
|
||||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
|
||||||
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS recipes (
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
item_id integer NOT NULL UNIQUE REFERENCES items(id),
|
item_id integer NOT NULL UNIQUE REFERENCES items(id),
|
||||||
@@ -1049,12 +965,16 @@ CREATE TABLE IF NOT EXISTS item_favorite_things (
|
|||||||
PRIMARY KEY (item_id, favorite_thing_id)
|
PRIMARY KEY (item_id, favorite_thing_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ancient_artifact_favorite_things (
|
CREATE TABLE IF NOT EXISTS pokemon_trading_items (
|
||||||
ancient_artifact_id integer NOT NULL REFERENCES ancient_artifacts(id) ON DELETE CASCADE,
|
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
||||||
favorite_thing_id integer NOT NULL REFERENCES favorite_things(id),
|
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
PRIMARY KEY (ancient_artifact_id, favorite_thing_id)
|
preference text NOT NULL CHECK (preference IN ('like', 'neutral')),
|
||||||
|
PRIMARY KEY (pokemon_id, item_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS pokemon_trading_items_item_idx
|
||||||
|
ON pokemon_trading_items(item_id, preference, pokemon_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_skill_item_drops (
|
CREATE TABLE IF NOT EXISTS pokemon_skill_item_drops (
|
||||||
pokemon_id integer NOT NULL,
|
pokemon_id integer NOT NULL,
|
||||||
skill_id integer NOT NULL,
|
skill_id integer NOT NULL,
|
||||||
@@ -1084,58 +1004,6 @@ CREATE TABLE IF NOT EXISTS dish_categories (
|
|||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE dish_categories
|
|
||||||
ADD COLUMN IF NOT EXISTS main_material_item_id integer REFERENCES items(id);
|
|
||||||
|
|
||||||
ALTER TABLE dish_categories
|
|
||||||
ADD COLUMN IF NOT EXISTS total_material_quantity integer NOT NULL DEFAULT 2;
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF to_regclass('public.dishes') IS NOT NULL
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
AND table_name = 'dishes'
|
|
||||||
AND column_name = 'main_material_item_id'
|
|
||||||
)
|
|
||||||
THEN
|
|
||||||
EXECUTE '
|
|
||||||
UPDATE dish_categories dc
|
|
||||||
SET main_material_item_id = source.main_material_item_id
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT ON (category_id) category_id, main_material_item_id
|
|
||||||
FROM dishes
|
|
||||||
WHERE main_material_item_id IS NOT NULL
|
|
||||||
ORDER BY category_id, sort_order, id
|
|
||||||
) AS source
|
|
||||||
WHERE dc.id = source.category_id
|
|
||||||
AND dc.main_material_item_id IS NULL
|
|
||||||
';
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
UPDATE dish_categories
|
|
||||||
SET main_material_item_id = cookware_item_id
|
|
||||||
WHERE main_material_item_id IS NULL;
|
|
||||||
|
|
||||||
ALTER TABLE dish_categories
|
|
||||||
ALTER COLUMN main_material_item_id SET NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE dish_categories
|
|
||||||
ALTER COLUMN total_material_quantity SET DEFAULT 2;
|
|
||||||
|
|
||||||
UPDATE dish_categories
|
|
||||||
SET total_material_quantity = 2
|
|
||||||
WHERE total_material_quantity < 2;
|
|
||||||
|
|
||||||
ALTER TABLE dish_categories
|
|
||||||
DROP CONSTRAINT IF EXISTS dish_categories_total_material_quantity_check;
|
|
||||||
|
|
||||||
ALTER TABLE dish_categories
|
|
||||||
ADD CONSTRAINT dish_categories_total_material_quantity_check CHECK (total_material_quantity >= 2);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dish_flavors (
|
CREATE TABLE IF NOT EXISTS dish_flavors (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
@@ -1167,15 +1035,6 @@ CREATE TABLE IF NOT EXISTS dishes (
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE dishes
|
|
||||||
ADD COLUMN IF NOT EXISTS flavor_id integer REFERENCES dish_flavors(id);
|
|
||||||
|
|
||||||
ALTER TABLE dishes
|
|
||||||
ALTER COLUMN secondary_material_1_item_id DROP NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE dishes
|
|
||||||
DROP COLUMN IF EXISTS main_material_item_id;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS maps (
|
CREATE TABLE IF NOT EXISTS maps (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
@@ -1215,126 +1074,6 @@ CREATE TABLE IF NOT EXISTS habitat_pokemon (
|
|||||||
PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather)
|
PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather)
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE life_tags
|
|
||||||
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
|
|
||||||
|
|
||||||
ALTER TABLE items
|
|
||||||
ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '',
|
|
||||||
ADD COLUMN IF NOT EXISTS category_key text,
|
|
||||||
ADD COLUMN IF NOT EXISTS usage_key text;
|
|
||||||
|
|
||||||
ALTER TABLE ancient_artifacts
|
|
||||||
ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT '';
|
|
||||||
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM information_schema.columns
|
|
||||||
WHERE table_name = 'items'
|
|
||||||
AND column_name = 'category_id'
|
|
||||||
AND table_schema = current_schema()
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE items ALTER COLUMN category_id DROP NOT NULL;
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
UPDATE items i
|
|
||||||
SET category_key = CASE lower(trim(c.name))
|
|
||||||
WHEN 'furniture' THEN 'furniture'
|
|
||||||
WHEN 'misc' THEN 'misc'
|
|
||||||
WHEN 'outdoor' THEN 'outdoor'
|
|
||||||
WHEN 'utilities' THEN 'utilities'
|
|
||||||
WHEN 'buildings' THEN 'buildings'
|
|
||||||
WHEN 'blocks' THEN 'blocks'
|
|
||||||
WHEN 'kits' THEN 'kits'
|
|
||||||
WHEN 'nature' THEN 'nature'
|
|
||||||
WHEN 'food' THEN 'food'
|
|
||||||
WHEN 'materials' THEN 'materials'
|
|
||||||
WHEN 'key items' THEN 'key-items'
|
|
||||||
WHEN 'key-items' THEN 'key-items'
|
|
||||||
WHEN 'other' THEN 'other'
|
|
||||||
ELSE 'other'
|
|
||||||
END
|
|
||||||
FROM item_categories c
|
|
||||||
WHERE i.category_id = c.id
|
|
||||||
AND (i.category_key IS NULL OR i.category_key = '');
|
|
||||||
|
|
||||||
UPDATE items i
|
|
||||||
SET usage_key = CASE lower(trim(u.name))
|
|
||||||
WHEN 'decoration' THEN 'decoration'
|
|
||||||
WHEN 'relaxation' THEN 'relaxation'
|
|
||||||
WHEN 'toy' THEN 'toy'
|
|
||||||
WHEN 'road' THEN 'road'
|
|
||||||
ELSE NULL
|
|
||||||
END
|
|
||||||
FROM item_usages u
|
|
||||||
WHERE i.usage_id = u.id
|
|
||||||
AND i.usage_key IS NULL;
|
|
||||||
|
|
||||||
UPDATE items
|
|
||||||
SET category_key = 'other'
|
|
||||||
WHERE category_key IS NULL
|
|
||||||
OR category_key NOT IN (
|
|
||||||
'furniture',
|
|
||||||
'misc',
|
|
||||||
'outdoor',
|
|
||||||
'utilities',
|
|
||||||
'buildings',
|
|
||||||
'blocks',
|
|
||||||
'kits',
|
|
||||||
'nature',
|
|
||||||
'food',
|
|
||||||
'materials',
|
|
||||||
'key-items',
|
|
||||||
'other'
|
|
||||||
);
|
|
||||||
|
|
||||||
UPDATE items
|
|
||||||
SET usage_key = NULL
|
|
||||||
WHERE usage_key IS NOT NULL
|
|
||||||
AND usage_key NOT IN ('decoration', 'relaxation', 'toy', 'road');
|
|
||||||
|
|
||||||
ALTER TABLE items
|
|
||||||
ALTER COLUMN category_key SET NOT NULL,
|
|
||||||
ALTER COLUMN category_key SET DEFAULT 'other';
|
|
||||||
|
|
||||||
ALTER TABLE items
|
|
||||||
DROP CONSTRAINT IF EXISTS items_display_id_positive,
|
|
||||||
DROP CONSTRAINT IF EXISTS items_category_key_check,
|
|
||||||
DROP CONSTRAINT IF EXISTS items_usage_key_check;
|
|
||||||
|
|
||||||
ALTER TABLE items
|
|
||||||
ADD CONSTRAINT items_category_key_check CHECK (category_key IN (
|
|
||||||
'furniture',
|
|
||||||
'misc',
|
|
||||||
'outdoor',
|
|
||||||
'utilities',
|
|
||||||
'buildings',
|
|
||||||
'blocks',
|
|
||||||
'kits',
|
|
||||||
'nature',
|
|
||||||
'food',
|
|
||||||
'materials',
|
|
||||||
'key-items',
|
|
||||||
'other'
|
|
||||||
)),
|
|
||||||
ADD CONSTRAINT items_usage_key_check CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'));
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS items_display_event_item_key;
|
|
||||||
DROP INDEX IF EXISTS items_display_order_idx;
|
|
||||||
DROP INDEX IF EXISTS ancient_artifacts_display_order_idx;
|
|
||||||
|
|
||||||
ALTER TABLE ancient_artifacts
|
|
||||||
DROP CONSTRAINT IF EXISTS ancient_artifacts_display_id_key,
|
|
||||||
DROP CONSTRAINT IF EXISTS ancient_artifacts_display_id_check;
|
|
||||||
|
|
||||||
ALTER TABLE items
|
|
||||||
DROP COLUMN IF EXISTS display_id;
|
|
||||||
|
|
||||||
ALTER TABLE ancient_artifacts
|
|
||||||
DROP COLUMN IF EXISTS display_id;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
|
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
|
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
|
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
|
||||||
@@ -1343,11 +1082,8 @@ CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item);
|
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item);
|
||||||
CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id);
|
CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS life_tags_single_default_idx ON life_tags(is_default) WHERE is_default = true;
|
CREATE UNIQUE INDEX IF NOT EXISTS life_tags_single_default_idx ON life_tags(is_default) WHERE is_default = true;
|
||||||
CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sort_order, id);
|
|
||||||
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);
|
|
||||||
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
|
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id);
|
CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS ancient_artifacts_sort_order_idx ON ancient_artifacts(sort_order, id);
|
|
||||||
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
|
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS dish_categories_sort_order_idx ON dish_categories(sort_order, id);
|
CREATE INDEX IF NOT EXISTS dish_categories_sort_order_idx ON dish_categories(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS dish_flavors_sort_order_idx ON dish_flavors(sort_order, id);
|
CREATE INDEX IF NOT EXISTS dish_flavors_sort_order_idx ON dish_flavors(sort_order, id);
|
||||||
@@ -1387,14 +1123,6 @@ CREATE TABLE IF NOT EXISTS entity_image_uploads (
|
|||||||
CHECK (path !~ '(^/|\\.\\.)')
|
CHECK (path !~ '(^/|\\.\\.)')
|
||||||
);
|
);
|
||||||
|
|
||||||
ALTER TABLE entity_image_uploads
|
|
||||||
DROP CONSTRAINT IF EXISTS entity_image_uploads_entity_type_check;
|
|
||||||
|
|
||||||
ALTER TABLE entity_image_uploads
|
|
||||||
ADD CONSTRAINT entity_image_uploads_entity_type_check CHECK (
|
|
||||||
entity_type IN ('pokemon', 'items', 'habitats', 'ancient-artifacts')
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS entity_image_uploads_entity_idx
|
CREATE INDEX IF NOT EXISTS entity_image_uploads_entity_idx
|
||||||
ON entity_image_uploads(entity_type, entity_id, created_at DESC, id DESC);
|
ON entity_image_uploads(entity_type, entity_id, created_at DESC, id DESC);
|
||||||
|
|
||||||
@@ -1443,14 +1171,6 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_comment_idx
|
|||||||
CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_user_idx
|
CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_user_idx
|
||||||
ON entity_discussion_comment_likes(user_id, created_at DESC, comment_id DESC);
|
ON entity_discussion_comment_likes(user_id, created_at DESC, comment_id DESC);
|
||||||
|
|
||||||
ALTER TABLE entity_discussion_comments
|
|
||||||
DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check;
|
|
||||||
|
|
||||||
ALTER TABLE entity_discussion_comments
|
|
||||||
ADD CONSTRAINT entity_discussion_comments_entity_type_check CHECK (
|
|
||||||
entity_type IN ('pokemon', 'items', 'recipes', 'habitats', 'ancient-artifacts')
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS notifications (
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
recipient_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
recipient_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -1506,9 +1226,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS notifications_life_post_reaction_unique_idx
|
|||||||
ON notifications(recipient_user_id, actor_user_id, life_post_id)
|
ON notifications(recipient_user_id, actor_user_id, life_post_id)
|
||||||
WHERE type = 'life_post_reaction' AND actor_user_id IS NOT NULL AND life_post_id IS NOT NULL;
|
WHERE type = 'life_post_reaction' AND actor_user_id IS NOT NULL AND life_post_id IS NOT NULL;
|
||||||
|
|
||||||
ALTER TABLE notifications
|
|
||||||
ADD COLUMN IF NOT EXISTS profile_user_id integer REFERENCES users(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS notifications_user_follow_unique_idx
|
CREATE UNIQUE INDEX IF NOT EXISTS notifications_user_follow_unique_idx
|
||||||
ON notifications(recipient_user_id, actor_user_id, profile_user_id)
|
ON notifications(recipient_user_id, actor_user_id, profile_user_id)
|
||||||
WHERE type = 'user_follow' AND actor_user_id IS NOT NULL AND profile_user_id IS NOT NULL;
|
WHERE type = 'user_follow' AND actor_user_id IS NOT NULL AND profile_user_id IS NOT NULL;
|
||||||
@@ -1525,66 +1242,6 @@ CREATE TABLE IF NOT EXISTS notification_ws_tickets (
|
|||||||
CREATE INDEX IF NOT EXISTS notification_ws_tickets_user_idx
|
CREATE INDEX IF NOT EXISTS notification_ws_tickets_user_idx
|
||||||
ON notification_ws_tickets(user_id, expires_at DESC);
|
ON notification_ws_tickets(user_id, expires_at DESC);
|
||||||
|
|
||||||
ALTER TABLE notifications
|
|
||||||
ADD COLUMN IF NOT EXISTS moderation_reason text;
|
|
||||||
|
|
||||||
ALTER TABLE notifications
|
|
||||||
ADD COLUMN IF NOT EXISTS profile_user_id integer REFERENCES users(id) ON DELETE CASCADE;
|
|
||||||
|
|
||||||
ALTER TABLE notifications
|
|
||||||
DROP CONSTRAINT IF EXISTS notifications_type_check;
|
|
||||||
|
|
||||||
ALTER TABLE notifications
|
|
||||||
ADD CONSTRAINT notifications_type_check CHECK (
|
|
||||||
type IN (
|
|
||||||
'life_post_comment',
|
|
||||||
'life_comment_reply',
|
|
||||||
'discussion_comment_reply',
|
|
||||||
'life_post_reaction',
|
|
||||||
'user_follow',
|
|
||||||
'moderation_result'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE life_tags
|
|
||||||
ADD COLUMN IF NOT EXISTS is_rateable boolean NOT NULL DEFAULT false;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS game_versions (
|
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
||||||
name text NOT NULL UNIQUE,
|
|
||||||
change_log text NOT NULL DEFAULT '',
|
|
||||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
|
||||||
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS game_versions_sort_order_idx
|
|
||||||
ON game_versions(sort_order, id);
|
|
||||||
|
|
||||||
ALTER TABLE life_posts
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
|
||||||
ADD COLUMN IF NOT EXISTS category_id integer REFERENCES life_tags(id) ON DELETE RESTRICT,
|
|
||||||
ADD COLUMN IF NOT EXISTS game_version_id integer REFERENCES game_versions(id) ON DELETE SET NULL,
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_reason text,
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text,
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz,
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now();
|
|
||||||
|
|
||||||
UPDATE life_posts lp
|
|
||||||
SET category_id = selected.tag_id
|
|
||||||
FROM (
|
|
||||||
SELECT DISTINCT ON (lpt.post_id) lpt.post_id, lpt.tag_id
|
|
||||||
FROM life_post_tags lpt
|
|
||||||
JOIN life_tags lt ON lt.id = lpt.tag_id
|
|
||||||
ORDER BY lpt.post_id, lt.sort_order, lt.id
|
|
||||||
) selected
|
|
||||||
WHERE lp.id = selected.post_id
|
|
||||||
AND lp.category_id IS NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS life_posts_category_idx
|
CREATE INDEX IF NOT EXISTS life_posts_category_idx
|
||||||
ON life_posts(category_id, created_at DESC, id DESC)
|
ON life_posts(category_id, created_at DESC, id DESC)
|
||||||
WHERE deleted_at IS NULL;
|
WHERE deleted_at IS NULL;
|
||||||
@@ -1593,39 +1250,6 @@ CREATE INDEX IF NOT EXISTS life_posts_game_version_idx
|
|||||||
ON life_posts(game_version_id, created_at DESC, id DESC)
|
ON life_posts(game_version_id, created_at DESC, id DESC)
|
||||||
WHERE deleted_at IS NULL;
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS life_post_ratings (
|
|
||||||
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
|
|
||||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
rating integer NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
PRIMARY KEY (post_id, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS life_post_ratings_post_idx
|
|
||||||
ON life_post_ratings(post_id, rating);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS life_post_ratings_user_idx
|
|
||||||
ON life_post_ratings(user_id, updated_at DESC, post_id DESC);
|
|
||||||
|
|
||||||
ALTER TABLE life_post_comments
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_reason text,
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text,
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz,
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now();
|
|
||||||
|
|
||||||
ALTER TABLE entity_discussion_comments
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_reason text,
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text,
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz,
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
|
|
||||||
ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now();
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS life_posts_ai_moderation_status_idx
|
CREATE INDEX IF NOT EXISTS life_posts_ai_moderation_status_idx
|
||||||
ON life_posts(ai_moderation_status, ai_moderation_updated_at, id)
|
ON life_posts(ai_moderation_status, ai_moderation_updated_at, id)
|
||||||
WHERE deleted_at IS NULL;
|
WHERE deleted_at IS NULL;
|
||||||
@@ -1649,3 +1273,6 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_status_idx
|
|||||||
CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_language_idx
|
CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_language_idx
|
||||||
ON entity_discussion_comments(entity_type, entity_id, ai_moderation_language_code, created_at, id)
|
ON entity_discussion_comments(entity_type, entity_id, ai_moderation_language_code, created_at, id)
|
||||||
WHERE deleted_at IS NULL;
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE skills
|
||||||
|
ADD COLUMN IF NOT EXISTS has_trading boolean NOT NULL DEFAULT false;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -83,6 +83,8 @@ import {
|
|||||||
getRecipe,
|
getRecipe,
|
||||||
globalSearch,
|
globalSearch,
|
||||||
importAdminData,
|
importAdminData,
|
||||||
|
importAdminHabitatsCsv,
|
||||||
|
importAdminItemsCsv,
|
||||||
isConfigType,
|
isConfigType,
|
||||||
listAncientArtifacts,
|
listAncientArtifacts,
|
||||||
listEntityDiscussionComments,
|
listEntityDiscussionComments,
|
||||||
@@ -109,7 +111,6 @@ import {
|
|||||||
reorderHabitats,
|
reorderHabitats,
|
||||||
reorderItems,
|
reorderItems,
|
||||||
reorderLanguages,
|
reorderLanguages,
|
||||||
reorderPokemon,
|
|
||||||
reorderRecipes,
|
reorderRecipes,
|
||||||
retryEntityDiscussionCommentModeration,
|
retryEntityDiscussionCommentModeration,
|
||||||
retryLifeCommentModeration,
|
retryLifeCommentModeration,
|
||||||
@@ -164,6 +165,9 @@ const app = Fastify({
|
|||||||
logger: true,
|
logger: true,
|
||||||
trustProxy: process.env.TRUST_PROXY === 'true'
|
trustProxy: process.env.TRUST_PROXY === 'true'
|
||||||
});
|
});
|
||||||
|
const sessionCookieName = 'pokopia_session';
|
||||||
|
const rememberedSessionDays = 30;
|
||||||
|
const sessionOnlySessionDays = 1;
|
||||||
|
|
||||||
function configuredCorsOrigin(): true | string | string[] {
|
function configuredCorsOrigin(): true | string | string[] {
|
||||||
const rawOrigin = process.env.FRONTEND_ORIGIN?.trim();
|
const rawOrigin = process.env.FRONTEND_ORIGIN?.trim();
|
||||||
@@ -180,8 +184,9 @@ function configuredCorsOrigin(): true | string | string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'],
|
allowedHeaders: ['Content-Type', 'X-Locale'],
|
||||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
origin: configuredCorsOrigin()
|
origin: configuredCorsOrigin()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,9 +246,52 @@ app.get('/api/search', async (request) =>
|
|||||||
globalSearch(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
globalSearch(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||||
);
|
);
|
||||||
|
|
||||||
function getBearerToken(authorization: string | undefined): string | null {
|
function getCookieValue(cookieHeader: string | undefined, name: string): string | null {
|
||||||
const [scheme, token] = authorization?.split(' ') ?? [];
|
if (!cookieHeader) {
|
||||||
return scheme === 'Bearer' && token ? token : null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cookiePart of cookieHeader.split(';')) {
|
||||||
|
const [rawName, ...rawValue] = cookiePart.trim().split('=');
|
||||||
|
if (rawName === name) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(rawValue.join('='));
|
||||||
|
} catch {
|
||||||
|
return rawValue.join('=');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionToken(request: FastifyRequest): string | null {
|
||||||
|
return getCookieValue(request.headers.cookie, sessionCookieName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionCookieSecure(): boolean {
|
||||||
|
const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? '';
|
||||||
|
return origin.split(',').some((value) => value.trim().startsWith('https://'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionCookie(value: string, maxAgeSeconds: number): string {
|
||||||
|
return [
|
||||||
|
`${sessionCookieName}=${encodeURIComponent(value)}`,
|
||||||
|
'Path=/',
|
||||||
|
'HttpOnly',
|
||||||
|
'SameSite=Lax',
|
||||||
|
`Max-Age=${maxAgeSeconds}`,
|
||||||
|
...(sessionCookieSecure() ? ['Secure'] : [])
|
||||||
|
].join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionCookie(reply: FastifyReply, token: string, rememberMe: boolean): void {
|
||||||
|
const sessionDays = rememberMe ? rememberedSessionDays : sessionOnlySessionDays;
|
||||||
|
reply.header('Set-Cookie', sessionCookie(token, sessionDays * 24 * 60 * 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionCookie(reply: FastifyReply): void {
|
||||||
|
reply.header('Set-Cookie', `${sessionCookie('', 0)}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestLocale(request: FastifyRequest): string {
|
function requestLocale(request: FastifyRequest): string {
|
||||||
@@ -866,7 +914,7 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getSessionToken(request);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
const locale = requestLocale(request);
|
const locale = requestLocale(request);
|
||||||
|
|
||||||
@@ -948,7 +996,7 @@ async function requireAnyPermissionWithRateLimits(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
|
async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getSessionToken(request);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -981,7 +1029,10 @@ app.post('/api/auth/login', async (request, reply) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return loginUser(request.body as Record<string, unknown>, requestLocale(request));
|
const payload = request.body as Record<string, unknown>;
|
||||||
|
const response = await loginUser(payload, requestLocale(request));
|
||||||
|
setSessionCookie(reply, response.token, payload.rememberMe === true);
|
||||||
|
return { user: response.user };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/auth/request-password-reset', async (request, reply) => {
|
app.post('/api/auth/request-password-reset', async (request, reply) => {
|
||||||
@@ -1005,7 +1056,7 @@ app.get('/api/auth/me', async (request, reply) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getSessionToken(request);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -1020,7 +1071,7 @@ app.patch('/api/auth/me', async (request, reply) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getSessionToken(request);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -1040,7 +1091,7 @@ app.patch('/api/auth/me/password', async (request, reply) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getSessionToken(request);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|
||||||
if (!user || !token) {
|
if (!user || !token) {
|
||||||
@@ -1060,7 +1111,7 @@ app.get('/api/auth/referral', async (request, reply) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getSessionToken(request);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -1097,11 +1148,12 @@ app.post('/api/notifications/:id/read', async (request, reply) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/auth/logout', async (request, reply) => {
|
app.post('/api/auth/logout', async (request, reply) => {
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getSessionToken(request);
|
||||||
if (token) {
|
if (token) {
|
||||||
await logoutSession(token);
|
await logoutSession(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearSessionCookie(reply);
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1196,7 +1248,9 @@ app.get('/api/project-updates', async (request) =>
|
|||||||
getProjectUpdates(request.query as Record<string, string | string[] | undefined>)
|
getProjectUpdates(request.query as Record<string, string | string[] | undefined>)
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
app.get('/api/daily-checklist', async (request) =>
|
||||||
|
listDailyChecklistItems(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/api/users/:id/profile', async (request, reply) => {
|
app.get('/api/users/:id/profile', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
@@ -2037,11 +2091,6 @@ app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
|
|||||||
return deleted ? reply.code(204).send() : notFound(reply, request);
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/admin/pokemon/order', async (request, reply) => {
|
|
||||||
const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.order', 'wikiWrite');
|
|
||||||
return user ? reorderPokemon(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
app.put('/api/admin/items/order', async (request, reply) => {
|
app.put('/api/admin/items/order', async (request, reply) => {
|
||||||
const user = await requirePermissionWithRateLimits(request, reply, 'items.order', 'wikiWrite');
|
const user = await requirePermissionWithRateLimits(request, reply, 'items.order', 'wikiWrite');
|
||||||
return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||||
@@ -2151,6 +2200,16 @@ app.post('/api/admin/data-tools/import', async (request, reply) => {
|
|||||||
return user ? importAdminData(request.body as Record<string, unknown>) : undefined;
|
return user ? importAdminData(request.body as Record<string, unknown>) : undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/data-tools/import-items-csv', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
|
||||||
|
return user ? importAdminItemsCsv(request.body as Record<string, unknown>, user.id) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/data-tools/import-habitats-csv', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
|
||||||
|
return user ? importAdminHabitatsCsv(request.body as Record<string, unknown>, user.id) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/admin/data-tools/wipe', async (request, reply) => {
|
app.post('/api/admin/data-tools/wipe', async (request, reply) => {
|
||||||
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
|
||||||
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
|
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
|
||||||
|
|||||||
108
docker-compose.debug.yml
Normal file
108
docker-compose.debug.yml
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: pokopia
|
||||||
|
POSTGRES_USER: pokopia
|
||||||
|
POSTGRES_PASSWORD: pokopia
|
||||||
|
volumes:
|
||||||
|
- postgres18_data:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U pokopia -d pokopia"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
deps:
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
PNPM_HOME: /pnpm
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- root_node_modules:/app/node_modules
|
||||||
|
- backend_node_modules:/app/backend/node_modules
|
||||||
|
- frontend_node_modules:/app/frontend/node_modules
|
||||||
|
- pnpm_store:/pnpm/store
|
||||||
|
command: >
|
||||||
|
sh -lc "corepack enable &&
|
||||||
|
corepack prepare pnpm@10.33.2 --activate &&
|
||||||
|
pnpm config set store-dir /pnpm/store &&
|
||||||
|
pnpm install --frozen-lockfile"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
PNPM_HOME: /pnpm
|
||||||
|
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
|
||||||
|
BACKEND_PORT: 3001
|
||||||
|
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||||
|
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:20015}
|
||||||
|
APP_ORIGIN: ${APP_ORIGIN:-http://localhost:20015}
|
||||||
|
UPLOAD_DIR: /app/uploads
|
||||||
|
BACKEND_PUBLIC_ORIGIN: ${BACKEND_PUBLIC_ORIGIN:-http://localhost:20016}
|
||||||
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
|
EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki <onboarding@resend.dev>}"
|
||||||
|
RESEND_DAILY_QUOTA_LIMIT: ${RESEND_DAILY_QUOTA_LIMIT:-100}
|
||||||
|
RESEND_MONTHLY_QUOTA_LIMIT: ${RESEND_MONTHLY_QUOTA_LIMIT:-3000}
|
||||||
|
RESEND_QUOTA_RESERVE: ${RESEND_QUOTA_RESERVE:-5}
|
||||||
|
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES: ${RESEND_QUOTA_SNAPSHOT_TTL_MINUTES:-10}
|
||||||
|
AI_MODERATION_API_KEY: ${AI_MODERATION_API_KEY:-}
|
||||||
|
ports:
|
||||||
|
- "20016:3001"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- root_node_modules:/app/node_modules
|
||||||
|
- backend_node_modules:/app/backend/node_modules
|
||||||
|
- frontend_node_modules:/app/frontend/node_modules
|
||||||
|
- pnpm_store:/pnpm/store
|
||||||
|
- backend_uploads:/app/uploads
|
||||||
|
command: >
|
||||||
|
sh -lc "corepack enable &&
|
||||||
|
corepack prepare pnpm@10.33.2 --activate &&
|
||||||
|
pnpm --filter @pokopia/backend dev"
|
||||||
|
depends_on:
|
||||||
|
deps:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
PNPM_HOME: /pnpm
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 20015
|
||||||
|
CHOKIDAR_USEPOLLING: "true"
|
||||||
|
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
|
||||||
|
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
|
||||||
|
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-http://localhost:20015}
|
||||||
|
ports:
|
||||||
|
- "20015:20015"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- root_node_modules:/app/node_modules
|
||||||
|
- backend_node_modules:/app/backend/node_modules
|
||||||
|
- frontend_node_modules:/app/frontend/node_modules
|
||||||
|
- pnpm_store:/pnpm/store
|
||||||
|
command: >
|
||||||
|
sh -lc "corepack enable &&
|
||||||
|
corepack prepare pnpm@10.33.2 --activate &&
|
||||||
|
pnpm --filter @pokopia/frontend dev"
|
||||||
|
depends_on:
|
||||||
|
deps:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
backend:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres18_data:
|
||||||
|
backend_uploads:
|
||||||
|
root_node_modules:
|
||||||
|
backend_node_modules:
|
||||||
|
frontend_node_modules:
|
||||||
|
pnpm_store:
|
||||||
@@ -40,10 +40,14 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: frontend/Dockerfile
|
dockerfile: frontend/Dockerfile
|
||||||
args:
|
args:
|
||||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:20016}
|
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
|
||||||
VITE_SITE_URL: ${VITE_SITE_URL:-https://pokopiawiki.tootaio.com}
|
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
|
||||||
|
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://pokopiawiki.tootaio.com}
|
||||||
environment:
|
environment:
|
||||||
PORT: 20015
|
PORT: 20015
|
||||||
|
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
|
||||||
|
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
|
||||||
|
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://pokopiawiki.tootaio.com}
|
||||||
expose:
|
expose:
|
||||||
- "20015"
|
- "20015"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -8,21 +8,23 @@ RUN corepack enable && corepack prepare pnpm@10.33.2 --activate && pnpm install
|
|||||||
COPY frontend ./frontend
|
COPY frontend ./frontend
|
||||||
COPY system-wordings.ts ./system-wordings.ts
|
COPY system-wordings.ts ./system-wordings.ts
|
||||||
|
|
||||||
ARG VITE_API_BASE_URL=http://localhost:3001
|
ARG NUXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
||||||
ARG VITE_SITE_URL=https://pokopiawiki.tootaio.com
|
ARG NUXT_SERVER_API_BASE_URL=http://localhost:3001
|
||||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
ARG NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
|
||||||
ENV VITE_SITE_URL=$VITE_SITE_URL
|
ENV NUXT_PUBLIC_API_BASE_URL=$NUXT_PUBLIC_API_BASE_URL
|
||||||
|
ENV NUXT_SERVER_API_BASE_URL=$NUXT_SERVER_API_BASE_URL
|
||||||
|
ENV NUXT_PUBLIC_SITE_URL=$NUXT_PUBLIC_SITE_URL
|
||||||
RUN pnpm --filter @pokopia/frontend build
|
RUN pnpm --filter @pokopia/frontend build
|
||||||
|
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=20015
|
ENV PORT=20015
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/frontend/dist ./dist
|
COPY --from=build /app/frontend/.output ./.output
|
||||||
COPY frontend/static-server.mjs ./static-server.mjs
|
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 20015
|
EXPOSE 20015
|
||||||
CMD ["node", "static-server.mjs"]
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from 'vue-router';
|
import AppShell from './src/components/AppShell.vue';
|
||||||
import AppShell from './components/AppShell.vue';
|
|
||||||
import {
|
import {
|
||||||
iconAction,
|
iconAction,
|
||||||
iconAdmin,
|
iconAdmin,
|
||||||
@@ -20,12 +19,11 @@ import {
|
|||||||
iconPokemon,
|
iconPokemon,
|
||||||
iconRecipe,
|
iconRecipe,
|
||||||
type AppIcon
|
type AppIcon
|
||||||
} from './icons';
|
} from './src/icons';
|
||||||
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './i18n';
|
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
|
||||||
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
|
import { api, notifyAuthChange, onAuthChange, type AuthUser, type Language } from './src/services/api';
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const languages = ref<Language[]>([
|
const languages = ref<Language[]>([
|
||||||
@@ -114,17 +112,11 @@ const navItems = computed<NavItem[]>(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function loadCurrentUser() {
|
async function loadCurrentUser() {
|
||||||
if (!getAuthToken()) {
|
|
||||||
currentUser.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.me();
|
const response = await api.me();
|
||||||
currentUser.value = response.user;
|
currentUser.value = response.user;
|
||||||
} catch {
|
} catch {
|
||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
setAuthToken(null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +128,7 @@ async function logout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
setAuthToken(null);
|
notifyAuthChange();
|
||||||
await router.push('/');
|
await router.push('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +157,7 @@ async function updateLocale(value: string) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadLanguages();
|
void loadLanguages();
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
removeAuthListener = onAuthTokenChange(() => {
|
removeAuthListener = onAuthChange(() => {
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
});
|
});
|
||||||
removeLocaleListener = onLocaleChange(() => {
|
removeLocaleListener = onLocaleChange(() => {
|
||||||
@@ -188,6 +180,6 @@ onUnmounted(() => {
|
|||||||
@logout="logout"
|
@logout="logout"
|
||||||
@update:locale="updateLocale"
|
@update:locale="updateLocale"
|
||||||
>
|
>
|
||||||
<RouterView :key="locale" />
|
<NuxtPage :key="locale" />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
9
frontend/app/router.options.ts
Normal file
9
frontend/app/router.options.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { RouterConfig } from '@nuxt/schema';
|
||||||
|
|
||||||
|
export default <RouterConfig>{
|
||||||
|
scrollBehavior(to, from, savedPosition) {
|
||||||
|
if (savedPosition) return savedPosition;
|
||||||
|
if (to.meta.editorModal === true || from.meta.editorModal === true) return false;
|
||||||
|
return { top: 0 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
|
||||||
/>
|
|
||||||
<meta name="robots" content="index, follow" />
|
|
||||||
<meta name="theme-color" content="#6ccf32" />
|
|
||||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
|
||||||
<link rel="canonical" href="%POKOPIA_SITE_URL%/pokemon" />
|
|
||||||
<meta property="og:site_name" content="Pokopia Wiki" />
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
|
|
||||||
<meta
|
|
||||||
property="og:description"
|
|
||||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
|
||||||
/>
|
|
||||||
<meta property="og:url" content="%POKOPIA_SITE_URL%/pokemon" />
|
|
||||||
<meta property="og:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
|
|
||||||
<meta property="og:locale" content="en_US" />
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
|
|
||||||
<meta
|
|
||||||
name="twitter:description"
|
|
||||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
|
||||||
/>
|
|
||||||
<meta name="twitter:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const UMAMI_SCRIPT_JS = "https://umami.tootaio.com/script.js";
|
|
||||||
const UMAMI_ID = "6c00a2e5-dc72-41f3-9d5d-aac93aaaf1cb";
|
|
||||||
|
|
||||||
var script = document.createElement("script");
|
|
||||||
script.async = true;
|
|
||||||
script.src = UMAMI_SCRIPT_JS;
|
|
||||||
script.setAttribute("data-website-id", UMAMI_ID);
|
|
||||||
document.head.appendChild(script);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<title>Pokopia Wiki - Pokemon Pokopia Guide</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
35
frontend/middleware/auth.global.ts
Normal file
35
frontend/middleware/auth.global.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { api } from '../src/services/api';
|
||||||
|
|
||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
const requiredPermissions = to.matched
|
||||||
|
.map((record) => record.meta.requiredPermission)
|
||||||
|
.filter((permission): permission is string => typeof permission === 'string');
|
||||||
|
const requiredAnyPermissions = to.matched.flatMap((record) =>
|
||||||
|
Array.isArray(record.meta.requiredAnyPermission)
|
||||||
|
? record.meta.requiredAnyPermission.filter((permission): permission is string => typeof permission === 'string')
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true) || requiredPermissions.length > 0 || requiredAnyPermissions.length > 0;
|
||||||
|
const requiresAuth = requiresVerified || to.matched.some((record) => record.meta.requiresAuth === true);
|
||||||
|
|
||||||
|
if (!requiresAuth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.me(import.meta.server ? { headers: useRequestHeaders(['cookie']) } : undefined);
|
||||||
|
if (requiresVerified && !response.user.emailVerified) {
|
||||||
|
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionSet = new Set(response.user.permissions);
|
||||||
|
if (requiredPermissions.some((permission) => !permissionSet.has(permission))) {
|
||||||
|
return navigateTo('/pokemon');
|
||||||
|
}
|
||||||
|
if (requiredAnyPermissions.length && !requiredAnyPermissions.some((permission) => permissionSet.has(permission))) {
|
||||||
|
return navigateTo('/pokemon');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
|
||||||
|
}
|
||||||
|
});
|
||||||
50
frontend/nuxt.config.ts
Normal file
50
frontend/nuxt.config.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||||
|
|
||||||
|
function normalizeSiteUrl(value: string | undefined): string {
|
||||||
|
return (value?.trim() || fallbackSiteUrl).replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
ssr: true,
|
||||||
|
devtools: { enabled: false },
|
||||||
|
css: ['~/src/styles/main.css'],
|
||||||
|
compatibilityDate: '2026-05-06',
|
||||||
|
runtimeConfig: {
|
||||||
|
serverApiBaseUrl:
|
||||||
|
process.env.NUXT_SERVER_API_BASE_URL ??
|
||||||
|
process.env.NUXT_PUBLIC_API_BASE_URL ??
|
||||||
|
'http://localhost:3001',
|
||||||
|
public: {
|
||||||
|
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3001',
|
||||||
|
siteUrl: normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
htmlAttrs: {
|
||||||
|
lang: 'en'
|
||||||
|
},
|
||||||
|
title: 'Pokopia Wiki - Pokemon Pokopia Guide',
|
||||||
|
meta: [
|
||||||
|
{ charset: 'utf-8' },
|
||||||
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
|
||||||
|
{ name: 'theme-color', content: '#6ccf32' }
|
||||||
|
],
|
||||||
|
link: [
|
||||||
|
{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' }
|
||||||
|
],
|
||||||
|
script: [
|
||||||
|
{
|
||||||
|
async: true,
|
||||||
|
src: 'https://umami.tootaio.com/script.js',
|
||||||
|
'data-website-id': '6c00a2e5-dc72-41f3-9d5d-aac93aaaf1cb'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nitro: {
|
||||||
|
prerender: {
|
||||||
|
routes: ['/robots.txt', '/sitemap.xml']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -5,16 +5,15 @@
|
|||||||
"packageManager": "pnpm@10.33.2",
|
"packageManager": "pnpm@10.33.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0 --port 20015",
|
"dev": "nuxt dev --host 0.0.0.0 --port 20015",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "nuxt build",
|
||||||
"lint": "vue-tsc --noEmit",
|
"lint": "nuxt typecheck",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "nuxt typecheck",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iconify/vue": "5.0.0",
|
"@iconify/vue": "5.0.0",
|
||||||
"@vitejs/plugin-vue": "6.0.6",
|
"nuxt": "4.4.4",
|
||||||
"vite": "8.0.10",
|
|
||||||
"vue": "3.5.33",
|
"vue": "3.5.33",
|
||||||
"vue-i18n": "11.4.0",
|
"vue-i18n": "11.4.0",
|
||||||
"vue-router": "5.0.6"
|
"vue-router": "5.0.6"
|
||||||
@@ -22,6 +21,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "25.6.0",
|
"@types/node": "25.6.0",
|
||||||
"@vue/tsconfig": "0.9.1",
|
"@vue/tsconfig": "0.9.1",
|
||||||
|
"postcss": "8.5.13",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vitest": "4.1.5",
|
"vitest": "4.1.5",
|
||||||
"vue-tsc": "3.2.7"
|
"vue-tsc": "3.2.7"
|
||||||
|
|||||||
12
frontend/pages/actions.vue
Normal file
12
frontend/pages/actions.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'actions',
|
||||||
|
seo: { titleKey: 'pages.comingSoon.sections.actions.title', descriptionKey: 'pages.comingSoon.sections.actions.subtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComingSoonView page="actions" />
|
||||||
|
</template>
|
||||||
13
frontend/pages/admin.vue
Normal file
13
frontend/pages/admin.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AdminView from '../src/views/AdminView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'admin',
|
||||||
|
requiredPermission: 'admin.access',
|
||||||
|
seo: { titleKey: 'pages.admin.title', descriptionKey: 'pages.admin.subtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AdminView />
|
||||||
|
</template>
|
||||||
20
frontend/pages/ancient-artifacts/[id]/edit.vue
Normal file
20
frontend/pages/ancient-artifacts/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
|
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'ancient-artifact-edit',
|
||||||
|
requiredPermission: 'items.update',
|
||||||
|
editorModal: true,
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.ancientArtifacts.editKicker',
|
||||||
|
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
|
||||||
|
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/ancient-artifacts/${String(route.params.id)}`,
|
||||||
|
noindex: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/ancient-artifacts/[id]/index.vue
Normal file
12
frontend/pages/ancient-artifacts/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'ancient-artifact-detail',
|
||||||
|
seo: { titleKey: 'pages.ancientArtifacts.detailKicker', descriptionKey: 'pages.ancientArtifacts.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/ancient-artifacts/index.vue
Normal file
12
frontend/pages/ancient-artifacts/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AncientArtifactList from '../../src/views/AncientArtifactList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'ancient-artifact-list',
|
||||||
|
seo: { titleKey: 'pages.ancientArtifacts.title', descriptionKey: 'pages.ancientArtifacts.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AncientArtifactList />
|
||||||
|
</template>
|
||||||
19
frontend/pages/ancient-artifacts/new.vue
Normal file
19
frontend/pages/ancient-artifacts/new.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AncientArtifactList from '../../src/views/AncientArtifactList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'ancient-artifact-new',
|
||||||
|
requiredPermission: 'items.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.ancientArtifacts.newTitle',
|
||||||
|
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
|
||||||
|
canonicalPath: '/ancient-artifacts',
|
||||||
|
noindex: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AncientArtifactList />
|
||||||
|
</template>
|
||||||
12
frontend/pages/automation.vue
Normal file
12
frontend/pages/automation.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'automation',
|
||||||
|
seo: { titleKey: 'pages.comingSoon.sections.automation.title', descriptionKey: 'pages.comingSoon.sections.automation.subtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComingSoonView page="automation" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/checklist.vue
Normal file
12
frontend/pages/checklist.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DailyChecklistView from '../src/views/DailyChecklistView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'checklist',
|
||||||
|
seo: { titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DailyChecklistView />
|
||||||
|
</template>
|
||||||
12
frontend/pages/clothes.vue
Normal file
12
frontend/pages/clothes.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'clothes',
|
||||||
|
seo: { titleKey: 'pages.comingSoon.sections.clothes.title', descriptionKey: 'pages.comingSoon.sections.clothes.subtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComingSoonView page="clothes" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/disclaimers.vue
Normal file
12
frontend/pages/disclaimers.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LegalView from '../src/views/LegalView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'disclaimers',
|
||||||
|
seo: { titleKey: 'pages.legal.disclaimers.title', descriptionKey: 'pages.legal.disclaimers.subtitle', canonicalPath: '/disclaimers' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LegalView page="disclaimers" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/dish.vue
Normal file
12
frontend/pages/dish.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DishView from '../src/views/DishView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'dish',
|
||||||
|
seo: { titleKey: 'pages.dish.title', descriptionKey: 'pages.dish.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DishView />
|
||||||
|
</template>
|
||||||
12
frontend/pages/dream-island.vue
Normal file
12
frontend/pages/dream-island.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'dream-island',
|
||||||
|
seo: { titleKey: 'pages.comingSoon.sections.dreamIsland.title', descriptionKey: 'pages.comingSoon.sections.dreamIsland.subtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComingSoonView page="dreamIsland" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/event-habitats/index.vue
Normal file
12
frontend/pages/event-habitats/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import HabitatList from '../../src/views/HabitatList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'event-habitat-list',
|
||||||
|
seo: { titleKey: 'pages.eventHabitats.title', descriptionKey: 'pages.eventHabitats.subtitle', canonicalPath: '/event-habitats' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HabitatList :event-only="true" />
|
||||||
|
</template>
|
||||||
14
frontend/pages/event-habitats/new.vue
Normal file
14
frontend/pages/event-habitats/new.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import HabitatList from '../../src/views/HabitatList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'event-habitat-new',
|
||||||
|
requiredPermission: 'habitats.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: { titleKey: 'pages.eventHabitats.newTitle', descriptionKey: 'pages.eventHabitats.editSubtitle', canonicalPath: '/event-habitats', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HabitatList :event-only="true" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/event-items/index.vue
Normal file
12
frontend/pages/event-items/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ItemsList from '../../src/views/ItemsList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'event-item-list',
|
||||||
|
seo: { titleKey: 'pages.eventItems.title', descriptionKey: 'pages.eventItems.subtitle', canonicalPath: '/event-items' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemsList :event-only="true" />
|
||||||
|
</template>
|
||||||
14
frontend/pages/event-items/new.vue
Normal file
14
frontend/pages/event-items/new.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ItemsList from '../../src/views/ItemsList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'event-item-new',
|
||||||
|
requiredPermission: 'items.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: { titleKey: 'pages.eventItems.newTitle', descriptionKey: 'pages.eventItems.editSubtitle', canonicalPath: '/event-items', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemsList :event-only="true" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/event-pokemon/index.vue
Normal file
12
frontend/pages/event-pokemon/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PokemonList from '../../src/views/PokemonList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'event-pokemon-list',
|
||||||
|
seo: { titleKey: 'pages.eventPokemon.title', descriptionKey: 'pages.eventPokemon.subtitle', canonicalPath: '/event-pokemon' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PokemonList :event-only="true" />
|
||||||
|
</template>
|
||||||
14
frontend/pages/event-pokemon/new.vue
Normal file
14
frontend/pages/event-pokemon/new.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PokemonList from '../../src/views/PokemonList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'event-pokemon-new',
|
||||||
|
requiredPermission: 'pokemon.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: { titleKey: 'pages.eventPokemon.newTitle', descriptionKey: 'pages.eventPokemon.editSubtitle', canonicalPath: '/event-pokemon', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PokemonList :event-only="true" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/events.vue
Normal file
12
frontend/pages/events.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'events',
|
||||||
|
seo: { titleKey: 'pages.comingSoon.sections.events.title', descriptionKey: 'pages.comingSoon.sections.events.subtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComingSoonView page="events" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/forgot-password.vue
Normal file
12
frontend/pages/forgot-password.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ForgotPasswordView from '../src/views/ForgotPasswordView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'forgot-password',
|
||||||
|
seo: { titleKey: 'auth.requestResetTitle', descriptionKey: 'auth.requestResetSubtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ForgotPasswordView />
|
||||||
|
</template>
|
||||||
20
frontend/pages/habitats/[id]/edit.vue
Normal file
20
frontend/pages/habitats/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
|
import HabitatDetail from '../../../src/views/HabitatDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'habitat-edit',
|
||||||
|
requiredPermission: 'habitats.update',
|
||||||
|
editorModal: true,
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.habitats.detailKicker',
|
||||||
|
descriptionKey: 'pages.habitats.editSubtitle',
|
||||||
|
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/habitats/${String(route.params.id)}`,
|
||||||
|
noindex: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HabitatDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/habitats/[id]/index.vue
Normal file
12
frontend/pages/habitats/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import HabitatDetail from '../../../src/views/HabitatDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'habitat-detail',
|
||||||
|
seo: { titleKey: 'pages.habitats.detailKicker', descriptionKey: 'pages.habitats.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HabitatDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/habitats/index.vue
Normal file
12
frontend/pages/habitats/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import HabitatList from '../../src/views/HabitatList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'habitat-list',
|
||||||
|
seo: { titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HabitatList :event-only="false" />
|
||||||
|
</template>
|
||||||
14
frontend/pages/habitats/new.vue
Normal file
14
frontend/pages/habitats/new.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import HabitatList from '../../src/views/HabitatList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'habitat-new',
|
||||||
|
requiredPermission: 'habitats.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: { titleKey: 'pages.habitats.newTitle', descriptionKey: 'pages.habitats.editSubtitle', canonicalPath: '/habitats', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HabitatList :event-only="false" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/index.vue
Normal file
12
frontend/pages/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import HomeView from '../src/views/HomeView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'home',
|
||||||
|
seo: { titleKey: 'pages.home.title', descriptionKey: 'pages.home.subtitle', canonicalPath: '/' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HomeView />
|
||||||
|
</template>
|
||||||
20
frontend/pages/items/[id]/edit.vue
Normal file
20
frontend/pages/items/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
|
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'item-edit',
|
||||||
|
requiredPermission: 'items.update',
|
||||||
|
editorModal: true,
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.items.editKicker',
|
||||||
|
descriptionKey: 'pages.items.editSubtitle',
|
||||||
|
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/items/${String(route.params.id)}`,
|
||||||
|
noindex: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/items/[id]/index.vue
Normal file
12
frontend/pages/items/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'item-detail',
|
||||||
|
seo: { titleKey: 'pages.items.detailKicker', descriptionKey: 'pages.items.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/items/index.vue
Normal file
12
frontend/pages/items/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ItemsList from '../../src/views/ItemsList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'item-list',
|
||||||
|
seo: { titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemsList :event-only="false" />
|
||||||
|
</template>
|
||||||
14
frontend/pages/items/new.vue
Normal file
14
frontend/pages/items/new.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ItemsList from '../../src/views/ItemsList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'item-new',
|
||||||
|
requiredPermission: 'items.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: { titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/items', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemsList :event-only="false" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/life/[id].vue
Normal file
12
frontend/pages/life/[id].vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LifePostDetail from '../../src/views/LifePostDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'life-id',
|
||||||
|
seo: { titleKey: 'pages.life.detailTitle', descriptionKey: 'pages.life.detailSubtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LifePostDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/life/index.vue
Normal file
12
frontend/pages/life/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LifeView from '../../src/views/LifeView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'life',
|
||||||
|
seo: { titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LifeView />
|
||||||
|
</template>
|
||||||
12
frontend/pages/login.vue
Normal file
12
frontend/pages/login.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LoginView from '../src/views/LoginView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'login',
|
||||||
|
seo: { titleKey: 'auth.loginTitle', descriptionKey: 'auth.loginSubtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LoginView />
|
||||||
|
</template>
|
||||||
20
frontend/pages/pokemon/[id]/edit.vue
Normal file
20
frontend/pages/pokemon/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
|
import PokemonDetail from '../../../src/views/PokemonDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'pokemon-edit',
|
||||||
|
requiredPermission: 'pokemon.update',
|
||||||
|
editorModal: true,
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.pokemon.editKicker',
|
||||||
|
descriptionKey: 'pages.pokemon.editSubtitle',
|
||||||
|
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/pokemon/${String(route.params.id)}`,
|
||||||
|
noindex: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PokemonDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/pokemon/[id]/index.vue
Normal file
12
frontend/pages/pokemon/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PokemonDetail from '../../../src/views/PokemonDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'pokemon-detail',
|
||||||
|
seo: { titleKey: 'pages.pokemon.detailKicker', descriptionKey: 'pages.pokemon.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PokemonDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/pokemon/index.vue
Normal file
12
frontend/pages/pokemon/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PokemonList from '../../src/views/PokemonList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'pokemon-list',
|
||||||
|
seo: { titleKey: 'pages.pokemon.title', descriptionKey: 'pages.pokemon.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PokemonList :event-only="false" />
|
||||||
|
</template>
|
||||||
14
frontend/pages/pokemon/new.vue
Normal file
14
frontend/pages/pokemon/new.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PokemonList from '../../src/views/PokemonList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'pokemon-new',
|
||||||
|
requiredPermission: 'pokemon.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: { titleKey: 'pages.pokemon.newTitle', descriptionKey: 'pages.pokemon.editSubtitle', canonicalPath: '/pokemon', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PokemonList :event-only="false" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/privacy-policy.vue
Normal file
12
frontend/pages/privacy-policy.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LegalView from '../src/views/LegalView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'privacy-policy',
|
||||||
|
seo: { titleKey: 'pages.legal.privacy.title', descriptionKey: 'pages.legal.privacy.subtitle', canonicalPath: '/privacy-policy' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LegalView page="privacy" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/profile/[id].vue
Normal file
12
frontend/pages/profile/[id].vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import UserProfileView from '../../src/views/UserProfileView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'profile-id',
|
||||||
|
seo: { titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.publicSubtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UserProfileView />
|
||||||
|
</template>
|
||||||
13
frontend/pages/profile/index.vue
Normal file
13
frontend/pages/profile/index.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import UserProfileView from '../../src/views/UserProfileView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'profile',
|
||||||
|
requiresAuth: true,
|
||||||
|
seo: { titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.subtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UserProfileView />
|
||||||
|
</template>
|
||||||
16
frontend/pages/project-updates.vue
Normal file
16
frontend/pages/project-updates.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ProjectUpdatesView from '../src/views/ProjectUpdatesView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'project-updates',
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.projectUpdates.title',
|
||||||
|
descriptionKey: 'pages.projectUpdates.subtitle',
|
||||||
|
canonicalPath: '/project-updates'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ProjectUpdatesView />
|
||||||
|
</template>
|
||||||
20
frontend/pages/recipes/[id]/edit.vue
Normal file
20
frontend/pages/recipes/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
|
import RecipeDetail from '../../../src/views/RecipeDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'recipe-edit',
|
||||||
|
requiredPermission: 'recipes.update',
|
||||||
|
editorModal: true,
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.recipes.editKicker',
|
||||||
|
descriptionKey: 'pages.recipes.editSubtitle',
|
||||||
|
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/recipes/${String(route.params.id)}`,
|
||||||
|
noindex: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RecipeDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/recipes/[id]/index.vue
Normal file
12
frontend/pages/recipes/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import RecipeDetail from '../../../src/views/RecipeDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'recipe-detail',
|
||||||
|
seo: { titleKey: 'pages.recipes.detailKicker', descriptionKey: 'pages.recipes.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RecipeDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/recipes/index.vue
Normal file
12
frontend/pages/recipes/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import RecipeList from '../../src/views/RecipeList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'recipe-list',
|
||||||
|
seo: { titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RecipeList />
|
||||||
|
</template>
|
||||||
14
frontend/pages/recipes/new.vue
Normal file
14
frontend/pages/recipes/new.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import RecipeList from '../../src/views/RecipeList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'recipe-new',
|
||||||
|
requiredPermission: 'recipes.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: { titleKey: 'pages.recipes.newTitle', descriptionKey: 'pages.recipes.editSubtitle', canonicalPath: '/recipes', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RecipeList />
|
||||||
|
</template>
|
||||||
12
frontend/pages/register.vue
Normal file
12
frontend/pages/register.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import RegisterView from '../src/views/RegisterView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'register',
|
||||||
|
seo: { titleKey: 'auth.registerTitle', descriptionKey: 'auth.registerSubtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RegisterView />
|
||||||
|
</template>
|
||||||
12
frontend/pages/reset-password.vue
Normal file
12
frontend/pages/reset-password.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ResetPasswordView from '../src/views/ResetPasswordView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'reset-password',
|
||||||
|
seo: { titleKey: 'auth.resetTitle', descriptionKey: 'auth.resetSubtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ResetPasswordView />
|
||||||
|
</template>
|
||||||
12
frontend/pages/terms-of-service.vue
Normal file
12
frontend/pages/terms-of-service.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LegalView from '../src/views/LegalView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'terms-of-service',
|
||||||
|
seo: { titleKey: 'pages.legal.terms.title', descriptionKey: 'pages.legal.terms.subtitle', canonicalPath: '/terms-of-service' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LegalView page="terms" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/verify-email.vue
Normal file
12
frontend/pages/verify-email.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import VerifyEmailView from '../src/views/VerifyEmailView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'verify-email',
|
||||||
|
seo: { titleKey: 'auth.verifyTitle', descriptionKey: 'auth.verifySubtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VerifyEmailView />
|
||||||
|
</template>
|
||||||
15
frontend/plugins/00-runtime-config.ts
Normal file
15
frontend/plugins/00-runtime-config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { setSystemWordingsApiBaseUrls } from '../src/i18n';
|
||||||
|
import { setConfiguredSiteUrl } from '../src/seo';
|
||||||
|
import { setApiBaseUrls } from '../src/services/api';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBaseUrls = {
|
||||||
|
browser: config.public.apiBaseUrl,
|
||||||
|
server: config.serverApiBaseUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
setApiBaseUrls(apiBaseUrls);
|
||||||
|
setSystemWordingsApiBaseUrls(apiBaseUrls);
|
||||||
|
setConfiguredSiteUrl(config.public.siteUrl);
|
||||||
|
});
|
||||||
15
frontend/plugins/01-i18n.ts
Normal file
15
frontend/plugins/01-i18n.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { createPokopiaI18n, setActiveI18n } from '../src/i18n';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
const i18n = createPokopiaI18n();
|
||||||
|
if (import.meta.client) {
|
||||||
|
setActiveI18n(i18n);
|
||||||
|
}
|
||||||
|
|
||||||
|
nuxtApp.vueApp.use(i18n);
|
||||||
|
return {
|
||||||
|
provide: {
|
||||||
|
pokopiaI18n: i18n
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
32
frontend/plugins/02-seo.ts
Normal file
32
frontend/plugins/02-seo.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { onLocaleChange } from '../src/i18n';
|
||||||
|
import { applyRouteSeo, onSeoChange, resolvedSeoHead, resolveRouteSeo, setSeoTranslator, type ResolvedSeoConfig } from '../src/seo';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
const router = useRouter();
|
||||||
|
const nuxtApp = useNuxtApp();
|
||||||
|
const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
|
||||||
|
const dynamicSeo = ref<ResolvedSeoConfig | null>(null);
|
||||||
|
const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t));
|
||||||
|
useHead(() => resolvedSeoHead(activeSeo.value));
|
||||||
|
|
||||||
|
if (import.meta.server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSeoTranslator(t);
|
||||||
|
onSeoChange((seo) => {
|
||||||
|
dynamicSeo.value = seo;
|
||||||
|
});
|
||||||
|
onLocaleChange(() => {
|
||||||
|
dynamicSeo.value = null;
|
||||||
|
applyRouteSeo(router.currentRoute.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.afterEach((to) => {
|
||||||
|
dynamicSeo.value = null;
|
||||||
|
applyRouteSeo(to);
|
||||||
|
});
|
||||||
|
|
||||||
|
applyRouteSeo(router.currentRoute.value);
|
||||||
|
});
|
||||||
76
frontend/plugins/03-detail-seo.server.ts
Normal file
76
frontend/plugins/03-detail-seo.server.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { resolvedSeoHead, resolveSeo, type SeoConfig } from '../src/seo';
|
||||||
|
import { api } from '../src/services/api';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(async () => {
|
||||||
|
const route = useRoute();
|
||||||
|
const routeId = typeof route.params.id === 'string' && route.params.id.trim() !== '' ? route.params.id : null;
|
||||||
|
if (!routeId || typeof route.name !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nuxtApp = useNuxtApp();
|
||||||
|
const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
|
||||||
|
const seo = await detailSeo(String(route.name), routeId, t);
|
||||||
|
if (seo) {
|
||||||
|
useHead(resolvedSeoHead(resolveSeo(seo)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function detailSeo(
|
||||||
|
routeName: string,
|
||||||
|
routeId: string,
|
||||||
|
t: (key: string, values?: Record<string, string | number>) => string
|
||||||
|
): Promise<SeoConfig | null> {
|
||||||
|
try {
|
||||||
|
if (routeName === 'pokemon-detail') {
|
||||||
|
const pokemon = await api.pokemonDetail(routeId);
|
||||||
|
return {
|
||||||
|
title: `${pokemon.name} - ${t(pokemon.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`,
|
||||||
|
description: t('seo.pokemonDetailDescription', { name: pokemon.name }),
|
||||||
|
canonicalPath: `/pokemon/${pokemon.id}`,
|
||||||
|
image: pokemon.image?.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeName === 'habitat-detail') {
|
||||||
|
const habitat = await api.habitatDetail(routeId);
|
||||||
|
return {
|
||||||
|
title: `${habitat.name} - ${t(habitat.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`,
|
||||||
|
description: t('seo.habitatDetailDescription', { name: habitat.name }),
|
||||||
|
canonicalPath: `/habitats/${habitat.id}`,
|
||||||
|
image: habitat.image?.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeName === 'item-detail' || routeName === 'ancient-artifact-detail') {
|
||||||
|
const item = await api.itemDetail(routeId);
|
||||||
|
const ancientArtifactRoute = routeName === 'ancient-artifact-detail';
|
||||||
|
if (ancientArtifactRoute && !item.ancientArtifactCategory) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleKey = ancientArtifactRoute ? 'pages.ancientArtifacts.title' : item.isEventItem ? 'pages.eventItems.title' : 'pages.items.title';
|
||||||
|
const descriptionKey = ancientArtifactRoute ? 'seo.ancientArtifactDetailDescription' : 'seo.itemDetailDescription';
|
||||||
|
return {
|
||||||
|
title: `${item.name} - ${t(titleKey)}`,
|
||||||
|
description: t(descriptionKey, { name: item.name }),
|
||||||
|
canonicalPath: ancientArtifactRoute ? `/ancient-artifacts/${item.id}` : `/items/${item.id}`,
|
||||||
|
image: item.image?.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeName === 'recipe-detail') {
|
||||||
|
const recipe = await api.recipeDetail(routeId);
|
||||||
|
return {
|
||||||
|
title: `${recipe.name} - ${t('pages.recipes.title')}`,
|
||||||
|
description: t('seo.recipeDetailDescription', { name: recipe.name }),
|
||||||
|
canonicalPath: `/recipes/${recipe.id}`,
|
||||||
|
image: recipe.item.image?.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
7
frontend/server/routes/robots.txt.ts
Normal file
7
frontend/server/routes/robots.txt.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { normalizeSiteUrl, robotsTxt } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
return robotsTxt(normalizeSiteUrl(config.public.siteUrl));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap.xml.ts
Normal file
7
frontend/server/routes/sitemap.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { normalizeSiteUrl, sitemapXml } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return sitemapXml(normalizeSiteUrl(config.public.siteUrl));
|
||||||
|
});
|
||||||
73
frontend/server/utils/seo-files.ts
Normal file
73
frontend/server/utils/seo-files.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||||
|
|
||||||
|
const sitemapPaths = [
|
||||||
|
'/',
|
||||||
|
'/pokemon',
|
||||||
|
'/event-pokemon',
|
||||||
|
'/habitats',
|
||||||
|
'/event-habitats',
|
||||||
|
'/items',
|
||||||
|
'/event-items',
|
||||||
|
'/ancient-artifacts',
|
||||||
|
'/recipes',
|
||||||
|
'/dish',
|
||||||
|
'/checklist',
|
||||||
|
'/life',
|
||||||
|
'/project-updates',
|
||||||
|
'/privacy-policy',
|
||||||
|
'/terms-of-service',
|
||||||
|
'/disclaimers'
|
||||||
|
];
|
||||||
|
|
||||||
|
const robotsDisallowPaths = [
|
||||||
|
'/admin',
|
||||||
|
'/login',
|
||||||
|
'/register',
|
||||||
|
'/forgot-password',
|
||||||
|
'/reset-password',
|
||||||
|
'/verify-email',
|
||||||
|
'/pokemon/new',
|
||||||
|
'/event-pokemon/new',
|
||||||
|
'/pokemon/*/edit',
|
||||||
|
'/habitats/new',
|
||||||
|
'/event-habitats/new',
|
||||||
|
'/habitats/*/edit',
|
||||||
|
'/items/new',
|
||||||
|
'/event-items/new',
|
||||||
|
'/items/*/edit',
|
||||||
|
'/ancient-artifacts/new',
|
||||||
|
'/ancient-artifacts/*/edit',
|
||||||
|
'/recipes/new',
|
||||||
|
'/recipes/*/edit',
|
||||||
|
'/automation',
|
||||||
|
'/events',
|
||||||
|
'/actions',
|
||||||
|
'/dream-island',
|
||||||
|
'/clothes'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeSiteUrl(value: unknown): string {
|
||||||
|
return (typeof value === 'string' && value.trim() ? value.trim() : fallbackSiteUrl).replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function robotsTxt(siteUrl: string): string {
|
||||||
|
const disallowLines = robotsDisallowPaths.map((path) => `Disallow: ${path}`).join('\n');
|
||||||
|
return `User-agent: *\nAllow: /\n${disallowLines}\nSitemap: ${siteUrl}/sitemap.xml\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sitemapXml(siteUrl: string): string {
|
||||||
|
const urls = sitemapPaths
|
||||||
|
.map(
|
||||||
|
(path) => ` <url>
|
||||||
|
<loc>${siteUrl}${path}</loc>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
</url>`
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${urls}
|
||||||
|
</urlset>
|
||||||
|
`;
|
||||||
|
}
|
||||||
47
frontend/src/components/ConfirmDialog.vue
Normal file
47
frontend/src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import Modal from './Modal.vue';
|
||||||
|
import { iconCancel, iconDelete } from '../icons';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
closeLabel: string;
|
||||||
|
busy?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
busy: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
cancel: [];
|
||||||
|
confirm: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:title="title"
|
||||||
|
:close-label="closeLabel"
|
||||||
|
:close-on-backdrop="!busy"
|
||||||
|
:close-on-escape="!busy"
|
||||||
|
@close="emit('cancel')"
|
||||||
|
>
|
||||||
|
<p class="confirm-dialog__message">{{ message }}</p>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button type="button" class="link-button link-button--danger" :disabled="busy" @click="emit('confirm')">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ confirmLabel }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="plain-button" :disabled="busy" @click="emit('cancel')">
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ cancelLabel }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import EditMeta from './EditMeta.vue';
|
||||||
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
|
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
entity: EditInfo;
|
entity: EditInfo;
|
||||||
history: EditHistoryEntry[];
|
history: EditHistoryEntry[];
|
||||||
}>();
|
}>();
|
||||||
@@ -45,10 +46,15 @@ const changeLabelKeys: Record<string, string> = {
|
|||||||
'Speciality drops': 'pages.pokemon.skillDrops',
|
'Speciality drops': 'pages.pokemon.skillDrops',
|
||||||
'Skill drops': 'pages.pokemon.skillDrops',
|
'Skill drops': 'pages.pokemon.skillDrops',
|
||||||
特长掉落物: 'pages.pokemon.skillDrops',
|
特长掉落物: 'pages.pokemon.skillDrops',
|
||||||
|
Trading: 'pages.pokemon.trading',
|
||||||
|
'Trading items': 'pages.pokemon.tradingItems',
|
||||||
Category: 'pages.items.category',
|
Category: 'pages.items.category',
|
||||||
分类: 'pages.items.category',
|
分类: 'pages.items.category',
|
||||||
Usage: 'pages.items.usage',
|
Usage: 'pages.items.usage',
|
||||||
用途: 'pages.items.usage',
|
用途: 'pages.items.usage',
|
||||||
|
'Base Price': 'pages.items.basePrice',
|
||||||
|
'Base price': 'pages.items.basePrice',
|
||||||
|
基础价格: 'pages.items.basePrice',
|
||||||
Dyeable: 'pages.items.dyeable',
|
Dyeable: 'pages.items.dyeable',
|
||||||
可染色: 'pages.items.dyeable',
|
可染色: 'pages.items.dyeable',
|
||||||
'Dual dyeable': 'pages.items.dualDyeable',
|
'Dual dyeable': 'pages.items.dualDyeable',
|
||||||
@@ -73,6 +79,8 @@ const changeLabelKeys: Record<string, string> = {
|
|||||||
排序: 'pages.admin.sortOrder',
|
排序: 'pages.admin.sortOrder',
|
||||||
'Has item drop': 'pages.admin.hasItemDrop',
|
'Has item drop': 'pages.admin.hasItemDrop',
|
||||||
有掉落物: 'pages.admin.hasItemDrop',
|
有掉落物: 'pages.admin.hasItemDrop',
|
||||||
|
'Has trading': 'pages.admin.hasTrading',
|
||||||
|
'有 Trading': 'pages.admin.hasTrading',
|
||||||
'Default category': 'pages.admin.defaultCategory',
|
'Default category': 'pages.admin.defaultCategory',
|
||||||
默认分类: 'pages.admin.defaultCategory',
|
默认分类: 'pages.admin.defaultCategory',
|
||||||
Rateable: 'pages.admin.rateableCategory',
|
Rateable: 'pages.admin.rateableCategory',
|
||||||
@@ -118,7 +126,11 @@ function changeValue(value: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function visibleChanges(entry: EditHistoryEntry) {
|
function visibleChanges(entry: EditHistoryEntry) {
|
||||||
return entry.changes.filter((change) => change.label !== 'Display ID');
|
return entry.changes.filter((change) => change.label !== 'Display ID' && change.label !== 'Sort order' && change.label !== '排序');
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleHistoryEntries() {
|
||||||
|
return props.history.filter((entry) => entry.action !== 'update' || visibleChanges(entry).length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function historySummary(entry: EditHistoryEntry): string {
|
function historySummary(entry: EditHistoryEntry): string {
|
||||||
@@ -148,29 +160,25 @@ function formatDateTime(value: string): string {
|
|||||||
<div>
|
<div>
|
||||||
<dt>{{ t('history.createdBy') }}</dt>
|
<dt>{{ t('history.createdBy') }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<RouterLink v-if="entity.createdBy" class="user-profile-link" :to="`/profile/${entity.createdBy.id}`">
|
<RouterLink v-if="props.entity.createdBy" class="user-profile-link" :to="`/profile/${props.entity.createdBy.id}`">
|
||||||
{{ entity.createdBy.displayName }}
|
{{ props.entity.createdBy.displayName }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<strong v-else>{{ displayName(entity.createdBy) }}</strong>
|
<strong v-else>{{ displayName(props.entity.createdBy) }}</strong>
|
||||||
<time :datetime="entity.createdAt">{{ formatDateTime(entity.createdAt) }}</time>
|
<time :datetime="props.entity.createdAt">{{ formatDateTime(props.entity.createdAt) }}</time>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>{{ t('history.lastEdited') }}</dt>
|
<dt>{{ t('history.lastEdited') }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`">
|
<EditMeta :entity="props.entity" :show-label="false" />
|
||||||
{{ entity.updatedBy.displayName }}
|
|
||||||
</RouterLink>
|
|
||||||
<strong v-else>{{ displayName(entity.updatedBy) }}</strong>
|
|
||||||
<time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<section class="edit-history-list" aria-labelledby="edit-history-list-title">
|
<section class="edit-history-list" aria-labelledby="edit-history-list-title">
|
||||||
<h3 id="edit-history-list-title">{{ t('history.editHistory') }}</h3>
|
<h3 id="edit-history-list-title">{{ t('history.editHistory') }}</h3>
|
||||||
<ol v-if="history.length" class="edit-timeline">
|
<ol v-if="visibleHistoryEntries().length" class="edit-timeline">
|
||||||
<li v-for="entry in history" :key="`${entry.action}-${entry.createdAt}-${entry.user?.id ?? 'system'}`">
|
<li v-for="entry in visibleHistoryEntries()" :key="`${entry.action}-${entry.createdAt}-${entry.user?.id ?? 'system'}`">
|
||||||
<span class="edit-timeline__avatar" aria-hidden="true">{{ actionMark(entry.action) }}</span>
|
<span class="edit-timeline__avatar" aria-hidden="true">{{ actionMark(entry.action) }}</span>
|
||||||
<div class="edit-timeline__body">
|
<div class="edit-timeline__body">
|
||||||
<details class="edit-history-entry">
|
<details class="edit-history-entry">
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { EditInfo } from '../services/api';
|
import type { EditInfo } from '../services/api';
|
||||||
|
|
||||||
defineProps<{
|
withDefaults(
|
||||||
entity: EditInfo;
|
defineProps<{
|
||||||
}>();
|
entity: EditInfo;
|
||||||
|
showLabel?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
showLabel: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
|
|
||||||
@@ -18,11 +24,11 @@ function formatDateTime(value: string): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<p class="edit-meta">
|
<p class="edit-meta">
|
||||||
{{ t('history.lastEdited') }}:
|
<template v-if="showLabel">{{ t('history.lastEdited') }}: </template>
|
||||||
<RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`">
|
<RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`">
|
||||||
{{ entity.updatedBy.displayName }}
|
{{ entity.updatedBy.displayName }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<span v-else>{{ t('common.system') }}</span>
|
<span v-else>{{ t('common.system') }}</span>
|
||||||
/ {{ formatDateTime(entity.updatedAt) }}
|
/ <time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import ConfirmDialog from './ConfirmDialog.vue';
|
||||||
|
import LoadMoreSentinel from './LoadMoreSentinel.vue';
|
||||||
import StatusBadge from './StatusBadge.vue';
|
import StatusBadge from './StatusBadge.vue';
|
||||||
import Tabs, { type TabOption } from './Tabs.vue';
|
import Tabs, { type TabOption } from './Tabs.vue';
|
||||||
import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons';
|
import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
getAuthToken,
|
|
||||||
moderationUpdateEvent,
|
moderationUpdateEvent,
|
||||||
onAuthTokenChange,
|
onAuthChange,
|
||||||
setAuthToken,
|
|
||||||
type AiModerationStatus,
|
type AiModerationStatus,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
type CommentSort,
|
type CommentSort,
|
||||||
@@ -53,6 +53,8 @@ let removeAuthListener: (() => void) | null = null;
|
|||||||
const nextCursor = ref<string | null>(null);
|
const nextCursor = ref<string | null>(null);
|
||||||
const hasMoreComments = ref(false);
|
const hasMoreComments = ref(false);
|
||||||
const commentTotal = ref(0);
|
const commentTotal = ref(0);
|
||||||
|
const pendingDeleteComment = ref<EntityDiscussionComment | null>(null);
|
||||||
|
const deleteConfirmBusy = ref(false);
|
||||||
|
|
||||||
function can(permissionKey: string) {
|
function can(permissionKey: string) {
|
||||||
return currentUser.value?.permissions.includes(permissionKey) === true;
|
return currentUser.value?.permissions.includes(permissionKey) === true;
|
||||||
@@ -76,18 +78,11 @@ const sortOptions = computed<Array<{ value: CommentSort; label: string }>>(() =>
|
|||||||
async function loadCurrentUser() {
|
async function loadCurrentUser() {
|
||||||
authReady.value = false;
|
authReady.value = false;
|
||||||
|
|
||||||
if (!getAuthToken()) {
|
|
||||||
currentUser.value = null;
|
|
||||||
authReady.value = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.me();
|
const response = await api.me();
|
||||||
currentUser.value = response.user;
|
currentUser.value = response.user;
|
||||||
} catch {
|
} catch {
|
||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
setAuthToken(null);
|
|
||||||
} finally {
|
} finally {
|
||||||
authReady.value = true;
|
authReady.value = true;
|
||||||
}
|
}
|
||||||
@@ -470,11 +465,34 @@ function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolea
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteComment(comment: EntityDiscussionComment) {
|
function requestDeleteComment(comment: EntityDiscussionComment) {
|
||||||
if (!window.confirm(t('discussion.deleteConfirm'))) {
|
pendingDeleteComment.value = comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteConfirm() {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingDeleteComment.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteComment() {
|
||||||
|
const comment = pendingDeleteComment.value;
|
||||||
|
if (!comment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmBusy.value = true;
|
||||||
|
try {
|
||||||
|
await deleteComment(comment);
|
||||||
|
pendingDeleteComment.value = null;
|
||||||
|
} finally {
|
||||||
|
deleteConfirmBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteComment(comment: EntityDiscussionComment) {
|
||||||
const key = commentKey(comment.id);
|
const key = commentKey(comment.id);
|
||||||
clearCommentError(key);
|
clearCommentError(key);
|
||||||
|
|
||||||
@@ -514,7 +532,7 @@ onMounted(() => {
|
|||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
void loadLanguages();
|
void loadLanguages();
|
||||||
void loadDiscussion();
|
void loadDiscussion();
|
||||||
removeAuthListener = onAuthTokenChange(() => {
|
removeAuthListener = onAuthChange(() => {
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -656,7 +674,7 @@ onUnmounted(() => {
|
|||||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('discussion.deleteComment')"
|
:aria-label="t('discussion.deleteComment')"
|
||||||
@click="deleteComment(comment)"
|
@click="requestDeleteComment(comment)"
|
||||||
>
|
>
|
||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
|
||||||
@@ -758,7 +776,7 @@ onUnmounted(() => {
|
|||||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('discussion.deleteComment')"
|
:aria-label="t('discussion.deleteComment')"
|
||||||
@click="deleteComment(reply)"
|
@click="requestDeleteComment(reply)"
|
||||||
>
|
>
|
||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
|
||||||
@@ -776,17 +794,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div v-if="hasMoreComments" class="life-feed__retry">
|
<LoadMoreSentinel :active="hasMoreComments" :disabled="loading || loadingMore" @load="loadDiscussion(false)" />
|
||||||
<button
|
|
||||||
class="ui-button ui-button--ghost ui-button--small"
|
|
||||||
type="button"
|
|
||||||
:disabled="loadingMore"
|
|
||||||
@click="loadDiscussion(false)"
|
|
||||||
>
|
|
||||||
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
|
|
||||||
{{ loadingMore ? t('common.loading') : t('discussion.loadMore') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="entity-discussion-empty">
|
<div v-else class="entity-discussion-empty">
|
||||||
@@ -796,5 +804,17 @@ onUnmounted(() => {
|
|||||||
<p>{{ t('discussion.emptyHint') }}</p>
|
<p>{{ t('discussion.emptyHint') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
v-if="pendingDeleteComment"
|
||||||
|
:title="t('discussion.deleteComment')"
|
||||||
|
:message="t('discussion.deleteConfirm')"
|
||||||
|
:confirm-label="t('common.delete')"
|
||||||
|
:cancel-label="t('common.cancel')"
|
||||||
|
:close-label="t('common.close')"
|
||||||
|
:busy="deleteConfirmBusy"
|
||||||
|
@cancel="closeDeleteConfirm"
|
||||||
|
@confirm="confirmDeleteComment"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
68
frontend/src/components/LoadMoreSentinel.vue
Normal file
68
frontend/src/components/LoadMoreSentinel.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
active: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
rootMargin?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
disabled: false,
|
||||||
|
rootMargin: '360px 0px'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
load: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const sentinel = ref<HTMLElement | null>(null);
|
||||||
|
let observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
function disconnectObserver() {
|
||||||
|
observer?.disconnect();
|
||||||
|
observer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeSentinel() {
|
||||||
|
disconnectObserver();
|
||||||
|
|
||||||
|
if (!props.active || props.disabled || !sentinel.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
emit('load');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
emit('load');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: props.rootMargin }
|
||||||
|
);
|
||||||
|
observer.observe(sentinel.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void nextTick(observeSentinel);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(disconnectObserver);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.active, props.disabled, props.rootMargin, sentinel.value],
|
||||||
|
() => {
|
||||||
|
void nextTick(observeSentinel);
|
||||||
|
},
|
||||||
|
{ flush: 'post' }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="active" ref="sentinel" class="load-more-sentinel" aria-hidden="true"></div>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let openModalCount = 0;
|
||||||
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue';
|
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, useId, watch } from 'vue';
|
||||||
import { iconClose } from '../icons';
|
import { iconClose } from '../icons';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -25,7 +29,7 @@ const emit = defineEmits<{
|
|||||||
close: [];
|
close: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const titleId = `modal-title-${Math.random().toString(36).slice(2)}`;
|
const titleId = useId();
|
||||||
const dialog = ref<HTMLElement | null>(null);
|
const dialog = ref<HTMLElement | null>(null);
|
||||||
const modalBody = ref<HTMLElement | null>(null);
|
const modalBody = ref<HTMLElement | null>(null);
|
||||||
const closeButton = ref<HTMLButtonElement | null>(null);
|
const closeButton = ref<HTMLButtonElement | null>(null);
|
||||||
@@ -54,11 +58,15 @@ const bodyFallbackSelector = [
|
|||||||
].join(',');
|
].join(',');
|
||||||
|
|
||||||
function lockPage() {
|
function lockPage() {
|
||||||
|
openModalCount += 1;
|
||||||
document.body.classList.add('lock-scroll');
|
document.body.classList.add('lock-scroll');
|
||||||
}
|
}
|
||||||
|
|
||||||
function unlockPage() {
|
function unlockPage() {
|
||||||
document.body.classList.remove('lock-scroll');
|
openModalCount = Math.max(0, openModalCount - 1);
|
||||||
|
if (openModalCount === 0) {
|
||||||
|
document.body.classList.remove('lock-scroll');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreFocus() {
|
function restoreFocus() {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
} from '../icons';
|
} from '../icons';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
getAuthToken,
|
|
||||||
moderationUpdateEvent,
|
moderationUpdateEvent,
|
||||||
notificationWebSocketUrl,
|
notificationWebSocketUrl,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
@@ -92,7 +91,7 @@ function disconnectNotifications() {
|
|||||||
|
|
||||||
function scheduleReconnect() {
|
function scheduleReconnect() {
|
||||||
clearReconnectTimer();
|
clearReconnectTimer();
|
||||||
if (stopped || !props.currentUser || !getAuthToken()) {
|
if (stopped || !props.currentUser) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +117,7 @@ function isNotificationWsMessage(value: unknown): value is NotificationWsMessage
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function connectNotifications() {
|
async function connectNotifications() {
|
||||||
if (!props.currentUser || !getAuthToken() || typeof WebSocket === 'undefined') {
|
if (!props.currentUser || typeof WebSocket === 'undefined') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
export type SwitchGroupOption = {
|
export type SwitchGroupOption = {
|
||||||
value: string;
|
value: string | number;
|
||||||
label: string;
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
modelValue: string[];
|
modelValue: Array<string | number>;
|
||||||
options: SwitchGroupOption[];
|
options: SwitchGroupOption[];
|
||||||
|
layout?: 'inline' | 'grid';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: string[]];
|
'update:modelValue': [value: Array<string | number>];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function optionId(index: number) {
|
function optionId(index: number) {
|
||||||
return `${props.id}-${index}`;
|
return `${props.id}-${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSelected(value: string) {
|
function isSelected(value: string | number) {
|
||||||
return props.modelValue.includes(value);
|
return props.modelValue.includes(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateOption(value: string, event: Event) {
|
function updateOption(value: string | number, event: Event) {
|
||||||
if (!(event.target instanceof HTMLInputElement)) return;
|
if (!(event.target instanceof HTMLInputElement)) return;
|
||||||
|
|
||||||
const { checked } = event.target;
|
const { checked } = event.target;
|
||||||
@@ -43,14 +46,23 @@ function updateOption(value: string, event: Event) {
|
|||||||
<template>
|
<template>
|
||||||
<fieldset class="switch-group">
|
<fieldset class="switch-group">
|
||||||
<legend>{{ label }}</legend>
|
<legend>{{ label }}</legend>
|
||||||
<div class="switch-group__options">
|
<div class="switch-group__options" :class="{ 'switch-group__options--grid': layout === 'grid' }">
|
||||||
<label v-for="(option, index) in options" :key="option.value" class="switch-control switch-control--stacked">
|
<label
|
||||||
<span class="switch-control__label">{{ option.label }}</span>
|
v-for="(option, index) in options"
|
||||||
|
:key="option.value"
|
||||||
|
class="switch-control switch-control--stacked"
|
||||||
|
:class="{ 'switch-control--disabled': option.disabled }"
|
||||||
|
>
|
||||||
|
<span class="switch-control__copy">
|
||||||
|
<span class="switch-control__label">{{ option.label }}</span>
|
||||||
|
<span v-if="option.description" class="switch-control__description">{{ option.description }}</span>
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
:id="optionId(index)"
|
:id="optionId(index)"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="isSelected(option.value)"
|
:checked="isSelected(option.value)"
|
||||||
:value="option.value"
|
:value="option.value"
|
||||||
|
:disabled="option.disabled"
|
||||||
@change="updateOption(option.value, $event)"
|
@change="updateOption(option.value, $event)"
|
||||||
/>
|
/>
|
||||||
<span class="switch-track" aria-hidden="true"></span>
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
|
|||||||
@@ -33,12 +33,14 @@ const props = withDefaults(
|
|||||||
creating?: boolean;
|
creating?: boolean;
|
||||||
createLabel?: string;
|
createLabel?: string;
|
||||||
dropdownStrategy?: DropdownStrategy;
|
dropdownStrategy?: DropdownStrategy;
|
||||||
|
clearable?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
multiple: true,
|
multiple: true,
|
||||||
max: 0,
|
max: 0,
|
||||||
allowCreate: false,
|
allowCreate: false,
|
||||||
creating: false
|
creating: false,
|
||||||
|
clearable: false
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -167,6 +169,12 @@ function updateValue(values: string[]) {
|
|||||||
|
|
||||||
function selectOption(value: string) {
|
function selectOption(value: string) {
|
||||||
if (!props.multiple) {
|
if (!props.multiple) {
|
||||||
|
if (props.clearable && selectedValues.value.has(value)) {
|
||||||
|
updateValue([]);
|
||||||
|
closeDropdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateValue([value]);
|
updateValue([value]);
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { createI18n } from 'vue-i18n';
|
|||||||
import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings';
|
import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings';
|
||||||
|
|
||||||
export { defaultLocale } from '../../system-wordings';
|
export { defaultLocale } from '../../system-wordings';
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
|
let browserApiBaseUrl = 'http://localhost:3001';
|
||||||
|
let serverApiBaseUrl = 'http://localhost:3001';
|
||||||
const localeStorageKey = 'pokopia_locale';
|
const localeStorageKey = 'pokopia_locale';
|
||||||
const localeChangeEvent = 'pokopia-locale-change';
|
const localeChangeEvent = 'pokopia-locale-change';
|
||||||
|
|
||||||
@@ -17,15 +18,52 @@ type SystemWordingsResponse = {
|
|||||||
|
|
||||||
export type MessageKey = keyof typeof messages.en;
|
export type MessageKey = keyof typeof messages.en;
|
||||||
|
|
||||||
export const i18n = createI18n({
|
export function createPokopiaI18n(initialLocale = readStoredLocale()) {
|
||||||
legacy: false,
|
return createI18n({
|
||||||
globalInjection: true,
|
legacy: false,
|
||||||
locale: readStoredLocale(),
|
globalInjection: true,
|
||||||
fallbackLocale: defaultLocale,
|
locale: initialLocale || defaultLocale,
|
||||||
messages
|
fallbackLocale: defaultLocale,
|
||||||
});
|
messages
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function readStoredLocale(): string {
|
type PokopiaI18n = ReturnType<typeof createPokopiaI18n>;
|
||||||
|
let activeI18n: PokopiaI18n | null = null;
|
||||||
|
|
||||||
|
export function setActiveI18n(instance: PokopiaI18n): void {
|
||||||
|
activeI18n = instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSystemWordingsApiBaseUrl(value: unknown): void {
|
||||||
|
setSystemWordingsApiBaseUrls({ browser: value, server: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSystemWordingsApiBaseUrls(value: { browser?: unknown; server?: unknown }): void {
|
||||||
|
const browserBaseUrl = normalizeApiBaseUrl(value.browser);
|
||||||
|
const serverBaseUrl = normalizeApiBaseUrl(value.server);
|
||||||
|
|
||||||
|
if (browserBaseUrl) {
|
||||||
|
browserApiBaseUrl = browserBaseUrl;
|
||||||
|
}
|
||||||
|
if (serverBaseUrl) {
|
||||||
|
serverApiBaseUrl = serverBaseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApiBaseUrl(value: unknown): string | null {
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
return value.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeApiBaseUrl(): string {
|
||||||
|
return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredLocale(): string {
|
||||||
if (typeof localStorage === 'undefined') {
|
if (typeof localStorage === 'undefined') {
|
||||||
return defaultLocale;
|
return defaultLocale;
|
||||||
}
|
}
|
||||||
@@ -35,11 +73,11 @@ function readStoredLocale(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function globalLocaleRef() {
|
function globalLocaleRef() {
|
||||||
return i18n.global.locale as unknown as { value: string };
|
return activeI18n?.global.locale as unknown as { value: string } | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentLocale(): string {
|
export function getCurrentLocale(): string {
|
||||||
return globalLocaleRef().value || defaultLocale;
|
return globalLocaleRef()?.value || defaultLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMessageTree(value: SystemWordingTree[string] | undefined): value is SystemWordingTree {
|
function isMessageTree(value: SystemWordingTree[string] | undefined): value is SystemWordingTree {
|
||||||
@@ -68,6 +106,11 @@ function builtInMessagesFor(locale: string): SystemWordingTree {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSystemWordings(locale = getCurrentLocale(), force = false): Promise<void> {
|
export async function loadSystemWordings(locale = getCurrentLocale(), force = false): Promise<void> {
|
||||||
|
if (!activeI18n) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetI18n = activeI18n;
|
||||||
const targetLocale = locale || defaultLocale;
|
const targetLocale = locale || defaultLocale;
|
||||||
if (!force && loadedWordingLocales.has(targetLocale)) {
|
if (!force && loadedWordingLocales.has(targetLocale)) {
|
||||||
return;
|
return;
|
||||||
@@ -81,19 +124,19 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa
|
|||||||
|
|
||||||
const loadPromise = (async () => {
|
const loadPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${apiBaseUrl}/api/system-wordings?locale=${encodeURIComponent(targetLocale)}`);
|
const response = await fetch(`${activeApiBaseUrl()}/api/system-wordings?locale=${encodeURIComponent(targetLocale)}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`System wordings failed (${response.status})`);
|
throw new Error(`System wordings failed (${response.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as SystemWordingsResponse;
|
const data = (await response.json()) as SystemWordingsResponse;
|
||||||
i18n.global.setLocaleMessage(
|
targetI18n.global.setLocaleMessage(
|
||||||
targetLocale,
|
targetLocale,
|
||||||
mergeMessageTrees(messages[defaultLocale], messages[targetLocale], data.messages) as never
|
mergeMessageTrees(messages[defaultLocale], messages[targetLocale], data.messages) as never
|
||||||
);
|
);
|
||||||
loadedWordingLocales.add(targetLocale);
|
loadedWordingLocales.add(targetLocale);
|
||||||
} catch {
|
} catch {
|
||||||
i18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never);
|
targetI18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never);
|
||||||
} finally {
|
} finally {
|
||||||
pendingWordingLoads.delete(targetLocale);
|
pendingWordingLoads.delete(targetLocale);
|
||||||
}
|
}
|
||||||
@@ -105,7 +148,10 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa
|
|||||||
|
|
||||||
export function setCurrentLocale(locale: string): void {
|
export function setCurrentLocale(locale: string): void {
|
||||||
const nextLocale = locale || defaultLocale;
|
const nextLocale = locale || defaultLocale;
|
||||||
globalLocaleRef().value = nextLocale;
|
const localeRef = globalLocaleRef();
|
||||||
|
if (localeRef) {
|
||||||
|
localeRef.value = nextLocale;
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
document.documentElement.lang = nextLocale;
|
document.documentElement.lang = nextLocale;
|
||||||
@@ -121,8 +167,10 @@ export function setCurrentLocale(locale: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function onLocaleChange(callback: () => void): () => void {
|
export function onLocaleChange(callback: () => void): () => void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener(localeChangeEvent, callback);
|
window.addEventListener(localeChangeEvent, callback);
|
||||||
return () => window.removeEventListener(localeChangeEvent, callback);
|
return () => window.removeEventListener(localeChangeEvent, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentLocale(getCurrentLocale());
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { createApp } from 'vue';
|
|
||||||
import App from './App.vue';
|
|
||||||
import { i18n } from './i18n';
|
|
||||||
import { router } from './router';
|
|
||||||
import { setupSeo } from './seo';
|
|
||||||
import './styles/main.css';
|
|
||||||
|
|
||||||
setupSeo(router);
|
|
||||||
createApp(App).use(i18n).use(router).mount('#app');
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
|
||||||
import HomeView from '../views/HomeView.vue';
|
|
||||||
import PokemonList from '../views/PokemonList.vue';
|
|
||||||
import PokemonDetail from '../views/PokemonDetail.vue';
|
|
||||||
import HabitatList from '../views/HabitatList.vue';
|
|
||||||
import HabitatDetail from '../views/HabitatDetail.vue';
|
|
||||||
import ItemsList from '../views/ItemsList.vue';
|
|
||||||
import ItemDetail from '../views/ItemDetail.vue';
|
|
||||||
import AncientArtifactList from '../views/AncientArtifactList.vue';
|
|
||||||
import AncientArtifactDetail from '../views/AncientArtifactDetail.vue';
|
|
||||||
import RecipeList from '../views/RecipeList.vue';
|
|
||||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
|
||||||
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
|
||||||
import LifePostDetail from '../views/LifePostDetail.vue';
|
|
||||||
import LifeView from '../views/LifeView.vue';
|
|
||||||
import DishView from '../views/DishView.vue';
|
|
||||||
import ProjectUpdatesView from '../views/ProjectUpdatesView.vue';
|
|
||||||
import LegalView from '../views/LegalView.vue';
|
|
||||||
import ComingSoonView from '../views/ComingSoonView.vue';
|
|
||||||
import AdminView from '../views/AdminView.vue';
|
|
||||||
import ForgotPasswordView from '../views/ForgotPasswordView.vue';
|
|
||||||
import LoginView from '../views/LoginView.vue';
|
|
||||||
import UserProfileView from '../views/UserProfileView.vue';
|
|
||||||
import RegisterView from '../views/RegisterView.vue';
|
|
||||||
import ResetPasswordView from '../views/ResetPasswordView.vue';
|
|
||||||
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
|
||||||
import { api, getAuthToken, setAuthToken } from '../services/api';
|
|
||||||
import type { RouteSeoConfig } from '../seo';
|
|
||||||
|
|
||||||
const seo = (config: RouteSeoConfig) => config;
|
|
||||||
|
|
||||||
export const router = createRouter({
|
|
||||||
history: createWebHistory(),
|
|
||||||
routes: [
|
|
||||||
{ path: '/', name: 'home', component: HomeView, meta: { seo: seo({ titleKey: 'pages.home.title', descriptionKey: 'pages.home.subtitle', canonicalPath: '/' }) } },
|
|
||||||
{
|
|
||||||
path: '/pokemon',
|
|
||||||
name: 'pokemon-list',
|
|
||||||
component: PokemonList,
|
|
||||||
props: { eventOnly: false },
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.pokemon.title', descriptionKey: 'pages.pokemon.subtitle' }) }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/pokemon/new',
|
|
||||||
name: 'pokemon-new',
|
|
||||||
component: PokemonList,
|
|
||||||
props: { eventOnly: false },
|
|
||||||
meta: {
|
|
||||||
requiredPermission: 'pokemon.create',
|
|
||||||
editorModal: true,
|
|
||||||
seo: seo({ titleKey: 'pages.pokemon.newTitle', descriptionKey: 'pages.pokemon.editSubtitle', canonicalPath: '/pokemon', noindex: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/event-pokemon',
|
|
||||||
name: 'event-pokemon-list',
|
|
||||||
component: PokemonList,
|
|
||||||
props: { eventOnly: true },
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.eventPokemon.title', descriptionKey: 'pages.eventPokemon.subtitle', canonicalPath: '/event-pokemon' }) }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/event-pokemon/new',
|
|
||||||
name: 'event-pokemon-new',
|
|
||||||
component: PokemonList,
|
|
||||||
props: { eventOnly: true },
|
|
||||||
meta: {
|
|
||||||
requiredPermission: 'pokemon.create',
|
|
||||||
editorModal: true,
|
|
||||||
seo: seo({ titleKey: 'pages.eventPokemon.newTitle', descriptionKey: 'pages.eventPokemon.editSubtitle', canonicalPath: '/event-pokemon', noindex: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/pokemon/:id/edit',
|
|
||||||
name: 'pokemon-edit',
|
|
||||||
component: PokemonDetail,
|
|
||||||
meta: {
|
|
||||||
requiredPermission: 'pokemon.update',
|
|
||||||
editorModal: true,
|
|
||||||
seo: seo({
|
|
||||||
titleKey: 'pages.pokemon.editKicker',
|
|
||||||
descriptionKey: 'pages.pokemon.editSubtitle',
|
|
||||||
canonicalPath: (route) => `/pokemon/${String(route.params.id)}`,
|
|
||||||
noindex: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail, meta: { seo: seo({ titleKey: 'pages.pokemon.detailKicker', descriptionKey: 'pages.pokemon.subtitle' }) } },
|
|
||||||
{
|
|
||||||
path: '/habitats',
|
|
||||||
name: 'habitat-list',
|
|
||||||
component: HabitatList,
|
|
||||||
props: { eventOnly: false },
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }) }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/habitats/new',
|
|
||||||
name: 'habitat-new',
|
|
||||||
component: HabitatList,
|
|
||||||
props: { eventOnly: false },
|
|
||||||
meta: {
|
|
||||||
requiredPermission: 'habitats.create',
|
|
||||||
editorModal: true,
|
|
||||||
seo: seo({ titleKey: 'pages.habitats.newTitle', descriptionKey: 'pages.habitats.editSubtitle', canonicalPath: '/habitats', noindex: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/event-habitats',
|
|
||||||
name: 'event-habitat-list',
|
|
||||||
component: HabitatList,
|
|
||||||
props: { eventOnly: true },
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.eventHabitats.title', descriptionKey: 'pages.eventHabitats.subtitle', canonicalPath: '/event-habitats' }) }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/event-habitats/new',
|
|
||||||
name: 'event-habitat-new',
|
|
||||||
component: HabitatList,
|
|
||||||
props: { eventOnly: true },
|
|
||||||
meta: {
|
|
||||||
requiredPermission: 'habitats.create',
|
|
||||||
editorModal: true,
|
|
||||||
seo: seo({ titleKey: 'pages.eventHabitats.newTitle', descriptionKey: 'pages.eventHabitats.editSubtitle', canonicalPath: '/event-habitats', noindex: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/habitats/:id/edit',
|
|
||||||
name: 'habitat-edit',
|
|
||||||
component: HabitatDetail,
|
|
||||||
meta: {
|
|
||||||
requiredPermission: 'habitats.update',
|
|
||||||
editorModal: true,
|
|
||||||
seo: seo({
|
|
||||||
titleKey: 'pages.habitats.detailKicker',
|
|
||||||
descriptionKey: 'pages.habitats.editSubtitle',
|
|
||||||
canonicalPath: (route) => `/habitats/${String(route.params.id)}`,
|
|
||||||
noindex: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail, meta: { seo: seo({ titleKey: 'pages.habitats.detailKicker', descriptionKey: 'pages.habitats.subtitle' }) } },
|
|
||||||
{
|
|
||||||
path: '/items',
|
|
||||||
name: 'item-list',
|
|
||||||
component: ItemsList,
|
|
||||||
props: { eventOnly: false },
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }) }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/items/new',
|
|
||||||
name: 'item-new',
|
|
||||||
component: ItemsList,
|
|
||||||
props: { eventOnly: false },
|
|
||||||
meta: {
|
|
||||||
requiredPermission: 'items.create',
|
|
||||||
editorModal: true,
|
|
||||||
seo: seo({ titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/items', noindex: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/event-items',
|
|
||||||
name: 'event-item-list',
|
|
||||||
component: ItemsList,
|
|
||||||
props: { eventOnly: true },
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.eventItems.title', descriptionKey: 'pages.eventItems.subtitle', canonicalPath: '/event-items' }) }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/event-items/new',
|
|
||||||
name: 'event-item-new',
|
|
||||||
component: ItemsList,
|
|
||||||
props: { eventOnly: true },
|
|
||||||
meta: {
|
|
||||||
requiredPermission: 'items.create',
|
|
||||||
editorModal: true,
|
|
||||||
seo: seo({ titleKey: 'pages.eventItems.newTitle', descriptionKey: 'pages.eventItems.editSubtitle', canonicalPath: '/event-items', noindex: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/items/:id/edit',
|
|
||||||
name: 'item-edit',
|
|
||||||
component: ItemDetail,
|
|
||||||
meta: {
|
|
||||||
requiredPermission: 'items.update',
|
|
||||||
editorModal: true,
|
|
||||||
seo: seo({
|
|
||||||
titleKey: 'pages.items.editKicker',
|
|
||||||
descriptionKey: 'pages.items.editSubtitle',
|
|
||||||
canonicalPath: (route) => `/items/${String(route.params.id)}`,
|
|
||||||
noindex: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ path: '/items/:id', name: 'item-detail', component: ItemDetail, meta: { seo: seo({ titleKey: 'pages.items.detailKicker', descriptionKey: 'pages.items.subtitle' }) } },
|
|
||||||
{
|
|
||||||
path: '/ancient-artifacts',
|
|
||||||
name: 'ancient-artifact-list',
|
|
||||||
component: AncientArtifactList,
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.title', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/ancient-artifacts/new',
|
|
||||||
name: 'ancient-artifact-new',
|
|
||||||
component: AncientArtifactList,
|
|
||||||
meta: {
|
|
||||||
requiredPermission: 'ancient-artifacts.create',
|
|
||||||
editorModal: true,
|
|
||||||
seo: seo({
|
|
||||||
titleKey: 'pages.ancientArtifacts.newTitle',
|
|
||||||
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
|
|
||||||
canonicalPath: '/ancient-artifacts',
|
|
||||||
noindex: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/ancient-artifacts/:id/edit',
|
|
||||||
name: 'ancient-artifact-edit',
|
|
||||||
component: AncientArtifactDetail,
|
|
||||||
meta: {
|
|
||||||
requiredPermission: 'ancient-artifacts.update',
|
|
||||||
editorModal: true,
|
|
||||||
seo: seo({
|
|
||||||
titleKey: 'pages.ancientArtifacts.editKicker',
|
|
||||||
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
|
|
||||||
canonicalPath: (route) => `/ancient-artifacts/${String(route.params.id)}`,
|
|
||||||
noindex: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/ancient-artifacts/:id',
|
|
||||||
name: 'ancient-artifact-detail',
|
|
||||||
component: AncientArtifactDetail,
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.detailKicker', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
|
|
||||||
},
|
|
||||||
{ path: '/recipes', name: 'recipe-list', component: RecipeList, meta: { seo: seo({ titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }) } },
|
|
||||||
{
|
|
||||||
path: '/recipes/new',
|
|
||||||
name: 'recipe-new',
|
|
||||||
component: RecipeList,
|
|
||||||
meta: {
|
|
||||||
requiredPermission: 'recipes.create',
|
|
||||||
editorModal: true,
|
|
||||||
seo: seo({ titleKey: 'pages.recipes.newTitle', descriptionKey: 'pages.recipes.editSubtitle', canonicalPath: '/recipes', noindex: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/recipes/:id/edit',
|
|
||||||
name: 'recipe-edit',
|
|
||||||
component: RecipeDetail,
|
|
||||||
meta: {
|
|
||||||
requiredPermission: 'recipes.update',
|
|
||||||
editorModal: true,
|
|
||||||
seo: seo({
|
|
||||||
titleKey: 'pages.recipes.editKicker',
|
|
||||||
descriptionKey: 'pages.recipes.editSubtitle',
|
|
||||||
canonicalPath: (route) => `/recipes/${String(route.params.id)}`,
|
|
||||||
noindex: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail, meta: { seo: seo({ titleKey: 'pages.recipes.detailKicker', descriptionKey: 'pages.recipes.subtitle' }) } },
|
|
||||||
{
|
|
||||||
path: '/automation',
|
|
||||||
name: 'automation',
|
|
||||||
component: ComingSoonView,
|
|
||||||
props: { page: 'automation' },
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.automation.title', descriptionKey: 'pages.comingSoon.sections.automation.subtitle', noindex: true }) }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/dish',
|
|
||||||
name: 'dish',
|
|
||||||
component: DishView,
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.dish.title', descriptionKey: 'pages.dish.subtitle' }) }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/events',
|
|
||||||
name: 'events',
|
|
||||||
component: ComingSoonView,
|
|
||||||
props: { page: 'events' },
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.events.title', descriptionKey: 'pages.comingSoon.sections.events.subtitle', noindex: true }) }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/actions',
|
|
||||||
name: 'actions',
|
|
||||||
component: ComingSoonView,
|
|
||||||
props: { page: 'actions' },
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.actions.title', descriptionKey: 'pages.comingSoon.sections.actions.subtitle', noindex: true }) }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/dream-island',
|
|
||||||
name: 'dream-island',
|
|
||||||
component: ComingSoonView,
|
|
||||||
props: { page: 'dreamIsland' },
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.dreamIsland.title', descriptionKey: 'pages.comingSoon.sections.dreamIsland.subtitle', noindex: true }) }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/clothes',
|
|
||||||
name: 'clothes',
|
|
||||||
component: ComingSoonView,
|
|
||||||
props: { page: 'clothes' },
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.clothes.title', descriptionKey: 'pages.comingSoon.sections.clothes.subtitle', noindex: true }) }
|
|
||||||
},
|
|
||||||
{ path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } },
|
|
||||||
{ path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } },
|
|
||||||
{ path: '/life/:id', component: LifePostDetail, meta: { seo: seo({ titleKey: 'pages.life.detailTitle', descriptionKey: 'pages.life.detailSubtitle' }) } },
|
|
||||||
{
|
|
||||||
path: '/project-updates',
|
|
||||||
component: ProjectUpdatesView,
|
|
||||||
meta: {
|
|
||||||
seo: seo({
|
|
||||||
titleKey: 'pages.projectUpdates.title',
|
|
||||||
descriptionKey: 'pages.projectUpdates.subtitle',
|
|
||||||
canonicalPath: '/project-updates'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/privacy-policy',
|
|
||||||
component: LegalView,
|
|
||||||
props: { page: 'privacy' },
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.legal.privacy.title', descriptionKey: 'pages.legal.privacy.subtitle', canonicalPath: '/privacy-policy' }) }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/terms-of-service',
|
|
||||||
component: LegalView,
|
|
||||||
props: { page: 'terms' },
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.legal.terms.title', descriptionKey: 'pages.legal.terms.subtitle', canonicalPath: '/terms-of-service' }) }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/disclaimers',
|
|
||||||
component: LegalView,
|
|
||||||
props: { page: 'disclaimers' },
|
|
||||||
meta: { seo: seo({ titleKey: 'pages.legal.disclaimers.title', descriptionKey: 'pages.legal.disclaimers.subtitle', canonicalPath: '/disclaimers' }) }
|
|
||||||
},
|
|
||||||
{ path: '/admin', component: AdminView, meta: { requiredPermission: 'admin.access', seo: seo({ titleKey: 'pages.admin.title', descriptionKey: 'pages.admin.subtitle', noindex: true }) } },
|
|
||||||
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true, seo: seo({ titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.subtitle', noindex: true }) } },
|
|
||||||
{ path: '/profile/:id', component: UserProfileView, meta: { seo: seo({ titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.publicSubtitle' }) } },
|
|
||||||
{ path: '/login', component: LoginView, meta: { seo: seo({ titleKey: 'auth.loginTitle', descriptionKey: 'auth.loginSubtitle', noindex: true }) } },
|
|
||||||
{ path: '/forgot-password', component: ForgotPasswordView, meta: { seo: seo({ titleKey: 'auth.requestResetTitle', descriptionKey: 'auth.requestResetSubtitle', noindex: true }) } },
|
|
||||||
{ path: '/reset-password', component: ResetPasswordView, meta: { seo: seo({ titleKey: 'auth.resetTitle', descriptionKey: 'auth.resetSubtitle', noindex: true }) } },
|
|
||||||
{ path: '/register', component: RegisterView, meta: { seo: seo({ titleKey: 'auth.registerTitle', descriptionKey: 'auth.registerSubtitle', noindex: true }) } },
|
|
||||||
{ path: '/verify-email', component: VerifyEmailView, meta: { seo: seo({ titleKey: 'auth.verifyTitle', descriptionKey: 'auth.verifySubtitle', noindex: true }) } }
|
|
||||||
],
|
|
||||||
scrollBehavior(to, from, savedPosition) {
|
|
||||||
if (savedPosition) return savedPosition;
|
|
||||||
if (to.meta.editorModal === true || from.meta.editorModal === true) return false;
|
|
||||||
return { top: 0 };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.beforeEach(async (to) => {
|
|
||||||
const requiredPermissions = to.matched
|
|
||||||
.map((record) => record.meta.requiredPermission)
|
|
||||||
.filter((permission): permission is string => typeof permission === 'string');
|
|
||||||
const requiredAnyPermissions = to.matched.flatMap((record) =>
|
|
||||||
Array.isArray(record.meta.requiredAnyPermission)
|
|
||||||
? record.meta.requiredAnyPermission.filter((permission): permission is string => typeof permission === 'string')
|
|
||||||
: []
|
|
||||||
);
|
|
||||||
const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true) || requiredPermissions.length > 0 || requiredAnyPermissions.length > 0;
|
|
||||||
const requiresAuth = requiresVerified || to.matched.some((record) => record.meta.requiresAuth === true);
|
|
||||||
|
|
||||||
if (!requiresAuth) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!getAuthToken()) {
|
|
||||||
return { path: '/login', query: { redirect: to.fullPath } };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.me();
|
|
||||||
if (requiresVerified && !response.user.emailVerified) {
|
|
||||||
return { path: '/login', query: { redirect: to.fullPath } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissionSet = new Set(response.user.permissions);
|
|
||||||
if (requiredPermissions.some((permission) => !permissionSet.has(permission))) {
|
|
||||||
return { path: '/pokemon' };
|
|
||||||
}
|
|
||||||
if (requiredAnyPermissions.length && !requiredAnyPermissions.some((permission) => permissionSet.has(permission))) {
|
|
||||||
return { path: '/pokemon' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
setAuthToken(null);
|
|
||||||
return { path: '/login', query: { redirect: to.fullPath } };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router';
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
import { getCurrentLocale, i18n, onLocaleChange } from './i18n';
|
import { getCurrentLocale } from './i18n';
|
||||||
|
import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings';
|
||||||
|
|
||||||
const siteName = 'Pokopia Wiki';
|
const siteName = 'Pokopia Wiki';
|
||||||
const defaultCanonicalPath = '/';
|
const defaultCanonicalPath = '/';
|
||||||
const defaultImagePath = '/seo/pokopia-hero.jpg';
|
const defaultImagePath = '/seo/pokopia-hero.jpg';
|
||||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||||
|
let runtimeSiteUrl: string | null = null;
|
||||||
|
|
||||||
type TranslationValues = Record<string, string | number>;
|
type TranslationValues = Record<string, string | number>;
|
||||||
|
type Translator = (key: string, values?: TranslationValues) => string;
|
||||||
|
|
||||||
export type RouteSeoConfig = {
|
export type RouteSeoConfig = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -26,12 +29,34 @@ export type SeoConfig = {
|
|||||||
noindex?: boolean;
|
noindex?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const translate = i18n.global.t as (key: string, values?: TranslationValues) => string;
|
export type ResolvedSeoConfig = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
canonicalUrl: string;
|
||||||
|
imageUrl: string;
|
||||||
|
robots: string;
|
||||||
|
locale: string;
|
||||||
|
structuredData: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = systemWordingMessages as unknown as Record<string, SystemWordingTree>;
|
||||||
|
let activeTranslator: Translator | null = null;
|
||||||
|
let currentSeo: ResolvedSeoConfig | null = null;
|
||||||
|
const seoListeners = new Set<(seo: ResolvedSeoConfig) => void>();
|
||||||
|
|
||||||
|
export function setSeoTranslator(translator: Translator): void {
|
||||||
|
activeTranslator = translator;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setConfiguredSiteUrl(value: unknown): void {
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
runtimeSiteUrl = normalizeSiteUrl(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function configuredSiteUrl(): string {
|
function configuredSiteUrl(): string {
|
||||||
const fromEnv = import.meta.env.VITE_SITE_URL;
|
if (runtimeSiteUrl) {
|
||||||
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') {
|
return runtimeSiteUrl;
|
||||||
return normalizeSiteUrl(fromEnv);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && window.location.origin) {
|
if (typeof window !== 'undefined' && window.location.origin) {
|
||||||
@@ -68,115 +93,129 @@ function metaTitle(title?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function metaDescription(description?: string): string {
|
function metaDescription(description?: string): string {
|
||||||
return description?.trim() || translate('seo.siteDescription');
|
return description?.trim() || translateSeo('seo.siteDescription');
|
||||||
}
|
}
|
||||||
|
|
||||||
function localeForOpenGraph(locale: string): string {
|
function builtInTranslate(key: string, values: TranslationValues = {}): string {
|
||||||
if (locale === 'en') {
|
let message: SystemWordingTree[string] | undefined = messages[defaultLocale];
|
||||||
return 'en_US';
|
for (const part of key.split('.')) {
|
||||||
|
message = typeof message === 'object' && message !== null ? message[part] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return locale.replace('-', '_');
|
if (typeof message !== 'string') {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(values).reduce((nextMessage, [name, value]) => nextMessage.replaceAll(`{${name}}`, String(value)), message);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setMeta(attribute: 'name' | 'property', key: string, content: string): void {
|
function translateSeo(key: string, values?: TranslationValues, translator = activeTranslator): string {
|
||||||
let element = document.head.querySelector<HTMLMetaElement>(`meta[${attribute}="${key}"]`);
|
return translator ? translator(key, values) : builtInTranslate(key, values);
|
||||||
if (!element) {
|
|
||||||
element = document.createElement('meta');
|
|
||||||
element.setAttribute(attribute, key);
|
|
||||||
document.head.appendChild(element);
|
|
||||||
}
|
|
||||||
element.setAttribute('content', content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCanonical(href: string): void {
|
export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
|
||||||
let element = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
|
|
||||||
if (!element) {
|
|
||||||
element = document.createElement('link');
|
|
||||||
element.setAttribute('rel', 'canonical');
|
|
||||||
document.head.appendChild(element);
|
|
||||||
}
|
|
||||||
element.setAttribute('href', href);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStructuredData(title: string, description: string, canonicalUrl: string): void {
|
|
||||||
let element = document.getElementById('pokopia-structured-data') as HTMLScriptElement | null;
|
|
||||||
if (!element) {
|
|
||||||
element = document.createElement('script');
|
|
||||||
element.id = 'pokopia-structured-data';
|
|
||||||
element.type = 'application/ld+json';
|
|
||||||
document.head.appendChild(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
element.textContent = JSON.stringify({
|
|
||||||
'@context': 'https://schema.org',
|
|
||||||
'@type': 'WebPage',
|
|
||||||
name: title,
|
|
||||||
description,
|
|
||||||
url: canonicalUrl,
|
|
||||||
isPartOf: {
|
|
||||||
'@type': 'WebSite',
|
|
||||||
name: siteName,
|
|
||||||
url: absoluteUrl('/')
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applySeo(config: SeoConfig = {}): void {
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = metaTitle(config.title);
|
const title = metaTitle(config.title);
|
||||||
const description = metaDescription(config.description);
|
const description = metaDescription(config.description);
|
||||||
const canonicalUrl = absoluteUrl(normalizePath(config.canonicalPath));
|
const canonicalUrl = absoluteUrl(normalizePath(config.canonicalPath));
|
||||||
const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath);
|
const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath);
|
||||||
const noindex = config.noindex === true;
|
const robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow';
|
||||||
const robots = noindex ? 'noindex, nofollow' : 'index, follow';
|
|
||||||
const locale = getCurrentLocale();
|
const locale = getCurrentLocale();
|
||||||
|
|
||||||
document.title = title;
|
return {
|
||||||
setMeta('name', 'description', description);
|
title,
|
||||||
setMeta('name', 'robots', robots);
|
description,
|
||||||
setMeta('name', 'twitter:card', 'summary_large_image');
|
canonicalUrl,
|
||||||
setMeta('name', 'twitter:title', title);
|
imageUrl,
|
||||||
setMeta('name', 'twitter:description', description);
|
robots,
|
||||||
setMeta('name', 'twitter:image', imageUrl);
|
locale,
|
||||||
setMeta('property', 'og:site_name', siteName);
|
structuredData: {
|
||||||
setMeta('property', 'og:type', 'website');
|
'@context': 'https://schema.org',
|
||||||
setMeta('property', 'og:title', title);
|
'@type': 'WebPage',
|
||||||
setMeta('property', 'og:description', description);
|
name: title,
|
||||||
setMeta('property', 'og:url', canonicalUrl);
|
description,
|
||||||
setMeta('property', 'og:image', imageUrl);
|
url: canonicalUrl,
|
||||||
setMeta('property', 'og:locale', localeForOpenGraph(locale));
|
isPartOf: {
|
||||||
setCanonical(canonicalUrl);
|
'@type': 'WebSite',
|
||||||
setStructuredData(title, description, canonicalUrl);
|
name: siteName,
|
||||||
|
url: absoluteUrl('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void {
|
export function resolvedSeoHead(seo: ResolvedSeoConfig) {
|
||||||
|
return {
|
||||||
|
title: seo.title,
|
||||||
|
htmlAttrs: {
|
||||||
|
lang: seo.locale
|
||||||
|
},
|
||||||
|
meta: [
|
||||||
|
{ key: 'description', name: 'description', content: seo.description },
|
||||||
|
{ key: 'robots', name: 'robots', content: seo.robots },
|
||||||
|
{ key: 'twitter-card', name: 'twitter:card', content: 'summary_large_image' },
|
||||||
|
{ key: 'twitter-title', name: 'twitter:title', content: seo.title },
|
||||||
|
{ key: 'twitter-description', name: 'twitter:description', content: seo.description },
|
||||||
|
{ key: 'twitter-image', name: 'twitter:image', content: seo.imageUrl },
|
||||||
|
{ key: 'og-site-name', property: 'og:site_name', content: siteName },
|
||||||
|
{ key: 'og-type', property: 'og:type', content: 'website' },
|
||||||
|
{ key: 'og-title', property: 'og:title', content: seo.title },
|
||||||
|
{ key: 'og-description', property: 'og:description', content: seo.description },
|
||||||
|
{ key: 'og-url', property: 'og:url', content: seo.canonicalUrl },
|
||||||
|
{ key: 'og-image', property: 'og:image', content: seo.imageUrl },
|
||||||
|
{ key: 'og-locale', property: 'og:locale', content: seo.locale === 'en' ? 'en_US' : seo.locale.replace('-', '_') }
|
||||||
|
],
|
||||||
|
link: [{ key: 'canonical', rel: 'canonical', href: seo.canonicalUrl }],
|
||||||
|
script: [
|
||||||
|
{
|
||||||
|
key: 'pokopia-structured-data',
|
||||||
|
id: 'pokopia-structured-data',
|
||||||
|
type: 'application/ld+json',
|
||||||
|
innerHTML: JSON.stringify(seo.structuredData).replace(/</g, '\\u003C')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routeSeoConfig(route: RouteLocationNormalizedLoaded, translator?: Translator): SeoConfig {
|
||||||
const routeSeo = route.meta.seo as RouteSeoConfig | undefined;
|
const routeSeo = route.meta.seo as RouteSeoConfig | undefined;
|
||||||
const canonicalPath =
|
const canonicalPath =
|
||||||
typeof routeSeo?.canonicalPath === 'function'
|
typeof routeSeo?.canonicalPath === 'function'
|
||||||
? routeSeo.canonicalPath(route)
|
? routeSeo.canonicalPath(route)
|
||||||
: routeSeo?.canonicalPath ?? route.path ?? defaultCanonicalPath;
|
: routeSeo?.canonicalPath ?? route.path ?? defaultCanonicalPath;
|
||||||
|
const requiresPrivateAccess = route.matched.some(
|
||||||
|
(record) =>
|
||||||
|
record.meta.requiresAuth === true ||
|
||||||
|
record.meta.requiresVerified === true ||
|
||||||
|
typeof record.meta.requiredPermission === 'string' ||
|
||||||
|
(Array.isArray(record.meta.requiredAnyPermission) && record.meta.requiredAnyPermission.length > 0)
|
||||||
|
);
|
||||||
|
|
||||||
applySeo({
|
return {
|
||||||
title: routeSeo?.titleKey ? translate(routeSeo.titleKey) : routeSeo?.title,
|
title: routeSeo?.titleKey ? translateSeo(routeSeo.titleKey, undefined, translator) : routeSeo?.title,
|
||||||
description: routeSeo?.descriptionKey ? translate(routeSeo.descriptionKey) : routeSeo?.description,
|
description: routeSeo?.descriptionKey ? translateSeo(routeSeo.descriptionKey, undefined, translator) : routeSeo?.description,
|
||||||
canonicalPath,
|
canonicalPath,
|
||||||
image: routeSeo?.image,
|
image: routeSeo?.image,
|
||||||
noindex: routeSeo?.noindex
|
noindex: routeSeo?.noindex === true || requiresPrivateAccess
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupSeo(router: Router): void {
|
export function resolveRouteSeo(route: RouteLocationNormalizedLoaded, translator?: Translator): ResolvedSeoConfig {
|
||||||
router.afterEach((to) => {
|
return resolveSeo(routeSeoConfig(route, translator));
|
||||||
applyRouteSeo(to);
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
export function onSeoChange(callback: (seo: ResolvedSeoConfig) => void): () => void {
|
||||||
onLocaleChange(() => {
|
seoListeners.add(callback);
|
||||||
applyRouteSeo(router.currentRoute.value);
|
callback(currentSeo ?? resolveSeo());
|
||||||
});
|
return () => seoListeners.delete(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySeo(config: SeoConfig = {}): void {
|
||||||
|
currentSeo = resolveSeo(config);
|
||||||
|
for (const listener of seoListeners) {
|
||||||
|
listener(currentSeo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void {
|
||||||
|
applySeo(routeSeoConfig(route));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { getCurrentLocale } from '../i18n';
|
import { getCurrentLocale } from '../i18n';
|
||||||
|
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
|
let browserApiBaseUrl = 'http://localhost:3001';
|
||||||
const authTokenKey = 'pokopia_auth_token';
|
let serverApiBaseUrl = 'http://localhost:3001';
|
||||||
const authChangeEvent = 'pokopia-auth-change';
|
const authChangeEvent = 'pokopia-auth-change';
|
||||||
|
|
||||||
|
export interface ApiRequestOptions {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
headers?: HeadersInit;
|
||||||
|
}
|
||||||
|
|
||||||
export type TranslationField = 'name' | 'title' | 'details' | 'genus' | 'effect' | 'mosslaxEffect';
|
export type TranslationField = 'name' | 'title' | 'details' | 'genus' | 'effect' | 'mosslaxEffect';
|
||||||
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
|
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
|
||||||
|
|
||||||
@@ -15,6 +20,38 @@ export interface Language {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setApiBaseUrl(value: unknown): void {
|
||||||
|
setApiBaseUrls({ browser: value, server: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setApiBaseUrls(value: { browser?: unknown; server?: unknown }): void {
|
||||||
|
const browserBaseUrl = normalizeApiBaseUrl(value.browser);
|
||||||
|
const serverBaseUrl = normalizeApiBaseUrl(value.server);
|
||||||
|
|
||||||
|
if (browserBaseUrl) {
|
||||||
|
browserApiBaseUrl = browserBaseUrl;
|
||||||
|
}
|
||||||
|
if (serverBaseUrl) {
|
||||||
|
serverApiBaseUrl = serverBaseUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApiBaseUrl(value: unknown): string | null {
|
||||||
|
if (typeof value === 'string' && value.trim() !== '') {
|
||||||
|
return value.trim().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeApiBaseUrl(): string {
|
||||||
|
return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiUrl(path: string): string {
|
||||||
|
return `${activeApiBaseUrl()}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
export type SystemWordingSurface = 'frontend' | 'backend' | 'email';
|
export type SystemWordingSurface = 'frontend' | 'backend' | 'email';
|
||||||
|
|
||||||
export interface SystemWording {
|
export interface SystemWording {
|
||||||
@@ -48,8 +85,11 @@ export interface GameVersion extends NamedEntity {
|
|||||||
|
|
||||||
export interface Skill extends NamedEntity {
|
export interface Skill extends NamedEntity {
|
||||||
hasItemDrop: boolean;
|
hasItemDrop: boolean;
|
||||||
|
hasTrading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TradingPreference = 'like' | 'neutral';
|
||||||
|
|
||||||
export interface PokemonStats {
|
export interface PokemonStats {
|
||||||
hp: number;
|
hp: number;
|
||||||
attack: number;
|
attack: number;
|
||||||
@@ -106,6 +146,19 @@ export interface ProjectUpdatesParams {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ListPage<T> {
|
||||||
|
items: T[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicListParams {
|
||||||
|
cursor?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicListQueryParams = Record<string, string | number | boolean | null | undefined> & PublicListParams;
|
||||||
|
|
||||||
export interface EntityImage {
|
export interface EntityImage {
|
||||||
path: string;
|
path: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -174,6 +227,12 @@ export interface Pokemon extends EditInfo {
|
|||||||
favorite_things: NamedEntity[];
|
favorite_things: NamedEntity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PokemonTradingItem extends NamedEntity {
|
||||||
|
itemId: number;
|
||||||
|
preference: TradingPreference;
|
||||||
|
image?: EntityImage | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RelatedPokemon {
|
export interface RelatedPokemon {
|
||||||
id: number;
|
id: number;
|
||||||
displayId: number;
|
displayId: number;
|
||||||
@@ -188,6 +247,7 @@ export interface RelatedPokemon {
|
|||||||
export interface PokemonDetail extends Pokemon {
|
export interface PokemonDetail extends Pokemon {
|
||||||
skills: Array<Skill & { itemDrop: (NamedEntity & { image?: EntityImage | null }) | null }>;
|
skills: Array<Skill & { itemDrop: (NamedEntity & { image?: EntityImage | null }) | null }>;
|
||||||
favoriteThingItems: Array<NamedEntity & { image?: EntityImage | null; category: NamedEntity; tags: NamedEntity[] }>;
|
favoriteThingItems: Array<NamedEntity & { image?: EntityImage | null; category: NamedEntity; tags: NamedEntity[] }>;
|
||||||
|
tradingItems: PokemonTradingItem[];
|
||||||
relatedPokemon: RelatedPokemon[];
|
relatedPokemon: RelatedPokemon[];
|
||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
imageHistory: EntityImageUpload[];
|
imageHistory: EntityImageUpload[];
|
||||||
@@ -257,6 +317,8 @@ export interface Item extends EditInfo {
|
|||||||
baseName?: string;
|
baseName?: string;
|
||||||
details: string;
|
details: string;
|
||||||
baseDetails?: string;
|
baseDetails?: string;
|
||||||
|
basePrice: number | null;
|
||||||
|
ancientArtifactCategory: NamedEntity | null;
|
||||||
isEventItem: boolean;
|
isEventItem: boolean;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
image: EntityImage | null;
|
image: EntityImage | null;
|
||||||
@@ -294,6 +356,7 @@ export interface ItemDetail extends Item {
|
|||||||
recipe: RecipeDetail | null;
|
recipe: RecipeDetail | null;
|
||||||
relatedRecipes: RecipeUsage[];
|
relatedRecipes: RecipeUsage[];
|
||||||
relatedHabitats: HabitatUsage[];
|
relatedHabitats: HabitatUsage[];
|
||||||
|
possibleTags: ItemPossibleTags;
|
||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
imageHistory: EntityImageUpload[];
|
imageHistory: EntityImageUpload[];
|
||||||
droppedByPokemon: Array<{
|
droppedByPokemon: Array<{
|
||||||
@@ -302,6 +365,22 @@ export interface ItemDetail extends Item {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ItemPossibleTags {
|
||||||
|
highlyLikely: NamedEntity[];
|
||||||
|
possible: NamedEntity[];
|
||||||
|
excluded: NamedEntity[];
|
||||||
|
evidence: {
|
||||||
|
likes: ItemPossibleTagEvidence[];
|
||||||
|
neutral: ItemPossibleTagEvidence[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ItemPossibleTagEvidence {
|
||||||
|
pokemon: NamedEntity & { displayId: number; isEventItem: boolean; image?: PokemonImage | null };
|
||||||
|
preference: TradingPreference;
|
||||||
|
tags: NamedEntity[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Recipe extends EditInfo {
|
export interface Recipe extends EditInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -727,7 +806,6 @@ export interface RegisterPayload extends LoginPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
token: string;
|
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,6 +837,7 @@ export interface PokemonPayload {
|
|||||||
skillIds: number[];
|
skillIds: number[];
|
||||||
favoriteThingIds: number[];
|
favoriteThingIds: number[];
|
||||||
skillItemDrops: Array<{ skillId: number; itemId: number }>;
|
skillItemDrops: Array<{ skillId: number; itemId: number }>;
|
||||||
|
tradingItems: Array<{ itemId: number; preference: TradingPreference }>;
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,6 +868,8 @@ export interface PokemonImageOptionsResult {
|
|||||||
export interface ItemPayload {
|
export interface ItemPayload {
|
||||||
name: string;
|
name: string;
|
||||||
details: string;
|
details: string;
|
||||||
|
basePrice: number | null;
|
||||||
|
ancientArtifactCategoryId: number | null;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
usageId: number | null;
|
usageId: number | null;
|
||||||
@@ -965,11 +1046,11 @@ export interface RateLimitSettingsPayload {
|
|||||||
policies: Record<RateLimitPolicyKey, RateLimitPolicySettings>;
|
policies: Record<RateLimitPolicyKey, RateLimitPolicySettings>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildQuery(params: Record<string, string | number | boolean | undefined>): string {
|
export function buildQuery(params: Record<string, string | number | boolean | null | undefined>): string {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
|
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== '') {
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
search.set(key, String(value));
|
search.set(key, String(value));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -978,40 +1059,11 @@ export function buildQuery(params: Record<string, string | number | boolean | un
|
|||||||
return query ? `?${query}` : '';
|
return query ? `?${query}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function authStorage(type: 'local' | 'session'): Storage | null {
|
export function onAuthChange(callback: () => void): () => void {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return null;
|
return () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return type === 'local' ? window.localStorage : window.sessionStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAuthToken(): string | null {
|
|
||||||
const sessionToken = authStorage('session')?.getItem(authTokenKey);
|
|
||||||
return sessionToken ?? authStorage('local')?.getItem(authTokenKey) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setAuthToken(token: string | null, options: { persistent?: boolean } = {}): void {
|
|
||||||
const local = authStorage('local');
|
|
||||||
const session = authStorage('session');
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
if (options.persistent === false) {
|
|
||||||
session?.setItem(authTokenKey, token);
|
|
||||||
local?.removeItem(authTokenKey);
|
|
||||||
} else {
|
|
||||||
local?.setItem(authTokenKey, token);
|
|
||||||
session?.removeItem(authTokenKey);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
local?.removeItem(authTokenKey);
|
|
||||||
session?.removeItem(authTokenKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyAuthChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function onAuthTokenChange(callback: () => void): () => void {
|
|
||||||
window.addEventListener(authChangeEvent, callback);
|
window.addEventListener(authChangeEvent, callback);
|
||||||
return () => window.removeEventListener(authChangeEvent, callback);
|
return () => window.removeEventListener(authChangeEvent, callback);
|
||||||
}
|
}
|
||||||
@@ -1022,16 +1074,14 @@ export function notifyAuthChange(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestHeaders(): HeadersInit {
|
function requestHeaders(extraHeaders?: HeadersInit): Headers {
|
||||||
const token = getAuthToken();
|
const headers = new Headers(extraHeaders);
|
||||||
return {
|
headers.set('X-Locale', headers.get('X-Locale') ?? getCurrentLocale());
|
||||||
'X-Locale': getCurrentLocale(),
|
return headers;
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notificationWebSocketUrl(ticket: string): string {
|
export function notificationWebSocketUrl(ticket: string): string {
|
||||||
const base = new URL(apiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
|
const base = new URL(browserApiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
|
||||||
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
|
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
base.pathname = '/api/notifications/ws';
|
base.pathname = '/api/notifications/ws';
|
||||||
base.search = '';
|
base.search = '';
|
||||||
@@ -1052,10 +1102,24 @@ async function getErrorMessage(response: Response): Promise<string> {
|
|||||||
return `Request failed (${response.status})`;
|
return `Request failed (${response.status})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
function normalizeRequestOptions(options?: AbortSignal | ApiRequestOptions): ApiRequestOptions {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
if (!options) {
|
||||||
headers: requestHeaders(),
|
return {};
|
||||||
signal
|
}
|
||||||
|
|
||||||
|
if ('aborted' in options && 'addEventListener' in options) {
|
||||||
|
return { signal: options };
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson<T>(path: string, options?: AbortSignal | ApiRequestOptions): Promise<T> {
|
||||||
|
const requestOptions = normalizeRequestOptions(options);
|
||||||
|
const response = await fetch(apiUrl(path), {
|
||||||
|
credentials: 'include',
|
||||||
|
headers: requestHeaders(requestOptions.headers),
|
||||||
|
signal: requestOptions.signal
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -1066,12 +1130,13 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
|
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const headers = requestHeaders();
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl(path), {
|
||||||
|
credentials: 'include',
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...requestHeaders()
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1083,7 +1148,8 @@ async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sendFormData<T>(path: string, body: FormData): Promise<T> {
|
async function sendFormData<T>(path: string, body: FormData): Promise<T> {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(apiUrl(path), {
|
||||||
|
credentials: 'include',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: requestHeaders(),
|
headers: requestHeaders(),
|
||||||
body
|
body
|
||||||
@@ -1097,7 +1163,8 @@ async function sendFormData<T>(path: string, body: FormData): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function postEmpty(path: string): Promise<void> {
|
async function postEmpty(path: string): Promise<void> {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(apiUrl(path), {
|
||||||
|
credentials: 'include',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: requestHeaders()
|
headers: requestHeaders()
|
||||||
});
|
});
|
||||||
@@ -1108,7 +1175,8 @@ async function postEmpty(path: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteJson(path: string): Promise<void> {
|
async function deleteJson(path: string): Promise<void> {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(apiUrl(path), {
|
||||||
|
credentials: 'include',
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: requestHeaders()
|
headers: requestHeaders()
|
||||||
});
|
});
|
||||||
@@ -1119,7 +1187,8 @@ async function deleteJson(path: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAndGetJson<T>(path: string): Promise<T> {
|
async function deleteAndGetJson<T>(path: string): Promise<T> {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(apiUrl(path), {
|
||||||
|
credentials: 'include',
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: requestHeaders()
|
headers: requestHeaders()
|
||||||
});
|
});
|
||||||
@@ -1162,6 +1231,8 @@ export const api = {
|
|||||||
dataToolsSummary: () => getJson<DataToolsSummary>('/api/admin/data-tools/summary'),
|
dataToolsSummary: () => getJson<DataToolsSummary>('/api/admin/data-tools/summary'),
|
||||||
exportDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsBundle>('/api/admin/data-tools/export', 'POST', { scopes }),
|
exportDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsBundle>('/api/admin/data-tools/export', 'POST', { scopes }),
|
||||||
importDataTools: (bundle: DataToolsBundle) => sendJson<DataToolsSummary>('/api/admin/data-tools/import', 'POST', { bundle }),
|
importDataTools: (bundle: DataToolsBundle) => sendJson<DataToolsSummary>('/api/admin/data-tools/import', 'POST', { bundle }),
|
||||||
|
importItemsCsvDataTools: (csv: string) => sendJson<DataToolsSummary>('/api/admin/data-tools/import-items-csv', 'POST', { csv }),
|
||||||
|
importHabitatsCsvDataTools: (csv: string) => sendJson<DataToolsSummary>('/api/admin/data-tools/import-habitats-csv', 'POST', { csv }),
|
||||||
wipeDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsSummary>('/api/admin/data-tools/wipe', 'POST', { scopes }),
|
wipeDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsSummary>('/api/admin/data-tools/wipe', 'POST', { scopes }),
|
||||||
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
||||||
verifyEmail: (token: string) =>
|
verifyEmail: (token: string) =>
|
||||||
@@ -1171,7 +1242,7 @@ export const api = {
|
|||||||
sendJson<{ message: string }>('/api/auth/request-password-reset', 'POST', payload),
|
sendJson<{ message: string }>('/api/auth/request-password-reset', 'POST', payload),
|
||||||
resetPassword: (payload: { token: string; password: string }) =>
|
resetPassword: (payload: { token: string; password: string }) =>
|
||||||
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
|
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
|
||||||
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
me: (options?: ApiRequestOptions) => getJson<{ user: AuthUser }>('/api/auth/me', options),
|
||||||
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
|
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
|
||||||
changePassword: (payload: ChangePasswordPayload) =>
|
changePassword: (payload: ChangePasswordPayload) =>
|
||||||
sendJson<{ message: string }>('/api/auth/me/password', 'PATCH', payload),
|
sendJson<{ message: string }>('/api/auth/me/password', 'PATCH', payload),
|
||||||
@@ -1245,6 +1316,13 @@ export const api = {
|
|||||||
deletePermission: (id: string | number) => deleteJson(`/api/admin/permissions/${id}`),
|
deletePermission: (id: string | number) => deleteJson(`/api/admin/permissions/${id}`),
|
||||||
options: () => getJson<Options>('/api/options'),
|
options: () => getJson<Options>('/api/options'),
|
||||||
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
||||||
|
dailyChecklistPage: (params: PublicListParams = {}) =>
|
||||||
|
getJson<ListPage<DailyChecklistItem>>(
|
||||||
|
`/api/daily-checklist${buildQuery({
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
lifePosts: (params: LifePostsParams = {}) =>
|
lifePosts: (params: LifePostsParams = {}) =>
|
||||||
getJson<LifePostsPage>(
|
getJson<LifePostsPage>(
|
||||||
`/api/life-posts${buildQuery({
|
`/api/life-posts${buildQuery({
|
||||||
@@ -1347,7 +1425,7 @@ export const api = {
|
|||||||
config: (type: ConfigType) => getJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
|
config: (type: ConfigType) => getJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
|
||||||
createConfig: (
|
createConfig: (
|
||||||
type: ConfigType,
|
type: ConfigType,
|
||||||
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
|
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
|
||||||
) =>
|
) =>
|
||||||
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
||||||
reorderConfig: (type: ConfigType, ids: number[]) =>
|
reorderConfig: (type: ConfigType, ids: number[]) =>
|
||||||
@@ -1355,12 +1433,20 @@ export const api = {
|
|||||||
updateConfig: (
|
updateConfig: (
|
||||||
type: ConfigType,
|
type: ConfigType,
|
||||||
id: number,
|
id: number,
|
||||||
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
|
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
|
||||||
) =>
|
) =>
|
||||||
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
||||||
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
||||||
pokemon: (params: Record<string, string | number | boolean | undefined>) =>
|
pokemon: (params: Record<string, string | number | boolean | undefined>) =>
|
||||||
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
|
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
|
||||||
|
pokemonPage: (params: PublicListQueryParams) =>
|
||||||
|
getJson<ListPage<Pokemon>>(
|
||||||
|
`/api/pokemon${buildQuery({
|
||||||
|
...params,
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
|
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
|
||||||
pokemonFetchOptions: (search: string, signal?: AbortSignal, all = false) =>
|
pokemonFetchOptions: (search: string, signal?: AbortSignal, all = false) =>
|
||||||
getJson<PokemonFetchOption[]>(
|
getJson<PokemonFetchOption[]>(
|
||||||
@@ -1374,9 +1460,16 @@ export const api = {
|
|||||||
updatePokemon: (id: string | number, payload: PokemonPayload) =>
|
updatePokemon: (id: string | number, payload: PokemonPayload) =>
|
||||||
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
|
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
|
||||||
deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`),
|
deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`),
|
||||||
reorderPokemon: (ids: number[]) => sendJson<Pokemon[]>('/api/admin/pokemon/order', 'PUT', { ids }),
|
|
||||||
habitats: (params: Record<string, string | number | boolean | undefined> = {}) =>
|
habitats: (params: Record<string, string | number | boolean | undefined> = {}) =>
|
||||||
getJson<Habitat[]>(`/api/habitats${buildQuery(params)}`),
|
getJson<Habitat[]>(`/api/habitats${buildQuery(params)}`),
|
||||||
|
habitatsPage: (params: PublicListQueryParams = {}) =>
|
||||||
|
getJson<ListPage<Habitat>>(
|
||||||
|
`/api/habitats${buildQuery({
|
||||||
|
...params,
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
|
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
|
||||||
createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload),
|
createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload),
|
||||||
updateHabitat: (id: string | number, payload: HabitatPayload) =>
|
updateHabitat: (id: string | number, payload: HabitatPayload) =>
|
||||||
@@ -1385,6 +1478,14 @@ export const api = {
|
|||||||
reorderHabitats: (ids: number[]) => sendJson<Habitat[]>('/api/admin/habitats/order', 'PUT', { ids }),
|
reorderHabitats: (ids: number[]) => sendJson<Habitat[]>('/api/admin/habitats/order', 'PUT', { ids }),
|
||||||
items: (params: Record<string, string | number | boolean | undefined>) =>
|
items: (params: Record<string, string | number | boolean | undefined>) =>
|
||||||
getJson<Item[]>(`/api/items${buildQuery(params)}`),
|
getJson<Item[]>(`/api/items${buildQuery(params)}`),
|
||||||
|
itemsPage: (params: PublicListQueryParams) =>
|
||||||
|
getJson<ListPage<Item>>(
|
||||||
|
`/api/items${buildQuery({
|
||||||
|
...params,
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
|
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
|
||||||
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
|
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
|
||||||
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
|
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
|
||||||
@@ -1392,6 +1493,14 @@ export const api = {
|
|||||||
reorderItems: (ids: number[]) => sendJson<Item[]>('/api/admin/items/order', 'PUT', { ids }),
|
reorderItems: (ids: number[]) => sendJson<Item[]>('/api/admin/items/order', 'PUT', { ids }),
|
||||||
ancientArtifacts: (params: Record<string, string | number | undefined> = {}) =>
|
ancientArtifacts: (params: Record<string, string | number | undefined> = {}) =>
|
||||||
getJson<AncientArtifact[]>(`/api/ancient-artifacts${buildQuery(params)}`),
|
getJson<AncientArtifact[]>(`/api/ancient-artifacts${buildQuery(params)}`),
|
||||||
|
ancientArtifactsPage: (params: PublicListQueryParams = {}) =>
|
||||||
|
getJson<ListPage<AncientArtifact>>(
|
||||||
|
`/api/ancient-artifacts${buildQuery({
|
||||||
|
...params,
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
ancientArtifactDetail: (id: string | number) => getJson<AncientArtifactDetail>(`/api/ancient-artifacts/${id}`),
|
ancientArtifactDetail: (id: string | number) => getJson<AncientArtifactDetail>(`/api/ancient-artifacts/${id}`),
|
||||||
createAncientArtifact: (payload: AncientArtifactPayload) =>
|
createAncientArtifact: (payload: AncientArtifactPayload) =>
|
||||||
sendJson<AncientArtifactDetail>('/api/ancient-artifacts', 'POST', payload),
|
sendJson<AncientArtifactDetail>('/api/ancient-artifacts', 'POST', payload),
|
||||||
@@ -1402,6 +1511,14 @@ export const api = {
|
|||||||
sendJson<AncientArtifact[]>('/api/admin/ancient-artifacts/order', 'PUT', { ids }),
|
sendJson<AncientArtifact[]>('/api/admin/ancient-artifacts/order', 'PUT', { ids }),
|
||||||
recipes: (params: Record<string, string | number | undefined> = {}) =>
|
recipes: (params: Record<string, string | number | undefined> = {}) =>
|
||||||
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
|
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
|
||||||
|
recipesPage: (params: PublicListQueryParams = {}) =>
|
||||||
|
getJson<ListPage<Recipe>>(
|
||||||
|
`/api/recipes${buildQuery({
|
||||||
|
...params,
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
|
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
|
||||||
createRecipe: (payload: RecipePayload) => sendJson<RecipeDetail>('/api/recipes', 'POST', payload),
|
createRecipe: (payload: RecipePayload) => sendJson<RecipeDetail>('/api/recipes', 'POST', payload),
|
||||||
updateRecipe: (id: string | number, payload: RecipePayload) =>
|
updateRecipe: (id: string | number, payload: RecipePayload) =>
|
||||||
|
|||||||
@@ -1318,6 +1318,11 @@ svg {
|
|||||||
--btn-fg: #ffffff;
|
--btn-fg: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-button--danger {
|
||||||
|
--btn-bg: var(--danger);
|
||||||
|
--btn-fg: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
.ui-button--ghost,
|
.ui-button--ghost,
|
||||||
.plain-button,
|
.plain-button,
|
||||||
.row-actions button,
|
.row-actions button,
|
||||||
@@ -1335,6 +1340,13 @@ svg {
|
|||||||
box-shadow: 0 2px 0 var(--line-strong);
|
box-shadow: 0 2px 0 var(--line-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plain-button--icon {
|
||||||
|
width: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
button:disabled,
|
button:disabled,
|
||||||
.ui-button:disabled,
|
.ui-button:disabled,
|
||||||
.primary-button:disabled,
|
.primary-button:disabled,
|
||||||
@@ -2755,6 +2767,14 @@ button:disabled,
|
|||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.confirm-dialog__message {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 750;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.checklist-list {
|
.checklist-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -2939,6 +2959,10 @@ button:disabled,
|
|||||||
min-height: 1px;
|
min-height: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.load-more-sentinel {
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.life-feed__retry {
|
.life-feed__retry {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -5082,6 +5106,244 @@ button:disabled,
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trading-manager__panel,
|
||||||
|
.trading-selected-group,
|
||||||
|
.possible-tags-evidence {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-detail-grid,
|
||||||
|
.possible-tags-grid,
|
||||||
|
.possible-tags-evidence__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-detail-group,
|
||||||
|
.possible-tags-group,
|
||||||
|
.possible-tags-evidence__group {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
align-content: start;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-detail-group h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-detail-list {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-manager {
|
||||||
|
min-height: 640px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-manager__panel {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-manager__toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 180px;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-manager__target {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-manager__list-frame {
|
||||||
|
min-height: 420px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-manager__list-frame--selected {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-default-toggle {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-item-list,
|
||||||
|
.trading-selected-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: auto;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-item-list {
|
||||||
|
min-height: 360px;
|
||||||
|
max-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-selected-list {
|
||||||
|
max-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-item-list--loading {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-pick-row,
|
||||||
|
.trading-selected-list li {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-pick-row {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-pick-row--selected {
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-pick-row--active {
|
||||||
|
border-color: var(--pokemon-blue);
|
||||||
|
box-shadow: 0 0 0 2px rgba(42, 117, 187, .16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-pick-row__copy,
|
||||||
|
.trading-selected-list__copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-pick-row__copy strong,
|
||||||
|
.trading-selected-list__copy strong,
|
||||||
|
.possible-tags-evidence__group h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-pick-row__copy span,
|
||||||
|
.trading-selected-list__copy span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-pick-row__state {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-selected-list li {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-preference-toggle {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-preference-toggle button {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-item-list__skeleton {
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.possible-tags-evidence__list li {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.possible-tags-evidence__list .chips {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.trading-manager {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-manager__toolbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-selected-list li {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-manager__list-frame,
|
||||||
|
.trading-item-list {
|
||||||
|
min-height: 280px;
|
||||||
|
max-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-selected-list {
|
||||||
|
max-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-detail-list {
|
||||||
|
max-height: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trading-preference-toggle,
|
||||||
|
.trading-selected-list .plain-button--icon {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.pokemon-related-grid {
|
.pokemon-related-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@@ -7302,6 +7564,12 @@ button:disabled,
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-group__options--grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.switch-control {
|
.switch-control {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -7321,7 +7589,29 @@ button:disabled,
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-group__options--grid .switch-control--stacked {
|
||||||
|
min-height: 52px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
justify-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-control--disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-control__copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.switch-control__label {
|
.switch-control__label {
|
||||||
|
display: block;
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@@ -7329,6 +7619,21 @@ button:disabled,
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-control__description {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-group__options--grid .switch-control__label,
|
||||||
|
.switch-group__options--grid .switch-control__description {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.switch-control input {
|
.switch-control input {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inline-size: 1px;
|
inline-size: 1px;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import PageHeader from '../components/PageHeader.vue';
|
|||||||
import ReorderableList from '../components/ReorderableList.vue';
|
import ReorderableList from '../components/ReorderableList.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
|
import SwitchGroup, { type SwitchGroupOption } from '../components/SwitchGroup.vue';
|
||||||
|
import TagsSelect, { type TagsSelectOption } from '../components/TagsSelect.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import TranslationFields from '../components/TranslationFields.vue';
|
import TranslationFields from '../components/TranslationFields.vue';
|
||||||
import {
|
import {
|
||||||
@@ -90,6 +92,7 @@ type AdminNavItem = { key: AdminTab; label: string; permission: string | string[
|
|||||||
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
|
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
|
||||||
type EditableConfig = (NamedEntity | Skill | LifeCategory | GameVersion) & {
|
type EditableConfig = (NamedEntity | Skill | LifeCategory | GameVersion) & {
|
||||||
hasItemDrop?: boolean;
|
hasItemDrop?: boolean;
|
||||||
|
hasTrading?: boolean;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
isRateable?: boolean;
|
isRateable?: boolean;
|
||||||
changeLog?: string;
|
changeLog?: string;
|
||||||
@@ -153,7 +156,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
|
|||||||
label: t('pages.admin.contentGroup'),
|
label: t('pages.admin.contentGroup'),
|
||||||
items: [
|
items: [
|
||||||
{ key: 'checklist', label: t('pages.admin.checklist'), permission: ['checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order'] },
|
{ key: 'checklist', label: t('pages.admin.checklist'), permission: ['checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order'] },
|
||||||
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.delete'] },
|
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: 'pokemon.delete' },
|
||||||
{ key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
|
{ key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
|
||||||
{
|
{
|
||||||
key: 'ancientArtifacts',
|
key: 'ancientArtifacts',
|
||||||
@@ -194,10 +197,18 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
|
|||||||
const tabs = computed<AdminNavItem[]>(() => adminNavigationGroups.value.flatMap((group) => group.items));
|
const tabs = computed<AdminNavItem[]>(() => adminNavigationGroups.value.flatMap((group) => group.items));
|
||||||
|
|
||||||
const configTypes = computed<
|
const configTypes = computed<
|
||||||
Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean; supportsDefault?: boolean; supportsRateable?: boolean; supportsChangeLog?: boolean }>
|
Array<{
|
||||||
|
key: ConfigType;
|
||||||
|
label: string;
|
||||||
|
supportsItemDrop?: boolean;
|
||||||
|
supportsTrading?: boolean;
|
||||||
|
supportsDefault?: boolean;
|
||||||
|
supportsRateable?: boolean;
|
||||||
|
supportsChangeLog?: boolean;
|
||||||
|
}>
|
||||||
>(() => [
|
>(() => [
|
||||||
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
|
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
|
||||||
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
|
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true },
|
||||||
{ key: 'environments', label: t('config.environments') },
|
{ key: 'environments', label: t('config.environments') },
|
||||||
{ key: 'favorite-things', label: t('config.favoriteThings') },
|
{ key: 'favorite-things', label: t('config.favoriteThings') },
|
||||||
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
|
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
|
||||||
@@ -237,6 +248,7 @@ const configForm = ref({
|
|||||||
name: '',
|
name: '',
|
||||||
translations: {} as TranslationMap,
|
translations: {} as TranslationMap,
|
||||||
hasItemDrop: false,
|
hasItemDrop: false,
|
||||||
|
hasTrading: false,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
isRateable: false,
|
isRateable: false,
|
||||||
changeLog: ''
|
changeLog: ''
|
||||||
@@ -359,6 +371,31 @@ const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDis
|
|||||||
const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes));
|
const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes));
|
||||||
const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null);
|
const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null);
|
||||||
const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2);
|
const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2);
|
||||||
|
const dishItemSelectOptions = computed<TagsSelectOption[]>(() => dishItemRows.value.map((item) => ({ id: item.id, name: item.name })));
|
||||||
|
const optionalDishItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...dishItemSelectOptions.value]);
|
||||||
|
const dishCategorySelectOptions = computed<TagsSelectOption[]>(() =>
|
||||||
|
dishCategoryRows.value.map((category) => ({ id: category.id, name: category.name }))
|
||||||
|
);
|
||||||
|
const dishFlavorSelectOptions = computed<TagsSelectOption[]>(() => dishFlavorRows.value.map((flavor) => ({ id: flavor.id, name: flavor.name })));
|
||||||
|
const optionalDishSkillSelectOptions = computed<TagsSelectOption[]>(() => [
|
||||||
|
{ id: '', name: t('common.none') },
|
||||||
|
...dishSkillRows.value.map((skill) => ({ id: skill.id, name: skill.name }))
|
||||||
|
]);
|
||||||
|
const dishCategoryFormValid = computed(
|
||||||
|
() =>
|
||||||
|
dishCategoryForm.value.name.trim() !== '' &&
|
||||||
|
dishCategoryForm.value.effect.trim() !== '' &&
|
||||||
|
dishCategoryForm.value.cookwareItemId !== '' &&
|
||||||
|
dishCategoryForm.value.mainMaterialItemId !== '' &&
|
||||||
|
Number(dishCategoryForm.value.totalMaterialQuantity) >= 2
|
||||||
|
);
|
||||||
|
const dishFormValid = computed(
|
||||||
|
() =>
|
||||||
|
dishForm.value.categoryId !== '' &&
|
||||||
|
dishForm.value.itemId !== '' &&
|
||||||
|
dishForm.value.flavorId !== '' &&
|
||||||
|
dishForm.value.mosslaxEffect.trim() !== ''
|
||||||
|
);
|
||||||
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
|
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
|
||||||
const wordingModalTitle = computed(() => t('pages.admin.editWording'));
|
const wordingModalTitle = computed(() => t('pages.admin.editWording'));
|
||||||
const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole')));
|
const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole')));
|
||||||
@@ -376,6 +413,26 @@ const permissionGroups = computed(() => {
|
|||||||
}
|
}
|
||||||
return [...groups.entries()].map(([category, permissions]) => ({ category, permissions }));
|
return [...groups.entries()].map(([category, permissions]) => ({ category, permissions }));
|
||||||
});
|
});
|
||||||
|
const userRoleSwitchOptions = computed<SwitchGroupOption[]>(() =>
|
||||||
|
roleRows.value.map((role) => ({
|
||||||
|
value: role.id,
|
||||||
|
label: role.name,
|
||||||
|
description: role.description,
|
||||||
|
disabled: busy.value || !role.enabled
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const userRoleSwitchValue = computed<Array<string | number>>({
|
||||||
|
get: () => userRoleForm.value.roleIds,
|
||||||
|
set: (values) => {
|
||||||
|
userRoleForm.value.roleIds = values.map((value) => Number(value)).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const rolePermissionSwitchValue = computed<Array<string | number>>({
|
||||||
|
get: () => rolePermissionForm.value.permissionIds,
|
||||||
|
set: (values) => {
|
||||||
|
rolePermissionForm.value.permissionIds = values.map((value) => Number(value)).sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
});
|
||||||
const wordingLocaleOptions = computed(() =>
|
const wordingLocaleOptions = computed(() =>
|
||||||
languageRows.value.length
|
languageRows.value.length
|
||||||
? languageRows.value
|
? languageRows.value
|
||||||
@@ -445,8 +502,6 @@ const languageKey = (item: Language) => item.code;
|
|||||||
const languageLabel = (item: Language) => item.name;
|
const languageLabel = (item: Language) => item.name;
|
||||||
const configKey = (item: EditableConfig) => item.id;
|
const configKey = (item: EditableConfig) => item.id;
|
||||||
const configLabel = (item: EditableConfig) => item.name;
|
const configLabel = (item: EditableConfig) => item.name;
|
||||||
const pokemonKey = (item: Pokemon) => item.id;
|
|
||||||
const pokemonLabel = (item: Pokemon) => `#${item.displayId} ${item.name}`;
|
|
||||||
const itemKey = (item: Item) => item.id;
|
const itemKey = (item: Item) => item.id;
|
||||||
const itemLabel = (item: Item) => item.name;
|
const itemLabel = (item: Item) => item.name;
|
||||||
const ancientArtifactKey = (item: AncientArtifact) => item.id;
|
const ancientArtifactKey = (item: AncientArtifact) => item.id;
|
||||||
@@ -515,24 +570,13 @@ function rolePermissionCount(role: RoleDetail) {
|
|||||||
return t('pages.admin.permissionCount', { count: role.permissionIds.length });
|
return t('pages.admin.permissionCount', { count: role.permissionIds.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleUserRole(roleId: number) {
|
function permissionSwitchOptions(permissions: Permission[]): SwitchGroupOption[] {
|
||||||
const roleIds = new Set(userRoleForm.value.roleIds);
|
return permissions.map((permission) => ({
|
||||||
if (roleIds.has(roleId)) {
|
value: permission.id,
|
||||||
roleIds.delete(roleId);
|
label: permission.name,
|
||||||
} else {
|
description: permission.key,
|
||||||
roleIds.add(roleId);
|
disabled: busy.value || !permission.enabled
|
||||||
}
|
}));
|
||||||
userRoleForm.value.roleIds = [...roleIds].sort((a, b) => a - b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRolePermission(permissionId: number) {
|
|
||||||
const permissionIds = new Set(rolePermissionForm.value.permissionIds);
|
|
||||||
if (permissionIds.has(permissionId)) {
|
|
||||||
permissionIds.delete(permissionId);
|
|
||||||
} else {
|
|
||||||
permissionIds.add(permissionId);
|
|
||||||
}
|
|
||||||
rolePermissionForm.value.permissionIds = [...permissionIds].sort((a, b) => a - b);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorText(error: unknown, fallback: string) {
|
function errorText(error: unknown, fallback: string) {
|
||||||
@@ -561,7 +605,7 @@ async function loadLanguages() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetConfigForm() {
|
function resetConfigForm() {
|
||||||
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, isDefault: false, isRateable: false, changeLog: '' };
|
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, hasTrading: false, isDefault: false, isRateable: false, changeLog: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetChecklistForm() {
|
function resetChecklistForm() {
|
||||||
@@ -667,6 +711,7 @@ function editConfig(item: EditableConfig) {
|
|||||||
name: item.baseName ?? item.name,
|
name: item.baseName ?? item.name,
|
||||||
translations: item.translations ?? {},
|
translations: item.translations ?? {},
|
||||||
hasItemDrop: item.hasItemDrop === true,
|
hasItemDrop: item.hasItemDrop === true,
|
||||||
|
hasTrading: item.hasTrading === true,
|
||||||
isDefault: item.isDefault === true,
|
isDefault: item.isDefault === true,
|
||||||
isRateable: item.isRateable === true,
|
isRateable: item.isRateable === true,
|
||||||
changeLog: item.changeLog ?? ''
|
changeLog: item.changeLog ?? ''
|
||||||
@@ -885,10 +930,6 @@ function previewConfigOrder(rows: EditableConfig[]) {
|
|||||||
configRows.value = rows;
|
configRows.value = rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewPokemonOrder(rows: Pokemon[]) {
|
|
||||||
pokemonRows.value = rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
function previewItemOrder(rows: Item[]) {
|
function previewItemOrder(rows: Item[]) {
|
||||||
itemRows.value = rows;
|
itemRows.value = rows;
|
||||||
}
|
}
|
||||||
@@ -957,18 +998,6 @@ async function persistConfigOrder(nextRows: EditableConfig[], fallbackRows: Edit
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistPokemonOrder(nextRows: Pokemon[], fallbackRows: Pokemon[]) {
|
|
||||||
pokemonRows.value = nextRows;
|
|
||||||
await run(async () => {
|
|
||||||
try {
|
|
||||||
pokemonRows.value = await api.reorderPokemon(nextRows.map((item) => item.id));
|
|
||||||
} catch (error) {
|
|
||||||
pokemonRows.value = fallbackRows;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persistItemOrder(nextRows: Item[], fallbackRows: Item[]) {
|
async function persistItemOrder(nextRows: Item[], fallbackRows: Item[]) {
|
||||||
itemRows.value = nextRows;
|
itemRows.value = nextRows;
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
@@ -1047,6 +1076,7 @@ async function saveConfig() {
|
|||||||
name: configBaseNameForSave(),
|
name: configBaseNameForSave(),
|
||||||
translations: configForm.value.translations,
|
translations: configForm.value.translations,
|
||||||
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
|
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
|
||||||
|
hasTrading: selectedConfig.value.supportsTrading ? configForm.value.hasTrading : undefined,
|
||||||
isDefault: selectedConfig.value.supportsDefault ? configForm.value.isDefault : undefined,
|
isDefault: selectedConfig.value.supportsDefault ? configForm.value.isDefault : undefined,
|
||||||
isRateable: selectedConfig.value.supportsRateable ? configForm.value.isRateable : undefined,
|
isRateable: selectedConfig.value.supportsRateable ? configForm.value.isRateable : undefined,
|
||||||
changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined
|
changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined
|
||||||
@@ -1117,6 +1147,10 @@ function dishPayloadForSave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveDishCategory() {
|
async function saveDishCategory() {
|
||||||
|
if (!dishCategoryFormValid.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const payload = dishCategoryPayloadForSave();
|
const payload = dishCategoryPayloadForSave();
|
||||||
if (dishCategoryForm.value.id) {
|
if (dishCategoryForm.value.id) {
|
||||||
@@ -1130,6 +1164,10 @@ async function saveDishCategory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveDish() {
|
async function saveDish() {
|
||||||
|
if (!dishFormValid.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
const payload = dishPayloadForSave();
|
const payload = dishPayloadForSave();
|
||||||
if (dishForm.value.id) {
|
if (dishForm.value.id) {
|
||||||
@@ -1594,6 +1632,40 @@ async function selectImportDataToolsFile(event: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectImportItemsCsvFile(event: Event) {
|
||||||
|
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||||
|
const file = input?.files?.[0];
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await run(async () => {
|
||||||
|
const csv = await file.text();
|
||||||
|
dataToolsSummary.value = await api.importItemsCsvDataTools(csv);
|
||||||
|
message.value = t('pages.admin.dataToolItemsCsvImported');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectImportHabitatsCsvFile(event: Event) {
|
||||||
|
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||||
|
const file = input?.files?.[0];
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await run(async () => {
|
||||||
|
const csv = await file.text();
|
||||||
|
dataToolsSummary.value = await api.importHabitatsCsvDataTools(csv);
|
||||||
|
message.value = t('pages.admin.dataToolHabitatsCsvImported');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function closeImportDataToolsModal() {
|
function closeImportDataToolsModal() {
|
||||||
dataToolImportModalOpen.value = false;
|
dataToolImportModalOpen.value = false;
|
||||||
pendingImportBundle.value = null;
|
pendingImportBundle.value = null;
|
||||||
@@ -1925,6 +1997,16 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</p>
|
<p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</p>
|
||||||
<p class="meta-line">{{ t('pages.admin.dataToolImportMode') }}</p>
|
<p class="meta-line">{{ t('pages.admin.dataToolImportMode') }}</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="data-tools-items-csv-file">{{ t('pages.admin.dataToolItemsCsvFile') }}</label>
|
||||||
|
<input id="data-tools-items-csv-file" type="file" accept="text/csv,.csv" :disabled="busy || !can('admin.data.import')" @change="selectImportItemsCsvFile" />
|
||||||
|
</div>
|
||||||
|
<p class="meta-line">{{ t('pages.admin.dataToolItemsCsvMode') }}</p>
|
||||||
|
<div class="field">
|
||||||
|
<label for="data-tools-habitats-csv-file">{{ t('pages.admin.dataToolHabitatsCsvFile') }}</label>
|
||||||
|
<input id="data-tools-habitats-csv-file" type="file" accept="text/csv,.csv" :disabled="busy || !can('admin.data.import')" @change="selectImportHabitatsCsvFile" />
|
||||||
|
</div>
|
||||||
|
<p class="meta-line">{{ t('pages.admin.dataToolHabitatsCsvMode') }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="data-tool-panel data-tool-panel--danger" :aria-label="t('pages.admin.dataToolWipe')">
|
<section class="data-tool-panel data-tool-panel--danger" :aria-label="t('pages.admin.dataToolWipe')">
|
||||||
@@ -1980,6 +2062,7 @@ onMounted(() => {
|
|||||||
<span class="reorderable-row-title">
|
<span class="reorderable-row-title">
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
|
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
|
||||||
|
<span v-if="item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span>
|
||||||
<span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultCategory') }}</span>
|
<span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultCategory') }}</span>
|
||||||
<span v-if="item.isRateable" class="config-flag">{{ t('pages.admin.rateableCategory') }}</span>
|
<span v-if="item.isRateable" class="config-flag">{{ t('pages.admin.rateableCategory') }}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -2218,20 +2301,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
|
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
|
||||||
<h2>{{ t('pages.admin.pokemonList') }}</h2>
|
<h2>{{ t('pages.admin.pokemonList') }}</h2>
|
||||||
<ReorderableList
|
<ul v-if="pokemonRows.length" class="row-list">
|
||||||
v-if="pokemonRows.length"
|
<li v-for="item in pokemonRows" :key="item.id">
|
||||||
:items="pokemonRows"
|
|
||||||
:item-key="pokemonKey"
|
|
||||||
:item-label="pokemonLabel"
|
|
||||||
list-key-prefix="pokemon"
|
|
||||||
:disabled="busy || !can('pokemon.order')"
|
|
||||||
:handle-label="dragSortLabel"
|
|
||||||
:handle-title="t('pages.admin.dragSortTitle')"
|
|
||||||
@preview="previewPokemonOrder"
|
|
||||||
@cancel="previewPokemonOrder"
|
|
||||||
@reorder="persistPokemonOrder"
|
|
||||||
>
|
|
||||||
<template #default="{ item }">
|
|
||||||
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
|
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
|
||||||
<span class="row-actions">
|
<span class="row-actions">
|
||||||
<button v-if="can('pokemon.delete')" type="button" :disabled="busy" @click="removePokemon(item.id)">
|
<button v-if="can('pokemon.delete')" type="button" :disabled="busy" @click="removePokemon(item.id)">
|
||||||
@@ -2239,8 +2310,8 @@ onMounted(() => {
|
|||||||
{{ t('common.delete') }}
|
{{ t('common.delete') }}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</li>
|
||||||
</ReorderableList>
|
</ul>
|
||||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -2480,20 +2551,7 @@ onMounted(() => {
|
|||||||
<strong>{{ editingUser.displayName }}</strong>
|
<strong>{{ editingUser.displayName }}</strong>
|
||||||
<span class="meta-line">{{ editingUser.email }}</span>
|
<span class="meta-line">{{ editingUser.email }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="permission-grid" role="group" :aria-label="t('pages.admin.roles')">
|
<SwitchGroup id="admin-user-roles" v-model="userRoleSwitchValue" :label="t('pages.admin.roles')" :options="userRoleSwitchOptions" layout="grid" />
|
||||||
<label v-for="role in roleRows" :key="role.id" class="permission-toggle">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="userRoleForm.roleIds.includes(role.id)"
|
|
||||||
:disabled="busy || !role.enabled"
|
|
||||||
@change="toggleUserRole(role.id)"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<strong>{{ role.name }}</strong>
|
|
||||||
<small>{{ role.description }}</small>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -2550,22 +2608,14 @@ onMounted(() => {
|
|||||||
<span class="meta-line">{{ editingRole.description }}</span>
|
<span class="meta-line">{{ editingRole.description }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="permission-groups">
|
<div class="permission-groups">
|
||||||
<section v-for="group in permissionGroups" :key="group.category" class="permission-group">
|
<section v-for="(group, index) in permissionGroups" :key="group.category" class="permission-group">
|
||||||
<h3>{{ group.category }}</h3>
|
<SwitchGroup
|
||||||
<div class="permission-grid" role="group" :aria-label="group.category">
|
:id="`admin-role-permissions-${index}`"
|
||||||
<label v-for="permission in group.permissions" :key="permission.id" class="permission-toggle">
|
v-model="rolePermissionSwitchValue"
|
||||||
<input
|
:label="group.category"
|
||||||
type="checkbox"
|
:options="permissionSwitchOptions(group.permissions)"
|
||||||
:checked="rolePermissionForm.permissionIds.includes(permission.id)"
|
layout="grid"
|
||||||
:disabled="busy || !permission.enabled"
|
/>
|
||||||
@change="toggleRolePermission(permission.id)"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<strong>{{ permission.name }}</strong>
|
|
||||||
<small>{{ permission.key }}</small>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -2656,10 +2706,14 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-category-cookware">{{ t('pages.dish.cookware') }}</label>
|
<label for="dish-category-cookware">{{ t('pages.dish.cookware') }}</label>
|
||||||
<select id="dish-category-cookware" v-model="dishCategoryForm.cookwareItemId" required>
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-category-cookware"
|
||||||
<option v-for="item in dishItemRows" :key="`cookware-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
v-model="dishCategoryForm.cookwareItemId"
|
||||||
</select>
|
:options="dishItemSelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.select')"
|
||||||
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-category-total-material-quantity">{{ t('pages.dish.totalMaterialQuantity') }}</label>
|
<label for="dish-category-total-material-quantity">{{ t('pages.dish.totalMaterialQuantity') }}</label>
|
||||||
@@ -2667,10 +2721,14 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-category-main-material">{{ t('pages.dish.mainMaterial') }}</label>
|
<label for="dish-category-main-material">{{ t('pages.dish.mainMaterial') }}</label>
|
||||||
<select id="dish-category-main-material" v-model="dishCategoryForm.mainMaterialItemId" required>
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-category-main-material"
|
||||||
<option v-for="item in dishItemRows" :key="`category-main-material-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
v-model="dishCategoryForm.mainMaterialItemId"
|
||||||
</select>
|
:options="dishItemSelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.select')"
|
||||||
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TranslationFields
|
<TranslationFields
|
||||||
@@ -2685,7 +2743,7 @@ onMounted(() => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<button type="submit" form="admin-dish-category-form" class="link-button" :disabled="busy">
|
<button type="submit" form="admin-dish-category-form" class="link-button" :disabled="busy || !dishCategoryFormValid">
|
||||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
{{ busy ? t('common.saving') : t('common.save') }}
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -2701,47 +2759,71 @@ onMounted(() => {
|
|||||||
<div class="dish-form-row dish-form-row--3">
|
<div class="dish-form-row dish-form-row--3">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-category">{{ t('pages.dish.category') }}</label>
|
<label for="dish-category">{{ t('pages.dish.category') }}</label>
|
||||||
<select id="dish-category" v-model="dishForm.categoryId" required>
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-category"
|
||||||
<option v-for="category in dishCategoryRows" :key="`dish-category-option-${category.id}`" :value="String(category.id)">{{ category.name }}</option>
|
v-model="dishForm.categoryId"
|
||||||
</select>
|
:options="dishCategorySelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.select')"
|
||||||
|
:search-placeholder="t('pages.dish.category')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-item">{{ t('pages.dish.dishItem') }}</label>
|
<label for="dish-item">{{ t('pages.dish.dishItem') }}</label>
|
||||||
<select id="dish-item" v-model="dishForm.itemId" required>
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-item"
|
||||||
<option v-for="item in dishItemRows" :key="`dish-item-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
v-model="dishForm.itemId"
|
||||||
</select>
|
:options="dishItemSelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.select')"
|
||||||
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-flavor">{{ t('pages.dish.flavor') }}</label>
|
<label for="dish-flavor">{{ t('pages.dish.flavor') }}</label>
|
||||||
<select id="dish-flavor" v-model="dishForm.flavorId" required>
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-flavor"
|
||||||
<option v-for="flavor in dishFlavorRows" :key="`dish-flavor-${flavor.id}`" :value="String(flavor.id)">{{ flavor.name }}</option>
|
v-model="dishForm.flavorId"
|
||||||
</select>
|
:options="dishFlavorSelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.select')"
|
||||||
|
:search-placeholder="t('pages.dish.flavor')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dish-form-row dish-form-row--3">
|
<div class="dish-form-row dish-form-row--3">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-secondary-material-1">{{ t('pages.dish.secondaryMaterial') }}</label>
|
<label for="dish-secondary-material-1">{{ t('pages.dish.secondaryMaterial') }}</label>
|
||||||
<select id="dish-secondary-material-1" v-model="dishForm.secondaryMaterialItemIds[0]">
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-secondary-material-1"
|
||||||
<option v-for="item in dishItemRows" :key="`dish-secondary-material-1-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
v-model="dishForm.secondaryMaterialItemIds[0]"
|
||||||
</select>
|
:options="optionalDishItemSelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.none')"
|
||||||
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="dishAllowsSecondSecondaryMaterial" class="field">
|
<div v-if="dishAllowsSecondSecondaryMaterial" class="field">
|
||||||
<label for="dish-secondary-material-2">{{ t('pages.dish.secondSecondaryMaterial') }}</label>
|
<label for="dish-secondary-material-2">{{ t('pages.dish.secondSecondaryMaterial') }}</label>
|
||||||
<select id="dish-secondary-material-2" v-model="dishForm.secondaryMaterialItemIds[1]">
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-secondary-material-2"
|
||||||
<option v-for="item in dishItemRows" :key="`dish-secondary-material-2-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
v-model="dishForm.secondaryMaterialItemIds[1]"
|
||||||
</select>
|
:options="optionalDishItemSelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.none')"
|
||||||
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="dish-pokemon-skill">{{ t('pages.dish.pokemonSkill') }}</label>
|
<label for="dish-pokemon-skill">{{ t('pages.dish.pokemonSkill') }}</label>
|
||||||
<select id="dish-pokemon-skill" v-model="dishForm.pokemonSkillId">
|
<TagsSelect
|
||||||
<option value="">{{ t('common.none') }}</option>
|
id="dish-pokemon-skill"
|
||||||
<option v-for="skill in dishSkillRows" :key="`dish-skill-${skill.id}`" :value="String(skill.id)">{{ skill.name }}</option>
|
v-model="dishForm.pokemonSkillId"
|
||||||
</select>
|
:options="optionalDishSkillSelectOptions"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.none')"
|
||||||
|
:search-placeholder="t('pages.dish.pokemonSkill')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TranslationFields
|
<TranslationFields
|
||||||
@@ -2756,7 +2838,7 @@ onMounted(() => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<button type="submit" form="admin-dish-form" class="link-button" :disabled="busy">
|
<button type="submit" form="admin-dish-form" class="link-button" :disabled="busy || !dishFormValid">
|
||||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
{{ busy ? t('common.saving') : t('common.save') }}
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -2779,6 +2861,12 @@ onMounted(() => {
|
|||||||
{{ t('pages.admin.hasItemDrop') }}
|
{{ t('pages.admin.hasItemDrop') }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="selectedConfig.supportsTrading" class="check-row">
|
||||||
|
<label>
|
||||||
|
<input v-model="configForm.hasTrading" type="checkbox" />
|
||||||
|
{{ t('pages.admin.hasTrading') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div v-if="selectedConfig.supportsDefault" class="check-row">
|
<div v-if="selectedConfig.supportsDefault" class="check-row">
|
||||||
<label>
|
<label>
|
||||||
<input v-model="configForm.isDefault" type="checkbox" />
|
<input v-model="configForm.isDefault" type="checkbox" />
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Icon } from '@iconify/vue';
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
|
||||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
|
||||||
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
|
||||||
import { iconArtifact, iconBack, iconEdit } from '../icons';
|
|
||||||
import { applySeo } from '../seo';
|
|
||||||
import { api, getAuthToken, type AncientArtifactDetail, type AuthUser } from '../services/api';
|
|
||||||
import AncientArtifactEdit from './AncientArtifactEdit.vue';
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const { t } = useI18n();
|
|
||||||
const artifact = ref<AncientArtifactDetail | null>(null);
|
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
|
||||||
const detailTab = ref('details');
|
|
||||||
const showEditor = computed(() => route.name === 'ancient-artifact-edit');
|
|
||||||
const canUpdateArtifact = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.update') === true);
|
|
||||||
const detailTabs = computed<TabOption[]>(() => [
|
|
||||||
{ value: 'details', label: t('common.details') },
|
|
||||||
{ value: 'discussion', label: t('discussion.title') },
|
|
||||||
{ value: 'history', label: t('history.editHistory') }
|
|
||||||
]);
|
|
||||||
|
|
||||||
async function loadArtifactDetail() {
|
|
||||||
const nextArtifact = await api.ancientArtifactDetail(String(route.params.id));
|
|
||||||
artifact.value = nextArtifact;
|
|
||||||
|
|
||||||
if (route.meta.editorModal !== true) {
|
|
||||||
applySeo({
|
|
||||||
title: `${nextArtifact.name} - ${t('pages.ancientArtifacts.title')}`,
|
|
||||||
description: t('seo.ancientArtifactDetailDescription', { name: nextArtifact.name }),
|
|
||||||
canonicalPath: `/ancient-artifacts/${nextArtifact.id}`,
|
|
||||||
image: nextArtifact.image?.url
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (getAuthToken()) {
|
|
||||||
try {
|
|
||||||
currentUser.value = (await api.me()).user;
|
|
||||||
} catch {
|
|
||||||
currentUser.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await loadArtifactDetail();
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.name,
|
|
||||||
(name, oldName) => {
|
|
||||||
if (oldName === 'ancient-artifact-edit' && name === 'ancient-artifact-detail') {
|
|
||||||
void loadArtifactDetail();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.params.id,
|
|
||||||
() => {
|
|
||||||
artifact.value = null;
|
|
||||||
detailTab.value = 'details';
|
|
||||||
void loadArtifactDetail();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section v-if="!artifact" class="page-stack" aria-busy="true" :aria-label="t('pages.ancientArtifacts.loadingDetail')">
|
|
||||||
<div class="page-header page-header--skeleton" aria-hidden="true">
|
|
||||||
<div class="page-header__copy">
|
|
||||||
<Skeleton width="132px" />
|
|
||||||
<Skeleton width="260px" height="46px" />
|
|
||||||
<Skeleton width="220px" />
|
|
||||||
</div>
|
|
||||||
<div class="page-header__actions">
|
|
||||||
<Skeleton variant="box" width="88px" height="36px" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="detail-section skeleton-detail-section" aria-hidden="true">
|
|
||||||
<div class="detail-section__header">
|
|
||||||
<Skeleton width="112px" height="24px" />
|
|
||||||
</div>
|
|
||||||
<div class="detail-section__body">
|
|
||||||
<Skeleton width="45%" />
|
|
||||||
<Skeleton variant="box" height="120px" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
<section v-else class="page-stack">
|
|
||||||
<PageHeader :title="artifact.name" :subtitle="artifact.category.name">
|
|
||||||
<template #kicker>{{ t('pages.ancientArtifacts.detailKicker') }}</template>
|
|
||||||
<template #actions>
|
|
||||||
<RouterLink v-if="canUpdateArtifact" class="ui-button ui-button--primary ui-button--small" :to="`/ancient-artifacts/${artifact.id}/edit`">
|
|
||||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
|
||||||
{{ t('common.edit') }}
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/ancient-artifacts">
|
|
||||||
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
|
|
||||||
{{ t('common.backToList') }}
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<div class="detail-tabs">
|
|
||||||
<Tabs id="artifact-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
|
||||||
|
|
||||||
<div v-if="detailTab === 'details'" class="detail-grid">
|
|
||||||
<DetailSection :title="t('common.details')">
|
|
||||||
<dl class="entity-profile-facts">
|
|
||||||
<div>
|
|
||||||
<dt>{{ t('pages.ancientArtifacts.category') }}</dt>
|
|
||||||
<dd>{{ artifact.category.name }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
<DetailSection :title="t('media.image')">
|
|
||||||
<div class="entity-detail-image">
|
|
||||||
<div class="entity-detail-image__frame" :class="{ 'entity-detail-image__frame--placeholder': !artifact.image }">
|
|
||||||
<img v-if="artifact.image" :src="artifact.image.url" :alt="t('media.imageAlt', { name: artifact.name })" />
|
|
||||||
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
|
|
||||||
<Icon :icon="iconArtifact" class="entity-card__icon" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
<DetailSection :title="t('pages.ancientArtifacts.description')">
|
|
||||||
<p v-if="artifact.details" class="preserve-lines">{{ artifact.details }}</p>
|
|
||||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
<DetailSection :title="t('pages.ancientArtifacts.category')">
|
|
||||||
<span class="chip">
|
|
||||||
<Icon :icon="iconArtifact" class="ui-icon" aria-hidden="true" />
|
|
||||||
{{ artifact.category.name }}
|
|
||||||
</span>
|
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
<DetailSection :title="t('pages.ancientArtifacts.tags')">
|
|
||||||
<EntityChips v-if="artifact.tags.length" :items="artifact.tags" />
|
|
||||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
|
||||||
</DetailSection>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
|
|
||||||
<EntityDiscussionPanel entity-type="ancient-artifacts" :entity-id="artifact.id" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="detail-tab-panel">
|
|
||||||
<EditHistoryPanel :entity="artifact" :history="artifact.editHistory" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<AncientArtifactEdit v-if="showEditor" />
|
|
||||||
</template>
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Icon } from '@iconify/vue';
|
|
||||||
import { computed, onMounted, ref } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
|
||||||
import ImageUploadField from '../components/ImageUploadField.vue';
|
|
||||||
import Modal from '../components/Modal.vue';
|
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
|
||||||
import TranslationFields from '../components/TranslationFields.vue';
|
|
||||||
import { iconCancel, iconSave } from '../icons';
|
|
||||||
import {
|
|
||||||
api,
|
|
||||||
getAuthToken,
|
|
||||||
type AncientArtifactPayload,
|
|
||||||
type AuthUser,
|
|
||||||
type ConfigType,
|
|
||||||
type EntityImage,
|
|
||||||
type EntityImageUpload,
|
|
||||||
type Language,
|
|
||||||
type Options,
|
|
||||||
type TranslationMap
|
|
||||||
} from '../services/api';
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
const { locale, t } = useI18n();
|
|
||||||
const options = ref<Options | null>(null);
|
|
||||||
const languages = ref<Language[]>([]);
|
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
|
||||||
const currentImage = ref<EntityImage | null>(null);
|
|
||||||
const imageHistory = ref<EntityImageUpload[]>([]);
|
|
||||||
const loading = ref(true);
|
|
||||||
const busy = ref(false);
|
|
||||||
const message = ref('');
|
|
||||||
const creatingSelect = ref('');
|
|
||||||
const artifactForm = ref({
|
|
||||||
name: '',
|
|
||||||
details: '',
|
|
||||||
translations: {} as TranslationMap,
|
|
||||||
categoryId: '',
|
|
||||||
tagIds: [] as string[],
|
|
||||||
imagePath: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
|
||||||
const isEditing = computed(() => routeId.value !== '');
|
|
||||||
const pageTitle = computed(() =>
|
|
||||||
isEditing.value
|
|
||||||
? t('pages.ancientArtifacts.editTitle', { name: artifactForm.value.name || t('pages.ancientArtifacts.fallbackName') })
|
|
||||||
: t('pages.ancientArtifacts.newTitle')
|
|
||||||
);
|
|
||||||
const cancelTo = computed(() => (isEditing.value ? `/ancient-artifacts/${routeId.value}` : '/ancient-artifacts'));
|
|
||||||
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
|
||||||
const canUploadImage = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.upload') === true);
|
|
||||||
const imageEntityName = computed(() => artifactNameForSave().trim());
|
|
||||||
|
|
||||||
function toIds(values: string[]): number[] {
|
|
||||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorText(error: unknown, fallback: string) {
|
|
||||||
return error instanceof Error && error.message ? error.message : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditor() {
|
|
||||||
void router.push(cancelTo.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function artifactNameForSave() {
|
|
||||||
const baseName = artifactForm.value.name.trim();
|
|
||||||
if (baseName !== '') {
|
|
||||||
return artifactForm.value.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return artifactForm.value.translations[String(locale.value || '')]?.name ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEditor() {
|
|
||||||
loading.value = true;
|
|
||||||
message.value = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [loadedOptions, loadedLanguages] = await Promise.all([api.options(), api.languages()]);
|
|
||||||
options.value = loadedOptions;
|
|
||||||
languages.value = loadedLanguages;
|
|
||||||
|
|
||||||
if (getAuthToken()) {
|
|
||||||
try {
|
|
||||||
currentUser.value = (await api.me()).user;
|
|
||||||
} catch {
|
|
||||||
currentUser.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEditing.value) {
|
|
||||||
const artifact = await api.ancientArtifactDetail(routeId.value);
|
|
||||||
artifactForm.value = {
|
|
||||||
name: artifact.baseName ?? artifact.name,
|
|
||||||
details: artifact.baseDetails ?? artifact.details,
|
|
||||||
translations: artifact.translations ?? {},
|
|
||||||
categoryId: String(artifact.category.id),
|
|
||||||
tagIds: artifact.tags.map((tag) => String(tag.id)),
|
|
||||||
imagePath: artifact.image?.path ?? ''
|
|
||||||
};
|
|
||||||
currentImage.value = artifact.image;
|
|
||||||
imageHistory.value = artifact.imageHistory;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
message.value = errorText(error, t('errors.loadFailed'));
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
|
|
||||||
const cleanName = name.trim();
|
|
||||||
if (!cleanName || !canCreateConfig.value) return;
|
|
||||||
|
|
||||||
creatingSelect.value = selectKey;
|
|
||||||
message.value = '';
|
|
||||||
try {
|
|
||||||
const created = await api.createConfig(type, { name: cleanName });
|
|
||||||
options.value = await api.options();
|
|
||||||
const value = String(created.id);
|
|
||||||
if (!values.includes(value)) {
|
|
||||||
values.push(value);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
message.value = errorText(error, t('errors.addFailed'));
|
|
||||||
} finally {
|
|
||||||
creatingSelect.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveArtifact() {
|
|
||||||
busy.value = true;
|
|
||||||
message.value = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload: AncientArtifactPayload = {
|
|
||||||
name: artifactNameForSave(),
|
|
||||||
details: artifactForm.value.details,
|
|
||||||
translations: artifactForm.value.translations,
|
|
||||||
categoryId: Number(artifactForm.value.categoryId),
|
|
||||||
tagIds: toIds(artifactForm.value.tagIds),
|
|
||||||
imagePath: artifactForm.value.imagePath
|
|
||||||
};
|
|
||||||
const saved = isEditing.value
|
|
||||||
? await api.updateAncientArtifact(routeId.value, payload)
|
|
||||||
: await api.createAncientArtifact(payload);
|
|
||||||
await router.push(`/ancient-artifacts/${saved.id}`);
|
|
||||||
} catch (error) {
|
|
||||||
message.value = errorText(error, t('errors.saveFailed'));
|
|
||||||
} finally {
|
|
||||||
busy.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImageSelected(image: EntityImage) {
|
|
||||||
currentImage.value = image;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImageUploaded(image: EntityImageUpload) {
|
|
||||||
currentImage.value = image;
|
|
||||||
imageHistory.value = [image, ...imageHistory.value.filter((item) => item.path !== image.path)];
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void loadEditor();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Modal :title="pageTitle" :subtitle="t('pages.ancientArtifacts.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
|
||||||
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
|
||||||
|
|
||||||
<form v-if="!loading && options" id="artifact-edit-form" class="modal-edit-form" @submit.prevent="saveArtifact">
|
|
||||||
<TranslationFields
|
|
||||||
id-prefix="artifact-name"
|
|
||||||
v-model:base-value="artifactForm.name"
|
|
||||||
v-model:translations="artifactForm.translations"
|
|
||||||
field="name"
|
|
||||||
:label="t('common.name')"
|
|
||||||
:languages="languages"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TranslationFields
|
|
||||||
id-prefix="artifact-details"
|
|
||||||
v-model:base-value="artifactForm.details"
|
|
||||||
v-model:translations="artifactForm.translations"
|
|
||||||
field="details"
|
|
||||||
:label="t('pages.ancientArtifacts.description')"
|
|
||||||
:languages="languages"
|
|
||||||
multiline
|
|
||||||
:rows="4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="artifact-category">{{ t('pages.ancientArtifacts.category') }}</label>
|
|
||||||
<TagsSelect
|
|
||||||
id="artifact-category"
|
|
||||||
v-model="artifactForm.categoryId"
|
|
||||||
:options="options.ancientArtifactCategories"
|
|
||||||
:multiple="false"
|
|
||||||
:placeholder="t('common.select')"
|
|
||||||
:search-placeholder="t('pages.ancientArtifacts.searchCategory')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ImageUploadField
|
|
||||||
v-model="artifactForm.imagePath"
|
|
||||||
entity-type="ancient-artifacts"
|
|
||||||
:entity-id="isEditing ? routeId : null"
|
|
||||||
:entity-name="imageEntityName"
|
|
||||||
:label="t('media.image')"
|
|
||||||
:current-image="currentImage"
|
|
||||||
:history="imageHistory"
|
|
||||||
:disabled="busy"
|
|
||||||
:allow-upload="canUploadImage"
|
|
||||||
@selected="handleImageSelected"
|
|
||||||
@uploaded="handleImageUploaded"
|
|
||||||
@error="message = $event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="artifact-tags">{{ t('pages.ancientArtifacts.tags') }}</label>
|
|
||||||
<TagsSelect
|
|
||||||
id="artifact-tags"
|
|
||||||
v-model="artifactForm.tagIds"
|
|
||||||
:options="options.itemTags"
|
|
||||||
:allow-create="canCreateConfig"
|
|
||||||
:creating="creatingSelect === 'artifact-tags'"
|
|
||||||
:placeholder="t('pages.ancientArtifacts.searchTags')"
|
|
||||||
@create="createMultiOption('artifact-tags', 'favorite-things', $event, artifactForm.tagIds)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.ancientArtifacts.loadingEdit')">
|
|
||||||
<Skeleton width="160px" />
|
|
||||||
<Skeleton variant="box" height="44px" />
|
|
||||||
<Skeleton width="140px" />
|
|
||||||
<Skeleton variant="box" height="120px" />
|
|
||||||
<Skeleton width="120px" />
|
|
||||||
<Skeleton variant="box" height="44px" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<button type="button" class="ui-button ui-button--ghost" @click="closeEditor">
|
|
||||||
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
|
||||||
{{ t('common.cancel') }}
|
|
||||||
</button>
|
|
||||||
<button type="submit" form="artifact-edit-form" class="ui-button ui-button--primary" :disabled="busy || loading">
|
|
||||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
|
||||||
{{ busy ? t('common.saving') : t('common.save') }}
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
@@ -5,20 +5,24 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
import FilterPanel from '../components/FilterPanel.vue';
|
import FilterPanel from '../components/FilterPanel.vue';
|
||||||
|
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
import { iconAdd, iconArtifact } from '../icons';
|
import { iconAdd, iconArtifact } from '../icons';
|
||||||
import { api, getAuthToken, type AncientArtifact, type AuthUser, type Options } from '../services/api';
|
import { api, type AncientArtifact, type AuthUser, type ListPage, type Options } from '../services/api';
|
||||||
import AncientArtifactEdit from './AncientArtifactEdit.vue';
|
import ItemEdit from './ItemEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
const artifacts = ref<AncientArtifact[]>([]);
|
const artifacts = ref<AncientArtifact[]>([]);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const nextCursor = ref<string | null>(null);
|
||||||
|
const hasMoreArtifacts = ref(false);
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
const categoryId = ref('');
|
const categoryId = ref('');
|
||||||
const tagIds = ref<string[]>([]);
|
const tagIds = ref<string[]>([]);
|
||||||
@@ -26,6 +30,8 @@ const tagIds = ref<string[]>([]);
|
|||||||
const categorySkeletonWidths = ['64px', '132px', '132px', '86px'];
|
const categorySkeletonWidths = ['64px', '132px', '132px', '86px'];
|
||||||
const filterSkeletonWidths = ['52px', '36px'];
|
const filterSkeletonWidths = ['52px', '36px'];
|
||||||
const skeletonCardCount = 6;
|
const skeletonCardCount = 6;
|
||||||
|
const listPageSize = 24;
|
||||||
|
let loadRequestId = 0;
|
||||||
|
|
||||||
const categoryTabs = computed<TabOption[]>(() => [
|
const categoryTabs = computed<TabOption[]>(() => [
|
||||||
{ value: '', label: t('common.all') },
|
{ value: '', label: t('common.all') },
|
||||||
@@ -36,32 +42,136 @@ const artifactQuery = computed(() => ({
|
|||||||
categoryId: categoryId.value,
|
categoryId: categoryId.value,
|
||||||
tagIds: tagIds.value.join(',')
|
tagIds: tagIds.value.join(',')
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
type AncientArtifactListInitialData = {
|
||||||
|
options: Options | null;
|
||||||
|
page: ListPage<AncientArtifact> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: initialData } = useAsyncData<AncientArtifactListInitialData>(
|
||||||
|
`ancient-artifact-list-initial:${locale.value}`,
|
||||||
|
async () => {
|
||||||
|
const [optionsResult, artifactsResult] = await Promise.allSettled([
|
||||||
|
api.options(),
|
||||||
|
api.ancientArtifactsPage({
|
||||||
|
...artifactQuery.value,
|
||||||
|
cursor: null,
|
||||||
|
limit: listPageSize
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
options: optionsResult.status === 'fulfilled' ? optionsResult.value : null,
|
||||||
|
page: artifactsResult.status === 'fulfilled' ? artifactsResult.value : null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ default: () => ({ options: null, page: null }) }
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialPageLoaded = ref(false);
|
||||||
|
|
||||||
|
function applyInitialData(data: AncientArtifactListInitialData | null | undefined) {
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
if (!options.value && data.options) {
|
||||||
|
options.value = data.options;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialPageLoaded.value || !data.page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
artifacts.value = data.page.items;
|
||||||
|
nextCursor.value = data.page.nextCursor;
|
||||||
|
hasMoreArtifacts.value = data.page.hasMore;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
const showEditor = computed(() => route.name === 'ancient-artifact-new');
|
const showEditor = computed(() => route.name === 'ancient-artifact-new');
|
||||||
const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.create') === true);
|
const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('items.create') === true);
|
||||||
|
|
||||||
function artifactCardImage(artifact: AncientArtifact) {
|
function artifactCardImage(artifact: AncientArtifact) {
|
||||||
return artifact.image ? { src: artifact.image.url, alt: t('media.imageAlt', { name: artifact.name }) } : undefined;
|
return artifact.image ? { src: artifact.image.url, alt: t('media.imageAlt', { name: artifact.name }) } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadArtifacts() {
|
async function loadArtifacts(reset = true) {
|
||||||
loading.value = true;
|
if (!reset && (loading.value || loadingMore.value || !hasMoreArtifacts.value)) {
|
||||||
artifacts.value = await api.ancientArtifacts(artifactQuery.value);
|
return;
|
||||||
loading.value = false;
|
}
|
||||||
|
|
||||||
|
const requestId = ++loadRequestId;
|
||||||
|
if (reset) {
|
||||||
|
loading.value = true;
|
||||||
|
loadingMore.value = false;
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMoreArtifacts.value = false;
|
||||||
|
} else {
|
||||||
|
loadingMore.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await api.ancientArtifactsPage({
|
||||||
|
...artifactQuery.value,
|
||||||
|
cursor: reset ? null : nextCursor.value,
|
||||||
|
limit: listPageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestId !== loadRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
artifacts.value = page.items;
|
||||||
|
} else {
|
||||||
|
const existingIds = new Set(artifacts.value.map((item) => item.id));
|
||||||
|
artifacts.value = [...artifacts.value, ...page.items.filter((item) => !existingIds.has(item.id))];
|
||||||
|
}
|
||||||
|
nextCursor.value = page.nextCursor;
|
||||||
|
hasMoreArtifacts.value = page.hasMore;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
} catch {
|
||||||
|
if (requestId === loadRequestId && reset) {
|
||||||
|
artifacts.value = [];
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMoreArtifacts.value = false;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === loadRequestId) {
|
||||||
|
loading.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMoreArtifacts() {
|
||||||
|
void loadArtifacts(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (getAuthToken()) {
|
try {
|
||||||
|
currentUser.value = (await api.me()).user;
|
||||||
|
} catch {
|
||||||
|
currentUser.value = null;
|
||||||
|
}
|
||||||
|
if (!options.value) {
|
||||||
try {
|
try {
|
||||||
currentUser.value = (await api.me()).user;
|
options.value = await api.options();
|
||||||
} catch {
|
} catch {
|
||||||
currentUser.value = null;
|
options.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
options.value = await api.options();
|
if (!initialPageLoaded.value) {
|
||||||
await loadArtifacts();
|
await loadArtifacts();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(artifactQuery, loadArtifacts);
|
watch(artifactQuery, () => {
|
||||||
|
void loadArtifacts();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(initialData, applyInitialData, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -138,7 +248,21 @@ watch(artifactQuery, loadArtifacts);
|
|||||||
compact-tooltip
|
compact-tooltip
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="loadingMore" class="entity-grid catalog-card-grid collections-card-grid" aria-hidden="true">
|
||||||
|
<article
|
||||||
|
v-for="index in 2"
|
||||||
|
:key="`artifact-more-${index}`"
|
||||||
|
class="entity-card entity-card--skeleton entity-card--collection-compact"
|
||||||
|
>
|
||||||
|
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||||
|
<div class="entity-card__content">
|
||||||
|
<Skeleton width="128px" height="24px" />
|
||||||
|
<Skeleton width="92px" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<LoadMoreSentinel :active="hasMoreArtifacts" :disabled="loading || loadingMore" @load="loadMoreArtifacts" />
|
||||||
|
|
||||||
<AncientArtifactEdit v-if="showEditor" />
|
<ItemEdit v-if="showEditor" />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from 'vue';
|
import { onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import { api, type DailyChecklistItem } from '../services/api';
|
import { api, type DailyChecklistItem, type ListPage } from '../services/api';
|
||||||
|
|
||||||
type ChecklistState = {
|
type ChecklistState = {
|
||||||
date: string;
|
date: string;
|
||||||
@@ -11,13 +12,40 @@ type ChecklistState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checklistStateKey = 'pokopia_daily_checklist_state';
|
const checklistStateKey = 'pokopia_daily_checklist_state';
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const stateRefreshIntervalMs = 60_000;
|
const stateRefreshIntervalMs = 60_000;
|
||||||
const checklistItems = ref<DailyChecklistItem[]>([]);
|
const checklistItems = ref<DailyChecklistItem[]>([]);
|
||||||
const checkedTaskIds = ref<Set<number>>(new Set());
|
const checkedTaskIds = ref<Set<number>>(new Set());
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const nextCursor = ref<string | null>(null);
|
||||||
|
const hasMoreItems = ref(false);
|
||||||
const skeletonRows = 5;
|
const skeletonRows = 5;
|
||||||
|
const listPageSize = 20;
|
||||||
let stateRefreshTimer: number | null = null;
|
let stateRefreshTimer: number | null = null;
|
||||||
|
let loadRequestId = 0;
|
||||||
|
|
||||||
|
const { data: initialData } = await useAsyncData<ListPage<DailyChecklistItem> | null>(
|
||||||
|
`daily-checklist-initial:${locale.value}`,
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
return await api.dailyChecklistPage({
|
||||||
|
cursor: null,
|
||||||
|
limit: listPageSize
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ default: () => null }
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialPage = initialData.value;
|
||||||
|
checklistItems.value = initialPage?.items ?? [];
|
||||||
|
const initialPageLoaded = ref(initialPage !== null);
|
||||||
|
loading.value = !initialPageLoaded.value;
|
||||||
|
nextCursor.value = initialPage?.nextCursor ?? null;
|
||||||
|
hasMoreItems.value = initialPage?.hasMore ?? false;
|
||||||
|
|
||||||
function todayKey() {
|
function todayKey() {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -85,20 +113,71 @@ function handleTaskChange(id: number, event: Event) {
|
|||||||
toggleTask(id, checkbox?.checked === true);
|
toggleTask(id, checkbox?.checked === true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDailyChecklist() {
|
async function loadDailyChecklist(reset = true) {
|
||||||
loading.value = true;
|
if (!reset && (loading.value || loadingMore.value || !hasMoreItems.value)) {
|
||||||
try {
|
return;
|
||||||
checklistItems.value = await api.dailyChecklist();
|
|
||||||
syncChecklistState();
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestId = ++loadRequestId;
|
||||||
|
if (reset) {
|
||||||
|
loading.value = true;
|
||||||
|
loadingMore.value = false;
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMoreItems.value = false;
|
||||||
|
} else {
|
||||||
|
loadingMore.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await api.dailyChecklistPage({
|
||||||
|
cursor: reset ? null : nextCursor.value,
|
||||||
|
limit: listPageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestId !== loadRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
checklistItems.value = page.items;
|
||||||
|
} else {
|
||||||
|
const existingIds = new Set(checklistItems.value.map((item) => item.id));
|
||||||
|
checklistItems.value = [...checklistItems.value, ...page.items.filter((item) => !existingIds.has(item.id))];
|
||||||
|
}
|
||||||
|
nextCursor.value = page.nextCursor;
|
||||||
|
hasMoreItems.value = page.hasMore;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
if (!page.hasMore) {
|
||||||
|
syncChecklistState();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (requestId === loadRequestId && reset) {
|
||||||
|
checklistItems.value = [];
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMoreItems.value = false;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === loadRequestId) {
|
||||||
|
loading.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMoreDailyChecklist() {
|
||||||
|
void loadDailyChecklist(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadChecklistState();
|
loadChecklistState();
|
||||||
|
if (initialPageLoaded.value && !hasMoreItems.value) {
|
||||||
|
syncChecklistState();
|
||||||
|
}
|
||||||
stateRefreshTimer = window.setInterval(loadChecklistState, stateRefreshIntervalMs);
|
stateRefreshTimer = window.setInterval(loadChecklistState, stateRefreshIntervalMs);
|
||||||
void loadDailyChecklist();
|
if (!initialPageLoaded.value) {
|
||||||
|
void loadDailyChecklist();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -135,9 +214,14 @@ onUnmounted(() => {
|
|||||||
<span>{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
|
<li v-for="index in loadingMore ? 2 : 0" :key="`checklist-more-${index}`" class="checklist-item checklist-check" aria-hidden="true">
|
||||||
|
<Skeleton variant="box" width="34px" height="34px" />
|
||||||
|
<Skeleton :width="index % 2 === 0 ? '220px' : '160px'" />
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p v-else class="meta-line">{{ t('pages.checklist.empty') }}</p>
|
<p v-else class="meta-line">{{ t('pages.checklist.empty') }}</p>
|
||||||
|
<LoadMoreSentinel :active="hasMoreItems" :disabled="loading || loadingMore" @load="loadMoreDailyChecklist" />
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import TranslationFields from '../components/TranslationFields.vue';
|
|||||||
import { iconAdd, iconCancel, iconDelete, iconDish, iconEdit, iconItem, iconSave } from '../icons';
|
import { iconAdd, iconCancel, iconDelete, iconDish, iconEdit, iconItem, iconSave } from '../icons';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
getAuthToken,
|
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
type Dish,
|
type Dish,
|
||||||
type DishCategory,
|
type DishCategory,
|
||||||
@@ -25,7 +24,7 @@ import {
|
|||||||
type TranslationMap
|
type TranslationMap
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const categories = ref<DishCategory[]>([]);
|
const categories = ref<DishCategory[]>([]);
|
||||||
const activeCategoryId = ref('');
|
const activeCategoryId = ref('');
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@@ -96,6 +95,24 @@ const dishFormValid = computed(
|
|||||||
dishForm.value.mosslaxEffect.trim() !== ''
|
dishForm.value.mosslaxEffect.trim() !== ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: initialData } = await useAsyncData<DishCategory[] | null>(
|
||||||
|
`dish-initial:${locale.value}`,
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
return await api.dish();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ default: () => null }
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialCategories = initialData.value;
|
||||||
|
categories.value = initialCategories ?? [];
|
||||||
|
activeCategoryId.value = categories.value[0] ? String(categories.value[0].id) : '';
|
||||||
|
const initialCategoriesLoaded = ref(initialCategories !== null);
|
||||||
|
loading.value = !initialCategoriesLoaded.value;
|
||||||
|
|
||||||
function itemImage(item: ItemLink) {
|
function itemImage(item: ItemLink) {
|
||||||
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : null;
|
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : null;
|
||||||
}
|
}
|
||||||
@@ -221,6 +238,7 @@ async function loadDish(showSkeleton = false) {
|
|||||||
}
|
}
|
||||||
categories.value = await api.dish();
|
categories.value = await api.dish();
|
||||||
activeCategoryId.value = categories.value[0] ? String(categories.value[0].id) : '';
|
activeCategoryId.value = categories.value[0] ? String(categories.value[0].id) : '';
|
||||||
|
initialCategoriesLoaded.value = true;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,14 +300,12 @@ async function loadEditorOptions() {
|
|||||||
|
|
||||||
async function loadPage() {
|
async function loadPage() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
if (getAuthToken()) {
|
try {
|
||||||
try {
|
currentUser.value = (await api.me()).user;
|
||||||
currentUser.value = (await api.me()).user;
|
} catch {
|
||||||
} catch {
|
currentUser.value = null;
|
||||||
currentUser.value = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await Promise.all([loadDish(), loadEditorOptions()]);
|
await Promise.all([initialCategoriesLoaded.value ? Promise.resolve() : loadDish(), loadEditorOptions()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(categories, (nextCategories) => {
|
watch(categories, (nextCategories) => {
|
||||||
|
|||||||
@@ -12,17 +12,18 @@ import PokeBallMark from '../components/PokeBallMark.vue';
|
|||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import { iconBack, iconEdit, iconHabitat } from '../icons';
|
import { iconBack, iconEdit, iconHabitat } from '../icons';
|
||||||
import { applySeo } from '../seo';
|
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
|
||||||
import { api, getAuthToken, type AuthUser, type HabitatDetail } from '../services/api';
|
import { api, type AuthUser, type HabitatDetail } from '../services/api';
|
||||||
import HabitatEdit from './HabitatEdit.vue';
|
import HabitatEdit from './HabitatEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const habitat = ref<HabitatDetail | null>(null);
|
const habitat = ref<HabitatDetail | null>(null);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const detailTab = ref('details');
|
const detailTab = ref('details');
|
||||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||||
const weathers = ['晴天', '阴天', '雨天'];
|
const weathers = ['晴天', '阴天', '雨天'];
|
||||||
|
const habitatDetailRouteNames = new Set(['habitat-detail', 'habitat-edit']);
|
||||||
const showEditor = computed(() => route.name === 'habitat-edit');
|
const showEditor = computed(() => route.name === 'habitat-edit');
|
||||||
const canUpdateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.update') === true);
|
const canUpdateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.update') === true);
|
||||||
const listPath = computed(() => (habitat.value?.isEventItem ? '/event-habitats' : '/habitats'));
|
const listPath = computed(() => (habitat.value?.isEventItem ? '/event-habitats' : '/habitats'));
|
||||||
@@ -33,6 +34,44 @@ const detailTabs = computed<TabOption[]>(() => [
|
|||||||
{ value: 'history', label: t('history.editHistory') }
|
{ value: 'history', label: t('history.editHistory') }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const { data: initialHabitat } = useAsyncData<HabitatDetail | null>(
|
||||||
|
`habitat-detail:${activeHabitatRouteId() ?? 'none'}:${locale.value}`,
|
||||||
|
async () => {
|
||||||
|
const routeId = activeHabitatRouteId();
|
||||||
|
if (!routeId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await api.habitatDetail(routeId);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ default: () => null }
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialHabitatLoaded = ref(false);
|
||||||
|
const habitatSeo = computed(() =>
|
||||||
|
habitat.value && route.meta.editorModal !== true
|
||||||
|
? resolveSeo({
|
||||||
|
title: `${habitat.value.name} - ${t(habitat.value.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`,
|
||||||
|
description: t('seo.habitatDetailDescription', { name: habitat.value.name }),
|
||||||
|
canonicalPath: `/habitats/${habitat.value.id}`,
|
||||||
|
image: habitat.value.image?.url
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
useHead(() => (habitatSeo.value ? resolvedSeoHead(habitatSeo.value) : {}));
|
||||||
|
|
||||||
|
function applyInitialHabitat(value: HabitatDetail | null | undefined) {
|
||||||
|
if (!value || initialHabitatLoaded.value) return;
|
||||||
|
|
||||||
|
habitat.value = value;
|
||||||
|
initialHabitatLoaded.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
type PokemonRow = {
|
type PokemonRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -73,6 +112,15 @@ function weatherLabel(value: string): string {
|
|||||||
return labels[value] ?? value;
|
return labels[value] ?? value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activeHabitatRouteId(): string | null {
|
||||||
|
return typeof route.name === 'string' &&
|
||||||
|
habitatDetailRouteNames.has(route.name) &&
|
||||||
|
typeof route.params.id === 'string' &&
|
||||||
|
route.params.id.trim() !== ''
|
||||||
|
? route.params.id
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
const pokemonRows = computed<PokemonRow[]>(() => {
|
const pokemonRows = computed<PokemonRow[]>(() => {
|
||||||
if (!habitat.value) return [];
|
if (!habitat.value) return [];
|
||||||
|
|
||||||
@@ -119,28 +167,41 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function loadHabitatDetail() {
|
async function loadHabitatDetail() {
|
||||||
const nextHabitat = await api.habitatDetail(String(route.params.id));
|
const routeId = activeHabitatRouteId();
|
||||||
habitat.value = nextHabitat;
|
if (!routeId) {
|
||||||
|
initialHabitatLoaded.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (route.meta.editorModal !== true) {
|
try {
|
||||||
applySeo({
|
const nextHabitat = await api.habitatDetail(routeId);
|
||||||
title: `${nextHabitat.name} - ${t(nextHabitat.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`,
|
habitat.value = nextHabitat;
|
||||||
description: t('seo.habitatDetailDescription', { name: nextHabitat.name }),
|
initialHabitatLoaded.value = true;
|
||||||
canonicalPath: `/habitats/${nextHabitat.id}`,
|
|
||||||
image: nextHabitat.image?.url
|
if (route.meta.editorModal !== true) {
|
||||||
});
|
applySeo({
|
||||||
|
title: `${nextHabitat.name} - ${t(nextHabitat.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`,
|
||||||
|
description: t('seo.habitatDetailDescription', { name: nextHabitat.name }),
|
||||||
|
canonicalPath: `/habitats/${nextHabitat.id}`,
|
||||||
|
image: nextHabitat.image?.url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
habitat.value = null;
|
||||||
|
initialHabitatLoaded.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (getAuthToken()) {
|
try {
|
||||||
try {
|
currentUser.value = (await api.me()).user;
|
||||||
currentUser.value = (await api.me()).user;
|
} catch {
|
||||||
} catch {
|
currentUser.value = null;
|
||||||
currentUser.value = null;
|
}
|
||||||
}
|
|
||||||
|
if (!initialHabitatLoaded.value) {
|
||||||
|
await loadHabitatDetail();
|
||||||
}
|
}
|
||||||
await loadHabitatDetail();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -155,11 +216,17 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => route.params.id,
|
() => route.params.id,
|
||||||
() => {
|
() => {
|
||||||
|
if (!activeHabitatRouteId()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
habitat.value = null;
|
habitat.value = null;
|
||||||
detailTab.value = 'details';
|
detailTab.value = 'details';
|
||||||
void loadHabitatDetail();
|
void loadHabitatDetail();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(initialHabitat, applyInitialHabitat, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import TranslationFields from '../components/TranslationFields.vue';
|
|||||||
import { iconAdd, iconCancel, iconDelete, iconPokemon, iconSave } from '../icons';
|
import { iconAdd, iconCancel, iconDelete, iconPokemon, iconSave } from '../icons';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
getAuthToken,
|
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
type ConfigType,
|
type ConfigType,
|
||||||
type EntityImage,
|
type EntityImage,
|
||||||
@@ -156,11 +155,6 @@ function habitatNameForSave() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCurrentUser() {
|
async function loadCurrentUser() {
|
||||||
if (!getAuthToken()) {
|
|
||||||
currentUser.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
currentUser.value = (await api.me()).user;
|
currentUser.value = (await api.me()).user;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { computed, onMounted, ref, watch } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
|
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import { iconAdd, iconHabitat } from '../icons';
|
import { iconAdd, iconHabitat } from '../icons';
|
||||||
import { api, getAuthToken, type AuthUser, type Habitat } from '../services/api';
|
import { api, type AuthUser, type Habitat, type ListPage } from '../services/api';
|
||||||
import HabitatEdit from './HabitatEdit.vue';
|
import HabitatEdit from './HabitatEdit.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -17,12 +18,46 @@ const props = defineProps<{
|
|||||||
const habitats = ref<Habitat[]>([]);
|
const habitats = ref<Habitat[]>([]);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
const loading = ref(true);
|
const loadingMore = ref(false);
|
||||||
|
const nextCursor = ref<string | null>(null);
|
||||||
|
const hasMoreHabitats = ref(false);
|
||||||
const skeletonCardCount = 6;
|
const skeletonCardCount = 6;
|
||||||
|
const listPageSize = 24;
|
||||||
|
let loadRequestId = 0;
|
||||||
const query = computed(() => ({
|
const query = computed(() => ({
|
||||||
isEventItem: props.eventOnly ? 'true' : 'false'
|
isEventItem: props.eventOnly ? 'true' : 'false'
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const { data: initialData } = useAsyncData<ListPage<Habitat> | null>(
|
||||||
|
`${props.eventOnly ? 'event-habitat-list-initial' : 'habitat-list-initial'}:${locale.value}`,
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
return await api.habitatsPage({
|
||||||
|
...query.value,
|
||||||
|
cursor: null,
|
||||||
|
limit: listPageSize
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ default: () => null }
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialPageLoaded = ref(false);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
function applyInitialData(page: ListPage<Habitat> | null | undefined) {
|
||||||
|
if (!page || initialPageLoaded.value) return;
|
||||||
|
|
||||||
|
habitats.value = page.items;
|
||||||
|
nextCursor.value = page.nextCursor;
|
||||||
|
hasMoreHabitats.value = page.hasMore;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
const showEditor = computed(() => route.name === 'habitat-new' || route.name === 'event-habitat-new');
|
const showEditor = computed(() => route.name === 'habitat-new' || route.name === 'event-habitat-new');
|
||||||
const canCreateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.create') === true);
|
const canCreateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.create') === true);
|
||||||
const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventHabitats.title' : 'pages.habitats.title'));
|
const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventHabitats.title' : 'pages.habitats.title'));
|
||||||
@@ -35,24 +70,76 @@ function habitatCardImage(item: Habitat) {
|
|||||||
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
|
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadHabitats() {
|
async function loadHabitats(reset = true) {
|
||||||
loading.value = true;
|
if (!reset && (loading.value || loadingMore.value || !hasMoreHabitats.value)) {
|
||||||
habitats.value = await api.habitats(query.value);
|
return;
|
||||||
loading.value = false;
|
}
|
||||||
|
|
||||||
|
const requestId = ++loadRequestId;
|
||||||
|
if (reset) {
|
||||||
|
loading.value = true;
|
||||||
|
loadingMore.value = false;
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMoreHabitats.value = false;
|
||||||
|
} else {
|
||||||
|
loadingMore.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await api.habitatsPage({
|
||||||
|
...query.value,
|
||||||
|
cursor: reset ? null : nextCursor.value,
|
||||||
|
limit: listPageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestId !== loadRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
habitats.value = page.items;
|
||||||
|
} else {
|
||||||
|
const existingIds = new Set(habitats.value.map((item) => item.id));
|
||||||
|
habitats.value = [...habitats.value, ...page.items.filter((item) => !existingIds.has(item.id))];
|
||||||
|
}
|
||||||
|
nextCursor.value = page.nextCursor;
|
||||||
|
hasMoreHabitats.value = page.hasMore;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
} catch {
|
||||||
|
if (requestId === loadRequestId && reset) {
|
||||||
|
habitats.value = [];
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMoreHabitats.value = false;
|
||||||
|
initialPageLoaded.value = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === loadRequestId) {
|
||||||
|
loading.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMoreHabitats() {
|
||||||
|
void loadHabitats(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (getAuthToken()) {
|
try {
|
||||||
try {
|
currentUser.value = (await api.me()).user;
|
||||||
currentUser.value = (await api.me()).user;
|
} catch {
|
||||||
} catch {
|
currentUser.value = null;
|
||||||
currentUser.value = null;
|
}
|
||||||
}
|
if (!initialPageLoaded.value) {
|
||||||
|
await loadHabitats();
|
||||||
}
|
}
|
||||||
await loadHabitats();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(query, loadHabitats);
|
watch(query, () => {
|
||||||
|
void loadHabitats();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(initialData, applyInitialData, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -85,6 +172,15 @@ watch(query, loadHabitats);
|
|||||||
:image="habitatCardImage(item)"
|
:image="habitatCardImage(item)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="loadingMore" class="entity-grid pokemon-list-grid" aria-hidden="true">
|
||||||
|
<article v-for="index in 2" :key="`habitat-more-${index}`" class="entity-card entity-card--skeleton">
|
||||||
|
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||||
|
<div class="entity-card__content">
|
||||||
|
<Skeleton width="128px" height="24px" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<LoadMoreSentinel :active="hasMoreHabitats" :disabled="loading || loadingMore" @load="loadMoreHabitats" />
|
||||||
|
|
||||||
<HabitatEdit v-if="showEditor" />
|
<HabitatEdit v-if="showEditor" />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -63,8 +63,27 @@ const showProjectUpdates = computed(
|
|||||||
const showProjectUpdatesViewAll = computed(() => projectCommits.value.length > 0 || latestReleases.value.length > 0);
|
const showProjectUpdatesViewAll = computed(() => projectCommits.value.length > 0 || latestReleases.value.length > 0);
|
||||||
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
|
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
|
||||||
|
|
||||||
|
const { data: initialProjectUpdates } = await useAsyncData<ProjectUpdates | null>(
|
||||||
|
`home-project-updates:${locale.value}`,
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
return await api.projectUpdates({ limit: projectCommitPageSize });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ default: () => null }
|
||||||
|
);
|
||||||
|
|
||||||
|
projectUpdates.value = initialProjectUpdates.value;
|
||||||
|
projectCommits.value = initialProjectUpdates.value?.commits.items ?? [];
|
||||||
|
const initialProjectUpdatesLoaded = ref(initialProjectUpdates.value !== null);
|
||||||
|
projectUpdatesLoading.value = !initialProjectUpdatesLoaded.value;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadProjectUpdates();
|
if (!initialProjectUpdatesLoaded.value) {
|
||||||
|
void loadProjectUpdates();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function sectionTitleKey(key: string) {
|
function sectionTitleKey(key: string) {
|
||||||
@@ -81,9 +100,11 @@ async function loadProjectUpdates(): Promise<void> {
|
|||||||
const updates = await api.projectUpdates({ limit: projectCommitPageSize });
|
const updates = await api.projectUpdates({ limit: projectCommitPageSize });
|
||||||
projectUpdates.value = updates;
|
projectUpdates.value = updates;
|
||||||
projectCommits.value = updates.commits.items;
|
projectCommits.value = updates.commits.items;
|
||||||
|
initialProjectUpdatesLoaded.value = true;
|
||||||
} catch {
|
} catch {
|
||||||
projectUpdates.value = null;
|
projectUpdates.value = null;
|
||||||
projectCommits.value = [];
|
projectCommits.value = [];
|
||||||
|
initialProjectUpdatesLoaded.value = true;
|
||||||
} finally {
|
} finally {
|
||||||
projectUpdatesLoading.value = false;
|
projectUpdatesLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||||
@@ -12,16 +12,19 @@ import PokeBallMark from '../components/PokeBallMark.vue';
|
|||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
|
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
|
||||||
import { applySeo } from '../seo';
|
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
|
||||||
import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/api';
|
import { api, type AuthUser, type ItemDetail } from '../services/api';
|
||||||
import ItemEdit from './ItemEdit.vue';
|
import ItemEdit from './ItemEdit.vue';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const router = useRouter();
|
||||||
|
const { locale, t } = useI18n();
|
||||||
const item = ref<ItemDetail | null>(null);
|
const item = ref<ItemDetail | null>(null);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const detailTab = ref('details');
|
const detailTab = ref('details');
|
||||||
const showEditor = computed(() => route.name === 'item-edit');
|
const itemDetailRouteNames = new Set(['item-detail', 'item-edit', 'ancient-artifact-detail', 'ancient-artifact-edit']);
|
||||||
|
const isAncientArtifactRoute = computed(() => route.name === 'ancient-artifact-detail' || route.name === 'ancient-artifact-edit');
|
||||||
|
const showEditor = computed(() => route.name === 'item-edit' || route.name === 'ancient-artifact-edit');
|
||||||
const canUpdateItem = computed(() => currentUser.value?.permissions.includes('items.update') === true);
|
const canUpdateItem = computed(() => currentUser.value?.permissions.includes('items.update') === true);
|
||||||
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
|
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
|
||||||
const detailTabs = computed<TabOption[]>(() => [
|
const detailTabs = computed<TabOption[]>(() => [
|
||||||
@@ -36,8 +39,78 @@ const itemSubtitle = computed(() => {
|
|||||||
|
|
||||||
return item.value.usage ? `${item.value.category.name} · ${item.value.usage.name}` : item.value.category.name;
|
return item.value.usage ? `${item.value.category.name} · ${item.value.usage.name}` : item.value.category.name;
|
||||||
});
|
});
|
||||||
const detailKicker = computed(() => (item.value?.isEventItem ? t('pages.eventItems.detailKicker') : t('pages.items.detailKicker')));
|
const detailKicker = computed(() =>
|
||||||
const listTarget = computed(() => (item.value?.isEventItem ? '/event-items' : '/items'));
|
isAncientArtifactRoute.value
|
||||||
|
? t('pages.ancientArtifacts.detailKicker')
|
||||||
|
: item.value?.isEventItem
|
||||||
|
? t('pages.eventItems.detailKicker')
|
||||||
|
: t('pages.items.detailKicker')
|
||||||
|
);
|
||||||
|
const listTarget = computed(() => (isAncientArtifactRoute.value ? '/ancient-artifacts' : item.value?.isEventItem ? '/event-items' : '/items'));
|
||||||
|
const editTarget = computed(() =>
|
||||||
|
item.value ? (isAncientArtifactRoute.value ? `/ancient-artifacts/${item.value.id}/edit` : `/items/${item.value.id}/edit`) : ''
|
||||||
|
);
|
||||||
|
const detailCanonicalPath = computed(() =>
|
||||||
|
item.value ? (isAncientArtifactRoute.value ? `/ancient-artifacts/${item.value.id}` : `/items/${item.value.id}`) : ''
|
||||||
|
);
|
||||||
|
const detailTitleKey = computed(() =>
|
||||||
|
isAncientArtifactRoute.value ? 'pages.ancientArtifacts.title' : item.value?.isEventItem ? 'pages.eventItems.title' : 'pages.items.title'
|
||||||
|
);
|
||||||
|
const detailDescriptionKey = computed(() =>
|
||||||
|
isAncientArtifactRoute.value ? 'seo.ancientArtifactDetailDescription' : 'seo.itemDetailDescription'
|
||||||
|
);
|
||||||
|
const basePriceDisplay = computed(() => {
|
||||||
|
const price = item.value?.basePrice;
|
||||||
|
return price === null || price === undefined ? t('common.none') : new Intl.NumberFormat(locale.value).format(price);
|
||||||
|
});
|
||||||
|
const possibleTagSections = computed(() => [
|
||||||
|
{ key: 'highlyLikely', title: t('pages.items.highlyLikelyTags'), tags: item.value?.possibleTags?.highlyLikely ?? [] },
|
||||||
|
{ key: 'possible', title: t('pages.items.possibleTagsPossible'), tags: item.value?.possibleTags?.possible ?? [] },
|
||||||
|
{ key: 'excluded', title: t('pages.items.excludedTags'), tags: item.value?.possibleTags?.excluded ?? [] }
|
||||||
|
]);
|
||||||
|
const possibleTagEvidenceSections = computed(() => [
|
||||||
|
{ key: 'likes', title: t('pages.pokemon.tradingLikes'), rows: item.value?.possibleTags?.evidence.likes ?? [] },
|
||||||
|
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { data: initialItem } = useAsyncData<ItemDetail | null>(
|
||||||
|
`item-detail:${String(route.name)}:${activeItemRouteId() ?? 'none'}:${locale.value}`,
|
||||||
|
async () => {
|
||||||
|
const routeId = activeItemRouteId();
|
||||||
|
if (!routeId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nextItem = await api.itemDetail(routeId);
|
||||||
|
return isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory ? null : nextItem;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ default: () => null }
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialItemLoaded = ref(false);
|
||||||
|
const itemSeo = computed(() =>
|
||||||
|
item.value && route.meta.editorModal !== true
|
||||||
|
? resolveSeo({
|
||||||
|
title: `${item.value.name} - ${t(detailTitleKey.value)}`,
|
||||||
|
description: t(detailDescriptionKey.value, { name: item.value.name }),
|
||||||
|
canonicalPath: detailCanonicalPath.value,
|
||||||
|
image: item.value.image?.url
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
useHead(() => (itemSeo.value ? resolvedSeoHead(itemSeo.value) : {}));
|
||||||
|
|
||||||
|
function applyInitialItem(value: ItemDetail | null | undefined) {
|
||||||
|
if (!value || initialItemLoaded.value) return;
|
||||||
|
|
||||||
|
item.value = value;
|
||||||
|
initialItemLoaded.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
const customization = computed(() => {
|
const customization = computed(() => {
|
||||||
if (!item.value) {
|
if (!item.value) {
|
||||||
@@ -52,34 +125,65 @@ const customization = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function loadItemDetail() {
|
async function loadItemDetail() {
|
||||||
const nextItem = await api.itemDetail(String(route.params.id));
|
const routeId = activeItemRouteId();
|
||||||
item.value = nextItem;
|
if (!routeId) {
|
||||||
|
initialItemLoaded.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (route.meta.editorModal !== true) {
|
try {
|
||||||
applySeo({
|
const nextItem = await api.itemDetail(routeId);
|
||||||
title: `${nextItem.name} - ${t(nextItem.isEventItem ? 'pages.eventItems.title' : 'pages.items.title')}`,
|
|
||||||
description: t('seo.itemDetailDescription', { name: nextItem.name }),
|
if (isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory) {
|
||||||
canonicalPath: `/items/${nextItem.id}`,
|
await router.replace(route.name === 'ancient-artifact-edit' ? `/items/${nextItem.id}/edit` : `/items/${nextItem.id}`);
|
||||||
image: nextItem.image?.url
|
return;
|
||||||
});
|
}
|
||||||
|
|
||||||
|
item.value = nextItem;
|
||||||
|
initialItemLoaded.value = true;
|
||||||
|
|
||||||
|
if (route.meta.editorModal !== true) {
|
||||||
|
applySeo({
|
||||||
|
title: `${nextItem.name} - ${t(detailTitleKey.value)}`,
|
||||||
|
description: t(detailDescriptionKey.value, { name: nextItem.name }),
|
||||||
|
canonicalPath: detailCanonicalPath.value,
|
||||||
|
image: nextItem.image?.url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
item.value = null;
|
||||||
|
initialItemLoaded.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isItemDetailRouteName(value: unknown) {
|
||||||
|
return typeof value === 'string' && itemDetailRouteNames.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeItemRouteId(): string | null {
|
||||||
|
return isItemDetailRouteName(route.name) && typeof route.params.id === 'string' && route.params.id.trim() !== ''
|
||||||
|
? route.params.id
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (getAuthToken()) {
|
try {
|
||||||
try {
|
currentUser.value = (await api.me()).user;
|
||||||
currentUser.value = (await api.me()).user;
|
} catch {
|
||||||
} catch {
|
currentUser.value = null;
|
||||||
currentUser.value = null;
|
}
|
||||||
}
|
|
||||||
|
if (!initialItemLoaded.value) {
|
||||||
|
await loadItemDetail();
|
||||||
}
|
}
|
||||||
await loadItemDetail();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.name,
|
() => route.name,
|
||||||
(name, oldName) => {
|
(name, oldName) => {
|
||||||
if (oldName === 'item-edit' && name === 'item-detail') {
|
if (name !== oldName && isItemDetailRouteName(name) && isItemDetailRouteName(oldName)) {
|
||||||
|
item.value = null;
|
||||||
|
detailTab.value = 'details';
|
||||||
void loadItemDetail();
|
void loadItemDetail();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,11 +192,17 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => route.params.id,
|
() => route.params.id,
|
||||||
() => {
|
() => {
|
||||||
|
if (!activeItemRouteId()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
item.value = null;
|
item.value = null;
|
||||||
detailTab.value = 'details';
|
detailTab.value = 'details';
|
||||||
void loadItemDetail();
|
void loadItemDetail();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(initialItem, applyInitialItem, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -152,7 +262,7 @@ watch(
|
|||||||
<PageHeader :title="item.name" :subtitle="itemSubtitle">
|
<PageHeader :title="item.name" :subtitle="itemSubtitle">
|
||||||
<template #kicker>{{ detailKicker }}</template>
|
<template #kicker>{{ detailKicker }}</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
|
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="editTarget">
|
||||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('common.edit') }}
|
{{ t('common.edit') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -190,6 +300,14 @@ watch(
|
|||||||
<dt>{{ t('pages.items.usage') }}</dt>
|
<dt>{{ t('pages.items.usage') }}</dt>
|
||||||
<dd>{{ item.usage?.name ?? t('common.none') }}</dd>
|
<dd>{{ item.usage?.name ?? t('common.none') }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('pages.items.basePrice') }}</dt>
|
||||||
|
<dd>{{ basePriceDisplay }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="item.ancientArtifactCategory">
|
||||||
|
<dt>{{ t('pages.items.ancientArtifact') }}</dt>
|
||||||
|
<dd>{{ item.ancientArtifactCategory.name }}</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>{{ t('pages.items.recipeInfo') }}</dt>
|
<dt>{{ t('pages.items.recipeInfo') }}</dt>
|
||||||
<dd>{{ item.noRecipe ? t('pages.items.noRecipe') : item.recipe ? item.recipe.name : t('common.none') }}</dd>
|
<dd>{{ item.noRecipe ? t('pages.items.noRecipe') : item.recipe ? item.recipe.name : t('common.none') }}</dd>
|
||||||
@@ -224,6 +342,39 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DetailSection :title="t('pages.items.possibleTags')">
|
||||||
|
<div class="possible-tags-grid">
|
||||||
|
<div v-for="section in possibleTagSections" :key="section.key" class="possible-tags-group">
|
||||||
|
<h3 class="section-subtitle">{{ section.title }}</h3>
|
||||||
|
<EntityChips v-if="section.tags.length" :items="section.tags" />
|
||||||
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="possible-tags-evidence">
|
||||||
|
<h3 class="section-subtitle">{{ t('pages.items.possibleTagsEvidence') }}</h3>
|
||||||
|
<div class="possible-tags-evidence__grid">
|
||||||
|
<div v-for="section in possibleTagEvidenceSections" :key="section.key" class="possible-tags-evidence__group">
|
||||||
|
<h4>{{ section.title }}</h4>
|
||||||
|
<ul v-if="section.rows.length" class="row-list possible-tags-evidence__list">
|
||||||
|
<li v-for="entry in section.rows" :key="`${section.key}-${entry.pokemon.id}`">
|
||||||
|
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/pokemon/${entry.pokemon.id}`">
|
||||||
|
<span class="related-entity-media related-entity-media--inline related-entity-media--pokemon" aria-hidden="true">
|
||||||
|
<img v-if="entry.pokemon.image" :src="entry.pokemon.image.url" alt="" loading="lazy" />
|
||||||
|
<PokeBallMark v-else size="22px" />
|
||||||
|
</span>
|
||||||
|
<span>#{{ entry.pokemon.displayId }} {{ entry.pokemon.name }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
<EntityChips v-if="entry.tags.length" :items="entry.tags" />
|
||||||
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
<DetailSection :title="t('pages.items.recipeInfo')">
|
<DetailSection :title="t('pages.items.recipeInfo')">
|
||||||
<template v-if="item.recipe">
|
<template v-if="item.recipe">
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import TranslationFields from '../components/TranslationFields.vue';
|
|||||||
import { iconCancel, iconSave } from '../icons';
|
import { iconCancel, iconSave } from '../icons';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
getAuthToken,
|
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
type ConfigType,
|
type ConfigType,
|
||||||
type EntityImage,
|
type EntityImage,
|
||||||
@@ -38,6 +37,8 @@ const creatingSelect = ref('');
|
|||||||
const itemForm = ref({
|
const itemForm = ref({
|
||||||
name: '',
|
name: '',
|
||||||
details: '',
|
details: '',
|
||||||
|
basePrice: '',
|
||||||
|
ancientArtifactCategoryId: '',
|
||||||
translations: {} as TranslationMap,
|
translations: {} as TranslationMap,
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
usageId: '',
|
usageId: '',
|
||||||
@@ -53,6 +54,7 @@ const itemForm = ref({
|
|||||||
|
|
||||||
type ItemCreateDefaults = {
|
type ItemCreateDefaults = {
|
||||||
categoryId: string;
|
categoryId: string;
|
||||||
|
usageId: string;
|
||||||
dyeable: boolean;
|
dyeable: boolean;
|
||||||
dualDyeable: boolean;
|
dualDyeable: boolean;
|
||||||
patternEditable: boolean;
|
patternEditable: boolean;
|
||||||
@@ -65,18 +67,39 @@ const itemCreateDefaultsStorageKey = 'pokopia_item_create_defaults';
|
|||||||
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||||
const isEditing = computed(() => routeId.value !== '');
|
const isEditing = computed(() => routeId.value !== '');
|
||||||
const isEventCreate = computed(() => route.name === 'event-item-new');
|
const isEventCreate = computed(() => route.name === 'event-item-new');
|
||||||
|
const isAncientArtifactRoute = computed(() => route.name === 'ancient-artifact-new' || route.name === 'ancient-artifact-edit');
|
||||||
|
const isAncientArtifactCreate = computed(() => route.name === 'ancient-artifact-new');
|
||||||
const insertBeforeItemId = computed(() => queryItemId(route.query.insertBeforeItemId));
|
const insertBeforeItemId = computed(() => queryItemId(route.query.insertBeforeItemId));
|
||||||
const insertAfterItemId = computed(() => queryItemId(route.query.insertAfterItemId));
|
const insertAfterItemId = computed(() => queryItemId(route.query.insertAfterItemId));
|
||||||
const pageTitle = computed(() =>
|
const pageTitle = computed(() =>
|
||||||
isEditing.value
|
isEditing.value
|
||||||
? t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') })
|
? isAncientArtifactRoute.value
|
||||||
|
? t('pages.ancientArtifacts.editTitle', { name: itemForm.value.name || t('pages.ancientArtifacts.fallbackName') })
|
||||||
|
: t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') })
|
||||||
|
: isAncientArtifactCreate.value
|
||||||
|
? t('pages.ancientArtifacts.newTitle')
|
||||||
: isEventCreate.value
|
: isEventCreate.value
|
||||||
? t('pages.eventItems.newTitle')
|
? t('pages.eventItems.newTitle')
|
||||||
: t('pages.items.newTitle')
|
: t('pages.items.newTitle')
|
||||||
);
|
);
|
||||||
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : isEventCreate.value ? '/event-items' : '/items'));
|
const pageSubtitle = computed(() => (isAncientArtifactRoute.value ? t('pages.ancientArtifacts.editSubtitle') : t('pages.items.editSubtitle')));
|
||||||
|
const cancelTo = computed(() =>
|
||||||
|
isEditing.value
|
||||||
|
? isAncientArtifactRoute.value
|
||||||
|
? `/ancient-artifacts/${routeId.value}`
|
||||||
|
: `/items/${routeId.value}`
|
||||||
|
: isAncientArtifactCreate.value
|
||||||
|
? '/ancient-artifacts'
|
||||||
|
: isEventCreate.value
|
||||||
|
? '/event-items'
|
||||||
|
: '/items'
|
||||||
|
);
|
||||||
const hasRecipe = ref(false);
|
const hasRecipe = ref(false);
|
||||||
const imageEntityName = computed(() => itemNameForSave().trim());
|
const imageEntityName = computed(() => itemNameForSave().trim());
|
||||||
|
const ancientArtifactOptions = computed(() => [
|
||||||
|
{ value: '', label: t('common.no') },
|
||||||
|
...(options.value?.ancientArtifactCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
|
||||||
|
]);
|
||||||
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
||||||
const canUploadImage = computed(() => currentUser.value?.permissions.includes('items.upload') === true);
|
const canUploadImage = computed(() => currentUser.value?.permissions.includes('items.upload') === true);
|
||||||
|
|
||||||
@@ -98,6 +121,7 @@ function readItemCreateDefaults(): ItemCreateDefaults {
|
|||||||
if (typeof sessionStorage === 'undefined') {
|
if (typeof sessionStorage === 'undefined') {
|
||||||
return {
|
return {
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
|
usageId: '',
|
||||||
dyeable: false,
|
dyeable: false,
|
||||||
dualDyeable: false,
|
dualDyeable: false,
|
||||||
patternEditable: false,
|
patternEditable: false,
|
||||||
@@ -111,6 +135,7 @@ function readItemCreateDefaults(): ItemCreateDefaults {
|
|||||||
if (!rawValue) {
|
if (!rawValue) {
|
||||||
return {
|
return {
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
|
usageId: '',
|
||||||
dyeable: false,
|
dyeable: false,
|
||||||
dualDyeable: false,
|
dualDyeable: false,
|
||||||
patternEditable: false,
|
patternEditable: false,
|
||||||
@@ -122,6 +147,7 @@ function readItemCreateDefaults(): ItemCreateDefaults {
|
|||||||
const parsedValue = JSON.parse(rawValue) as Partial<ItemCreateDefaults>;
|
const parsedValue = JSON.parse(rawValue) as Partial<ItemCreateDefaults>;
|
||||||
return {
|
return {
|
||||||
categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '',
|
categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '',
|
||||||
|
usageId: typeof parsedValue.usageId === 'string' ? parsedValue.usageId : '',
|
||||||
dyeable: parsedValue.dyeable === true,
|
dyeable: parsedValue.dyeable === true,
|
||||||
dualDyeable: parsedValue.dualDyeable === true,
|
dualDyeable: parsedValue.dualDyeable === true,
|
||||||
patternEditable: parsedValue.patternEditable === true,
|
patternEditable: parsedValue.patternEditable === true,
|
||||||
@@ -133,6 +159,7 @@ function readItemCreateDefaults(): ItemCreateDefaults {
|
|||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
|
usageId: '',
|
||||||
dyeable: false,
|
dyeable: false,
|
||||||
dualDyeable: false,
|
dualDyeable: false,
|
||||||
patternEditable: false,
|
patternEditable: false,
|
||||||
@@ -151,10 +178,13 @@ function applyItemCreateDefaults(isEventItem: boolean) {
|
|||||||
|
|
||||||
const defaults = readItemCreateDefaults();
|
const defaults = readItemCreateDefaults();
|
||||||
const categoryIds = new Set(loadedOptions.itemCategories.map((item) => String(item.id)));
|
const categoryIds = new Set(loadedOptions.itemCategories.map((item) => String(item.id)));
|
||||||
|
const usageIds = new Set(loadedOptions.itemUsages.map((item) => String(item.id)));
|
||||||
const methodIds = new Set(loadedOptions.acquisitionMethods.map((item) => String(item.id)));
|
const methodIds = new Set(loadedOptions.acquisitionMethods.map((item) => String(item.id)));
|
||||||
itemForm.value = {
|
itemForm.value = {
|
||||||
...itemForm.value,
|
...itemForm.value,
|
||||||
categoryId: categoryIds.has(defaults.categoryId) ? defaults.categoryId : '',
|
categoryId: categoryIds.has(defaults.categoryId) ? defaults.categoryId : '',
|
||||||
|
usageId: usageIds.has(defaults.usageId) ? defaults.usageId : '',
|
||||||
|
ancientArtifactCategoryId: isAncientArtifactCreate.value ? String(loadedOptions.ancientArtifactCategories[0]?.id ?? '') : '',
|
||||||
dyeable: defaults.dyeable,
|
dyeable: defaults.dyeable,
|
||||||
dualDyeable: defaults.dualDyeable,
|
dualDyeable: defaults.dualDyeable,
|
||||||
patternEditable: defaults.patternEditable,
|
patternEditable: defaults.patternEditable,
|
||||||
@@ -184,11 +214,6 @@ async function loadOptions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCurrentUser() {
|
async function loadCurrentUser() {
|
||||||
if (!getAuthToken()) {
|
|
||||||
currentUser.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
currentUser.value = (await api.me()).user;
|
currentUser.value = (await api.me()).user;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -207,6 +232,8 @@ async function loadEditor() {
|
|||||||
itemForm.value = {
|
itemForm.value = {
|
||||||
name: item.baseName ?? item.name,
|
name: item.baseName ?? item.name,
|
||||||
details: item.baseDetails ?? item.details,
|
details: item.baseDetails ?? item.details,
|
||||||
|
basePrice: item.basePrice === null || item.basePrice === undefined ? '' : String(item.basePrice),
|
||||||
|
ancientArtifactCategoryId: item.ancientArtifactCategory ? String(item.ancientArtifactCategory.id) : '',
|
||||||
translations: item.translations ?? {},
|
translations: item.translations ?? {},
|
||||||
categoryId: String(item.category.id),
|
categoryId: String(item.category.id),
|
||||||
usageId: item.usage ? String(item.usage.id) : '',
|
usageId: item.usage ? String(item.usage.id) : '',
|
||||||
@@ -260,6 +287,9 @@ async function saveItem() {
|
|||||||
const payload: ItemPayload = {
|
const payload: ItemPayload = {
|
||||||
name: itemNameForSave(),
|
name: itemNameForSave(),
|
||||||
details: itemForm.value.details,
|
details: itemForm.value.details,
|
||||||
|
basePrice: itemForm.value.basePrice.trim() === '' ? null : Number(itemForm.value.basePrice),
|
||||||
|
ancientArtifactCategoryId:
|
||||||
|
itemForm.value.ancientArtifactCategoryId.trim() === '' ? null : Number(itemForm.value.ancientArtifactCategoryId),
|
||||||
translations: itemForm.value.translations,
|
translations: itemForm.value.translations,
|
||||||
categoryId: Number(itemForm.value.categoryId),
|
categoryId: Number(itemForm.value.categoryId),
|
||||||
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
|
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
|
||||||
@@ -279,7 +309,7 @@ async function saveItem() {
|
|||||||
payload.insertAfterItemId = insertAfterItemId.value;
|
payload.insertAfterItemId = insertAfterItemId.value;
|
||||||
}
|
}
|
||||||
const saved = isEditing.value ? await api.updateItem(routeId.value, payload) : await api.createItem(payload);
|
const saved = isEditing.value ? await api.updateItem(routeId.value, payload) : await api.createItem(payload);
|
||||||
await router.push(`/items/${saved.id}`);
|
await router.push(isAncientArtifactRoute.value ? `/ancient-artifacts/${saved.id}` : `/items/${saved.id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, t('errors.saveFailed'));
|
message.value = errorText(error, t('errors.saveFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -302,19 +332,26 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal :title="pageTitle" :subtitle="t('pages.items.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
<Modal :title="pageTitle" :subtitle="pageSubtitle" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
||||||
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
||||||
|
|
||||||
<form v-if="!loading && options" id="item-edit-form" class="modal-edit-form" @submit.prevent="saveItem">
|
<form v-if="!loading && options" id="item-edit-form" class="modal-edit-form" @submit.prevent="saveItem">
|
||||||
<TranslationFields
|
<div class="item-edit-row item-edit-row--name-price">
|
||||||
id-prefix="item-name"
|
<TranslationFields
|
||||||
v-model:base-value="itemForm.name"
|
id-prefix="item-name"
|
||||||
v-model:translations="itemForm.translations"
|
v-model:base-value="itemForm.name"
|
||||||
field="name"
|
v-model:translations="itemForm.translations"
|
||||||
:label="t('common.name')"
|
field="name"
|
||||||
:languages="languages"
|
:label="t('common.name')"
|
||||||
required
|
:languages="languages"
|
||||||
/>
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="item-base-price">{{ t('pages.items.basePrice') }}</label>
|
||||||
|
<input id="item-base-price" v-model="itemForm.basePrice" type="number" min="0" step="1" inputmode="numeric" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TranslationFields
|
<TranslationFields
|
||||||
id-prefix="item-details"
|
id-prefix="item-details"
|
||||||
@@ -342,28 +379,43 @@ onMounted(() => {
|
|||||||
@error="message = $event"
|
@error="message = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="field">
|
<div class="item-edit-row item-edit-row--category-usage">
|
||||||
<label for="item-category">{{ t('pages.items.category') }}</label>
|
<div class="field">
|
||||||
<TagsSelect
|
<label for="item-category">{{ t('pages.items.category') }}</label>
|
||||||
id="item-category"
|
<TagsSelect
|
||||||
v-model="itemForm.categoryId"
|
id="item-category"
|
||||||
:options="options.itemCategories"
|
v-model="itemForm.categoryId"
|
||||||
:multiple="false"
|
:options="options.itemCategories"
|
||||||
:placeholder="t('common.select')"
|
:multiple="false"
|
||||||
:search-placeholder="t('pages.items.searchCategory')"
|
:placeholder="t('common.select')"
|
||||||
/>
|
:search-placeholder="t('pages.items.searchCategory')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="item-usage">{{ t('pages.items.usage') }}</label>
|
||||||
|
<TagsSelect
|
||||||
|
id="item-usage"
|
||||||
|
v-model="itemForm.usageId"
|
||||||
|
:options="options.itemUsages"
|
||||||
|
:multiple="false"
|
||||||
|
clearable
|
||||||
|
:placeholder="t('common.none')"
|
||||||
|
:search-placeholder="t('pages.items.searchUsage')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="item-usage">{{ t('pages.items.usage') }}</label>
|
<fieldset class="radio-group">
|
||||||
<TagsSelect
|
<legend>{{ t('pages.items.ancientArtifact') }}</legend>
|
||||||
id="item-usage"
|
<div class="radio-group__options">
|
||||||
v-model="itemForm.usageId"
|
<label v-for="option in ancientArtifactOptions" :key="option.value" class="radio-group__option">
|
||||||
:options="options.itemUsages"
|
<input v-model="itemForm.ancientArtifactCategoryId" type="radio" name="item-ancient-artifact" :value="option.value" />
|
||||||
:multiple="false"
|
<span>{{ option.label }}</span>
|
||||||
:placeholder="t('common.none')"
|
</label>
|
||||||
:search-placeholder="t('pages.items.searchUsage')"
|
</div>
|
||||||
/>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="check-row">
|
<div class="check-row">
|
||||||
@@ -402,7 +454,7 @@ onMounted(() => {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.items.loadingEdit')">
|
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.items.loadingEdit')">
|
||||||
<div v-for="index in 6" :key="index" class="field">
|
<div v-for="index in 7" :key="index" class="field">
|
||||||
<Skeleton :width="index === 1 ? '52px' : '88px'" />
|
<Skeleton :width="index === 1 ? '52px' : '88px'" />
|
||||||
<Skeleton variant="box" height="44px" />
|
<Skeleton variant="box" height="44px" />
|
||||||
</div>
|
</div>
|
||||||
@@ -420,3 +472,70 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.item-edit-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-edit-row--name-price {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(180px, 240px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-edit-row--category-usage {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-edit-row > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
min-inline-size: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group legend {
|
||||||
|
padding: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group__options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group__option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 36px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-weight: 850;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group__option input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--pokemon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.item-edit-row--name-price,
|
||||||
|
.item-edit-row--category-usage {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user