Compare commits

..

30 Commits

Author SHA1 Message Date
26bef1b749 chore(docker): expose postgres port to host
Map container port 5432 to host port 50001 for external database access
2026-05-07 20:46:47 +08:00
02db73aa4e feat(auth): add view as user and role functionality for owners
Allow owners to impersonate users or roles for debugging permissions.
Add view-as targets to user sessions and resolve effective permissions.
Display a persistent banner in the app shell to exit view-as mode.
2026-05-07 20:31:52 +08:00
ee054dcd15 feat(pokemon): sort by display_id instead of internal id
Update schema to replace sort_order index with display_id index
Apply display_id ordering to global search, lists, and relations
Update design documentation to reflect the new sorting behavior
2026-05-07 19:56:20 +08:00
575597b146 feat(pokemon): enforce bidirectional opposite sync and hide opposite text
Add unique indexes and transactional sync for opposite configurations
Remove explicit opposite names and labels from Pokemon detail view
2026-05-07 16:21:11 +08:00
953b90eba1 feat(pokemon): add opposite relationships and redesign detail view
Add description and opposite relationships to environments and favorite things
Move pokedex reference data (stats, dimensions, types) to a separate tab
Highlight core mechanics (skills, habitat, favorite things) in detail view
Update related pokemon scoring to account for opposite relationships
2026-05-07 15:57:38 +08:00
a781bc559b refactor(life): remove life categories and ratings
Drop life_tags and life_post_ratings tables and related schema
Remove category selection and rating UI from Life posts
Simplify Life feed filters and API endpoints
2026-05-07 15:38:32 +08:00
e9d356a656 chore: remove generated repomix output and update gitignore
Delete the accidentally committed repomix-output.xml repository dump
Add .repomix-output.xml to .gitignore to prevent future commits
2026-05-07 15:29:35 +08:00
9db8e60f3d feat(sitemap): implement dynamic sitemap index and entity sitemaps
Convert sitemap.xml to a sitemap index referencing split modules
Add dynamic sitemaps for pokedex, habitats, collections, life, and threads
Fetch entity data from API to populate lastmod and priority
2026-05-07 13:55:25 +08:00
4a7309027a feat(threads): add SEO metadata, sitemap, and structured data
Include /threads in sitemap and set canonical paths
Generate DiscussionForumPosting structured data for thread details
Add dynamic SEO updates for thread navigation and server-side rendering
2026-05-07 13:46:08 +08:00
520d988589 feat(threads): preserve list state and scroll position across navigation
Sync thread list filters and search with URL query parameters
Save and restore list state and scroll position using session storage
2026-05-07 13:39:15 +08:00
64ca494d82 feat(threads): add editing, moderation retry, and emoji reactions
Add API routes and UI for editing threads and messages
Allow users to retry AI moderation for failed messages
Migrate thread reactions to use native emojis
Implement frontend search filtering for thread list
2026-05-07 13:30:13 +08:00
cbb101336b feat(threads): add real-time forum and chat system
Implement DB schema, API, and WebSocket for channels and messages
Add frontend views, AI moderation, and admin management
2026-05-07 11:28:14 +08:00
23a7301598 feat(items): replace dyeable booleans with dyeability level
Add dyeability integer field to support up to triple dyeable items
Update frontend forms to use a radio group for dyeability selection
2026-05-07 10:17:45 +08:00
515297ab74 style(frontend): remove borders and backgrounds from image containers
Make image preview and detail screens transparent
Remove borders and padding from entity card marks and profile images
2026-05-07 10:04:11 +08:00
b1cf40edd0 feat(frontend): add thumbnail support to TagsSelect component
Display item and pokemon images in dropdown options and selected values
Update Admin, Dish, Habitat, Pokemon, and Recipe views to pass image URLs
2026-05-07 09:59:10 +08:00
bcf8dd9cb5 docs: finalize SSR migration documentation
Remove the temporary SSR migration tasklist and workflow instructions.
Update project context to reflect that Nuxt SSR is now fully enabled.
2026-05-07 09:51:28 +08:00
d87539e897 fix(frontend): handle errors and loading state in dish view
Catch and display errors during dish and editor options loading
Ensure loading state is reset in finally block
2026-05-07 09:45:30 +08:00
82f08c1684 feat(pokemon): remove manual sorting and enforce ID-based order
Remove pokemon.order permission and related API endpoints
Update queries to sort Pokemon by internal ID ascending
Replace reorderable list with standard list in Admin view
2026-05-06 22:35:46 +08:00
df78685dc3 feat(frontend): enhance trading item search and keyboard navigation
Implement weighted search scoring for trading items
Add keyboard support (arrows, enter) for item selection
Limit trading detail list height with independent scrolling
2026-05-06 22:11:09 +08:00
cc440ea949 feat(frontend): replace native confirms and enhance form controls
Add ConfirmDialog to replace window.confirm for delete actions
Enhance SwitchGroup with grid layout, descriptions, and disabled state
Update AdminView to use TagsSelect and SwitchGroup for better UX
2026-05-06 21:14:47 +08:00
5ef1f4ecc9 refactor(frontend): move detail view state initialization to server plugin
Remove top-level await from useAsyncData in detail views
Remove manual state initialization blocks in components
Introduce 03-detail-seo.server.ts to handle SEO and state
2026-05-06 17:40:44 +08:00
4dc73d42cb fix(frontend): await useAsyncData and initialize state in detail views
Restore await for useAsyncData to ensure data is fetched during SSR
Assign initial data to local refs to prevent empty states on load
2026-05-06 17:26:49 +08:00
fa656a8d02 refactor(auth): migrate fully to HTTP-only cookie sessions
Remove client-side token storage and Authorization header injection
Backend login now only returns user data, omitting the session token
Remove Authorization from backend CORS allowed headers
Clean up obsolete VITE_* environment variable fallbacks
Update Modal component to use Vue useId() instead of Math.random()
2026-05-06 17:15:46 +08:00
f26cfdc830 refactor(frontend): remove top-level await from useAsyncData
Transition to non-blocking data fetching to prevent navigation delays.
Initial data is now applied via immediate watchers instead of blocking setup.
2026-05-06 16:35:03 +08:00
71b35b9cc6 chore(docker): add local debug compose setup and scripts
Add docker-compose.debug.yml for local hot-reload debugging
Add docker:debug and docker:prod scripts to package.json
Update documentation and environment examples for debug usage
Update pnpm version in packageManager field
2026-05-06 16:18:23 +08:00
70f7a73e6d fix(frontend): safely resolve route IDs and remove manual auth checks
Prevent invalid API calls during route transitions in detail views
Allow builds for esbuild and @parcel/watcher in pnpm workspace
2026-05-06 15:59:36 +08:00
f92e97b747 feat(ssr): load initial data and SEO for public detail pages
Fetch initial content server-side for detail views and Life feed.
Bind detail-specific SEO head tags during SSR.
Extract resolvedSeoHead to share head tag generation.
2026-05-06 12:01:00 +08:00
d66124862a feat(ssr): load initial data for remaining public routes
Use useAsyncData to fetch initial list pages and options server-side
Apply SSR loading to Habitats, Items, Artifacts, Recipes, Dishes, and Home
2026-05-06 11:21:00 +08:00
f7986ca520 feat(seo): centralize route metadata and expand sitemap coverage
Remove static fallback tags from Nuxt config to prevent duplication.
Auto-apply noindex to authenticated and permissioned routes.
Add home, project updates, and legal pages to sitemap.
Properly escape JSON-LD structured data.
2026-05-06 11:01:19 +08:00
425f2f4d5f feat(ssr): load Pokemon lists and forward auth cookies on server
Update auth middleware to pass incoming request cookies to api.me()
Refactor API service to support custom headers via ApiRequestOptions
Use useAsyncData in PokemonList to fetch initial data during SSR
Ensure graceful fallback to client-side fetching on SSR failure
2026-05-06 10:50:51 +08:00
69 changed files with 7986 additions and 1668 deletions

View File

@@ -10,8 +10,6 @@ BACKEND_PUBLIC_ORIGIN=http://localhost:20016
NUXT_PUBLIC_API_BASE_URL=http://localhost:20016 NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
NUXT_SERVER_API_BASE_URL=http://localhost:3001 NUXT_SERVER_API_BASE_URL=http://localhost:3001
NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
VITE_API_BASE_URL=http://localhost:20016
VITE_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
@@ -20,6 +18,12 @@ RESEND_QUOTA_RESERVE=5
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10 RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
AI_MODERATION_API_KEY= AI_MODERATION_API_KEY=
# Local Docker debug defaults:
# docker compose -f docker-compose.debug.yml up --build
# NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
# NUXT_SERVER_API_BASE_URL=http://backend:3001
# NUXT_PUBLIC_SITE_URL=http://localhost:20015
# Cloudflared tunnel deployment example: # Cloudflared tunnel deployment example:
# FRONTEND_ORIGIN=https://pokopiawiki.tootaio.com,http://localhost:20015 # FRONTEND_ORIGIN=https://pokopiawiki.tootaio.com,http://localhost:20015
# APP_ORIGIN=https://pokopiawiki.tootaio.com # APP_ORIGIN=https://pokopiawiki.tootaio.com
@@ -27,4 +31,3 @@ AI_MODERATION_API_KEY=
# NUXT_PUBLIC_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_SERVER_API_BASE_URL=http://backend:3001
# NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com # NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
# VITE_API_BASE_URL=https://api-pokopiawiki.tootaio.com

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ coverage/
.DS_Store .DS_Store
.agents/ .agents/
skills-lock.json skills-lock.json
repomix-output.xml

View File

@@ -15,12 +15,11 @@
For any non-trivial task: For any non-trivial task:
1. **Read `DESIGN.md`** 1. **Read `DESIGN.md`**
2. While `SSR_MIGRATION_TASKLIST.md` exists, **also read `SSR_MIGRATION_TASKLIST.md`** and keep SSR migration work aligned with it. 2. For UI, component, layout, or styling tasks, **also read `DesignGuidelines.html`**
3. For UI, component, layout, or styling tasks, **also read `DesignGuidelines.html`** 3. **Produce a short plan (no code)**
4. **Produce a short plan (no code)** 4. Wait for approval
5. Wait for approval 5. Implement in small steps
6. Implement in small steps 6. Run lightweight validation when practical (lint/typecheck). Do not run tests in WSL.
7. Run lightweight validation when practical (lint/typecheck). Do not run tests in WSL.
Do NOT skip planning. Do NOT skip planning.
@@ -28,16 +27,6 @@ For documentation-only tasks, still follow the planning workflow, but do not run
--- ---
## Temporary SSR Migration Workflow
* `SSR_MIGRATION_TASKLIST.md` is the active task list for completing the Nuxt SSR migration.
* Until that migration is fully implemented and validated, every task that touches frontend routing, auth, API fetching, i18n, SEO, Docker frontend deployment, Nuxt config, or SSR/client runtime behavior must read and follow `SSR_MIGRATION_TASKLIST.md`.
* Update task checkboxes in `SSR_MIGRATION_TASKLIST.md` only when the corresponding implementation is actually complete and validated.
* Do not delete `SSR_MIGRATION_TASKLIST.md` early. Delete it only after the project is fully migrated to the final SSR deployment model, validation is complete, and `DESIGN.md` reflects the final behavior.
* When deleting `SSR_MIGRATION_TASKLIST.md`, also remove this Temporary SSR Migration Workflow section and the mandatory workflow step that requires reading the task list.
---
## Project Context ## Project Context
* Goal: Pokopia Wiki, a community-editable game wiki. * Goal: Pokopia Wiki, a community-editable game wiki.
@@ -45,7 +34,7 @@ For documentation-only tasks, still follow the planning workflow, but do not run
* Runtime baseline: Node.js >= 22. * Runtime baseline: Node.js >= 22.
* Frontend: * Frontend:
* Nuxt SPA mode currently (`ssr: false`), with SSR migration tracked in `SSR_MIGRATION_TASKLIST.md` * Nuxt SSR enabled (`ssr: true`)
* Vue * Vue
* Vue Router * Vue Router
* Vue I18n * Vue I18n

261
DESIGN.md
View File

@@ -6,6 +6,7 @@
- 所有人都可以浏览 Wiki 内容。 - 所有人都可以浏览 Wiki 内容。
- 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。 - 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。
- 前台以 Home 首页、PokedexMain Game / Event、Habitat DexMain Game / Event、CollectionsMain Game / Event / Ancient Artifacts、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。 - 前台以 Home 首页、PokedexMain Game / Event、Habitat DexMain Game / Event、CollectionsMain 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 可展示用户名外,顶部操作以图标按钮呈现。
@@ -27,7 +28,7 @@
- 全局搜索 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`)升序展示,不提供手动排序
## 国际化 ## 国际化
@@ -63,7 +64,6 @@
- 地图 - 地图
- 栖息地 - 栖息地
- 每日 CheckList Task - 每日 CheckList Task
- Life Category
- Game Version - Game Version
- Dish Category - Dish Category
- Dish Flavor - Dish Flavor
@@ -123,12 +123,13 @@
- 登录页提供 Remember me - 登录页提供 Remember me
- 未勾选时 session 有效期为 1 天。 - 未勾选时 session 有效期为 1 天。
- 勾选时 session 有效期为 30 天。 - 勾选时 session 有效期为 30 天。
- SSR 迁移期认证使用 hybrid session model - SSR 认证使用 HTTP-only cookie session
- 登录成功后后端设置 HTTP-only `pokopia_session` cookiecookie 只保存明文 session token数据库只保存 session token hash。 - 登录成功后后端设置 HTTP-only `pokopia_session` cookiecookie 只保存明文 session token数据库只保存 session token hash。
- 迁移期登录响应返回明文 session token,前端继续按 Remember me 语义保存到 `sessionStorage``localStorage``pokopia_auth_token`,用于保持现有 SPA 客户端流程兼容 - 登录响应只返回当前用户必要字段,不返回明文 session tokensession token hash 或内部 session 元数据
- 受保护 API 优先接受 HTTP-only cookie session并继续兼容 `Authorization: Bearer` legacy token - Remember me 通过 HTTP-only session cookie 有效期实现:未勾选时有效期为 1 天,勾选时有效期为 30 天
- 受保护 API 只接受 HTTP-only cookie session不接受前端 JavaScript 保存的 legacy Bearer token。
- 前端 API 请求携带 credentials以便浏览器自动发送 HTTP-only session cookieJavaScript 不读取该 cookie。 - 前端 API 请求携带 credentials以便浏览器自动发送 HTTP-only session cookieJavaScript 不读取该 cookie。
- 用户可退出登录,退出时删除对应 session清除 HTTP-only session cookie,并清理前端 legacy token storage - 用户可退出登录,退出时删除对应 session清除 HTTP-only session cookie。
- 对外用户字段只包含必要信息: - 对外用户字段只包含必要信息:
- 当前用户:`id``email``displayName``emailVerified` - 当前用户:`id``email``displayName``emailVerified`
- 编辑署名:`id``displayName` - 编辑署名:`id``displayName`
@@ -199,6 +200,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
@@ -301,6 +309,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 未读数量。
## 滥用防护与限流 ## 滥用防护与限流
@@ -327,8 +336,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 编辑与审计
@@ -357,7 +366,7 @@
- `created_at` - `created_at`
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。 - 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
- 编辑历史中的用户信息只展示必要署名不暴露邮箱、token、hash 或内部元数据。 - 编辑历史中的用户信息只展示必要署名不暴露邮箱、token、hash 或内部元数据。
- 排序操作仍更新列表顺序、最后编辑者和最后编辑时间,但 `sort_order` / Sort order 字段变更不写入或展示在详情页编辑历史面板中。 - 非 Pokemon 列表排序操作仍更新列表顺序、最后编辑者和最后编辑时间,但 `sort_order` / Sort order 字段变更不写入或展示在详情页编辑历史面板中。
- 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。 - 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。
## Wiki 图片上传 ## Wiki 图片上传
@@ -463,10 +472,13 @@
### 喜欢的环境 ### 喜欢的环境
- 名称 - 名称
- Description可为空用于解释该 Ideal Habitat 对 Pokemon 栖息地选择的意义
- Opposite可为空双向关联另一个喜欢的环境作为反义关系每个喜欢的环境最多只能属于一组 Opposite 配对;设置、替换或清空一侧时,系统必须在同一事务中同步维护另一侧
### 喜欢的东西 / 标签 ### 喜欢的东西 / 标签
- 名称 - 名称
- Opposite可为空双向关联另一个喜欢的东西 / 标签作为反义关系;每个喜欢的东西 / 标签最多只能属于一组 Opposite 配对;设置、替换或清空一侧时,系统必须在同一事务中同步维护另一侧
- 同时用于: - 同时用于:
- Pokemon 喜欢的东西 - Pokemon 喜欢的东西
- 物品标签 - 物品标签
@@ -481,13 +493,6 @@
- 名称 - 名称
- 用于栖息地中 Pokemon 出现地点。 - 用于栖息地中 Pokemon 出现地点。
### Life Category
- 名称
- 是否默认选中:最多一个 Life Category 可设为默认;新建 Life Post 时默认选中该分类。
- 是否可评分Rateable Life Category 下的 Life Post 可由用户进行 1-5 星评分。
- 用于 Life Post 分类展示和 Feed 筛选。
### Game Version ### Game Version
- 版本号 / 名称 - 版本号 / 名称
@@ -528,7 +533,6 @@ Pokemon 可配置:
- Speed - Speed
- 出现的栖息地:由栖息地出现配置反向展示 - 出现的栖息地:由栖息地出现配置反向展示
- 翻译 - 翻译
- 排序
普通 Pokemon 与 Event Pokemon 分开展示: 普通 Pokemon 与 Event Pokemon 分开展示:
@@ -585,7 +589,7 @@ Pokemon 列表功能:
- 按喜欢的东西筛选: - 按喜欢的东西筛选:
- 满足任意条件 - 满足任意条件
- 满足全部条件 - 满足全部条件
-自定义排序展示 - Pokopia 展示 ID`display_id`)升序展示;内部 `id` 仅用于路由、外键和稳定排序兜底
- 列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Pokemon。 - 列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Pokemon。
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。 - Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。 - Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
@@ -594,19 +598,17 @@ Pokemon 列表功能:
Pokemon 详情页展示: Pokemon 详情页展示:
- 基本信息 - 基本信息
- 详情主内容在六维 Stats 右侧始终保留正方形图片区;已配置图片时展示居中的 Pokédex 风格图片,未配置图片时展示默认 Poké Ball 占位符;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情 - 标题区不展示 Ideal HabitatIdeal 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.5xNeutral 表示无加成,未配置观察时展示空状态 - Trading当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品Likes 表示交易价格 1.5xNeutral 表示无加成,未配置观察时展示空状态;详情页双列列表高度受限,内容较多时每列内部独立滚动,避免 Section 被大量物品无限拉长
- Trading 可在详情页通过 Manage Trading Modal 维护Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动 - Trading 可在详情页通过 Manage Trading Modal 维护Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;搜索结果优先展示名称精确匹配、名称开头匹配和单词匹配的物品再展示名称包含、分类或用途包含的物品搜索框支持上下键切换当前结果、左右键切换默认加入组、Enter 将当前结果加入默认目标组;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动
- 喜欢的环境 - 参考资料 TabHeight / 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 在桌面端左右并排展示;每项展示物品图标,未配置图标时显示默认物品标记占位符
- 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符 - 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符
- 最后编辑信息 - 最后编辑信息
@@ -645,8 +647,11 @@ Pokemon 详情页展示:
- Road - Road
- 入手方式:可多选 - 入手方式:可多选
- 客制化: - 客制化:
- 染色 - 染色能力:`dyeability`,使用互斥枚举值维护:
-双区染色 - `0`:不可染色
- `1`:可染色
- `2`:可双区染色
- `3`:可三区染色
- 可改花纹 - 可改花纹
- 无材料单:`no_recipe` - 无材料单:`no_recipe`
- 标签:使用喜欢的东西配置,可多选 - 标签:使用喜欢的东西配置,可多选
@@ -882,13 +887,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`
- RatingsRateable Category 下的 Post 支持 1-5 星评分;每个用户每条 Post 最多一条评分,重复评分会替换原评分。
前台行为: 前台行为:
@@ -898,10 +901,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 的评论区,并使用独立分页接口继续加载完整评论列表。
@@ -911,15 +913,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 FeedFollowing Feed 只展示当前用户已 Follow 用户发布且当前用户可见的 Life Post并继续支持搜索、语言、Game Version 和排序筛选
- 登录用户可切换 All Feed 和 Following FeedFollowing Feed 只展示当前用户已 Follow 用户发布且当前用户可见的 Life Post并继续支持 Life Category、语言、Game Version、Rateable 和排序筛选。
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。 - 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
- 当前没有图片上传、转发或置顶。 - 当前没有图片上传、转发或置顶。
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。 - Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
@@ -935,9 +933,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`;不返回点赞用户列表、邮箱、角色、权限或内部审计。
@@ -951,6 +947,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 顶部搜索框预填 TitleTitle 可在创建表单中继续修改。
- Thread 作者本人或拥有现有 Thread 管理权限的管理员可编辑 Thread 标题和 TagsTags 只能选择该 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 / UnfollowFollow 后新审核通过 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 WebSocketWebSocket 使用短期一次性 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`
- 初始化会创建默认 ChannelGeneral、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、编辑表单、管理入口或排序能力
@@ -1004,7 +1114,7 @@ API 暴露边界:
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。 - 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块: - 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
- 配置System config。 - 配置System config。
- 内容Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口以及 Data Tools。 - 内容Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口以及 Data ToolsPokemon 在 Admin 中可删除但不提供手动排序
- 内容管理包含 Items、Event Items 与 Ancient ArtifactsItems / Event Items 使用同一物品数据模型,通过 `is_event_item` 拆分入口。 - 内容管理包含 Items、Event Items 与 Ancient ArtifactsItems / Event Items 使用同一物品数据模型,通过 `is_event_item` 拆分入口。
- 本地化Languages、System wordings。 - 本地化Languages、System wordings。
- 访问权限Users、Roles、Permissions、Rate limits。 - 访问权限Users、Roles、Permissions、Rate limits。
@@ -1041,7 +1151,7 @@ API 暴露边界:
- `favicon.ico` - `favicon.ico`
- 默认社交分享图 - 默认社交分享图
- 品牌 Logo 素材 - 品牌 Logo 素材
- `NUXT_PUBLIC_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`Nuxt 配置仍兼容读取旧的 `VITE_SITE_URL` 作为 fallback。 - `NUXT_PUBLIC_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`
- 前端 Nuxt app head 配置提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon路由 metadata 与详情页公开业务数据通过 Nuxt `useHead` 更新页面 metadata避免直接操作 `document.head` - 前端 Nuxt app head 配置提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon路由 metadata 与详情页公开业务数据通过 Nuxt `useHead` 更新页面 metadata避免直接操作 `document.head`
- 主要公开浏览入口可索引: - 主要公开浏览入口可索引:
- `/pokemon` - `/pokemon`
@@ -1055,23 +1165,35 @@ 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 并进入 sitemapThread 详情页在公开 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` 提供Nuxt 配置仍兼容读取旧的 `VITE_API_BASE_URL` 作为 fallback - 前端浏览器 API base URL 由 `NUXT_PUBLIC_API_BASE_URL` 提供。
- Nuxt 服务端 API base URL 由 `NUXT_SERVER_API_BASE_URL` 提供;在 Docker 内默认使用 `http://backend:3001`,本地非 Docker 运行可使用 `http://localhost:3001`。服务端公开数据读取使用该内部地址,浏览器请求继续使用 `NUXT_PUBLIC_API_BASE_URL` - 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` 代理。 - 前端 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 概览
@@ -1093,11 +1215,17 @@ API 暴露边界:
- `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。
@@ -1170,12 +1298,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 权限控制:
@@ -1188,7 +1336,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`)排序,不提供列表排序 API 或 Admin 手动排序入口
## 开发与验证 ## 开发与验证
@@ -1198,3 +1346,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`

View File

@@ -1,145 +0,0 @@
# SSR Migration Task List
This temporary task list tracks the work required to move the frontend from the current Nuxt SPA migration state to a complete SSR-capable Nuxt deployment.
Keep this file aligned with implementation progress while the SSR migration is in flight. When SSR migration is complete and validated, delete this file and remove the related temporary instruction from `AGENTS.md`.
## Target State
- [x] Nuxt runs with `ssr: true` for production.
- [ ] Public browsing routes render meaningful HTML on the server, including localized metadata and public business data where practical.
- [ ] Authenticated, management, edit, and modal workflows remain functionally equivalent to the current SPA behavior.
- [ ] No password hashes, session token hashes, verification/reset token hashes, role internals, permission internals, audit payloads, debug fields, or implementation notes are exposed through SSR payloads, API responses, generated HTML, metadata, logs, or UI.
- [ ] `DESIGN.md`, Docker configuration, environment variable documentation, and runtime behavior agree.
## Phase 1: Baseline Audit
- [x] Read `DESIGN.md`, `DesignGuidelines.html` when UI behavior is touched, `AGENTS.md`, and this task list before making SSR migration changes.
- [x] Inventory all browser-only access in `frontend/src`, `frontend/app.vue`, `frontend/plugins`, `frontend/middleware`, and `frontend/pages`: `window`, `document`, `localStorage`, `sessionStorage`, DOM measurement, event listeners, timers, clipboard, `confirm`, `matchMedia`, and direct head mutation.
- [x] Classify each browser-only usage as client-only component behavior, SSR-safe fallback behavior, or logic that must be moved into Nuxt composables/plugins.
- [x] Inventory route-level data loading across public list/detail pages, authenticated pages, management pages, and route-backed modal pages.
- [x] Identify public routes that should SSR first: Home, Pokemon/Event Pokemon lists, Habitat/Event Habitat lists, Items/Event Items lists, Ancient Artifacts, Recipes, Daily CheckList, Dish, Life list/detail, Project Updates, legal pages, and public Profile.
- [x] Identify routes that should remain client-only or mostly client-rendered initially: Login, Register, Forgot/Reset Password, Verify Email, Admin, `/profile`, notification UI, upload/edit forms, and route-backed edit/create modals.
### Phase 1 Audit Notes
- Browser-only access is concentrated in client interactions: modal focus/body locking, dropdown positioning, sidebar/mobile drawer behavior, search debounce, infinite-scroll sentinels, upload/download helpers, clipboard copy, local checklist state, route-backed form defaults, WebSocket notifications, moderation update events, and destructive-action `window.confirm` calls. These should stay in mounted/client-only lifecycle paths during SSR enablement.
- SSR-safe fallback candidates already guard storage or DOM access in `frontend/src/i18n.ts`, `frontend/src/services/api.ts`, and several views with `typeof window`, `typeof document`, `typeof localStorage`, `typeof sessionStorage`, or `typeof IntersectionObserver`.
- Logic that must move to SSR-aware Nuxt APIs in later phases: direct SEO mutation in `frontend/src/seo.ts` / `frontend/plugins/02-seo.client.ts`, global Vue I18n singleton request state, auth middleware's storage-only token model, and Nuxt config analytics script injection.
- Current route data loading is client-mounted in views rather than route-level `useAsyncData` / `useFetch`. Public list/detail candidates load through `api.*Page`, `api.*Detail`, `api.dish`, `api.dailyChecklistPage`, `api.lifePosts`, `api.lifePost`, `api.projectUpdates`, and public profile/activity endpoints. Auth, admin, edit/create modal, notification, upload, comment/reaction, and profile account flows depend on client auth state or browser APIs and should not be first-wave SSR data routes.
- First SSR data groups should be low-risk public reads: Home/project update preview, legal/static pages, Pokemon/Event Pokemon lists and details, Habitat/Event Habitat lists and details, Items/Event Items/Ancient Artifacts lists and details, Recipes list/details, Daily CheckList, Dish, Life public feed/detail, Project Updates, and public Profile.
## Phase 2: Runtime Config And API Layer
- [x] Replace client-only API base URL setup with an SSR-safe runtime config helper that works in server and client contexts.
- [x] Define separate public/browser API origin and internal server API origin if Docker networking requires different URLs for server-side fetches and browser fetches.
- [x] Ensure every server-side API read sends the correct `X-Locale` and never sends browser-only bearer tokens unless a secure SSR auth mechanism is implemented.
- [x] Add a small SSR-safe fetch wrapper or adapt `frontend/src/services/api.ts` so public reads can be called from server-side setup without depending on `window`, storage, or DOM APIs.
- [x] Keep frontend API response types consistent with `frontend/src/services/api.ts`.
- [ ] Ensure API errors used for SSR public routes degrade to intended empty/error states without leaking stack traces or internal fields into rendered HTML.
## Phase 3: Authentication And Session Model
- [x] Decide and document the SSR-compatible auth model in `DESIGN.md` before implementation.
- [x] Migrate auth from `localStorage` / `sessionStorage` bearer-token-only behavior to an HTTP-only cookie/session model, or explicitly document a hybrid model if Remember me must preserve current storage behavior.
- [x] Update backend login/logout/session endpoints to support the chosen cookie/session model without exposing session token hashes or internal session metadata.
- [x] Preserve Remember me semantics: 1 day for non-remembered sessions, 30 days for remembered sessions.
- [x] Preserve email verification as the base requirement for protected writes.
- [ ] Ensure current-user SSR reads expose only the allowed current-user fields defined in `DESIGN.md`.
- [ ] Update route middleware so server-side redirects for authenticated and permissioned routes match current client-side behavior.
- [ ] Ensure public SSR pages never render private current-user data into HTML meant for anonymous users.
- [x] Add a clear logout flow that clears both server cookies and any legacy client storage during the transition.
### Phase 3 Auth Notes
- The migration now uses a hybrid session model: backend login sets an HTTP-only `pokopia_session` cookie and still returns the legacy bearer token so existing SPA storage behavior keeps working during the transition.
- Protected backend reads and writes accept the HTTP-only cookie first and remain compatible with `Authorization: Bearer` tokens.
- Frontend API requests use `credentials: 'include'` so browser requests can carry the cookie without exposing it to JavaScript.
- Login still stores the legacy token according to Remember me semantics; logout deletes the server session, clears the cookie, and clears legacy frontend storage.
## Phase 4: Nuxt SSR Enablement
- [x] Change Nuxt config from `ssr: false` to `ssr: true` only after browser-only usage and auth strategy are ready.
- [ ] Split plugins by runtime where needed: `.client.ts` for DOM/event/storage logic, `.server.ts` for SSR-only initialization, and universal plugins only for code safe in both contexts.
- [x] Ensure Vue I18n is installed safely for SSR and does not share mutable per-request state across users.
- [x] Move direct `document.head` SEO mutation to Nuxt `useHead` / `useSeoMeta` or another SSR-aware head strategy.
- [x] Ensure route metadata remains the source for default SEO, required auth, required permission, editor modal behavior, and noindex rules.
- [ ] Confirm route-backed modal pages still preserve underlying page context and avoid unwanted scroll jumps.
- [ ] Keep UI business text localized through Vue I18n/system wordings; do not add implementation notes or debug text to the UI.
### Phase 4 SEO Notes
- `frontend/src/seo.ts` now resolves SEO state without mutating `document.head` or `document.title`.
- `frontend/plugins/02-seo.ts` is a universal Nuxt plugin that binds route metadata and client-side detail overrides to `useHead`.
- The Nuxt config analytics script is declarative and no longer injects a script with `document.head.appendChild`.
### Phase 4 I18n Notes
- `frontend/src/i18n.ts` now exports a Vue I18n factory instead of a module-level singleton.
- `frontend/plugins/01-i18n.ts` creates and installs one I18n instance per Nuxt app/request; only the browser instance is registered for legacy helpers that need localStorage and locale-change events.
- SEO route metadata translation uses the current Nuxt app's I18n translator instead of importing a shared global I18n instance.
### Phase 4 SSR Config Notes
- `frontend/nuxt.config.ts` now uses `ssr: true`.
- `pnpm --filter @pokopia/frontend build` completed with Nuxt SSR enabled and generated Nuxt server output at `.output/server/index.mjs`.
- Production container now targets the Nuxt server entry point; Docker runtime validation remains tracked in Phase 8.
## Phase 5: Server-Side Data And SEO
- [ ] Implement SSR data loading for stable public routes in small groups, starting with low-risk public pages.
- [ ] For each SSR-enabled public route, render title, description, canonical URL, robots value, Open Graph, Twitter card, and structured data from public business data and system wording only.
- [ ] For detail pages, use entity names, public images, localized public fields, and canonical detail URLs after public API data loads server-side.
- [ ] Preserve `noindex` on auth, admin, new, edit, and in-development routes.
- [ ] Keep `robots.txt` and `sitemap.xml` generated from the same stable public route set documented in `DESIGN.md`.
- [ ] Avoid serializing private auth state, raw permissions, internal audit payloads, or unneeded API payload fields into Nuxt payloads.
- [ ] Confirm localized reads follow the fallback order in `DESIGN.md`: requested locale, default-language translation, base field.
## Phase 6: Browser-Only UI Isolation
- [ ] Move DOM event listeners, resize/scroll handlers, focus traps, modal body locking, clipboard behavior, and `window.confirm` calls into client-only lifecycle paths.
- [ ] Ensure components with DOM measurement render stable SSR placeholders or no-op behavior until mounted.
- [ ] Keep loading states as Skeleton loaders where existing page patterns support them.
- [ ] Validate that notification WebSocket setup only runs on the client and never during SSR.
- [ ] Validate upload widgets and file APIs only run on the client.
- [ ] Ensure route transitions and scroll behavior remain consistent with the current route-backed modal rules.
## Phase 7: Docker And Deployment
- [x] Update frontend Docker image from static `.output/public` serving to Nuxt server output when SSR is enabled.
- [ ] Run the production container with the Nuxt server entry point rather than the current static server.
- [x] Update `frontend_gateway` proxy behavior if SSR server health, fallback, or cache behavior changes.
- [x] Document required environment variables, including public browser API URL, internal server API URL, site URL, and any cookie/session settings.
- [x] Keep the upgrade maintenance page independent from Nuxt, backend API, and database.
- [x] Preserve public frontend port `20015` unless `DESIGN.md` and compose configuration are intentionally updated together.
### Phase 7 Deployment Notes
- `frontend/package.json` now uses `nuxt build` so the production build emits Nitro server output.
- `frontend/Dockerfile` now runs `node .output/server/index.mjs` with `HOST=0.0.0.0` and `PORT=20015`; the obsolete lightweight static server file was removed.
- `frontend_gateway` continues to proxy `frontend:20015` and keep the backend health-gated maintenance fallback independent from Nuxt.
- `DESIGN.md` now documents the Nuxt server output deployment model and the existing browser API, server API, site URL, origin, and proxy environment variables.
- A local smoke check of `node frontend/.output/server/index.mjs` on port `20115` returned SSR HTML for `/` and `200` for `/robots.txt`; Docker compose runtime validation is still pending.
## Phase 8: Validation
- [x] Run `pnpm --filter @pokopia/frontend typecheck`.
- [x] Run `pnpm --filter @pokopia/frontend lint`.
- [x] Run `pnpm --filter @pokopia/frontend build`.
- [ ] Do not run tests in WSL unless explicitly requested.
- [ ] Ask the user to run `docker compose up --build` for runtime validation, then fix any pasted Docker output in follow-up passes.
- [ ] Verify anonymous SSR HTML for public routes includes meaningful public content and metadata.
- [ ] Verify authenticated routes redirect correctly when unauthenticated, unverified, or missing permissions.
- [ ] Verify logged-in flows still work after hydration: login, logout, Remember me, Profile, notifications, create/edit modals, uploads, comments, reactions, and admin access.
- [ ] Verify generated HTML and Nuxt payloads do not contain forbidden internal data.
- [ ] Verify `robots.txt`, `sitemap.xml`, canonical URLs, noindex routes, and public detail metadata.
## Phase 9: Cleanup
- [ ] Remove legacy SPA-only compatibility paths once SSR behavior is stable and no longer needed.
- [x] Remove obsolete static server usage if the production frontend container runs the Nuxt server.
- [ ] Remove obsolete `VITE_*` fallback support only after deployment configuration has fully moved to `NUXT_PUBLIC_*` or documented replacement variables.
- [ ] Update `DESIGN.md` from "Nuxt SPA mode" to the final SSR deployment model.
- [ ] Update `AGENTS.md` frontend stack and workflow notes to the final SSR state.
- [ ] Delete `SSR_MIGRATION_TASKLIST.md`.
- [ ] Remove the temporary `AGENTS.md` instruction that requires reading and maintaining this task list.

View File

@@ -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 (
@@ -231,7 +258,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 +293,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 +311,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 +371,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 +406,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 +462,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 +489,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 +565,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 +582,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 +603,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 +724,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 +755,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 +772,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 +799,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 +864,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 +1085,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 (
@@ -905,6 +1172,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,
@@ -1078,10 +1346,9 @@ 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 INDEX IF NOT EXISTS pokemon_display_order_idx ON pokemon(is_event_item, display_id, id);
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item); CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item);
CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id);
CREATE UNIQUE INDEX IF NOT EXISTS life_tags_single_default_idx ON life_tags(is_default) WHERE is_default = true;
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 +1509,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 +1539,67 @@ 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));
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;
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);

View File

@@ -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') {
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') { if (status === 'approved') {
await createApprovedCommentNotification(target); await createApprovedCommentNotification(notificationTarget);
} }
} catch (error) { } catch (error) {
logger?.warn( logger?.warn(

View File

@@ -85,6 +85,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 +154,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 +567,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,
@@ -1275,9 +1319,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 +1389,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>,

View File

@@ -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

View File

@@ -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,14 +71,18 @@ 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,
@@ -81,6 +93,7 @@ import {
getPokemon, getPokemon,
getPublicUserProfile, getPublicUserProfile,
getRecipe, getRecipe,
getThread,
globalSearch, globalSearch,
importAdminData, importAdminData,
importAdminHabitatsCsv, importAdminHabitatsCsv,
@@ -88,6 +101,7 @@ import {
isConfigType, isConfigType,
listAncientArtifacts, listAncientArtifacts,
listEntityDiscussionComments, listEntityDiscussionComments,
listAdminThreadChannels,
listConfig, listConfig,
listDailyChecklistItems, listDailyChecklistItems,
listHabitats, listHabitats,
@@ -100,6 +114,9 @@ import {
listPokemon, listPokemon,
listPokemonFetchOptions, listPokemonFetchOptions,
listRecipes, listRecipes,
listThreadChannels,
listThreadMessages,
listThreads,
listUserCommentActivities, listUserCommentActivities,
listUserLifePosts, listUserLifePosts,
listUserReactionActivities, listUserReactionActivities,
@@ -111,16 +128,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,
@@ -132,7 +151,12 @@ import {
updateLifePost, updateLifePost,
updatePokemon, updatePokemon,
updateRecipe, updateRecipe,
updateAdminThreadChannel,
updateThread,
updateThreadLock,
updateThreadMessage,
unfollowUser, unfollowUser,
unfollowThread,
wipeAdminData wipeAdminData
} from './queries.ts'; } from './queries.ts';
import { import {
@@ -161,6 +185,7 @@ 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,
@@ -185,7 +210,7 @@ function configuredCorsOrigin(): true | string | string[] {
} }
await app.register(cors, { await app.register(cors, {
allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'], allowedHeaders: ['Content-Type', 'X-Locale'],
credentials: true, credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
origin: configuredCorsOrigin() origin: configuredCorsOrigin()
@@ -247,11 +272,6 @@ 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 {
const [scheme, token] = authorization?.split(' ') ?? [];
return scheme === 'Bearer' && token ? token : null;
}
function getCookieValue(cookieHeader: string | undefined, name: string): string | null { function getCookieValue(cookieHeader: string | undefined, name: string): string | null {
if (!cookieHeader) { if (!cookieHeader) {
return null; return null;
@@ -272,7 +292,7 @@ function getCookieValue(cookieHeader: string | undefined, name: string): string
} }
function getSessionToken(request: FastifyRequest): string | null { function getSessionToken(request: FastifyRequest): string | null {
return getCookieValue(request.headers.cookie, sessionCookieName) ?? getBearerToken(request.headers.authorization); return getCookieValue(request.headers.cookie, sessionCookieName);
} }
function sessionCookieSecure(): boolean { function sessionCookieSecure(): boolean {
@@ -1038,7 +1058,7 @@ app.post('/api/auth/login', async (request, reply) => {
const payload = request.body as Record<string, unknown>; const payload = request.body as Record<string, unknown>;
const response = await loginUser(payload, requestLocale(request)); const response = await loginUser(payload, requestLocale(request));
setSessionCookie(reply, response.token, payload.rememberMe === true); setSessionCookie(reply, response.token, payload.rememberMe === true);
return response; return { user: response.user };
}); });
app.post('/api/auth/request-password-reset', async (request, reply) => { app.post('/api/auth/request-password-reset', async (request, reply) => {
@@ -1072,6 +1092,47 @@ 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;
@@ -1483,26 +1544,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,
@@ -1695,6 +1736,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))
); );
@@ -2097,11 +2304,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;
@@ -2226,6 +2428,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) {
@@ -2297,6 +2562,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);

View 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');
}
});
}

108
docker-compose.debug.yml Normal file
View File

@@ -0,0 +1,108 @@
services:
postgres:
image: postgres:18-alpine
environment:
POSTGRES_DB: pokopia
POSTGRES_USER: pokopia
POSTGRES_PASSWORD: pokopia
volumes:
- postgres18_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pokopia -d pokopia"]
interval: 5s
timeout: 3s
retries: 10
deps:
image: node:22-alpine
working_dir: /app
environment:
PNPM_HOME: /pnpm
volumes:
- .:/app
- root_node_modules:/app/node_modules
- backend_node_modules:/app/backend/node_modules
- frontend_node_modules:/app/frontend/node_modules
- pnpm_store:/pnpm/store
command: >
sh -lc "corepack enable &&
corepack prepare pnpm@10.33.2 --activate &&
pnpm config set store-dir /pnpm/store &&
pnpm install --frozen-lockfile"
backend:
image: node:22-alpine
working_dir: /app
environment:
NODE_ENV: development
PNPM_HOME: /pnpm
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
BACKEND_PORT: 3001
TRUST_PROXY: ${TRUST_PROXY:-false}
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:20015}
APP_ORIGIN: ${APP_ORIGIN:-http://localhost:20015}
UPLOAD_DIR: /app/uploads
BACKEND_PUBLIC_ORIGIN: ${BACKEND_PUBLIC_ORIGIN:-http://localhost:20016}
RESEND_API_KEY: ${RESEND_API_KEY:-}
EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki <onboarding@resend.dev>}"
RESEND_DAILY_QUOTA_LIMIT: ${RESEND_DAILY_QUOTA_LIMIT:-100}
RESEND_MONTHLY_QUOTA_LIMIT: ${RESEND_MONTHLY_QUOTA_LIMIT:-3000}
RESEND_QUOTA_RESERVE: ${RESEND_QUOTA_RESERVE:-5}
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES: ${RESEND_QUOTA_SNAPSHOT_TTL_MINUTES:-10}
AI_MODERATION_API_KEY: ${AI_MODERATION_API_KEY:-}
ports:
- "20016:3001"
volumes:
- .:/app
- root_node_modules:/app/node_modules
- backend_node_modules:/app/backend/node_modules
- frontend_node_modules:/app/frontend/node_modules
- pnpm_store:/pnpm/store
- backend_uploads:/app/uploads
command: >
sh -lc "corepack enable &&
corepack prepare pnpm@10.33.2 --activate &&
pnpm --filter @pokopia/backend dev"
depends_on:
deps:
condition: service_completed_successfully
postgres:
condition: service_healthy
frontend:
image: node:22-alpine
working_dir: /app
environment:
NODE_ENV: development
PNPM_HOME: /pnpm
HOST: 0.0.0.0
PORT: 20015
CHOKIDAR_USEPOLLING: "true"
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-http://localhost:20015}
ports:
- "20015:20015"
volumes:
- .:/app
- root_node_modules:/app/node_modules
- backend_node_modules:/app/backend/node_modules
- frontend_node_modules:/app/frontend/node_modules
- pnpm_store:/pnpm/store
command: >
sh -lc "corepack enable &&
corepack prepare pnpm@10.33.2 --activate &&
pnpm --filter @pokopia/frontend dev"
depends_on:
deps:
condition: service_completed_successfully
backend:
condition: service_started
volumes:
postgres18_data:
backend_uploads:
root_node_modules:
backend_node_modules:
frontend_node_modules:
pnpm_store:

View File

@@ -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

View File

@@ -18,14 +18,16 @@ import {
iconLife, iconLife,
iconPokemon, iconPokemon,
iconRecipe, iconRecipe,
iconThreads,
type AppIcon type AppIcon
} from './src/icons'; } from './src/icons';
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n'; import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './src/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 }
@@ -101,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')) {
@@ -117,9 +120,6 @@ async function loadCurrentUser() {
currentUser.value = response.user; currentUser.value = response.user;
} catch { } catch {
currentUser.value = null; currentUser.value = null;
if (getAuthToken()) {
setAuthToken(null);
}
} }
} }
@@ -131,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();
@@ -160,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(() => {
@@ -180,7 +195,9 @@ 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"
> >
<NuxtPage :key="locale" /> <NuxtPage :key="locale" />

View File

@@ -1,4 +1,4 @@
import { api, setAuthToken } from '../src/services/api'; import { api } from '../src/services/api';
export default defineNuxtRouteMiddleware(async (to) => { export default defineNuxtRouteMiddleware(async (to) => {
const requiredPermissions = to.matched const requiredPermissions = to.matched
@@ -17,7 +17,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
} }
try { try {
const response = await api.me(); const response = await api.me(import.meta.server ? { headers: useRequestHeaders(['cookie']) } : undefined);
if (requiresVerified && !response.user.emailVerified) { if (requiresVerified && !response.user.emailVerified) {
return navigateTo({ path: '/login', query: { redirect: to.fullPath } }); return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
} }
@@ -30,7 +30,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
return navigateTo('/pokemon'); return navigateTo('/pokemon');
} }
} catch { } catch {
setAuthToken(null);
return navigateTo({ path: '/login', query: { redirect: to.fullPath } }); return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
} }
}); });

View File

@@ -12,13 +12,11 @@ export default defineNuxtConfig({
runtimeConfig: { runtimeConfig: {
serverApiBaseUrl: serverApiBaseUrl:
process.env.NUXT_SERVER_API_BASE_URL ?? process.env.NUXT_SERVER_API_BASE_URL ??
process.env.NUXT_API_BASE_URL ??
process.env.NUXT_PUBLIC_API_BASE_URL ?? process.env.NUXT_PUBLIC_API_BASE_URL ??
process.env.VITE_API_BASE_URL ??
'http://localhost:3001', 'http://localhost:3001',
public: { public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? process.env.VITE_API_BASE_URL ?? 'http://localhost:3001', apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3001',
siteUrl: normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL) siteUrl: normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL)
} }
}, },
app: { app: {
@@ -30,35 +28,10 @@ export default defineNuxtConfig({
meta: [ meta: [
{ charset: 'utf-8' }, { charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0' }, { name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
{ { name: 'theme-color', content: '#6ccf32' }
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.'
},
{ name: 'robots', content: 'index, follow' },
{ name: 'theme-color', content: '#6ccf32' },
{ property: 'og:site_name', content: 'Pokopia Wiki' },
{ property: 'og:type', content: 'website' },
{ property: 'og:title', content: 'Pokopia Wiki - Pokemon Pokopia Guide' },
{
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.'
},
{ property: 'og:image', content: `${normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)}/seo/pokopia-hero.jpg` },
{ property: 'og:locale', content: 'en_US' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: 'Pokopia Wiki - Pokemon Pokopia Guide' },
{
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.'
},
{ name: 'twitter:image', content: `${normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)}/seo/pokopia-hero.jpg` }
], ],
link: [ link: [
{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' }, { rel: 'icon', href: '/favicon.ico', sizes: '32x32' }
{ rel: 'canonical', href: `${normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)}/pokemon` }
], ],
script: [ script: [
{ {

View 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>

View 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>

View File

@@ -1,6 +1,6 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { onLocaleChange } from '../src/i18n'; import { onLocaleChange } from '../src/i18n';
import { applyRouteSeo, onSeoChange, resolveRouteSeo, setSeoTranslator, type ResolvedSeoConfig } from '../src/seo'; import { applyRouteSeo, onSeoChange, resolvedSeoHead, resolveRouteSeo, setSeoTranslator, type ResolvedSeoConfig } from '../src/seo';
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
const router = useRouter(); const router = useRouter();
@@ -8,37 +8,7 @@ export default defineNuxtPlugin(() => {
const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t; const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
const dynamicSeo = ref<ResolvedSeoConfig | null>(null); const dynamicSeo = ref<ResolvedSeoConfig | null>(null);
const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t)); const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t));
useHead(() => resolvedSeoHead(activeSeo.value));
useHead(() => ({
title: activeSeo.value.title,
htmlAttrs: {
lang: activeSeo.value.locale
},
meta: [
{ key: 'description', name: 'description', content: activeSeo.value.description },
{ key: 'robots', name: 'robots', content: activeSeo.value.robots },
{ key: 'twitter-card', name: 'twitter:card', content: 'summary_large_image' },
{ key: 'twitter-title', name: 'twitter:title', content: activeSeo.value.title },
{ key: 'twitter-description', name: 'twitter:description', content: activeSeo.value.description },
{ key: 'twitter-image', name: 'twitter:image', content: activeSeo.value.imageUrl },
{ key: 'og-site-name', property: 'og:site_name', content: 'Pokopia Wiki' },
{ key: 'og-type', property: 'og:type', content: 'website' },
{ key: 'og-title', property: 'og:title', content: activeSeo.value.title },
{ key: 'og-description', property: 'og:description', content: activeSeo.value.description },
{ key: 'og-url', property: 'og:url', content: activeSeo.value.canonicalUrl },
{ key: 'og-image', property: 'og:image', content: activeSeo.value.imageUrl },
{ key: 'og-locale', property: 'og:locale', content: activeSeo.value.locale === 'en' ? 'en_US' : activeSeo.value.locale.replace('-', '_') }
],
link: [{ key: 'canonical', rel: 'canonical', href: activeSeo.value.canonicalUrl }],
script: [
{
key: 'pokopia-structured-data',
id: 'pokopia-structured-data',
type: 'application/ld+json',
children: JSON.stringify(activeSeo.value.structuredData)
}
]
}));
if (import.meta.server) { if (import.meta.server) {
return; return;

View 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;
}

View 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));
});

View 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));
});

View 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));
});

View 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));
});

View 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));
});

View 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));
});

View File

@@ -1,7 +1,7 @@
import { normalizeSiteUrl, sitemapXml } from '../utils/seo-files'; import { normalizeSiteUrl, sitemapIndexXml } from '../utils/seo-files';
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const config = useRuntimeConfig(event); const config = useRuntimeConfig(event);
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8'); setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
return sitemapXml(normalizeSiteUrl(config.public.siteUrl)); return sitemapIndexXml(normalizeSiteUrl(config.public.siteUrl));
}); });

View File

@@ -1,17 +1,58 @@
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com'; const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
const fallbackApiBaseUrl = 'http://localhost:3001';
const staticLastmod = new Date().toISOString();
const sitemapPageSize = 72;
const sitemapPaths = [ type ChangeFrequency = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
'/pokemon',
'/event-pokemon', export type SitemapUrl = {
'/habitats', path: string;
'/event-habitats', lastmod?: string | null;
'/items', changefreq?: ChangeFrequency;
'/event-items', priority?: number;
'/ancient-artifacts', };
'/recipes',
'/dish', type SitemapEntity = {
'/checklist', id: number;
'/life' 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 = [ const robotsDisallowPaths = [
@@ -45,24 +86,188 @@ export function normalizeSiteUrl(value: unknown): string {
return (typeof value === 'string' && value.trim() ? value.trim() : fallbackSiteUrl).replace(/\/+$/, ''); 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 { export function robotsTxt(siteUrl: string): string {
const disallowLines = robotsDisallowPaths.map((path) => `Disallow: ${path}`).join('\n'); const disallowLines = robotsDisallowPaths.map((path) => `Disallow: ${path}`).join('\n');
return `User-agent: *\nAllow: /\n${disallowLines}\nSitemap: ${siteUrl}/sitemap.xml\n`; return `User-agent: *\nAllow: /\n${disallowLines}\nSitemap: ${siteUrl}/sitemap.xml\n`;
} }
export function sitemapXml(siteUrl: string): string { export function sitemapIndexXml(siteUrl: string): string {
const urls = sitemapPaths const sitemaps = sitemapFiles
.map( .map(
(path) => ` <url> (path) => ` <sitemap>
<loc>${siteUrl}${path}</loc> <loc>${xmlEscape(siteUrl + path)}</loc>
<changefreq>weekly</changefreq> <lastmod>${formatLastmod(staticLastmod)}</lastmod>
</url>` </sitemap>`
) )
.join('\n'); .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"?> return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls} ${body}
</urlset> </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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

View File

@@ -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')">

View 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>

View File

@@ -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,14 @@ 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',
'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 +85,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 +117,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 +178,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>

View File

@@ -2,9 +2,15 @@
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import type { EditInfo } from '../services/api'; import type { EditInfo } from '../services/api';
defineProps<{ withDefaults(
entity: EditInfo; defineProps<{
}>(); entity: EditInfo;
showLabel?: boolean;
}>(),
{
showLabel: true
}
);
const { locale, t } = useI18n(); const { locale, t } = useI18n();
@@ -18,11 +24,11 @@ function formatDateTime(value: string): string {
<template> <template>
<p class="edit-meta"> <p class="edit-meta">
{{ t('history.lastEdited') }}: <template v-if="showLabel">{{ t('history.lastEdited') }}: </template>
<RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`"> <RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`">
{{ entity.updatedBy.displayName }} {{ entity.updatedBy.displayName }}
</RouterLink> </RouterLink>
<span v-else>{{ t('common.system') }}</span> <span v-else>{{ t('common.system') }}</span>
/ {{ formatDateTime(entity.updatedAt) }} / <time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
</p> </p>
</template> </template>

View File

@@ -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>

View File

@@ -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);

View File

@@ -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;
} }

View File

@@ -1,29 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
export type SwitchGroupOption = { export type SwitchGroupOption = {
value: string; value: string | number;
label: string; label: string;
description?: string;
disabled?: boolean;
}; };
const props = defineProps<{ const props = defineProps<{
id: string; id: string;
label: string; label: string;
modelValue: string[]; modelValue: Array<string | number>;
options: SwitchGroupOption[]; options: SwitchGroupOption[];
layout?: 'inline' | 'grid';
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: string[]]; 'update:modelValue': [value: Array<string | number>];
}>(); }>();
function optionId(index: number) { function optionId(index: number) {
return `${props.id}-${index}`; return `${props.id}-${index}`;
} }
function isSelected(value: string) { function isSelected(value: string | number) {
return props.modelValue.includes(value); return props.modelValue.includes(value);
} }
function updateOption(value: string, event: Event) { function updateOption(value: string | number, event: Event) {
if (!(event.target instanceof HTMLInputElement)) return; if (!(event.target instanceof HTMLInputElement)) return;
const { checked } = event.target; const { checked } = event.target;
@@ -43,14 +46,23 @@ function updateOption(value: string, event: Event) {
<template> <template>
<fieldset class="switch-group"> <fieldset class="switch-group">
<legend>{{ label }}</legend> <legend>{{ label }}</legend>
<div class="switch-group__options"> <div class="switch-group__options" :class="{ 'switch-group__options--grid': layout === 'grid' }">
<label v-for="(option, index) in options" :key="option.value" class="switch-control switch-control--stacked"> <label
<span class="switch-control__label">{{ option.label }}</span> v-for="(option, index) in options"
:key="option.value"
class="switch-control switch-control--stacked"
:class="{ 'switch-control--disabled': option.disabled }"
>
<span class="switch-control__copy">
<span class="switch-control__label">{{ option.label }}</span>
<span v-if="option.description" class="switch-control__description">{{ option.description }}</span>
</span>
<input <input
:id="optionId(index)" :id="optionId(index)"
type="checkbox" type="checkbox"
:checked="isSelected(option.value)" :checked="isSelected(option.value)"
:value="option.value" :value="option.value"
:disabled="option.disabled"
@change="updateOption(option.value, $event)" @change="updateOption(option.value, $event)"
/> />
<span class="switch-track" aria-hidden="true"></span> <span class="switch-track" aria-hidden="true"></span>

View File

@@ -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>{{ option.label }}</span> <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>
<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') }}

View 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>

View File

@@ -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';

View File

@@ -9,7 +9,7 @@ const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
let runtimeSiteUrl: string | null = null; let runtimeSiteUrl: string | null = null;
type TranslationValues = Record<string, string | number>; type TranslationValues = Record<string, string | number>;
type Translator = (key: string, values?: TranslationValues) => string; export type Translator = (key: string, values?: TranslationValues) => string;
export type RouteSeoConfig = { export type RouteSeoConfig = {
title?: string; title?: string;
@@ -27,6 +27,8 @@ export type SeoConfig = {
canonicalPath?: string; canonicalPath?: string;
image?: string | null; image?: string | null;
noindex?: boolean; noindex?: boolean;
openGraphType?: 'website' | 'article';
structuredData?: Record<string, unknown>;
}; };
export type ResolvedSeoConfig = { export type ResolvedSeoConfig = {
@@ -36,9 +38,21 @@ export type ResolvedSeoConfig = {
imageUrl: string; imageUrl: string;
robots: string; robots: string;
locale: string; locale: string;
openGraphType: 'website' | 'article';
structuredData: Record<string, unknown>; 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>; const messages = systemWordingMessages as unknown as Record<string, SystemWordingTree>;
let activeTranslator: Translator | null = null; let activeTranslator: Translator | null = null;
let currentSeo: ResolvedSeoConfig | null = null; let currentSeo: ResolvedSeoConfig | null = null;
@@ -120,6 +134,7 @@ export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath); const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath);
const robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow'; const robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow';
const locale = getCurrentLocale(); const locale = getCurrentLocale();
const openGraphType = config.openGraphType ?? 'website';
return { return {
title, title,
@@ -128,7 +143,8 @@ export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
imageUrl, imageUrl,
robots, robots,
locale, locale,
structuredData: { openGraphType,
structuredData: config.structuredData ?? {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'WebPage', '@type': 'WebPage',
name: title, name: title,
@@ -143,19 +159,59 @@ export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
}; };
} }
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 { 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)
);
return { return {
title: routeSeo?.titleKey ? translateSeo(routeSeo.titleKey, undefined, translator) : routeSeo?.title, title: routeSeo?.titleKey ? translateSeo(routeSeo.titleKey, undefined, translator) : routeSeo?.title,
description: routeSeo?.descriptionKey ? translateSeo(routeSeo.descriptionKey, undefined, translator) : 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
}; };
} }
@@ -179,3 +235,39 @@ export function applySeo(config: SeoConfig = {}): void {
export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void { export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void {
applySeo(routeSeoConfig(route)); 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')
}
}
};
}

View File

@@ -2,9 +2,13 @@ import { getCurrentLocale } from '../i18n';
let browserApiBaseUrl = 'http://localhost:3001'; let browserApiBaseUrl = 'http://localhost:3001';
let serverApiBaseUrl = 'http://localhost:3001'; let serverApiBaseUrl = 'http://localhost:3001';
const authTokenKey = 'pokopia_auth_token';
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>>>;
@@ -67,14 +71,11 @@ export interface NamedEntity {
id: number; id: number;
name: string; name: string;
baseName?: string; baseName?: 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;
} }
@@ -235,9 +236,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 {
@@ -321,8 +322,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;
@@ -491,11 +491,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;
@@ -512,11 +508,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 {
@@ -572,6 +566,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;
@@ -652,7 +766,6 @@ 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[];
} }
@@ -664,6 +777,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 {
@@ -802,7 +921,6 @@ export interface RegisterPayload extends LoginPayload {
} }
export interface AuthResponse { export interface AuthResponse {
token: string;
user: AuthUser; user: AuthUser;
} }
@@ -813,7 +931,6 @@ export type ConfigType =
| 'favorite-things' | 'favorite-things'
| 'acquisition-methods' | 'acquisition-methods'
| 'maps' | 'maps'
| 'life-tags'
| 'game-versions' | 'game-versions'
| 'dish-flavors'; | 'dish-flavors';
@@ -870,8 +987,7 @@ 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;
@@ -938,7 +1054,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;
} }
@@ -1056,40 +1171,7 @@ 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') {
return null;
}
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 {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return () => {}; return () => {};
} }
@@ -1104,12 +1186,10 @@ 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 {
@@ -1121,6 +1201,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 };
@@ -1134,11 +1223,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 {
if (!options) {
return {};
}
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), { const response = await fetch(apiUrl(path), {
credentials: 'include', credentials: 'include',
headers: requestHeaders(), headers: requestHeaders(requestOptions.headers),
signal signal: requestOptions.signal
}); });
if (!response.ok) { if (!response.ok) {
@@ -1148,14 +1250,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 headers = requestHeaders();
headers.set('Content-Type', 'application/json');
const response = await fetch(apiUrl(path), { const response = await fetch(apiUrl(path), {
credentials: 'include', credentials: 'include',
method, method,
headers: { headers,
'Content-Type': 'application/json',
...requestHeaders()
},
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
@@ -1261,7 +1363,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),
@@ -1287,10 +1392,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
})}` })}`
), ),
@@ -1348,10 +1451,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
})}` })}`
), ),
@@ -1373,9 +1474,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 = {}) =>
@@ -1441,20 +1589,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; 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; 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)}`),
@@ -1479,7 +1627,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 = {}) =>

View File

@@ -100,6 +100,18 @@ svg {
flex: 0 0 auto; flex: 0 0 auto;
} }
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
:focus-visible { :focus-visible {
outline: 3px solid var(--focus); outline: 3px solid var(--focus);
outline-offset: 3px; outline-offset: 3px;
@@ -358,6 +370,42 @@ svg {
padding: 0; padding: 0;
} }
.view-as-banner {
position: sticky;
top: 64px;
z-index: 44;
grid-column: 2;
border-bottom: 1px solid rgba(31, 42, 59, 0.14);
background: #fff7cc;
}
.view-as-banner__inner {
width: min(100%, var(--container));
min-height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin: 0 auto;
padding: 8px 24px;
}
.view-as-banner__label {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--pokemon-blue-deep);
font-size: 14px;
font-weight: 900;
line-height: 1.25;
overflow-wrap: anywhere;
}
.view-as-banner__button {
flex: 0 0 auto;
}
.site-sidebar { .site-sidebar {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -1318,6 +1366,11 @@ svg {
--btn-fg: #ffffff; --btn-fg: #ffffff;
} }
.link-button--danger {
--btn-bg: var(--danger);
--btn-fg: #ffffff;
}
.ui-button--ghost, .ui-button--ghost,
.plain-button, .plain-button,
.row-actions button, .row-actions button,
@@ -1797,15 +1850,13 @@ button:disabled,
min-height: 220px; min-height: 220px;
display: grid; display: grid;
place-items: center; place-items: center;
border: 2px solid rgba(23, 32, 54, 0.18); border: 0;
border-radius: var(--radius-card); border-radius: var(--radius-card);
background: background: transparent;
linear-gradient(135deg, rgba(255, 203, 5, 0.24), rgba(42, 117, 187, 0.12)),
#ffffff;
} }
.pokemon-image-preview__screen img { .pokemon-image-preview__screen img {
width: min(100%, 360px); width: 100%;
max-height: 220px; max-height: 220px;
object-fit: contain; object-fit: contain;
} }
@@ -2036,10 +2087,18 @@ button:disabled,
} }
.tags-select__single-value { .tags-select__single-value {
display: block; display: flex;
align-items: center;
gap: 8px;
min-width: 0; min-width: 0;
max-width: 100%;
overflow: hidden; overflow: hidden;
color: var(--ink); color: var(--ink);
}
.tags-select__single-value span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -2049,6 +2108,7 @@ button:disabled,
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
min-width: 0;
min-height: 28px; min-height: 28px;
padding: 4px 8px; padding: 4px 8px;
border: 1px solid rgba(42, 117, 187, 0.28); border: 1px solid rgba(42, 117, 187, 0.28);
@@ -2059,6 +2119,27 @@ button:disabled,
font-weight: 850; font-weight: 850;
} }
.tags-select__tag > span:first-of-type {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.tags-select__thumb {
flex: 0 0 auto;
width: 28px;
height: 28px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--surface-soft);
object-fit: contain;
}
.tags-select__thumb--tag {
width: 20px;
height: 20px;
}
.tags-select__remove { .tags-select__remove {
min-width: 18px; min-width: 18px;
min-height: 18px; min-height: 18px;
@@ -2140,6 +2221,20 @@ button:disabled,
cursor: pointer; cursor: pointer;
} }
.tags-select__option-label {
display: flex;
align-items: center;
flex: 1 1 auto;
gap: 8px;
min-width: 0;
}
.tags-select__option-label span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.tags-select__option:hover, .tags-select__option:hover,
.tags-select__option.active, .tags-select__option.active,
.tags-select__option.selected { .tags-select__option.selected {
@@ -2422,11 +2517,11 @@ button:disabled,
font-weight: 950; font-weight: 950;
} }
.entity-card__mark--image { .entity-card__mark.entity-card__mark--image {
padding: 3px; padding: 0;
background: border: 0;
linear-gradient(135deg, rgba(255, 203, 5, 0.22), rgba(42, 117, 187, 0.12)), background: transparent;
#ffffff; box-shadow: none;
} }
.entity-card__icon { .entity-card__icon {
@@ -2762,6 +2857,14 @@ button:disabled,
font-weight: 850; font-weight: 850;
} }
.confirm-dialog__message {
margin: 0;
color: var(--ink-soft);
font-size: 15px;
font-weight: 750;
line-height: 1.5;
}
.checklist-list { .checklist-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -5130,6 +5233,14 @@ button:disabled,
flex-wrap: wrap; flex-wrap: wrap;
} }
.trading-detail-list {
max-height: 360px;
overflow: auto;
padding-right: 4px;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.trading-manager { .trading-manager {
min-height: 640px; min-height: 640px;
display: grid; display: grid;
@@ -5219,6 +5330,11 @@ button:disabled,
background: var(--surface-soft); background: var(--surface-soft);
} }
.trading-pick-row--active {
border-color: var(--pokemon-blue);
box-shadow: 0 0 0 2px rgba(42, 117, 187, .16);
}
.trading-pick-row__copy, .trading-pick-row__copy,
.trading-selected-list__copy { .trading-selected-list__copy {
min-width: 0; min-width: 0;
@@ -5307,6 +5423,10 @@ button:disabled,
max-height: 240px; max-height: 240px;
} }
.trading-detail-list {
max-height: 280px;
}
.trading-preference-toggle, .trading-preference-toggle,
.trading-selected-list .plain-button--icon { .trading-selected-list .plain-button--icon {
grid-column: 2; grid-column: 2;
@@ -5517,6 +5637,12 @@ button:disabled,
var(--surface-soft); var(--surface-soft);
} }
.entity-detail-image__frame:not(.entity-detail-image__frame--placeholder) {
padding: 0;
border: 0;
background: transparent;
}
.entity-detail-image__frame img { .entity-detail-image__frame img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -5628,17 +5754,14 @@ button:disabled,
min-height: 260px; min-height: 260px;
display: grid; display: grid;
place-items: center; place-items: center;
border: 4px solid #172036; border: 0;
border-radius: var(--radius-card); border-radius: var(--radius-card);
background: background: transparent;
linear-gradient(90deg, rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
linear-gradient(rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
#eef9ff;
} }
.pokemon-image-detail__screen img { .pokemon-image-detail__screen img {
width: min(100%, 380px); width: 100%;
max-height: 250px; max-height: 260px;
object-fit: contain; object-fit: contain;
} }
@@ -5669,6 +5792,95 @@ button:disabled,
color: var(--ink-soft); color: var(--ink-soft);
} }
.pokemon-description-grid {
display: grid;
grid-template-columns: minmax(220px, 340px) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.pokemon-description-image {
width: 100%;
min-height: 260px;
aspect-ratio: 1 / 1;
display: grid;
place-items: center;
padding: 14px;
overflow: hidden;
border: 3px solid var(--line-strong);
border-radius: var(--radius-card);
background:
linear-gradient(90deg, rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
linear-gradient(rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
#eef9ff;
box-shadow: var(--shadow-soft);
cursor: pointer;
}
.pokemon-description-image:not(.pokemon-description-image--placeholder) {
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.pokemon-description-image img {
width: 100%;
height: 100%;
object-fit: contain;
}
.pokemon-description-image--placeholder {
cursor: default;
}
.pokemon-description-card {
align-content: start;
}
.pokemon-description-card .detail-text {
max-width: 78ch;
font-size: 1rem;
line-height: 1.7;
}
.pokemon-core-grid,
.pokemon-reference-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
align-items: stretch;
}
.pokemon-core-card {
align-content: start;
}
.pokemon-core-note {
margin: 0;
}
.pokemon-core-value,
.pokemon-favourite-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-width: 0;
}
.pokemon-favourite-chip {
align-items: flex-start;
flex-direction: column;
gap: 3px;
}
.related-pokemon-row__environment--opposite,
.related-favourite-chip--opposite {
border-color: color-mix(in srgb, var(--danger) 62%, var(--line));
background: color-mix(in srgb, var(--danger) 16%, var(--surface));
color: #8f1717;
}
.pokemon-profile-grid { .pokemon-profile-grid {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
@@ -5730,6 +5942,13 @@ button:disabled,
cursor: pointer; cursor: pointer;
} }
.pokemon-profile-image:not(.pokemon-profile-image--placeholder) {
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.pokemon-profile-image:not(.pokemon-profile-image--placeholder):hover, .pokemon-profile-image:not(.pokemon-profile-image--placeholder):hover,
.pokemon-profile-image:not(.pokemon-profile-image--placeholder):focus-visible { .pokemon-profile-image:not(.pokemon-profile-image--placeholder):focus-visible {
border-color: var(--pokemon-blue); border-color: var(--pokemon-blue);
@@ -7395,6 +7614,46 @@ button:disabled,
align-items: center; align-items: center;
} }
.radio-group {
display: grid;
gap: 7px;
min-width: 0;
min-inline-size: 0;
margin: 0;
padding: 0;
border: 0;
}
.radio-group legend {
padding: 0;
color: var(--ink-soft);
font-size: 14px;
font-weight: 850;
}
.radio-group__options {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
}
.radio-group__option {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 36px;
color: var(--ink-soft);
font-weight: 850;
cursor: pointer;
}
.radio-group__option input {
width: 18px;
height: 18px;
accent-color: var(--pokemon-blue);
}
.row-actions { .row-actions {
flex: 0 0 auto; flex: 0 0 auto;
flex-wrap: wrap; flex-wrap: wrap;
@@ -7534,6 +7793,12 @@ button:disabled,
align-items: center; align-items: center;
} }
.switch-group__options--grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
align-items: stretch;
}
.switch-control { .switch-control {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@@ -7553,7 +7818,29 @@ button:disabled,
gap: 6px; gap: 6px;
} }
.switch-group__options--grid .switch-control--stacked {
min-height: 52px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
justify-items: stretch;
gap: 10px;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
}
.switch-control--disabled {
cursor: not-allowed;
opacity: 0.58;
}
.switch-control__copy {
min-width: 0;
}
.switch-control__label { .switch-control__label {
display: block;
color: var(--ink-soft); color: var(--ink-soft);
font-size: 13px; font-size: 13px;
line-height: 1.2; line-height: 1.2;
@@ -7561,6 +7848,21 @@ button:disabled,
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.switch-control__description {
display: block;
margin-top: 2px;
color: var(--muted);
font-size: 12px;
font-weight: 650;
line-height: 1.3;
overflow-wrap: anywhere;
}
.switch-group__options--grid .switch-control__label,
.switch-group__options--grid .switch-control__description {
text-align: left;
}
.switch-control input { .switch-control input {
position: absolute; position: absolute;
inline-size: 1px; inline-size: 1px;
@@ -7689,6 +7991,19 @@ button:disabled,
gap: 6px; gap: 6px;
} }
.view-as-banner {
top: 60px;
}
.view-as-banner__inner {
min-height: 48px;
padding: 8px 12px;
}
.view-as-banner__button {
min-height: 40px;
}
.sidebar-toggle { .sidebar-toggle {
width: 44px; width: 44px;
min-width: 44px; min-width: 44px;
@@ -7799,8 +8114,11 @@ button:disabled,
.entity-profile-grid, .entity-profile-grid,
.home-hero, .home-hero,
.pokemon-image-detail, .pokemon-image-detail,
.pokemon-description-grid,
.pokemon-profile-grid, .pokemon-profile-grid,
.pokemon-profile-row, .pokemon-profile-row,
.pokemon-core-grid,
.pokemon-reference-grid,
.pokemon-related-grid, .pokemon-related-grid,
.profile-layout, .profile-layout,
.profile-layout--loading, .profile-layout--loading,
@@ -9524,12 +9842,439 @@ button:disabled,
font-size: 14px; font-size: 14px;
} }
.threads-layout {
min-height: min(78vh, 860px);
display: grid;
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.threads-sidebar,
.threads-list-panel,
.thread-chat-panel {
min-width: 0;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface);
box-shadow: var(--shadow-soft);
}
.threads-sidebar {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
}
.threads-sidebar h2 {
margin: 0 0 6px;
color: var(--ink-soft);
font-size: 14px;
font-weight: 900;
}
.thread-channel,
.thread-list-item {
width: 100%;
min-width: 0;
display: grid;
gap: 6px;
border: 1px solid transparent;
border-radius: var(--radius-control);
background: transparent;
color: var(--ink-soft);
text-align: left;
cursor: pointer;
}
.thread-channel {
min-height: 44px;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
padding: 8px 10px;
font-weight: 900;
}
.thread-channel:hover,
.thread-channel.active,
.thread-list-item:hover,
.thread-list-item.active {
border-color: color-mix(in srgb, var(--pokemon-blue) 35%, var(--line));
background: color-mix(in srgb, var(--pokemon-blue) 8%, var(--surface));
color: var(--ink);
}
.thread-unread-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--pokemon-red);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--pokemon-red) 16%, transparent);
}
.threads-list-panel {
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr);
gap: 12px;
padding: 14px;
overflow: hidden;
}
.thread-search-create {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: end;
}
.thread-search-control {
min-width: 0;
min-height: 44px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-soft);
color: var(--muted);
}
.thread-search-control .ui-icon {
width: 20px;
height: 20px;
flex: 0 0 auto;
}
.thread-search-control input {
width: 100%;
min-width: 0;
border: 0;
background: transparent;
color: var(--ink);
outline: 0;
}
.thread-search-control:focus-within {
border-color: var(--focus);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 16%, transparent);
}
.thread-filters {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.thread-filters label {
display: grid;
gap: 5px;
color: var(--ink-soft);
font-size: 13px;
font-weight: 900;
}
.thread-composer textarea {
width: 100%;
}
.thread-tag-filter {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.thread-chip {
min-height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 9px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--surface-soft);
color: var(--ink-soft);
font-size: 13px;
font-weight: 800;
}
button.thread-chip {
cursor: pointer;
}
.thread-chip.active,
.thread-list-item.unread .thread-list-item__title {
color: var(--ink);
}
.thread-chip.active {
border-color: var(--pokemon-blue);
background: color-mix(in srgb, var(--pokemon-yellow) 26%, var(--surface));
}
.thread-list {
min-height: 0;
display: grid;
align-content: start;
gap: 10px;
overflow: auto;
}
.thread-list-item {
padding: 12px;
}
.thread-list-item__title {
display: flex;
align-items: center;
gap: 8px;
color: var(--ink);
font-weight: 900;
line-height: 1.35;
}
.thread-list-item__meta,
.threads-empty,
.thread-chat-header p,
.thread-message-meta time {
color: var(--muted);
font-size: 13px;
}
.thread-list-item__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.thread-chat-panel {
position: relative;
min-height: 620px;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
overflow: hidden;
}
.thread-chat-panel--modal {
min-height: min(72vh, 680px);
border: 0;
border-radius: 0;
box-shadow: none;
}
.thread-chat-header {
display: flex;
justify-content: flex-end;
gap: 14px;
padding: 16px;
border-bottom: 1px solid var(--line);
}
.thread-chat-header h2 {
margin: 0;
font-size: 22px;
line-height: 1.25;
}
.thread-chat-header p {
margin: 4px 0 0;
}
.thread-chat-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
align-items: center;
}
.thread-reactions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
padding: 10px 16px;
border-bottom: 1px solid var(--line);
}
.thread-reactions--message {
padding: 0;
border-bottom: 0;
}
.thread-reaction {
min-width: 44px;
min-height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-soft);
color: var(--ink-soft);
cursor: pointer;
}
.thread-reaction.active {
border-color: var(--pokemon-blue);
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface));
color: var(--ink);
}
.thread-reaction__emoji {
font-size: 17px;
line-height: 1;
}
.thread-reaction--action {
padding: 4px 9px;
font-size: 13px;
font-weight: 900;
}
.thread-message-scroll {
min-height: 0;
overflow: auto;
padding: 16px;
background: color-mix(in srgb, var(--surface-soft) 58%, var(--surface));
}
.thread-message-list {
display: grid;
gap: 16px;
}
.thread-message-group {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
gap: 10px;
}
.thread-avatar {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 2px solid var(--line-strong);
border-radius: 50%;
background: var(--pokemon-yellow);
color: var(--pokeball-black);
font-weight: 900;
}
.thread-message-group__body {
min-width: 0;
display: grid;
gap: 6px;
}
.thread-message-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: baseline;
}
.thread-message {
display: grid;
gap: 8px;
}
.thread-message p {
margin: 0;
white-space: pre-wrap;
}
.thread-message-edit {
display: grid;
gap: 8px;
}
.thread-message-edit textarea {
width: 100%;
min-height: 92px;
resize: vertical;
}
.thread-message-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.thread-composer {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
padding: 12px;
border-top: 1px solid var(--line);
background: var(--surface);
}
.thread-jump-button {
position: absolute;
right: 18px;
bottom: 86px;
min-height: 38px;
padding: 7px 12px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-control);
background: var(--pokemon-yellow);
color: var(--pokeball-black);
font-weight: 900;
box-shadow: var(--shadow-control);
}
.thread-load-older {
width: fit-content;
margin: 0 auto 14px;
}
.threads-empty {
margin: 0;
padding: 18px;
}
.threads-empty--select {
align-self: center;
justify-self: center;
}
@media (max-width: 1100px) {
.threads-layout {
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
}
}
@media (max-width: 640px) { @media (max-width: 640px) {
.threads-layout,
.dish-category-summary, .dish-category-summary,
.dish-card { .dish-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.thread-search-create,
.thread-filters,
.thread-composer,
.thread-chat-header {
grid-template-columns: 1fr;
}
.thread-chat-header {
display: grid;
}
.thread-chat-actions {
justify-content: flex-start;
}
.thread-chat-panel {
min-height: 70dvh;
}
.dish-form-row, .dish-form-row,
.dish-form-row--3, .dish-form-row--3,
.dish-form-row--4 { .dish-form-row--4 {

View File

@@ -7,6 +7,8 @@ import PageHeader from '../components/PageHeader.vue';
import ReorderableList from '../components/ReorderableList.vue'; import ReorderableList from '../components/ReorderableList.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import SwitchGroup, { type SwitchGroupOption } from '../components/SwitchGroup.vue';
import TagsSelect, { type TagsSelectOption } from '../components/TagsSelect.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import TranslationFields from '../components/TranslationFields.vue'; import TranslationFields from '../components/TranslationFields.vue';
import { import {
@@ -18,6 +20,7 @@ import {
iconDelete, iconDelete,
iconDish, iconDish,
iconEdit, iconEdit,
iconEye,
iconHabitat, iconHabitat,
iconItem, iconItem,
iconKey, iconKey,
@@ -25,6 +28,7 @@ import {
iconProfile, iconProfile,
iconRecipe, iconRecipe,
iconSave, iconSave,
iconThreads,
iconTranslate, iconTranslate,
iconUpload, iconUpload,
type AppIcon type AppIcon
@@ -32,6 +36,7 @@ import {
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n'; import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
import { import {
api, api,
notifyAuthChange,
type AncientArtifact, type AncientArtifact,
type AiModerationApiFormat, type AiModerationApiFormat,
type AiModerationAuthMode, type AiModerationAuthMode,
@@ -50,7 +55,6 @@ import {
type Habitat, type Habitat,
type Item, type Item,
type Language, type Language,
type LifeCategory,
type NamedEntity, type NamedEntity,
type Permission, type Permission,
type PermissionPayload, type PermissionPayload,
@@ -65,6 +69,7 @@ import {
type Skill, type Skill,
type SystemWording, type SystemWording,
type SystemWordingSurface, type SystemWordingSurface,
type ThreadChannel,
type TranslationMap type TranslationMap
} from '../services/api'; } from '../services/api';
@@ -84,15 +89,16 @@ type AdminTab =
| 'ancientArtifacts' | 'ancientArtifacts'
| 'recipes' | 'recipes'
| 'dish' | 'dish'
| 'habitats'; | 'habitats'
| 'threadChannels';
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access'; type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] }; type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] }; type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
type EditableConfig = (NamedEntity | Skill | LifeCategory | GameVersion) & { type EditableConfig = (NamedEntity | Skill | GameVersion) & {
description?: string;
opposite?: NamedEntity | null;
hasItemDrop?: boolean; hasItemDrop?: boolean;
hasTrading?: boolean; hasTrading?: boolean;
isDefault?: boolean;
isRateable?: boolean;
changeLog?: string; changeLog?: string;
}; };
type RateLimitPolicyForm = { type RateLimitPolicyForm = {
@@ -137,7 +143,8 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
ancientArtifacts: iconArtifact, ancientArtifacts: iconArtifact,
recipes: iconRecipe, recipes: iconRecipe,
dish: iconDish, dish: iconDish,
habitats: iconHabitat habitats: iconHabitat,
threadChannels: iconThreads
}; };
const { locale, t } = useI18n(); const { locale, t } = useI18n();
@@ -154,7 +161,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
label: t('pages.admin.contentGroup'), label: t('pages.admin.contentGroup'),
items: [ items: [
{ key: 'checklist', label: t('pages.admin.checklist'), permission: ['checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order'] }, { key: 'checklist', label: t('pages.admin.checklist'), permission: ['checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order'] },
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.delete'] }, { key: 'pokemon', label: t('pages.admin.pokemonList'), permission: 'pokemon.delete' },
{ key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] }, { key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
{ {
key: 'ancientArtifacts', key: 'ancientArtifacts',
@@ -164,6 +171,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] }, { key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] },
{ key: 'dish', label: t('pages.admin.dishList'), permission: ['dish.create', 'dish.update', 'dish.delete', 'dish.order'] }, { key: 'dish', label: t('pages.admin.dishList'), permission: ['dish.create', 'dish.update', 'dish.delete', 'dish.order'] },
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] }, { key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] },
{ key: 'threadChannels', label: t('pages.admin.threadChannels'), permission: 'admin.threads.channels.read' },
{ key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] } { key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] }
] ]
}, },
@@ -200,18 +208,17 @@ const configTypes = computed<
label: string; label: string;
supportsItemDrop?: boolean; supportsItemDrop?: boolean;
supportsTrading?: boolean; supportsTrading?: boolean;
supportsDefault?: boolean;
supportsRateable?: boolean;
supportsChangeLog?: boolean; supportsChangeLog?: boolean;
supportsDescription?: boolean;
supportsOpposite?: boolean;
}> }>
>(() => [ >(() => [
{ key: 'pokemon-types', label: t('config.pokemonTypes') }, { key: 'pokemon-types', label: t('config.pokemonTypes') },
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true }, { key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true },
{ key: 'environments', label: t('config.environments') }, { key: 'environments', label: t('config.environments'), supportsDescription: true, supportsOpposite: true },
{ key: 'favorite-things', label: t('config.favoriteThings') }, { key: 'favorite-things', label: t('config.favoriteThings'), supportsOpposite: true },
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') }, { key: 'acquisition-methods', label: t('config.acquisitionMethods') },
{ key: 'maps', label: t('config.maps') }, { key: 'maps', label: t('config.maps') },
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true },
{ key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true }, { key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true },
{ key: 'dish-flavors', label: t('config.dishFlavors') } { key: 'dish-flavors', label: t('config.dishFlavors') }
]); ]);
@@ -233,6 +240,7 @@ const dishItemRows = ref<Item[]>([]);
const dishSkillRows = ref<Skill[]>([]); const dishSkillRows = ref<Skill[]>([]);
const dishFlavorRows = ref<NamedEntity[]>([]); const dishFlavorRows = ref<NamedEntity[]>([]);
const habitatRows = ref<Habitat[]>([]); const habitatRows = ref<Habitat[]>([]);
const threadChannelRows = ref<ThreadChannel[]>([]);
const wordingRows = ref<SystemWording[]>([]); const wordingRows = ref<SystemWording[]>([]);
const aiModerationSettings = ref<AiModerationSettings | null>(null); const aiModerationSettings = ref<AiModerationSettings | null>(null);
const rateLimitSettings = ref<RateLimitSettings | null>(null); const rateLimitSettings = ref<RateLimitSettings | null>(null);
@@ -244,11 +252,11 @@ const message = ref('');
const configForm = ref({ const configForm = ref({
id: 0, id: 0,
name: '', name: '',
description: '',
oppositeId: '',
translations: {} as TranslationMap, translations: {} as TranslationMap,
hasItemDrop: false, hasItemDrop: false,
hasTrading: false, hasTrading: false,
isDefault: false,
isRateable: false,
changeLog: '' changeLog: ''
}); });
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap }); const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
@@ -299,6 +307,7 @@ const userRoleForm = ref({ userId: 0, roleIds: [] as number[] });
const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true }); const roleForm = ref({ id: 0, key: '', name: '', description: '', level: 100, enabled: true });
const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] }); const rolePermissionForm = ref({ roleId: 0, permissionIds: [] as number[] });
const permissionForm = ref({ id: 0, key: '', name: '', description: '', category: 'General', enabled: true }); const permissionForm = ref({ id: 0, key: '', name: '', description: '', category: 'General', enabled: true });
const threadChannelForm = ref({ id: 0, name: '', allowUserThreads: true, tagsText: '', languages: [] as string[] });
const editingLanguageCode = ref(''); const editingLanguageCode = ref('');
const configModalOpen = ref(false); const configModalOpen = ref(false);
const checklistModalOpen = ref(false); const checklistModalOpen = ref(false);
@@ -310,6 +319,7 @@ const userRoleModalOpen = ref(false);
const roleModalOpen = ref(false); const roleModalOpen = ref(false);
const rolePermissionsModalOpen = ref(false); const rolePermissionsModalOpen = ref(false);
const permissionModalOpen = ref(false); const permissionModalOpen = ref(false);
const threadChannelModalOpen = ref(false);
const dataToolImportModalOpen = ref(false); const dataToolImportModalOpen = ref(false);
const dataToolWipeModalOpen = ref(false); const dataToolWipeModalOpen = ref(false);
const wordingLocale = ref(getCurrentLocale()); const wordingLocale = ref(getCurrentLocale());
@@ -344,6 +354,12 @@ const configNameInput = computed({
} }
}); });
const configNamePlaceholder = computed(() => (isConfigDefaultLocale.value ? '' : configForm.value.name)); const configNamePlaceholder = computed(() => (isConfigDefaultLocale.value ? '' : configForm.value.name));
const configOppositeOptions = computed(() => [
{ id: '', name: t('common.none') },
...configRows.value
.filter((item) => item.id !== configForm.value.id)
.map((item) => ({ id: item.id, name: item.name }))
]);
const activeConfigTab = computed({ const activeConfigTab = computed({
get: () => activeConfigType.value, get: () => activeConfigType.value,
set: (value: string) => { set: (value: string) => {
@@ -356,6 +372,7 @@ const activeConfigTab = computed({
} }
}); });
const canEdit = computed(() => can('admin.access')); const canEdit = computed(() => can('admin.access'));
const canUseViewAs = computed(() => currentUser.value?.roles.some((role) => role.key === 'owner') === true && !currentUser.value?.viewAs);
const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value)); const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value));
const canSetLanguageDefault = computed(() => languageForm.value.code === 'en'); const canSetLanguageDefault = computed(() => languageForm.value.code === 'en');
const configModalTitle = computed(() => const configModalTitle = computed(() =>
@@ -369,12 +386,42 @@ const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDis
const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes)); const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes));
const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null); const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null);
const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2); const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2);
const dishItemSelectOptions = computed<TagsSelectOption[]>(() =>
dishItemRows.value.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url }))
);
const optionalDishItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...dishItemSelectOptions.value]);
const dishCategorySelectOptions = computed<TagsSelectOption[]>(() =>
dishCategoryRows.value.map((category) => ({ id: category.id, name: category.name }))
);
const dishFlavorSelectOptions = computed<TagsSelectOption[]>(() => dishFlavorRows.value.map((flavor) => ({ id: flavor.id, name: flavor.name })));
const optionalDishSkillSelectOptions = computed<TagsSelectOption[]>(() => [
{ id: '', name: t('common.none') },
...dishSkillRows.value.map((skill) => ({ id: skill.id, name: skill.name }))
]);
const dishCategoryFormValid = computed(
() =>
dishCategoryForm.value.name.trim() !== '' &&
dishCategoryForm.value.effect.trim() !== '' &&
dishCategoryForm.value.cookwareItemId !== '' &&
dishCategoryForm.value.mainMaterialItemId !== '' &&
Number(dishCategoryForm.value.totalMaterialQuantity) >= 2
);
const dishFormValid = computed(
() =>
dishForm.value.categoryId !== '' &&
dishForm.value.itemId !== '' &&
dishForm.value.flavorId !== '' &&
dishForm.value.mosslaxEffect.trim() !== ''
);
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage'))); const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
const wordingModalTitle = computed(() => t('pages.admin.editWording')); const wordingModalTitle = computed(() => t('pages.admin.editWording'));
const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole'))); const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole')));
const permissionModalTitle = computed(() => const permissionModalTitle = computed(() =>
permissionForm.value.id ? t('pages.admin.editPermission') : t('pages.admin.newPermission') permissionForm.value.id ? t('pages.admin.editPermission') : t('pages.admin.newPermission')
); );
const threadChannelModalTitle = computed(() =>
threadChannelForm.value.id ? t('pages.admin.editThreadChannel') : t('pages.admin.newThreadChannel')
);
const rolePermissionsModalTitle = computed(() => t('pages.admin.rolePermissions')); const rolePermissionsModalTitle = computed(() => t('pages.admin.rolePermissions'));
const userRoleModalTitle = computed(() => t('pages.admin.userRoles')); const userRoleModalTitle = computed(() => t('pages.admin.userRoles'));
const editingUser = computed(() => userRows.value.find((user) => user.id === userRoleForm.value.userId) ?? null); const editingUser = computed(() => userRows.value.find((user) => user.id === userRoleForm.value.userId) ?? null);
@@ -386,6 +433,26 @@ const permissionGroups = computed(() => {
} }
return [...groups.entries()].map(([category, permissions]) => ({ category, permissions })); return [...groups.entries()].map(([category, permissions]) => ({ category, permissions }));
}); });
const userRoleSwitchOptions = computed<SwitchGroupOption[]>(() =>
roleRows.value.map((role) => ({
value: role.id,
label: role.name,
description: role.description,
disabled: busy.value || !role.enabled
}))
);
const userRoleSwitchValue = computed<Array<string | number>>({
get: () => userRoleForm.value.roleIds,
set: (values) => {
userRoleForm.value.roleIds = values.map((value) => Number(value)).sort((a, b) => a - b);
}
});
const rolePermissionSwitchValue = computed<Array<string | number>>({
get: () => rolePermissionForm.value.permissionIds,
set: (values) => {
rolePermissionForm.value.permissionIds = values.map((value) => Number(value)).sort((a, b) => a - b);
}
});
const wordingLocaleOptions = computed(() => const wordingLocaleOptions = computed(() =>
languageRows.value.length languageRows.value.length
? languageRows.value ? languageRows.value
@@ -455,8 +522,6 @@ const languageKey = (item: Language) => item.code;
const languageLabel = (item: Language) => item.name; const languageLabel = (item: Language) => item.name;
const configKey = (item: EditableConfig) => item.id; const configKey = (item: EditableConfig) => item.id;
const configLabel = (item: EditableConfig) => item.name; const configLabel = (item: EditableConfig) => item.name;
const pokemonKey = (item: Pokemon) => item.id;
const pokemonLabel = (item: Pokemon) => `#${item.displayId} ${item.name}`;
const itemKey = (item: Item) => item.id; const itemKey = (item: Item) => item.id;
const itemLabel = (item: Item) => item.name; const itemLabel = (item: Item) => item.name;
const ancientArtifactKey = (item: AncientArtifact) => item.id; const ancientArtifactKey = (item: AncientArtifact) => item.id;
@@ -525,24 +590,13 @@ function rolePermissionCount(role: RoleDetail) {
return t('pages.admin.permissionCount', { count: role.permissionIds.length }); return t('pages.admin.permissionCount', { count: role.permissionIds.length });
} }
function toggleUserRole(roleId: number) { function permissionSwitchOptions(permissions: Permission[]): SwitchGroupOption[] {
const roleIds = new Set(userRoleForm.value.roleIds); return permissions.map((permission) => ({
if (roleIds.has(roleId)) { value: permission.id,
roleIds.delete(roleId); label: permission.name,
} else { description: permission.key,
roleIds.add(roleId); disabled: busy.value || !permission.enabled
} }));
userRoleForm.value.roleIds = [...roleIds].sort((a, b) => a - b);
}
function toggleRolePermission(permissionId: number) {
const permissionIds = new Set(rolePermissionForm.value.permissionIds);
if (permissionIds.has(permissionId)) {
permissionIds.delete(permissionId);
} else {
permissionIds.add(permissionId);
}
rolePermissionForm.value.permissionIds = [...permissionIds].sort((a, b) => a - b);
} }
function errorText(error: unknown, fallback: string) { function errorText(error: unknown, fallback: string) {
@@ -571,7 +625,7 @@ async function loadLanguages() {
} }
function resetConfigForm() { function resetConfigForm() {
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, hasTrading: false, isDefault: false, isRateable: false, changeLog: '' }; configForm.value = { id: 0, name: '', description: '', oppositeId: '', translations: {}, hasItemDrop: false, hasTrading: false, changeLog: '' };
} }
function resetChecklistForm() { function resetChecklistForm() {
@@ -657,6 +711,10 @@ function resetPermissionForm() {
permissionForm.value = { id: 0, key: '', name: '', description: '', category: 'General', enabled: true }; permissionForm.value = { id: 0, key: '', name: '', description: '', category: 'General', enabled: true };
} }
function resetThreadChannelForm() {
threadChannelForm.value = { id: 0, name: '', allowUserThreads: true, tagsText: '', languages: languageRows.value.map((language) => language.code) };
}
function selectWordingModule(module: string) { function selectWordingModule(module: string) {
wordingModule.value = module; wordingModule.value = module;
} }
@@ -675,11 +733,11 @@ function editConfig(item: EditableConfig) {
configForm.value = { configForm.value = {
id: item.id, id: item.id,
name: item.baseName ?? item.name, name: item.baseName ?? item.name,
description: item.description ?? '',
oppositeId: item.opposite ? String(item.opposite.id) : '',
translations: item.translations ?? {}, translations: item.translations ?? {},
hasItemDrop: item.hasItemDrop === true, hasItemDrop: item.hasItemDrop === true,
hasTrading: item.hasTrading === true, hasTrading: item.hasTrading === true,
isDefault: item.isDefault === true,
isRateable: item.isRateable === true,
changeLog: item.changeLog ?? '' changeLog: item.changeLog ?? ''
}; };
configModalOpen.value = true; configModalOpen.value = true;
@@ -767,6 +825,14 @@ function openUserRoles(user: AdminUser) {
userRoleModalOpen.value = true; userRoleModalOpen.value = true;
} }
async function viewAsUser(user: AdminUser) {
await run(async () => {
const response = await api.viewAsUser(user.id);
currentUser.value = response.user;
notifyAuthChange();
});
}
function closeUserRoleModal() { function closeUserRoleModal() {
userRoleModalOpen.value = false; userRoleModalOpen.value = false;
resetUserRoleForm(); resetUserRoleForm();
@@ -799,6 +865,14 @@ function editRolePermissions(role: RoleDetail) {
rolePermissionsModalOpen.value = true; rolePermissionsModalOpen.value = true;
} }
async function viewAsRole(role: RoleDetail) {
await run(async () => {
const response = await api.viewAsRole(role.id);
currentUser.value = response.user;
notifyAuthChange();
});
}
function closeRolePermissionsModal() { function closeRolePermissionsModal() {
rolePermissionsModalOpen.value = false; rolePermissionsModalOpen.value = false;
resetRolePermissionForm(); resetRolePermissionForm();
@@ -826,6 +900,27 @@ function closePermissionModal() {
resetPermissionForm(); resetPermissionForm();
} }
function openNewThreadChannel() {
resetThreadChannelForm();
threadChannelModalOpen.value = true;
}
function closeThreadChannelModal() {
threadChannelModalOpen.value = false;
resetThreadChannelForm();
}
function editThreadChannel(channel: ThreadChannel) {
threadChannelForm.value = {
id: channel.id,
name: channel.name,
allowUserThreads: channel.allowUserThreads,
tagsText: channel.tags.map((tag) => tag.name).join(', '),
languages: channel.languages.map((language) => language.code)
};
threadChannelModalOpen.value = true;
}
function editLanguage(item: Language) { function editLanguage(item: Language) {
editingLanguageCode.value = item.code; editingLanguageCode.value = item.code;
languageForm.value = { languageForm.value = {
@@ -896,10 +991,6 @@ function previewConfigOrder(rows: EditableConfig[]) {
configRows.value = rows; configRows.value = rows;
} }
function previewPokemonOrder(rows: Pokemon[]) {
pokemonRows.value = rows;
}
function previewItemOrder(rows: Item[]) { function previewItemOrder(rows: Item[]) {
itemRows.value = rows; itemRows.value = rows;
} }
@@ -968,18 +1059,6 @@ async function persistConfigOrder(nextRows: EditableConfig[], fallbackRows: Edit
}); });
} }
async function persistPokemonOrder(nextRows: Pokemon[], fallbackRows: Pokemon[]) {
pokemonRows.value = nextRows;
await run(async () => {
try {
pokemonRows.value = await api.reorderPokemon(nextRows.map((item) => item.id));
} catch (error) {
pokemonRows.value = fallbackRows;
throw error;
}
});
}
async function persistItemOrder(nextRows: Item[], fallbackRows: Item[]) { async function persistItemOrder(nextRows: Item[], fallbackRows: Item[]) {
itemRows.value = nextRows; itemRows.value = nextRows;
await run(async () => { await run(async () => {
@@ -1057,10 +1136,10 @@ async function saveConfig() {
const payload = { const payload = {
name: configBaseNameForSave(), name: configBaseNameForSave(),
translations: configForm.value.translations, translations: configForm.value.translations,
description: selectedConfig.value.supportsDescription ? configForm.value.description : undefined,
oppositeId: selectedConfig.value.supportsOpposite && configForm.value.oppositeId ? Number(configForm.value.oppositeId) : null,
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined, hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
hasTrading: selectedConfig.value.supportsTrading ? configForm.value.hasTrading : undefined, hasTrading: selectedConfig.value.supportsTrading ? configForm.value.hasTrading : undefined,
isDefault: selectedConfig.value.supportsDefault ? configForm.value.isDefault : undefined,
isRateable: selectedConfig.value.supportsRateable ? configForm.value.isRateable : undefined,
changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined
}; };
@@ -1083,6 +1162,33 @@ async function loadChecklist() {
} }
} }
async function loadThreadChannels() {
await loadLanguages();
threadChannelRows.value = await api.adminThreadChannels();
}
function threadChannelTagNames() {
return threadChannelForm.value.tagsText
.split(',')
.map((tag) => tag.trim())
.filter(Boolean);
}
async function saveThreadChannel() {
await run(async () => {
const payload = {
name: threadChannelForm.value.name,
allowUserThreads: threadChannelForm.value.allowUserThreads,
tags: threadChannelTagNames(),
languages: threadChannelForm.value.languages
};
threadChannelRows.value = threadChannelForm.value.id
? await api.updateAdminThreadChannel(threadChannelForm.value.id, payload)
: await api.createAdminThreadChannel(payload);
closeThreadChannelModal();
});
}
async function saveChecklistItem() { async function saveChecklistItem() {
await run(async () => { await run(async () => {
const payload = { const payload = {
@@ -1129,6 +1235,10 @@ function dishPayloadForSave() {
} }
async function saveDishCategory() { async function saveDishCategory() {
if (!dishCategoryFormValid.value) {
return;
}
await run(async () => { await run(async () => {
const payload = dishCategoryPayloadForSave(); const payload = dishCategoryPayloadForSave();
if (dishCategoryForm.value.id) { if (dishCategoryForm.value.id) {
@@ -1142,6 +1252,10 @@ async function saveDishCategory() {
} }
async function saveDish() { async function saveDish() {
if (!dishFormValid.value) {
return;
}
await run(async () => { await run(async () => {
const payload = dishPayloadForSave(); const payload = dishPayloadForSave();
if (dishForm.value.id) { if (dishForm.value.id) {
@@ -1386,6 +1500,7 @@ async function loadCurrentTab(showSkeleton = false) {
if (activeTab.value === 'recipes') await loadRecipes(); if (activeTab.value === 'recipes') await loadRecipes();
if (activeTab.value === 'dish') await loadDishAdmin(); if (activeTab.value === 'dish') await loadDishAdmin();
if (activeTab.value === 'habitats') await loadHabitats(); if (activeTab.value === 'habitats') await loadHabitats();
if (activeTab.value === 'threadChannels') await loadThreadChannels();
} finally { } finally {
if (showSkeleton) { if (showSkeleton) {
contentLoading.value = false; contentLoading.value = false;
@@ -1457,6 +1572,16 @@ async function removeChecklistItem(id: number) {
}); });
} }
async function removeThreadChannel(id: number) {
await run(async () => {
await api.deleteAdminThreadChannel(id);
if (threadChannelForm.value.id === id) {
closeThreadChannelModal();
}
await loadThreadChannels();
});
}
async function removePokemon(id: number) { async function removePokemon(id: number) {
await run(async () => { await run(async () => {
await api.deletePokemon(id); await api.deletePokemon(id);
@@ -1745,6 +1870,10 @@ onMounted(() => {
</span> </span>
</span> </span>
<span class="row-actions"> <span class="row-actions">
<button v-if="canUseViewAs" type="button" :disabled="busy" @click="viewAsUser(user)">
<Icon :icon="iconEye" class="ui-icon" aria-hidden="true" />
{{ t('viewAs.userAction') }}
</button>
<button v-if="can('admin.users.update') && can('admin.roles.read')" type="button" :disabled="busy" @click="openUserRoles(user)"> <button v-if="can('admin.users.update') && can('admin.roles.read')" type="button" :disabled="busy" @click="openUserRoles(user)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('pages.admin.userRoles') }} {{ t('pages.admin.userRoles') }}
@@ -1777,6 +1906,10 @@ onMounted(() => {
</span> </span>
</span> </span>
<span class="row-actions"> <span class="row-actions">
<button v-if="canUseViewAs && role.enabled" type="button" :disabled="busy" @click="viewAsRole(role)">
<Icon :icon="iconEye" class="ui-icon" aria-hidden="true" />
{{ t('viewAs.roleAction') }}
</button>
<button v-if="can('admin.roles.update')" type="button" :disabled="busy" @click="editRole(role)"> <button v-if="can('admin.roles.update')" type="button" :disabled="busy" @click="editRole(role)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }} {{ t('common.edit') }}
@@ -2009,6 +2142,39 @@ onMounted(() => {
</div> </div>
</section> </section>
<section v-else-if="canEdit && activeTab === 'threadChannels'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.threadChannels') }}</h2>
<button v-if="can('admin.threads.channels.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewThreadChannel">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.new') }}
</button>
</div>
<ul v-if="threadChannelRows.length" class="row-list access-list">
<li v-for="channel in threadChannelRows" :key="channel.id">
<span class="access-row">
<strong>{{ channel.name }}</strong>
<span class="system-wording-row__meta">
<span class="config-flag">{{ channel.allowUserThreads ? t('pages.admin.userThreadsAllowed') : t('pages.admin.userThreadsDisabled') }}</span>
<span v-for="tag in channel.tags" :key="tag.id" class="config-flag">{{ tag.name }}</span>
<span v-for="language in channel.languages" :key="language.code" class="config-flag">{{ language.name }}</span>
</span>
</span>
<span class="row-actions">
<button v-if="can('admin.threads.channels.update')" type="button" :disabled="busy" @click="editThreadChannel(channel)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button v-if="can('admin.threads.channels.delete')" type="button" :disabled="busy" @click="removeThreadChannel(channel.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</span>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
<div class="detail-section__header"> <div class="detail-section__header">
<h2>{{ t('pages.admin.config') }}</h2> <h2>{{ t('pages.admin.config') }}</h2>
@@ -2035,11 +2201,11 @@ onMounted(() => {
<template #default="{ item }"> <template #default="{ item }">
<span class="reorderable-row-title"> <span class="reorderable-row-title">
{{ item.name }} {{ item.name }}
<span v-if="item.opposite" class="config-flag">{{ t('pages.admin.opposite') }}: {{ item.opposite.name }}</span>
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span> <span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
<span v-if="item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span> <span v-if="item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span>
<span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultCategory') }}</span>
<span v-if="item.isRateable" class="config-flag">{{ t('pages.admin.rateableCategory') }}</span>
</span> </span>
<span v-if="item.description" class="meta-line">{{ item.description }}</span>
<span class="row-actions"> <span class="row-actions">
<button v-if="can('admin.config.update')" type="button" :disabled="busy" @click="editConfig(item)"> <button v-if="can('admin.config.update')" type="button" :disabled="busy" @click="editConfig(item)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
@@ -2275,20 +2441,8 @@ onMounted(() => {
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
<h2>{{ t('pages.admin.pokemonList') }}</h2> <h2>{{ t('pages.admin.pokemonList') }}</h2>
<ReorderableList <ul v-if="pokemonRows.length" class="row-list">
v-if="pokemonRows.length" <li v-for="item in pokemonRows" :key="item.id">
:items="pokemonRows"
:item-key="pokemonKey"
:item-label="pokemonLabel"
list-key-prefix="pokemon"
:disabled="busy || !can('pokemon.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewPokemonOrder"
@cancel="previewPokemonOrder"
@reorder="persistPokemonOrder"
>
<template #default="{ item }">
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink> <RouterLink :to="`/pokemon/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
<span class="row-actions"> <span class="row-actions">
<button v-if="can('pokemon.delete')" type="button" :disabled="busy" @click="removePokemon(item.id)"> <button v-if="can('pokemon.delete')" type="button" :disabled="busy" @click="removePokemon(item.id)">
@@ -2296,8 +2450,8 @@ onMounted(() => {
{{ t('common.delete') }} {{ t('common.delete') }}
</button> </button>
</span> </span>
</template> </li>
</ReorderableList> </ul>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
@@ -2537,20 +2691,7 @@ onMounted(() => {
<strong>{{ editingUser.displayName }}</strong> <strong>{{ editingUser.displayName }}</strong>
<span class="meta-line">{{ editingUser.email }}</span> <span class="meta-line">{{ editingUser.email }}</span>
</div> </div>
<div class="permission-grid" role="group" :aria-label="t('pages.admin.roles')"> <SwitchGroup id="admin-user-roles" v-model="userRoleSwitchValue" :label="t('pages.admin.roles')" :options="userRoleSwitchOptions" layout="grid" />
<label v-for="role in roleRows" :key="role.id" class="permission-toggle">
<input
type="checkbox"
:checked="userRoleForm.roleIds.includes(role.id)"
:disabled="busy || !role.enabled"
@change="toggleUserRole(role.id)"
/>
<span>
<strong>{{ role.name }}</strong>
<small>{{ role.description }}</small>
</span>
</label>
</div>
</form> </form>
<template #footer> <template #footer>
@@ -2607,22 +2748,14 @@ onMounted(() => {
<span class="meta-line">{{ editingRole.description }}</span> <span class="meta-line">{{ editingRole.description }}</span>
</div> </div>
<div class="permission-groups"> <div class="permission-groups">
<section v-for="group in permissionGroups" :key="group.category" class="permission-group"> <section v-for="(group, index) in permissionGroups" :key="group.category" class="permission-group">
<h3>{{ group.category }}</h3> <SwitchGroup
<div class="permission-grid" role="group" :aria-label="group.category"> :id="`admin-role-permissions-${index}`"
<label v-for="permission in group.permissions" :key="permission.id" class="permission-toggle"> v-model="rolePermissionSwitchValue"
<input :label="group.category"
type="checkbox" :options="permissionSwitchOptions(group.permissions)"
:checked="rolePermissionForm.permissionIds.includes(permission.id)" layout="grid"
:disabled="busy || !permission.enabled" />
@change="toggleRolePermission(permission.id)"
/>
<span>
<strong>{{ permission.name }}</strong>
<small>{{ permission.key }}</small>
</span>
</label>
</div>
</section> </section>
</div> </div>
</form> </form>
@@ -2674,6 +2807,45 @@ onMounted(() => {
</template> </template>
</Modal> </Modal>
<Modal v-if="threadChannelModalOpen" :title="threadChannelModalTitle" :close-label="t('common.close')" @close="closeThreadChannelModal">
<form id="admin-thread-channel-form" class="modal-edit-form" @submit.prevent="saveThreadChannel">
<div class="field">
<label for="thread-channel-name">{{ t('common.name') }}</label>
<input id="thread-channel-name" v-model="threadChannelForm.name" required maxlength="80" />
</div>
<div class="check-row">
<label>
<input v-model="threadChannelForm.allowUserThreads" type="checkbox" />
{{ t('pages.admin.allowUserThreads') }}
</label>
</div>
<div class="field">
<label for="thread-channel-tags">{{ t('pages.threads.tags') }}</label>
<input id="thread-channel-tags" v-model="threadChannelForm.tagsText" :placeholder="t('pages.admin.threadTagsPlaceholder')" />
</div>
<div class="field">
<span class="field-label">{{ t('pages.threads.language') }}</span>
<div class="permission-groups">
<label v-for="language in languageRows" :key="language.code" class="data-tool-scope">
<input v-model="threadChannelForm.languages" type="checkbox" :value="language.code" />
<span>{{ language.name }}</span>
</label>
</div>
</div>
</form>
<template #footer>
<button type="submit" form="admin-thread-channel-form" class="link-button" :disabled="busy || !threadChannelForm.name.trim()">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closeThreadChannelModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<Modal v-if="checklistModalOpen" :title="checklistModalTitle" :close-label="t('common.close')" size="wide" @close="closeChecklistModal"> <Modal v-if="checklistModalOpen" :title="checklistModalTitle" :close-label="t('common.close')" size="wide" @close="closeChecklistModal">
<form id="admin-checklist-form" class="modal-edit-form" @submit.prevent="saveChecklistItem"> <form id="admin-checklist-form" class="modal-edit-form" @submit.prevent="saveChecklistItem">
<TranslationFields <TranslationFields
@@ -2713,10 +2885,14 @@ onMounted(() => {
/> />
<div class="field"> <div class="field">
<label for="dish-category-cookware">{{ t('pages.dish.cookware') }}</label> <label for="dish-category-cookware">{{ t('pages.dish.cookware') }}</label>
<select id="dish-category-cookware" v-model="dishCategoryForm.cookwareItemId" required> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-category-cookware"
<option v-for="item in dishItemRows" :key="`cookware-${item.id}`" :value="String(item.id)">{{ item.name }}</option> v-model="dishCategoryForm.cookwareItemId"
</select> :options="dishItemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div> </div>
<div class="field"> <div class="field">
<label for="dish-category-total-material-quantity">{{ t('pages.dish.totalMaterialQuantity') }}</label> <label for="dish-category-total-material-quantity">{{ t('pages.dish.totalMaterialQuantity') }}</label>
@@ -2724,10 +2900,14 @@ onMounted(() => {
</div> </div>
<div class="field"> <div class="field">
<label for="dish-category-main-material">{{ t('pages.dish.mainMaterial') }}</label> <label for="dish-category-main-material">{{ t('pages.dish.mainMaterial') }}</label>
<select id="dish-category-main-material" v-model="dishCategoryForm.mainMaterialItemId" required> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-category-main-material"
<option v-for="item in dishItemRows" :key="`category-main-material-${item.id}`" :value="String(item.id)">{{ item.name }}</option> v-model="dishCategoryForm.mainMaterialItemId"
</select> :options="dishItemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div> </div>
</div> </div>
<TranslationFields <TranslationFields
@@ -2742,7 +2922,7 @@ onMounted(() => {
</form> </form>
<template #footer> <template #footer>
<button type="submit" form="admin-dish-category-form" class="link-button" :disabled="busy"> <button type="submit" form="admin-dish-category-form" class="link-button" :disabled="busy || !dishCategoryFormValid">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }} {{ busy ? t('common.saving') : t('common.save') }}
</button> </button>
@@ -2758,47 +2938,71 @@ onMounted(() => {
<div class="dish-form-row dish-form-row--3"> <div class="dish-form-row dish-form-row--3">
<div class="field"> <div class="field">
<label for="dish-category">{{ t('pages.dish.category') }}</label> <label for="dish-category">{{ t('pages.dish.category') }}</label>
<select id="dish-category" v-model="dishForm.categoryId" required> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-category"
<option v-for="category in dishCategoryRows" :key="`dish-category-option-${category.id}`" :value="String(category.id)">{{ category.name }}</option> v-model="dishForm.categoryId"
</select> :options="dishCategorySelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.dish.category')"
/>
</div> </div>
<div class="field"> <div class="field">
<label for="dish-item">{{ t('pages.dish.dishItem') }}</label> <label for="dish-item">{{ t('pages.dish.dishItem') }}</label>
<select id="dish-item" v-model="dishForm.itemId" required> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-item"
<option v-for="item in dishItemRows" :key="`dish-item-${item.id}`" :value="String(item.id)">{{ item.name }}</option> v-model="dishForm.itemId"
</select> :options="dishItemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div> </div>
<div class="field"> <div class="field">
<label for="dish-flavor">{{ t('pages.dish.flavor') }}</label> <label for="dish-flavor">{{ t('pages.dish.flavor') }}</label>
<select id="dish-flavor" v-model="dishForm.flavorId" required> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-flavor"
<option v-for="flavor in dishFlavorRows" :key="`dish-flavor-${flavor.id}`" :value="String(flavor.id)">{{ flavor.name }}</option> v-model="dishForm.flavorId"
</select> :options="dishFlavorSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.dish.flavor')"
/>
</div> </div>
</div> </div>
<div class="dish-form-row dish-form-row--3"> <div class="dish-form-row dish-form-row--3">
<div class="field"> <div class="field">
<label for="dish-secondary-material-1">{{ t('pages.dish.secondaryMaterial') }}</label> <label for="dish-secondary-material-1">{{ t('pages.dish.secondaryMaterial') }}</label>
<select id="dish-secondary-material-1" v-model="dishForm.secondaryMaterialItemIds[0]"> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-secondary-material-1"
<option v-for="item in dishItemRows" :key="`dish-secondary-material-1-${item.id}`" :value="String(item.id)">{{ item.name }}</option> v-model="dishForm.secondaryMaterialItemIds[0]"
</select> :options="optionalDishItemSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div> </div>
<div v-if="dishAllowsSecondSecondaryMaterial" class="field"> <div v-if="dishAllowsSecondSecondaryMaterial" class="field">
<label for="dish-secondary-material-2">{{ t('pages.dish.secondSecondaryMaterial') }}</label> <label for="dish-secondary-material-2">{{ t('pages.dish.secondSecondaryMaterial') }}</label>
<select id="dish-secondary-material-2" v-model="dishForm.secondaryMaterialItemIds[1]"> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-secondary-material-2"
<option v-for="item in dishItemRows" :key="`dish-secondary-material-2-${item.id}`" :value="String(item.id)">{{ item.name }}</option> v-model="dishForm.secondaryMaterialItemIds[1]"
</select> :options="optionalDishItemSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div> </div>
<div class="field"> <div class="field">
<label for="dish-pokemon-skill">{{ t('pages.dish.pokemonSkill') }}</label> <label for="dish-pokemon-skill">{{ t('pages.dish.pokemonSkill') }}</label>
<select id="dish-pokemon-skill" v-model="dishForm.pokemonSkillId"> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-pokemon-skill"
<option v-for="skill in dishSkillRows" :key="`dish-skill-${skill.id}`" :value="String(skill.id)">{{ skill.name }}</option> v-model="dishForm.pokemonSkillId"
</select> :options="optionalDishSkillSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.dish.pokemonSkill')"
/>
</div> </div>
</div> </div>
<TranslationFields <TranslationFields
@@ -2813,7 +3017,7 @@ onMounted(() => {
</form> </form>
<template #footer> <template #footer>
<button type="submit" form="admin-dish-form" class="link-button" :disabled="busy"> <button type="submit" form="admin-dish-form" class="link-button" :disabled="busy || !dishFormValid">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }} {{ busy ? t('common.saving') : t('common.save') }}
</button> </button>
@@ -2830,6 +3034,20 @@ onMounted(() => {
<label for="config-name">{{ t('common.name') }}</label> <label for="config-name">{{ t('common.name') }}</label>
<input id="config-name" v-model="configNameInput" :placeholder="configNamePlaceholder" :required="configNameRequired" /> <input id="config-name" v-model="configNameInput" :placeholder="configNamePlaceholder" :required="configNameRequired" />
</div> </div>
<div v-if="selectedConfig.supportsDescription" class="field">
<label for="config-description">{{ t('pages.admin.description') }}</label>
<textarea id="config-description" v-model="configForm.description"></textarea>
</div>
<div v-if="selectedConfig.supportsOpposite" class="field">
<label for="config-opposite">{{ t('pages.admin.opposite') }}</label>
<TagsSelect
id="config-opposite"
v-model="configForm.oppositeId"
:multiple="false"
:options="configOppositeOptions"
:placeholder="t('pages.admin.opposite')"
/>
</div>
<div v-if="selectedConfig.supportsItemDrop" class="check-row"> <div v-if="selectedConfig.supportsItemDrop" class="check-row">
<label> <label>
<input v-model="configForm.hasItemDrop" type="checkbox" /> <input v-model="configForm.hasItemDrop" type="checkbox" />
@@ -2842,18 +3060,6 @@ onMounted(() => {
{{ t('pages.admin.hasTrading') }} {{ t('pages.admin.hasTrading') }}
</label> </label>
</div> </div>
<div v-if="selectedConfig.supportsDefault" class="check-row">
<label>
<input v-model="configForm.isDefault" type="checkbox" />
{{ t('pages.admin.defaultCategory') }}
</label>
</div>
<div v-if="selectedConfig.supportsRateable" class="check-row">
<label>
<input v-model="configForm.isRateable" type="checkbox" />
{{ t('pages.admin.rateableCategory') }}
</label>
</div>
<div v-if="selectedConfig.supportsChangeLog" class="field"> <div v-if="selectedConfig.supportsChangeLog" class="field">
<label for="config-change-log">{{ t('pages.admin.changeLog') }}</label> <label for="config-change-log">{{ t('pages.admin.changeLog') }}</label>
<textarea id="config-change-log" v-model="configForm.changeLog"></textarea> <textarea id="config-change-log" v-model="configForm.changeLog"></textarea>

View File

@@ -11,11 +11,11 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconArtifact } from '../icons'; import { iconAdd, iconArtifact } from '../icons';
import { api, getAuthToken, type AncientArtifact, type AuthUser, type Options } from '../services/api'; import { api, type AncientArtifact, type AuthUser, type ListPage, type Options } from '../services/api';
import ItemEdit from './ItemEdit.vue'; import ItemEdit from './ItemEdit.vue';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t, locale } = useI18n();
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const artifacts = ref<AncientArtifact[]>([]); const artifacts = ref<AncientArtifact[]>([]);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
@@ -42,6 +42,52 @@ const artifactQuery = computed(() => ({
categoryId: categoryId.value, categoryId: categoryId.value,
tagIds: tagIds.value.join(',') tagIds: tagIds.value.join(',')
})); }));
type AncientArtifactListInitialData = {
options: Options | null;
page: ListPage<AncientArtifact> | null;
};
const { data: initialData } = useAsyncData<AncientArtifactListInitialData>(
`ancient-artifact-list-initial:${locale.value}`,
async () => {
const [optionsResult, artifactsResult] = await Promise.allSettled([
api.options(),
api.ancientArtifactsPage({
...artifactQuery.value,
cursor: null,
limit: listPageSize
})
]);
return {
options: optionsResult.status === 'fulfilled' ? optionsResult.value : null,
page: artifactsResult.status === 'fulfilled' ? artifactsResult.value : null
};
},
{ default: () => ({ options: null, page: null }) }
);
const initialPageLoaded = ref(false);
function applyInitialData(data: AncientArtifactListInitialData | null | undefined) {
if (!data) return;
if (!options.value && data.options) {
options.value = data.options;
}
if (initialPageLoaded.value || !data.page) {
return;
}
artifacts.value = data.page.items;
nextCursor.value = data.page.nextCursor;
hasMoreArtifacts.value = data.page.hasMore;
initialPageLoaded.value = true;
loading.value = false;
}
const showEditor = computed(() => route.name === 'ancient-artifact-new'); const showEditor = computed(() => route.name === 'ancient-artifact-new');
const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('items.create') === true); const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('items.create') === true);
@@ -83,6 +129,14 @@ async function loadArtifacts(reset = true) {
} }
nextCursor.value = page.nextCursor; nextCursor.value = page.nextCursor;
hasMoreArtifacts.value = page.hasMore; hasMoreArtifacts.value = page.hasMore;
initialPageLoaded.value = true;
} catch {
if (requestId === loadRequestId && reset) {
artifacts.value = [];
nextCursor.value = null;
hasMoreArtifacts.value = false;
initialPageLoaded.value = true;
}
} finally { } finally {
if (requestId === loadRequestId) { if (requestId === loadRequestId) {
loading.value = false; loading.value = false;
@@ -96,20 +150,28 @@ function loadMoreArtifacts() {
} }
onMounted(async () => { onMounted(async () => {
if (getAuthToken()) { try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
if (!options.value) {
try { try {
currentUser.value = (await api.me()).user; options.value = await api.options();
} catch { } catch {
currentUser.value = null; options.value = null;
} }
} }
options.value = await api.options(); if (!initialPageLoaded.value) {
await loadArtifacts(); await loadArtifacts();
}
}); });
watch(artifactQuery, () => { watch(artifactQuery, () => {
void loadArtifacts(); void loadArtifacts();
}); });
watch(initialData, applyInitialData, { immediate: true });
</script> </script>
<template> <template>

View File

@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue'; import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import { api, type DailyChecklistItem } from '../services/api'; import { api, type DailyChecklistItem, type ListPage } from '../services/api';
type ChecklistState = { type ChecklistState = {
date: string; date: string;
@@ -12,7 +12,7 @@ type ChecklistState = {
}; };
const checklistStateKey = 'pokopia_daily_checklist_state'; const checklistStateKey = 'pokopia_daily_checklist_state';
const { t } = useI18n(); const { t, locale } = useI18n();
const stateRefreshIntervalMs = 60_000; const stateRefreshIntervalMs = 60_000;
const checklistItems = ref<DailyChecklistItem[]>([]); const checklistItems = ref<DailyChecklistItem[]>([]);
const checkedTaskIds = ref<Set<number>>(new Set()); const checkedTaskIds = ref<Set<number>>(new Set());
@@ -25,6 +25,28 @@ const listPageSize = 20;
let stateRefreshTimer: number | null = null; let stateRefreshTimer: number | null = null;
let loadRequestId = 0; let loadRequestId = 0;
const { data: initialData } = await useAsyncData<ListPage<DailyChecklistItem> | null>(
`daily-checklist-initial:${locale.value}`,
async () => {
try {
return await api.dailyChecklistPage({
cursor: null,
limit: listPageSize
});
} catch {
return null;
}
},
{ default: () => null }
);
const initialPage = initialData.value;
checklistItems.value = initialPage?.items ?? [];
const initialPageLoaded = ref(initialPage !== null);
loading.value = !initialPageLoaded.value;
nextCursor.value = initialPage?.nextCursor ?? null;
hasMoreItems.value = initialPage?.hasMore ?? false;
function todayKey() { function todayKey() {
const today = new Date(); const today = new Date();
const year = today.getFullYear(); const year = today.getFullYear();
@@ -124,9 +146,17 @@ async function loadDailyChecklist(reset = true) {
} }
nextCursor.value = page.nextCursor; nextCursor.value = page.nextCursor;
hasMoreItems.value = page.hasMore; hasMoreItems.value = page.hasMore;
initialPageLoaded.value = true;
if (!page.hasMore) { if (!page.hasMore) {
syncChecklistState(); syncChecklistState();
} }
} catch {
if (requestId === loadRequestId && reset) {
checklistItems.value = [];
nextCursor.value = null;
hasMoreItems.value = false;
initialPageLoaded.value = true;
}
} finally { } finally {
if (requestId === loadRequestId) { if (requestId === loadRequestId) {
loading.value = false; loading.value = false;
@@ -141,8 +171,13 @@ function loadMoreDailyChecklist() {
onMounted(() => { onMounted(() => {
loadChecklistState(); loadChecklistState();
if (initialPageLoaded.value && !hasMoreItems.value) {
syncChecklistState();
}
stateRefreshTimer = window.setInterval(loadChecklistState, stateRefreshIntervalMs); stateRefreshTimer = window.setInterval(loadChecklistState, stateRefreshIntervalMs);
void loadDailyChecklist(); if (!initialPageLoaded.value) {
void loadDailyChecklist();
}
}); });
onUnmounted(() => { onUnmounted(() => {

View File

@@ -13,7 +13,6 @@ import TranslationFields from '../components/TranslationFields.vue';
import { iconAdd, iconCancel, iconDelete, iconDish, iconEdit, iconItem, iconSave } from '../icons'; import { iconAdd, iconCancel, iconDelete, iconDish, iconEdit, iconItem, iconSave } from '../icons';
import { import {
api, api,
getAuthToken,
type AuthUser, type AuthUser,
type Dish, type Dish,
type DishCategory, type DishCategory,
@@ -25,7 +24,7 @@ import {
type TranslationMap type TranslationMap
} from '../services/api'; } from '../services/api';
const { t } = useI18n(); const { t, locale } = useI18n();
const categories = ref<DishCategory[]>([]); const categories = ref<DishCategory[]>([]);
const activeCategoryId = ref(''); const activeCategoryId = ref('');
const loading = ref(true); const loading = ref(true);
@@ -74,7 +73,7 @@ const dishCategoryModalTitle = computed(() =>
); );
const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDish') : t('pages.dish.newDish'))); const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDish') : t('pages.dish.newDish')));
const itemSelectOptions = computed<TagsSelectOption[]>(() => const itemSelectOptions = computed<TagsSelectOption[]>(() =>
items.value.map((item) => ({ id: item.id, name: item.name })) items.value.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url }))
); );
const optionalItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...itemSelectOptions.value]); const optionalItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...itemSelectOptions.value]);
const categorySelectOptions = computed<TagsSelectOption[]>(() => categories.value.map((category) => ({ id: category.id, name: category.name }))); const categorySelectOptions = computed<TagsSelectOption[]>(() => categories.value.map((category) => ({ id: category.id, name: category.name })));
@@ -96,6 +95,24 @@ const dishFormValid = computed(
dishForm.value.mosslaxEffect.trim() !== '' dishForm.value.mosslaxEffect.trim() !== ''
); );
const { data: initialData } = await useAsyncData<DishCategory[] | null>(
`dish-initial:${locale.value}`,
async () => {
try {
return await api.dish();
} catch {
return null;
}
},
{ default: () => null }
);
const initialCategories = initialData.value;
categories.value = initialCategories ?? [];
activeCategoryId.value = categories.value[0] ? String(categories.value[0].id) : '';
const initialCategoriesLoaded = ref(initialCategories !== null);
loading.value = !initialCategoriesLoaded.value;
function itemImage(item: ItemLink) { function itemImage(item: ItemLink) {
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : null; return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : null;
} }
@@ -221,6 +238,7 @@ async function loadDish(showSkeleton = false) {
} }
categories.value = await api.dish(); categories.value = await api.dish();
activeCategoryId.value = categories.value[0] ? String(categories.value[0].id) : ''; activeCategoryId.value = categories.value[0] ? String(categories.value[0].id) : '';
initialCategoriesLoaded.value = true;
loading.value = false; loading.value = false;
} }
@@ -282,14 +300,18 @@ async function loadEditorOptions() {
async function loadPage() { async function loadPage() {
loading.value = true; loading.value = true;
if (getAuthToken()) { try {
try { currentUser.value = (await api.me()).user;
currentUser.value = (await api.me()).user; } catch {
} catch { currentUser.value = null;
currentUser.value = null; }
} try {
await Promise.all([initialCategoriesLoaded.value ? Promise.resolve() : loadDish(), loadEditorOptions()]);
} catch (error) {
message.value = errorText(error);
} finally {
loading.value = false;
} }
await Promise.all([loadDish(), loadEditorOptions()]);
} }
watch(categories, (nextCategories) => { watch(categories, (nextCategories) => {

View File

@@ -12,17 +12,18 @@ import PokeBallMark from '../components/PokeBallMark.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit, iconHabitat } from '../icons'; import { iconBack, iconEdit, iconHabitat } from '../icons';
import { applySeo } from '../seo'; import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
import { api, getAuthToken, type AuthUser, type HabitatDetail } from '../services/api'; import { api, type AuthUser, type HabitatDetail } from '../services/api';
import HabitatEdit from './HabitatEdit.vue'; import HabitatEdit from './HabitatEdit.vue';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t, locale } = useI18n();
const habitat = ref<HabitatDetail | null>(null); const habitat = ref<HabitatDetail | null>(null);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const detailTab = ref('details'); const detailTab = ref('details');
const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天']; const weathers = ['晴天', '阴天', '雨天'];
const habitatDetailRouteNames = new Set(['habitat-detail', 'habitat-edit']);
const showEditor = computed(() => route.name === 'habitat-edit'); const showEditor = computed(() => route.name === 'habitat-edit');
const canUpdateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.update') === true); const canUpdateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.update') === true);
const listPath = computed(() => (habitat.value?.isEventItem ? '/event-habitats' : '/habitats')); const listPath = computed(() => (habitat.value?.isEventItem ? '/event-habitats' : '/habitats'));
@@ -33,6 +34,44 @@ const detailTabs = computed<TabOption[]>(() => [
{ value: 'history', label: t('history.editHistory') } { value: 'history', label: t('history.editHistory') }
]); ]);
const { data: initialHabitat } = useAsyncData<HabitatDetail | null>(
`habitat-detail:${activeHabitatRouteId() ?? 'none'}:${locale.value}`,
async () => {
const routeId = activeHabitatRouteId();
if (!routeId) {
return null;
}
try {
return await api.habitatDetail(routeId);
} catch {
return null;
}
},
{ default: () => null }
);
const initialHabitatLoaded = ref(false);
const habitatSeo = computed(() =>
habitat.value && route.meta.editorModal !== true
? resolveSeo({
title: `${habitat.value.name} - ${t(habitat.value.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`,
description: t('seo.habitatDetailDescription', { name: habitat.value.name }),
canonicalPath: `/habitats/${habitat.value.id}`,
image: habitat.value.image?.url
})
: null
);
useHead(() => (habitatSeo.value ? resolvedSeoHead(habitatSeo.value) : {}));
function applyInitialHabitat(value: HabitatDetail | null | undefined) {
if (!value || initialHabitatLoaded.value) return;
habitat.value = value;
initialHabitatLoaded.value = true;
}
type PokemonRow = { type PokemonRow = {
id: number; id: number;
name: string; name: string;
@@ -73,6 +112,15 @@ function weatherLabel(value: string): string {
return labels[value] ?? value; return labels[value] ?? value;
} }
function activeHabitatRouteId(): string | null {
return typeof route.name === 'string' &&
habitatDetailRouteNames.has(route.name) &&
typeof route.params.id === 'string' &&
route.params.id.trim() !== ''
? route.params.id
: null;
}
const pokemonRows = computed<PokemonRow[]>(() => { const pokemonRows = computed<PokemonRow[]>(() => {
if (!habitat.value) return []; if (!habitat.value) return [];
@@ -119,28 +167,41 @@ const pokemonRows = computed<PokemonRow[]>(() => {
}); });
async function loadHabitatDetail() { async function loadHabitatDetail() {
const nextHabitat = await api.habitatDetail(String(route.params.id)); const routeId = activeHabitatRouteId();
habitat.value = nextHabitat; if (!routeId) {
initialHabitatLoaded.value = true;
return;
}
if (route.meta.editorModal !== true) { try {
applySeo({ const nextHabitat = await api.habitatDetail(routeId);
title: `${nextHabitat.name} - ${t(nextHabitat.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`, habitat.value = nextHabitat;
description: t('seo.habitatDetailDescription', { name: nextHabitat.name }), initialHabitatLoaded.value = true;
canonicalPath: `/habitats/${nextHabitat.id}`,
image: nextHabitat.image?.url if (route.meta.editorModal !== true) {
}); applySeo({
title: `${nextHabitat.name} - ${t(nextHabitat.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`,
description: t('seo.habitatDetailDescription', { name: nextHabitat.name }),
canonicalPath: `/habitats/${nextHabitat.id}`,
image: nextHabitat.image?.url
});
}
} catch {
habitat.value = null;
initialHabitatLoaded.value = true;
} }
} }
onMounted(async () => { onMounted(async () => {
if (getAuthToken()) { try {
try { currentUser.value = (await api.me()).user;
currentUser.value = (await api.me()).user; } catch {
} catch { currentUser.value = null;
currentUser.value = null; }
}
if (!initialHabitatLoaded.value) {
await loadHabitatDetail();
} }
await loadHabitatDetail();
}); });
watch( watch(
@@ -155,11 +216,17 @@ watch(
watch( watch(
() => route.params.id, () => route.params.id,
() => { () => {
if (!activeHabitatRouteId()) {
return;
}
habitat.value = null; habitat.value = null;
detailTab.value = 'details'; detailTab.value = 'details';
void loadHabitatDetail(); void loadHabitatDetail();
} }
); );
watch(initialHabitat, applyInitialHabitat, { immediate: true });
</script> </script>
<template> <template>

View File

@@ -13,7 +13,6 @@ import TranslationFields from '../components/TranslationFields.vue';
import { iconAdd, iconCancel, iconDelete, iconPokemon, iconSave } from '../icons'; import { iconAdd, iconCancel, iconDelete, iconPokemon, iconSave } from '../icons';
import { import {
api, api,
getAuthToken,
type AuthUser, type AuthUser,
type ConfigType, type ConfigType,
type EntityImage, type EntityImage,
@@ -74,9 +73,9 @@ const weatherOptions = computed(() => [
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : '')); const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== ''); const isEditing = computed(() => routeId.value !== '');
const isEventCreate = computed(() => route.name === 'event-habitat-new'); const isEventCreate = computed(() => route.name === 'event-habitat-new');
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name }))); const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url })));
const pokemonSelectOptions = computed(() => const pokemonSelectOptions = computed(() =>
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.displayId} ${pokemon.name}` })) pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.displayId} ${pokemon.name}`, thumbnailUrl: pokemon.image?.url }))
); );
const pageTitle = computed(() => const pageTitle = computed(() =>
isEditing.value isEditing.value
@@ -156,11 +155,6 @@ function habitatNameForSave() {
} }
async function loadCurrentUser() { async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try { try {
currentUser.value = (await api.me()).user; currentUser.value = (await api.me()).user;
} catch { } catch {

View File

@@ -8,7 +8,7 @@ import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import { iconAdd, iconHabitat } from '../icons'; import { iconAdd, iconHabitat } from '../icons';
import { api, getAuthToken, type AuthUser, type Habitat } from '../services/api'; import { api, type AuthUser, type Habitat, type ListPage } from '../services/api';
import HabitatEdit from './HabitatEdit.vue'; import HabitatEdit from './HabitatEdit.vue';
const props = defineProps<{ const props = defineProps<{
@@ -18,8 +18,7 @@ const props = defineProps<{
const habitats = ref<Habitat[]>([]); const habitats = ref<Habitat[]>([]);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t, locale } = useI18n();
const loading = ref(true);
const loadingMore = ref(false); const loadingMore = ref(false);
const nextCursor = ref<string | null>(null); const nextCursor = ref<string | null>(null);
const hasMoreHabitats = ref(false); const hasMoreHabitats = ref(false);
@@ -29,6 +28,36 @@ let loadRequestId = 0;
const query = computed(() => ({ const query = computed(() => ({
isEventItem: props.eventOnly ? 'true' : 'false' isEventItem: props.eventOnly ? 'true' : 'false'
})); }));
const { data: initialData } = useAsyncData<ListPage<Habitat> | null>(
`${props.eventOnly ? 'event-habitat-list-initial' : 'habitat-list-initial'}:${locale.value}`,
async () => {
try {
return await api.habitatsPage({
...query.value,
cursor: null,
limit: listPageSize
});
} catch {
return null;
}
},
{ default: () => null }
);
const initialPageLoaded = ref(false);
const loading = ref(true);
function applyInitialData(page: ListPage<Habitat> | null | undefined) {
if (!page || initialPageLoaded.value) return;
habitats.value = page.items;
nextCursor.value = page.nextCursor;
hasMoreHabitats.value = page.hasMore;
initialPageLoaded.value = true;
loading.value = false;
}
const showEditor = computed(() => route.name === 'habitat-new' || route.name === 'event-habitat-new'); const showEditor = computed(() => route.name === 'habitat-new' || route.name === 'event-habitat-new');
const canCreateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.create') === true); const canCreateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.create') === true);
const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventHabitats.title' : 'pages.habitats.title')); const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventHabitats.title' : 'pages.habitats.title'));
@@ -75,6 +104,14 @@ async function loadHabitats(reset = true) {
} }
nextCursor.value = page.nextCursor; nextCursor.value = page.nextCursor;
hasMoreHabitats.value = page.hasMore; hasMoreHabitats.value = page.hasMore;
initialPageLoaded.value = true;
} catch {
if (requestId === loadRequestId && reset) {
habitats.value = [];
nextCursor.value = null;
hasMoreHabitats.value = false;
initialPageLoaded.value = true;
}
} finally { } finally {
if (requestId === loadRequestId) { if (requestId === loadRequestId) {
loading.value = false; loading.value = false;
@@ -88,19 +125,21 @@ function loadMoreHabitats() {
} }
onMounted(async () => { onMounted(async () => {
if (getAuthToken()) { try {
try { currentUser.value = (await api.me()).user;
currentUser.value = (await api.me()).user; } catch {
} catch { currentUser.value = null;
currentUser.value = null; }
} if (!initialPageLoaded.value) {
await loadHabitats();
} }
await loadHabitats();
}); });
watch(query, () => { watch(query, () => {
void loadHabitats(); void loadHabitats();
}); });
watch(initialData, applyInitialData, { immediate: true });
</script> </script>
<template> <template>

View File

@@ -63,8 +63,27 @@ const showProjectUpdates = computed(
const showProjectUpdatesViewAll = computed(() => projectCommits.value.length > 0 || latestReleases.value.length > 0); const showProjectUpdatesViewAll = computed(() => projectCommits.value.length > 0 || latestReleases.value.length > 0);
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null)); const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
const { data: initialProjectUpdates } = await useAsyncData<ProjectUpdates | null>(
`home-project-updates:${locale.value}`,
async () => {
try {
return await api.projectUpdates({ limit: projectCommitPageSize });
} catch {
return null;
}
},
{ default: () => null }
);
projectUpdates.value = initialProjectUpdates.value;
projectCommits.value = initialProjectUpdates.value?.commits.items ?? [];
const initialProjectUpdatesLoaded = ref(initialProjectUpdates.value !== null);
projectUpdatesLoading.value = !initialProjectUpdatesLoaded.value;
onMounted(() => { onMounted(() => {
void loadProjectUpdates(); if (!initialProjectUpdatesLoaded.value) {
void loadProjectUpdates();
}
}); });
function sectionTitleKey(key: string) { function sectionTitleKey(key: string) {
@@ -81,9 +100,11 @@ async function loadProjectUpdates(): Promise<void> {
const updates = await api.projectUpdates({ limit: projectCommitPageSize }); const updates = await api.projectUpdates({ limit: projectCommitPageSize });
projectUpdates.value = updates; projectUpdates.value = updates;
projectCommits.value = updates.commits.items; projectCommits.value = updates.commits.items;
initialProjectUpdatesLoaded.value = true;
} catch { } catch {
projectUpdates.value = null; projectUpdates.value = null;
projectCommits.value = []; projectCommits.value = [];
initialProjectUpdatesLoaded.value = true;
} finally { } finally {
projectUpdatesLoading.value = false; projectUpdatesLoading.value = false;
} }

View File

@@ -12,8 +12,8 @@ import PokeBallMark from '../components/PokeBallMark.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons'; import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
import { applySeo } from '../seo'; import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/api'; import { api, type AuthUser, type ItemDetail } from '../services/api';
import ItemEdit from './ItemEdit.vue'; import ItemEdit from './ItemEdit.vue';
const route = useRoute(); const route = useRoute();
@@ -73,35 +73,91 @@ const possibleTagEvidenceSections = computed(() => [
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] } { key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] }
]); ]);
const { data: initialItem } = useAsyncData<ItemDetail | null>(
`item-detail:${String(route.name)}:${activeItemRouteId() ?? 'none'}:${locale.value}`,
async () => {
const routeId = activeItemRouteId();
if (!routeId) {
return null;
}
try {
const nextItem = await api.itemDetail(routeId);
return isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory ? null : nextItem;
} catch {
return null;
}
},
{ default: () => null }
);
const initialItemLoaded = ref(false);
const itemSeo = computed(() =>
item.value && route.meta.editorModal !== true
? resolveSeo({
title: `${item.value.name} - ${t(detailTitleKey.value)}`,
description: t(detailDescriptionKey.value, { name: item.value.name }),
canonicalPath: detailCanonicalPath.value,
image: item.value.image?.url
})
: null
);
useHead(() => (itemSeo.value ? resolvedSeoHead(itemSeo.value) : {}));
function applyInitialItem(value: ItemDetail | null | undefined) {
if (!value || initialItemLoaded.value) return;
item.value = value;
initialItemLoaded.value = true;
}
const customization = computed(() => { const customization = computed(() => {
if (!item.value) { if (!item.value) {
return []; return [];
} }
const dyeabilityLabels: Record<number, string> = {
1: t('pages.items.dyeable'),
2: t('pages.items.dualDyeable'),
3: t('pages.items.tripleDyeable')
};
return [ return [
item.value.customization.dyeable ? t('pages.items.dyeable') : '', dyeabilityLabels[item.value.customization.dyeability] ?? '',
item.value.customization.dualDyeable ? t('pages.items.dualDyeable') : '',
item.value.customization.patternEditable ? t('pages.items.patternEditable') : '' item.value.customization.patternEditable ? t('pages.items.patternEditable') : ''
].filter(Boolean); ].filter(Boolean);
}); });
async function loadItemDetail() { async function loadItemDetail() {
const nextItem = await api.itemDetail(String(route.params.id)); const routeId = activeItemRouteId();
if (!routeId) {
if (isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory) { initialItemLoaded.value = true;
await router.replace(route.name === 'ancient-artifact-edit' ? `/items/${nextItem.id}/edit` : `/items/${nextItem.id}`);
return; return;
} }
item.value = nextItem; try {
const nextItem = await api.itemDetail(routeId);
if (route.meta.editorModal !== true) { if (isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory) {
applySeo({ await router.replace(route.name === 'ancient-artifact-edit' ? `/items/${nextItem.id}/edit` : `/items/${nextItem.id}`);
title: `${nextItem.name} - ${t(detailTitleKey.value)}`, return;
description: t(detailDescriptionKey.value, { name: nextItem.name }), }
canonicalPath: detailCanonicalPath.value,
image: nextItem.image?.url item.value = nextItem;
}); initialItemLoaded.value = true;
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextItem.name} - ${t(detailTitleKey.value)}`,
description: t(detailDescriptionKey.value, { name: nextItem.name }),
canonicalPath: detailCanonicalPath.value,
image: nextItem.image?.url
});
}
} catch {
item.value = null;
initialItemLoaded.value = true;
} }
} }
@@ -109,15 +165,22 @@ function isItemDetailRouteName(value: unknown) {
return typeof value === 'string' && itemDetailRouteNames.has(value); return typeof value === 'string' && itemDetailRouteNames.has(value);
} }
function activeItemRouteId(): string | null {
return isItemDetailRouteName(route.name) && typeof route.params.id === 'string' && route.params.id.trim() !== ''
? route.params.id
: null;
}
onMounted(async () => { onMounted(async () => {
if (getAuthToken()) { try {
try { currentUser.value = (await api.me()).user;
currentUser.value = (await api.me()).user; } catch {
} catch { currentUser.value = null;
currentUser.value = null; }
}
if (!initialItemLoaded.value) {
await loadItemDetail();
} }
await loadItemDetail();
}); });
watch( watch(
@@ -134,11 +197,17 @@ watch(
watch( watch(
() => route.params.id, () => route.params.id,
() => { () => {
if (!activeItemRouteId()) {
return;
}
item.value = null; item.value = null;
detailTab.value = 'details'; detailTab.value = 'details';
void loadItemDetail(); void loadItemDetail();
} }
); );
watch(initialItem, applyInitialItem, { immediate: true });
</script> </script>
<template> <template>

View File

@@ -12,7 +12,6 @@ import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave } from '../icons'; import { iconCancel, iconSave } from '../icons';
import { import {
api, api,
getAuthToken,
type AuthUser, type AuthUser,
type ConfigType, type ConfigType,
type EntityImage, type EntityImage,
@@ -35,6 +34,9 @@ const loading = ref(true);
const busy = ref(false); const busy = ref(false);
const message = ref(''); const message = ref('');
const creatingSelect = ref(''); const creatingSelect = ref('');
type Dyeability = 0 | 1 | 2 | 3;
const itemForm = ref({ const itemForm = ref({
name: '', name: '',
details: '', details: '',
@@ -43,8 +45,7 @@ const itemForm = ref({
translations: {} as TranslationMap, translations: {} as TranslationMap,
categoryId: '', categoryId: '',
usageId: '', usageId: '',
dyeable: false, dyeability: 0 as Dyeability,
dualDyeable: false,
patternEditable: false, patternEditable: false,
noRecipe: false, noRecipe: false,
isEventItem: false, isEventItem: false,
@@ -56,8 +57,7 @@ const itemForm = ref({
type ItemCreateDefaults = { type ItemCreateDefaults = {
categoryId: string; categoryId: string;
usageId: string; usageId: string;
dyeable: boolean; dyeability: Dyeability;
dualDyeable: boolean;
patternEditable: boolean; patternEditable: boolean;
noRecipe: boolean; noRecipe: boolean;
acquisitionMethodIds: string[]; acquisitionMethodIds: string[];
@@ -101,6 +101,12 @@ const ancientArtifactOptions = computed(() => [
{ value: '', label: t('common.no') }, { value: '', label: t('common.no') },
...(options.value?.ancientArtifactCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? []) ...(options.value?.ancientArtifactCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
]); ]);
const dyeabilityOptions = computed<Array<{ value: Dyeability; label: string }>>(() => [
{ value: 0, label: t('pages.items.notDyeable') },
{ value: 1, label: t('pages.items.dyeable') },
{ value: 2, label: t('pages.items.dualDyeable') },
{ value: 3, label: t('pages.items.tripleDyeable') }
]);
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true); const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
const canUploadImage = computed(() => currentUser.value?.permissions.includes('items.upload') === true); const canUploadImage = computed(() => currentUser.value?.permissions.includes('items.upload') === true);
@@ -118,13 +124,26 @@ function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback; return error instanceof Error && error.message ? error.message : fallback;
} }
function defaultDyeability(value: { dyeability?: unknown; dualDyeable?: unknown; dyeable?: unknown }): Dyeability {
const dyeability = Number(value.dyeability);
if (Number.isInteger(dyeability) && dyeability >= 0 && dyeability <= 3) {
return dyeability as Dyeability;
}
if (value.dualDyeable === true) {
return 2;
}
if (value.dyeable === true) {
return 1;
}
return 0;
}
function readItemCreateDefaults(): ItemCreateDefaults { function readItemCreateDefaults(): ItemCreateDefaults {
if (typeof sessionStorage === 'undefined') { if (typeof sessionStorage === 'undefined') {
return { return {
categoryId: '', categoryId: '',
usageId: '', usageId: '',
dyeable: false, dyeability: 0,
dualDyeable: false,
patternEditable: false, patternEditable: false,
noRecipe: false, noRecipe: false,
acquisitionMethodIds: [] acquisitionMethodIds: []
@@ -137,8 +156,7 @@ function readItemCreateDefaults(): ItemCreateDefaults {
return { return {
categoryId: '', categoryId: '',
usageId: '', usageId: '',
dyeable: false, dyeability: 0,
dualDyeable: false,
patternEditable: false, patternEditable: false,
noRecipe: false, noRecipe: false,
acquisitionMethodIds: [] acquisitionMethodIds: []
@@ -149,8 +167,7 @@ function readItemCreateDefaults(): ItemCreateDefaults {
return { return {
categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '', categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '',
usageId: typeof parsedValue.usageId === 'string' ? parsedValue.usageId : '', usageId: typeof parsedValue.usageId === 'string' ? parsedValue.usageId : '',
dyeable: parsedValue.dyeable === true, dyeability: defaultDyeability(parsedValue),
dualDyeable: parsedValue.dualDyeable === true,
patternEditable: parsedValue.patternEditable === true, patternEditable: parsedValue.patternEditable === true,
noRecipe: parsedValue.noRecipe === true, noRecipe: parsedValue.noRecipe === true,
acquisitionMethodIds: Array.isArray(parsedValue.acquisitionMethodIds) acquisitionMethodIds: Array.isArray(parsedValue.acquisitionMethodIds)
@@ -161,8 +178,7 @@ function readItemCreateDefaults(): ItemCreateDefaults {
return { return {
categoryId: '', categoryId: '',
usageId: '', usageId: '',
dyeable: false, dyeability: 0,
dualDyeable: false,
patternEditable: false, patternEditable: false,
noRecipe: false, noRecipe: false,
acquisitionMethodIds: [] acquisitionMethodIds: []
@@ -186,8 +202,7 @@ function applyItemCreateDefaults(isEventItem: boolean) {
categoryId: categoryIds.has(defaults.categoryId) ? defaults.categoryId : '', categoryId: categoryIds.has(defaults.categoryId) ? defaults.categoryId : '',
usageId: usageIds.has(defaults.usageId) ? defaults.usageId : '', usageId: usageIds.has(defaults.usageId) ? defaults.usageId : '',
ancientArtifactCategoryId: isAncientArtifactCreate.value ? String(loadedOptions.ancientArtifactCategories[0]?.id ?? '') : '', ancientArtifactCategoryId: isAncientArtifactCreate.value ? String(loadedOptions.ancientArtifactCategories[0]?.id ?? '') : '',
dyeable: defaults.dyeable, dyeability: defaults.dyeability,
dualDyeable: defaults.dualDyeable,
patternEditable: defaults.patternEditable, patternEditable: defaults.patternEditable,
noRecipe: defaults.noRecipe, noRecipe: defaults.noRecipe,
isEventItem, isEventItem,
@@ -215,11 +230,6 @@ async function loadOptions() {
} }
async function loadCurrentUser() { async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try { try {
currentUser.value = (await api.me()).user; currentUser.value = (await api.me()).user;
} catch { } catch {
@@ -243,8 +253,7 @@ async function loadEditor() {
translations: item.translations ?? {}, translations: item.translations ?? {},
categoryId: String(item.category.id), categoryId: String(item.category.id),
usageId: item.usage ? String(item.usage.id) : '', usageId: item.usage ? String(item.usage.id) : '',
dyeable: item.customization.dyeable, dyeability: defaultDyeability(item.customization),
dualDyeable: item.customization.dualDyeable,
patternEditable: item.customization.patternEditable, patternEditable: item.customization.patternEditable,
noRecipe: item.noRecipe, noRecipe: item.noRecipe,
isEventItem: item.isEventItem, isEventItem: item.isEventItem,
@@ -299,8 +308,7 @@ async function saveItem() {
translations: itemForm.value.translations, translations: itemForm.value.translations,
categoryId: Number(itemForm.value.categoryId), categoryId: Number(itemForm.value.categoryId),
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null, usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
dyeable: itemForm.value.dyeable, dyeability: itemForm.value.dyeability,
dualDyeable: itemForm.value.dualDyeable,
patternEditable: itemForm.value.patternEditable, patternEditable: itemForm.value.patternEditable,
noRecipe: itemForm.value.noRecipe, noRecipe: itemForm.value.noRecipe,
isEventItem: itemForm.value.isEventItem, isEventItem: itemForm.value.isEventItem,
@@ -424,9 +432,19 @@ onMounted(() => {
</fieldset> </fieldset>
</div> </div>
<div class="field">
<fieldset class="radio-group">
<legend>{{ t('pages.items.dyeability') }}</legend>
<div class="radio-group__options">
<label v-for="option in dyeabilityOptions" :key="option.value" class="radio-group__option">
<input v-model="itemForm.dyeability" type="radio" name="item-dyeability" :value="option.value" />
<span>{{ option.label }}</span>
</label>
</div>
</fieldset>
</div>
<div class="check-row"> <div class="check-row">
<label><input v-model="itemForm.dyeable" type="checkbox" /> {{ t('pages.items.dyeable') }}</label>
<label><input v-model="itemForm.dualDyeable" type="checkbox" /> {{ t('pages.items.dualDyeable') }}</label>
<label><input v-model="itemForm.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label> <label><input v-model="itemForm.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label>
<label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> {{ t('pages.items.noRecipe') }}</label> <label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> {{ t('pages.items.noRecipe') }}</label>
<label><input v-model="itemForm.isEventItem" type="checkbox" :disabled="isEventCreate" /> {{ t('pages.items.eventItem') }}</label> <label><input v-model="itemForm.isEventItem" type="checkbox" :disabled="isEventCreate" /> {{ t('pages.items.eventItem') }}</label>
@@ -498,46 +516,6 @@ onMounted(() => {
min-width: 0; min-width: 0;
} }
.radio-group {
display: grid;
gap: 7px;
min-width: 0;
min-inline-size: 0;
margin: 0;
padding: 0;
border: 0;
}
.radio-group legend {
padding: 0;
color: var(--ink-soft);
font-size: 14px;
font-weight: 850;
}
.radio-group__options {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
}
.radio-group__option {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 36px;
color: var(--ink-soft);
font-weight: 850;
cursor: pointer;
}
.radio-group__option input {
width: 18px;
height: 18px;
accent-color: var(--pokemon-blue);
}
@media (max-width: 720px) { @media (max-width: 720px) {
.item-edit-row--name-price, .item-edit-row--name-price,
.item-edit-row--category-usage { .item-edit-row--category-usage {

View File

@@ -11,7 +11,7 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconChevronDown, iconChevronUp, iconItem } from '../icons'; import { iconAdd, iconChevronDown, iconChevronUp, iconItem } from '../icons';
import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api'; import { api, type AuthUser, type Item, type ListPage, type Options } from '../services/api';
import ItemEdit from './ItemEdit.vue'; import ItemEdit from './ItemEdit.vue';
const props = defineProps<{ const props = defineProps<{
@@ -21,7 +21,7 @@ const props = defineProps<{
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { t } = useI18n(); const { t, locale } = useI18n();
const items = ref<Item[]>([]); const items = ref<Item[]>([]);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const loading = ref(true); const loading = ref(true);
@@ -48,11 +48,12 @@ const suppressNextItemClick = ref(false);
const dragSourceItems = ref<Item[]>([]); const dragSourceItems = ref<Item[]>([]);
const dropCommitted = ref(false); const dropCommitted = ref(false);
type Dyeability = 0 | 1 | 2 | 3;
type ItemCreateDefaults = { type ItemCreateDefaults = {
categoryId: string; categoryId: string;
usageId: string; usageId: string;
dyeable: boolean; dyeability: Dyeability;
dualDyeable: boolean;
patternEditable: boolean; patternEditable: boolean;
noRecipe: boolean; noRecipe: boolean;
acquisitionMethodIds: string[]; acquisitionMethodIds: string[];
@@ -63,8 +64,7 @@ const itemCreateDefaultsStorageKey = 'pokopia_item_create_defaults';
const emptyItemCreateDefaults = (): ItemCreateDefaults => ({ const emptyItemCreateDefaults = (): ItemCreateDefaults => ({
categoryId: '', categoryId: '',
usageId: '', usageId: '',
dyeable: false, dyeability: 0,
dualDyeable: false,
patternEditable: false, patternEditable: false,
noRecipe: false, noRecipe: false,
acquisitionMethodIds: [] acquisitionMethodIds: []
@@ -96,6 +96,12 @@ const categoryTabs = computed<TabOption[]>(() => [
{ value: '', label: t('common.all') }, { value: '', label: t('common.all') },
...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? []) ...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
]); ]);
const dyeabilityOptions = computed<Array<{ value: Dyeability; label: string }>>(() => [
{ value: 0, label: t('pages.items.notDyeable') },
{ value: 1, label: t('pages.items.dyeable') },
{ value: 2, label: t('pages.items.dualDyeable') },
{ value: 3, label: t('pages.items.tripleDyeable') }
]);
const itemQuery = computed(() => ({ const itemQuery = computed(() => ({
search: search.value, search: search.value,
@@ -104,14 +110,59 @@ const itemQuery = computed(() => ({
tagIds: tagIds.value.join(','), tagIds: tagIds.value.join(','),
isEventItem: props.eventOnly isEventItem: props.eventOnly
})); }));
type ItemListInitialData = {
options: Options | null;
page: ListPage<Item> | null;
};
const { data: initialData } = useAsyncData<ItemListInitialData>(
`${props.eventOnly ? 'event-item-list-initial' : 'item-list-initial'}:${locale.value}`,
async () => {
const [optionsResult, itemsResult] = await Promise.allSettled([
api.options(),
api.itemsPage({
...itemQuery.value,
cursor: null,
limit: listPageSize
})
]);
return {
options: optionsResult.status === 'fulfilled' ? optionsResult.value : null,
page: itemsResult.status === 'fulfilled' ? itemsResult.value : null
};
},
{ default: () => ({ options: null, page: null }) }
);
const initialPageLoaded = ref(false);
function applyInitialData(data: ItemListInitialData | null | undefined) {
if (!data) return;
if (!options.value && data.options) {
options.value = data.options;
}
if (initialPageLoaded.value || !data.page) {
return;
}
items.value = data.page.items;
nextCursor.value = data.page.nextCursor;
hasMoreItems.value = data.page.hasMore;
initialPageLoaded.value = true;
loading.value = false;
}
const showEditor = computed(() => route.name === 'item-new' || route.name === 'event-item-new'); const showEditor = computed(() => route.name === 'item-new' || route.name === 'event-item-new');
const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true); const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true);
const hasItemCreateDefaults = computed( const hasItemCreateDefaults = computed(
() => () =>
itemCreateDefaults.value.categoryId !== '' || itemCreateDefaults.value.categoryId !== '' ||
itemCreateDefaults.value.usageId !== '' || itemCreateDefaults.value.usageId !== '' ||
itemCreateDefaults.value.dyeable || itemCreateDefaults.value.dyeability !== 0 ||
itemCreateDefaults.value.dualDyeable ||
itemCreateDefaults.value.patternEditable || itemCreateDefaults.value.patternEditable ||
itemCreateDefaults.value.noRecipe || itemCreateDefaults.value.noRecipe ||
itemCreateDefaults.value.acquisitionMethodIds.length > 0 itemCreateDefaults.value.acquisitionMethodIds.length > 0
@@ -172,6 +223,20 @@ function menuPositionForEvent(event: MouseEvent | KeyboardEvent) {
return clampMenuPosition(window.innerWidth / 2, window.innerHeight / 2); return clampMenuPosition(window.innerWidth / 2, window.innerHeight / 2);
} }
function defaultDyeability(value: { dyeability?: unknown; dualDyeable?: unknown; dyeable?: unknown }): Dyeability {
const dyeability = Number(value.dyeability);
if (Number.isInteger(dyeability) && dyeability >= 0 && dyeability <= 3) {
return dyeability as Dyeability;
}
if (value.dualDyeable === true) {
return 2;
}
if (value.dyeable === true) {
return 1;
}
return 0;
}
function readItemCreateDefaults(): ItemCreateDefaults { function readItemCreateDefaults(): ItemCreateDefaults {
if (typeof sessionStorage === 'undefined') { if (typeof sessionStorage === 'undefined') {
return emptyItemCreateDefaults(); return emptyItemCreateDefaults();
@@ -187,8 +252,7 @@ function readItemCreateDefaults(): ItemCreateDefaults {
return { return {
categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '', categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '',
usageId: typeof parsedValue.usageId === 'string' ? parsedValue.usageId : '', usageId: typeof parsedValue.usageId === 'string' ? parsedValue.usageId : '',
dyeable: parsedValue.dyeable === true, dyeability: defaultDyeability(parsedValue),
dualDyeable: parsedValue.dualDyeable === true,
patternEditable: parsedValue.patternEditable === true, patternEditable: parsedValue.patternEditable === true,
noRecipe: parsedValue.noRecipe === true, noRecipe: parsedValue.noRecipe === true,
acquisitionMethodIds: Array.isArray(parsedValue.acquisitionMethodIds) acquisitionMethodIds: Array.isArray(parsedValue.acquisitionMethodIds)
@@ -458,6 +522,14 @@ async function loadItems(reset = true) {
} }
nextCursor.value = page.nextCursor; nextCursor.value = page.nextCursor;
hasMoreItems.value = page.hasMore; hasMoreItems.value = page.hasMore;
initialPageLoaded.value = true;
} catch {
if (requestId === loadRequestId && reset) {
items.value = [];
nextCursor.value = null;
hasMoreItems.value = false;
initialPageLoaded.value = true;
}
} finally { } finally {
if (requestId === loadRequestId) { if (requestId === loadRequestId) {
loading.value = false; loading.value = false;
@@ -473,16 +545,22 @@ function loadMoreItems() {
onMounted(async () => { onMounted(async () => {
document.addEventListener('pointerdown', onCreateDefaultsDocumentPointerDown); document.addEventListener('pointerdown', onCreateDefaultsDocumentPointerDown);
document.addEventListener('keydown', onDocumentKeydown); document.addEventListener('keydown', onDocumentKeydown);
if (getAuthToken()) { try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
if (!options.value) {
try { try {
currentUser.value = (await api.me()).user; options.value = await api.options();
} catch { } catch {
currentUser.value = null; options.value = null;
} }
} }
options.value = await api.options();
sanitizeItemCreateDefaults(); sanitizeItemCreateDefaults();
await loadItems(); if (!initialPageLoaded.value) {
await loadItems();
}
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -493,6 +571,8 @@ onBeforeUnmount(() => {
watch(itemQuery, () => { watch(itemQuery, () => {
void loadItems(); void loadItems();
}); });
watch(initialData, applyInitialData, { immediate: true });
watch(itemCreateDefaults, persistItemCreateDefaults, { deep: true }); watch(itemCreateDefaults, persistItemCreateDefaults, { deep: true });
watch(showEditor, () => { watch(showEditor, () => {
closeCreateDefaultsMenu(); closeCreateDefaultsMenu();
@@ -576,9 +656,17 @@ watch(itemSortingAllowed, (allowed) => {
/> />
</div> </div>
<fieldset class="radio-group">
<legend>{{ t('pages.items.dyeability') }}</legend>
<div class="radio-group__options">
<label v-for="option in dyeabilityOptions" :key="option.value" class="radio-group__option">
<input v-model="itemCreateDefaults.dyeability" type="radio" name="item-default-dyeability" :value="option.value" />
<span>{{ option.label }}</span>
</label>
</div>
</fieldset>
<div class="check-row item-create-defaults-menu__checks"> <div class="check-row item-create-defaults-menu__checks">
<label><input v-model="itemCreateDefaults.dyeable" type="checkbox" /> {{ t('pages.items.dyeable') }}</label>
<label><input v-model="itemCreateDefaults.dualDyeable" type="checkbox" /> {{ t('pages.items.dualDyeable') }}</label>
<label><input v-model="itemCreateDefaults.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label> <label><input v-model="itemCreateDefaults.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label>
<label><input v-model="itemCreateDefaults.noRecipe" type="checkbox" /> {{ t('pages.items.noRecipe') }}</label> <label><input v-model="itemCreateDefaults.noRecipe" type="checkbox" /> {{ t('pages.items.noRecipe') }}</label>
</div> </div>

View File

@@ -3,7 +3,7 @@ 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 { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import LifeRatingControl from '../components/LifeRatingControl.vue'; import ConfirmDialog from '../components/ConfirmDialog.vue';
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue'; import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue'; import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
@@ -28,10 +28,8 @@ import {
} from '../icons'; } from '../icons';
import { import {
api, api,
getAuthToken,
moderationUpdateEvent, moderationUpdateEvent,
onAuthTokenChange, onAuthChange,
setAuthToken,
type AiModerationStatus, type AiModerationStatus,
type AuthUser, type AuthUser,
type CommentSort, type CommentSort,
@@ -40,6 +38,7 @@ import {
type LifeReactionType, type LifeReactionType,
type ModerationUpdateDetail type ModerationUpdateDetail
} from '../services/api'; } from '../services/api';
import { resolvedSeoHead, resolveSeo } from '../seo';
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const route = useRoute(); const route = useRoute();
@@ -64,11 +63,11 @@ const commentErrors = ref<Record<string, string>>({});
const reactionPickerPostId = ref<number | null>(null); const reactionPickerPostId = ref<number | null>(null);
const reactionBusyPostId = ref<number | null>(null); const reactionBusyPostId = ref<number | null>(null);
const reactionErrors = ref<Record<number, string>>({}); const reactionErrors = ref<Record<number, string>>({});
const ratingBusyPostId = ref<number | null>(null);
const ratingErrors = ref<Record<number, string>>({});
const moderationBusyPostId = ref<number | null>(null); const moderationBusyPostId = ref<number | null>(null);
const moderationErrors = ref<Record<number, string>>({}); const moderationErrors = ref<Record<number, string>>({});
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null); const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
const pendingDeleteComment = ref<LifeComment | null>(null);
const deleteConfirmBusy = ref(false);
const lifeCommentPageSize = 20; const lifeCommentPageSize = 20;
const commentMaxLength = 1000; const commentMaxLength = 1000;
let removeAuthListener: (() => void) | null = null; let removeAuthListener: (() => void) | null = null;
@@ -87,7 +86,6 @@ function can(permissionKey: string) {
const canComment = computed(() => can('life.comments.create')); const canComment = computed(() => can('life.comments.create'));
const canLikeComments = computed(() => can('life.comments.like')); const canLikeComments = computed(() => can('life.comments.like'));
const canReact = computed(() => can('life.reactions.set')); const canReact = computed(() => can('life.reactions.set'));
const canRate = computed(() => can('life.ratings.set'));
const canCommentOnPost = computed(() => canComment.value && post.value?.moderationStatus === 'approved'); const canCommentOnPost = computed(() => canComment.value && post.value?.moderationStatus === 'approved');
const commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [ const commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
{ value: 'oldest', label: t('pages.life.sortOldest') }, { value: 'oldest', label: t('pages.life.sortOldest') },
@@ -101,18 +99,21 @@ function routePostId() {
return Array.isArray(value) ? value[0] : value; return Array.isArray(value) ? value[0] : value;
} }
async function loadCurrentUser() { function summaryText(value: string, maxLength: number) {
if (!getAuthToken()) { const normalized = value.replace(/\s+/g, ' ').trim();
currentUser.value = null; if (normalized.length <= maxLength) {
return; return normalized;
} }
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}...`;
}
async function loadCurrentUser() {
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);
} }
} }
@@ -133,6 +134,41 @@ function resetCommentsFromPost(nextPost: LifePost) {
commentErrors.value = {}; commentErrors.value = {};
} }
const { data: initialPost } = await useAsyncData<LifePost | null>(
`life-post-detail:${String(routePostId())}:${locale.value}`,
async () => {
const id = routePostId();
if (!id) {
return null;
}
try {
return await api.lifePost(id);
} catch {
return null;
}
},
{ default: () => null }
);
if (initialPost.value) {
post.value = initialPost.value;
resetCommentsFromPost(initialPost.value);
}
const initialPostLoaded = ref(initialPost.value !== null);
loading.value = !initialPostLoaded.value;
const postSeo = computed(() =>
post.value
? resolveSeo({
title: `${summaryText(post.value.body, 64) || t('pages.life.detailTitle')} - ${t('pages.life.title')}`,
description: summaryText(post.value.body, 155) || t('pages.life.detailSubtitle'),
canonicalPath: `/life/${post.value.id}`
})
: null
);
useHead(() => (postSeo.value ? resolvedSeoHead(postSeo.value) : {}));
async function loadPost() { async function loadPost() {
const id = routePostId(); const id = routePostId();
if (!id) { if (!id) {
@@ -147,9 +183,11 @@ async function loadPost() {
const nextPost = await api.lifePost(id); const nextPost = await api.lifePost(id);
post.value = nextPost; post.value = nextPost;
resetCommentsFromPost(nextPost); resetCommentsFromPost(nextPost);
initialPostLoaded.value = true;
void loadComments(true); void loadComments(true);
} catch (error) { } catch (error) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed'); loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
initialPostLoaded.value = true;
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -237,10 +275,6 @@ function isReactionBusy(postId: number) {
return reactionBusyPostId.value === postId; return reactionBusyPostId.value === postId;
} }
function isRatingBusy(postId: number) {
return ratingBusyPostId.value === postId;
}
function canManage(currentPost: LifePost) { function canManage(currentPost: LifePost) {
return (currentUser.value?.id === currentPost.author?.id && can('life.posts.update')) || can('life.posts.update-any'); return (currentUser.value?.id === currentPost.author?.id && can('life.posts.update')) || can('life.posts.update-any');
} }
@@ -274,10 +308,6 @@ function canUseReactions() {
return canReact.value && reactionBusyPostId.value === null; return canReact.value && reactionBusyPostId.value === null;
} }
function canUseRatings(currentPost: LifePost) {
return canRate.value && ratingBusyPostId.value === null && currentPost.moderationStatus === 'approved' && currentPost.category?.isRateable === true;
}
function reactionTotal(currentPost: LifePost) { function reactionTotal(currentPost: LifePost) {
return reactionOptions.reduce((count, option) => count + (currentPost.reactionCounts[option.type] ?? 0), 0); return reactionOptions.reduce((count, option) => count + (currentPost.reactionCounts[option.type] ?? 0), 0);
} }
@@ -504,25 +534,6 @@ async function toggleReaction(currentPost: LifePost, reactionType: LifeReactionT
} }
} }
async function toggleRating(currentPost: LifePost, rating: number) {
if (!canUseRatings(currentPost)) {
return;
}
ratingBusyPostId.value = currentPost.id;
clearRatingError(currentPost.id);
try {
const updatedPost =
currentPost.myRating === rating ? await api.deleteLifeRating(currentPost.id) : await api.setLifeRating(currentPost.id, rating);
replacePost(updatedPost);
} catch (error) {
setRatingError(currentPost.id, error instanceof Error && error.message ? error.message : t('pages.life.ratingFailed'));
} finally {
ratingBusyPostId.value = null;
}
}
function setReactionError(postId: number, message: string) { function setReactionError(postId: number, message: string) {
reactionErrors.value = { ...reactionErrors.value, [postId]: message }; reactionErrors.value = { ...reactionErrors.value, [postId]: message };
} }
@@ -533,16 +544,6 @@ function clearReactionError(postId: number) {
reactionErrors.value = nextErrors; reactionErrors.value = nextErrors;
} }
function setRatingError(postId: number, message: string) {
ratingErrors.value = { ...ratingErrors.value, [postId]: message };
}
function clearRatingError(postId: number) {
const nextErrors = { ...ratingErrors.value };
delete nextErrors[postId];
ratingErrors.value = nextErrors;
}
function setCommentError(key: string, message: string) { function setCommentError(key: string, message: string) {
commentErrors.value = { ...commentErrors.value, [key]: message }; commentErrors.value = { ...commentErrors.value, [key]: message };
} }
@@ -668,10 +669,6 @@ function markOwnCommentDeleted(items: LifeComment[], id: number): boolean {
} }
async function deleteComment(comment: LifeComment) { async function deleteComment(comment: LifeComment) {
if (!window.confirm(t('pages.life.deleteCommentConfirm'))) {
return;
}
const key = replyKey(comment.id); const key = replyKey(comment.id);
clearCommentError(key); clearCommentError(key);
@@ -698,6 +695,33 @@ async function deleteComment(comment: LifeComment) {
} }
} }
function requestDeleteComment(comment: LifeComment) {
pendingDeleteComment.value = comment;
}
function closeDeleteConfirm() {
if (deleteConfirmBusy.value) {
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 restoreComment(comment: LifeComment) { async function restoreComment(comment: LifeComment) {
const key = replyKey(comment.id); const key = replyKey(comment.id);
commentBusyKey.value = key; commentBusyKey.value = key;
@@ -793,9 +817,13 @@ onMounted(() => {
document.addEventListener('click', closeReactionPickerFromDocument); document.addEventListener('click', closeReactionPickerFromDocument);
document.addEventListener('keydown', closeReactionPickerFromKeyboard); document.addEventListener('keydown', closeReactionPickerFromKeyboard);
window.addEventListener(moderationUpdateEvent, handleModerationUpdate); window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
void loadCurrentUser(); void (async () => {
void loadPost(); await loadCurrentUser();
removeAuthListener = onAuthTokenChange(() => { if (!initialPostLoaded.value || currentUser.value) {
await loadPost();
}
})();
removeAuthListener = onAuthChange(() => {
void loadCurrentUser(); void loadCurrentUser();
void loadPost(); void loadPost();
}); });
@@ -860,8 +888,7 @@ onUnmounted(() => {
<p class="life-post__body">{{ post.body }}</p> <p class="life-post__body">{{ post.body }}</p>
<div v-if="post.category || post.gameVersion" class="life-post__tags" :aria-label="t('pages.life.postMeta')"> <div v-if="post.gameVersion" class="life-post__tags" :aria-label="t('pages.life.postMeta')">
<span v-if="post.category" class="life-post__tag">{{ post.category.name }}</span>
<span v-if="post.gameVersion" class="life-post__tag life-post__tag--version"> <span v-if="post.gameVersion" class="life-post__tag life-post__tag--version">
<Icon :icon="iconVersion" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconVersion" class="ui-icon" aria-hidden="true" />
{{ post.gameVersion.name }} {{ post.gameVersion.name }}
@@ -875,16 +902,6 @@ onUnmounted(() => {
<div class="life-post__engagement"> <div class="life-post__engagement">
<div class="life-post__engagement-actions"> <div class="life-post__engagement-actions">
<LifeRatingControl
v-if="post.category?.isRateable"
:rating-average="post.ratingAverage"
:rating-count="post.ratingCount"
:my-rating="post.myRating"
:disabled="!canUseRatings(post)"
:busy="isRatingBusy(post.id)"
@rate="toggleRating(post, $event)"
/>
<div class="life-reactions"> <div class="life-reactions">
<div class="life-reaction-control"> <div class="life-reaction-control">
<button <button
@@ -999,7 +1016,6 @@ onUnmounted(() => {
<span>{{ post.moderationReason }}</span> <span>{{ post.moderationReason }}</span>
</p> </p>
<p v-if="ratingErrors[post.id]" class="life-form__error" role="alert">{{ ratingErrors[post.id] }}</p>
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p> <p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
<p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p> <p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>
@@ -1117,7 +1133,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('pages.life.deleteComment')" :aria-label="t('pages.life.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('pages.life.deleteComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
@@ -1234,7 +1250,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('pages.life.deleteComment')" :aria-label="t('pages.life.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('pages.life.deleteComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
@@ -1290,6 +1306,18 @@ onUnmounted(() => {
<h2>{{ t('pages.life.empty') }}</h2> <h2>{{ t('pages.life.empty') }}</h2>
</div> </div>
</div> </div>
<ConfirmDialog
v-if="pendingDeleteComment"
:title="t('pages.life.deleteComment')"
:message="t('pages.life.deleteCommentConfirm')"
:confirm-label="t('common.delete')"
:cancel-label="t('common.cancel')"
:close-label="t('common.close')"
:busy="deleteConfirmBusy"
@cancel="closeDeleteConfirm"
@confirm="confirmDeleteComment"
/>
</div> </div>
</section> </section>
</template> </template>

View File

@@ -2,8 +2,8 @@
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import FilterPanel from '../components/FilterPanel.vue'; import FilterPanel from '../components/FilterPanel.vue';
import LifeRatingControl from '../components/LifeRatingControl.vue';
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue'; import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue'; import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
import Modal from '../components/Modal.vue'; import Modal from '../components/Modal.vue';
@@ -35,18 +35,16 @@ import {
} from '../icons'; } from '../icons';
import { import {
api, api,
getAuthToken,
moderationUpdateEvent, moderationUpdateEvent,
onAuthTokenChange, onAuthChange,
setAuthToken,
type AiModerationStatus, type AiModerationStatus,
type AuthUser, type AuthUser,
type CommentSort, type CommentSort,
type GameVersion, type GameVersion,
type Language, type Language,
type LifeCategory,
type LifeComment, type LifeComment,
type LifePost, type LifePost,
type LifePostsPage,
type LifeReactionType, type LifeReactionType,
type ModerationUpdateDetail type ModerationUpdateDetail
} from '../services/api'; } from '../services/api';
@@ -62,12 +60,12 @@ type LifeCommentPageState = {
error: string; error: string;
}; };
type LifePostSort = 'latest' | 'oldest' | 'top-rated'; type LifePostSort = 'latest' | 'oldest';
type LifeFeedScope = 'all' | 'following'; type LifeFeedScope = 'all' | 'following';
type PendingLifeDelete = { type: 'post'; post: LifePost } | { type: 'comment'; post: LifePost; comment: LifeComment };
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const posts = ref<LifePost[]>([]); const posts = ref<LifePost[]>([]);
const lifeCategories = ref<LifeCategory[]>([]);
const gameVersions = ref<GameVersion[]>([]); const gameVersions = ref<GameVersion[]>([]);
const languages = ref<Language[]>([]); const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
@@ -77,14 +75,11 @@ const authReady = ref(false);
const busy = ref(false); const busy = ref(false);
const searchDraft = ref(''); const searchDraft = ref('');
const submittedSearch = ref(''); const submittedSearch = ref('');
const activeCategoryId = ref('all');
const activeLanguageCode = ref('all'); const activeLanguageCode = ref('all');
const activeGameVersionId = ref('all'); const activeGameVersionId = ref('all');
const activeRateableFilter = ref('all');
const activeSort = ref<LifePostSort>('latest'); const activeSort = ref<LifePostSort>('latest');
const activeFeedScope = ref<LifeFeedScope>('all'); const activeFeedScope = ref<LifeFeedScope>('all');
const body = ref(''); const body = ref('');
const selectedCategoryId = ref('');
const selectedGameVersionId = ref(''); const selectedGameVersionId = ref('');
const editingPostId = ref<number | null>(null); const editingPostId = ref<number | null>(null);
const postModalOpen = ref(false); const postModalOpen = ref(false);
@@ -101,11 +96,11 @@ const commentErrors = ref<Record<string, string>>({});
const reactionPickerPostId = ref<number | null>(null); const reactionPickerPostId = ref<number | null>(null);
const reactionBusyPostId = ref<number | null>(null); const reactionBusyPostId = ref<number | null>(null);
const reactionErrors = ref<Record<number, string>>({}); const reactionErrors = ref<Record<number, string>>({});
const ratingBusyPostId = ref<number | null>(null);
const ratingErrors = ref<Record<number, string>>({});
const moderationBusyPostId = ref<number | null>(null); const moderationBusyPostId = ref<number | null>(null);
const moderationErrors = ref<Record<number, string>>({}); const moderationErrors = ref<Record<number, string>>({});
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null); const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
const pendingDelete = ref<PendingLifeDelete | null>(null);
const deleteConfirmBusy = ref(false);
const bodyInput = ref<HTMLTextAreaElement | null>(null); const bodyInput = ref<HTMLTextAreaElement | null>(null);
const loadMoreSentinel = ref<HTMLElement | null>(null); const loadMoreSentinel = ref<HTMLElement | null>(null);
const lifePostPageSize = 20; const lifePostPageSize = 20;
@@ -120,9 +115,54 @@ let postsRequestId = 0;
const nextCursor = ref<string | null>(null); const nextCursor = ref<string | null>(null);
const hasMorePosts = ref(false); const hasMorePosts = ref(false);
const loadMorePaused = ref(false); const loadMorePaused = ref(false);
const allCategoryValue = 'all';
const allLanguageValue = 'all'; const allLanguageValue = 'all';
const allGameVersionValue = 'all'; const allGameVersionValue = 'all';
const deleteConfirmTitle = computed(() =>
pendingDelete.value?.type === 'comment' ? t('pages.life.deleteComment') : t('pages.life.deletePost')
);
const deleteConfirmMessage = computed(() =>
pendingDelete.value?.type === 'comment' ? t('pages.life.deleteCommentConfirm') : t('pages.life.deleteConfirm')
);
type LifeInitialData = {
options: { gameVersions: GameVersion[] } | null;
languages: Language[] | null;
posts: LifePostsPage | null;
};
const { data: initialData } = await useAsyncData<LifeInitialData>(
`life-feed-initial:${locale.value}`,
async () => {
const [optionsResult, languagesResult, postsResult] = await Promise.allSettled([
api.options(),
api.languages(),
api.lifePosts({
limit: lifePostPageSize,
sort: 'latest'
})
]);
return {
options:
optionsResult.status === 'fulfilled'
? { gameVersions: optionsResult.value.gameVersions }
: null,
languages: languagesResult.status === 'fulfilled' ? languagesResult.value.filter((language) => language.enabled) : null,
posts: postsResult.status === 'fulfilled' ? postsResult.value : null
};
},
{ default: () => ({ options: null, languages: null, posts: null }) }
);
gameVersions.value = initialData.value.options?.gameVersions ?? [];
languages.value = initialData.value.languages ?? [];
posts.value = initialData.value.posts?.items ?? [];
nextCursor.value = initialData.value.posts?.nextCursor ?? null;
hasMorePosts.value = initialData.value.posts?.hasMore ?? false;
const initialOptionsLoaded = ref(initialData.value.options !== null);
const initialLanguagesLoaded = ref(initialData.value.languages !== null);
const initialPostsLoaded = ref(initialData.value.posts !== null);
loading.value = !initialPostsLoaded.value;
const reactionOptions = [ const reactionOptions = [
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' }, { type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
@@ -139,14 +179,9 @@ const canPost = computed(() => can('life.posts.create'));
const canComment = computed(() => can('life.comments.create')); const canComment = computed(() => can('life.comments.create'));
const canLikeComments = computed(() => can('life.comments.like')); const canLikeComments = computed(() => can('life.comments.like'));
const canReact = computed(() => can('life.reactions.set')); const canReact = computed(() => can('life.reactions.set'));
const canRate = computed(() => can('life.ratings.set'));
const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length)); const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
const isEditing = computed(() => editingPostId.value !== null); const isEditing = computed(() => editingPostId.value !== null);
const searchQuery = computed(() => submittedSearch.value.trim()); const searchQuery = computed(() => submittedSearch.value.trim());
const selectedFeedCategoryId = computed(() => {
const categoryId = Number(activeCategoryId.value);
return activeCategoryId.value === allCategoryValue || !Number.isInteger(categoryId) || categoryId <= 0 ? undefined : categoryId;
});
const selectedFeedLanguageCode = computed(() => const selectedFeedLanguageCode = computed(() =>
activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value
); );
@@ -156,19 +191,6 @@ const selectedFeedGameVersionId = computed(() => {
? undefined ? undefined
: gameVersionId; : gameVersionId;
}); });
const selectedRateableFilter = computed(() => {
if (activeRateableFilter.value === 'rateable') {
return true;
}
if (activeRateableFilter.value === 'not-rateable') {
return false;
}
return null;
});
const categoryFilterOptions = computed<TabOption[]>(() => [
{ value: allCategoryValue, label: t('pages.life.allCategories') },
...lifeCategories.value.map((category) => ({ value: String(category.id), label: category.name }))
]);
const languageFilterOptions = computed<TabOption[]>(() => [ const languageFilterOptions = computed<TabOption[]>(() => [
{ value: allLanguageValue, label: t('pages.life.allLanguages') }, { value: allLanguageValue, label: t('pages.life.allLanguages') },
...languages.value.map((language) => ({ value: language.code, label: language.name })) ...languages.value.map((language) => ({ value: language.code, label: language.name }))
@@ -177,15 +199,9 @@ const gameVersionFilterOptions = computed(() => [
{ value: allGameVersionValue, label: t('pages.life.allVersions') }, { value: allGameVersionValue, label: t('pages.life.allVersions') },
...gameVersions.value.map((version) => ({ value: String(version.id), label: version.name })) ...gameVersions.value.map((version) => ({ value: String(version.id), label: version.name }))
]); ]);
const rateableFilterOptions = computed(() => [
{ value: 'all', label: t('pages.life.allRatingModes') },
{ value: 'rateable', label: t('pages.life.rateableOnly') },
{ value: 'not-rateable', label: t('pages.life.notRateableOnly') }
]);
const sortOptions = computed<Array<{ value: LifePostSort; label: string }>>(() => [ const sortOptions = computed<Array<{ value: LifePostSort; label: string }>>(() => [
{ value: 'latest', label: t('pages.life.sortLatest') }, { value: 'latest', label: t('pages.life.sortLatest') },
{ value: 'oldest', label: t('pages.life.sortOldest') }, { value: 'oldest', label: t('pages.life.sortOldest') }
{ value: 'top-rated', label: t('pages.life.sortTopRated') }
]); ]);
const commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [ const commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
{ value: 'oldest', label: t('pages.life.sortOldest') }, { value: 'oldest', label: t('pages.life.sortOldest') },
@@ -197,10 +213,6 @@ const feedScopeOptions = computed<TabOption[]>(() => [
{ value: 'all', label: t('pages.life.allFeed') }, { value: 'all', label: t('pages.life.allFeed') },
{ value: 'following', label: t('pages.life.followingFeed') } { value: 'following', label: t('pages.life.followingFeed') }
]); ]);
const defaultLifeCategoryId = computed(() => {
const category = lifeCategories.value.find((item) => item.isDefault);
return category ? String(category.id) : '';
});
const postModalTitle = computed(() => (isEditing.value ? t('pages.life.editPost') : t('pages.life.newPost'))); const postModalTitle = computed(() => (isEditing.value ? t('pages.life.editPost') : t('pages.life.newPost')));
const submitLabel = computed(() => { const submitLabel = computed(() => {
if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing'); if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing');
@@ -210,46 +222,28 @@ const submitLabel = computed(() => {
async function loadCurrentUser() { async function loadCurrentUser() {
authReady.value = false; authReady.value = false;
if (!getAuthToken()) {
currentUser.value = null;
activeFeedScope.value = 'all';
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;
activeFeedScope.value = 'all'; activeFeedScope.value = 'all';
setAuthToken(null);
} finally { } finally {
authReady.value = true; authReady.value = true;
} }
} }
async function loadLifeCategories() { async function loadLifeOptions() {
try { try {
const options = await api.options(); const options = await api.options();
lifeCategories.value = options.lifeCategories;
gameVersions.value = options.gameVersions; gameVersions.value = options.gameVersions;
if (activeCategoryId.value !== allCategoryValue && !lifeCategories.value.some((category) => String(category.id) === activeCategoryId.value)) {
activeCategoryId.value = allCategoryValue;
}
if ( if (
activeGameVersionId.value !== allGameVersionValue && activeGameVersionId.value !== allGameVersionValue &&
!gameVersions.value.some((gameVersion) => String(gameVersion.id) === activeGameVersionId.value) !gameVersions.value.some((gameVersion) => String(gameVersion.id) === activeGameVersionId.value)
) { ) {
activeGameVersionId.value = allGameVersionValue; activeGameVersionId.value = allGameVersionValue;
} }
if (!isEditing.value && postModalOpen.value && !selectedCategoryId.value) {
selectedCategoryId.value = defaultLifeCategoryId.value;
}
if (!isEditing.value && selectedCategoryId.value && !lifeCategories.value.some((category) => String(category.id) === selectedCategoryId.value)) {
selectedCategoryId.value = defaultLifeCategoryId.value;
}
if (selectedGameVersionId.value && !gameVersions.value.some((gameVersion) => String(gameVersion.id) === selectedGameVersionId.value)) { if (selectedGameVersionId.value && !gameVersions.value.some((gameVersion) => String(gameVersion.id) === selectedGameVersionId.value)) {
selectedGameVersionId.value = ''; selectedGameVersionId.value = '';
} }
@@ -286,10 +280,8 @@ async function loadPosts() {
const params = { const params = {
limit: lifePostPageSize, limit: lifePostPageSize,
search: searchQuery.value, search: searchQuery.value,
categoryId: selectedFeedCategoryId.value,
language: selectedFeedLanguageCode.value, language: selectedFeedLanguageCode.value,
gameVersionId: selectedFeedGameVersionId.value, gameVersionId: selectedFeedGameVersionId.value,
rateable: selectedRateableFilter.value,
sort: activeSort.value sort: activeSort.value
}; };
const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params); const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params);
@@ -332,10 +324,8 @@ async function loadMorePosts() {
cursor, cursor,
limit: lifePostPageSize, limit: lifePostPageSize,
search: searchQuery.value, search: searchQuery.value,
categoryId: selectedFeedCategoryId.value,
language: selectedFeedLanguageCode.value, language: selectedFeedLanguageCode.value,
gameVersionId: selectedFeedGameVersionId.value, gameVersionId: selectedFeedGameVersionId.value,
rateable: selectedRateableFilter.value,
sort: activeSort.value sort: activeSort.value
}; };
const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params); const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params);
@@ -361,7 +351,6 @@ async function loadMorePosts() {
function resetForm() { function resetForm() {
body.value = ''; body.value = '';
selectedCategoryId.value = '';
selectedGameVersionId.value = ''; selectedGameVersionId.value = '';
editingPostId.value = null; editingPostId.value = null;
formError.value = ''; formError.value = '';
@@ -370,17 +359,11 @@ function resetForm() {
function payload() { function payload() {
return { return {
body: body.value.trim(), body: body.value.trim(),
categoryId: selectedLifeCategoryId() ?? 0,
gameVersionId: selectedGameVersionForPost(), gameVersionId: selectedGameVersionForPost(),
languageCode: selectedFeedLanguageCode.value ?? null languageCode: selectedFeedLanguageCode.value ?? null
}; };
} }
function selectedLifeCategoryId() {
const categoryId = Number(selectedCategoryId.value);
return Number.isInteger(categoryId) && categoryId > 0 ? categoryId : null;
}
function selectedGameVersionForPost() { function selectedGameVersionForPost() {
const gameVersionId = Number(selectedGameVersionId.value); const gameVersionId = Number(selectedGameVersionId.value);
return Number.isInteger(gameVersionId) && gameVersionId > 0 ? gameVersionId : null; return Number.isInteger(gameVersionId) && gameVersionId > 0 ? gameVersionId : null;
@@ -417,21 +400,16 @@ function retryLoadMore() {
function matchesCurrentFilters(post: LifePost) { function matchesCurrentFilters(post: LifePost) {
const keyword = searchQuery.value.toLowerCase(); const keyword = searchQuery.value.toLowerCase();
const categoryId = selectedFeedCategoryId.value;
const gameVersionId = selectedFeedGameVersionId.value; const gameVersionId = selectedFeedGameVersionId.value;
const rateable = selectedRateableFilter.value;
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword); const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword);
const matchesCategory = categoryId === undefined || post.category?.id === categoryId;
const matchesGameVersion = gameVersionId === undefined || post.gameVersion?.id === gameVersionId; const matchesGameVersion = gameVersionId === undefined || post.gameVersion?.id === gameVersionId;
const matchesRateable = rateable === null || post.category?.isRateable === rateable;
const matchesLanguage = const matchesLanguage =
selectedFeedLanguageCode.value === undefined || post.moderationLanguageCode === selectedFeedLanguageCode.value; selectedFeedLanguageCode.value === undefined || post.moderationLanguageCode === selectedFeedLanguageCode.value;
return matchesSearch && matchesCategory && matchesGameVersion && matchesRateable && matchesLanguage; return matchesSearch && matchesGameVersion && matchesLanguage;
} }
function openCreatePostModal() { function openCreatePostModal() {
resetForm(); resetForm();
selectedCategoryId.value = defaultLifeCategoryId.value;
postModalOpen.value = true; postModalOpen.value = true;
void nextTick(() => bodyInput.value?.focus()); void nextTick(() => bodyInput.value?.focus());
} }
@@ -452,12 +430,6 @@ async function submitPost() {
return; return;
} }
if (selectedLifeCategoryId() === null) {
formError.value = t('pages.life.categoryRequired');
document.getElementById('life-post-category')?.focus();
return;
}
busy.value = true; busy.value = true;
formError.value = ''; formError.value = '';
@@ -844,10 +816,6 @@ function isReactionBusy(postId: number) {
return reactionBusyPostId.value === postId; return reactionBusyPostId.value === postId;
} }
function isRatingBusy(postId: number) {
return ratingBusyPostId.value === postId;
}
function commentAuthorName(comment: LifeComment) { function commentAuthorName(comment: LifeComment) {
return comment.author?.displayName ?? t('pages.life.byUnknown'); return comment.author?.displayName ?? t('pages.life.byUnknown');
} }
@@ -881,16 +849,6 @@ function clearReactionError(postId: number) {
reactionErrors.value = nextErrors; reactionErrors.value = nextErrors;
} }
function setRatingError(postId: number, message: string) {
ratingErrors.value = { ...ratingErrors.value, [postId]: message };
}
function clearRatingError(postId: number) {
const nextErrors = { ...ratingErrors.value };
delete nextErrors[postId];
ratingErrors.value = nextErrors;
}
function canUseReactions() { function canUseReactions() {
return canReact.value && reactionBusyPostId.value === null; return canReact.value && reactionBusyPostId.value === null;
} }
@@ -912,10 +870,6 @@ function commentLikeLabel(comment: LifeComment) {
return comment.myLiked ? t('pages.life.unlikeComment') : t('pages.life.likeComment'); return comment.myLiked ? t('pages.life.unlikeComment') : t('pages.life.likeComment');
} }
function canUseRatings(post: LifePost) {
return canRate.value && ratingBusyPostId.value === null && post.moderationStatus === 'approved' && post.category?.isRateable === true;
}
function closeReactionPicker() { function closeReactionPicker() {
reactionPickerPostId.value = null; reactionPickerPostId.value = null;
} }
@@ -985,31 +939,9 @@ async function toggleReaction(post: LifePost, reactionType: LifeReactionType) {
} }
} }
async function toggleRating(post: LifePost, rating: number) {
if (!canUseRatings(post)) {
return;
}
ratingBusyPostId.value = post.id;
clearRatingError(post.id);
try {
const updatedPost = post.myRating === rating ? await api.deleteLifeRating(post.id) : await api.setLifeRating(post.id, rating);
replacePost(updatedPost);
if (activeSort.value === 'top-rated') {
void loadPosts();
}
} catch (error) {
setRatingError(post.id, error instanceof Error && error.message ? error.message : t('pages.life.ratingFailed'));
} finally {
ratingBusyPostId.value = null;
}
}
function startEdit(post: LifePost) { function startEdit(post: LifePost) {
editingPostId.value = post.id; editingPostId.value = post.id;
body.value = post.body; body.value = post.body;
selectedCategoryId.value = post.category ? String(post.category.id) : '';
selectedGameVersionId.value = post.gameVersion ? String(post.gameVersion.id) : ''; selectedGameVersionId.value = post.gameVersion ? String(post.gameVersion.id) : '';
formError.value = ''; formError.value = '';
postModalOpen.value = true; postModalOpen.value = true;
@@ -1017,10 +949,6 @@ function startEdit(post: LifePost) {
} }
async function deletePost(post: LifePost) { async function deletePost(post: LifePost) {
if (!window.confirm(t('pages.life.deleteConfirm'))) {
return;
}
loadError.value = ''; loadError.value = '';
try { try {
@@ -1035,6 +963,10 @@ async function deletePost(post: LifePost) {
} }
} }
function requestDeletePost(post: LifePost) {
pendingDelete.value = { type: 'post', post };
}
function startReply(comment: LifeComment) { function startReply(comment: LifeComment) {
replyTargetId.value = comment.id; replyTargetId.value = comment.id;
clearCommentError(replyKey(comment.id)); clearCommentError(replyKey(comment.id));
@@ -1159,10 +1091,6 @@ function markOwnCommentDeleted(comments: LifeComment[], id: number): boolean {
} }
async function deleteComment(post: LifePost, comment: LifeComment) { async function deleteComment(post: LifePost, comment: LifeComment) {
if (!window.confirm(t('pages.life.deleteCommentConfirm'))) {
return;
}
const key = replyKey(comment.id); const key = replyKey(comment.id);
clearCommentError(key); clearCommentError(key);
@@ -1194,6 +1122,37 @@ async function deleteComment(post: LifePost, comment: LifeComment) {
} }
} }
function requestDeleteComment(post: LifePost, comment: LifeComment) {
pendingDelete.value = { type: 'comment', post, comment };
}
function closeDeleteConfirm() {
if (deleteConfirmBusy.value) {
return;
}
pendingDelete.value = null;
}
async function confirmDelete() {
const target = pendingDelete.value;
if (!target) {
return;
}
deleteConfirmBusy.value = true;
try {
if (target.type === 'post') {
await deletePost(target.post);
} else {
await deleteComment(target.post, target.comment);
}
pendingDelete.value = null;
} finally {
deleteConfirmBusy.value = false;
}
}
async function restoreComment(post: LifePost, comment: LifeComment) { async function restoreComment(post: LifePost, comment: LifeComment) {
const key = replyKey(comment.id); const key = replyKey(comment.id);
commentBusyKey.value = key; commentBusyKey.value = key;
@@ -1304,9 +1263,6 @@ function observeLoadMore() {
} }
watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], observeLoadMore, { flush: 'post' }); watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], observeLoadMore, { flush: 'post' });
watch(activeCategoryId, () => {
void loadPosts();
});
watch(activeLanguageCode, () => { watch(activeLanguageCode, () => {
expandedComments.value = {}; expandedComments.value = {};
commentPages.value = {}; commentPages.value = {};
@@ -1315,9 +1271,6 @@ watch(activeLanguageCode, () => {
watch(activeGameVersionId, () => { watch(activeGameVersionId, () => {
void loadPosts(); void loadPosts();
}); });
watch(activeRateableFilter, () => {
void loadPosts();
});
watch(activeSort, () => { watch(activeSort, () => {
void loadPosts(); void loadPosts();
}); });
@@ -1326,7 +1279,7 @@ watch(activeFeedScope, () => {
}); });
watch(locale, () => { watch(locale, () => {
void loadLanguages(); void loadLanguages();
void loadLifeCategories(); void loadLifeOptions();
void loadPosts(); void loadPosts();
}); });
@@ -1334,11 +1287,22 @@ onMounted(() => {
document.addEventListener('click', closeReactionPickerFromDocument); document.addEventListener('click', closeReactionPickerFromDocument);
document.addEventListener('keydown', closeReactionPickerFromKeyboard); document.addEventListener('keydown', closeReactionPickerFromKeyboard);
window.addEventListener(moderationUpdateEvent, handleModerationUpdate); window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
void loadCurrentUser(); void (async () => {
void loadLanguages(); await loadCurrentUser();
void loadLifeCategories(); if (!initialLanguagesLoaded.value) {
void loadPosts(); await loadLanguages();
removeAuthListener = onAuthTokenChange(() => { initialLanguagesLoaded.value = true;
}
if (!initialOptionsLoaded.value) {
await loadLifeOptions();
initialOptionsLoaded.value = true;
}
if (!initialPostsLoaded.value || currentUser.value) {
await loadPosts();
initialPostsLoaded.value = true;
}
})();
removeAuthListener = onAuthChange(() => {
void (async () => { void (async () => {
await loadCurrentUser(); await loadCurrentUser();
await loadPosts(); await loadPosts();
@@ -1393,14 +1357,6 @@ onUnmounted(() => {
</option> </option>
</select> </select>
</div> </div>
<div class="field life-toolbar__select">
<label for="life-rateable-filter">{{ t('pages.life.ratingFilter') }}</label>
<select id="life-rateable-filter" v-model="activeRateableFilter">
<option v-for="option in rateableFilterOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div class="field life-toolbar__select"> <div class="field life-toolbar__select">
<label for="life-sort">{{ t('pages.life.sort') }}</label> <label for="life-sort">{{ t('pages.life.sort') }}</label>
<select id="life-sort" v-model="activeSort"> <select id="life-sort" v-model="activeSort">
@@ -1448,19 +1404,6 @@ onUnmounted(() => {
<span class="life-form__counter">{{ t('pages.life.charactersLeft', { count: charactersLeft }) }}</span> <span class="life-form__counter">{{ t('pages.life.charactersLeft', { count: charactersLeft }) }}</span>
</div> </div>
<div class="field">
<label for="life-post-category">{{ t('pages.life.category') }}</label>
<TagsSelect
id="life-post-category"
v-model="selectedCategoryId"
:options="lifeCategories"
:multiple="false"
:placeholder="t('pages.life.categoryPlaceholder')"
:search-placeholder="t('pages.life.searchCategories')"
dropdown-strategy="fixed"
/>
</div>
<div class="field"> <div class="field">
<label for="life-post-version">{{ t('pages.life.gameVersion') }}</label> <label for="life-post-version">{{ t('pages.life.gameVersion') }}</label>
<TagsSelect <TagsSelect
@@ -1511,7 +1454,6 @@ onUnmounted(() => {
:label="t('pages.life.feedScope')" :label="t('pages.life.feedScope')"
/> />
<Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" /> <Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" />
<Tabs id="life-category-filter" v-model="activeCategoryId" :tabs="categoryFilterOptions" :label="t('pages.life.category')" />
<section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')"> <section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')">
<div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')"> <div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')">
@@ -1558,7 +1500,7 @@ onUnmounted(() => {
class="life-icon-button life-icon-button--danger" class="life-icon-button life-icon-button--danger"
type="button" type="button"
:aria-label="t('pages.life.deletePost')" :aria-label="t('pages.life.deletePost')"
@click="deletePost(post)" @click="requestDeletePost(post)"
> >
<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('pages.life.deletePost') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deletePost') }}</span>
@@ -1568,8 +1510,7 @@ onUnmounted(() => {
<p class="life-post__body">{{ post.body }}</p> <p class="life-post__body">{{ post.body }}</p>
<div v-if="post.category || post.gameVersion" class="life-post__tags" :aria-label="t('pages.life.postMeta')"> <div v-if="post.gameVersion" class="life-post__tags" :aria-label="t('pages.life.postMeta')">
<span v-if="post.category" class="life-post__tag">{{ post.category.name }}</span>
<span v-if="post.gameVersion" class="life-post__tag life-post__tag--version"> <span v-if="post.gameVersion" class="life-post__tag life-post__tag--version">
<Icon :icon="iconVersion" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconVersion" class="ui-icon" aria-hidden="true" />
{{ post.gameVersion.name }} {{ post.gameVersion.name }}
@@ -1582,16 +1523,6 @@ onUnmounted(() => {
<div class="life-post__engagement"> <div class="life-post__engagement">
<div class="life-post__engagement-actions"> <div class="life-post__engagement-actions">
<LifeRatingControl
v-if="post.category?.isRateable"
:rating-average="post.ratingAverage"
:rating-count="post.ratingCount"
:my-rating="post.myRating"
:disabled="!canUseRatings(post)"
:busy="isRatingBusy(post.id)"
@rate="toggleRating(post, $event)"
/>
<div class="life-reactions"> <div class="life-reactions">
<div class="life-reaction-control"> <div class="life-reaction-control">
<button <button
@@ -1730,7 +1661,6 @@ onUnmounted(() => {
<span>{{ post.moderationReason }}</span> <span>{{ post.moderationReason }}</span>
</p> </p>
<p v-if="ratingErrors[post.id]" class="life-form__error" role="alert">{{ ratingErrors[post.id] }}</p>
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p> <p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
<p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p> <p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>
@@ -1853,7 +1783,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('pages.life.deleteComment')" :aria-label="t('pages.life.deleteComment')"
@click="deleteComment(post, comment)" @click="requestDeleteComment(post, 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('pages.life.deleteComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
@@ -1970,7 +1900,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('pages.life.deleteComment')" :aria-label="t('pages.life.deleteComment')"
@click="deleteComment(post, reply)" @click="requestDeleteComment(post, 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('pages.life.deleteComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
@@ -2061,6 +1991,18 @@ onUnmounted(() => {
{{ t('pages.life.newPost') }} {{ t('pages.life.newPost') }}
</button> </button>
</div> </div>
<ConfirmDialog
v-if="pendingDelete"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:confirm-label="t('common.delete')"
:cancel-label="t('common.cancel')"
:close-label="t('common.close')"
:busy="deleteConfirmBusy"
@cancel="closeDeleteConfirm"
@confirm="confirmDelete"
/>
</section> </section>
</section> </section>
</template> </template>

View File

@@ -6,7 +6,7 @@ import { useRoute, useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import { iconLogin } from '../icons'; import { iconLogin } from '../icons';
import { api, setAuthToken } from '../services/api'; import { api, notifyAuthChange } from '../services/api';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -22,12 +22,12 @@ async function submitLogin() {
errorMessage.value = ''; errorMessage.value = '';
try { try {
const response = await api.login({ await api.login({
email: email.value, email: email.value,
password: password.value, password: password.value,
rememberMe: rememberMe.value rememberMe: rememberMe.value
}); });
setAuthToken(response.token, { persistent: rememberMe.value }); notifyAuthChange();
const redirect = const redirect =
typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/') typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
@@ -15,12 +15,12 @@ import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconAdd, iconBack, iconCancel, iconCheck, iconEdit, iconHabitat, iconItem } from '../icons'; import { iconAdd, iconBack, iconCancel, iconCheck, iconEdit, iconHabitat, iconItem } from '../icons';
import { applySeo } from '../seo'; import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
import { api, getAuthToken, type AuthUser, type Item, type PokemonDetail, type PokemonPayload, type TradingPreference } from '../services/api'; import { api, type AuthUser, type Item, type PokemonDetail, type PokemonPayload, type TradingPreference } from '../services/api';
import PokemonEdit from './PokemonEdit.vue'; import PokemonEdit from './PokemonEdit.vue';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t, locale } = useI18n();
const pokemon = ref<PokemonDetail | null>(null); const pokemon = ref<PokemonDetail | null>(null);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const itemCategoryTab = ref(''); const itemCategoryTab = ref('');
@@ -36,9 +36,50 @@ const tradingCategoryId = ref('');
const tradingDefaultPreference = ref<TradingPreference>('like'); const tradingDefaultPreference = ref<TradingPreference>('like');
const tradingItemChoices = ref<Item[]>([]); const tradingItemChoices = ref<Item[]>([]);
const tradingDraftItems = ref<Array<{ itemId: number; preference: TradingPreference }>>([]); const tradingDraftItems = ref<Array<{ itemId: number; preference: TradingPreference }>>([]);
const tradingActiveItemIndex = ref(0);
const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天']; const weathers = ['晴天', '阴天', '雨天'];
const relatedPokemonLimit = 6; const relatedPokemonLimit = 6;
const pokemonDetailRouteNames = new Set(['pokemon-detail', 'pokemon-edit']);
const { data: initialPokemon } = useAsyncData<PokemonDetail | null>(
`pokemon-detail:${activePokemonRouteId() ?? 'none'}:${locale.value}`,
async () => {
const routeId = activePokemonRouteId();
if (!routeId) {
return null;
}
try {
return await api.pokemonDetail(routeId);
} catch {
return null;
}
},
{ default: () => null }
);
const initialPokemonLoaded = ref(false);
const pokemonSeo = computed(() =>
pokemon.value && route.meta.editorModal !== true
? resolveSeo({
title: `${pokemon.value.name} - ${t(pokemon.value.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`,
description: t('seo.pokemonDetailDescription', { name: pokemon.value.name }),
canonicalPath: `/pokemon/${pokemon.value.id}`,
image: pokemon.value.image?.url
})
: null
);
useHead(() => (pokemonSeo.value ? resolvedSeoHead(pokemonSeo.value) : {}));
function applyInitialPokemon(value: PokemonDetail | null | undefined) {
if (!value || initialPokemonLoaded.value) return;
pokemon.value = value;
relatedHabitatTab.value = habitatTabValue(value.environment.id);
initialPokemonLoaded.value = true;
}
type HabitatRow = { type HabitatRow = {
id: number; id: number;
@@ -65,6 +106,15 @@ function habitatTabValue(id: number): string {
return `habitat-${id}`; return `habitat-${id}`;
} }
function activePokemonRouteId(): string | null {
return typeof route.name === 'string' &&
pokemonDetailRouteNames.has(route.name) &&
typeof route.params.id === 'string' &&
route.params.id.trim() !== ''
? route.params.id
: null;
}
function timeLabel(value: string): string { function timeLabel(value: string): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
早晨: t('appearance.morning'), 早晨: t('appearance.morning'),
@@ -149,20 +199,58 @@ const tradingCategoryOptions = computed(() => {
return [{ value: '', label: t('common.all') }, ...[...categories.entries()].map(([value, label]) => ({ value, label }))]; return [{ value: '', label: t('common.all') }, ...[...categories.entries()].map(([value, label]) => ({ value, label }))];
}); });
const tradingDraftPreferenceByItemId = computed(() => new Map(tradingDraftItems.value.map((item) => [String(item.itemId), item.preference]))); const tradingDraftPreferenceByItemId = computed(() => new Map(tradingDraftItems.value.map((item) => [String(item.itemId), item.preference])));
const filteredTradingItems = computed(() => { function normalizedTradingValue(value: string) {
const search = tradingSearch.value.trim().toLocaleLowerCase(); return value.trim().toLocaleLowerCase();
}
return tradingItemChoices.value.filter((item) => { function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function tradingSearchScore(item: Item, search: string) {
const name = normalizedTradingValue(item.name);
const category = normalizedTradingValue(item.category.name);
const usage = normalizedTradingValue(item.usage?.name ?? '');
if (name === search) {
return 0;
}
if (name.startsWith(search)) {
return 1;
}
if (new RegExp(`(^|\\s)${escapeRegExp(search)}`).test(name)) {
return 2;
}
if (name.includes(search)) {
return 3;
}
if (category.includes(search)) {
return 4;
}
if (usage.includes(search)) {
return 5;
}
return -1;
}
const filteredTradingItems = computed(() => {
const search = normalizedTradingValue(tradingSearch.value);
const rows = tradingItemChoices.value.flatMap((item, index) => {
if (tradingCategoryId.value && String(item.category.id) !== tradingCategoryId.value) { if (tradingCategoryId.value && String(item.category.id) !== tradingCategoryId.value) {
return false; return [];
} }
if (!search) { if (!search) {
return true; return [{ item, index, score: 0 }];
} }
return [item.name, item.category.name, item.usage?.name ?? ''].some((value) => value.toLocaleLowerCase().includes(search)); const score = tradingSearchScore(item, search);
return score >= 0 ? [{ item, index, score }] : [];
}); });
return rows.sort((a, b) => a.score - b.score || a.index - b.index).map((row) => row.item);
}); });
const tradingDraftGroups = computed(() => { const tradingDraftGroups = computed(() => {
const itemsById = new Map(tradingItemChoices.value.map((item) => [item.id, item])); const itemsById = new Map(tradingItemChoices.value.map((item) => [item.id, item]));
@@ -188,6 +276,7 @@ const listPath = computed(() => (pokemon.value?.isEventItem ? '/event-pokemon' :
const detailKicker = computed(() => t(pokemon.value?.isEventItem ? 'pages.eventPokemon.detailKicker' : 'pages.pokemon.detailKicker')); const detailKicker = computed(() => t(pokemon.value?.isEventItem ? 'pages.eventPokemon.detailKicker' : 'pages.pokemon.detailKicker'));
const detailTabs = computed<TabOption[]>(() => [ const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') }, { value: 'details', label: t('common.details') },
{ value: 'reference', label: t('pages.pokemon.referenceTab') },
{ value: 'discussion', label: t('discussion.title') }, { value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') } { value: 'history', label: t('history.editHistory') }
]); ]);
@@ -234,6 +323,12 @@ const relatedPokemonRows = computed(() => {
return rows.slice(0, relatedPokemonLimit); return rows.slice(0, relatedPokemonLimit);
} }
if (pokemon.value && selectedTab === habitatTabValue(pokemon.value.environment.id)) {
return rows
.filter((item) => habitatTabValue(item.environment.id) === selectedTab || item.environment.isOpposite)
.slice(0, relatedPokemonLimit);
}
return rows.filter((item) => habitatTabValue(item.environment.id) === selectedTab).slice(0, relatedPokemonLimit); return rows.filter((item) => habitatTabValue(item.environment.id) === selectedTab).slice(0, relatedPokemonLimit);
}); });
const typeSlotClass = computed(() => ({ const typeSlotClass = computed(() => ({
@@ -343,6 +438,7 @@ function resetTradingDraft() {
tradingDefaultPreference.value = 'like'; tradingDefaultPreference.value = 'like';
tradingSearch.value = ''; tradingSearch.value = '';
tradingCategoryId.value = ''; tradingCategoryId.value = '';
tradingActiveItemIndex.value = 0;
tradingMessage.value = ''; tradingMessage.value = '';
} }
@@ -350,13 +446,72 @@ function isTradingItemSelected(itemId: string | number) {
return tradingDraftPreferenceByItemId.value.has(String(itemId)); return tradingDraftPreferenceByItemId.value.has(String(itemId));
} }
function addTradingItem(item: Item) { function firstAddableTradingItemIndex(items = filteredTradingItems.value, startIndex = 0, direction: -1 | 1 = 1) {
if (!items.length) {
return 0;
}
const start = Math.min(Math.max(startIndex, 0), items.length - 1);
for (let offset = 0; offset < items.length; offset += 1) {
const index = (start + direction * offset + items.length) % items.length;
if (!isTradingItemSelected(items[index].id)) {
return index;
}
}
return start;
}
function setTradingActiveItemIndex(index: number) {
const maxIndex = filteredTradingItems.value.length - 1;
tradingActiveItemIndex.value = maxIndex >= 0 ? Math.min(Math.max(index, 0), maxIndex) : 0;
}
function moveTradingActiveItem(direction: -1 | 1) {
const items = filteredTradingItems.value;
if (!items.length) {
tradingActiveItemIndex.value = 0;
return;
}
const startIndex = Math.min(Math.max(tradingActiveItemIndex.value, 0), items.length - 1);
for (let offset = 1; offset <= items.length; offset += 1) {
const index = (startIndex + direction * offset + items.length) % items.length;
if (!isTradingItemSelected(items[index].id)) {
tradingActiveItemIndex.value = index;
return;
}
}
tradingActiveItemIndex.value = startIndex;
}
function activeTradingItemId() {
const item = filteredTradingItems.value[tradingActiveItemIndex.value];
return item ? `pokemon-trading-item-${item.id}` : undefined;
}
function scrollActiveTradingItemIntoView() {
if (typeof document === 'undefined') {
return;
}
nextTick(() => {
const activeId = activeTradingItemId();
if (activeId) {
document.getElementById(activeId)?.scrollIntoView({ block: 'nearest' });
}
});
}
function addTradingItem(item: Item, index = tradingActiveItemIndex.value) {
const itemId = String(item.id); const itemId = String(item.id);
if (isTradingItemSelected(itemId)) { if (isTradingItemSelected(itemId)) {
return; return;
} }
tradingDraftItems.value.push({ itemId: item.id, preference: tradingDefaultPreference.value }); tradingDraftItems.value.push({ itemId: item.id, preference: tradingDefaultPreference.value });
tradingActiveItemIndex.value = firstAddableTradingItemIndex(filteredTradingItems.value, index, 1);
} }
function removeTradingItem(itemId: string | number) { function removeTradingItem(itemId: string | number) {
@@ -372,6 +527,61 @@ function setTradingPreference(itemId: string | number, preference: TradingPrefer
} }
} }
function handleTradingSearchKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
moveTradingActiveItem(1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
moveTradingActiveItem(-1);
return;
}
if (event.key === 'ArrowLeft') {
event.preventDefault();
tradingDefaultPreference.value = 'like';
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
tradingDefaultPreference.value = 'neutral';
return;
}
if (event.key === 'Enter') {
event.preventDefault();
const item = filteredTradingItems.value[tradingActiveItemIndex.value];
if (item) {
addTradingItem(item, tradingActiveItemIndex.value);
}
}
}
watch([tradingSearch, tradingCategoryId], () => {
tradingActiveItemIndex.value = firstAddableTradingItemIndex(filteredTradingItems.value, 0, 1);
scrollActiveTradingItemIntoView();
});
watch([filteredTradingItems, tradingDraftPreferenceByItemId], () => {
const items = filteredTradingItems.value;
if (!items.length) {
tradingActiveItemIndex.value = 0;
return;
}
const currentIndex = Math.min(Math.max(tradingActiveItemIndex.value, 0), items.length - 1);
tradingActiveItemIndex.value = isTradingItemSelected(items[currentIndex].id)
? firstAddableTradingItemIndex(items, currentIndex, 1)
: currentIndex;
scrollActiveTradingItemIntoView();
});
watch(tradingActiveItemIndex, scrollActiveTradingItemIntoView);
async function openTradingModal() { async function openTradingModal() {
if (!pokemon.value) { if (!pokemon.value) {
return; return;
@@ -411,29 +621,43 @@ async function saveTradingItems() {
} }
async function loadPokemonDetail() { async function loadPokemonDetail() {
const nextPokemon = await api.pokemonDetail(String(route.params.id)); const routeId = activePokemonRouteId();
pokemon.value = nextPokemon; if (!routeId) {
relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id); initialPokemonLoaded.value = true;
return;
}
if (route.meta.editorModal !== true) { try {
applySeo({ const nextPokemon = await api.pokemonDetail(routeId);
title: `${nextPokemon.name} - ${t(nextPokemon.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`, pokemon.value = nextPokemon;
description: t('seo.pokemonDetailDescription', { name: nextPokemon.name }), relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id);
canonicalPath: `/pokemon/${nextPokemon.id}`, initialPokemonLoaded.value = true;
image: nextPokemon.image?.url
}); if (route.meta.editorModal !== true) {
applySeo({
title: `${nextPokemon.name} - ${t(nextPokemon.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`,
description: t('seo.pokemonDetailDescription', { name: nextPokemon.name }),
canonicalPath: `/pokemon/${nextPokemon.id}`,
image: nextPokemon.image?.url
});
}
} catch {
pokemon.value = null;
relatedHabitatTab.value = '';
initialPokemonLoaded.value = true;
} }
} }
onMounted(async () => { onMounted(async () => {
if (getAuthToken()) { try {
try { currentUser.value = (await api.me()).user;
currentUser.value = (await api.me()).user; } catch {
} catch { currentUser.value = null;
currentUser.value = null; }
}
if (!initialPokemonLoaded.value) {
await loadPokemonDetail();
} }
await loadPokemonDetail();
}); });
watch( watch(
@@ -448,6 +672,10 @@ watch(
watch( watch(
() => route.params.id, () => route.params.id,
() => { () => {
if (!activePokemonRouteId()) {
return;
}
pokemon.value = null; pokemon.value = null;
relatedHabitatTab.value = ''; relatedHabitatTab.value = '';
detailTab.value = 'details'; detailTab.value = 'details';
@@ -457,6 +685,8 @@ watch(
void loadPokemonDetail(); void loadPokemonDetail();
} }
); );
watch(initialPokemon, applyInitialPokemon, { immediate: true });
</script> </script>
<template> <template>
@@ -517,7 +747,7 @@ watch(
</div> </div>
</section> </section>
<section v-else class="page-stack"> <section v-else class="page-stack">
<PageHeader :title="`#${pokemon.displayId} ${pokemon.name}`" :subtitle="t('pages.pokemon.environmentPrefix', { name: pokemon.environment.name })"> <PageHeader :title="`#${pokemon.displayId} ${pokemon.name}`" :subtitle="pokemon.genus || t('pages.pokemon.detailSubtitle')">
<template #kicker>{{ detailKicker }}</template> <template #kicker>{{ detailKicker }}</template>
<template #actions> <template #actions>
<RouterLink v-if="canUpdatePokemon" class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`"> <RouterLink v-if="canUpdatePokemon" class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">
@@ -535,66 +765,45 @@ watch(
<Tabs id="pokemon-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" /> <Tabs id="pokemon-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack"> <div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
<div class="pokemon-profile-grid pokemon-profile-grid--with-image"> <div class="pokemon-description-grid">
<div class="pokemon-profile-main"> <button v-if="pokemon.image" type="button" class="pokemon-description-image" :aria-label="pokemonImageLabel()" @click="openImageModal">
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.details')"> <img :src="pokemon.image.url" :alt="pokemonImageAlt()" />
<p v-if="pokemon.genus" class="pokemon-genus">{{ pokemon.genus }}</p> </button>
<div v-if="pokemon.genus && pokemon.details.trim()" class="pokemon-profile-divider"></div> <div v-else class="pokemon-description-image pokemon-description-image--placeholder" role="img" :aria-label="t('pages.pokemon.imageEmpty')">
<p v-if="pokemon.details.trim()" class="detail-text">{{ pokemon.details }}</p> <PokeBallMark size="82px" />
<p v-if="!pokemon.genus && !pokemon.details.trim()" class="meta-line">{{ t('common.none') }}</p>
</section>
<div class="pokemon-profile-row">
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.measurements')">
<div class="pokemon-measurement-display">
<div class="pokemon-measurement-item" :title="`${formatImperialHeight(pokemon.heightInches)} / ${formatMetricMeasure(pokemon.heightMeters)} m`">
<div class="pokemon-measurement-stack">
<strong class="pokemon-measurement-value">{{ formatImperialHeight(pokemon.heightInches) }}</strong>
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.heightMeters) }} m</strong>
<span class="pokemon-measurement-label">{{ t('pages.pokemon.height') }}</span>
</div>
</div>
<div class="pokemon-measurement-item" :title="`${formatPoundsMeasure(pokemon.weightPounds)} lbs / ${formatMetricMeasure(pokemon.weightKg)} kg`">
<div class="pokemon-measurement-stack">
<strong class="pokemon-measurement-value">{{ formatPoundsMeasure(pokemon.weightPounds) }} lbs</strong>
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.weightKg) }} kg</strong>
<span class="pokemon-measurement-label">{{ t('pages.pokemon.weight') }}</span>
</div>
</div>
</div>
</section>
<section class="detail-section pokemon-profile-card pokemon-types-card" :aria-label="t('pages.pokemon.types')">
<div v-if="pokemon.types.length" class="pokemon-type-slots" :class="typeSlotClass">
<span v-for="type in pokemon.types.slice(0, 2)" :key="type.id" class="chip pokemon-type-chip">
<img v-if="pokemonTypeIconSrc(type.id)" class="pokemon-type-chip__icon" :src="pokemonTypeIconSrc(type.id) ?? undefined" alt="" aria-hidden="true" />
<span>{{ type.name }}</span>
</span>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</section>
</div>
</div> </div>
<div class="pokemon-profile-side pokemon-profile-side--with-image"> <section class="detail-section pokemon-description-card" :aria-label="t('pages.pokemon.details')">
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')"> <h2 class="section-subtitle">{{ t('pages.pokemon.description') }}</h2>
<PokemonStatsPanel :stats="pokemon.stats" /> <p v-if="pokemon.details.trim()" class="detail-text">{{ pokemon.details }}</p>
</DetailSection> <p v-else class="meta-line">{{ t('common.none') }}</p>
</section>
<button v-if="pokemon.image" type="button" class="pokemon-profile-image" :aria-label="pokemonImageLabel()" @click="openImageModal">
<img :src="pokemon.image.url" :alt="pokemonImageAlt()" />
</button>
<div v-else class="pokemon-profile-image pokemon-profile-image--placeholder" role="img" :aria-label="t('pages.pokemon.imageEmpty')">
<PokeBallMark size="64px" />
</div>
</div>
</div> </div>
<DetailSection :title="t('pages.pokemon.skills')"> <div class="pokemon-core-grid" :aria-label="t('pages.pokemon.coreFactors')">
<EntityChips :items="pokemon.skills" /> <DetailSection class="pokemon-core-card" :title="t('pages.pokemon.skills')">
</DetailSection> <p class="meta-line pokemon-core-note">{{ t('pages.pokemon.skillsCoreNote') }}</p>
<EntityChips :items="pokemon.skills" />
</DetailSection>
<DetailSection class="pokemon-core-card" :title="t('pages.pokemon.environment')">
<p class="meta-line pokemon-core-note">{{ t('pages.pokemon.environmentCoreNote') }}</p>
<div class="pokemon-core-value">
<span class="chip">{{ pokemon.environment.name }}</span>
</div>
<p v-if="pokemon.environment.description" class="detail-text">{{ pokemon.environment.description }}</p>
</DetailSection>
<DetailSection class="pokemon-core-card" :title="t('pages.pokemon.favoriteThings')">
<p class="meta-line pokemon-core-note">{{ t('pages.pokemon.favoriteThingsCoreNote') }}</p>
<div v-if="pokemon.favorite_things.length" class="pokemon-favourite-list">
<span v-for="thing in pokemon.favorite_things" :key="thing.id" class="chip pokemon-favourite-chip">
<span>{{ thing.name }}</span>
</span>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
</div>
<DetailSection v-if="hasItemDropSkill" :title="t('pages.pokemon.skillDrops')"> <DetailSection v-if="hasItemDropSkill" :title="t('pages.pokemon.skillDrops')">
<ul class="row-list skill-drop-summary"> <ul class="row-list skill-drop-summary">
@@ -641,10 +850,6 @@ watch(
</div> </div>
</DetailSection> </DetailSection>
<DetailSection :title="t('pages.pokemon.favoriteThings')">
<EntityChips :items="pokemon.favorite_things" />
</DetailSection>
<div class="pokemon-related-grid"> <div class="pokemon-related-grid">
<DetailSection :title="t('pages.pokemon.relatedPokemon')"> <DetailSection :title="t('pages.pokemon.relatedPokemon')">
<template v-if="pokemon.relatedPokemon.length"> <template v-if="pokemon.relatedPokemon.length">
@@ -673,7 +878,10 @@ watch(
/> />
<span <span
class="chip related-pokemon-row__environment" class="chip related-pokemon-row__environment"
:class="{ 'related-pokemon-row__environment--match': related.environment.id === pokemon.environment.id }" :class="{
'related-pokemon-row__environment--match': related.environment.matches || related.environment.id === pokemon.environment.id,
'related-pokemon-row__environment--opposite': related.environment.isOpposite
}"
> >
{{ related.environment.name }} {{ related.environment.name }}
</span> </span>
@@ -687,7 +895,7 @@ watch(
v-for="thing in related.favorite_things" v-for="thing in related.favorite_things"
:key="thing.id" :key="thing.id"
class="chip related-favourite-chip" class="chip related-favourite-chip"
:class="{ 'related-favourite-chip--match': thing.matches }" :class="{ 'related-favourite-chip--match': thing.matches, 'related-favourite-chip--opposite': thing.isOpposite }"
> >
{{ thing.name }} {{ thing.name }}
</span> </span>
@@ -759,6 +967,51 @@ watch(
</DetailSection> </DetailSection>
</div> </div>
<div v-else-if="detailTab === 'reference'" class="detail-grid detail-grid--stack">
<DetailSection :title="t('pages.pokemon.referenceData')">
<p class="meta-line">{{ t('pages.pokemon.pokedexReferenceNote') }}</p>
</DetailSection>
<div class="pokemon-reference-grid">
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.measurements')">
<h2 class="section-subtitle">{{ t('pages.pokemon.measurements') }}</h2>
<div class="pokemon-measurement-display">
<div class="pokemon-measurement-item" :title="`${formatImperialHeight(pokemon.heightInches)} / ${formatMetricMeasure(pokemon.heightMeters)} m`">
<div class="pokemon-measurement-stack">
<strong class="pokemon-measurement-value">{{ formatImperialHeight(pokemon.heightInches) }}</strong>
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.heightMeters) }} m</strong>
<span class="pokemon-measurement-label">{{ t('pages.pokemon.height') }}</span>
</div>
</div>
<div class="pokemon-measurement-item" :title="`${formatPoundsMeasure(pokemon.weightPounds)} lbs / ${formatMetricMeasure(pokemon.weightKg)} kg`">
<div class="pokemon-measurement-stack">
<strong class="pokemon-measurement-value">{{ formatPoundsMeasure(pokemon.weightPounds) }} lbs</strong>
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.weightKg) }} kg</strong>
<span class="pokemon-measurement-label">{{ t('pages.pokemon.weight') }}</span>
</div>
</div>
</div>
</section>
<section class="detail-section pokemon-profile-card pokemon-types-card" :aria-label="t('pages.pokemon.types')">
<h2 class="section-subtitle">{{ t('pages.pokemon.types') }}</h2>
<div v-if="pokemon.types.length" class="pokemon-type-slots" :class="typeSlotClass">
<span v-for="type in pokemon.types.slice(0, 2)" :key="type.id" class="chip pokemon-type-chip">
<img v-if="pokemonTypeIconSrc(type.id)" class="pokemon-type-chip__icon" :src="pokemonTypeIconSrc(type.id) ?? undefined" alt="" aria-hidden="true" />
<span>{{ type.name }}</span>
</span>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</section>
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')">
<PokemonStatsPanel :stats="pokemon.stats" />
</DetailSection>
</div>
</div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel"> <div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="pokemon" :entity-id="pokemon.id" /> <EntityDiscussionPanel entity-type="pokemon" :entity-id="pokemon.id" />
</div> </div>
@@ -808,7 +1061,13 @@ watch(
id="pokemon-trading-search" id="pokemon-trading-search"
v-model="tradingSearch" v-model="tradingSearch"
type="search" type="search"
autocomplete="off"
role="combobox"
:aria-expanded="filteredTradingItems.length > 0"
aria-controls="pokemon-trading-results"
:aria-activedescendant="activeTradingItemId()"
:placeholder="t('pages.pokemon.searchItems')" :placeholder="t('pages.pokemon.searchItems')"
@keydown="handleTradingSearchKeydown"
/> />
</div> </div>
@@ -840,14 +1099,19 @@ watch(
<Skeleton variant="box" height="58px" /> <Skeleton variant="box" height="58px" />
</li> </li>
</ul> </ul>
<ul v-else-if="filteredTradingItems.length" class="trading-item-list"> <ul v-else-if="filteredTradingItems.length" id="pokemon-trading-results" class="trading-item-list">
<li v-for="item in filteredTradingItems" :key="item.id"> <li v-for="(item, index) in filteredTradingItems" :id="`pokemon-trading-item-${item.id}`" :key="item.id">
<button <button
type="button" type="button"
class="trading-pick-row" class="trading-pick-row"
:class="{ 'trading-pick-row--selected': isTradingItemSelected(item.id) }" :class="{
'trading-pick-row--active': tradingActiveItemIndex === index,
'trading-pick-row--selected': isTradingItemSelected(item.id)
}"
:disabled="isTradingItemSelected(item.id)" :disabled="isTradingItemSelected(item.id)"
@click="addTradingItem(item)" @mouseenter="setTradingActiveItemIndex(index)"
@focus="setTradingActiveItemIndex(index)"
@click="addTradingItem(item, index)"
> >
<span class="related-entity-media related-entity-media--inline" aria-hidden="true"> <span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" /> <img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />

View File

@@ -9,18 +9,16 @@ import PokemonStatsFields from '../components/PokemonStatsFields.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import Tabs from '../components/Tabs.vue'; import Tabs from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect, { type TagsSelectOption } from '../components/TagsSelect.vue';
import TranslationFields from '../components/TranslationFields.vue'; import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave, iconSearch } from '../icons'; import { iconCancel, iconSave, iconSearch } from '../icons';
import { import {
api, api,
getAuthToken,
type AuthUser, type AuthUser,
type ConfigType, type ConfigType,
type EntityImage, type EntityImage,
type EntityImageUpload, type EntityImageUpload,
type Language, type Language,
type NamedEntity,
type Options, type Options,
type PokemonFetchOption, type PokemonFetchOption,
type PokemonFetchResult, type PokemonFetchResult,
@@ -40,7 +38,7 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const itemOptions = ref<NamedEntity[]>([]); const itemOptions = ref<TagsSelectOption[]>([]);
const languages = ref<Language[]>([]); const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const loading = ref(true); const loading = ref(true);
@@ -190,16 +188,11 @@ function errorText(error: unknown, fallback: string) {
async function loadOptions() { async function loadOptions() {
const [loadedOptions, loadedItems, loadedLanguages] = await Promise.all([api.options(), api.items({}), api.languages()]); const [loadedOptions, loadedItems, loadedLanguages] = await Promise.all([api.options(), api.items({}), api.languages()]);
options.value = loadedOptions; options.value = loadedOptions;
itemOptions.value = loadedItems.map((item) => ({ id: item.id, name: item.name })); itemOptions.value = loadedItems.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url }));
languages.value = loadedLanguages; languages.value = loadedLanguages;
} }
async function loadCurrentUser() { async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try { try {
currentUser.value = (await api.me()).user; currentUser.value = (await api.me()).user;
} catch { } catch {

View File

@@ -10,22 +10,15 @@ import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd } from '../icons'; import { iconAdd } from '../icons';
import { api, getAuthToken, type AuthUser, type Options, type Pokemon } from '../services/api'; import { api, type AuthUser, type ListPage, type Options, type Pokemon } from '../services/api';
import PokemonEdit from './PokemonEdit.vue'; import PokemonEdit from './PokemonEdit.vue';
const props = defineProps<{ const props = defineProps<{
eventOnly?: boolean; eventOnly?: boolean;
}>(); }>();
const options = ref<Options | null>(null);
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t, locale } = useI18n();
const pokemon = ref<Pokemon[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const loadingMore = ref(false);
const nextCursor = ref<string | null>(null);
const hasMorePokemon = ref(false);
const search = ref(''); const search = ref('');
const environmentId = ref(''); const environmentId = ref('');
const skillIds = ref<string[]>([]); const skillIds = ref<string[]>([]);
@@ -37,6 +30,11 @@ const skeletonCardCount = 6;
const listPageSize = 24; const listPageSize = 24;
let loadRequestId = 0; let loadRequestId = 0;
type PokemonListInitialData = {
options: Options | null;
page: ListPage<Pokemon> | null;
};
const query = computed(() => ({ const query = computed(() => ({
search: search.value, search: search.value,
isEventItem: props.eventOnly ? 'true' : 'false', isEventItem: props.eventOnly ? 'true' : 'false',
@@ -46,6 +44,35 @@ const query = computed(() => ({
favoriteThingIds: favoriteThingIds.value.join(','), favoriteThingIds: favoriteThingIds.value.join(','),
favoriteThingMode: favoriteThingMode.value favoriteThingMode: favoriteThingMode.value
})); }));
const { data: initialData } = useAsyncData<PokemonListInitialData>(
`${props.eventOnly ? 'event-pokemon-list-initial' : 'pokemon-list-initial'}:${locale.value}`,
async () => {
const [optionsResult, pokemonResult] = await Promise.allSettled([
api.options(),
api.pokemonPage({
...query.value,
cursor: null,
limit: listPageSize
})
]);
return {
options: optionsResult.status === 'fulfilled' ? optionsResult.value : null,
page: pokemonResult.status === 'fulfilled' ? pokemonResult.value : null
};
},
{ default: () => ({ options: null, page: null }) }
);
const options = ref<Options | null>(null);
const pokemon = ref<Pokemon[]>([]);
const currentUser = ref<AuthUser | null>(null);
const initialPageLoaded = ref(false);
const loading = ref(true);
const loadingMore = ref(false);
const nextCursor = ref<string | null>(null);
const hasMorePokemon = ref(false);
const showEditor = computed(() => route.name === 'pokemon-new' || route.name === 'event-pokemon-new'); const showEditor = computed(() => route.name === 'pokemon-new' || route.name === 'event-pokemon-new');
const canCreatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.create') === true); const canCreatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.create') === true);
const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventPokemon.title' : 'pages.pokemon.title')); const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventPokemon.title' : 'pages.pokemon.title'));
@@ -54,6 +81,24 @@ const pageKicker = computed(() => t(props.eventOnly ? 'pages.eventPokemon.kicker
const newPokemonPath = computed(() => (props.eventOnly ? '/event-pokemon/new' : '/pokemon/new')); const newPokemonPath = computed(() => (props.eventOnly ? '/event-pokemon/new' : '/pokemon/new'));
const loadingListLabel = computed(() => t(props.eventOnly ? 'pages.eventPokemon.loadingList' : 'pages.pokemon.loadingList')); const loadingListLabel = computed(() => t(props.eventOnly ? 'pages.eventPokemon.loadingList' : 'pages.pokemon.loadingList'));
function applyInitialData(data: PokemonListInitialData | null | undefined) {
if (!data) return;
if (!options.value && data.options) {
options.value = data.options;
}
if (initialPageLoaded.value || !data.page) {
return;
}
pokemon.value = data.page.items;
nextCursor.value = data.page.nextCursor;
hasMorePokemon.value = data.page.hasMore;
initialPageLoaded.value = true;
loading.value = false;
}
async function loadPokemon(reset = true) { async function loadPokemon(reset = true) {
if (!reset && (loading.value || loadingMore.value || !hasMorePokemon.value)) { if (!reset && (loading.value || loadingMore.value || !hasMorePokemon.value)) {
return; return;
@@ -88,6 +133,14 @@ async function loadPokemon(reset = true) {
} }
nextCursor.value = page.nextCursor; nextCursor.value = page.nextCursor;
hasMorePokemon.value = page.hasMore; hasMorePokemon.value = page.hasMore;
initialPageLoaded.value = true;
} catch {
if (requestId === loadRequestId && reset) {
pokemon.value = [];
nextCursor.value = null;
hasMorePokemon.value = false;
initialPageLoaded.value = true;
}
} finally { } finally {
if (requestId === loadRequestId) { if (requestId === loadRequestId) {
loading.value = false; loading.value = false;
@@ -105,20 +158,28 @@ function pokemonCardImage(item: Pokemon) {
} }
onMounted(async () => { onMounted(async () => {
if (getAuthToken()) { try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
if (!options.value) {
try { try {
currentUser.value = (await api.me()).user; options.value = await api.options();
} catch { } catch {
currentUser.value = null; options.value = null;
} }
} }
options.value = await api.options(); if (!initialPageLoaded.value) {
await loadPokemon(); await loadPokemon();
}
}); });
watch(query, () => { watch(query, () => {
void loadPokemon(); void loadPokemon();
}); });
watch(initialData, applyInitialData, { immediate: true });
</script> </script>
<template> <template>

View File

@@ -27,11 +27,33 @@ const loadMoreSentinel = ref<HTMLElement | null>(null);
const expandedCommitShas = ref<Set<string>>(new Set()); const expandedCommitShas = ref<Set<string>>(new Set());
let projectUpdatesObserver: IntersectionObserver | null = null; let projectUpdatesObserver: IntersectionObserver | null = null;
const { data: initialData } = await useAsyncData<ProjectUpdates | null>(
`project-updates-initial:${locale.value}`,
async () => {
try {
return await api.projectUpdates({ limit: projectCommitPageSize });
} catch {
return null;
}
},
{ default: () => null }
);
const initialUpdates = initialData.value;
projectUpdates.value = initialUpdates;
projectCommits.value = initialUpdates?.commits.items ?? [];
projectCommitCursor.value = initialUpdates?.commits.nextCursor ?? null;
projectHasMoreCommits.value = initialUpdates?.commits.hasMore ?? false;
const initialUpdatesLoaded = ref(initialUpdates !== null);
loading.value = !initialUpdatesLoaded.value;
const releases = computed(() => projectUpdates.value?.releases ?? []); const releases = computed(() => projectUpdates.value?.releases ?? []);
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null)); const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
onMounted(() => { onMounted(() => {
void loadProjectUpdates(); if (!initialUpdatesLoaded.value) {
void loadProjectUpdates();
}
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -53,9 +75,11 @@ async function loadProjectUpdates(): Promise<void> {
projectCommits.value = updates.commits.items; projectCommits.value = updates.commits.items;
projectCommitCursor.value = updates.commits.nextCursor; projectCommitCursor.value = updates.commits.nextCursor;
projectHasMoreCommits.value = updates.commits.hasMore; projectHasMoreCommits.value = updates.commits.hasMore;
initialUpdatesLoaded.value = true;
} catch { } catch {
projectUpdates.value = null; projectUpdates.value = null;
projectCommits.value = []; projectCommits.value = [];
initialUpdatesLoaded.value = true;
loadError.value = true; loadError.value = true;
} finally { } finally {
loading.value = false; loading.value = false;

View File

@@ -11,12 +11,12 @@ import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit, iconRecipe } from '../icons'; import { iconBack, iconEdit, iconRecipe } from '../icons';
import { applySeo } from '../seo'; import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
import { api, getAuthToken, type AuthUser, type RecipeDetail } from '../services/api'; import { api, type AuthUser, type RecipeDetail } from '../services/api';
import RecipeEdit from './RecipeEdit.vue'; import RecipeEdit from './RecipeEdit.vue';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t, locale } = useI18n();
const recipe = ref<RecipeDetail | null>(null); const recipe = ref<RecipeDetail | null>(null);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const detailTab = ref('details'); const detailTab = ref('details');
@@ -42,29 +42,68 @@ const recipeSubtitle = computed(() => {
return categoryName ?? t('pages.recipes.detailSubtitle'); return categoryName ?? t('pages.recipes.detailSubtitle');
}); });
async function loadRecipeDetail() { const { data: initialRecipe } = useAsyncData<RecipeDetail | null>(
const nextRecipe = await api.recipeDetail(String(route.params.id)); `recipe-detail:${String(route.params.id)}:${locale.value}`,
recipe.value = nextRecipe; async () => {
try {
return await api.recipeDetail(String(route.params.id));
} catch {
return null;
}
},
{ default: () => null }
);
if (route.meta.editorModal !== true) { const initialRecipeLoaded = ref(false);
applySeo({ const recipeSeo = computed(() =>
title: `${nextRecipe.name} - ${t('pages.recipes.title')}`, recipe.value && route.meta.editorModal !== true
description: t('seo.recipeDetailDescription', { name: nextRecipe.name }), ? resolveSeo({
canonicalPath: `/recipes/${nextRecipe.id}`, title: `${recipe.value.name} - ${t('pages.recipes.title')}`,
image: nextRecipe.item.image?.url description: t('seo.recipeDetailDescription', { name: recipe.value.name }),
}); canonicalPath: `/recipes/${recipe.value.id}`,
image: recipe.value.item.image?.url
})
: null
);
useHead(() => (recipeSeo.value ? resolvedSeoHead(recipeSeo.value) : {}));
function applyInitialRecipe(value: RecipeDetail | null | undefined) {
if (!value || initialRecipeLoaded.value) return;
recipe.value = value;
initialRecipeLoaded.value = true;
}
async function loadRecipeDetail() {
try {
const nextRecipe = await api.recipeDetail(String(route.params.id));
recipe.value = nextRecipe;
initialRecipeLoaded.value = true;
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextRecipe.name} - ${t('pages.recipes.title')}`,
description: t('seo.recipeDetailDescription', { name: nextRecipe.name }),
canonicalPath: `/recipes/${nextRecipe.id}`,
image: nextRecipe.item.image?.url
});
}
} catch {
recipe.value = null;
initialRecipeLoaded.value = true;
} }
} }
onMounted(async () => { onMounted(async () => {
if (getAuthToken()) { try {
try { currentUser.value = (await api.me()).user;
currentUser.value = (await api.me()).user; } catch {
} catch { currentUser.value = null;
currentUser.value = null; }
} if (!initialRecipeLoaded.value) {
await loadRecipeDetail();
} }
await loadRecipeDetail();
}); });
watch( watch(
@@ -84,6 +123,8 @@ watch(
void loadRecipeDetail(); void loadRecipeDetail();
} }
); );
watch(initialRecipe, applyInitialRecipe, { immediate: true });
</script> </script>
<template> <template>

View File

@@ -8,7 +8,7 @@ import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconCancel, iconDelete, iconSave } from '../icons'; import { iconAdd, iconCancel, iconDelete, iconSave } from '../icons';
import { api, getAuthToken, type AuthUser, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api'; import { api, type AuthUser, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -28,11 +28,11 @@ const recipeForm = ref({
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : '')); const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== ''); const isEditing = computed(() => routeId.value !== '');
const materialItemOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name }))); const materialItemOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url })));
const resultItemOptions = computed(() => const resultItemOptions = computed(() =>
itemRows.value itemRows.value
.filter((item) => !item.noRecipe || String(item.id) === recipeForm.value.itemId) .filter((item) => !item.noRecipe || String(item.id) === recipeForm.value.itemId)
.map((item) => ({ id: item.id, name: item.name })) .map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url }))
); );
const selectedItemName = computed(() => resultItemOptions.value.find((item) => String(item.id) === recipeForm.value.itemId)?.name ?? ''); const selectedItemName = computed(() => resultItemOptions.value.find((item) => String(item.id) === recipeForm.value.itemId)?.name ?? '');
const pageTitle = computed(() => const pageTitle = computed(() =>
@@ -105,11 +105,6 @@ async function loadOptions() {
} }
async function loadCurrentUser() { async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try { try {
currentUser.value = (await api.me()).user; currentUser.value = (await api.me()).user;
} catch { } catch {

View File

@@ -11,12 +11,12 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconNoRecipe, iconRecipe } from '../icons'; import { iconAdd, iconNoRecipe, iconRecipe } from '../icons';
import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api'; import { api, type AuthUser, type Item, type ListPage, type Options } from '../services/api';
import RecipeEdit from './RecipeEdit.vue'; import RecipeEdit from './RecipeEdit.vue';
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t, locale } = useI18n();
const items = ref<Item[]>([]); const items = ref<Item[]>([]);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const loading = ref(true); const loading = ref(true);
@@ -46,6 +46,52 @@ const itemQuery = computed(() => ({
tagIds: tagIds.value.join(','), tagIds: tagIds.value.join(','),
recipeOrder: 1 recipeOrder: 1
})); }));
type RecipeListInitialData = {
options: Options | null;
page: ListPage<Item> | null;
};
const { data: initialData } = useAsyncData<RecipeListInitialData>(
`recipe-list-initial:${locale.value}`,
async () => {
const [optionsResult, itemsResult] = await Promise.allSettled([
api.options(),
api.itemsPage({
...itemQuery.value,
cursor: null,
limit: listPageSize
})
]);
return {
options: optionsResult.status === 'fulfilled' ? optionsResult.value : null,
page: itemsResult.status === 'fulfilled' ? itemsResult.value : null
};
},
{ default: () => ({ options: null, page: null }) }
);
const initialPageLoaded = ref(false);
function applyInitialData(data: RecipeListInitialData | null | undefined) {
if (!data) return;
if (!options.value && data.options) {
options.value = data.options;
}
if (initialPageLoaded.value || !data.page) {
return;
}
items.value = data.page.items;
nextCursor.value = data.page.nextCursor;
hasMoreItems.value = data.page.hasMore;
initialPageLoaded.value = true;
loading.value = false;
}
const showEditor = computed(() => route.name === 'recipe-new'); const showEditor = computed(() => route.name === 'recipe-new');
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true); const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
@@ -103,6 +149,14 @@ async function loadItems(reset = true) {
} }
nextCursor.value = page.nextCursor; nextCursor.value = page.nextCursor;
hasMoreItems.value = page.hasMore; hasMoreItems.value = page.hasMore;
initialPageLoaded.value = true;
} catch {
if (requestId === loadRequestId && reset) {
items.value = [];
nextCursor.value = null;
hasMoreItems.value = false;
initialPageLoaded.value = true;
}
} finally { } finally {
if (requestId === loadRequestId) { if (requestId === loadRequestId) {
loading.value = false; loading.value = false;
@@ -116,20 +170,28 @@ function loadMoreItems() {
} }
onMounted(async () => { onMounted(async () => {
if (getAuthToken()) { try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
if (!options.value) {
try { try {
currentUser.value = (await api.me()).user; options.value = await api.options();
} catch { } catch {
currentUser.value = null; options.value = null;
} }
} }
options.value = await api.options(); if (!initialPageLoaded.value) {
await loadItems(); await loadItems();
}
}); });
watch(itemQuery, () => { watch(itemQuery, () => {
void loadItems(); void loadItems();
}); });
watch(initialData, applyInitialData, { immediate: true });
</script> </script>
<template> <template>

File diff suppressed because it is too large Load Diff

View File

@@ -26,12 +26,11 @@ import {
} from '../icons'; } from '../icons';
import { import {
api, api,
getAuthToken,
notifyAuthChange, notifyAuthChange,
setAuthToken,
type AuthUser, type AuthUser,
type DiscussionEntityType, type DiscussionEntityType,
type LifePost, type LifePost,
type LifePostsPage,
type LifeReactionType, type LifeReactionType,
type ProfileCommentSource, type ProfileCommentSource,
type PublicUserProfile, type PublicUserProfile,
@@ -39,6 +38,7 @@ import {
type UserCommentActivity, type UserCommentActivity,
type UserReactionActivity type UserReactionActivity
} from '../services/api'; } from '../services/api';
import { resolvedSeoHead, resolveSeo } from '../seo';
type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account'; type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account';
type PrimaryContributionFilter = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats' | 'daily-checklist'; type PrimaryContributionFilter = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats' | 'daily-checklist';
@@ -199,6 +199,47 @@ const socialStats = computed(() => {
{ label: t('pages.profile.friends'), value: social?.friendCount ?? 0 } { label: t('pages.profile.friends'), value: social?.friendCount ?? 0 }
]; ];
}); });
type PublicProfileInitialData = {
profile: PublicUserProfile | null;
feeds: LifePostsPage | null;
};
const { data: initialPublicProfile } = await useAsyncData<PublicProfileInitialData>(
`public-profile:${String(routeProfileId.value ?? '')}:${locale.value}`,
async () => {
const targetId = routeProfileId.value;
if (!targetId) {
return { profile: null, feeds: null };
}
const profileResult = await Promise.allSettled([api.publicProfile(targetId), api.userLifePosts(targetId, { limit: activityLimit })]);
return {
profile: profileResult[0].status === 'fulfilled' ? profileResult[0].value.profile : null,
feeds: profileResult[1].status === 'fulfilled' ? profileResult[1].value : null
};
},
{ default: () => ({ profile: null, feeds: null }) }
);
profile.value = initialPublicProfile.value.profile;
feeds.value = initialPublicProfile.value.feeds?.items ?? [];
feedsCursor.value = initialPublicProfile.value.feeds?.nextCursor ?? null;
feedsHasMore.value = initialPublicProfile.value.feeds?.hasMore ?? false;
const initialPublicProfileLoaded = ref(initialPublicProfile.value.profile !== null);
loading.value = !initialPublicProfileLoaded.value;
const profileSeo = computed(() =>
profile.value && !isAccountRoute.value
? resolveSeo({
title: `${profile.value.user.displayName} - ${t('pages.profile.title')}`,
description: t('pages.profile.publicSubtitle'),
canonicalPath: `/profile/${profile.value.user.id}`
})
: null
);
useHead(() => (profileSeo.value ? resolvedSeoHead(profileSeo.value) : {}));
const filteredContributions = computed(() => { const filteredContributions = computed(() => {
const items = profile.value?.contributions ?? []; const items = profile.value?.contributions ?? [];
if (contributionFilter.value === 'all') { if (contributionFilter.value === 'all') {
@@ -280,18 +321,12 @@ function resetActivity() {
} }
async function loadOptionalCurrentUser() { async function loadOptionalCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return null;
}
try { try {
const response = await api.me(); const response = await api.me();
currentUser.value = response.user; currentUser.value = response.user;
return response.user; return response.user;
} catch { } catch {
currentUser.value = null; currentUser.value = null;
setAuthToken(null);
return null; return null;
} }
} }
@@ -648,8 +683,7 @@ function contentTypeLabel(contentType: string): string {
environments: t('config.environments'), environments: t('config.environments'),
'favorite-things': t('config.favoriteThings'), 'favorite-things': t('config.favoriteThings'),
'acquisition-methods': t('config.acquisitionMethods'), 'acquisition-methods': t('config.acquisitionMethods'),
maps: t('config.maps'), maps: t('config.maps')
'life-tags': t('config.lifeCategories')
}; };
return labels[contentType] ?? t('pages.profile.otherContributions'); return labels[contentType] ?? t('pages.profile.otherContributions');
} }
@@ -805,10 +839,6 @@ onMounted(() => {
<p class="life-post__body">{{ post.body }}</p> <p class="life-post__body">{{ post.body }}</p>
<div v-if="post.category" class="life-post__tags" :aria-label="t('pages.life.category')">
<span class="life-post__tag">{{ post.category.name }}</span>
</div>
<div class="profile-feed-card__metrics"> <div class="profile-feed-card__metrics">
<button <button
class="profile-reaction-open-button" class="profile-reaction-open-button"

View File

@@ -2,13 +2,15 @@
"name": "pokopia", "name": "pokopia",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "pnpm@10.33.2", "packageManager": "pnpm@10.33.3+sha512.a19744364a7e248b92657a4ca5973f9354d21caf982579674b1c539f32c7420c47138ad8b1254df07aba9bc782d9b3029e3db34d5dbff974326eb74dac8ff489",
"scripts": { "scripts": {
"dev": "pnpm --parallel --filter @pokopia/backend --filter @pokopia/frontend dev", "dev": "pnpm --parallel --filter @pokopia/backend --filter @pokopia/frontend dev",
"lint": "pnpm -r lint", "lint": "pnpm -r lint",
"typecheck": "pnpm -r typecheck", "typecheck": "pnpm -r typecheck",
"test": "pnpm -r test", "test": "pnpm -r test",
"build": "pnpm -r build" "build": "pnpm -r build",
"docker:debug": "docker compose -f docker-compose.debug.yml up --build",
"docker:prod": "docker compose up --build"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22"

View File

@@ -1,3 +1,6 @@
packages: packages:
- backend - backend
- frontend - frontend
allowBuilds:
'@parcel/watcher': true
esbuild: true

View File

@@ -65,6 +65,7 @@ export const systemWordingMessages = {
clothes: 'Clothes', clothes: 'Clothes',
checklist: 'CheckList', checklist: 'CheckList',
life: 'Life', life: 'Life',
threads: 'Threads',
admin: 'Admin', admin: 'Admin',
main: 'Main navigation', main: 'Main navigation',
openMenu: 'Open navigation', openMenu: 'Open navigation',
@@ -77,6 +78,12 @@ export const systemWordingMessages = {
logout: 'Log out', logout: 'Log out',
register: 'Register' register: 'Register'
}, },
viewAs: {
banner: 'View As {name}',
exit: 'Exit',
userAction: 'View As user',
roleAction: 'View As role'
},
search: { search: {
label: 'Search Pokopia Wiki', label: 'Search Pokopia Wiki',
placeholder: 'Search wiki', placeholder: 'Search wiki',
@@ -132,6 +139,10 @@ export const systemWordingMessages = {
seo: { seo: {
siteDescription: siteDescription:
'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.', '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.',
threadsDescription:
'Browse Pokopia Wiki Threads for Pokemon Pokopia community discussions by channel, language, tags, and recent activity.',
threadDetailDescription:
'Read the {title} community thread in Pokopia Wiki Threads, including public discussion, channel tags, language, and {count} visible messages.',
pokemonDetailDescription: pokemonDetailDescription:
'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.', 'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.',
itemDetailDescription: itemDetailDescription:
@@ -547,6 +558,7 @@ export const systemWordingMessages = {
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.', subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
listKicker: 'Pokédex', listKicker: 'Pokédex',
detailKicker: 'Pokédex Detail', detailKicker: 'Pokédex Detail',
detailSubtitle: 'Pokemon profile',
editKicker: 'Pokédex Edit', editKicker: 'Pokédex Edit',
editSubtitle: 'Maintain Pokemon profile, details, types, stats, specialities, and favourites.', editSubtitle: 'Maintain Pokemon profile, details, types, stats, specialities, and favourites.',
editSections: 'Pokemon edit sections', editSections: 'Pokemon edit sections',
@@ -583,6 +595,14 @@ export const systemWordingMessages = {
loadingEdit: 'Loading Pokemon editor', loadingEdit: 'Loading Pokemon editor',
environmentPrefix: 'Ideal Habitat: {name}', environmentPrefix: 'Ideal Habitat: {name}',
details: 'Details', details: 'Details',
description: 'Pokemon Description',
referenceTab: 'Pokédex reference',
referenceData: 'Pokédex reference data',
pokedexReferenceNote: 'Stats, height, weight, and types follow the Pokédex-style reference design only. They are not Pokopia mechanics.',
coreFactors: 'Core factors',
skillsCoreNote: 'Affects habitat selection, item drops, and Trading behavior.',
environmentCoreNote: 'Affects habitat selection and related Pokemon comparison.',
favoriteThingsCoreNote: 'Affects item drops, hidden item tags, and Trading price evidence.',
genus: 'Genus', genus: 'Genus',
height: 'Height', height: 'Height',
heightInput: 'Height (in)', heightInput: 'Height (in)',
@@ -708,8 +728,11 @@ export const systemWordingMessages = {
tags: 'Tags', tags: 'Tags',
acquisitionMethods: 'Acquisition methods', acquisitionMethods: 'Acquisition methods',
customization: 'Customization', customization: 'Customization',
dyeability: 'Dyeability',
notDyeable: 'Not dyeable',
dyeable: 'Dyeable', dyeable: 'Dyeable',
dualDyeable: 'Dual dyeable', dualDyeable: 'Dual dyeable',
tripleDyeable: 'Triple dyeable',
patternEditable: 'Pattern editable', patternEditable: 'Pattern editable',
noRecipe: 'No recipe', noRecipe: 'No recipe',
eventItem: 'Event item', eventItem: 'Event item',
@@ -901,30 +924,21 @@ export const systemWordingMessages = {
bodyLabel: 'Post', bodyLabel: 'Post',
bodyPlaceholder: 'Share a thought, tip, or discovery...', bodyPlaceholder: 'Share a thought, tip, or discovery...',
newPost: 'New Post', newPost: 'New Post',
category: 'Category',
gameVersion: 'Game version', gameVersion: 'Game version',
versionPlaceholder: 'No version', versionPlaceholder: 'No version',
searchVersions: 'Search versions', searchVersions: 'Search versions',
languages: 'Languages', languages: 'Languages',
allLanguages: 'All languages', allLanguages: 'All languages',
allCategories: 'All',
feedScope: 'Feed scope', feedScope: 'Feed scope',
allFeed: 'All feed', allFeed: 'All feed',
followingFeed: 'Following', followingFeed: 'Following',
allVersions: 'All versions', allVersions: 'All versions',
versionFilter: 'Version', versionFilter: 'Version',
ratingFilter: 'Rating',
allRatingModes: 'All posts',
rateableOnly: 'Rateable only',
notRateableOnly: 'Not rateable',
sort: 'Sort', sort: 'Sort',
sortLatest: 'Latest', sortLatest: 'Latest',
sortOldest: 'Oldest', sortOldest: 'Oldest',
sortTopRated: 'Top rated',
sortMostLiked: 'Most liked', sortMostLiked: 'Most liked',
sortMostReplied: 'Most replied', sortMostReplied: 'Most replied',
categoryPlaceholder: 'Select category',
searchCategories: 'Search categories',
search: 'Search Life', search: 'Search Life',
searchPlaceholder: 'Search post content...', searchPlaceholder: 'Search post content...',
clearSearch: 'Clear search', clearSearch: 'Clear search',
@@ -959,7 +973,6 @@ export const systemWordingMessages = {
removeRating: 'Remove rating', removeRating: 'Remove rating',
ratingAverage: '{average} average from {count} ratings', ratingAverage: '{average} average from {count} ratings',
noRatings: 'No ratings yet', noRatings: 'No ratings yet',
ratingFailed: 'Rating failed',
commentPlaceholder: 'Write a comment...', commentPlaceholder: 'Write a comment...',
commentReplyPlaceholder: 'Write a reply...', commentReplyPlaceholder: 'Write a reply...',
postComment: 'Post comment', postComment: 'Post comment',
@@ -1002,7 +1015,6 @@ export const systemWordingMessages = {
saveFailed: 'Save failed', saveFailed: 'Save failed',
deleteFailed: 'Delete failed', deleteFailed: 'Delete failed',
bodyRequired: 'Please enter a post.', bodyRequired: 'Please enter a post.',
categoryRequired: 'Please select a category.',
byUnknown: 'Community member', byUnknown: 'Community member',
edited: 'Edited', edited: 'Edited',
deleteConfirm: 'Delete this post?', deleteConfirm: 'Delete this post?',
@@ -1017,6 +1029,57 @@ export const systemWordingMessages = {
moderationRetryFailed: 'Review retry failed', moderationRetryFailed: 'Review retry failed',
charactersLeft: '{count} characters left' charactersLeft: '{count} characters left'
}, },
threads: {
kicker: 'Community Threads',
title: 'Threads',
subtitle: 'Browse channel discussions and chat inside each thread.',
channels: 'Channels',
allChannels: 'All channels',
newThread: 'New Thread',
createPost: 'Create Post',
searchOrCreate: 'Search or Create New Post',
editPost: 'Edit Post',
threadTitle: 'Title',
firstMessage: 'First message',
message: 'Message',
send: 'Send',
sending: 'Sending',
editMessage: 'Edit Message',
follow: 'Follow',
unfollow: 'Unfollow',
lock: 'Lock',
unlock: 'Unlock',
locked: 'Locked',
unread: 'Unread',
tags: 'Tags',
language: 'Language',
allLanguages: 'All languages',
sort: 'Sort',
sortLastActive: 'Last active',
sortLatest: 'Latest',
sortMostDiscussed: 'Most discussed',
loadMoreThreads: 'Load more threads',
loadOlder: 'Load older',
jumpToPresent: 'Jump to Present',
unreadDivider: 'Unread messages',
noThreads: 'No threads yet',
noSearchResults: 'No matching posts',
noMessages: 'No messages yet',
selectThread: 'Select a thread',
messageUnreviewed: 'Not reviewed',
messageReviewing: 'Reviewing',
messageRejected: 'Not approved',
messageFailedReview: 'Review failed',
moderationRetry: 'Retry review',
moderationRetrying: 'Retrying',
createFailed: 'Thread could not be created',
editFailed: 'Thread could not be updated',
messageFailed: 'Message could not be sent',
messageEditFailed: 'Message could not be updated',
moderationRetryFailed: 'Review retry failed',
reactionFailed: 'Reaction could not be updated',
followFailed: 'Follow could not be updated'
},
admin: { admin: {
title: 'Admin', title: 'Admin',
subtitle: 'Manage Wiki content, configuration, localization, and access.', subtitle: 'Manage Wiki content, configuration, localization, and access.',
@@ -1038,6 +1101,13 @@ export const systemWordingMessages = {
recipeList: 'Recipe list', recipeList: 'Recipe list',
dishList: 'Dish list', dishList: 'Dish list',
habitatList: 'Habitat list', habitatList: 'Habitat list',
threadChannels: 'Thread channels',
newThreadChannel: 'New Thread channel',
editThreadChannel: 'Edit Thread channel',
allowUserThreads: 'Allow users to create Threads',
userThreadsAllowed: 'Users can create Threads',
userThreadsDisabled: 'User Thread creation disabled',
threadTagsPlaceholder: 'tag, tag, tag',
dataTools: 'Data tools', dataTools: 'Data tools',
dataToolRefresh: 'Refresh', dataToolRefresh: 'Refresh',
dataToolExport: 'Export data', dataToolExport: 'Export data',
@@ -1074,7 +1144,7 @@ export const systemWordingMessages = {
editConfig: 'Edit {name}', editConfig: 'Edit {name}',
hasItemDrop: 'Has item drop', hasItemDrop: 'Has item drop',
hasTrading: 'Has trading', hasTrading: 'Has trading',
rateableCategory: 'Rateable', opposite: 'Opposite',
changeLog: 'ChangeLog', changeLog: 'ChangeLog',
dragSort: 'Drag to reorder: {name}', dragSort: 'Drag to reorder: {name}',
dragSortTitle: 'Drag to reorder', dragSortTitle: 'Drag to reorder',
@@ -1082,7 +1152,6 @@ export const systemWordingMessages = {
languageName: 'Language name', languageName: 'Language name',
enabled: 'Enabled', enabled: 'Enabled',
defaultLanguage: 'Default language', defaultLanguage: 'Default language',
defaultCategory: 'Default category',
sortOrder: 'Sort order', sortOrder: 'Sort order',
newLanguage: 'New language', newLanguage: 'New language',
editLanguage: 'Edit language', editLanguage: 'Edit language',
@@ -1157,7 +1226,6 @@ export const systemWordingMessages = {
favoriteThings: 'Favourites / tags', favoriteThings: 'Favourites / tags',
acquisitionMethods: 'Acquisition methods', acquisitionMethods: 'Acquisition methods',
maps: 'Maps', maps: 'Maps',
lifeCategories: 'Life categories',
gameVersions: 'Game versions', gameVersions: 'Game versions',
dishFlavors: 'Dish flavors' dishFlavors: 'Dish flavors'
}, },
@@ -1320,13 +1388,10 @@ export const systemWordingMessages = {
taskDoesNotExist: 'Task does not exist', taskDoesNotExist: 'Task does not exist',
postRequired: 'Please enter a post', postRequired: 'Please enter a post',
postTooLong: 'Post is too long', postTooLong: 'Post is too long',
lifeCategoryRequired: 'Please select a category',
lifeCategoryInvalid: 'Category is invalid',
gameVersionInvalid: 'Game version is invalid', gameVersionInvalid: 'Game version is invalid',
commentRequired: 'Please enter a comment', commentRequired: 'Please enter a comment',
commentTooLong: 'Comment is too long', commentTooLong: 'Comment is too long',
reactionInvalid: 'Reaction is invalid', reactionInvalid: 'Reaction is invalid',
ratingInvalid: 'Rating is invalid',
cursorInvalid: 'Cursor is invalid', cursorInvalid: 'Cursor is invalid',
tagInvalid: 'Tag is invalid', tagInvalid: 'Tag is invalid',
entityTypeInvalid: 'Entity type is invalid', entityTypeInvalid: 'Entity type is invalid',
@@ -1458,6 +1523,7 @@ export const systemWordingMessages = {
clothes: '服装', clothes: '服装',
checklist: 'CheckList', checklist: 'CheckList',
life: 'Life', life: 'Life',
threads: '讨论',
admin: '管理', admin: '管理',
main: '主导航', main: '主导航',
openMenu: '打开导航', openMenu: '打开导航',
@@ -1470,6 +1536,12 @@ export const systemWordingMessages = {
logout: '退出', logout: '退出',
register: '注册' register: '注册'
}, },
viewAs: {
banner: 'View As {name}',
exit: '退出',
userAction: '以用户身份 View As',
roleAction: '以角色身份 View As'
},
search: { search: {
label: '搜索 Pokopia Wiki', label: '搜索 Pokopia Wiki',
placeholder: '搜索 Wiki', placeholder: '搜索 Wiki',
@@ -1524,6 +1596,8 @@ export const systemWordingMessages = {
}, },
seo: { seo: {
siteDescription: '浏览 Pokopia Wiki 的 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、Event Items、Ancient Artifacts、材料单、每日清单和 Life 社区动态。', siteDescription: '浏览 Pokopia Wiki 的 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、Event Items、Ancient Artifacts、材料单、每日清单和 Life 社区动态。',
threadsDescription: '按频道、语言、标签和最近活跃浏览 Pokopia Wiki 讨论,查看 Pokemon Pokopia 社区帖子。',
threadDetailDescription: '查看 Pokopia Wiki 讨论中的 {title} 帖子,包含公开讨论、频道标签、语言和 {count} 条可见消息。',
pokemonDetailDescription: '查看 {name} 在 Pokopia Wiki 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。', pokemonDetailDescription: '查看 {name} 在 Pokopia Wiki 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。',
itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的基础价格、分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。', itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的基础价格、分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。',
ancientArtifactDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、标签、介绍、讨论和编辑历史。', ancientArtifactDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、标签、介绍、讨论和编辑历史。',
@@ -1914,6 +1988,7 @@ export const systemWordingMessages = {
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。', subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',
listKicker: 'Pokédex', listKicker: 'Pokédex',
detailKicker: 'Pokédex Detail', detailKicker: 'Pokédex Detail',
detailSubtitle: 'Pokemon 资料',
editKicker: 'Pokédex Edit', editKicker: 'Pokédex Edit',
editSubtitle: '维护 Pokemon 介绍、属性、六维、特长和喜欢的东西。', editSubtitle: '维护 Pokemon 介绍、属性、六维、特长和喜欢的东西。',
editSections: 'Pokemon 编辑分区', editSections: 'Pokemon 编辑分区',
@@ -1950,6 +2025,14 @@ export const systemWordingMessages = {
loadingEdit: '正在加载 Pokemon 编辑内容', loadingEdit: '正在加载 Pokemon 编辑内容',
environmentPrefix: '喜欢的环境:{name}', environmentPrefix: '喜欢的环境:{name}',
details: '介绍', details: '介绍',
description: 'Pokemon Description',
referenceTab: 'Pokédex 参考',
referenceData: 'Pokédex 参考数据',
pokedexReferenceNote: '六维、身高体重和属性仅参考 Pokédex 的展示设计,不属于 Pokopia 机制。',
coreFactors: '核心要素',
skillsCoreNote: '影响栖息地选择、物品掉落和 Trading 行为。',
environmentCoreNote: '影响栖息地选择和相关 Pokemon 对比。',
favoriteThingsCoreNote: '影响物品掉落、隐藏标签判断和 Trading 价格证据。',
genus: '分类', genus: '分类',
height: '身高', height: '身高',
heightInput: '身高in', heightInput: '身高in',
@@ -2075,8 +2158,11 @@ export const systemWordingMessages = {
tags: '标签', tags: '标签',
acquisitionMethods: '入手方式', acquisitionMethods: '入手方式',
customization: '自定义', customization: '自定义',
dyeability: '染色能力',
notDyeable: '不可染色',
dyeable: '可染色', dyeable: '可染色',
dualDyeable: '可双区染色', dualDyeable: '可双区染色',
tripleDyeable: '可三区染色',
patternEditable: '可改花纹', patternEditable: '可改花纹',
noRecipe: '无材料单', noRecipe: '无材料单',
eventItem: '活动物品', eventItem: '活动物品',
@@ -2268,30 +2354,21 @@ export const systemWordingMessages = {
bodyLabel: '动态内容', bodyLabel: '动态内容',
bodyPlaceholder: '分享一段想法、心得或发现……', bodyPlaceholder: '分享一段想法、心得或发现……',
newPost: 'New Post', newPost: 'New Post',
category: 'Category',
gameVersion: '游戏版本', gameVersion: '游戏版本',
versionPlaceholder: '不选择版本', versionPlaceholder: '不选择版本',
searchVersions: '搜索版本', searchVersions: '搜索版本',
languages: '语言区', languages: '语言区',
allLanguages: '全部语言', allLanguages: '全部语言',
allCategories: '全部',
feedScope: '动态范围', feedScope: '动态范围',
allFeed: '全部动态', allFeed: '全部动态',
followingFeed: '关注动态', followingFeed: '关注动态',
allVersions: '全部版本', allVersions: '全部版本',
versionFilter: '版本', versionFilter: '版本',
ratingFilter: '评分',
allRatingModes: '全部动态',
rateableOnly: '仅可评分',
notRateableOnly: '不可评分',
sort: '排序', sort: '排序',
sortLatest: '最新', sortLatest: '最新',
sortOldest: '最早', sortOldest: '最早',
sortTopRated: '评分最高',
sortMostLiked: '点赞最多', sortMostLiked: '点赞最多',
sortMostReplied: '回复最多', sortMostReplied: '回复最多',
categoryPlaceholder: '选择 Category',
searchCategories: '搜索 Category',
search: '搜索动态', search: '搜索动态',
searchPlaceholder: '搜索动态内容……', searchPlaceholder: '搜索动态内容……',
clearSearch: '清除搜索', clearSearch: '清除搜索',
@@ -2326,7 +2403,6 @@ export const systemWordingMessages = {
removeRating: '取消评分', removeRating: '取消评分',
ratingAverage: '{average} 平均分,{count} 人评分', ratingAverage: '{average} 平均分,{count} 人评分',
noRatings: '暂无评分', noRatings: '暂无评分',
ratingFailed: '评分失败',
commentPlaceholder: '写下评论……', commentPlaceholder: '写下评论……',
commentReplyPlaceholder: '写下回复……', commentReplyPlaceholder: '写下回复……',
postComment: '发表评论', postComment: '发表评论',
@@ -2369,7 +2445,6 @@ export const systemWordingMessages = {
saveFailed: '保存失败', saveFailed: '保存失败',
deleteFailed: '删除失败', deleteFailed: '删除失败',
bodyRequired: '请输入动态内容。', bodyRequired: '请输入动态内容。',
categoryRequired: '请选择 Category。',
byUnknown: '社区成员', byUnknown: '社区成员',
edited: '已编辑', edited: '已编辑',
deleteConfirm: '确认删除这条动态?', deleteConfirm: '确认删除这条动态?',
@@ -2384,6 +2459,57 @@ export const systemWordingMessages = {
moderationRetryFailed: '重新审核失败', moderationRetryFailed: '重新审核失败',
charactersLeft: '还可以输入 {count} 个字符' charactersLeft: '还可以输入 {count} 个字符'
}, },
threads: {
kicker: '社区讨论',
title: '讨论',
subtitle: '按频道浏览帖子,并在帖子内用聊天消息讨论。',
channels: '频道',
allChannels: '全部频道',
newThread: '新帖子',
createPost: '创建帖子',
searchOrCreate: '搜索或创建新帖子',
editPost: '编辑帖子',
threadTitle: '标题',
firstMessage: '首条消息',
message: '消息',
send: '发送',
sending: '发送中',
editMessage: '编辑消息',
follow: '关注',
unfollow: '取消关注',
lock: '锁定',
unlock: '解锁',
locked: '已锁定',
unread: '未读',
tags: '标签',
language: '语言',
allLanguages: '全部语言',
sort: '排序',
sortLastActive: '最后活跃',
sortLatest: '最新发布',
sortMostDiscussed: '讨论数',
loadMoreThreads: '加载更多帖子',
loadOlder: '加载旧消息',
jumpToPresent: '跳到最新',
unreadDivider: '未读消息',
noThreads: '暂无帖子',
noSearchResults: '没有匹配的帖子',
noMessages: '暂无消息',
selectThread: '选择一个帖子',
messageUnreviewed: '未审核',
messageReviewing: '审核中',
messageRejected: '未通过',
messageFailedReview: '审核失败',
moderationRetry: '重新审核',
moderationRetrying: '重审中',
createFailed: '帖子创建失败',
editFailed: '帖子更新失败',
messageFailed: '消息发送失败',
messageEditFailed: '消息更新失败',
moderationRetryFailed: '重新审核失败',
reactionFailed: 'Reaction 更新失败',
followFailed: '关注状态更新失败'
},
admin: { admin: {
title: '管理', title: '管理',
subtitle: '管理 Wiki 内容、配置、本地化和访问权限。', subtitle: '管理 Wiki 内容、配置、本地化和访问权限。',
@@ -2405,6 +2531,13 @@ export const systemWordingMessages = {
recipeList: '材料单列表', recipeList: '材料单列表',
dishList: '料理列表', dishList: '料理列表',
habitatList: '栖息地列表', habitatList: '栖息地列表',
threadChannels: '讨论频道',
newThreadChannel: '新增讨论频道',
editThreadChannel: '编辑讨论频道',
allowUserThreads: '允许用户创建帖子',
userThreadsAllowed: '用户可创建帖子',
userThreadsDisabled: '用户不可创建帖子',
threadTagsPlaceholder: '标签, 标签, 标签',
dataTools: '数据工具', dataTools: '数据工具',
dataToolRefresh: '刷新', dataToolRefresh: '刷新',
dataToolExport: '导出数据', dataToolExport: '导出数据',
@@ -2441,7 +2574,7 @@ export const systemWordingMessages = {
editConfig: '编辑{name}', editConfig: '编辑{name}',
hasItemDrop: '有掉落物', hasItemDrop: '有掉落物',
hasTrading: '有 Trading', hasTrading: '有 Trading',
rateableCategory: '可评分', opposite: '反义词',
changeLog: 'ChangeLog', changeLog: 'ChangeLog',
dragSort: '拖曳排序:{name}', dragSort: '拖曳排序:{name}',
dragSortTitle: '拖曳排序', dragSortTitle: '拖曳排序',
@@ -2449,7 +2582,6 @@ export const systemWordingMessages = {
languageName: '语言名称', languageName: '语言名称',
enabled: '启用', enabled: '启用',
defaultLanguage: '默认语言', defaultLanguage: '默认语言',
defaultCategory: '默认 Category',
sortOrder: '排序', sortOrder: '排序',
newLanguage: '新增语言', newLanguage: '新增语言',
editLanguage: '编辑语言', editLanguage: '编辑语言',
@@ -2524,7 +2656,6 @@ export const systemWordingMessages = {
favoriteThings: '喜欢的东西 / 标签', favoriteThings: '喜欢的东西 / 标签',
acquisitionMethods: '入手方式', acquisitionMethods: '入手方式',
maps: '地图', maps: '地图',
lifeCategories: 'Life Categories',
gameVersions: '游戏版本', gameVersions: '游戏版本',
dishFlavors: '料理味道' dishFlavors: '料理味道'
}, },
@@ -2687,13 +2818,10 @@ export const systemWordingMessages = {
taskDoesNotExist: '任务不存在', taskDoesNotExist: '任务不存在',
postRequired: '请输入动态内容', postRequired: '请输入动态内容',
postTooLong: '动态内容过长', postTooLong: '动态内容过长',
lifeCategoryRequired: '请选择 Category',
lifeCategoryInvalid: 'Category 不合法',
gameVersionInvalid: '游戏版本不合法', gameVersionInvalid: '游戏版本不合法',
commentRequired: '请输入评论内容', commentRequired: '请输入评论内容',
commentTooLong: '评论内容过长', commentTooLong: '评论内容过长',
reactionInvalid: '互动类型不合法', reactionInvalid: '互动类型不合法',
ratingInvalid: '评分不合法',
cursorInvalid: '分页位置不合法', cursorInvalid: '分页位置不合法',
tagInvalid: '标签不合法', tagInvalid: '标签不合法',
entityTypeInvalid: '实体类型不合法', entityTypeInvalid: '实体类型不合法',