Compare commits
46 Commits
c821e9ebba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a42c8ef5c8 | |||
| c15905bafd | |||
| 5c72766781 | |||
| 8628bdf68b | |||
| 231a7bb313 | |||
| 8caa95e78e | |||
| 929c148c56 | |||
| 0b137506b4 | |||
| b99ea1fad9 | |||
| 42319695e9 | |||
| 26bef1b749 | |||
| 02db73aa4e | |||
| ee054dcd15 | |||
| 575597b146 | |||
| 953b90eba1 | |||
| a781bc559b | |||
| e9d356a656 | |||
| 9db8e60f3d | |||
| 4a7309027a | |||
| 520d988589 | |||
| 64ca494d82 | |||
| cbb101336b | |||
| 23a7301598 | |||
| 515297ab74 | |||
| b1cf40edd0 | |||
| bcf8dd9cb5 | |||
| d87539e897 | |||
| 82f08c1684 | |||
| df78685dc3 | |||
| cc440ea949 | |||
| 5ef1f4ecc9 | |||
| 4dc73d42cb | |||
| fa656a8d02 | |||
| f26cfdc830 | |||
| 71b35b9cc6 | |||
| 70f7a73e6d | |||
| f92e97b747 | |||
| d66124862a | |||
| f7986ca520 | |||
| 425f2f4d5f | |||
| 35ee164794 | |||
| cf1eb6965e | |||
| 337a6bda1f | |||
| fd1f3ef636 | |||
| afed409127 | |||
| 6e8edbbb09 |
22
.env.example
22
.env.example
@@ -4,21 +4,37 @@ POSTGRES_PASSWORD=pokopia
|
|||||||
DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia
|
DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia
|
||||||
BACKEND_PORT=3001
|
BACKEND_PORT=3001
|
||||||
TRUST_PROXY=false
|
TRUST_PROXY=false
|
||||||
|
# The default localhost frontend origin also allows same-protocol, same-port private LAN aliases in development.
|
||||||
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
|
# Browser requests rewrite localhost/loopback API hosts to the current page host for LAN access.
|
||||||
VITE_SITE_URL=https://pokopiawiki.tootaio.com
|
NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
|
||||||
|
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
|
||||||
RESEND_MONTHLY_QUOTA_LIMIT=3000
|
RESEND_MONTHLY_QUOTA_LIMIT=3000
|
||||||
RESEND_QUOTA_RESERVE=5
|
RESEND_QUOTA_RESERVE=5
|
||||||
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
|
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
|
||||||
|
EMAIL_FALLBACK_LOG_TOKENS=false
|
||||||
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
|
||||||
|
#
|
||||||
|
# Optional LAN access example from another device:
|
||||||
|
# FRONTEND_ORIGIN=http://localhost:20015,http://10.147.20.4:20015
|
||||||
|
# NUXT_PUBLIC_API_BASE_URL=http://10.147.20.4:20016
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
3
.gitignore
vendored
3
.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
|
||||||
@@ -9,3 +11,4 @@ coverage/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.agents/
|
.agents/
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
|
repomix-output.xml
|
||||||
14
AGENTS.md
14
AGENTS.md
@@ -34,8 +34,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 SSR enabled (`ssr: true`)
|
||||||
* Vue
|
* Vue
|
||||||
* Vite
|
|
||||||
* Vue Router
|
* Vue Router
|
||||||
* Vue I18n
|
* Vue I18n
|
||||||
* Iconify
|
* Iconify
|
||||||
@@ -223,11 +223,19 @@ This project is developed from WSL, but runtime validation is done through Docke
|
|||||||
|
|
||||||
Agent workflow:
|
Agent workflow:
|
||||||
|
|
||||||
* Run when practical:
|
* Run once when practical:
|
||||||
|
|
||||||
* `pnpm lint`
|
* `pnpm lint`
|
||||||
* `pnpm typecheck`
|
* `pnpm typecheck`
|
||||||
|
|
||||||
|
* Do not repeatedly retry failed validation commands.
|
||||||
|
* If lint/typecheck fails because of missing dependencies, native optional bindings, WSL, network, registry, filesystem permission, or other environment/setup issues:
|
||||||
|
|
||||||
|
* Do not repeatedly run `pnpm install`, force reinstall dependencies, delete `node_modules`, or otherwise spend time repairing the local environment unless the user explicitly asks.
|
||||||
|
* Report the exact command attempted and the key error lines.
|
||||||
|
* Tell the user what command to run locally or in Docker.
|
||||||
|
* Wait for the user to paste `docker compose up --build`, lint, typecheck, or runtime output before fixing follow-up errors.
|
||||||
|
|
||||||
* Do NOT run tests in WSL.
|
* Do NOT run tests in WSL.
|
||||||
* Do NOT require local test execution before finishing a task.
|
* Do NOT require local test execution before finishing a task.
|
||||||
* The user will run `docker compose up --build`.
|
* The user will run `docker compose up --build`.
|
||||||
@@ -246,7 +254,7 @@ A task is complete ONLY IF:
|
|||||||
* Minimal diff, with no unrelated changes.
|
* Minimal diff, with no unrelated changes.
|
||||||
* No UI leaks of internal info.
|
* No UI leaks of internal info.
|
||||||
* Code is readable and concise.
|
* Code is readable and concise.
|
||||||
* Passes lint/typecheck when practical.
|
* Lint/typecheck has been run once when practical, or the environment blocker and user-side validation command have been reported.
|
||||||
* Docker runtime issues are handled from user-provided `docker compose up --build` output.
|
* Docker runtime issues are handled from user-provided `docker compose up --build` output.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
314
DESIGN.md
314
DESIGN.md
@@ -6,16 +6,18 @@
|
|||||||
- 所有人都可以浏览 Wiki 内容。
|
- 所有人都可以浏览 Wiki 内容。
|
||||||
- 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。
|
- 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。
|
||||||
- 前台以 Home 首页、Pokedex(Main Game / Event)、Habitat Dex(Main Game / Event)、Collections(Main Game / Event / Ancient Artifacts)、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
|
- 前台以 Home 首页、Pokedex(Main Game / Event)、Habitat Dex(Main Game / Event)、Collections(Main Game / Event / Ancient Artifacts)、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
|
||||||
|
- Threads 是社区讨论入口,采用 Discord Forum + 聊天室混合形态;用户按 Channel 浏览 Thread,并在 Thread 内使用聊天室式消息流讨论。
|
||||||
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口;Logo 导航回到 Home,用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
|
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口;Logo 导航回到 Home,用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
|
||||||
- 桌面端使用侧边栏导航,侧边栏可折叠为图标栏;移动端继续使用抽屉式侧边栏。
|
- 桌面端使用侧边栏导航,侧边栏可折叠为图标栏;移动端继续使用抽屉式侧边栏。
|
||||||
- 全局顶部导航栏承载语言切换、通知、User Profile 和登录 / 退出等账号操作;除 User Profile 可展示用户名外,顶部操作以图标按钮呈现。
|
- 全局顶部导航栏承载语言切换、通知、User Profile 和登录 / 退出等账号操作;除 User Profile 可展示用户名外,顶部操作以图标按钮呈现。
|
||||||
- 全局顶部导航栏提供全站搜索。搜索结果按内容类型分组展示,覆盖 Pokemon、Habitats、Items、Ancient Artifacts、Recipes、Daily CheckList、公开可见的 Life Post 和公开用户 Profile;结果跳转到对应公开详情页、页面锚点或 `/profile/:id`。
|
- 全局顶部导航栏提供全站搜索。搜索结果按内容类型分组展示,覆盖 Pokemon、Habitats、Items、Ancient Artifacts、Recipes、Daily CheckList、公开可见的 Life Post 和公开用户 Profile;结果跳转到对应公开详情页、页面锚点或 `/profile/:id`。
|
||||||
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
|
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
|
||||||
|
- 管理员可在管理入口启用或禁用模块级功能开关;Trading 模块关闭时保留 `has_trading` 和 Trading 观察数据,但前台与编辑界面不展示 Trading 相关功能。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- 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 +29,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 列表按 Pokopia 展示 ID(`display_id`)升序展示,同一展示 ID 下按 `sort_order` 和内部 `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`
|
||||||
@@ -62,7 +65,6 @@
|
|||||||
- 地图
|
- 地图
|
||||||
- 栖息地
|
- 栖息地
|
||||||
- 每日 CheckList Task
|
- 每日 CheckList Task
|
||||||
- Life Category
|
|
||||||
- Game Version
|
- Game Version
|
||||||
- Dish Category
|
- Dish Category
|
||||||
- Dish Flavor
|
- Dish Flavor
|
||||||
@@ -109,8 +111,10 @@
|
|||||||
- `EMAIL_FROM`
|
- `EMAIL_FROM`
|
||||||
- `APP_ORIGIN` 或 `FRONTEND_ORIGIN`
|
- `APP_ORIGIN` 或 `FRONTEND_ORIGIN`
|
||||||
- 认证邮件和密码重置邮件使用标准化 Pokopia Wiki 品牌 HTML 外壳;正文、按钮文案、兜底链接提示和纯文本版本仍通过 `surface=email` 的系统级文案维护。
|
- 认证邮件和密码重置邮件使用标准化 Pokopia Wiki 品牌 HTML 外壳;正文、按钮文案、兜底链接提示和纯文本版本仍通过 `surface=email` 的系统级文案维护。
|
||||||
- 后端从 Resend 邮件发送响应 headers 读取日/月发送额度和 rate limit 状态,并维护短期内存 snapshot;当 Resend 已报告额度接近用尽、额度耗尽或 API 限流时,认证邮件发送会暂时停止并返回本地化用户提示。
|
- 后端从 Resend 邮件发送响应 headers 读取日/月发送额度和 rate limit 状态,并维护短期内存 snapshot;当 Resend 已报告额度接近用尽、额度耗尽或 API 限流时,认证邮件发送会暂时停止;若没有启用邮件 fallback,注册等需要即时邮件送达的流程会返回本地化用户提示。
|
||||||
- Resend 额度保护不使用本项目自增发送计数;默认按 Free 计划 `100/day`、`3000/month` 和 5 封保留量判断,可通过 `RESEND_DAILY_QUOTA_LIMIT`、`RESEND_MONTHLY_QUOTA_LIMIT`、`RESEND_QUOTA_RESERVE`、`RESEND_QUOTA_SNAPSHOT_TTL_MINUTES` 调整。
|
- Resend 额度保护不使用本项目自增发送计数;默认按 Free 计划 `100/day`、`3000/month` 和 5 封保留量判断,可通过 `RESEND_DAILY_QUOTA_LIMIT`、`RESEND_MONTHLY_QUOTA_LIMIT`、`RESEND_QUOTA_RESERVE`、`RESEND_QUOTA_SNAPSHOT_TTL_MINUTES` 调整。
|
||||||
|
- 邮件发送失败时,后端可通过受保护的 fallback 日志显示一次性验证 / 重置 token 和完整链接,便于开发或应急验证;非生产环境默认启用,生产环境必须显式设置 `EMAIL_FALLBACK_LOG_TOKENS=true` 才会输出 token 或链接。
|
||||||
|
- 邮件 fallback token / 链接只允许出现在后端日志,不得进入 API 响应、前端 UI 或普通管理界面。
|
||||||
- 验证邮件包含一次性验证链接。
|
- 验证邮件包含一次性验证链接。
|
||||||
- 验证 token 只保存 hash,并带过期时间和使用状态。
|
- 验证 token 只保存 hash,并带过期时间和使用状态。
|
||||||
- 只有邮箱已验证的用户可以登录。
|
- 只有邮箱已验证的用户可以登录。
|
||||||
@@ -120,10 +124,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`
|
||||||
@@ -194,6 +203,13 @@
|
|||||||
- 调用者只能分配或移除 `roles.level` 严格低于自己最高启用角色等级的角色。
|
- 调用者只能分配或移除 `roles.level` 严格低于自己最高启用角色等级的角色。
|
||||||
- `owner` 角色只能由当前拥有启用 `owner` 角色且拥有 `admin.users.assign-owner` 权限的调用者分配或移除。
|
- `owner` 角色只能由当前拥有启用 `owner` 角色且拥有 `admin.users.assign-owner` 权限的调用者分配或移除。
|
||||||
- 非 Owner 即使拥有 `admin.users.update` 或自定义高等级角色,也不能分配或移除 `owner` 角色。
|
- 非 Owner 即使拥有 `admin.users.update` 或自定义高等级角色,也不能分配或移除 `owner` 角色。
|
||||||
|
- Owner 可使用 View As 调试权限和用户工作流:
|
||||||
|
- 只有当前 session 的真实用户拥有启用 `owner` 角色且邮箱已验证时,才能启动或退出 View As。
|
||||||
|
- View As User 会让当前 session 以目标用户的对外身份、角色和权限进行后续操作;普通写入仍按当前生效用户记录编辑署名。
|
||||||
|
- View As Role 会保留真实 Owner 的用户资料和邮箱验证状态,但后续权限判断只使用所选启用角色的权限;该模式用于验证角色能力边界,不伪造某个具体用户。
|
||||||
|
- 同一 session 同一时间只能 View As 一个用户或一个角色;退出后恢复真实 Owner 身份。
|
||||||
|
- 当前用户 API 可返回必要的 `viewAs` 展示状态,只包含模式和展示标签;不返回 session token、token hash、内部 session 字段或调试 payload。
|
||||||
|
- 前端在顶部显示全站 View As Banner,文案为当前 View As 对象,并提供退出按钮;View As 状态不得只隐藏在管理页内。
|
||||||
- 管理 API 只返回权限管理所需字段,不返回密码、session token hash、verification/reset token hash、内部审计 payload 或调试字段。
|
- 管理 API 只返回权限管理所需字段,不返回密码、session token hash、verification/reset token hash、内部审计 payload 或调试字段。
|
||||||
|
|
||||||
## Admin Data Tools
|
## Admin Data Tools
|
||||||
@@ -216,7 +232,7 @@
|
|||||||
- 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
|
- 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
|
||||||
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联 / Trading 观察,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
|
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联 / Trading 观察,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
|
||||||
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
|
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
|
||||||
- Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项、Pokemon 掉落关联和 Trading 观察。
|
- Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、染色预览、栖息地配方项、Pokemon 掉落关联和 Trading 观察。
|
||||||
- Wipe Ancient Artifacts 会清空物品上的 Ancient Artifact 分类并删除对应 Ancient Artifact 讨论;物品本身仍保留在 Items / Event Items 中。
|
- Wipe Ancient Artifacts 会清空物品上的 Ancient Artifact 分类并删除对应 Ancient Artifact 讨论;物品本身仍保留在 Items / Event Items 中。
|
||||||
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
|
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
|
||||||
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
|
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
|
||||||
@@ -224,7 +240,7 @@
|
|||||||
- Export 行为:
|
- Export 行为:
|
||||||
- 导出为版本化 JSON bundle,包含 `version`、`exportedAt`、`scopes` 和对应范围数据。
|
- 导出为版本化 JSON bundle,包含 `version`、`exportedAt`、`scopes` 和对应范围数据。
|
||||||
- JSON bundle 用于系统导入,不作为前台展示内容。
|
- JSON bundle 用于系统导入,不作为前台展示内容。
|
||||||
- 导出包含所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
|
- 导出包含所选范围的主数据、关联数据、物品染色预览、实体翻译、编辑历史、图片上传记录和实体讨论评论。
|
||||||
- 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落、Trading 观察和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。
|
- 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落、Trading 观察和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。
|
||||||
- JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。
|
- JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。
|
||||||
- Import 行为:
|
- Import 行为:
|
||||||
@@ -296,6 +312,7 @@
|
|||||||
- 通知 API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、调试字段或内部审计 payload。
|
- 通知 API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、调试字段或内部审计 payload。
|
||||||
- 前端在主导航登录区展示通知入口、未读数量和通知列表;点击通知后标记已读并跳转到对应 Life Post 或 Wiki 详情页。
|
- 前端在主导航登录区展示通知入口、未读数量和通知列表;点击通知后标记已读并跳转到对应 Life Post 或 Wiki 详情页。
|
||||||
- Follow 对象发布 Life Post 的动态属于 Following Feed,不进入 Notifications,不产生未读数量,也不需要标记已读。
|
- Follow 对象发布 Life Post 的动态属于 Following Feed,不进入 Notifications,不产生未读数量,也不需要标记已读。
|
||||||
|
- Thread Follow 的未读状态在 Threads 自身侧边栏和 Thread 列表展示,不进入全局 Notifications,不影响 NotificationBell 未读数量。
|
||||||
|
|
||||||
## 滥用防护与限流
|
## 滥用防护与限流
|
||||||
|
|
||||||
@@ -322,8 +339,8 @@
|
|||||||
- Wiki 内容写入(Pokemon、物品、材料单、栖息地、每日 CheckList 和排序)默认按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间。
|
- Wiki 内容写入(Pokemon、物品、材料单、栖息地、每日 CheckList 和排序)默认按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间。
|
||||||
- 上传默认按用户 ID 限制为 20 次 / 1 小时,并有 30 秒冷却时间。
|
- 上传默认按用户 ID 限制为 20 次 / 1 小时,并有 30 秒冷却时间。
|
||||||
- Community 写入:
|
- Community 写入:
|
||||||
- Life Post、Life 评论、Wiki 讨论评论和对应删除 / 更新操作默认按用户 ID 限制为 60 次 / 1 小时,并有 5 秒冷却时间。
|
- Life Post、Life 评论、Wiki 讨论评论、Thread / Thread Message 和对应删除 / 更新操作默认按用户 ID 限制为 60 次 / 1 小时,并有 5 秒冷却时间。
|
||||||
- Life reaction 写入默认按用户 ID 限制为 120 次 / 1 小时,并有 1 秒冷却时间。
|
- Life reaction 和 Thread reaction / follow 写入默认按用户 ID 限制为 120 次 / 1 小时,并有 1 秒冷却时间。
|
||||||
- Pokemon Fetch 数据和图片候选查询默认按用户 ID 限制为 60 次 / 10 分钟,并有 1 秒冷却时间。
|
- Pokemon Fetch 数据和图片候选查询默认按用户 ID 限制为 60 次 / 10 分钟,并有 1 秒冷却时间。
|
||||||
|
|
||||||
## Community 编辑与审计
|
## Community 编辑与审计
|
||||||
@@ -352,7 +369,7 @@
|
|||||||
- `created_at`
|
- `created_at`
|
||||||
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
|
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
|
||||||
- 编辑历史中的用户信息只展示必要署名,不暴露邮箱、token、hash 或内部元数据。
|
- 编辑历史中的用户信息只展示必要署名,不暴露邮箱、token、hash 或内部元数据。
|
||||||
- 排序操作仍更新列表顺序、最后编辑者和最后编辑时间,但 `sort_order` / Sort order 字段变更不写入或展示在详情页编辑历史面板中。
|
- 非 Pokemon 列表排序操作仍更新列表顺序、最后编辑者和最后编辑时间,但 `sort_order` / Sort order 字段变更不写入或展示在详情页编辑历史面板中。
|
||||||
- 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。
|
- 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。
|
||||||
|
|
||||||
## Wiki 图片上传
|
## Wiki 图片上传
|
||||||
@@ -362,7 +379,7 @@
|
|||||||
- 物品图标
|
- 物品图标
|
||||||
- 栖息地
|
- 栖息地
|
||||||
- 上传图片只支持 `png`、`jpg/jpeg`、`webp`、`gif`。
|
- 上传图片只支持 `png`、`jpg/jpeg`、`webp`、`gif`。
|
||||||
- 上传图片由服务端保存到受控上传目录,不接受任意外部 URL,也不信任客户端传入的最终文件路径。
|
- 上传图片由服务端保存到受控上传目录,不信任客户端传入的最终文件路径;实体当前图片也可引用完整的 `https://` 外部图片 URL,但不接受 `http://`、`data:`、`javascript:` 或带用户名 / 密码的 URL。
|
||||||
- 上传路径由服务端按实体类型、实体展示名称和时间戳生成,格式示例:
|
- 上传路径由服务端按实体类型、实体展示名称和时间戳生成,格式示例:
|
||||||
- `items/甜蜜蜜/20260501002000.png`
|
- `items/甜蜜蜜/20260501002000.png`
|
||||||
- `pokemon/Pikachu/20260501002000.png`
|
- `pokemon/Pikachu/20260501002000.png`
|
||||||
@@ -378,7 +395,8 @@
|
|||||||
- `byte_size`
|
- `byte_size`
|
||||||
- `created_by_user_id`
|
- `created_by_user_id`
|
||||||
- `created_at`
|
- `created_at`
|
||||||
- 实体表只保存当前显示图片的相对路径;历史上传记录不会因为切换当前图片而删除。
|
- 实体表保存当前显示图片引用:上传图片相对路径、受支持静态资源路径或完整 `https://` 外部图片 URL;历史上传记录不会因为切换当前图片而删除。
|
||||||
|
- 公共实体 API 返回图片时包含当前图片引用 `path` 和可直接展示的 `url`;完整外部 URL 的 `path` 和 `url` 相同,不再拼接受控上传或静态资源域名。
|
||||||
- 公共 API 对外返回图片上传历史只包含:`id`、`path`、`url`、`uploadedAt` 和上传者必要署名 `uploadedBy`;不返回 `entity_name`、原始文件名、MIME、文件大小、服务器绝对文件路径或内部存储元数据。若编辑接口确需实体关联,只能在受保护编辑接口返回 `entityId`。
|
- 公共 API 对外返回图片上传历史只包含:`id`、`path`、`url`、`uploadedAt` 和上传者必要署名 `uploadedBy`;不返回 `entity_name`、原始文件名、MIME、文件大小、服务器绝对文件路径或内部存储元数据。若编辑接口确需实体关联,只能在受保护编辑接口返回 `entityId`。
|
||||||
- 图片上传本身不直接改变实体内容;用户仍需保存实体编辑表单后,当前图片选择才成为实体行为并写入现有编辑审计。
|
- 图片上传本身不直接改变实体内容;用户仍需保存实体编辑表单后,当前图片选择才成为实体行为并写入现有编辑审计。
|
||||||
- Docker 运行时上传目录必须使用 volume 持久化,避免重新 build 后丢失用户上传图片。
|
- Docker 运行时上传目录必须使用 volume 持久化,避免重新 build 后丢失用户上传图片。
|
||||||
@@ -445,10 +463,25 @@
|
|||||||
- 名称
|
- 名称
|
||||||
- 是否有掉落物:`has_item_drop`
|
- 是否有掉落物:`has_item_drop`
|
||||||
- 是否支持 Trading:`has_trading`
|
- 是否支持 Trading:`has_trading`
|
||||||
|
- `has_trading` 是特长自身能力配置,不作为模块显示开关;Trading 模块关闭时该字段和已有 Trading 观察数据保留。
|
||||||
- 已移除 `subcategory` 字段。
|
- 已移除 `subcategory` 字段。
|
||||||
- 当特长允许掉落物时,Pokemon 编辑中可为该 Pokemon + 特长配置一个掉落物品。
|
- 当特长允许掉落物时,Pokemon 编辑中可为该 Pokemon + 特长配置一个掉落物品。
|
||||||
- 当 Pokemon 选择了至少一个支持 Trading 的特长时,Pokemon 详情页可直接维护该 Pokemon 对物品的 Trading 偏好观察。
|
- 当 Pokemon 选择了至少一个支持 Trading 的特长时,Pokemon 详情页可直接维护该 Pokemon 对物品的 Trading 偏好观察。
|
||||||
|
|
||||||
|
### 模块设置
|
||||||
|
|
||||||
|
- 模块设置存储在 `module_settings`,当前包含:
|
||||||
|
- `trading_enabled`:控制 Trading 相关界面和推断展示,默认启用。
|
||||||
|
- 管理端查看模块设置需要 `admin.config.read`,更新模块设置需要 `admin.config.update`。
|
||||||
|
- 关闭 Trading 模块时:
|
||||||
|
- 不删除、不清空 `skills.has_trading`。
|
||||||
|
- 不删除、不清空 `pokemon_trading_items`。
|
||||||
|
- Pokemon 详情页不展示 Trading 区块或管理 Trading 入口。
|
||||||
|
- Pokemon 创建 / 编辑流程不展示 Trading 相关编辑能力。
|
||||||
|
- Item 详情页不展示基于 Trading 观察推断的 Possible Tags 和证据区块。
|
||||||
|
- 管理端 Skill 配置不展示 `has_trading` 勾选项或列表标记。
|
||||||
|
- 重新启用 Trading 模块后,已有配置和观察数据恢复参与界面展示与推断。
|
||||||
|
|
||||||
### Pokemon Types
|
### Pokemon Types
|
||||||
|
|
||||||
- 名称
|
- 名称
|
||||||
@@ -458,10 +491,13 @@
|
|||||||
### 喜欢的环境
|
### 喜欢的环境
|
||||||
|
|
||||||
- 名称
|
- 名称
|
||||||
|
- Description:可为空,用于解释该 Ideal Habitat 对 Pokemon 栖息地选择的意义
|
||||||
|
- Opposite:可为空,双向关联另一个喜欢的环境作为反义关系;每个喜欢的环境最多只能属于一组 Opposite 配对;设置、替换或清空一侧时,系统必须在同一事务中同步维护另一侧
|
||||||
|
|
||||||
### 喜欢的东西 / 标签
|
### 喜欢的东西 / 标签
|
||||||
|
|
||||||
- 名称
|
- 名称
|
||||||
|
- Opposite:可为空,双向关联另一个喜欢的东西 / 标签作为反义关系;每个喜欢的东西 / 标签最多只能属于一组 Opposite 配对;设置、替换或清空一侧时,系统必须在同一事务中同步维护另一侧
|
||||||
- 同时用于:
|
- 同时用于:
|
||||||
- Pokemon 喜欢的东西
|
- Pokemon 喜欢的东西
|
||||||
- 物品标签
|
- 物品标签
|
||||||
@@ -469,6 +505,7 @@
|
|||||||
### 入手方式
|
### 入手方式
|
||||||
|
|
||||||
- 名称
|
- 名称
|
||||||
|
- Category:用于将入手方式分组展示;未单独维护分类主数据,默认可使用 `General`
|
||||||
- 可关联到物品和材料单。
|
- 可关联到物品和材料单。
|
||||||
|
|
||||||
### 地图
|
### 地图
|
||||||
@@ -476,13 +513,6 @@
|
|||||||
- 名称
|
- 名称
|
||||||
- 用于栖息地中 Pokemon 出现地点。
|
- 用于栖息地中 Pokemon 出现地点。
|
||||||
|
|
||||||
### Life Category
|
|
||||||
|
|
||||||
- 名称
|
|
||||||
- 是否默认选中:最多一个 Life Category 可设为默认;新建 Life Post 时默认选中该分类。
|
|
||||||
- 是否可评分:Rateable Life Category 下的 Life Post 可由用户进行 1-5 星评分。
|
|
||||||
- 用于 Life Post 分类展示和 Feed 筛选。
|
|
||||||
|
|
||||||
### Game Version
|
### Game Version
|
||||||
|
|
||||||
- 版本号 / 名称
|
- 版本号 / 名称
|
||||||
@@ -523,7 +553,6 @@ Pokemon 可配置:
|
|||||||
- Speed
|
- Speed
|
||||||
- 出现的栖息地:由栖息地出现配置反向展示
|
- 出现的栖息地:由栖息地出现配置反向展示
|
||||||
- 翻译
|
- 翻译
|
||||||
- 排序
|
|
||||||
|
|
||||||
普通 Pokemon 与 Event Pokemon 分开展示:
|
普通 Pokemon 与 Event Pokemon 分开展示:
|
||||||
|
|
||||||
@@ -531,7 +560,7 @@ Pokemon 可配置:
|
|||||||
- `/event-pokemon` 展示 Event Pokemon 列表。
|
- `/event-pokemon` 展示 Event Pokemon 列表。
|
||||||
- 两个列表复用 Pokemon 筛选、卡片和详情行为,但列表请求必须按 `is_event_item` 分开读取。
|
- 两个列表复用 Pokemon 筛选、卡片和详情行为,但列表请求必须按 `is_event_item` 分开读取。
|
||||||
|
|
||||||
Pokemon 的 Pokopia 展示 ID 在普通 Pokemon 和 Event Pokemon 之间可以重复,例如允许同时存在普通 `#1 妙蛙种子` 和 Event `#1 毽子草`。数据库只要求同一个 `display_id + is_event_item` 组合唯一;前端路由和实体关联必须继续使用内部 `id`,不能使用展示 ID 作为路由或外键。Fetch 得到的官方 data ID 必须与展示 ID 分开保存;例如 Zorua 的官方 data ID 为 `570` 时,用户把 Pokopia 展示 ID 改成 `123` 后仍应通过 `/pokemon/570` 访问该 Pokemon,`/pokemon/123` 只代表内部 ID 为 `123` 的其他 Pokemon。普通 Pokemon 和 Event Pokemon 不会同时存在同一个内部系统 ID;当 Event Pokemon 关联官方 data 时,内部 ID 同样使用官方 data Pokemon ID。
|
Pokemon 的 Pokopia 展示 ID 可以重复,用于支持同一 Pokopia 编号下的多个形态,例如允许普通 `#41 Tangrowth` 和普通 `#41 Professor Tangrowth` 同时存在;普通 Pokemon 和 Event Pokemon 之间也可以重复展示 ID,例如普通 `#1 妙蛙种子` 和 Event `#1 毽子草`。`is_event_item` 只表示普通 Pokemon / Event Pokemon 分流,不能用于标记特殊形态。数据库不要求 `display_id` 唯一;前端路由和实体关联必须继续使用内部 `id`,不能使用展示 ID 作为路由或外键。Fetch 得到的官方 data ID 必须与展示 ID 分开保存;例如 Zorua 的官方 data ID 为 `570` 时,用户把 Pokopia 展示 ID 改成 `123` 后仍应通过 `/pokemon/570` 访问该 Pokemon,`/pokemon/123` 只代表内部 ID 为 `123` 的其他 Pokemon。普通 Pokemon 和 Event Pokemon 不会同时存在同一个内部系统 ID;当 Event Pokemon 关联官方 data 时,内部 ID 同样使用官方 data Pokemon ID。同一列表内展示 ID 相同的 Pokemon 按 `sort_order` 和内部 `id` 稳定排序。
|
||||||
|
|
||||||
Pokemon 编辑表单使用标签页组织字段:
|
Pokemon 编辑表单使用标签页组织字段:
|
||||||
|
|
||||||
@@ -580,7 +609,7 @@ Pokemon 列表功能:
|
|||||||
- 按喜欢的东西筛选:
|
- 按喜欢的东西筛选:
|
||||||
- 满足任意条件
|
- 满足任意条件
|
||||||
- 满足全部条件
|
- 满足全部条件
|
||||||
- 按自定义排序展示
|
- 按 Pokopia 展示 ID(`display_id`)升序展示;展示 ID 可重复,同一展示 ID 下按 `sort_order` 和内部 `id` 稳定排序;内部 `id` 仅用于路由、外键和稳定排序兜底
|
||||||
- 列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Pokemon。
|
- 列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Pokemon。
|
||||||
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
|
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
|
||||||
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
|
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
|
||||||
@@ -589,19 +618,17 @@ Pokemon 列表功能:
|
|||||||
Pokemon 详情页展示:
|
Pokemon 详情页展示:
|
||||||
|
|
||||||
- 基本信息
|
- 基本信息
|
||||||
- 详情主内容在六维 Stats 右侧始终保留正方形图片区;已配置图片时展示居中的 Pokédex 风格图片,未配置图片时展示默认 Poké Ball 占位符;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情。
|
- 标题区不展示 Ideal Habitat;Ideal Habitat 属于正文核心资料。
|
||||||
- 主内容顶部按以下布局展示:
|
- 详情主内容顶部改为左侧 Pokemon 图片、右侧 Pokemon Description;已配置图片时展示居中的 Pokédex 风格图片,未配置图片时展示默认 Poké Ball 占位符;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情。
|
||||||
- 左上:Genus & Details;无区块标题;如有 Genus,先展示 Genus,再以分割线连接 Details 内容
|
- 详情页需要突出 Pokopia 机制核心要素:
|
||||||
- 左下:Height / Weight 与 Types 按 2:1 比例并排;Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开,每组按英制、分割线、公制、标签上下排列;Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示
|
- Skills:影响栖息地选择、物品掉落和 Trading 行为
|
||||||
- 右侧:六维 Stats;图片或默认占位符展示在 Stats 右侧
|
- Ideal Habitat:影响栖息地选择和相关 Pokemon 对比;正文中只展示正向 Ideal Habitat 名称和 Description,不展示反义词
|
||||||
- 六维使用 ProgressBar 展示,最大值按 150 计算。
|
- Favourite Things:影响物品掉落、隐藏标签判断和 Trading 价格证据;正文中只展示正向 Favourite Things,不展示反义词
|
||||||
- 特长
|
|
||||||
- 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标,未配置时展示空状态
|
- 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标,未配置时展示空状态
|
||||||
- Trading:当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品,Likes 表示交易价格 1.5x,Neutral 表示无加成,未配置观察时展示空状态
|
- Trading:当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品,Likes 表示交易价格 1.5x,Neutral 表示无加成,未配置观察时展示空状态;详情页双列列表高度受限,内容较多时每列内部独立滚动,避免 Section 被大量物品无限拉长
|
||||||
- Trading 可在详情页通过 Manage Trading Modal 维护;Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动
|
- Trading 可在详情页通过 Manage Trading Modal 维护;Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;搜索结果优先展示名称精确匹配、名称开头匹配和单词匹配的物品,再展示名称包含、分类或用途包含的物品;搜索框支持上下键切换当前结果、左右键切换默认加入组、Enter 将当前结果加入默认目标组;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动
|
||||||
- 喜欢的环境
|
- 参考资料 Tab:Height / Weight、Types 和六维 Stats 移到独立 Tab;该 Tab 必须注明这些数据只是参考 Pokédex 的展示设计,不属于 Pokopia 机制;六维使用 ProgressBar 展示,最大值按 150 计算
|
||||||
- 喜欢的东西
|
- 相关 Pokemon:与关联喜欢的东西的物品在桌面端左右并排展示;按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西;当相关 Pokemon 的 Ideal Habitat 或 Favourite Things 与当前 Pokemon 配置的 Opposite 反义关系命中时,只使用红色标记,不展示反义词或额外 Opposite 文案
|
||||||
- 相关 Pokemon:与关联喜欢的东西的物品在桌面端左右并排展示;按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
|
|
||||||
- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示;每项展示物品图标,未配置图标时显示默认物品标记占位符
|
- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示;每项展示物品图标,未配置图标时显示默认物品标记占位符
|
||||||
- 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符
|
- 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符
|
||||||
- 最后编辑信息
|
- 最后编辑信息
|
||||||
@@ -638,11 +665,16 @@ Pokemon 详情页展示:
|
|||||||
- Relaxation
|
- Relaxation
|
||||||
- Toy
|
- Toy
|
||||||
- Road
|
- Road
|
||||||
|
- Food
|
||||||
- 入手方式:可多选
|
- 入手方式:可多选
|
||||||
- 客制化:
|
- 客制化:
|
||||||
- 可染色
|
- 染色能力:`dyeability` 表示物品包含多少个可独立染色部位,使用互斥枚举值维护:
|
||||||
- 可双区染色
|
- `0`:不可染色
|
||||||
|
- `1`:1 个可独立染色部位
|
||||||
|
- `2`:2 个可独立染色部位
|
||||||
|
- `3`:3 个可独立染色部位
|
||||||
- 可改花纹
|
- 可改花纹
|
||||||
|
- 染色预览:当 `dyeability > 0` 时,物品可为每个染色部位维护不同颜色的预览图片;每条预览记录包含部位序号、颜色名称(例如 `None`、`Red`、`Blue`)和预览图片路径。部位序号必须在 `1..dyeability` 范围内;同一物品的同一部位同一颜色只能有一张预览图。
|
||||||
- 无材料单:`no_recipe`
|
- 无材料单:`no_recipe`
|
||||||
- 标签:使用喜欢的东西配置,可多选
|
- 标签:使用喜欢的东西配置,可多选
|
||||||
- 图标图片:通过通用 Wiki 图片上传维护当前图标和历史上传记录
|
- 图标图片:通过通用 Wiki 图片上传维护当前图标和历史上传记录
|
||||||
@@ -677,12 +709,13 @@ Items 与 Event Items 使用相同数据模型:
|
|||||||
- 基本信息
|
- 基本信息
|
||||||
- 当前图标图片;未配置图标时展示默认物品标记占位符
|
- 当前图标图片;未配置图标时展示默认物品标记占位符
|
||||||
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
|
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
|
||||||
|
- 染色预览:若已维护预览图,按染色部位分组展示各颜色预览,用户可查看每个独立染色部位在不同颜色下的效果
|
||||||
- 介绍
|
- 介绍
|
||||||
- Base Price
|
- Base Price
|
||||||
- Ancient Artifact 分类:仅在物品已配置 Ancient Artifact 分类时展示
|
- Ancient Artifact 分类:仅在物品已配置 Ancient Artifact 分类时展示
|
||||||
- 分类
|
- 分类
|
||||||
- 用途
|
- 用途
|
||||||
- 入手方式
|
- 入手方式:按 Category 分类表格展示,左侧为 Category,右侧为该分类下的入手方式 value 逐行展示
|
||||||
- 客制化
|
- 客制化
|
||||||
- 标签
|
- 标签
|
||||||
- Possible Tags:根据所有拥有支持 Trading 特长的 Pokemon Trading 观察推断该物品可能包含的隐藏标签
|
- Possible Tags:根据所有拥有支持 Trading 特长的 Pokemon Trading 观察推断该物品可能包含的隐藏标签
|
||||||
@@ -756,7 +789,7 @@ Ancient Artifacts 详情页使用同一套 Item Details 视图展示同一条 `i
|
|||||||
|
|
||||||
- 结果物品图片或默认材料单标记占位符;顶部概览卡片不显示 `Image` / `Details` 通用区块标题
|
- 结果物品图片或默认材料单标记占位符;顶部概览卡片不显示 `Image` / `Details` 通用区块标题
|
||||||
- 结果物品名称、分类和用途;`GET /api/recipes/:id` 的 `item` 字段返回展示所需的 `id`、`name`、`image`、`category`、`usage`
|
- 结果物品名称、分类和用途;`GET /api/recipes/:id` 的 `item` 字段返回展示所需的 `id`、`name`、`image`、`category`、`usage`
|
||||||
- 入手方式
|
- 入手方式:按 Category 分类表格展示,左侧为 Category,右侧为该分类下的入手方式 value 逐行展示
|
||||||
- 需要材料列表:展示材料物品图标;未配置图标时显示默认物品标记占位符
|
- 需要材料列表:展示材料物品图标;未配置图标时显示默认物品标记占位符
|
||||||
- 最后编辑信息
|
- 最后编辑信息
|
||||||
- 讨论
|
- 讨论
|
||||||
@@ -877,13 +910,11 @@ Life 是社区生活分享信息流,类似轻量社交动态。
|
|||||||
Life Post 可配置:
|
Life Post 可配置:
|
||||||
|
|
||||||
- Post 内容正文
|
- Post 内容正文
|
||||||
- Category:使用 Life Category 配置,必须且只能选择 1 个
|
|
||||||
- Game Version:可为空,使用 Game Version 配置;有值时在 Post 卡片展示版本号。
|
- Game Version:可为空,使用 Game Version 配置;有值时在 Post 卡片展示版本号。
|
||||||
- 创建者、最后编辑者、创建时间、最后编辑时间
|
- 创建者、最后编辑者、创建时间、最后编辑时间
|
||||||
- 评论
|
- 评论
|
||||||
- 评论回复:仅支持回复顶层评论,不做无限嵌套
|
- 评论回复:仅支持回复顶层评论,不做无限嵌套
|
||||||
- Reactions:`like`、`helpful`、`fun`、`thanks`
|
- Reactions:`like`、`helpful`、`fun`、`thanks`
|
||||||
- Ratings:Rateable Category 下的 Post 支持 1-5 星评分;每个用户每条 Post 最多一条评分,重复评分会替换原评分。
|
|
||||||
|
|
||||||
前台行为:
|
前台行为:
|
||||||
|
|
||||||
@@ -893,10 +924,9 @@ Life Post 可配置:
|
|||||||
- 已注册并完成邮箱验证且拥有 `life.posts.create` 权限的用户可以发布 Life Post。
|
- 已注册并完成邮箱验证且拥有 `life.posts.create` 权限的用户可以发布 Life Post。
|
||||||
- 作者本人拥有 `life.posts.update` / `life.posts.delete` 权限时可以编辑、删除自己的 Life Post;删除 Life Post 使用软删除。
|
- 作者本人拥有 `life.posts.update` / `life.posts.delete` 权限时可以编辑、删除自己的 Life Post;删除 Life Post 使用软删除。
|
||||||
- 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。
|
- 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。
|
||||||
- 已注册并完成邮箱验证且拥有 `life.posts.create` 或 `life.posts.update` 权限的用户发布或编辑 Life Post 时必须选择 1 个 Life Category。
|
|
||||||
- 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post,并回复顶层评论。
|
- 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post,并回复顶层评论。
|
||||||
- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除后的 Life Comment 仅对该评论作者本人可见并保留正文,作者可通过 Undo 恢复;其他用户不可见,不显示 Deleted Comment 占位,不出现在评论列表、评论预览或评论数量中。
|
- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除后的 Life Comment 仅对该评论作者本人可见并保留正文,作者可通过 Undo 恢复;其他用户不可见,不显示 Deleted Comment 占位,不出现在评论列表、评论预览或评论数量中。
|
||||||
- 已软删除的 Life Post 不出现在信息流、搜索或 Category 筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
- 已软删除的 Life Post 不出现在信息流或搜索结果中,也不能继续编辑、评论或设置 Reaction。
|
||||||
- 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。
|
- 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。
|
||||||
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
||||||
- Life Post 详情页默认展示该 Post 的评论区,并使用独立分页接口继续加载完整评论列表。
|
- Life Post 详情页默认展示该 Post 的评论区,并使用独立分页接口继续加载完整评论列表。
|
||||||
@@ -906,15 +936,11 @@ Life Post 可配置:
|
|||||||
- 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。
|
- 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction;普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`。
|
||||||
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
||||||
- 用户可在 Life Post 的 Reaction 汇总处打开 Modal 查看公开 Reaction 用户列表;列表支持按 Reaction 类型筛选并分页加载。
|
- 用户可在 Life Post 的 Reaction 汇总处打开 Modal 查看公开 Reaction 用户列表;列表支持按 Reaction 类型筛选并分页加载。
|
||||||
- 已注册并完成邮箱验证且拥有 `life.ratings.set` 权限的用户可以对 Rateable Life Post 设置或取消 1-5 星评分;非 Rateable Category 下的 Post 不显示评分控件,也不能通过 API 评分。
|
|
||||||
- Life Post 展示评分时只展示平均分、评分人数和当前用户自己的评分;不展示其他用户的评分明细。
|
|
||||||
- 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。
|
- 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。
|
||||||
- Feed 使用 Tabs 展示 Life Category 筛选;包含 All 和后台配置的 Life Category;点击 Category 后按该 Category 筛选,搜索和 Category 筛选可以同时生效。
|
- Feed 使用语言筛选展示 All languages 和启用语言;语言区筛选独立于系统 UI 语言,搜索和语言筛选可以同时生效。
|
||||||
- Feed 使用语言筛选展示 All languages 和启用语言;语言区筛选独立于系统 UI 语言,搜索、Category 和语言筛选可以同时生效。
|
|
||||||
- Feed 支持按 Game Version 筛选;All versions 表示不过滤版本。
|
- Feed 支持按 Game Version 筛选;All versions 表示不过滤版本。
|
||||||
- Feed 支持 Rateable 筛选;All 表示不过滤,Rateable only 只展示可评分 Category 下的 Post。
|
- Feed 支持排序:Latest 默认按创建时间倒序;Oldest 按创建时间正序。
|
||||||
- Feed 支持排序:Latest 默认按创建时间倒序;Oldest 按创建时间正序;Top rated 按平均评分倒序,同分时按创建时间倒序。
|
- 登录用户可切换 All Feed 和 Following Feed;Following Feed 只展示当前用户已 Follow 用户发布且当前用户可见的 Life Post,并继续支持搜索、语言、Game Version 和排序筛选。
|
||||||
- 登录用户可切换 All Feed 和 Following Feed;Following Feed 只展示当前用户已 Follow 用户发布且当前用户可见的 Life Post,并继续支持 Life Category、语言、Game Version、Rateable 和排序筛选。
|
|
||||||
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
|
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
|
||||||
- 当前没有图片上传、转发或置顶。
|
- 当前没有图片上传、转发或置顶。
|
||||||
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
|
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
|
||||||
@@ -930,9 +956,7 @@ Life Post 可配置:
|
|||||||
API 暴露边界:
|
API 暴露边界:
|
||||||
|
|
||||||
- Life Post 作者信息只返回 `id` 和 `displayName`。
|
- Life Post 作者信息只返回 `id` 和 `displayName`。
|
||||||
- Life Post Category 只返回 `id` 和按当前语言解析后的 `name`。
|
|
||||||
- Life Post Game Version 只返回 `id`、展示用 `name` 和可展示 `changeLog`;未选择版本时返回 `null`。
|
- Life Post Game Version 只返回 `id`、展示用 `name` 和可展示 `changeLog`;未选择版本时返回 `null`。
|
||||||
- Life Post Rating 只返回 `ratingAverage`、`ratingCount` 和当前用户自己的 `myRating`;不返回其他用户的评分明细。
|
|
||||||
- Life Post 可返回面向用户展示所需的审核状态、审核语言区、审核原因详情和是否可重审;审核原因详情仅用于 `rejected` / `failed`,不返回内部错误、AI prompt、模型响应、错误堆栈或 retry 细节。
|
- Life Post 可返回面向用户展示所需的审核状态、审核语言区、审核原因详情和是否可重审;审核原因详情仅用于 `rejected` / `failed`,不返回内部错误、AI prompt、模型响应、错误堆栈或 retry 细节。
|
||||||
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
||||||
- Life Comment 只返回 `likeCount`、`replyCount` 和当前用户自己的 `myLiked`;不返回点赞用户列表、邮箱、角色、权限或内部审计。
|
- Life Comment 只返回 `likeCount`、`replyCount` 和当前用户自己的 `myLiked`;不返回点赞用户列表、邮箱、角色、权限或内部审计。
|
||||||
@@ -946,6 +970,120 @@ API 暴露边界:
|
|||||||
- API 不返回 Life Post 的 `deleted_at`、`deleted_by_user_id` 等内部软删除字段。
|
- API 不返回 Life Post 的 `deleted_at`、`deleted_by_user_id` 等内部软删除字段。
|
||||||
- 非作者只有拥有对应 `*-any` 管理权限时才能编辑或删除其他用户的 Life Post 或 Life Comment。
|
- 非作者只有拥有对应 `*-any` 管理权限时才能编辑或删除其他用户的 Life Post 或 Life Comment。
|
||||||
|
|
||||||
|
## Threads
|
||||||
|
|
||||||
|
Threads 是社区长期讨论区,形态类似 Discord Forum + 聊天室混合系统。
|
||||||
|
|
||||||
|
Channel 可配置:
|
||||||
|
|
||||||
|
- 名称
|
||||||
|
- 是否允许用户创建 Thread
|
||||||
|
- 可用标签:每个 Channel 内唯一,按 `sort_order` 展示
|
||||||
|
- 可用语言:使用 `languages.code`,可配置允许的语言集合;未配置时前台回退到启用语言
|
||||||
|
- `sort_order`
|
||||||
|
|
||||||
|
Thread 可配置:
|
||||||
|
|
||||||
|
- 标题
|
||||||
|
- 所属 Channel
|
||||||
|
- 标签:多选,只能选择该 Channel 可用标签
|
||||||
|
- 语言:只能选择该 Channel 可用语言或启用语言中的一项
|
||||||
|
- 创建者、创建时间、最后活跃时间
|
||||||
|
- 消息数
|
||||||
|
- Follow 状态
|
||||||
|
- Reaction 汇总
|
||||||
|
- 锁定状态
|
||||||
|
|
||||||
|
Message 可配置:
|
||||||
|
|
||||||
|
- 所属 Thread
|
||||||
|
- 正文
|
||||||
|
- 创建者、创建时间、更新时间
|
||||||
|
- Reaction 汇总
|
||||||
|
- AI 审核状态和语言区
|
||||||
|
|
||||||
|
前台行为:
|
||||||
|
|
||||||
|
- 所有人都可以浏览已公开的 Channel、Thread 和审核通过的 Message。
|
||||||
|
- `/threads` 展示 Threads 工作区,左侧为 Channel 列表,中间为 Thread List。
|
||||||
|
- `/threads/:threadId` 通过 route-backed Modal 打开 Thread 详情;默认进入最新消息位置。
|
||||||
|
- 用户可在 Channel 内创建 Thread;需要已注册、邮箱已验证并拥有 `threads.create` 权限,且 Channel 允许用户创建 Thread。
|
||||||
|
- 创建 Thread 时可从 Thread List 顶部搜索框预填 Title,Title 可在创建表单中继续修改。
|
||||||
|
- Thread 作者本人或拥有现有 Thread 管理权限的管理员可编辑 Thread 标题和 Tags;Tags 只能选择该 Channel 可用标签。
|
||||||
|
- 已注册、邮箱已验证并拥有 `threads.messages.create` 权限的用户可以在未锁定 Thread 中发送 Message。
|
||||||
|
- Thread Message 输入框中 Enter 发送,Ctrl + Enter 输入换行。
|
||||||
|
- Message 作者本人或拥有 `admin.threads.messages.delete` 权限的管理用户可编辑 Message 正文;编辑后 Message 重新进入 AI 审核,审核通过前不向普通访客公开。
|
||||||
|
- `unreviewed`、`rejected` 和 `failed` 状态的 Message 可由作者本人或拥有 `admin.threads.messages.delete` 权限的管理用户触发重新审核;`reviewing` 和 `approved` 状态不可重新审核。
|
||||||
|
- Message 列表按创建时间正序展示,新消息出现在底部。
|
||||||
|
- 初始读取最新一页 Message;向上滚动或点击 To Top 加载更早历史消息。
|
||||||
|
- 有新消息且用户不在底部时显示 Jump to Present;点击后滚动到最新消息。
|
||||||
|
- 连续 Message 在展示层自动合并:同一用户连续发送,且相邻消息时间间隔不超过 5 分钟;合并组只显示一次 Avatar、Username 和组首条 Timestamp。合并窗口默认 5 分钟,后续可由系统配置扩展。
|
||||||
|
- Thread 支持 Follow / Unfollow;Follow 后新审核通过 Message 会让 Threads Sidebar 和 Thread List 显示未读红点或未读提示。
|
||||||
|
- Thread 详情支持未读消息分隔线;用户进入最新位置或显式标记已读后更新 `thread_reads`。
|
||||||
|
- Thread 和 Message 支持 Emoji Reaction,当前提供默认快捷 Emoji:`👍`、`❤️`、`😂`、`🔥`、`👀`;API 只返回各类型数量和当前用户自己的 Reaction,不内嵌用户列表。
|
||||||
|
- Thread List 支持排序:`last-active` 默认按最后活跃倒序;`latest` 按创建时间倒序;`most-discussed` 按公开消息数倒序。
|
||||||
|
- Thread List 支持语言筛选:All languages 或指定启用语言 / Channel 可用语言。
|
||||||
|
- Thread List 支持按 Channel 标签筛选。
|
||||||
|
- Thread List 提供前端快速搜索,可在当前已加载列表内按 Thread 标题、作者展示名、语言和标签过滤;当前不提供后端全文搜索。
|
||||||
|
- Thread 新消息实时更新通过 Thread WebSocket;WebSocket 使用短期一次性 ticket,不把 session token 放入 WebSocket URL。
|
||||||
|
- Thread Message 是用户生成内容,必须经过 AI 审核;未审核通过的 Message 不向普通访客公开。作者本人和拥有 `admin.threads.messages.delete` 权限的管理用户可以看到自己的未通过/审核中 Message 状态。
|
||||||
|
- 审核通过的 Message 才计入普通公开消息数、最后活跃排序和未读状态。
|
||||||
|
- Thread 被锁定后不可新增 Message,但仍可浏览和设置 Reaction / Follow。
|
||||||
|
- 删除 Thread 使用软删除;删除后不出现在列表,详情返回未找到。
|
||||||
|
- 删除 Message 使用软删除;普通列表不展示已删除 Message,不暴露删除字段。
|
||||||
|
|
||||||
|
管理员行为:
|
||||||
|
|
||||||
|
- 拥有 `admin.threads.channels.read` 可查看 Channel 管理。
|
||||||
|
- 拥有 `admin.threads.channels.create` / `update` / `delete` 可创建、编辑、删除 Channel,配置标签、语言和是否允许用户创建 Thread。
|
||||||
|
- 拥有 `admin.threads.threads.delete` 可删除任意 Thread。
|
||||||
|
- 拥有 `admin.threads.threads.lock` 可锁定 / 解锁任意 Thread。
|
||||||
|
- 拥有 `admin.threads.messages.delete` 可删除任意 Message。
|
||||||
|
|
||||||
|
API 暴露边界:
|
||||||
|
|
||||||
|
- Channel API 只返回展示和管理需要的 `id`、`name`、`allowUserThreads`、`tags`、`languages`、`sortOrder` 和未读摘要;不返回内部审计或调试字段。
|
||||||
|
- Thread API 只返回 `id`、`channelId`、`title`、标签、语言、作者必要署名、创建时间、最后活跃时间、锁定状态、消息数、Reaction 汇总、当前用户 Reaction、Follow 状态和未读状态。
|
||||||
|
- Message API 只返回 `id`、`threadId`、`body`、作者必要署名、创建时间、更新时间、审核状态、语言区、必要审核原因、Reaction 汇总和当前用户 Reaction。
|
||||||
|
- API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、删除时间、删除人或内部调试字段。
|
||||||
|
- Thread 内容正文按作者输入展示,不进入 `entity_translations`;Thread 的语言用于筛选和内容区分,不改变系统 UI 语言。
|
||||||
|
- Channel 名称和标签当前作为管理数据直接存储,不进入 `entity_translations`。
|
||||||
|
|
||||||
|
当前实现状态:
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
- 数据库已包含 `thread_channels`、`thread_channel_tags`、`thread_channel_languages`、`threads`、`thread_tag_links`、`thread_messages`、`thread_reactions`、`thread_message_reactions`、`thread_follows`、`thread_reads` 和 `thread_ws_tickets`。
|
||||||
|
- 初始化会创建默认 Channel:General、Questions、Showcase。
|
||||||
|
- RBAC 已包含 Thread 用户权限:`threads.create`、`threads.messages.create`、`threads.follow`、`threads.reactions.set`。
|
||||||
|
- RBAC 已包含 Thread 管理权限:`admin.threads.channels.*`、`admin.threads.threads.delete`、`admin.threads.threads.lock`、`admin.threads.messages.delete`。
|
||||||
|
- 公开 API 已支持读取 Channel、分页读取 Thread、读取单个 Thread、读取 Thread Message 历史。
|
||||||
|
- 写入 API 已支持创建 Thread、发送与编辑 Message、Message 重新审核、Follow / Unfollow、标记已读、设置 / 取消 Thread Reaction、设置 / 取消 Message Reaction。
|
||||||
|
- 管理 API 已支持创建、编辑、删除 Channel,锁定 / 解锁 Thread,删除 Thread,删除 Message。
|
||||||
|
- Thread Message 已接入 AI 审核队列;审核通过后才更新 Thread 的公开 `message_count`、`last_message_id` 和 `last_active_at`。
|
||||||
|
- Thread WebSocket 已实现短期 ticket 连接,并可推送新审核通过 Message、Reaction 更新和当前用户 read 状态更新。
|
||||||
|
- 前端已新增 `/threads` 和 `/threads/:threadId`,包含 Channel Sidebar、Thread List、Thread 详情 Modal、创建 Thread、编辑 Thread 标题和 Tags、发送与编辑 Message、Message 重新审核、Follow / Unfollow、Reaction、管理员锁定 / 解锁 Thread、管理员删除 Thread 和管理员删除 Message。
|
||||||
|
- 前端 Message 展示已支持同一用户 5 分钟内连续消息的合并显示。
|
||||||
|
- 前端 Message 历史已支持点击 Load older 向上加载更早消息。
|
||||||
|
- 前端已支持 Jump to Present:用户不在底部且收到新消息时可跳到最新。
|
||||||
|
- 前端 Thread List 已支持 Channel、标签、语言和排序筛选。
|
||||||
|
- 前端管理端已新增 Thread Channels 管理入口,可配置 Channel 名称、是否允许用户创建 Thread、标签和语言。
|
||||||
|
|
||||||
|
未实现 / 待完善:
|
||||||
|
|
||||||
|
- Thread 详情中的未读消息分隔线尚未完整实现;当前已记录 read 状态并显示列表未读红点,但没有在消息流中定位并渲染 unread divider。
|
||||||
|
- WebSocket 没有自动重连、退避重试或跨标签页连接复用;连接断开后需页面重新加载或后续操作重新进入。
|
||||||
|
- Reaction 用户列表 Modal 尚未实现;当前只显示 Reaction 类型和数量,以及当前用户自己的 Reaction 状态。
|
||||||
|
- Thread / Message Reaction 取消 API 当前通过 JSON body 传入 `reactionType`,前端可用;若后续需要更标准的 REST 形态,可改为 `DELETE /reaction/:reactionType`。
|
||||||
|
- Channel 排序 UI 尚未实现;数据库已有 `sort_order`,但管理端目前不能拖拽或调整 Channel / Tag / Language 顺序。
|
||||||
|
- Channel 名称和标签尚未进入 `entity_translations`;当前按管理数据原文展示。
|
||||||
|
- Thread 创建后的首条 Message 如果审核失败,Thread 会存在但普通访客看不到公开 Message,前端尚未提供 Message 审核重试入口。
|
||||||
|
- Thread Message 审核失败 / 拒绝后的重试 API 和 UI 尚未实现。
|
||||||
|
- Thread 删除、Message 删除和锁定 / 解锁当前直接执行,尚未使用确认 Modal。
|
||||||
|
- Thread List 的实时排序更新是基础 upsert 行为;复杂筛选条件下收到不匹配当前筛选的新 Thread / Message 时,仍可能需要后续刷新来得到完全一致的列表。
|
||||||
|
- 移动端已使用响应式堆叠布局,但还不是独立的移动端双页导航体验;后续可优化为 Channel / Thread List / Chat 分步视图。
|
||||||
|
- 当前没有 Thread 后端全文搜索、置顶、收藏、编辑 Thread 语言、编辑 Message、上传图片、@mention 或通知到全局 NotificationBell。
|
||||||
|
|
||||||
## 开发中入口
|
## 开发中入口
|
||||||
|
|
||||||
以下前台公开入口当前仅展示“正在开发中”占位页,不提供数据模型、后端 API、编辑表单、管理入口或排序能力:
|
以下前台公开入口当前仅展示“正在开发中”占位页,不提供数据模型、后端 API、编辑表单、管理入口或排序能力:
|
||||||
@@ -999,7 +1137,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。
|
||||||
@@ -1036,8 +1174,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`
|
||||||
@@ -1050,20 +1188,37 @@ API 暴露边界:
|
|||||||
- `/checklist`
|
- `/checklist`
|
||||||
- `/life`
|
- `/life`
|
||||||
- `/life/:id`
|
- `/life/:id`
|
||||||
|
- `/threads`
|
||||||
|
- `/threads/:threadId`
|
||||||
- `/project-updates`
|
- `/project-updates`
|
||||||
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页、Life Post 详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。
|
- `sitemap.xml` 输出 sitemap index,并引用按公开模块拆分的全量 sitemap:
|
||||||
|
- `/sitemap-static.xml`:稳定公开顶层浏览入口和法律页面。
|
||||||
|
- `/sitemap-pokedex.xml`:Pokemon 详情页,URL 使用 canonical `/pokemon/:id`。
|
||||||
|
- `/sitemap-habitats.xml`:Habitat 详情页,URL 使用 canonical `/habitats/:id`。
|
||||||
|
- `/sitemap-collections.xml`:Items、Ancient Artifacts 和 Recipes 详情页,URL 分别使用 canonical `/items/:id`、`/ancient-artifacts/:id` 和 `/recipes/:id`;带 Ancient Artifact 分类的 item 只输出 `/ancient-artifacts/:id`,避免同一内容在 sitemap 中重复提交。
|
||||||
|
- `/sitemap-life.xml`:公开可见 Life Post 详情页,URL 使用 canonical `/life/:id`。
|
||||||
|
- `/sitemap-threads.xml`:公开 Thread 详情页,URL 使用 canonical `/threads/:threadId`。
|
||||||
|
- Sitemap URL 条目输出 `lastmod` 和 `priority`;详情页 `lastmod` 优先使用公开列表数据中的 `updatedAt` 或活跃时间字段,缺失时回退到 `createdAt`,不得暴露编辑人、权限、审核原因、内部审计 payload 或调试信息。
|
||||||
|
- 当前不输出公开 Profile 全量 sitemap;公开 Profile 可通过站内搜索和站内链接发现,避免将用户目录作为 sitemap 枚举面。
|
||||||
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后,用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
|
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后,用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
|
||||||
|
- Threads 列表页使用 `/threads` canonical 并进入 sitemap;Thread 详情页在公开 Thread summary 加载完成后,用 Thread 标题、公开消息数、语言、标签、作者展示名和活跃时间更新 title、description、canonical、Open Graph 和 `DiscussionForumPosting` 结构化数据。
|
||||||
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
|
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
|
||||||
- 新建页面 canonical 指向对应列表页;编辑 Modal 路由 canonical 指向对应实体详情页。
|
- 新建页面 canonical 指向对应列表页;编辑 Modal 路由 canonical 指向对应实体详情页。
|
||||||
- SEO metadata 只能使用公开业务数据和系统文案;不得暴露邮箱、权限 key、token/hash、内部审计 payload、调试信息或实现说明。
|
- SEO metadata 只能使用公开业务数据和系统文案;不得暴露邮箱、权限 key、token/hash、内部审计 payload、调试信息、未审核 Thread Message、审核原因或实现说明。
|
||||||
- 多语言 metadata 使用当前前端语言和系统文案回退机制;当前没有语言专属 URL,因此暂不输出 `hreflang`。
|
- 多语言 metadata 使用当前前端语言和系统文案回退机制;当前没有语言专属 URL,因此暂不输出 `hreflang`。
|
||||||
|
|
||||||
## 部署与升级维护
|
## 部署与升级维护
|
||||||
|
|
||||||
- Docker 部署时公开前端端口由 `frontend_gateway` 承载,正常流量代理到 `frontend` 服务。
|
- Docker 部署时公开前端端口由 `frontend_gateway` 承载,正常流量代理到 `frontend` 服务。
|
||||||
|
- 前端浏览器 API base URL 由 `NUXT_PUBLIC_API_BASE_URL` 提供。
|
||||||
|
- 当前端浏览器 API base URL 配置为 `localhost`、`127.0.0.1` 或 `[::1]`,但用户通过局域网 IP 或其他非 loopback 主机访问前端时,浏览器端 API 请求会自动使用当前页面主机名并保留配置的协议和端口;该兼容行为只影响浏览器端请求,不改变 Nuxt 服务端 API base URL。
|
||||||
|
- 后端 CORS origin 由 `FRONTEND_ORIGIN` 提供;当 `FRONTEND_ORIGIN` 包含本地 loopback 前端地址时,后端允许同协议、同端口的 loopback、私有网段 IP 和常见 Docker 开发主机名作为本地开发别名,并对通过校验的浏览器 origin 回显对应 `Access-Control-Allow-Origin`。
|
||||||
|
- 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 概览
|
||||||
|
|
||||||
@@ -1079,17 +1234,23 @@ API 暴露边界:
|
|||||||
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回全部栖息地以兼容管理端和实体选择器。
|
- `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 拆分列表;默认返回所有物品,包括已配置 Ancient Artifact 分类的物品;传入 `ancientArtifactCategoryId` 时可额外筛选对应 Ancient Artifact 分类下的物品;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容管理端、实体选择器和排序。
|
- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;默认返回所有物品,包括已配置 Ancient Artifact 分类的物品;传入 `ancientArtifactCategoryId` 时可额外筛选对应 Ancient Artifact 分类下的物品;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容管理端、实体选择器和排序。
|
||||||
- `GET /api/items/:id`
|
- `GET /api/items/:id`:返回物品详情、材料单关联、相关内容、编辑历史、图片历史和染色预览;染色预览只包含公开展示所需的部位序号、颜色名称和图片 URL / 路径。
|
||||||
- `GET /api/ancient-artifacts`:支持 `search`、`categoryId` 和 `tagIds` 筛选;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
|
- `GET /api/ancient-artifacts`:支持 `search`、`categoryId` 和 `tagIds` 筛选;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
|
||||||
- `GET /api/ancient-artifacts/:id`
|
- `GET /api/ancient-artifacts/:id`
|
||||||
- `GET /api/recipes`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
|
- `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 正文搜索;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `sort` 为 `latest` 或 `oldest`。
|
||||||
- `GET /api/life-posts/following`:需要登录;分页读取当前用户已 Follow 用户发布的 Life Post 动态,支持与 Life Feed 相同的 `cursor` / `limit`、搜索、Category、语言、Game Version、Rateable 和排序筛选。
|
- `GET /api/life-posts/following`:需要登录;分页读取当前用户已 Follow 用户发布的 Life Post 动态,支持与 Life Feed 相同的 `cursor` / `limit`、搜索、语言、Game Version 和排序筛选。
|
||||||
- `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。
|
- `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。
|
||||||
- `GET /api/life-posts/:id/reactions`:分页读取该 Life Post 的公开 Reaction 用户列表;支持 `cursor` / `limit` 和 `reactionType` 筛选。
|
- `GET /api/life-posts/:id/reactions`:分页读取该 Life Post 的公开 Reaction 用户列表;支持 `cursor` / `limit` 和 `reactionType` 筛选。
|
||||||
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选;支持 `sort` 为 `oldest`、`latest`、`most-liked` 或 `most-replied`。
|
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选;支持 `sort` 为 `oldest`、`latest`、`most-liked` 或 `most-replied`。
|
||||||
|
- `GET /api/thread-channels`:读取公开 Channel 列表,登录用户可同时得到 Follow Thread 的未读摘要。
|
||||||
|
- `GET /api/threads`:支持 `cursor` / `limit` 分页读取 Thread;支持 `channelId`、`language`、`tagId` 和 `sort`(`last-active`、`latest`、`most-discussed`)。
|
||||||
|
- `GET /api/threads/:id`:读取单个 Thread 详情。
|
||||||
|
- `GET /api/threads/:id/messages`:读取 Thread 消息;默认返回最新一页,支持 `before` / `limit` 向上加载历史。
|
||||||
|
- `POST /api/threads/ws-ticket`:创建短期一次性 Thread WebSocket ticket;需要登录。
|
||||||
|
- `GET /api/threads/ws?ticket=...`:Thread WebSocket 连接;只接收短期一次性 ticket。
|
||||||
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计、公开社区统计和公开 Follow 统计;登录用户读取时返回自己与目标用户的关系状态。
|
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计、公开社区统计和公开 Follow 统计;登录用户读取时返回自己与目标用户的关系状态。
|
||||||
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
||||||
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
|
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
|
||||||
@@ -1162,12 +1323,32 @@ API 暴露边界:
|
|||||||
- 实体讨论评论的点赞和取消点赞需要 `discussions.comments.like` 权限。
|
- 实体讨论评论的点赞和取消点赞需要 `discussions.comments.like` 权限。
|
||||||
- `PUT /api/discussions/comments/:id/like`
|
- `PUT /api/discussions/comments/:id/like`
|
||||||
- `DELETE /api/discussions/comments/:id/like`
|
- `DELETE /api/discussions/comments/:id/like`
|
||||||
|
- Thread 创建需要 `threads.create`。
|
||||||
|
- `POST /api/threads`
|
||||||
|
- Thread 编辑需要作者本人或现有 Thread 管理权限。
|
||||||
|
- `PUT /api/threads/:id`
|
||||||
|
- Thread Message 创建需要 `threads.messages.create`。
|
||||||
|
- `POST /api/threads/:id/messages`
|
||||||
|
- Thread Follow 需要 `threads.follow`。
|
||||||
|
- `PUT /api/threads/:id/follow`
|
||||||
|
- `DELETE /api/threads/:id/follow`
|
||||||
|
- `POST /api/threads/:id/read`
|
||||||
|
- Thread 和 Message Reaction 需要 `threads.reactions.set`。
|
||||||
|
- `PUT /api/threads/:id/reaction`
|
||||||
|
- `DELETE /api/threads/:id/reaction`
|
||||||
|
- `PUT /api/thread-messages/:id/reaction`
|
||||||
|
- `DELETE /api/thread-messages/:id/reaction`
|
||||||
|
- Thread 管理需要 `admin.threads.*` 权限。
|
||||||
|
- `GET /api/admin/thread-channels`
|
||||||
|
- `POST /api/admin/thread-channels`
|
||||||
|
- `PUT /api/admin/thread-channels/:id`
|
||||||
|
- `DELETE /api/admin/thread-channels/:id`
|
||||||
|
- `PUT /api/admin/threads/:id/lock`
|
||||||
|
- `DELETE /api/admin/threads/:id`
|
||||||
|
- `DELETE /api/admin/thread-messages/:id`
|
||||||
- Life Reaction 的设置、替换和取消。
|
- Life Reaction 的设置、替换和取消。
|
||||||
- `PUT /api/life-posts/:id/reaction`
|
- `PUT /api/life-posts/:id/reaction`
|
||||||
- `DELETE /api/life-posts/:id/reaction`
|
- `DELETE /api/life-posts/:id/reaction`
|
||||||
- Life Rating 的设置、替换和取消。
|
|
||||||
- `PUT /api/life-posts/:id/rating`
|
|
||||||
- `DELETE /api/life-posts/:id/rating`
|
|
||||||
- 每日 CheckList 的创建、更新、删除、排序需要对应 `checklist.*` 权限。
|
- 每日 CheckList 的创建、更新、删除、排序需要对应 `checklist.*` 权限。
|
||||||
- 全局配置项的查看、创建、更新、删除、排序需要对应 `admin.config.*` 权限。
|
- 全局配置项的查看、创建、更新、删除、排序需要对应 `admin.config.*` 权限。
|
||||||
- 限流设置的查看和更新通过 Access 权限控制:
|
- 限流设置的查看和更新通过 Access 权限控制:
|
||||||
@@ -1180,7 +1361,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 按 Pokopia 展示 ID(`display_id`)、`sort_order`、内部 `id` 排序,不提供列表排序 API 或 Admin 手动排序入口。
|
||||||
|
|
||||||
## 开发与验证
|
## 开发与验证
|
||||||
|
|
||||||
@@ -1190,3 +1371,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`。
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ CREATE TABLE IF NOT EXISTS entity_translations (
|
|||||||
'maps',
|
'maps',
|
||||||
'habitats',
|
'habitats',
|
||||||
'daily-checklist-items',
|
'daily-checklist-items',
|
||||||
'life-tags',
|
|
||||||
'game-versions',
|
'game-versions',
|
||||||
'dish-categories',
|
'dish-categories',
|
||||||
'dish-flavors',
|
'dish-flavors',
|
||||||
@@ -46,6 +45,31 @@ CREATE TABLE IF NOT EXISTS entity_translations (
|
|||||||
PRIMARY KEY (entity_type, entity_id, locale, field_name)
|
PRIMARY KEY (entity_type, entity_id, locale, field_name)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
DELETE FROM entity_translations
|
||||||
|
WHERE entity_type = 'life-tags';
|
||||||
|
|
||||||
|
ALTER TABLE entity_translations
|
||||||
|
DROP CONSTRAINT IF EXISTS entity_translations_entity_type_check,
|
||||||
|
ADD CONSTRAINT entity_translations_entity_type_check CHECK (
|
||||||
|
entity_type IN (
|
||||||
|
'pokemon',
|
||||||
|
'pokemon-types',
|
||||||
|
'skills',
|
||||||
|
'environments',
|
||||||
|
'favorite-things',
|
||||||
|
'acquisition-methods',
|
||||||
|
'items',
|
||||||
|
'ancient-artifacts',
|
||||||
|
'maps',
|
||||||
|
'habitats',
|
||||||
|
'daily-checklist-items',
|
||||||
|
'game-versions',
|
||||||
|
'dish-categories',
|
||||||
|
'dish-flavors',
|
||||||
|
'dishes'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -85,11 +109,14 @@ CREATE INDEX IF NOT EXISTS user_follows_followed_created_idx
|
|||||||
CREATE TABLE IF NOT EXISTS environments (
|
CREATE TABLE IF NOT EXISTS environments (
|
||||||
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,
|
||||||
|
description text NOT NULL DEFAULT '',
|
||||||
|
opposite_environment_id integer REFERENCES environments(id) ON DELETE SET NULL,
|
||||||
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,
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CHECK (opposite_environment_id IS NULL OR opposite_environment_id <> id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS roles (
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
@@ -192,6 +219,18 @@ INSERT INTO rate_limit_settings (id)
|
|||||||
VALUES (true)
|
VALUES (true)
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS module_settings (
|
||||||
|
id boolean PRIMARY KEY DEFAULT true CHECK (id = true),
|
||||||
|
trading_enabled boolean NOT NULL DEFAULT true,
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO module_settings (id)
|
||||||
|
VALUES (true)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO permissions (key, name, description, category, system_permission)
|
INSERT INTO permissions (key, name, description, category, system_permission)
|
||||||
VALUES
|
VALUES
|
||||||
('admin.access', 'Access admin', 'Open the management area.', 'Admin', true),
|
('admin.access', 'Access admin', 'Open the management area.', 'Admin', true),
|
||||||
@@ -231,7 +270,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),
|
||||||
@@ -267,7 +305,17 @@ VALUES
|
|||||||
('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true),
|
('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true),
|
||||||
('life.comments.like', 'Like Life comments', 'Like and unlike Life comments.', 'Life', true),
|
('life.comments.like', 'Like Life comments', 'Like and unlike Life comments.', 'Life', true),
|
||||||
('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true),
|
('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true),
|
||||||
('life.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true),
|
('threads.create', 'Create Threads', 'Create forum threads.', 'Threads', true),
|
||||||
|
('threads.messages.create', 'Create Thread messages', 'Create chat messages inside Threads.', 'Threads', true),
|
||||||
|
('threads.follow', 'Follow Threads', 'Follow Threads and manage read state.', 'Threads', true),
|
||||||
|
('threads.reactions.set', 'Set Thread reactions', 'Set and remove Thread and Thread message reactions.', 'Threads', true),
|
||||||
|
('admin.threads.channels.read', 'View Thread channels', 'View Thread channel configuration.', 'Threads', true),
|
||||||
|
('admin.threads.channels.create', 'Create Thread channels', 'Create Thread channels.', 'Threads', true),
|
||||||
|
('admin.threads.channels.update', 'Update Thread channels', 'Edit Thread channel configuration.', 'Threads', true),
|
||||||
|
('admin.threads.channels.delete', 'Delete Thread channels', 'Delete Thread channels.', 'Threads', true),
|
||||||
|
('admin.threads.threads.delete', 'Delete any Thread', 'Delete any Thread.', 'Threads', true),
|
||||||
|
('admin.threads.threads.lock', 'Lock Threads', 'Lock and unlock Threads.', 'Threads', true),
|
||||||
|
('admin.threads.messages.delete', 'Delete any Thread message', 'Delete any Thread message.', 'Threads', true),
|
||||||
('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', true),
|
('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', true),
|
||||||
('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
|
('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
|
||||||
('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true),
|
('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true),
|
||||||
@@ -275,6 +323,12 @@ 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';
|
||||||
|
|
||||||
|
DELETE FROM permissions
|
||||||
|
WHERE key = 'life.ratings.set';
|
||||||
|
|
||||||
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),
|
||||||
@@ -329,7 +383,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',
|
||||||
@@ -365,7 +418,17 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
|||||||
'life.comments.delete-any',
|
'life.comments.delete-any',
|
||||||
'life.comments.like',
|
'life.comments.like',
|
||||||
'life.reactions.set',
|
'life.reactions.set',
|
||||||
'life.ratings.set',
|
'threads.create',
|
||||||
|
'threads.messages.create',
|
||||||
|
'threads.follow',
|
||||||
|
'threads.reactions.set',
|
||||||
|
'admin.threads.channels.read',
|
||||||
|
'admin.threads.channels.create',
|
||||||
|
'admin.threads.channels.update',
|
||||||
|
'admin.threads.channels.delete',
|
||||||
|
'admin.threads.threads.delete',
|
||||||
|
'admin.threads.threads.lock',
|
||||||
|
'admin.threads.messages.delete',
|
||||||
'users.follow',
|
'users.follow',
|
||||||
'discussions.comments.create',
|
'discussions.comments.create',
|
||||||
'discussions.comments.delete',
|
'discussions.comments.delete',
|
||||||
@@ -411,7 +474,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',
|
||||||
@@ -439,7 +501,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
|||||||
'life.comments.delete',
|
'life.comments.delete',
|
||||||
'life.comments.like',
|
'life.comments.like',
|
||||||
'life.reactions.set',
|
'life.reactions.set',
|
||||||
'life.ratings.set',
|
'threads.create',
|
||||||
|
'threads.messages.create',
|
||||||
|
'threads.follow',
|
||||||
|
'threads.reactions.set',
|
||||||
'users.follow',
|
'users.follow',
|
||||||
'discussions.comments.create',
|
'discussions.comments.create',
|
||||||
'discussions.comments.delete',
|
'discussions.comments.delete',
|
||||||
@@ -512,7 +577,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
|||||||
'life.comments.delete',
|
'life.comments.delete',
|
||||||
'life.comments.like',
|
'life.comments.like',
|
||||||
'life.reactions.set',
|
'life.reactions.set',
|
||||||
'life.ratings.set',
|
'threads.create',
|
||||||
|
'threads.messages.create',
|
||||||
|
'threads.follow',
|
||||||
|
'threads.reactions.set',
|
||||||
'users.follow',
|
'users.follow',
|
||||||
'discussions.comments.create',
|
'discussions.comments.create',
|
||||||
'discussions.comments.delete',
|
'discussions.comments.delete',
|
||||||
@@ -526,13 +594,6 @@ WHERE r.key = 'member'
|
|||||||
)
|
)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO role_permissions (role_id, permission_id)
|
|
||||||
SELECT r.id, p.id
|
|
||||||
FROM roles r
|
|
||||||
JOIN permissions p ON p.key = 'life.ratings.set'
|
|
||||||
WHERE r.key IN ('admin', 'editor', 'member')
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
INSERT INTO role_permissions (role_id, permission_id)
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
SELECT r.id, p.id
|
SELECT r.id, p.id
|
||||||
FROM roles r
|
FROM roles r
|
||||||
@@ -554,6 +615,33 @@ JOIN permissions p ON p.key = 'users.follow'
|
|||||||
WHERE r.key IN ('admin', 'editor', 'member')
|
WHERE r.key IN ('admin', 'editor', 'member')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.key = ANY (ARRAY[
|
||||||
|
'threads.create',
|
||||||
|
'threads.messages.create',
|
||||||
|
'threads.follow',
|
||||||
|
'threads.reactions.set'
|
||||||
|
])
|
||||||
|
WHERE r.key IN ('admin', 'editor', 'member')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.key = ANY (ARRAY[
|
||||||
|
'admin.threads.channels.read',
|
||||||
|
'admin.threads.channels.create',
|
||||||
|
'admin.threads.channels.update',
|
||||||
|
'admin.threads.channels.delete',
|
||||||
|
'admin.threads.threads.delete',
|
||||||
|
'admin.threads.threads.lock',
|
||||||
|
'admin.threads.messages.delete'
|
||||||
|
])
|
||||||
|
WHERE r.key = 'admin'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
WITH first_owner_user AS (
|
WITH first_owner_user AS (
|
||||||
SELECT u.id
|
SELECT u.id
|
||||||
FROM users u
|
FROM users u
|
||||||
@@ -648,10 +736,21 @@ CREATE TABLE IF NOT EXISTS user_sessions (
|
|||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
token_hash text NOT NULL UNIQUE,
|
token_hash text NOT NULL UNIQUE,
|
||||||
|
view_as_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
view_as_role_id integer REFERENCES roles(id) ON DELETE SET NULL,
|
||||||
expires_at timestamptz NOT NULL,
|
expires_at timestamptz NOT NULL,
|
||||||
created_at timestamptz NOT NULL DEFAULT now()
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT user_sessions_view_as_single_target_check CHECK (view_as_user_id IS NULL OR view_as_role_id IS NULL)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE user_sessions
|
||||||
|
ADD COLUMN IF NOT EXISTS view_as_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS view_as_role_id integer REFERENCES roles(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE user_sessions
|
||||||
|
DROP CONSTRAINT IF EXISTS user_sessions_view_as_single_target_check,
|
||||||
|
ADD CONSTRAINT user_sessions_view_as_single_target_check CHECK (view_as_user_id IS NULL OR view_as_role_id IS NULL);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx
|
CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx
|
||||||
ON user_sessions(user_id);
|
ON user_sessions(user_id);
|
||||||
|
|
||||||
@@ -668,18 +767,6 @@ CREATE TABLE IF NOT EXISTS daily_checklist_items (
|
|||||||
CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
|
CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
|
||||||
ON daily_checklist_items(sort_order, id);
|
ON daily_checklist_items(sort_order, id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS life_tags (
|
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
||||||
name text NOT NULL UNIQUE,
|
|
||||||
is_default boolean NOT NULL DEFAULT false,
|
|
||||||
is_rateable boolean NOT NULL DEFAULT false,
|
|
||||||
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 game_versions (
|
CREATE TABLE IF NOT EXISTS game_versions (
|
||||||
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,
|
||||||
@@ -697,7 +784,6 @@ CREATE INDEX IF NOT EXISTS game_versions_sort_order_idx
|
|||||||
CREATE TABLE IF NOT EXISTS life_posts (
|
CREATE TABLE IF NOT EXISTS life_posts (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
|
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
|
||||||
category_id integer REFERENCES life_tags(id) ON DELETE RESTRICT,
|
|
||||||
game_version_id integer REFERENCES game_versions(id) ON DELETE SET NULL,
|
game_version_id integer REFERENCES game_versions(id) ON DELETE SET NULL,
|
||||||
ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
||||||
ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
||||||
@@ -725,14 +811,14 @@ CREATE INDEX IF NOT EXISTS life_posts_user_created_at_idx
|
|||||||
ON life_posts(created_by_user_id, created_at DESC, id DESC)
|
ON life_posts(created_by_user_id, created_at DESC, id DESC)
|
||||||
WHERE deleted_at IS NULL;
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS life_post_tags (
|
DROP INDEX IF EXISTS life_posts_category_idx;
|
||||||
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
|
DROP TABLE IF EXISTS life_post_ratings;
|
||||||
tag_id integer NOT NULL REFERENCES life_tags(id) ON DELETE CASCADE,
|
DROP TABLE IF EXISTS life_post_tags;
|
||||||
PRIMARY KEY (post_id, tag_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS life_post_tags_tag_idx
|
ALTER TABLE life_posts
|
||||||
ON life_post_tags(tag_id, post_id);
|
DROP COLUMN IF EXISTS category_id;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS life_tags;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS life_post_comments (
|
CREATE TABLE IF NOT EXISTS life_post_comments (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
@@ -790,20 +876,211 @@ CREATE INDEX IF NOT EXISTS life_post_reactions_post_idx
|
|||||||
CREATE INDEX IF NOT EXISTS life_post_reactions_user_idx
|
CREATE INDEX IF NOT EXISTS life_post_reactions_user_idx
|
||||||
ON life_post_reactions(user_id, updated_at DESC, post_id DESC);
|
ON life_post_reactions(user_id, updated_at DESC, post_id DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS life_post_ratings (
|
CREATE TABLE IF NOT EXISTS thread_channels (
|
||||||
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
name text NOT NULL UNIQUE,
|
||||||
rating integer NOT NULL CHECK (rating BETWEEN 1 AND 5),
|
allow_user_threads boolean NOT NULL DEFAULT true,
|
||||||
|
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(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
PRIMARY KEY (post_id, user_id)
|
CHECK (length(name) BETWEEN 1 AND 80)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS life_post_ratings_post_idx
|
CREATE INDEX IF NOT EXISTS thread_channels_sort_order_idx
|
||||||
ON life_post_ratings(post_id, rating);
|
ON thread_channels(sort_order, id);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS life_post_ratings_user_idx
|
INSERT INTO thread_channels (name, allow_user_threads, sort_order)
|
||||||
ON life_post_ratings(user_id, updated_at DESC, post_id DESC);
|
VALUES
|
||||||
|
('General', true, 10),
|
||||||
|
('Questions', true, 20),
|
||||||
|
('Showcase', true, 30)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_channel_tags (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
channel_id integer NOT NULL REFERENCES thread_channels(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (channel_id, name),
|
||||||
|
CHECK (length(name) BETWEEN 1 AND 40)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_channel_tags_channel_sort_idx
|
||||||
|
ON thread_channel_tags(channel_id, sort_order, id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_channel_languages (
|
||||||
|
channel_id integer NOT NULL REFERENCES thread_channels(id) ON DELETE CASCADE,
|
||||||
|
language_code text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
|
PRIMARY KEY (channel_id, language_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_channel_languages_sort_idx
|
||||||
|
ON thread_channel_languages(channel_id, sort_order, language_code);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS threads (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
channel_id integer NOT NULL REFERENCES thread_channels(id) ON DELETE CASCADE,
|
||||||
|
title text NOT NULL,
|
||||||
|
language_code text NOT NULL REFERENCES languages(code) ON DELETE RESTRICT,
|
||||||
|
locked boolean NOT NULL DEFAULT false,
|
||||||
|
message_count integer NOT NULL DEFAULT 0 CHECK (message_count >= 0),
|
||||||
|
last_message_id integer,
|
||||||
|
last_active_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CHECK (length(title) BETWEEN 1 AND 140)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS threads_channel_last_active_idx
|
||||||
|
ON threads(channel_id, last_active_at DESC, id DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS threads_created_at_idx
|
||||||
|
ON threads(created_at DESC, id DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS threads_language_idx
|
||||||
|
ON threads(language_code, last_active_at DESC, id DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_tag_links (
|
||||||
|
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||||
|
tag_id integer NOT NULL REFERENCES thread_channel_tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (thread_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_tag_links_tag_idx
|
||||||
|
ON thread_tag_links(tag_id, thread_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_messages (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||||
|
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
|
||||||
|
ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
|
||||||
|
ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
|
||||||
|
ai_moderation_reason text,
|
||||||
|
ai_moderation_content_hash text,
|
||||||
|
ai_moderation_checked_at timestamptz,
|
||||||
|
ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
|
||||||
|
ai_moderation_updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conname = 'threads_last_message_fk'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE threads
|
||||||
|
ADD CONSTRAINT threads_last_message_fk
|
||||||
|
FOREIGN KEY (last_message_id) REFERENCES thread_messages(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_messages_thread_created_idx
|
||||||
|
ON thread_messages(thread_id, created_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_messages_user_idx
|
||||||
|
ON thread_messages(created_by_user_id, created_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_reactions (
|
||||||
|
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||||
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
reaction_type text NOT NULL CHECK (length(reaction_type) BETWEEN 1 AND 24),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (thread_id, user_id, reaction_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE thread_reactions
|
||||||
|
DROP CONSTRAINT IF EXISTS thread_reactions_reaction_type_check,
|
||||||
|
ADD CONSTRAINT thread_reactions_reaction_type_check CHECK (length(reaction_type) BETWEEN 1 AND 24);
|
||||||
|
UPDATE thread_reactions
|
||||||
|
SET reaction_type = CASE reaction_type
|
||||||
|
WHEN 'thumbs-up' THEN '👍'
|
||||||
|
WHEN 'heart' THEN '❤️'
|
||||||
|
WHEN 'laugh' THEN '😂'
|
||||||
|
WHEN 'fire' THEN '🔥'
|
||||||
|
WHEN 'eyes' THEN '👀'
|
||||||
|
ELSE reaction_type
|
||||||
|
END
|
||||||
|
WHERE reaction_type IN ('thumbs-up', 'heart', 'laugh', 'fire', 'eyes');
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_reactions_thread_idx
|
||||||
|
ON thread_reactions(thread_id, reaction_type);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_message_reactions (
|
||||||
|
message_id integer NOT NULL REFERENCES thread_messages(id) ON DELETE CASCADE,
|
||||||
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
reaction_type text NOT NULL CHECK (length(reaction_type) BETWEEN 1 AND 24),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (message_id, user_id, reaction_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE thread_message_reactions
|
||||||
|
DROP CONSTRAINT IF EXISTS thread_message_reactions_reaction_type_check,
|
||||||
|
ADD CONSTRAINT thread_message_reactions_reaction_type_check CHECK (length(reaction_type) BETWEEN 1 AND 24);
|
||||||
|
UPDATE thread_message_reactions
|
||||||
|
SET reaction_type = CASE reaction_type
|
||||||
|
WHEN 'thumbs-up' THEN '👍'
|
||||||
|
WHEN 'heart' THEN '❤️'
|
||||||
|
WHEN 'laugh' THEN '😂'
|
||||||
|
WHEN 'fire' THEN '🔥'
|
||||||
|
WHEN 'eyes' THEN '👀'
|
||||||
|
ELSE reaction_type
|
||||||
|
END
|
||||||
|
WHERE reaction_type IN ('thumbs-up', 'heart', 'laugh', 'fire', 'eyes');
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_message_reactions_message_idx
|
||||||
|
ON thread_message_reactions(message_id, reaction_type);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_follows (
|
||||||
|
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||||
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (thread_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_follows_user_idx
|
||||||
|
ON thread_follows(user_id, created_at DESC, thread_id DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_reads (
|
||||||
|
thread_id integer NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||||
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
last_read_message_id integer REFERENCES thread_messages(id) ON DELETE SET NULL,
|
||||||
|
last_read_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (thread_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_reads_user_idx
|
||||||
|
ON thread_reads(user_id, thread_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS thread_ws_tickets (
|
||||||
|
ticket_hash text PRIMARY KEY,
|
||||||
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CHECK (length(ticket_hash) BETWEEN 32 AND 128)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS thread_ws_tickets_expires_idx
|
||||||
|
ON thread_ws_tickets(expires_at);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS skills (
|
CREATE TABLE IF NOT EXISTS skills (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
@@ -820,11 +1097,13 @@ CREATE TABLE IF NOT EXISTS skills (
|
|||||||
CREATE TABLE IF NOT EXISTS favorite_things (
|
CREATE TABLE IF NOT EXISTS favorite_things (
|
||||||
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,
|
||||||
|
opposite_favorite_thing_id integer REFERENCES favorite_things(id) ON DELETE SET NULL,
|
||||||
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,
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_at timestamptz NOT NULL DEFAULT now()
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CHECK (opposite_favorite_thing_id IS NULL OR opposite_favorite_thing_id <> id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_types (
|
CREATE TABLE IF NOT EXISTS pokemon_types (
|
||||||
@@ -890,6 +1169,7 @@ CREATE TABLE IF NOT EXISTS pokemon_favorite_things (
|
|||||||
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,
|
||||||
|
category text NOT NULL DEFAULT 'General',
|
||||||
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,
|
||||||
@@ -905,6 +1185,7 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
ancient_artifact_category_key text,
|
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,
|
||||||
|
dyeability integer NOT NULL DEFAULT 0 CHECK (dyeability IN (0, 1, 2, 3)),
|
||||||
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,
|
||||||
@@ -934,9 +1215,25 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
ancient_artifact_category_key IS NULL
|
ancient_artifact_category_key IS NULL
|
||||||
OR ancient_artifact_category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils')
|
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', 'food'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_dye_previews (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
part_index integer NOT NULL CHECK (part_index BETWEEN 1 AND 3),
|
||||||
|
color_name text NOT NULL,
|
||||||
|
image_path text NOT NULL,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
|
CHECK (length(color_name) BETWEEN 1 AND 80)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS item_dye_previews_item_part_color_idx
|
||||||
|
ON item_dye_previews(item_id, part_index, lower(color_name));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS item_dye_previews_item_order_idx
|
||||||
|
ON item_dye_previews(item_id, part_index, sort_order, id);
|
||||||
|
|
||||||
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),
|
||||||
@@ -1078,10 +1375,10 @@ CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_orde
|
|||||||
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);
|
||||||
CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id);
|
CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
|
DROP INDEX IF EXISTS pokemon_sort_order_idx;
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item);
|
DROP INDEX IF EXISTS pokemon_display_event_item_key;
|
||||||
CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id);
|
DROP INDEX IF EXISTS pokemon_display_order_idx;
|
||||||
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 pokemon_display_order_idx ON pokemon(is_event_item, display_id, 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 recipes_sort_order_idx ON recipes(sort_order, id);
|
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
|
||||||
@@ -1242,10 +1539,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);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS life_posts_category_idx
|
|
||||||
ON life_posts(category_id, created_at DESC, id DESC)
|
|
||||||
WHERE deleted_at IS NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS life_posts_game_version_idx
|
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;
|
||||||
@@ -1276,3 +1569,86 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_language_idx
|
|||||||
|
|
||||||
ALTER TABLE skills
|
ALTER TABLE skills
|
||||||
ADD COLUMN IF NOT EXISTS has_trading boolean NOT NULL DEFAULT false;
|
ADD COLUMN IF NOT EXISTS has_trading boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE items
|
||||||
|
ADD COLUMN IF NOT EXISTS dyeability integer NOT NULL DEFAULT 0 CHECK (dyeability IN (0, 1, 2, 3));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_dye_previews (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
part_index integer NOT NULL CHECK (part_index BETWEEN 1 AND 3),
|
||||||
|
color_name text NOT NULL,
|
||||||
|
image_path text NOT NULL,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
|
CHECK (length(color_name) BETWEEN 1 AND 80)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS item_dye_previews_item_part_color_idx
|
||||||
|
ON item_dye_previews(item_id, part_index, lower(color_name));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS item_dye_previews_item_order_idx
|
||||||
|
ON item_dye_previews(item_id, part_index, sort_order, id);
|
||||||
|
|
||||||
|
ALTER TABLE environments
|
||||||
|
ADD COLUMN IF NOT EXISTS description text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE environments
|
||||||
|
ADD COLUMN IF NOT EXISTS opposite_environment_id integer;
|
||||||
|
|
||||||
|
ALTER TABLE favorite_things
|
||||||
|
ADD COLUMN IF NOT EXISTS opposite_favorite_thing_id integer;
|
||||||
|
|
||||||
|
ALTER TABLE acquisition_methods
|
||||||
|
ADD COLUMN IF NOT EXISTS category text NOT NULL DEFAULT 'General';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'environments_opposite_environment_id_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE environments
|
||||||
|
ADD CONSTRAINT environments_opposite_environment_id_fkey
|
||||||
|
FOREIGN KEY (opposite_environment_id) REFERENCES environments(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'environments_opposite_environment_id_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE environments
|
||||||
|
ADD CONSTRAINT environments_opposite_environment_id_check
|
||||||
|
CHECK (opposite_environment_id IS NULL OR opposite_environment_id <> id);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'favorite_things_opposite_favorite_thing_id_fkey'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE favorite_things
|
||||||
|
ADD CONSTRAINT favorite_things_opposite_favorite_thing_id_fkey
|
||||||
|
FOREIGN KEY (opposite_favorite_thing_id) REFERENCES favorite_things(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'favorite_things_opposite_favorite_thing_id_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE favorite_things
|
||||||
|
ADD CONSTRAINT favorite_things_opposite_favorite_thing_id_check
|
||||||
|
CHECK (opposite_favorite_thing_id IS NULL OR opposite_favorite_thing_id <> id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS environments_opposite_environment_unique_idx
|
||||||
|
ON environments(opposite_environment_id)
|
||||||
|
WHERE opposite_environment_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS favorite_things_opposite_favorite_thing_unique_idx
|
||||||
|
ON favorite_things(opposite_favorite_thing_id)
|
||||||
|
WHERE opposite_favorite_thing_id IS NOT NULL;
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET dyeability = CASE
|
||||||
|
WHEN dual_dyeable THEN 2
|
||||||
|
WHEN dyeable THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
WHERE dyeability = 0
|
||||||
|
AND (dual_dyeable OR dyeable);
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
createApprovedCommentNotification,
|
createApprovedCommentNotification,
|
||||||
createModerationResultNotification
|
createModerationResultNotification
|
||||||
} from './notifications.ts';
|
} from './notifications.ts';
|
||||||
|
import { applyApprovedThreadMessage, publishThreadMessageModeration } from './threadsRealtime.ts';
|
||||||
|
|
||||||
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
|
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
|
||||||
export type AiModerationTargetType = 'life-post' | 'life-comment' | 'discussion-comment';
|
export type AiModerationTargetType = 'life-post' | 'life-comment' | 'discussion-comment' | 'thread-message';
|
||||||
export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions';
|
export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions';
|
||||||
export type AiModerationAuthMode = 'query-key' | 'bearer-token';
|
export type AiModerationAuthMode = 'query-key' | 'bearer-token';
|
||||||
|
|
||||||
@@ -254,6 +255,49 @@ const targetQueries: Record<
|
|||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
|
},
|
||||||
|
'thread-message': {
|
||||||
|
select: `
|
||||||
|
SELECT
|
||||||
|
tm.id,
|
||||||
|
tm.body,
|
||||||
|
tm.ai_moderation_status AS status,
|
||||||
|
tm.ai_moderation_language_code AS "languageCode",
|
||||||
|
tm.ai_moderation_reason AS reason,
|
||||||
|
tm.ai_moderation_content_hash AS "contentHash"
|
||||||
|
FROM thread_messages tm
|
||||||
|
JOIN threads t ON t.id = tm.thread_id
|
||||||
|
WHERE tm.id = $1
|
||||||
|
AND tm.deleted_at IS NULL
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
`,
|
||||||
|
updateStatus: `
|
||||||
|
UPDATE thread_messages
|
||||||
|
SET ai_moderation_status = $2,
|
||||||
|
ai_moderation_language_code = $3,
|
||||||
|
ai_moderation_reason = CASE WHEN $2 IN ('rejected', 'failed') THEN $4 ELSE NULL END,
|
||||||
|
ai_moderation_checked_at = now(),
|
||||||
|
ai_moderation_updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
`,
|
||||||
|
updateForReview: `
|
||||||
|
UPDATE thread_messages
|
||||||
|
SET ai_moderation_status = 'reviewing',
|
||||||
|
ai_moderation_language_code = $2,
|
||||||
|
ai_moderation_reason = NULL,
|
||||||
|
ai_moderation_content_hash = $3,
|
||||||
|
ai_moderation_checked_at = NULL,
|
||||||
|
ai_moderation_retry_count = CASE
|
||||||
|
WHEN $4::boolean THEN 0
|
||||||
|
WHEN $5::boolean THEN ai_moderation_retry_count + 1
|
||||||
|
ELSE ai_moderation_retry_count
|
||||||
|
END,
|
||||||
|
ai_moderation_updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
RETURNING id
|
||||||
|
`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -595,6 +639,15 @@ async function enqueuePendingAiModeration(): Promise<void> {
|
|||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND ai_moderation_status IN ('unreviewed', 'reviewing')
|
AND ai_moderation_status IN ('unreviewed', 'reviewing')
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT 'thread-message'::text AS type, tm.id
|
||||||
|
FROM thread_messages tm
|
||||||
|
JOIN threads t ON t.id = tm.thread_id
|
||||||
|
WHERE tm.deleted_at IS NULL
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
AND tm.ai_moderation_status IN ('unreviewed', 'reviewing')
|
||||||
|
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
`,
|
`,
|
||||||
[retryScanLimit]
|
[retryScanLimit]
|
||||||
@@ -715,9 +768,63 @@ async function updateTargetStatus(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createModerationResultNotification(target, status);
|
if (target.type === 'thread-message') {
|
||||||
if (status === 'approved') {
|
if (status === 'approved') {
|
||||||
await createApprovedCommentNotification(target);
|
await applyApprovedThreadMessage(target.id);
|
||||||
|
} else {
|
||||||
|
const row = await queryOne<{
|
||||||
|
threadId: number;
|
||||||
|
body: string;
|
||||||
|
moderationStatus: AiModerationStatus;
|
||||||
|
moderationLanguageCode: string | null;
|
||||||
|
moderationReason: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
author: { id: number; displayName: string } | null;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
tm.thread_id AS "threadId",
|
||||||
|
tm.body,
|
||||||
|
tm.ai_moderation_status AS "moderationStatus",
|
||||||
|
tm.ai_moderation_language_code AS "moderationLanguageCode",
|
||||||
|
tm.ai_moderation_reason AS "moderationReason",
|
||||||
|
tm.created_at AS "createdAt",
|
||||||
|
tm.updated_at AS "updatedAt",
|
||||||
|
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'displayName', u.display_name) END AS author
|
||||||
|
FROM thread_messages tm
|
||||||
|
LEFT JOIN users u ON u.id = tm.created_by_user_id
|
||||||
|
WHERE tm.id = $1
|
||||||
|
AND tm.deleted_at IS NULL
|
||||||
|
`,
|
||||||
|
[target.id]
|
||||||
|
);
|
||||||
|
if (row) {
|
||||||
|
await publishThreadMessageModeration(row.threadId, target.id, {
|
||||||
|
id: target.id,
|
||||||
|
threadId: row.threadId,
|
||||||
|
body: row.body,
|
||||||
|
moderationStatus: row.moderationStatus,
|
||||||
|
moderationLanguageCode: row.moderationLanguageCode,
|
||||||
|
moderationReason: row.moderationReason,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
author: row.author,
|
||||||
|
reactionCounts: {},
|
||||||
|
myReactions: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationTarget = {
|
||||||
|
type: target.type as Exclude<AiModerationTargetType, 'thread-message'>,
|
||||||
|
id: target.id
|
||||||
|
};
|
||||||
|
await createModerationResultNotification(notificationTarget, status);
|
||||||
|
if (status === 'approved') {
|
||||||
|
await createApprovedCommentNotification(notificationTarget);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger?.warn(
|
logger?.warn(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const resendDailyQuotaLimit = positiveIntegerEnv('RESEND_DAILY_QUOTA_LIMIT', 100
|
|||||||
const resendMonthlyQuotaLimit = positiveIntegerEnv('RESEND_MONTHLY_QUOTA_LIMIT', 3000);
|
const resendMonthlyQuotaLimit = positiveIntegerEnv('RESEND_MONTHLY_QUOTA_LIMIT', 3000);
|
||||||
const resendQuotaReserve = nonNegativeIntegerEnv('RESEND_QUOTA_RESERVE', 5);
|
const resendQuotaReserve = nonNegativeIntegerEnv('RESEND_QUOTA_RESERVE', 5);
|
||||||
const resendQuotaSnapshotTtlMs = positiveIntegerEnv('RESEND_QUOTA_SNAPSHOT_TTL_MINUTES', 10) * 60 * 1000;
|
const resendQuotaSnapshotTtlMs = positiveIntegerEnv('RESEND_QUOTA_SNAPSHOT_TTL_MINUTES', 10) * 60 * 1000;
|
||||||
|
const emailFallbackLogTokens = process.env.EMAIL_FALLBACK_LOG_TOKENS === 'true';
|
||||||
|
|
||||||
type DbClient = PoolClient;
|
type DbClient = PoolClient;
|
||||||
|
|
||||||
@@ -85,6 +86,12 @@ export type AuthUser = {
|
|||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
roles: RoleSummary[];
|
roles: RoleSummary[];
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
viewAs?: ViewAsSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ViewAsSummary = {
|
||||||
|
mode: 'user' | 'role';
|
||||||
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReferralSummary = {
|
export type ReferralSummary = {
|
||||||
@@ -148,6 +155,12 @@ type RolePermissionRow = QueryResultRow & {
|
|||||||
permission_id: number;
|
permission_id: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SessionRow = QueryResultRow & {
|
||||||
|
user_id: number;
|
||||||
|
view_as_user_id: number | null;
|
||||||
|
view_as_role_id: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
const roleKeyPattern = /^[a-z][a-z0-9-]{1,63}$/;
|
const roleKeyPattern = /^[a-z][a-z0-9-]{1,63}$/;
|
||||||
const permissionKeyPattern = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
const permissionKeyPattern = /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/;
|
||||||
const ownerRoleKey = 'owner';
|
const ownerRoleKey = 'owner';
|
||||||
@@ -555,6 +568,38 @@ async function userPermissions(userId: number, client: DbClient | null = null):
|
|||||||
return rows.map((row) => row.key);
|
return rows.map((row) => row.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function rolePermissions(roleId: number, client: DbClient | null = null): Promise<string[]> {
|
||||||
|
const rows = await runQuery<QueryResultRow & { key: string }>(
|
||||||
|
client,
|
||||||
|
`
|
||||||
|
SELECT DISTINCT p.key
|
||||||
|
FROM role_permissions rp
|
||||||
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
|
WHERE rp.role_id = $1
|
||||||
|
AND p.enabled = true
|
||||||
|
ORDER BY p.key
|
||||||
|
`,
|
||||||
|
[roleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => row.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function roleById(roleId: number, client: DbClient | null = null): Promise<RoleSummary | null> {
|
||||||
|
const role = await runQueryOne<RoleRow>(
|
||||||
|
client,
|
||||||
|
`
|
||||||
|
SELECT id, key, name, description, level, enabled, system_role
|
||||||
|
FROM roles
|
||||||
|
WHERE id = $1
|
||||||
|
AND enabled = true
|
||||||
|
`,
|
||||||
|
[roleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return role ? toRoleSummary(role) : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function publicUserById(userId: number, client: DbClient | null = null): Promise<AuthUser | null> {
|
async function publicUserById(userId: number, client: DbClient | null = null): Promise<AuthUser | null> {
|
||||||
const user = await runQueryOne<UserRow>(
|
const user = await runQueryOne<UserRow>(
|
||||||
client,
|
client,
|
||||||
@@ -738,6 +783,37 @@ function buildPasswordResetUrl(token: string): string {
|
|||||||
return buildTokenUrl('/reset-password', token);
|
return buildTokenUrl('/reset-password', token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldLogEmailFallbackTokens(): boolean {
|
||||||
|
return emailFallbackLogTokens || process.env.NODE_ENV !== 'production';
|
||||||
|
}
|
||||||
|
|
||||||
|
function emailFallbackFailureMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logEmailFallback(options: {
|
||||||
|
purpose: 'email-verification' | 'password-reset';
|
||||||
|
email: string;
|
||||||
|
token: string;
|
||||||
|
url: string;
|
||||||
|
expiresInHours: number;
|
||||||
|
error: unknown;
|
||||||
|
}): boolean {
|
||||||
|
if (!shouldLogEmailFallbackTokens()) {
|
||||||
|
console.error(`${options.purpose} email failed`, options.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`${options.purpose} email failed; using backend fallback`, {
|
||||||
|
email: options.email,
|
||||||
|
expiresInHours: options.expiresInHours,
|
||||||
|
token: options.token,
|
||||||
|
url: options.url,
|
||||||
|
error: emailFallbackFailureMessage(options.error)
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function quotaThreshold(limit: number): number {
|
function quotaThreshold(limit: number): number {
|
||||||
const reserve = Math.min(resendQuotaReserve, Math.max(0, limit - 1));
|
const reserve = Math.min(resendQuotaReserve, Math.max(0, limit - 1));
|
||||||
return limit - reserve;
|
return limit - reserve;
|
||||||
@@ -1048,7 +1124,6 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
|
|||||||
const displayName = await cleanDisplayName(payload.displayName, locale);
|
const displayName = await cleanDisplayName(payload.displayName, locale);
|
||||||
const password = await cleanPassword(payload.password, locale);
|
const password = await cleanPassword(payload.password, locale);
|
||||||
const referralCode = await cleanReferralCode(payload.referralCode, locale);
|
const referralCode = await cleanReferralCode(payload.referralCode, locale);
|
||||||
await assertResendEmailAvailable(locale);
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const verificationToken = createPlainToken();
|
const verificationToken = createPlainToken();
|
||||||
const verificationTokenHash = hashToken(verificationToken);
|
const verificationTokenHash = hashToken(verificationToken);
|
||||||
@@ -1108,7 +1183,22 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
await sendVerificationEmail(email, verificationToken, locale);
|
await sendVerificationEmail(email, verificationToken, locale);
|
||||||
|
} catch (error) {
|
||||||
|
const fallbackLogged = logEmailFallback({
|
||||||
|
purpose: 'email-verification',
|
||||||
|
email,
|
||||||
|
token: verificationToken,
|
||||||
|
url: buildVerificationUrl(verificationToken),
|
||||||
|
expiresInHours: verificationTokenHours,
|
||||||
|
error
|
||||||
|
});
|
||||||
|
if (!fallbackLogged) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { message: await authMessage(locale, 'checkVerificationEmail') };
|
return { message: await authMessage(locale, 'checkVerificationEmail') };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1162,7 +1252,6 @@ export async function verifyEmail(payload: Record<string, unknown>, locale = def
|
|||||||
|
|
||||||
export async function requestPasswordReset(payload: Record<string, unknown>, locale = defaultLocale) {
|
export async function requestPasswordReset(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||||
const email = await cleanEmail(payload.email, locale);
|
const email = await cleanEmail(payload.email, locale);
|
||||||
await assertResendEmailAvailable(locale);
|
|
||||||
const user = await queryOne<UserRow>(
|
const user = await queryOne<UserRow>(
|
||||||
'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1',
|
'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1',
|
||||||
[email]
|
[email]
|
||||||
@@ -1186,7 +1275,14 @@ export async function requestPasswordReset(payload: Record<string, unknown>, loc
|
|||||||
try {
|
try {
|
||||||
await sendPasswordResetEmail(email, resetToken, locale);
|
await sendPasswordResetEmail(email, resetToken, locale);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Password reset email failed', error);
|
logEmailFallback({
|
||||||
|
purpose: 'password-reset',
|
||||||
|
email,
|
||||||
|
token: resetToken,
|
||||||
|
url: buildPasswordResetUrl(resetToken),
|
||||||
|
expiresInHours: passwordResetTokenHours,
|
||||||
|
error
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1275,9 +1371,66 @@ export async function getUserBySessionToken(token: string): Promise<AuthUser | n
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await queryOne<QueryResultRow & { user_id: number }>(
|
const session = await queryOne<SessionRow>(
|
||||||
`
|
`
|
||||||
SELECT s.user_id
|
SELECT s.user_id, s.view_as_user_id, s.view_as_role_id
|
||||||
|
FROM user_sessions s
|
||||||
|
WHERE s.token_hash = $1
|
||||||
|
AND s.expires_at > now()
|
||||||
|
`,
|
||||||
|
[hashToken(token)]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realUser = await publicUserById(session.user_id);
|
||||||
|
if (!realUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realUserCanViewAs = realUser.emailVerified && realUser.roles.some((role) => role.key === ownerRoleKey);
|
||||||
|
|
||||||
|
if (realUserCanViewAs && session.view_as_user_id) {
|
||||||
|
const viewAsUser = await publicUserById(session.view_as_user_id);
|
||||||
|
if (viewAsUser) {
|
||||||
|
return {
|
||||||
|
...viewAsUser,
|
||||||
|
viewAs: {
|
||||||
|
mode: 'user',
|
||||||
|
label: viewAsUser.displayName || viewAsUser.email
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (realUserCanViewAs && session.view_as_role_id) {
|
||||||
|
const role = await roleById(session.view_as_role_id);
|
||||||
|
if (role) {
|
||||||
|
return {
|
||||||
|
...realUser,
|
||||||
|
roles: [role],
|
||||||
|
permissions: await rolePermissions(role.id),
|
||||||
|
viewAs: {
|
||||||
|
mode: 'role',
|
||||||
|
label: role.name
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return realUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function realUserBySessionToken(token: string): Promise<AuthUser | null> {
|
||||||
|
if (token.length < 32) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await queryOne<SessionRow>(
|
||||||
|
`
|
||||||
|
SELECT s.user_id, s.view_as_user_id, s.view_as_role_id
|
||||||
FROM user_sessions s
|
FROM user_sessions s
|
||||||
WHERE s.token_hash = $1
|
WHERE s.token_hash = $1
|
||||||
AND s.expires_at > now()
|
AND s.expires_at > now()
|
||||||
@@ -1288,6 +1441,89 @@ export async function getUserBySessionToken(token: string): Promise<AuthUser | n
|
|||||||
return session ? publicUserById(session.user_id) : null;
|
return session ? publicUserById(session.user_id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertOwnerViewAsUser(user: AuthUser | null): AuthUser {
|
||||||
|
if (!user || !user.emailVerified || !user.roles.some((role) => role.key === ownerRoleKey)) {
|
||||||
|
throw statusError('server.permissions.permissionDenied', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanViewAsId(value: unknown): number {
|
||||||
|
const id = Number(value);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
throw statusError('server.permissions.invalidSelection', 400);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startViewAsUser(sessionToken: string, payload: Record<string, unknown>): Promise<AuthUser> {
|
||||||
|
assertOwnerViewAsUser(await realUserBySessionToken(sessionToken));
|
||||||
|
const targetUserId = cleanViewAsId(payload.userId);
|
||||||
|
const targetUser = await publicUserById(targetUserId);
|
||||||
|
if (!targetUser) {
|
||||||
|
throw statusError('server.permissions.userNotFound', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
UPDATE user_sessions
|
||||||
|
SET view_as_user_id = $1,
|
||||||
|
view_as_role_id = NULL
|
||||||
|
WHERE token_hash = $2
|
||||||
|
AND expires_at > now()
|
||||||
|
`,
|
||||||
|
[targetUserId, hashToken(sessionToken)]
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await getUserBySessionToken(sessionToken);
|
||||||
|
if (!user) {
|
||||||
|
throw statusError('server.errors.loginRequired', 401);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startViewAsRole(sessionToken: string, payload: Record<string, unknown>): Promise<AuthUser> {
|
||||||
|
assertOwnerViewAsUser(await realUserBySessionToken(sessionToken));
|
||||||
|
const targetRoleId = cleanViewAsId(payload.roleId);
|
||||||
|
const role = await roleById(targetRoleId);
|
||||||
|
if (!role) {
|
||||||
|
throw statusError('server.permissions.roleNotFound', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
UPDATE user_sessions
|
||||||
|
SET view_as_user_id = NULL,
|
||||||
|
view_as_role_id = $1
|
||||||
|
WHERE token_hash = $2
|
||||||
|
AND expires_at > now()
|
||||||
|
`,
|
||||||
|
[targetRoleId, hashToken(sessionToken)]
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = await getUserBySessionToken(sessionToken);
|
||||||
|
if (!user) {
|
||||||
|
throw statusError('server.errors.loginRequired', 401);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopViewAs(sessionToken: string): Promise<AuthUser> {
|
||||||
|
const realUser = assertOwnerViewAsUser(await realUserBySessionToken(sessionToken));
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
UPDATE user_sessions
|
||||||
|
SET view_as_user_id = NULL,
|
||||||
|
view_as_role_id = NULL
|
||||||
|
WHERE token_hash = $1
|
||||||
|
AND expires_at > now()
|
||||||
|
`,
|
||||||
|
[hashToken(sessionToken)]
|
||||||
|
);
|
||||||
|
return realUser;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateCurrentUser(
|
export async function updateCurrentUser(
|
||||||
userId: number,
|
userId: number,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
|
|||||||
@@ -945,7 +945,6 @@ export function setupNotificationWebSocketServer(server: Server, logger: Fastify
|
|||||||
server.on('upgrade', async (request, socket) => {
|
server.on('upgrade', async (request, socket) => {
|
||||||
const url = new URL(request.url ?? '/', 'http://localhost');
|
const url = new URL(request.url ?? '/', 'http://localhost');
|
||||||
if (url.pathname !== '/api/notifications/ws') {
|
if (url.pathname !== '/api/notifications/ws') {
|
||||||
socket.destroy();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,9 @@ import {
|
|||||||
registerUser,
|
registerUser,
|
||||||
requestPasswordReset,
|
requestPasswordReset,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
|
startViewAsRole,
|
||||||
|
startViewAsUser,
|
||||||
|
stopViewAs,
|
||||||
updateAdminUserRoles,
|
updateAdminUserRoles,
|
||||||
updateCurrentUser,
|
updateCurrentUser,
|
||||||
updatePermission,
|
updatePermission,
|
||||||
@@ -50,8 +53,13 @@ import {
|
|||||||
createLifePost,
|
createLifePost,
|
||||||
createPokemon,
|
createPokemon,
|
||||||
createRecipe,
|
createRecipe,
|
||||||
|
createAdminThreadChannel,
|
||||||
|
createThread,
|
||||||
|
createThreadMessage,
|
||||||
|
createThreadsWsTicketForUser,
|
||||||
deleteConfig,
|
deleteConfig,
|
||||||
deleteAncientArtifact,
|
deleteAncientArtifact,
|
||||||
|
deleteAdminThreadChannel,
|
||||||
deleteDailyChecklistItem,
|
deleteDailyChecklistItem,
|
||||||
deleteDish,
|
deleteDish,
|
||||||
deleteDishCategory,
|
deleteDishCategory,
|
||||||
@@ -63,24 +71,30 @@ import {
|
|||||||
deleteLifeComment,
|
deleteLifeComment,
|
||||||
deleteLifeCommentLike,
|
deleteLifeCommentLike,
|
||||||
deleteLifePost,
|
deleteLifePost,
|
||||||
deleteLifePostRating,
|
|
||||||
deleteLifePostReaction,
|
deleteLifePostReaction,
|
||||||
deletePokemon,
|
deletePokemon,
|
||||||
deleteRecipe,
|
deleteRecipe,
|
||||||
|
deleteThread,
|
||||||
|
deleteThreadMessage,
|
||||||
|
deleteThreadMessageReaction,
|
||||||
|
deleteThreadReaction,
|
||||||
exportAdminData,
|
exportAdminData,
|
||||||
fetchPokemonData,
|
fetchPokemonData,
|
||||||
fetchPokemonImageOptions,
|
fetchPokemonImageOptions,
|
||||||
followUser,
|
followUser,
|
||||||
|
followThread,
|
||||||
getAdminDataToolsSummary,
|
getAdminDataToolsSummary,
|
||||||
getAncientArtifact,
|
getAncientArtifact,
|
||||||
getHabitat,
|
getHabitat,
|
||||||
getItem,
|
getItem,
|
||||||
listDish,
|
listDish,
|
||||||
getLifePost,
|
getLifePost,
|
||||||
|
getModuleSettings,
|
||||||
getOptions,
|
getOptions,
|
||||||
getPokemon,
|
getPokemon,
|
||||||
getPublicUserProfile,
|
getPublicUserProfile,
|
||||||
getRecipe,
|
getRecipe,
|
||||||
|
getThread,
|
||||||
globalSearch,
|
globalSearch,
|
||||||
importAdminData,
|
importAdminData,
|
||||||
importAdminHabitatsCsv,
|
importAdminHabitatsCsv,
|
||||||
@@ -88,6 +102,7 @@ import {
|
|||||||
isConfigType,
|
isConfigType,
|
||||||
listAncientArtifacts,
|
listAncientArtifacts,
|
||||||
listEntityDiscussionComments,
|
listEntityDiscussionComments,
|
||||||
|
listAdminThreadChannels,
|
||||||
listConfig,
|
listConfig,
|
||||||
listDailyChecklistItems,
|
listDailyChecklistItems,
|
||||||
listHabitats,
|
listHabitats,
|
||||||
@@ -100,6 +115,9 @@ import {
|
|||||||
listPokemon,
|
listPokemon,
|
||||||
listPokemonFetchOptions,
|
listPokemonFetchOptions,
|
||||||
listRecipes,
|
listRecipes,
|
||||||
|
listThreadChannels,
|
||||||
|
listThreadMessages,
|
||||||
|
listThreads,
|
||||||
listUserCommentActivities,
|
listUserCommentActivities,
|
||||||
listUserLifePosts,
|
listUserLifePosts,
|
||||||
listUserReactionActivities,
|
listUserReactionActivities,
|
||||||
@@ -111,16 +129,18 @@ import {
|
|||||||
reorderHabitats,
|
reorderHabitats,
|
||||||
reorderItems,
|
reorderItems,
|
||||||
reorderLanguages,
|
reorderLanguages,
|
||||||
reorderPokemon,
|
|
||||||
reorderRecipes,
|
reorderRecipes,
|
||||||
|
markThreadRead,
|
||||||
retryEntityDiscussionCommentModeration,
|
retryEntityDiscussionCommentModeration,
|
||||||
retryLifeCommentModeration,
|
retryLifeCommentModeration,
|
||||||
retryLifePostModeration,
|
retryLifePostModeration,
|
||||||
|
retryThreadMessageModeration,
|
||||||
restoreLifeComment,
|
restoreLifeComment,
|
||||||
setLifePostRating,
|
|
||||||
setLifePostReaction,
|
setLifePostReaction,
|
||||||
setEntityDiscussionCommentLike,
|
setEntityDiscussionCommentLike,
|
||||||
setLifeCommentLike,
|
setLifeCommentLike,
|
||||||
|
setThreadMessageReaction,
|
||||||
|
setThreadReaction,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
updateAncientArtifact,
|
updateAncientArtifact,
|
||||||
updateDailyChecklistItem,
|
updateDailyChecklistItem,
|
||||||
@@ -130,9 +150,15 @@ import {
|
|||||||
updateItem,
|
updateItem,
|
||||||
updateLanguage,
|
updateLanguage,
|
||||||
updateLifePost,
|
updateLifePost,
|
||||||
|
updateModuleSettings,
|
||||||
updatePokemon,
|
updatePokemon,
|
||||||
updateRecipe,
|
updateRecipe,
|
||||||
|
updateAdminThreadChannel,
|
||||||
|
updateThread,
|
||||||
|
updateThreadLock,
|
||||||
|
updateThreadMessage,
|
||||||
unfollowUser,
|
unfollowUser,
|
||||||
|
unfollowThread,
|
||||||
wipeAdminData
|
wipeAdminData
|
||||||
} from './queries.ts';
|
} from './queries.ts';
|
||||||
import {
|
import {
|
||||||
@@ -161,13 +187,20 @@ import {
|
|||||||
markNotificationRead,
|
markNotificationRead,
|
||||||
setupNotificationWebSocketServer
|
setupNotificationWebSocketServer
|
||||||
} from './notifications.ts';
|
} from './notifications.ts';
|
||||||
|
import { setupThreadWebSocketServer } from './threadsRealtime.ts';
|
||||||
|
|
||||||
const app = Fastify({
|
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[] {
|
type CorsOriginCallback = (error: Error | null, origin: boolean | string) => void;
|
||||||
|
type CorsOriginResolver = (origin: string | undefined, callback: CorsOriginCallback) => void;
|
||||||
|
|
||||||
|
function configuredCorsOrigin(): true | CorsOriginResolver {
|
||||||
const rawOrigin = process.env.FRONTEND_ORIGIN?.trim();
|
const rawOrigin = process.env.FRONTEND_ORIGIN?.trim();
|
||||||
if (!rawOrigin) {
|
if (!rawOrigin) {
|
||||||
return true;
|
return true;
|
||||||
@@ -178,12 +211,84 @@ function configuredCorsOrigin(): true | string | string[] {
|
|||||||
.map((origin) => origin.trim())
|
.map((origin) => origin.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
return origins.length <= 1 ? (origins[0] ?? true) : origins;
|
if (origins.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (origin, callback) => {
|
||||||
|
callback(null, isAllowedCorsOrigin(origin, origins) ? (origin ?? false) : false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedCorsOrigin(origin: string | undefined, configuredOrigins: string[]): boolean {
|
||||||
|
if (!origin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuredOrigins.includes(origin)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configuredOrigins.some((configuredOrigin) => isLocalFrontendOriginAlias(origin, configuredOrigin));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalFrontendOriginAlias(origin: string, configuredOrigin: string): boolean {
|
||||||
|
try {
|
||||||
|
const requestUrl = new URL(origin);
|
||||||
|
const configuredUrl = new URL(configuredOrigin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
requestUrl.protocol === configuredUrl.protocol &&
|
||||||
|
effectivePort(requestUrl) === effectivePort(configuredUrl) &&
|
||||||
|
isLoopbackHost(configuredUrl.hostname) &&
|
||||||
|
isLocalDevelopmentHost(requestUrl.hostname)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectivePort(url: URL): string {
|
||||||
|
if (url.port) {
|
||||||
|
return url.port;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.protocol === 'https:' ? '443' : '80';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopbackHost(hostname: string): boolean {
|
||||||
|
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalDevelopmentHost(hostname: string): boolean {
|
||||||
|
return (
|
||||||
|
isLoopbackHost(hostname) ||
|
||||||
|
isPrivateIpv4Host(hostname) ||
|
||||||
|
['host.docker.internal', 'frontend', 'frontend_gateway'].includes(hostname)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrivateIpv4Host(hostname: string): boolean {
|
||||||
|
const parts = hostname.split('.').map((part) => Number(part));
|
||||||
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [first, second] = parts;
|
||||||
|
|
||||||
|
return (
|
||||||
|
first === 10 ||
|
||||||
|
first === 127 ||
|
||||||
|
(first === 172 && second >= 16 && second <= 31) ||
|
||||||
|
(first === 192 && second === 168) ||
|
||||||
|
(first === 169 && second === 254)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -243,9 +348,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 {
|
||||||
@@ -868,7 +1016,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);
|
||||||
|
|
||||||
@@ -950,7 +1098,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;
|
||||||
}
|
}
|
||||||
@@ -983,7 +1131,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) => {
|
||||||
@@ -1007,7 +1158,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) {
|
||||||
@@ -1017,12 +1168,53 @@ app.get('/api/auth/me', async (request, reply) => {
|
|||||||
return { user };
|
return { user };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/view-as/user', async (request, reply) => {
|
||||||
|
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getSessionToken(request);
|
||||||
|
if (!token) {
|
||||||
|
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
|
||||||
|
return { user: await startViewAsUser(token, payload) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/view-as/role', async (request, reply) => {
|
||||||
|
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getSessionToken(request);
|
||||||
|
if (!token) {
|
||||||
|
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
|
||||||
|
return { user: await startViewAsRole(token, payload) };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/view-as/stop', async (request, reply) => {
|
||||||
|
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getSessionToken(request);
|
||||||
|
if (!token) {
|
||||||
|
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user: await stopViewAs(token) };
|
||||||
|
});
|
||||||
|
|
||||||
app.patch('/api/auth/me', async (request, reply) => {
|
app.patch('/api/auth/me', async (request, reply) => {
|
||||||
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
|
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
|
||||||
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) {
|
||||||
@@ -1042,7 +1234,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) {
|
||||||
@@ -1062,7 +1254,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) {
|
||||||
@@ -1099,11 +1291,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();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1192,6 +1385,8 @@ app.get('/api/languages', async () => listLanguages());
|
|||||||
|
|
||||||
app.get('/api/system-wordings', async (request) => getSystemWordings(requestLocale(request)));
|
app.get('/api/system-wordings', async (request) => getSystemWordings(requestLocale(request)));
|
||||||
|
|
||||||
|
app.get('/api/module-settings', async () => getModuleSettings());
|
||||||
|
|
||||||
app.get('/api/options', async (request) => getOptions(requestLocale(request)));
|
app.get('/api/options', async (request) => getOptions(requestLocale(request)));
|
||||||
|
|
||||||
app.get('/api/project-updates', async (request) =>
|
app.get('/api/project-updates', async (request) =>
|
||||||
@@ -1427,26 +1622,6 @@ app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
|
|||||||
return post ? post : notFound(reply, request);
|
return post ? post : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/life-posts/:id/rating', async (request, reply) => {
|
|
||||||
const user = await requirePermissionWithRateLimits(request, reply, 'life.ratings.set', 'communityReaction');
|
|
||||||
if (!user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const post = await setLifePostRating(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
|
||||||
return post ? post : notFound(reply, request);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete('/api/life-posts/:id/rating', async (request, reply) => {
|
|
||||||
const user = await requirePermissionWithRateLimits(request, reply, 'life.ratings.set', 'communityReaction');
|
|
||||||
if (!user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { id } = request.params as { id: string };
|
|
||||||
const post = await deleteLifePostRating(Number(id), user.id, requestLocale(request));
|
|
||||||
return post ? post : notFound(reply, request);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete('/api/life-posts/:id', async (request, reply) => {
|
app.delete('/api/life-posts/:id', async (request, reply) => {
|
||||||
const user = await requireAnyPermissionWithRateLimits(
|
const user = await requireAnyPermissionWithRateLimits(
|
||||||
request,
|
request,
|
||||||
@@ -1639,6 +1814,172 @@ app.delete('/api/discussions/comments/:id/like', async (request, reply) => {
|
|||||||
return comment ? comment : notFound(reply, request);
|
return comment ? comment : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/thread-channels', async (request) => {
|
||||||
|
const user = await optionalUser(request);
|
||||||
|
return listThreadChannels(user?.id ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/threads', async (request) => {
|
||||||
|
const user = await optionalUser(request);
|
||||||
|
return listThreads(request.query as Record<string, string | string[] | undefined>, user?.id ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/threads/ws-ticket', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return createThreadsWsTicketForUser(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/threads', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.create', 'communityWrite');
|
||||||
|
return user ? reply.code(201).send(await createThread(request.body as Record<string, unknown>, user.id)) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/threads/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user || !(await enforceUserRateLimits(request, reply, user, 'communityWrite'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const canUpdateAny = userHasPermission(user, 'admin.threads.threads.lock') || userHasPermission(user, 'admin.threads.threads.delete');
|
||||||
|
const thread = await updateThread(Number(id), request.body as Record<string, unknown>, user.id, canUpdateAny);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/threads/:id', async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const user = await optionalUser(request);
|
||||||
|
const thread = await getThread(Number(id), user?.id ?? null);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/threads/:id/messages', async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const user = await optionalUser(request);
|
||||||
|
const canViewAll = user ? userHasPermission(user, 'admin.threads.messages.delete') : false;
|
||||||
|
const messages = await listThreadMessages(
|
||||||
|
Number(id),
|
||||||
|
request.query as Record<string, string | string[] | undefined>,
|
||||||
|
user?.id ?? null,
|
||||||
|
canViewAll
|
||||||
|
);
|
||||||
|
return messages ? messages : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/threads/:id/messages', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.messages.create', 'communityWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const message = await createThreadMessage(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
return message ? reply.code(201).send(message) : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/thread-messages/:id', async (request, reply) => {
|
||||||
|
const user = await requireAnyPermissionWithRateLimits(
|
||||||
|
request,
|
||||||
|
reply,
|
||||||
|
['threads.messages.create', 'admin.threads.messages.delete'],
|
||||||
|
'communityWrite'
|
||||||
|
);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const canUpdateAny = userHasPermission(user, 'admin.threads.messages.delete');
|
||||||
|
const message = await updateThreadMessage(Number(id), request.body as Record<string, unknown>, user.id, canUpdateAny);
|
||||||
|
return message ? message : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/thread-messages/:id/moderation/retry', async (request, reply) => {
|
||||||
|
const user = await requireAnyPermissionWithRateLimits(
|
||||||
|
request,
|
||||||
|
reply,
|
||||||
|
['threads.messages.create', 'admin.threads.messages.delete'],
|
||||||
|
'communityWrite'
|
||||||
|
);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const canRetryAny = userHasPermission(user, 'admin.threads.messages.delete');
|
||||||
|
const message = await retryThreadMessageModeration(Number(id), user.id, canRetryAny);
|
||||||
|
return message ? message : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/threads/:id/follow', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const thread = await followThread(Number(id), user.id);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/threads/:id/follow', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const thread = await unfollowThread(Number(id), user.id);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/threads/:id/read', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.follow', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const thread = await markThreadRead(Number(id), user.id);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/threads/:id/reaction', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const thread = await setThreadReaction(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/threads/:id/reaction', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const thread = await deleteThreadReaction(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/thread-messages/:id/reaction', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const message = await setThreadMessageReaction(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
return message ? message : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/thread-messages/:id/reaction', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'threads.reactions.set', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const message = await deleteThreadMessageReaction(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
return message ? message : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/pokemon', async (request) =>
|
app.get('/api/pokemon', async (request) =>
|
||||||
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||||
);
|
);
|
||||||
@@ -2041,11 +2382,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;
|
||||||
@@ -2127,6 +2463,19 @@ app.put('/api/admin/ai-moderation', async (request, reply) => {
|
|||||||
return updateAiModerationSettings(request.body as Record<string, unknown>, user.id);
|
return updateAiModerationSettings(request.body as Record<string, unknown>, user.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/module-settings', async (request, reply) => {
|
||||||
|
const user = await requirePermission(request, reply, 'admin.config.read');
|
||||||
|
return user ? getModuleSettings() : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/module-settings', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.update', 'adminWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return updateModuleSettings(request.body as Record<string, unknown>, user.id);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/admin/rate-limits', async (request, reply) => {
|
app.get('/api/admin/rate-limits', async (request, reply) => {
|
||||||
const user = await requirePermission(request, reply, 'admin.rate-limits.read');
|
const user = await requirePermission(request, reply, 'admin.rate-limits.read');
|
||||||
return user ? getRateLimitSettings() : undefined;
|
return user ? getRateLimitSettings() : undefined;
|
||||||
@@ -2170,6 +2519,69 @@ app.post('/api/admin/data-tools/wipe', async (request, reply) => {
|
|||||||
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
|
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/thread-channels', async (request, reply) => {
|
||||||
|
const user = await requirePermission(request, reply, 'admin.threads.channels.read');
|
||||||
|
return user ? listAdminThreadChannels() : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/admin/thread-channels', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.channels.create', 'adminWrite');
|
||||||
|
return user
|
||||||
|
? reply.code(201).send(await createAdminThreadChannel(request.body as Record<string, unknown>, user.id))
|
||||||
|
: undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/thread-channels/:id', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.channels.update', 'adminWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const channels = await updateAdminThreadChannel(Number(id), request.body as Record<string, unknown>, user.id);
|
||||||
|
return channels ? channels : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/thread-channels/:id', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.channels.delete', 'adminWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = await deleteAdminThreadChannel(Number(id));
|
||||||
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/threads/:id/lock', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.threads.lock', 'adminWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const payload = request.body as Record<string, unknown>;
|
||||||
|
const thread = await updateThreadLock(Number(id), payload.locked === true, user.id);
|
||||||
|
return thread ? thread : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/threads/:id', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.threads.delete', 'adminWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = await deleteThread(Number(id), user.id);
|
||||||
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/admin/thread-messages/:id', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'admin.threads.messages.delete', 'adminWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = await deleteThreadMessage(Number(id), user.id);
|
||||||
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/admin/config/:type', async (request, reply) => {
|
app.get('/api/admin/config/:type', async (request, reply) => {
|
||||||
const user = await requirePermission(request, reply, 'admin.config.read');
|
const user = await requirePermission(request, reply, 'admin.config.read');
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -2241,6 +2653,7 @@ try {
|
|||||||
await syncSystemWordingCatalog();
|
await syncSystemWordingCatalog();
|
||||||
await startAiModerationWorker(app.log);
|
await startAiModerationWorker(app.log);
|
||||||
setupNotificationWebSocketServer(app.server, app.log);
|
setupNotificationWebSocketServer(app.server, app.log);
|
||||||
|
setupThreadWebSocketServer(app.server, app.log);
|
||||||
await app.listen({ host: '0.0.0.0', port });
|
await app.listen({ host: '0.0.0.0', port });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.log.error(error);
|
app.log.error(error);
|
||||||
|
|||||||
442
backend/src/threadsRealtime.ts
Normal file
442
backend/src/threadsRealtime.ts
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
import type { Server } from 'node:http';
|
||||||
|
import type { Duplex } from 'node:stream';
|
||||||
|
import { pool, query, queryOne } from './db.ts';
|
||||||
|
import type { ThreadMessage, ThreadReactionCounts, ThreadReactionType, ThreadSummary } from './queries.ts';
|
||||||
|
|
||||||
|
export type ThreadWsMessage =
|
||||||
|
| { type: 'threads.connected'; followedUnreadCount: number }
|
||||||
|
| { type: 'thread.message.created'; threadId: number; message: ThreadMessage; thread: ThreadSummary }
|
||||||
|
| { type: 'thread.message.moderation'; threadId: number; messageId: number; message: ThreadMessage | null }
|
||||||
|
| {
|
||||||
|
type: 'thread.reactions.updated';
|
||||||
|
target: 'thread' | 'message';
|
||||||
|
threadId: number;
|
||||||
|
messageId: number | null;
|
||||||
|
reactionCounts: ThreadReactionCounts;
|
||||||
|
myReactions: ThreadReactionType[];
|
||||||
|
}
|
||||||
|
| { type: 'thread.read.updated'; threadId: number; unread: boolean; unreadCount: number };
|
||||||
|
|
||||||
|
const websocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||||
|
const websocketTicketMinutes = 2;
|
||||||
|
const threadClients = new Map<number, Set<Duplex>>();
|
||||||
|
const clientUsers = new WeakMap<Duplex, number>();
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createThreadWebSocketTicket(userId: number): Promise<{ ticket: string; expiresAt: Date }> {
|
||||||
|
const ticket = randomBytes(32).toString('base64url');
|
||||||
|
const expiresAt = new Date(Date.now() + websocketTicketMinutes * 60_000);
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO thread_ws_tickets (ticket_hash, user_id, expires_at)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`,
|
||||||
|
[hashToken(ticket), userId, expiresAt]
|
||||||
|
);
|
||||||
|
await pool.query('DELETE FROM thread_ws_tickets WHERE expires_at < now()');
|
||||||
|
return { ticket, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumeThreadWebSocketTicket(ticket: string): Promise<number | null> {
|
||||||
|
if (!ticket) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await queryOne<{ userId: number }>(
|
||||||
|
`
|
||||||
|
DELETE FROM thread_ws_tickets
|
||||||
|
WHERE ticket_hash = $1
|
||||||
|
AND expires_at > now()
|
||||||
|
RETURNING user_id AS "userId"
|
||||||
|
`,
|
||||||
|
[hashToken(ticket)]
|
||||||
|
);
|
||||||
|
return row?.userId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function followedUnreadCount(userId: number): Promise<number> {
|
||||||
|
const row = await queryOne<{ count: number }>(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*)::integer AS count
|
||||||
|
FROM thread_follows tf
|
||||||
|
JOIN threads t ON t.id = tf.thread_id
|
||||||
|
LEFT JOIN thread_reads tr ON tr.thread_id = t.id AND tr.user_id = tf.user_id
|
||||||
|
WHERE tf.user_id = $1
|
||||||
|
AND t.deleted_at IS NULL
|
||||||
|
AND t.last_message_id IS NOT NULL
|
||||||
|
AND (
|
||||||
|
tr.last_read_message_id IS NULL
|
||||||
|
OR t.last_message_id > tr.last_read_message_id
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return row?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wsFrame(data: Buffer, opcode = 0x1): Buffer {
|
||||||
|
const length = data.byteLength;
|
||||||
|
if (length < 126) {
|
||||||
|
return Buffer.concat([Buffer.from([0x80 | opcode, length]), data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (length < 65536) {
|
||||||
|
const header = Buffer.alloc(4);
|
||||||
|
header[0] = 0x80 | opcode;
|
||||||
|
header[1] = 126;
|
||||||
|
header.writeUInt16BE(length, 2);
|
||||||
|
return Buffer.concat([header, data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = Buffer.alloc(10);
|
||||||
|
header[0] = 0x80 | opcode;
|
||||||
|
header[1] = 127;
|
||||||
|
header.writeBigUInt64BE(BigInt(length), 2);
|
||||||
|
return Buffer.concat([header, data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendWsJson(socket: Duplex, message: ThreadWsMessage): void {
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
socket.write(wsFrame(Buffer.from(JSON.stringify(message), 'utf8')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function websocketPayload(buffer: Buffer): { opcode: number; payload: Buffer } | null {
|
||||||
|
if (buffer.byteLength < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opcode = buffer[0] & 0x0f;
|
||||||
|
const masked = (buffer[1] & 0x80) !== 0;
|
||||||
|
let length = buffer[1] & 0x7f;
|
||||||
|
let offset = 2;
|
||||||
|
|
||||||
|
if (length === 126) {
|
||||||
|
if (buffer.byteLength < offset + 2) return null;
|
||||||
|
length = buffer.readUInt16BE(offset);
|
||||||
|
offset += 2;
|
||||||
|
} else if (length === 127) {
|
||||||
|
if (buffer.byteLength < offset + 8) return null;
|
||||||
|
const longLength = buffer.readBigUInt64BE(offset);
|
||||||
|
if (longLength > BigInt(Number.MAX_SAFE_INTEGER)) return null;
|
||||||
|
length = Number(longLength);
|
||||||
|
offset += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mask: Buffer | null = null;
|
||||||
|
if (masked) {
|
||||||
|
if (buffer.byteLength < offset + 4) return null;
|
||||||
|
mask = buffer.subarray(offset, offset + 4);
|
||||||
|
offset += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.byteLength < offset + length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = Buffer.from(buffer.subarray(offset, offset + length));
|
||||||
|
if (mask) {
|
||||||
|
for (let index = 0; index < payload.byteLength; index += 1) {
|
||||||
|
payload[index] ^= mask[index % 4];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { opcode, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSocket(socket: Duplex, statusCode = 1000): void {
|
||||||
|
if (socket.destroyed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = Buffer.alloc(2);
|
||||||
|
payload.writeUInt16BE(statusCode, 0);
|
||||||
|
socket.end(wsFrame(payload, 0x8));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectUpgrade(socket: Duplex, statusCode: number, statusText: string): void {
|
||||||
|
socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\n\r\n`);
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addThreadClient(userId: number, socket: Duplex): void {
|
||||||
|
clientUsers.set(socket, userId);
|
||||||
|
let clients = threadClients.get(userId);
|
||||||
|
if (!clients) {
|
||||||
|
clients = new Set();
|
||||||
|
threadClients.set(userId, clients);
|
||||||
|
}
|
||||||
|
clients.add(socket);
|
||||||
|
socket.on('close', () => {
|
||||||
|
clients?.delete(socket);
|
||||||
|
if (clients?.size === 0) {
|
||||||
|
threadClients.delete(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recipientUserIds(threadId: number): Promise<number[]> {
|
||||||
|
const rows = await query<{ userId: number }>(
|
||||||
|
`
|
||||||
|
SELECT DISTINCT user_id AS "userId"
|
||||||
|
FROM thread_follows
|
||||||
|
WHERE thread_id = $1
|
||||||
|
`,
|
||||||
|
[threadId]
|
||||||
|
);
|
||||||
|
return rows.map((row) => row.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectedUserIds(): number[] {
|
||||||
|
return [...threadClients.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishToUsers(userIds: number[], message: ThreadWsMessage): Promise<void> {
|
||||||
|
for (const userId of userIds) {
|
||||||
|
const clients = threadClients.get(userId);
|
||||||
|
if (!clients) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const socket of clients) {
|
||||||
|
sendWsJson(socket, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishThreadMessageCreated(thread: ThreadSummary, message: ThreadMessage): Promise<void> {
|
||||||
|
const users = [...new Set([...(await recipientUserIds(thread.id)), ...connectedUserIds()])];
|
||||||
|
if (message.author?.id && !users.includes(message.author.id)) {
|
||||||
|
users.push(message.author.id);
|
||||||
|
}
|
||||||
|
await publishToUsers(users, {
|
||||||
|
type: 'thread.message.created',
|
||||||
|
threadId: thread.id,
|
||||||
|
message,
|
||||||
|
thread
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyApprovedThreadMessage(messageId: number): Promise<void> {
|
||||||
|
const row = await queryOne<{
|
||||||
|
threadId: number;
|
||||||
|
channelId: number;
|
||||||
|
title: string;
|
||||||
|
languageCode: string;
|
||||||
|
locked: boolean;
|
||||||
|
messageCount: number;
|
||||||
|
lastActiveAt: Date;
|
||||||
|
threadCreatedAt: Date;
|
||||||
|
threadAuthor: { id: number; displayName: string } | null;
|
||||||
|
messageBody: string;
|
||||||
|
moderationStatus: ThreadMessage['moderationStatus'];
|
||||||
|
moderationLanguageCode: string | null;
|
||||||
|
moderationReason: string | null;
|
||||||
|
messageCreatedAt: Date;
|
||||||
|
messageUpdatedAt: Date;
|
||||||
|
messageAuthor: { id: number; displayName: string } | null;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
WITH updated_thread AS (
|
||||||
|
UPDATE threads t
|
||||||
|
SET last_message_id = tm.id,
|
||||||
|
message_count = (
|
||||||
|
SELECT COUNT(*)::integer
|
||||||
|
FROM thread_messages visible_message
|
||||||
|
WHERE visible_message.thread_id = t.id
|
||||||
|
AND visible_message.deleted_at IS NULL
|
||||||
|
AND visible_message.ai_moderation_status = 'approved'
|
||||||
|
),
|
||||||
|
last_active_at = GREATEST(t.last_active_at, tm.created_at),
|
||||||
|
updated_at = now()
|
||||||
|
FROM thread_messages tm
|
||||||
|
WHERE tm.id = $1
|
||||||
|
AND tm.thread_id = t.id
|
||||||
|
AND tm.deleted_at IS NULL
|
||||||
|
AND tm.ai_moderation_status = 'approved'
|
||||||
|
RETURNING
|
||||||
|
t.id,
|
||||||
|
t.channel_id,
|
||||||
|
t.title,
|
||||||
|
t.language_code,
|
||||||
|
t.locked,
|
||||||
|
t.message_count,
|
||||||
|
t.last_active_at,
|
||||||
|
t.created_at,
|
||||||
|
t.created_by_user_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ut.id AS "threadId",
|
||||||
|
ut.channel_id AS "channelId",
|
||||||
|
ut.title,
|
||||||
|
ut.language_code AS "languageCode",
|
||||||
|
ut.locked,
|
||||||
|
ut.message_count AS "messageCount",
|
||||||
|
ut.last_active_at AS "lastActiveAt",
|
||||||
|
ut.created_at AS "threadCreatedAt",
|
||||||
|
CASE WHEN thread_user.id IS NULL THEN NULL ELSE json_build_object('id', thread_user.id, 'displayName', thread_user.display_name) END AS "threadAuthor",
|
||||||
|
tm.body AS "messageBody",
|
||||||
|
tm.ai_moderation_status AS "moderationStatus",
|
||||||
|
tm.ai_moderation_language_code AS "moderationLanguageCode",
|
||||||
|
tm.ai_moderation_reason AS "moderationReason",
|
||||||
|
tm.created_at AS "messageCreatedAt",
|
||||||
|
tm.updated_at AS "messageUpdatedAt",
|
||||||
|
CASE WHEN message_user.id IS NULL THEN NULL ELSE json_build_object('id', message_user.id, 'displayName', message_user.display_name) END AS "messageAuthor"
|
||||||
|
FROM updated_thread ut
|
||||||
|
JOIN thread_messages tm ON tm.id = $1
|
||||||
|
LEFT JOIN users thread_user ON thread_user.id = ut.created_by_user_id
|
||||||
|
LEFT JOIN users message_user ON message_user.id = tm.created_by_user_id
|
||||||
|
`,
|
||||||
|
[messageId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishThreadMessageCreated(
|
||||||
|
{
|
||||||
|
id: row.threadId,
|
||||||
|
channelId: row.channelId,
|
||||||
|
title: row.title,
|
||||||
|
languageCode: row.languageCode,
|
||||||
|
tags: [],
|
||||||
|
locked: row.locked,
|
||||||
|
messageCount: row.messageCount,
|
||||||
|
lastActiveAt: row.lastActiveAt,
|
||||||
|
createdAt: row.threadCreatedAt,
|
||||||
|
author: row.threadAuthor,
|
||||||
|
reactionCounts: {},
|
||||||
|
myReactions: [],
|
||||||
|
followed: true,
|
||||||
|
unread: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: messageId,
|
||||||
|
threadId: row.threadId,
|
||||||
|
body: row.messageBody,
|
||||||
|
moderationStatus: row.moderationStatus,
|
||||||
|
moderationLanguageCode: row.moderationLanguageCode,
|
||||||
|
moderationReason: row.moderationReason,
|
||||||
|
createdAt: row.messageCreatedAt,
|
||||||
|
updatedAt: row.messageUpdatedAt,
|
||||||
|
author: row.messageAuthor,
|
||||||
|
reactionCounts: {},
|
||||||
|
myReactions: []
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishThreadMessageModeration(
|
||||||
|
threadId: number,
|
||||||
|
messageId: number,
|
||||||
|
message: ThreadMessage | null
|
||||||
|
): Promise<void> {
|
||||||
|
const publicUsers = new Set([...(await recipientUserIds(threadId)), ...connectedUserIds()]);
|
||||||
|
if (message?.author?.id) {
|
||||||
|
publicUsers.delete(message.author.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishToUsers([...publicUsers], {
|
||||||
|
type: 'thread.message.moderation',
|
||||||
|
threadId,
|
||||||
|
messageId,
|
||||||
|
message: null
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!message?.author?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishToUsers([message.author.id], {
|
||||||
|
type: 'thread.message.moderation',
|
||||||
|
threadId,
|
||||||
|
messageId,
|
||||||
|
message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishThreadReactionUpdated(
|
||||||
|
userId: number,
|
||||||
|
message: Extract<ThreadWsMessage, { type: 'thread.reactions.updated' }>
|
||||||
|
): Promise<void> {
|
||||||
|
const users = await recipientUserIds(message.threadId);
|
||||||
|
for (const connectedUserId of connectedUserIds()) {
|
||||||
|
if (!users.includes(connectedUserId)) {
|
||||||
|
users.push(connectedUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!users.includes(userId)) {
|
||||||
|
users.push(userId);
|
||||||
|
}
|
||||||
|
await publishToUsers(users, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishThreadReadUpdated(userId: number, threadId: number, unread: boolean, unreadCount: number): Promise<void> {
|
||||||
|
await publishToUsers([userId], { type: 'thread.read.updated', threadId, unread, unreadCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupThreadWebSocketServer(server: Server, logger: FastifyBaseLogger): void {
|
||||||
|
server.on('upgrade', async (request, socket) => {
|
||||||
|
const url = new URL(request.url ?? '/', 'http://localhost');
|
||||||
|
if (url.pathname !== '/api/threads/ws') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = request.headers['sec-websocket-key'];
|
||||||
|
if (request.method !== 'GET' || typeof key !== 'string' || key.trim() === '') {
|
||||||
|
rejectUpgrade(socket, 400, 'Bad Request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ticket = url.searchParams.get('ticket') ?? '';
|
||||||
|
const userId = await consumeThreadWebSocketTicket(ticket);
|
||||||
|
if (!userId) {
|
||||||
|
rejectUpgrade(socket, 401, 'Unauthorized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accept = createHash('sha1').update(`${key}${websocketGuid}`).digest('base64');
|
||||||
|
socket.write(
|
||||||
|
[
|
||||||
|
'HTTP/1.1 101 Switching Protocols',
|
||||||
|
'Upgrade: websocket',
|
||||||
|
'Connection: Upgrade',
|
||||||
|
`Sec-WebSocket-Accept: ${accept}`,
|
||||||
|
'\r\n'
|
||||||
|
].join('\r\n')
|
||||||
|
);
|
||||||
|
|
||||||
|
addThreadClient(userId, socket);
|
||||||
|
sendWsJson(socket, {
|
||||||
|
type: 'threads.connected',
|
||||||
|
followedUnreadCount: await followedUnreadCount(userId)
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('data', (buffer: Buffer) => {
|
||||||
|
const frame = websocketPayload(buffer);
|
||||||
|
if (!frame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.opcode === 0x8) {
|
||||||
|
closeSocket(socket);
|
||||||
|
} else if (frame.opcode === 0x9) {
|
||||||
|
socket.write(wsFrame(frame.payload, 0x0a));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('error', () => {
|
||||||
|
socket.destroy();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error }, 'Thread WebSocket upgrade failed');
|
||||||
|
rejectUpgrade(socket, 500, 'Internal Server Error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -53,6 +53,20 @@ export function isUploadImagePath(value: string | null | undefined): boolean {
|
|||||||
return isUploadEntityType(entityType);
|
return isUploadEntityType(entityType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isExternalImageUrl(value: string | null | undefined): boolean {
|
||||||
|
const cleanUrl = value?.trim() ?? '';
|
||||||
|
if (cleanUrl === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(cleanUrl);
|
||||||
|
return url.protocol === 'https:' && url.username === '' && url.password === '';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function uploadImageUrl(relativePath: string): string {
|
export function uploadImageUrl(relativePath: string): string {
|
||||||
return `${uploadPublicBaseUrl}${relativePath.split('/').map(encodeURIComponent).join('/')}`;
|
return `${uploadPublicBaseUrl}${relativePath.split('/').map(encodeURIComponent).join('/')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
110
docker-compose.debug.yml
Normal file
110
docker-compose.debug.yml
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: pokopia
|
||||||
|
POSTGRES_USER: pokopia
|
||||||
|
POSTGRES_PASSWORD: pokopia
|
||||||
|
volumes:
|
||||||
|
- postgres18_data:/var/lib/postgresql
|
||||||
|
ports:
|
||||||
|
- "50001:5432"
|
||||||
|
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:
|
||||||
@@ -7,6 +7,8 @@ services:
|
|||||||
POSTGRES_PASSWORD: pokopia
|
POSTGRES_PASSWORD: pokopia
|
||||||
volumes:
|
volumes:
|
||||||
- postgres18_data:/var/lib/postgresql
|
- postgres18_data:/var/lib/postgresql
|
||||||
|
ports:
|
||||||
|
- "50001:5432" # 添加这一行:宿主机 50001 → 容器 5432
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U pokopia -d pokopia"]
|
test: ["CMD-SHELL", "pg_isready -U pokopia -d pokopia"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -40,10 +42,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,
|
||||||
@@ -19,15 +18,16 @@ import {
|
|||||||
iconLife,
|
iconLife,
|
||||||
iconPokemon,
|
iconPokemon,
|
||||||
iconRecipe,
|
iconRecipe,
|
||||||
|
iconThreads,
|
||||||
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 viewAsBusy = ref(false);
|
||||||
const languages = ref<Language[]>([
|
const languages = ref<Language[]>([
|
||||||
{ code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 10 },
|
{ code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 10 },
|
||||||
{ code: 'zh-CN', name: '简体中文', enabled: true, isDefault: false, sortOrder: 20 }
|
{ code: 'zh-CN', name: '简体中文', enabled: true, isDefault: false, sortOrder: 20 }
|
||||||
@@ -103,7 +103,8 @@ const navItems = computed<NavItem[]>(() => {
|
|||||||
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
|
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
|
||||||
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
|
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
|
||||||
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
|
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
|
||||||
{ label: t('nav.life'), to: '/life', icon: iconLife }
|
{ label: t('nav.life'), to: '/life', icon: iconLife },
|
||||||
|
{ label: t('nav.threads'), to: '/threads', icon: iconThreads }
|
||||||
];
|
];
|
||||||
|
|
||||||
if (can('admin.access')) {
|
if (can('admin.access')) {
|
||||||
@@ -114,17 +115,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,10 +131,25 @@ async function logout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
setAuthToken(null);
|
notifyAuthChange();
|
||||||
await router.push('/');
|
await router.push('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function stopViewAs() {
|
||||||
|
if (viewAsBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewAsBusy.value = true;
|
||||||
|
try {
|
||||||
|
const response = await api.stopViewAs();
|
||||||
|
currentUser.value = response.user;
|
||||||
|
notifyAuthChange();
|
||||||
|
} finally {
|
||||||
|
viewAsBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadLanguages() {
|
async function loadLanguages() {
|
||||||
try {
|
try {
|
||||||
const loadedLanguages = await api.languages();
|
const loadedLanguages = await api.languages();
|
||||||
@@ -165,7 +175,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(() => {
|
||||||
@@ -185,9 +195,11 @@ onUnmounted(() => {
|
|||||||
:languages="languages"
|
:languages="languages"
|
||||||
:locale="locale"
|
:locale="locale"
|
||||||
:nav-items="navItems"
|
:nav-items="navItems"
|
||||||
|
:view-as-busy="viewAsBusy"
|
||||||
@logout="logout"
|
@logout="logout"
|
||||||
|
@stop-view-as="stopViewAs"
|
||||||
@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>
|
||||||
17
frontend/pages/threads/[id].vue
Normal file
17
frontend/pages/threads/[id].vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
|
import ThreadsView from '../../src/views/ThreadsView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'thread-detail',
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.threads.title',
|
||||||
|
descriptionKey: 'seo.threadsDescription',
|
||||||
|
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/threads/${String(route.params.id)}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ThreadsView />
|
||||||
|
</template>
|
||||||
12
frontend/pages/threads/index.vue
Normal file
12
frontend/pages/threads/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ThreadsView from '../../src/views/ThreadsView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'threads',
|
||||||
|
seo: { titleKey: 'pages.threads.title', descriptionKey: 'seo.threadsDescription', canonicalPath: '/threads' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ThreadsView />
|
||||||
|
</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);
|
||||||
|
});
|
||||||
81
frontend/plugins/03-detail-seo.server.ts
Normal file
81
frontend/plugins/03-detail-seo.server.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { resolvedSeoHead, resolveSeo, threadSeoConfig, 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeName === 'thread-detail') {
|
||||||
|
const thread = await api.thread(routeId);
|
||||||
|
return threadSeoConfig(thread, t);
|
||||||
|
}
|
||||||
|
} 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-collections.xml.ts
Normal file
7
frontend/server/routes/sitemap-collections.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { collectionsSitemapXml, normalizeApiBaseUrl, normalizeSiteUrl } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return collectionsSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-habitats.xml.ts
Normal file
7
frontend/server/routes/sitemap-habitats.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { habitatsSitemapXml, normalizeApiBaseUrl, normalizeSiteUrl } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return habitatsSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-life.xml.ts
Normal file
7
frontend/server/routes/sitemap-life.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { lifeSitemapXml, normalizeApiBaseUrl, normalizeSiteUrl } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return lifeSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-pokedex.xml.ts
Normal file
7
frontend/server/routes/sitemap-pokedex.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { normalizeApiBaseUrl, normalizeSiteUrl, pokedexSitemapXml } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return pokedexSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-static.xml.ts
Normal file
7
frontend/server/routes/sitemap-static.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { normalizeSiteUrl, staticSitemapXml } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return staticSitemapXml(normalizeSiteUrl(config.public.siteUrl));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-threads.xml.ts
Normal file
7
frontend/server/routes/sitemap-threads.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { normalizeApiBaseUrl, normalizeSiteUrl, threadsSitemapXml } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return threadsSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap.xml.ts
Normal file
7
frontend/server/routes/sitemap.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { normalizeSiteUrl, sitemapIndexXml } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return sitemapIndexXml(normalizeSiteUrl(config.public.siteUrl));
|
||||||
|
});
|
||||||
273
frontend/server/utils/seo-files.ts
Normal file
273
frontend/server/utils/seo-files.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||||
|
const fallbackApiBaseUrl = 'http://localhost:3001';
|
||||||
|
const staticLastmod = new Date().toISOString();
|
||||||
|
const sitemapPageSize = 72;
|
||||||
|
|
||||||
|
type ChangeFrequency = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
||||||
|
|
||||||
|
export type SitemapUrl = {
|
||||||
|
path: string;
|
||||||
|
lastmod?: string | null;
|
||||||
|
changefreq?: ChangeFrequency;
|
||||||
|
priority?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SitemapEntity = {
|
||||||
|
id: number;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
lastActiveAt?: string | null;
|
||||||
|
ancientArtifactCategory?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ListPage<T> = {
|
||||||
|
items: T[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sitemapFiles = [
|
||||||
|
'/sitemap-static.xml',
|
||||||
|
'/sitemap-pokedex.xml',
|
||||||
|
'/sitemap-habitats.xml',
|
||||||
|
'/sitemap-collections.xml',
|
||||||
|
'/sitemap-life.xml',
|
||||||
|
'/sitemap-threads.xml'
|
||||||
|
];
|
||||||
|
|
||||||
|
const staticSitemapUrls: SitemapUrl[] = [
|
||||||
|
{ path: '/', changefreq: 'weekly', priority: 1 },
|
||||||
|
{ path: '/pokemon', changefreq: 'weekly', priority: 0.95 },
|
||||||
|
{ path: '/event-pokemon', changefreq: 'weekly', priority: 0.85 },
|
||||||
|
{ path: '/habitats', changefreq: 'weekly', priority: 0.9 },
|
||||||
|
{ path: '/event-habitats', changefreq: 'weekly', priority: 0.8 },
|
||||||
|
{ path: '/items', changefreq: 'weekly', priority: 0.9 },
|
||||||
|
{ path: '/event-items', changefreq: 'weekly', priority: 0.8 },
|
||||||
|
{ path: '/ancient-artifacts', changefreq: 'weekly', priority: 0.85 },
|
||||||
|
{ path: '/recipes', changefreq: 'weekly', priority: 0.85 },
|
||||||
|
{ path: '/dish', changefreq: 'weekly', priority: 0.8 },
|
||||||
|
{ path: '/checklist', changefreq: 'weekly', priority: 0.8 },
|
||||||
|
{ path: '/life', changefreq: 'daily', priority: 0.75 },
|
||||||
|
{ path: '/threads', changefreq: 'daily', priority: 0.75 },
|
||||||
|
{ path: '/project-updates', changefreq: 'weekly', priority: 0.6 },
|
||||||
|
{ path: '/privacy-policy', changefreq: 'yearly', priority: 0.3 },
|
||||||
|
{ path: '/terms-of-service', changefreq: 'yearly', priority: 0.3 },
|
||||||
|
{ path: '/disclaimers', changefreq: 'yearly', priority: 0.3 }
|
||||||
|
];
|
||||||
|
|
||||||
|
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 normalizeApiBaseUrl(value: unknown): string {
|
||||||
|
return (typeof value === 'string' && value.trim() ? value.trim() : fallbackApiBaseUrl).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 sitemapIndexXml(siteUrl: string): string {
|
||||||
|
const sitemaps = sitemapFiles
|
||||||
|
.map(
|
||||||
|
(path) => ` <sitemap>
|
||||||
|
<loc>${xmlEscape(siteUrl + path)}</loc>
|
||||||
|
<lastmod>${formatLastmod(staticLastmod)}</lastmod>
|
||||||
|
</sitemap>`
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${sitemaps}
|
||||||
|
</sitemapindex>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function staticSitemapXml(siteUrl: string): string {
|
||||||
|
return sitemapXml(
|
||||||
|
siteUrl,
|
||||||
|
staticSitemapUrls.map((url) => ({ ...url, lastmod: staticLastmod }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pokedexSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
|
||||||
|
const pokemon = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/pokemon');
|
||||||
|
return sitemapXml(
|
||||||
|
siteUrl,
|
||||||
|
pokemon.map((item) => ({
|
||||||
|
path: `/pokemon/${item.id}`,
|
||||||
|
lastmod: entityLastmod(item),
|
||||||
|
changefreq: 'weekly',
|
||||||
|
priority: 0.8
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function habitatsSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
|
||||||
|
const habitats = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/habitats');
|
||||||
|
return sitemapXml(
|
||||||
|
siteUrl,
|
||||||
|
habitats.map((item) => ({
|
||||||
|
path: `/habitats/${item.id}`,
|
||||||
|
lastmod: entityLastmod(item),
|
||||||
|
changefreq: 'weekly',
|
||||||
|
priority: 0.75
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectionsSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
|
||||||
|
const [items, artifacts, recipes] = await Promise.all([
|
||||||
|
fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/items'),
|
||||||
|
fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/ancient-artifacts'),
|
||||||
|
fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/recipes')
|
||||||
|
]);
|
||||||
|
return sitemapXml(siteUrl, [
|
||||||
|
...items
|
||||||
|
.filter((item) => !item.ancientArtifactCategory)
|
||||||
|
.map((item) => ({
|
||||||
|
path: `/items/${item.id}`,
|
||||||
|
lastmod: entityLastmod(item),
|
||||||
|
changefreq: 'weekly' as const,
|
||||||
|
priority: 0.75
|
||||||
|
})),
|
||||||
|
...artifacts.map((item) => ({
|
||||||
|
path: `/ancient-artifacts/${item.id}`,
|
||||||
|
lastmod: entityLastmod(item),
|
||||||
|
changefreq: 'weekly' as const,
|
||||||
|
priority: 0.75
|
||||||
|
})),
|
||||||
|
...recipes.map((item) => ({
|
||||||
|
path: `/recipes/${item.id}`,
|
||||||
|
lastmod: entityLastmod(item),
|
||||||
|
changefreq: 'weekly' as const,
|
||||||
|
priority: 0.7
|
||||||
|
}))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lifeSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
|
||||||
|
const posts = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/life-posts');
|
||||||
|
return sitemapXml(
|
||||||
|
siteUrl,
|
||||||
|
posts.map((item) => ({
|
||||||
|
path: `/life/${item.id}`,
|
||||||
|
lastmod: entityLastmod(item),
|
||||||
|
changefreq: 'daily',
|
||||||
|
priority: 0.65
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function threadsSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
|
||||||
|
const threads = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/threads');
|
||||||
|
return sitemapXml(
|
||||||
|
siteUrl,
|
||||||
|
threads.map((item) => ({
|
||||||
|
path: `/threads/${item.id}`,
|
||||||
|
lastmod: entityLastmod(item),
|
||||||
|
changefreq: 'daily',
|
||||||
|
priority: 0.65
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sitemapXml(siteUrl: string, urls: SitemapUrl[]): string {
|
||||||
|
const body = urls.map((url) => sitemapUrlXml(siteUrl, url)).join('\n');
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${body}
|
||||||
|
</urlset>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sitemapUrlXml(siteUrl: string, url: SitemapUrl): string {
|
||||||
|
return [
|
||||||
|
' <url>',
|
||||||
|
` <loc>${xmlEscape(siteUrl + normalizePath(url.path))}</loc>`,
|
||||||
|
...(url.lastmod ? [` <lastmod>${formatLastmod(url.lastmod)}</lastmod>`] : []),
|
||||||
|
...(url.changefreq ? [` <changefreq>${url.changefreq}</changefreq>`] : []),
|
||||||
|
...(url.priority !== undefined ? [` <priority>${formatPriority(url.priority)}</priority>`] : []),
|
||||||
|
' </url>'
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllPages<T extends SitemapEntity>(apiBaseUrl: string, path: string): Promise<T[]> {
|
||||||
|
const items: T[] = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const url = new URL(path, `${apiBaseUrl}/`);
|
||||||
|
url.searchParams.set('limit', String(sitemapPageSize));
|
||||||
|
if (cursor) {
|
||||||
|
url.searchParams.set('cursor', cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Sitemap source request failed: ${path} (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = (await response.json()) as ListPage<T>;
|
||||||
|
items.push(...page.items);
|
||||||
|
cursor = page.hasMore ? page.nextCursor : null;
|
||||||
|
} while (cursor);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityLastmod(entity: SitemapEntity): string | null {
|
||||||
|
return entity.lastActiveAt ?? entity.updatedAt ?? entity.createdAt ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(path: string): string {
|
||||||
|
return path.startsWith('/') ? path : `/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastmod(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? xmlEscape(value) : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPriority(value: number): string {
|
||||||
|
return Math.max(0, Math.min(1, value)).toFixed(2).replace(/0$/, '').replace(/\.0$/, '.0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlEscape(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import GlobalSearch from './GlobalSearch.vue';
|
|||||||
import NotificationBell from './NotificationBell.vue';
|
import NotificationBell from './NotificationBell.vue';
|
||||||
import PokeBallMark from './PokeBallMark.vue';
|
import PokeBallMark from './PokeBallMark.vue';
|
||||||
import StatusBadge from './StatusBadge.vue';
|
import StatusBadge from './StatusBadge.vue';
|
||||||
|
import ViewAsBanner from './ViewAsBanner.vue';
|
||||||
|
|
||||||
type NavBadge = {
|
type NavBadge = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -53,10 +54,12 @@ defineProps<{
|
|||||||
languages: Language[];
|
languages: Language[];
|
||||||
locale: string;
|
locale: string;
|
||||||
navItems: NavItem[];
|
navItems: NavItem[];
|
||||||
|
viewAsBusy?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
logout: [];
|
logout: [];
|
||||||
|
stopViewAs: [];
|
||||||
'update:locale': [value: string];
|
'update:locale': [value: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -147,6 +150,10 @@ function requestLogout() {
|
|||||||
emit('logout');
|
emit('logout');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestStopViewAs() {
|
||||||
|
emit('stopViewAs');
|
||||||
|
}
|
||||||
|
|
||||||
function isDesktopSidebar() {
|
function isDesktopSidebar() {
|
||||||
return typeof window !== 'undefined' && window.matchMedia('(min-width: 901px)').matches;
|
return typeof window !== 'undefined' && window.matchMedia('(min-width: 901px)').matches;
|
||||||
}
|
}
|
||||||
@@ -345,6 +352,13 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<ViewAsBanner
|
||||||
|
v-if="currentUser?.viewAs"
|
||||||
|
:view-as="currentUser.viewAs"
|
||||||
|
:busy="viewAsBusy"
|
||||||
|
@stop="requestStopViewAs"
|
||||||
|
/>
|
||||||
|
|
||||||
<button class="site-sidebar-scrim" type="button" :aria-label="t('nav.closeMenu')" @click="closeSidebar"></button>
|
<button class="site-sidebar-scrim" type="button" :aria-label="t('nav.closeMenu')" @click="closeSidebar"></button>
|
||||||
|
|
||||||
<aside id="app-sidebar" class="site-sidebar" :aria-label="t('nav.main')">
|
<aside id="app-sidebar" class="site-sidebar" :aria-label="t('nav.main')">
|
||||||
|
|||||||
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,5 +1,6 @@
|
|||||||
<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';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -54,10 +55,16 @@ const changeLabelKeys: Record<string, string> = {
|
|||||||
'Base Price': 'pages.items.basePrice',
|
'Base Price': 'pages.items.basePrice',
|
||||||
'Base price': 'pages.items.basePrice',
|
'Base price': 'pages.items.basePrice',
|
||||||
基础价格: 'pages.items.basePrice',
|
基础价格: 'pages.items.basePrice',
|
||||||
|
Dyeability: 'pages.items.dyeability',
|
||||||
|
染色能力: 'pages.items.dyeability',
|
||||||
Dyeable: 'pages.items.dyeable',
|
Dyeable: 'pages.items.dyeable',
|
||||||
可染色: 'pages.items.dyeable',
|
可染色: 'pages.items.dyeable',
|
||||||
'Dual dyeable': 'pages.items.dualDyeable',
|
'Dual dyeable': 'pages.items.dualDyeable',
|
||||||
可双区染色: 'pages.items.dualDyeable',
|
可双区染色: 'pages.items.dualDyeable',
|
||||||
|
'Triple dyeable': 'pages.items.tripleDyeable',
|
||||||
|
可三区染色: 'pages.items.tripleDyeable',
|
||||||
|
'Dye previews': 'pages.items.dyePreviews',
|
||||||
|
染色预览: 'pages.items.dyePreviews',
|
||||||
'Pattern editable': 'pages.items.patternEditable',
|
'Pattern editable': 'pages.items.patternEditable',
|
||||||
可改花纹: 'pages.items.patternEditable',
|
可改花纹: 'pages.items.patternEditable',
|
||||||
'No recipe': 'pages.items.noRecipe',
|
'No recipe': 'pages.items.noRecipe',
|
||||||
@@ -80,10 +87,6 @@ const changeLabelKeys: Record<string, string> = {
|
|||||||
有掉落物: 'pages.admin.hasItemDrop',
|
有掉落物: 'pages.admin.hasItemDrop',
|
||||||
'Has trading': 'pages.admin.hasTrading',
|
'Has trading': 'pages.admin.hasTrading',
|
||||||
'有 Trading': 'pages.admin.hasTrading',
|
'有 Trading': 'pages.admin.hasTrading',
|
||||||
'Default category': 'pages.admin.defaultCategory',
|
|
||||||
默认分类: 'pages.admin.defaultCategory',
|
|
||||||
Rateable: 'pages.admin.rateableCategory',
|
|
||||||
可评分: 'pages.admin.rateableCategory',
|
|
||||||
ChangeLog: 'pages.admin.changeLog'
|
ChangeLog: 'pages.admin.changeLog'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,6 +119,14 @@ function changeValue(value: string): string {
|
|||||||
const values: Record<string, string> = {
|
const values: Record<string, string> = {
|
||||||
None: t('common.none'),
|
None: t('common.none'),
|
||||||
无: t('common.none'),
|
无: t('common.none'),
|
||||||
|
'Not dyeable': t('pages.items.notDyeable'),
|
||||||
|
不可染色: t('pages.items.notDyeable'),
|
||||||
|
Dyeable: t('pages.items.dyeable'),
|
||||||
|
可染色: t('pages.items.dyeable'),
|
||||||
|
'Dual dyeable': t('pages.items.dualDyeable'),
|
||||||
|
可双区染色: t('pages.items.dualDyeable'),
|
||||||
|
'Triple dyeable': t('pages.items.tripleDyeable'),
|
||||||
|
可三区染色: t('pages.items.tripleDyeable'),
|
||||||
Yes: locale.value === 'zh-CN' ? '是' : 'Yes',
|
Yes: locale.value === 'zh-CN' ? '是' : 'Yes',
|
||||||
是: locale.value === 'zh-CN' ? '是' : 'Yes',
|
是: locale.value === 'zh-CN' ? '是' : 'Yes',
|
||||||
No: locale.value === 'zh-CN' ? '否' : 'No',
|
No: locale.value === 'zh-CN' ? '否' : 'No',
|
||||||
@@ -169,11 +180,7 @@ function formatDateTime(value: string): string {
|
|||||||
<div>
|
<div>
|
||||||
<dt>{{ t('history.lastEdited') }}</dt>
|
<dt>{{ t('history.lastEdited') }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<RouterLink v-if="props.entity.updatedBy" class="user-profile-link" :to="`/profile/${props.entity.updatedBy.id}`">
|
<EditMeta :entity="props.entity" :show-label="false" />
|
||||||
{{ props.entity.updatedBy.displayName }}
|
|
||||||
</RouterLink>
|
|
||||||
<strong v-else>{{ displayName(props.entity.updatedBy) }}</strong>
|
|
||||||
<time :datetime="props.entity.updatedAt">{{ formatDateTime(props.entity.updatedAt) }}</time>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
defineProps<{
|
||||||
entity: EditInfo;
|
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,16 +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 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,
|
||||||
@@ -54,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;
|
||||||
@@ -77,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;
|
||||||
}
|
}
|
||||||
@@ -471,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);
|
||||||
|
|
||||||
@@ -515,7 +532,7 @@ onMounted(() => {
|
|||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
void loadLanguages();
|
void loadLanguages();
|
||||||
void loadDiscussion();
|
void loadDiscussion();
|
||||||
removeAuthListener = onAuthTokenChange(() => {
|
removeAuthListener = onAuthChange(() => {
|
||||||
void loadCurrentUser();
|
void loadCurrentUser();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -657,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>
|
||||||
@@ -759,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>
|
||||||
@@ -787,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>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ let openModalCount = 0;
|
|||||||
|
|
||||||
<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(
|
||||||
@@ -29,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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
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 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>
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ export type TagsSelectOption = {
|
|||||||
id: number | string;
|
id: number | string;
|
||||||
name: string;
|
name: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
thumbnailUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type OptionRow = {
|
type OptionRow = {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CandidateRow = { type: 'option'; id: string; value: string; label: string } | { type: 'create'; id: string };
|
type CandidateRow = { type: 'option'; id: string; value: string; label: string } | { type: 'create'; id: string };
|
||||||
@@ -65,7 +67,8 @@ const optionRows = computed(() =>
|
|||||||
props.options.map((option, index) => ({
|
props.options.map((option, index) => ({
|
||||||
value: String(option.id),
|
value: String(option.id),
|
||||||
label: option.label ?? option.name,
|
label: option.label ?? option.name,
|
||||||
id: `${props.id}-option-${index}`
|
id: `${props.id}-option-${index}`,
|
||||||
|
thumbnailUrl: option.thumbnailUrl ?? null
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -79,9 +82,10 @@ const maxReached = computed(() => props.multiple && props.max > 0 && modelValues
|
|||||||
const selectedRows = computed(() =>
|
const selectedRows = computed(() =>
|
||||||
modelValues.value
|
modelValues.value
|
||||||
.map((value) => optionRows.value.find((option) => option.value === value))
|
.map((value) => optionRows.value.find((option) => option.value === value))
|
||||||
.filter((option) => option !== undefined)
|
.filter((option): option is OptionRow => option !== undefined)
|
||||||
);
|
);
|
||||||
const selectedLabel = computed(() => selectedRows.value[0]?.label ?? '');
|
const selectedLabel = computed(() => selectedRows.value[0]?.label ?? '');
|
||||||
|
const selectedThumbnailUrl = computed(() => selectedRows.value[0]?.thumbnailUrl ?? '');
|
||||||
|
|
||||||
const filteredRows = computed(() => {
|
const filteredRows = computed(() => {
|
||||||
const keyword = search.value.trim().toLowerCase();
|
const keyword = search.value.trim().toLowerCase();
|
||||||
@@ -360,6 +364,7 @@ watch(
|
|||||||
<span v-if="selectedRows.length" class="tags-select__selected">
|
<span v-if="selectedRows.length" class="tags-select__selected">
|
||||||
<template v-if="multiple">
|
<template v-if="multiple">
|
||||||
<span v-for="option in selectedRows" :key="option.value" class="tags-select__tag">
|
<span v-for="option in selectedRows" :key="option.value" class="tags-select__tag">
|
||||||
|
<img v-if="option.thumbnailUrl" class="tags-select__thumb tags-select__thumb--tag" :src="option.thumbnailUrl" alt="" loading="lazy" />
|
||||||
<span>{{ option.label }}</span>
|
<span>{{ option.label }}</span>
|
||||||
<span
|
<span
|
||||||
class="tags-select__remove"
|
class="tags-select__remove"
|
||||||
@@ -374,7 +379,10 @@ watch(
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="tags-select__single-value">{{ selectedLabel }}</span>
|
<span v-else class="tags-select__single-value">
|
||||||
|
<img v-if="selectedThumbnailUrl" class="tags-select__thumb" :src="selectedThumbnailUrl" alt="" loading="lazy" />
|
||||||
|
<span>{{ selectedLabel }}</span>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="tags-select__placeholder">{{ placeholderText }}</span>
|
<span v-else class="tags-select__placeholder">{{ placeholderText }}</span>
|
||||||
<Icon :icon="iconChevronDown" class="tags-select__arrow" aria-hidden="true" />
|
<Icon :icon="iconChevronDown" class="tags-select__arrow" aria-hidden="true" />
|
||||||
@@ -417,7 +425,10 @@ watch(
|
|||||||
:disabled="!selectedValues.has(option.value) && maxReached"
|
:disabled="!selectedValues.has(option.value) && maxReached"
|
||||||
@click="selectOption(option.value)"
|
@click="selectOption(option.value)"
|
||||||
>
|
>
|
||||||
|
<span class="tags-select__option-label">
|
||||||
|
<img v-if="option.thumbnailUrl" class="tags-select__thumb" :src="option.thumbnailUrl" alt="" loading="lazy" />
|
||||||
<span>{{ option.label }}</span>
|
<span>{{ option.label }}</span>
|
||||||
|
</span>
|
||||||
<span v-if="selectedValues.has(option.value)" class="tags-select__state">
|
<span v-if="selectedValues.has(option.value)" class="tags-select__state">
|
||||||
<Icon :icon="iconCheck" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconCheck" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('common.selected') }}
|
{{ t('common.selected') }}
|
||||||
|
|||||||
32
frontend/src/components/ViewAsBanner.vue
Normal file
32
frontend/src/components/ViewAsBanner.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { iconClose, iconProfile } from '../icons';
|
||||||
|
import type { ViewAsSummary } from '../services/api';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
viewAs: ViewAsSummary;
|
||||||
|
busy?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
stop: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="view-as-banner" role="status" aria-live="polite">
|
||||||
|
<div class="view-as-banner__inner">
|
||||||
|
<span class="view-as-banner__label">
|
||||||
|
<Icon :icon="iconProfile" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('viewAs.banner', { name: viewAs.label }) }}
|
||||||
|
</span>
|
||||||
|
<button class="ui-button ui-button--ghost ui-button--small view-as-banner__button" type="button" :disabled="busy" @click="emit('stop')">
|
||||||
|
<Icon :icon="iconClose" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('viewAs.exit') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -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,77 @@ 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()) {
|
||||||
|
return createI18n({
|
||||||
legacy: false,
|
legacy: false,
|
||||||
globalInjection: true,
|
globalInjection: true,
|
||||||
locale: readStoredLocale(),
|
locale: initialLocale || defaultLocale,
|
||||||
fallbackLocale: defaultLocale,
|
fallbackLocale: defaultLocale,
|
||||||
messages
|
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
|
||||||
|
: resolveBrowserApiBaseUrl(browserApiBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBrowserApiBaseUrl(value: string): string {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(value, window.location.origin);
|
||||||
|
|
||||||
|
if (isLoopbackHost(url.hostname) && !isLoopbackHost(window.location.hostname)) {
|
||||||
|
url.hostname = window.location.hostname;
|
||||||
|
return url.toString().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopbackHost(hostname: string): boolean {
|
||||||
|
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredLocale(): string {
|
||||||
if (typeof localStorage === 'undefined') {
|
if (typeof localStorage === 'undefined') {
|
||||||
return defaultLocale;
|
return defaultLocale;
|
||||||
}
|
}
|
||||||
@@ -35,11 +98,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 +131,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 +149,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 +173,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 +192,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());
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const iconEdit: AppIcon = 'mdi:pencil-outline';
|
|||||||
export const iconError: AppIcon = 'mdi:close-circle-outline';
|
export const iconError: AppIcon = 'mdi:close-circle-outline';
|
||||||
export const iconEvent: AppIcon = 'mdi:calendar-star';
|
export const iconEvent: AppIcon = 'mdi:calendar-star';
|
||||||
export const iconExternal: AppIcon = 'mdi:open-in-new';
|
export const iconExternal: AppIcon = 'mdi:open-in-new';
|
||||||
|
export const iconEye: AppIcon = 'mdi:eye-outline';
|
||||||
export const iconGitCommit: AppIcon = 'mdi:source-commit';
|
export const iconGitCommit: AppIcon = 'mdi:source-commit';
|
||||||
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
||||||
export const iconHome: AppIcon = 'mdi:home-variant-outline';
|
export const iconHome: AppIcon = 'mdi:home-variant-outline';
|
||||||
@@ -50,10 +51,12 @@ export const iconReactionLike: AppIcon = 'mdi:thumb-up-outline';
|
|||||||
export const iconReactionThanks: AppIcon = 'mdi:hand-heart-outline';
|
export const iconReactionThanks: AppIcon = 'mdi:hand-heart-outline';
|
||||||
export const iconSave: AppIcon = 'mdi:content-save-outline';
|
export const iconSave: AppIcon = 'mdi:content-save-outline';
|
||||||
export const iconSearch: AppIcon = 'mdi:magnify';
|
export const iconSearch: AppIcon = 'mdi:magnify';
|
||||||
|
export const iconSend: AppIcon = 'mdi:send-outline';
|
||||||
export const iconStar: AppIcon = 'mdi:star';
|
export const iconStar: AppIcon = 'mdi:star';
|
||||||
export const iconStarOutline: AppIcon = 'mdi:star-outline';
|
export const iconStarOutline: AppIcon = 'mdi:star-outline';
|
||||||
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
||||||
export const iconTranslate: AppIcon = 'mdi:translate';
|
export const iconTranslate: AppIcon = 'mdi:translate';
|
||||||
|
export const iconThreads: AppIcon = 'mdi:forum-outline';
|
||||||
export const iconUndo: AppIcon = 'mdi:undo';
|
export const iconUndo: AppIcon = 'mdi:undo';
|
||||||
export const iconUpload: AppIcon = 'mdi:upload-outline';
|
export const iconUpload: AppIcon = 'mdi:upload-outline';
|
||||||
export const iconVersion: AppIcon = 'mdi:tag-outline';
|
export const iconVersion: AppIcon = 'mdi:tag-outline';
|
||||||
|
|||||||
@@ -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,388 +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 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: 'items.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: ItemDetail,
|
|
||||||
meta: {
|
|
||||||
requiredPermission: 'items.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: ItemDetail,
|
|
||||||
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>;
|
||||||
|
export type Translator = (key: string, values?: TranslationValues) => string;
|
||||||
|
|
||||||
export type RouteSeoConfig = {
|
export type RouteSeoConfig = {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -24,14 +27,50 @@ export type SeoConfig = {
|
|||||||
canonicalPath?: string;
|
canonicalPath?: string;
|
||||||
image?: string | null;
|
image?: string | null;
|
||||||
noindex?: boolean;
|
noindex?: boolean;
|
||||||
|
openGraphType?: 'website' | 'article';
|
||||||
|
structuredData?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
|
openGraphType: 'website' | 'article';
|
||||||
|
structuredData: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ThreadSeoSummary = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
languageCode: string;
|
||||||
|
tags: Array<{ name: string }>;
|
||||||
|
messageCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
lastActiveAt: string;
|
||||||
|
author: { displayName: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
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,47 +107,44 @@ 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"]');
|
const title = metaTitle(config.title);
|
||||||
if (!element) {
|
const description = metaDescription(config.description);
|
||||||
element = document.createElement('link');
|
const canonicalUrl = absoluteUrl(normalizePath(config.canonicalPath));
|
||||||
element.setAttribute('rel', 'canonical');
|
const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath);
|
||||||
document.head.appendChild(element);
|
const robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow';
|
||||||
}
|
const locale = getCurrentLocale();
|
||||||
element.setAttribute('href', href);
|
const openGraphType = config.openGraphType ?? 'website';
|
||||||
}
|
|
||||||
|
|
||||||
function setStructuredData(title: string, description: string, canonicalUrl: string): void {
|
return {
|
||||||
let element = document.getElementById('pokopia-structured-data') as HTMLScriptElement | null;
|
title,
|
||||||
if (!element) {
|
description,
|
||||||
element = document.createElement('script');
|
canonicalUrl,
|
||||||
element.id = 'pokopia-structured-data';
|
imageUrl,
|
||||||
element.type = 'application/ld+json';
|
robots,
|
||||||
document.head.appendChild(element);
|
locale,
|
||||||
}
|
openGraphType,
|
||||||
|
structuredData: config.structuredData ?? {
|
||||||
element.textContent = JSON.stringify({
|
|
||||||
'@context': 'https://schema.org',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'WebPage',
|
'@type': 'WebPage',
|
||||||
name: title,
|
name: title,
|
||||||
@@ -119,64 +155,119 @@ function setStructuredData(title: string, description: string, canonicalUrl: str
|
|||||||
name: siteName,
|
name: siteName,
|
||||||
url: absoluteUrl('/')
|
url: absoluteUrl('/')
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applySeo(config: SeoConfig = {}): void {
|
|
||||||
if (typeof document === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
const title = metaTitle(config.title);
|
|
||||||
const description = metaDescription(config.description);
|
|
||||||
const canonicalUrl = absoluteUrl(normalizePath(config.canonicalPath));
|
|
||||||
const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath);
|
|
||||||
const noindex = config.noindex === true;
|
|
||||||
const robots = noindex ? 'noindex, nofollow' : 'index, follow';
|
|
||||||
const locale = getCurrentLocale();
|
|
||||||
|
|
||||||
document.title = title;
|
|
||||||
setMeta('name', 'description', description);
|
|
||||||
setMeta('name', 'robots', robots);
|
|
||||||
setMeta('name', 'twitter:card', 'summary_large_image');
|
|
||||||
setMeta('name', 'twitter:title', title);
|
|
||||||
setMeta('name', 'twitter:description', description);
|
|
||||||
setMeta('name', 'twitter:image', imageUrl);
|
|
||||||
setMeta('property', 'og:site_name', siteName);
|
|
||||||
setMeta('property', 'og:type', 'website');
|
|
||||||
setMeta('property', 'og:title', title);
|
|
||||||
setMeta('property', 'og:description', description);
|
|
||||||
setMeta('property', 'og:url', canonicalUrl);
|
|
||||||
setMeta('property', 'og:image', imageUrl);
|
|
||||||
setMeta('property', 'og:locale', localeForOpenGraph(locale));
|
|
||||||
setCanonical(canonicalUrl);
|
|
||||||
setStructuredData(title, description, canonicalUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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: seo.openGraphType },
|
||||||
|
{ 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function threadSeoConfig(thread: ThreadSeoSummary, translator: Translator): SeoConfig {
|
||||||
|
const title = thread.title.trim() || translator('pages.threads.title');
|
||||||
|
const canonicalPath = `/threads/${thread.id}`;
|
||||||
|
const keywords = thread.tags.map((tag) => tag.name.trim()).filter(Boolean).join(', ');
|
||||||
|
const description = translator('seo.threadDetailDescription', { title, count: thread.messageCount });
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${title} - ${translator('pages.threads.title')}`,
|
||||||
|
description,
|
||||||
|
canonicalPath,
|
||||||
|
openGraphType: 'article',
|
||||||
|
structuredData: {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'DiscussionForumPosting',
|
||||||
|
headline: title,
|
||||||
|
description,
|
||||||
|
url: absoluteUrl(canonicalPath),
|
||||||
|
datePublished: thread.createdAt,
|
||||||
|
dateModified: thread.lastActiveAt,
|
||||||
|
inLanguage: thread.languageCode,
|
||||||
|
keywords: keywords || undefined,
|
||||||
|
author: thread.author ? { '@type': 'Person', name: thread.author.displayName } : undefined,
|
||||||
|
interactionStatistic: {
|
||||||
|
'@type': 'InteractionCounter',
|
||||||
|
interactionType: { '@type': 'CommentAction' },
|
||||||
|
userInteractionCount: thread.messageCount
|
||||||
|
},
|
||||||
|
isPartOf: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
name: translator('pages.threads.title'),
|
||||||
|
url: absoluteUrl('/threads')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,63 @@ 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
|
||||||
|
: resolveBrowserApiBaseUrl(browserApiBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBrowserApiBaseUrl(value: string): string {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(value, window.location.origin);
|
||||||
|
|
||||||
|
if (isLoopbackHost(url.hostname) && !isLoopbackHost(window.location.hostname)) {
|
||||||
|
url.hostname = window.location.hostname;
|
||||||
|
return url.toString().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopbackHost(hostname: string): boolean {
|
||||||
|
return ['localhost', '127.0.0.1', '::1', '[::1]'].includes(hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -30,18 +92,20 @@ export interface SystemWording {
|
|||||||
updatedBy: UserSummary | null;
|
updatedBy: UserSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ModuleSettings {
|
||||||
|
tradingEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NamedEntity {
|
export interface NamedEntity {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
baseName?: string;
|
baseName?: string;
|
||||||
|
category?: string;
|
||||||
|
description?: string;
|
||||||
|
opposite?: NamedEntity | null;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LifeCategory extends NamedEntity {
|
|
||||||
isDefault: boolean;
|
|
||||||
isRateable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameVersion extends NamedEntity {
|
export interface GameVersion extends NamedEntity {
|
||||||
changeLog: string;
|
changeLog: string;
|
||||||
}
|
}
|
||||||
@@ -140,7 +204,7 @@ export interface PokemonImage extends EntityImage {
|
|||||||
version: string;
|
version: string;
|
||||||
variant: string;
|
variant: string;
|
||||||
description: string;
|
description: string;
|
||||||
source?: 'sprite' | 'upload';
|
source?: 'sprite' | 'upload' | 'external';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditInfo {
|
export interface EditInfo {
|
||||||
@@ -202,9 +266,9 @@ export interface RelatedPokemon {
|
|||||||
name: string;
|
name: string;
|
||||||
isEventItem: boolean;
|
isEventItem: boolean;
|
||||||
image?: PokemonImage | null;
|
image?: PokemonImage | null;
|
||||||
environment: NamedEntity;
|
environment: NamedEntity & { matches?: boolean; isOpposite?: boolean };
|
||||||
skills: Skill[];
|
skills: Skill[];
|
||||||
favorite_things: Array<NamedEntity & { matches: boolean }>;
|
favorite_things: Array<NamedEntity & { matches: boolean; isOpposite?: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PokemonDetail extends Pokemon {
|
export interface PokemonDetail extends Pokemon {
|
||||||
@@ -288,8 +352,7 @@ export interface Item extends EditInfo {
|
|||||||
category: NamedEntity;
|
category: NamedEntity;
|
||||||
usage: NamedEntity | null;
|
usage: NamedEntity | null;
|
||||||
customization: {
|
customization: {
|
||||||
dyeable: boolean;
|
dyeability: number;
|
||||||
dualDyeable: boolean;
|
|
||||||
patternEditable: boolean;
|
patternEditable: boolean;
|
||||||
};
|
};
|
||||||
noRecipe: boolean;
|
noRecipe: boolean;
|
||||||
@@ -297,6 +360,12 @@ export interface Item extends EditInfo {
|
|||||||
recipe: RecipeSummary | null;
|
recipe: RecipeSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ItemDyePreview {
|
||||||
|
partIndex: number;
|
||||||
|
colorName: string;
|
||||||
|
image: EntityImage;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AncientArtifact extends EditInfo {
|
export interface AncientArtifact extends EditInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -320,6 +389,7 @@ export interface ItemDetail extends Item {
|
|||||||
relatedRecipes: RecipeUsage[];
|
relatedRecipes: RecipeUsage[];
|
||||||
relatedHabitats: HabitatUsage[];
|
relatedHabitats: HabitatUsage[];
|
||||||
possibleTags: ItemPossibleTags;
|
possibleTags: ItemPossibleTags;
|
||||||
|
dyePreviews: ItemDyePreview[];
|
||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
imageHistory: EntityImageUpload[];
|
imageHistory: EntityImageUpload[];
|
||||||
droppedByPokemon: Array<{
|
droppedByPokemon: Array<{
|
||||||
@@ -458,11 +528,7 @@ export interface LifePost {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
author: UserSummary | null;
|
author: UserSummary | null;
|
||||||
updatedBy: UserSummary | null;
|
updatedBy: UserSummary | null;
|
||||||
category: (NamedEntity & { isRateable: boolean }) | null;
|
|
||||||
gameVersion: GameVersion | null;
|
gameVersion: GameVersion | null;
|
||||||
ratingAverage: number | null;
|
|
||||||
ratingCount: number;
|
|
||||||
myRating: number | null;
|
|
||||||
commentPreview: LifeComment[];
|
commentPreview: LifeComment[];
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
reactionCounts: LifeReactionCounts;
|
reactionCounts: LifeReactionCounts;
|
||||||
@@ -479,11 +545,9 @@ export interface LifePostsParams {
|
|||||||
cursor?: string | null;
|
cursor?: string | null;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
categoryId?: string | number;
|
|
||||||
language?: string;
|
language?: string;
|
||||||
gameVersionId?: string | number;
|
gameVersionId?: string | number;
|
||||||
rateable?: boolean | null;
|
sort?: 'latest' | 'oldest';
|
||||||
sort?: 'latest' | 'oldest' | 'top-rated';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommentPageParams {
|
export interface CommentPageParams {
|
||||||
@@ -539,6 +603,126 @@ export interface LifeReactionUsersParams {
|
|||||||
reactionType?: LifeReactionType;
|
reactionType?: LifeReactionType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ThreadReactionType = string;
|
||||||
|
export type ThreadReactionCounts = Record<string, number>;
|
||||||
|
export type ThreadSort = 'last-active' | 'latest' | 'most-discussed';
|
||||||
|
|
||||||
|
export interface ThreadChannelTag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadChannel {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
allowUserThreads: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
tags: ThreadChannelTag[];
|
||||||
|
languages: Array<{ code: string; name: string }>;
|
||||||
|
unreadCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadSummary {
|
||||||
|
id: number;
|
||||||
|
channelId: number;
|
||||||
|
title: string;
|
||||||
|
languageCode: string;
|
||||||
|
tags: ThreadChannelTag[];
|
||||||
|
locked: boolean;
|
||||||
|
messageCount: number;
|
||||||
|
lastActiveAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
author: UserSummary | null;
|
||||||
|
reactionCounts: ThreadReactionCounts;
|
||||||
|
myReactions: ThreadReactionType[];
|
||||||
|
followed: boolean;
|
||||||
|
unread: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadMessage {
|
||||||
|
id: number;
|
||||||
|
threadId: number;
|
||||||
|
body: string;
|
||||||
|
moderationStatus: AiModerationStatus;
|
||||||
|
moderationLanguageCode: string | null;
|
||||||
|
moderationReason: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
author: UserSummary | null;
|
||||||
|
reactionCounts: ThreadReactionCounts;
|
||||||
|
myReactions: ThreadReactionType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadsPage {
|
||||||
|
items: ThreadSummary[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadMessagesPage {
|
||||||
|
items: ThreadMessage[];
|
||||||
|
beforeCursor: string | null;
|
||||||
|
hasMoreBefore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadsParams {
|
||||||
|
cursor?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
channelId?: number | string | null;
|
||||||
|
language?: string;
|
||||||
|
tagId?: number | string | null;
|
||||||
|
sort?: ThreadSort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadMessagesParams {
|
||||||
|
before?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadPayload {
|
||||||
|
channelId: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
languageCode: string;
|
||||||
|
tagIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadUpdatePayload {
|
||||||
|
title: string;
|
||||||
|
tagIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadMessagePayload {
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreadWsTicket {
|
||||||
|
ticket: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThreadWsMessage =
|
||||||
|
| { type: 'threads.connected'; followedUnreadCount: number }
|
||||||
|
| { type: 'thread.message.created'; threadId: number; message: ThreadMessage; thread: ThreadSummary }
|
||||||
|
| { type: 'thread.message.moderation'; threadId: number; messageId: number; message: ThreadMessage | null }
|
||||||
|
| {
|
||||||
|
type: 'thread.reactions.updated';
|
||||||
|
target: 'thread' | 'message';
|
||||||
|
threadId: number;
|
||||||
|
messageId: number | null;
|
||||||
|
reactionCounts: ThreadReactionCounts;
|
||||||
|
myReactions: ThreadReactionType[];
|
||||||
|
}
|
||||||
|
| { type: 'thread.read.updated'; threadId: number; unread: boolean; unreadCount: number };
|
||||||
|
|
||||||
|
export interface AdminThreadChannelPayload {
|
||||||
|
name: string;
|
||||||
|
allowUserThreads: boolean;
|
||||||
|
tags: string[];
|
||||||
|
languages: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotificationTarget {
|
export interface NotificationTarget {
|
||||||
type: NotificationTargetType;
|
type: NotificationTargetType;
|
||||||
id: number;
|
id: number;
|
||||||
@@ -619,9 +803,9 @@ export interface Options {
|
|||||||
acquisitionMethods: NamedEntity[];
|
acquisitionMethods: NamedEntity[];
|
||||||
itemTags: NamedEntity[];
|
itemTags: NamedEntity[];
|
||||||
maps: NamedEntity[];
|
maps: NamedEntity[];
|
||||||
lifeCategories: LifeCategory[];
|
|
||||||
gameVersions: GameVersion[];
|
gameVersions: GameVersion[];
|
||||||
dishFlavors: NamedEntity[];
|
dishFlavors: NamedEntity[];
|
||||||
|
moduleSettings: ModuleSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
@@ -631,6 +815,12 @@ export interface AuthUser {
|
|||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
roles: RoleSummary[];
|
roles: RoleSummary[];
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
viewAs?: ViewAsSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewAsSummary {
|
||||||
|
mode: 'user' | 'role';
|
||||||
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReferralSummary {
|
export interface ReferralSummary {
|
||||||
@@ -769,7 +959,6 @@ export interface RegisterPayload extends LoginPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
token: string;
|
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -780,7 +969,6 @@ export type ConfigType =
|
|||||||
| 'favorite-things'
|
| 'favorite-things'
|
||||||
| 'acquisition-methods'
|
| 'acquisition-methods'
|
||||||
| 'maps'
|
| 'maps'
|
||||||
| 'life-tags'
|
|
||||||
| 'game-versions'
|
| 'game-versions'
|
||||||
| 'dish-flavors';
|
| 'dish-flavors';
|
||||||
|
|
||||||
@@ -837,14 +1025,14 @@ export interface ItemPayload {
|
|||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
usageId: number | null;
|
usageId: number | null;
|
||||||
dyeable: boolean;
|
dyeability: number;
|
||||||
dualDyeable: boolean;
|
|
||||||
patternEditable: boolean;
|
patternEditable: boolean;
|
||||||
noRecipe: boolean;
|
noRecipe: boolean;
|
||||||
isEventItem: boolean;
|
isEventItem: boolean;
|
||||||
acquisitionMethodIds: number[];
|
acquisitionMethodIds: number[];
|
||||||
tagIds: number[];
|
tagIds: number[];
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
|
dyePreviews: Array<{ partIndex: number; colorName: string; imagePath: string }>;
|
||||||
insertBeforeItemId?: number | null;
|
insertBeforeItemId?: number | null;
|
||||||
insertAfterItemId?: number | null;
|
insertAfterItemId?: number | null;
|
||||||
}
|
}
|
||||||
@@ -905,7 +1093,6 @@ export interface DailyChecklistPayload {
|
|||||||
|
|
||||||
export interface LifePostPayload {
|
export interface LifePostPayload {
|
||||||
body: string;
|
body: string;
|
||||||
categoryId: number;
|
|
||||||
gameVersionId?: number | null;
|
gameVersionId?: number | null;
|
||||||
languageCode?: string | null;
|
languageCode?: string | null;
|
||||||
}
|
}
|
||||||
@@ -1023,40 +1210,11 @@ export function buildQuery(params: Record<string, string | number | boolean | nu
|
|||||||
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);
|
||||||
}
|
}
|
||||||
@@ -1067,16 +1225,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 = '';
|
||||||
@@ -1084,6 +1240,15 @@ export function notificationWebSocketUrl(ticket: string): string {
|
|||||||
return base.toString();
|
return base.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function threadWebSocketUrl(ticket: string): string {
|
||||||
|
const base = new URL(browserApiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
|
||||||
|
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
base.pathname = '/api/threads/ws';
|
||||||
|
base.search = '';
|
||||||
|
base.searchParams.set('ticket', ticket);
|
||||||
|
return base.toString();
|
||||||
|
}
|
||||||
|
|
||||||
async function getErrorMessage(response: Response): Promise<string> {
|
async function getErrorMessage(response: Response): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const data = (await response.json()) as { message?: unknown };
|
const data = (await response.json()) as { message?: unknown };
|
||||||
@@ -1097,10 +1262,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) {
|
||||||
@@ -1110,13 +1289,14 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
|
async function sendJson<T>(path: string, method: 'DELETE' | '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)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1128,7 +1308,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
|
||||||
@@ -1142,7 +1323,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()
|
||||||
});
|
});
|
||||||
@@ -1153,7 +1335,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()
|
||||||
});
|
});
|
||||||
@@ -1164,7 +1347,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()
|
||||||
});
|
});
|
||||||
@@ -1180,6 +1364,7 @@ export const api = {
|
|||||||
globalSearch: (query: string, signal?: AbortSignal) =>
|
globalSearch: (query: string, signal?: AbortSignal) =>
|
||||||
getJson<GlobalSearchResults>(`/api/search${buildQuery({ query: query.trim() })}`, signal),
|
getJson<GlobalSearchResults>(`/api/search${buildQuery({ query: query.trim() })}`, signal),
|
||||||
languages: () => getJson<Language[]>('/api/languages'),
|
languages: () => getJson<Language[]>('/api/languages'),
|
||||||
|
moduleSettings: () => getJson<ModuleSettings>('/api/module-settings'),
|
||||||
projectUpdates: (params: ProjectUpdatesParams = {}) =>
|
projectUpdates: (params: ProjectUpdatesParams = {}) =>
|
||||||
getJson<ProjectUpdates>(
|
getJson<ProjectUpdates>(
|
||||||
`/api/project-updates${buildQuery({
|
`/api/project-updates${buildQuery({
|
||||||
@@ -1201,6 +1386,9 @@ export const api = {
|
|||||||
aiModerationSettings: () => getJson<AiModerationSettings>('/api/admin/ai-moderation'),
|
aiModerationSettings: () => getJson<AiModerationSettings>('/api/admin/ai-moderation'),
|
||||||
updateAiModerationSettings: (payload: AiModerationSettingsPayload) =>
|
updateAiModerationSettings: (payload: AiModerationSettingsPayload) =>
|
||||||
sendJson<AiModerationSettings>('/api/admin/ai-moderation', 'PUT', payload),
|
sendJson<AiModerationSettings>('/api/admin/ai-moderation', 'PUT', payload),
|
||||||
|
adminModuleSettings: () => getJson<ModuleSettings>('/api/admin/module-settings'),
|
||||||
|
updateAdminModuleSettings: (payload: ModuleSettings) =>
|
||||||
|
sendJson<ModuleSettings>('/api/admin/module-settings', 'PUT', payload),
|
||||||
rateLimitSettings: () => getJson<RateLimitSettings>('/api/admin/rate-limits'),
|
rateLimitSettings: () => getJson<RateLimitSettings>('/api/admin/rate-limits'),
|
||||||
updateRateLimitSettings: (payload: RateLimitSettingsPayload) =>
|
updateRateLimitSettings: (payload: RateLimitSettingsPayload) =>
|
||||||
sendJson<RateLimitSettings>('/api/admin/rate-limits', 'PUT', payload),
|
sendJson<RateLimitSettings>('/api/admin/rate-limits', 'PUT', payload),
|
||||||
@@ -1218,7 +1406,10 @@ 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),
|
||||||
|
viewAsUser: (userId: string | number) => sendJson<{ user: AuthUser }>('/api/auth/view-as/user', 'POST', { userId }),
|
||||||
|
viewAsRole: (roleId: string | number) => sendJson<{ user: AuthUser }>('/api/auth/view-as/role', 'POST', { roleId }),
|
||||||
|
stopViewAs: () => sendJson<{ user: AuthUser }>('/api/auth/view-as/stop', 'POST', {}),
|
||||||
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),
|
||||||
@@ -1244,10 +1435,8 @@ export const api = {
|
|||||||
cursor: params.cursor ?? undefined,
|
cursor: params.cursor ?? undefined,
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
search: params.search,
|
search: params.search,
|
||||||
categoryId: params.categoryId,
|
|
||||||
language: params.language,
|
language: params.language,
|
||||||
gameVersionId: params.gameVersionId,
|
gameVersionId: params.gameVersionId,
|
||||||
rateable: params.rateable === null ? undefined : params.rateable,
|
|
||||||
sort: params.sort
|
sort: params.sort
|
||||||
})}`
|
})}`
|
||||||
),
|
),
|
||||||
@@ -1305,10 +1494,8 @@ export const api = {
|
|||||||
cursor: params.cursor ?? undefined,
|
cursor: params.cursor ?? undefined,
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
search: params.search?.trim(),
|
search: params.search?.trim(),
|
||||||
categoryId: params.categoryId,
|
|
||||||
language: params.language,
|
language: params.language,
|
||||||
gameVersionId: params.gameVersionId,
|
gameVersionId: params.gameVersionId,
|
||||||
rateable: params.rateable === null ? undefined : params.rateable,
|
|
||||||
sort: params.sort
|
sort: params.sort
|
||||||
})}`
|
})}`
|
||||||
),
|
),
|
||||||
@@ -1330,9 +1517,56 @@ export const api = {
|
|||||||
reactionType: params.reactionType
|
reactionType: params.reactionType
|
||||||
})}`
|
})}`
|
||||||
),
|
),
|
||||||
setLifeRating: (id: string | number, rating: number) =>
|
threadChannels: () => getJson<ThreadChannel[]>('/api/thread-channels'),
|
||||||
sendJson<LifePost>(`/api/life-posts/${id}/rating`, 'PUT', { rating }),
|
threads: (params: ThreadsParams = {}) =>
|
||||||
deleteLifeRating: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/rating`),
|
getJson<ThreadsPage>(
|
||||||
|
`/api/threads${buildQuery({
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit,
|
||||||
|
channelId: params.channelId,
|
||||||
|
language: params.language,
|
||||||
|
tagId: params.tagId,
|
||||||
|
sort: params.sort
|
||||||
|
})}`
|
||||||
|
),
|
||||||
|
thread: (id: string | number) => getJson<ThreadSummary>(`/api/threads/${id}`),
|
||||||
|
createThread: (payload: ThreadPayload) => sendJson<ThreadSummary>('/api/threads', 'POST', payload),
|
||||||
|
updateThread: (id: string | number, payload: ThreadUpdatePayload) => sendJson<ThreadSummary>(`/api/threads/${id}`, 'PUT', payload),
|
||||||
|
threadMessages: (id: string | number, params: ThreadMessagesParams = {}) =>
|
||||||
|
getJson<ThreadMessagesPage>(
|
||||||
|
`/api/threads/${id}/messages${buildQuery({
|
||||||
|
before: params.before ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
|
createThreadMessage: (id: string | number, payload: ThreadMessagePayload) =>
|
||||||
|
sendJson<ThreadMessage>(`/api/threads/${id}/messages`, 'POST', payload),
|
||||||
|
updateThreadMessage: (id: string | number, payload: ThreadMessagePayload) =>
|
||||||
|
sendJson<ThreadMessage>(`/api/thread-messages/${id}`, 'PUT', payload),
|
||||||
|
retryThreadMessageModeration: (id: string | number) =>
|
||||||
|
sendJson<ThreadMessage>(`/api/thread-messages/${id}/moderation/retry`, 'POST', {}),
|
||||||
|
followThread: (id: string | number) => sendJson<ThreadSummary>(`/api/threads/${id}/follow`, 'PUT', {}),
|
||||||
|
unfollowThread: (id: string | number) => deleteAndGetJson<ThreadSummary>(`/api/threads/${id}/follow`),
|
||||||
|
markThreadRead: (id: string | number) => sendJson<ThreadSummary>(`/api/threads/${id}/read`, 'POST', {}),
|
||||||
|
setThreadReaction: (id: string | number, reactionType: ThreadReactionType) =>
|
||||||
|
sendJson<ThreadSummary>(`/api/threads/${id}/reaction`, 'PUT', { reactionType }),
|
||||||
|
deleteThreadReaction: (id: string | number, reactionType: ThreadReactionType) =>
|
||||||
|
sendJson<ThreadSummary>(`/api/threads/${id}/reaction`, 'DELETE', { reactionType }),
|
||||||
|
setThreadMessageReaction: (id: string | number, reactionType: ThreadReactionType) =>
|
||||||
|
sendJson<ThreadMessage>(`/api/thread-messages/${id}/reaction`, 'PUT', { reactionType }),
|
||||||
|
deleteThreadMessageReaction: (id: string | number, reactionType: ThreadReactionType) =>
|
||||||
|
sendJson<ThreadMessage>(`/api/thread-messages/${id}/reaction`, 'DELETE', { reactionType }),
|
||||||
|
threadWsTicket: () => sendJson<ThreadWsTicket>('/api/threads/ws-ticket', 'POST', {}),
|
||||||
|
adminThreadChannels: () => getJson<ThreadChannel[]>('/api/admin/thread-channels'),
|
||||||
|
createAdminThreadChannel: (payload: AdminThreadChannelPayload) =>
|
||||||
|
sendJson<ThreadChannel[]>('/api/admin/thread-channels', 'POST', payload),
|
||||||
|
updateAdminThreadChannel: (id: string | number, payload: AdminThreadChannelPayload) =>
|
||||||
|
sendJson<ThreadChannel[]>(`/api/admin/thread-channels/${id}`, 'PUT', payload),
|
||||||
|
deleteAdminThreadChannel: (id: string | number) => deleteJson(`/api/admin/thread-channels/${id}`),
|
||||||
|
lockThread: (id: string | number, locked: boolean) =>
|
||||||
|
sendJson<ThreadSummary>(`/api/admin/threads/${id}/lock`, 'PUT', { locked }),
|
||||||
|
deleteThread: (id: string | number) => deleteJson(`/api/admin/threads/${id}`),
|
||||||
|
deleteThreadMessage: (id: string | number) => deleteJson(`/api/admin/thread-messages/${id}`),
|
||||||
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
|
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
|
||||||
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
|
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
|
||||||
lifeComments: (postId: string | number, params: CommentPageParams = {}) =>
|
lifeComments: (postId: string | number, params: CommentPageParams = {}) =>
|
||||||
@@ -1398,20 +1632,20 @@ export const api = {
|
|||||||
reorderDailyChecklistItems: (ids: number[]) =>
|
reorderDailyChecklistItems: (ids: number[]) =>
|
||||||
sendJson<DailyChecklistItem[]>('/api/admin/daily-checklist/order', 'PUT', { ids }),
|
sendJson<DailyChecklistItem[]>('/api/admin/daily-checklist/order', 'PUT', { ids }),
|
||||||
deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`),
|
deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`),
|
||||||
config: (type: ConfigType) => getJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
|
config: (type: ConfigType) => getJson<Array<Skill | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
|
||||||
createConfig: (
|
createConfig: (
|
||||||
type: ConfigType,
|
type: ConfigType,
|
||||||
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
|
payload: { name: string; translations?: TranslationMap; category?: string; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
|
||||||
) =>
|
) =>
|
||||||
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
||||||
reorderConfig: (type: ConfigType, ids: number[]) =>
|
reorderConfig: (type: ConfigType, ids: number[]) =>
|
||||||
sendJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
|
sendJson<Array<Skill | GameVersion | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
|
||||||
updateConfig: (
|
updateConfig: (
|
||||||
type: ConfigType,
|
type: ConfigType,
|
||||||
id: number,
|
id: number,
|
||||||
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
|
payload: { name: string; translations?: TranslationMap; category?: string; description?: string; oppositeId?: number | null; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
|
||||||
) =>
|
) =>
|
||||||
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
sendJson<Skill | 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)}`),
|
||||||
@@ -1436,7 +1670,6 @@ 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 = {}) =>
|
habitatsPage: (params: PublicListQueryParams = {}) =>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user