Compare commits

..

42 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
35ee164794 feat(frontend): enable Nuxt SSR and migrate to Nitro server
Set `ssr: true` in Nuxt config and switch build command to `nuxt build`.
Update Dockerfile to run `.output/server/index.mjs` and remove static server.
Defer SEO initialization to prevent premature evaluation during SSR.
2026-05-06 10:28:12 +08:00
cf1eb6965e refactor(i18n): isolate Vue I18n instances per request for SSR
Replace global I18n singleton with a factory function
Inject request-specific I18n instances into Nuxt app and SEO metadata
Prevent cross-request locale state pollution during server-side rendering
2026-05-06 10:10:07 +08:00
337a6bda1f refactor(seo): migrate metadata handling to Nuxt useHead
Remove direct document.head mutations to support SSR compatibility
Implement observer pattern to sync SEO state with Nuxt universal plugin
Update analytics script to use declarative injection in Nuxt config
2026-05-06 09:59:38 +08:00
fd1f3ef636 feat(auth): implement hybrid session model with HTTP-only cookies
Add HTTP-only cookie session support to backend for SSR compatibility
Update frontend fetch calls to include credentials
Maintain legacy bearer token support for SPA transition
2026-05-06 09:48:18 +08:00
afed409127 feat(frontend): support separate browser and server API base URLs
Add NUXT_SERVER_API_BASE_URL for internal server-side API requests
Update API and i18n services to select base URL by execution context
2026-05-06 09:31:11 +08:00
6e8edbbb09 refactor(frontend): migrate from Vite to Nuxt SPA
Replace Vite and Vue Router with Nuxt framework
Update Docker, build scripts, and env vars for Nuxt generate
2026-05-06 09:19:23 +08:00
c821e9ebba feat: implement infinite scrolling for public entity lists
Add cursor-based pagination to backend list queries
Introduce LoadMoreSentinel for intersection-based loading
Replace manual load more buttons with infinite scroll sentinel
2026-05-06 08:33:08 +08:00
91a001e3f9 feat(admin): add habitats CSV import to data tools
Support importing habitats from CSV files to batch create entries
Add validation, API endpoint, and admin UI for the import process
2026-05-06 07:06:08 +08:00
22016365d8 feat: add pokemon trading preferences and item tag inference
Introduce trading preference (Likes/Neutral) for Pokemon with trading skills
Infer possible hidden tags for items based on trading observations
Update import/export, wipe, and admin config to support trading data
2026-05-05 22:54:32 +08:00
5b22d788d7 feat(admin): add items CSV import to data tools
Allow bulk importing items via CSV in the admin data tools
Support static image paths for items imported from CSV
2026-05-05 17:51:38 +08:00
0e2743b469 chore(db): clean up redundant schema migrations and legacy import logic
Remove obsolete ALTER TABLE statements and data migration blocks that are already reflected in base
table definitions.
Simplify data tool import normalization by removing legacy artifact mapping and unused entity types.
2026-05-05 11:51:08 +08:00
5a83a73108 refactor(items): merge ancient artifacts into items data model
Migrate ancient artifacts to items table using a category key.
Consolidate detail and edit views into ItemDetail and ItemEdit.
Update API, search, and data tools to reflect unified structure.
2026-05-05 10:50:07 +08:00
133 changed files with 17138 additions and 2843 deletions

View File

@@ -7,8 +7,9 @@ TRUST_PROXY=false
FRONTEND_ORIGIN=http://localhost:20015 FRONTEND_ORIGIN=http://localhost:20015
APP_ORIGIN=http://localhost:20015 APP_ORIGIN=http://localhost:20015
BACKEND_PUBLIC_ORIGIN=http://localhost:20016 BACKEND_PUBLIC_ORIGIN=http://localhost:20016
VITE_API_BASE_URL=http://localhost:20016 NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
VITE_SITE_URL=https://pokopiawiki.tootaio.com NUXT_SERVER_API_BASE_URL=http://localhost:3001
NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
RESEND_API_KEY= RESEND_API_KEY=
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>" EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
RESEND_DAILY_QUOTA_LIMIT=100 RESEND_DAILY_QUOTA_LIMIT=100
@@ -17,8 +18,16 @@ RESEND_QUOTA_RESERVE=5
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10 RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
AI_MODERATION_API_KEY= AI_MODERATION_API_KEY=
# Local Docker debug defaults:
# docker compose -f docker-compose.debug.yml up --build
# NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
# NUXT_SERVER_API_BASE_URL=http://backend:3001
# NUXT_PUBLIC_SITE_URL=http://localhost:20015
# Cloudflared tunnel deployment example: # Cloudflared tunnel deployment example:
# FRONTEND_ORIGIN=https://pokopiawiki.tootaio.com,http://localhost:20015 # FRONTEND_ORIGIN=https://pokopiawiki.tootaio.com,http://localhost:20015
# APP_ORIGIN=https://pokopiawiki.tootaio.com # APP_ORIGIN=https://pokopiawiki.tootaio.com
# BACKEND_PUBLIC_ORIGIN=https://api-pokopiawiki.tootaio.com # BACKEND_PUBLIC_ORIGIN=https://api-pokopiawiki.tootaio.com
# VITE_API_BASE_URL=https://api-pokopiawiki.tootaio.com # NUXT_PUBLIC_API_BASE_URL=https://api-pokopiawiki.tootaio.com
# NUXT_SERVER_API_BASE_URL=http://backend:3001
# NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com

5
.gitignore vendored
View File

@@ -1,6 +1,8 @@
node_modules/ node_modules/
.pnpm-store/ .pnpm-store/
dist/ dist/
.nuxt/
.output/
.env .env
.env.* .env.*
!.env.example !.env.example
@@ -8,4 +10,5 @@ coverage/
*.log *.log
.DS_Store .DS_Store
.agents/ .agents/
skills-lock.json skills-lock.json
repomix-output.xml

1
.repomixignore Normal file
View File

@@ -0,0 +1 @@
data/**/*.csv

View File

@@ -34,8 +34,8 @@ For documentation-only tasks, still follow the planning workflow, but do not run
* Runtime baseline: Node.js >= 22. * Runtime baseline: Node.js >= 22.
* Frontend: * Frontend:
* Nuxt SSR enabled (`ssr: true`)
* Vue * Vue
* Vite
* Vue Router * Vue Router
* Vue I18n * Vue I18n
* Iconify * Iconify

317
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 可展示用户名外,顶部操作以图标按钮呈现。
@@ -15,7 +16,7 @@
## 技术栈 ## 技术栈
- Monorepopnpm workspaceNode.js >= 22TypeScript。 - Monorepopnpm workspaceNode.js >= 22TypeScript。
- 前端:Vue、Vite、Vue Router、Vue I18n、Iconify。 - 前端:Nuxt`ssr: true`、Vue、Vue Router、Vue I18n、Iconify。
- 后端Node.js、Fastify、pg、PostgreSQL。 - 后端Node.js、Fastify、pg、PostgreSQL。
- 运维Docker / docker compose。 - 运维Docker / docker compose。
- 依赖版本遵循现有 `package.json`,新增依赖时优先使用当前主流稳定版本,并保持项目结构简单。 - 依赖版本遵循现有 `package.json`,新增依赖时优先使用当前主流稳定版本,并保持项目结构简单。
@@ -27,12 +28,13 @@
- 全局搜索 API 只返回公开浏览所需的最小结果字段结果类型、ID、展示标题、目标 URL、可选摘要和可选图片用户搜索结果只使用公开 Profile 所需的 `id``displayName` 和目标 URL不返回邮箱、角色、权限、Referral、编辑审计、审核原因、token/hash、内部字段或调试信息。 - 全局搜索 API 只返回公开浏览所需的最小结果字段结果类型、ID、展示标题、目标 URL、可选摘要和可选图片用户搜索结果只使用公开 Profile 所需的 `id``displayName` 和目标 URL不返回邮箱、角色、权限、Referral、编辑审计、审核原因、token/hash、内部字段或调试信息。
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。 - 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。 - 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
- 列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序。 - 除 Pokemon 外,列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序Pokemon 列表按 Pokopia 展示 ID`display_id`)升序展示,不提供手动排序
## 国际化 ## 国际化
- 前端使用 Vue I18n 管理界面文案,并通过 `X-Locale` 请求头告知后端当前语言。 - 前端使用 Vue I18n 管理界面文案,并通过 `X-Locale` 请求头告知后端当前语言。
- 前端当前语言保存在 `localStorage``pokopia_locale` - 前端当前语言保存在 `localStorage``pokopia_locale`
- Nuxt SSR 运行时每个 Nuxt app/request 创建独立 Vue I18n 实例,避免跨请求共享 locale 或系统文案状态;服务端默认使用 `en`,客户端 hydration 后按 `pokopia_locale` 恢复用户语言。
- 后端默认语言为 `en` - 后端默认语言为 `en`
- 语言配置存储在 `languages` - 语言配置存储在 `languages`
- `code` - `code`
@@ -62,7 +64,6 @@
- 地图 - 地图
- 栖息地 - 栖息地
- 每日 CheckList Task - 每日 CheckList Task
- Life Category
- Game Version - Game Version
- Dish Category - Dish Category
- Dish Flavor - Dish Flavor
@@ -120,10 +121,15 @@
- 重置 token 只保存 hash并带过期时间和使用状态。 - 重置 token 只保存 hash并带过期时间和使用状态。
- 密码重置成功后不自动登录,并删除该用户已有 session。 - 密码重置成功后不自动登录,并删除该用户已有 session。
- 登录页提供 Remember me - 登录页提供 Remember me
- 未勾选时前端将登录 token 保存在 `sessionStorage``pokopia_auth_token`,服务端 session 有效期为 1 天。 - 未勾选时 session 有效期为 1 天。
- 勾选时前端将登录 token 保存在 `localStorage``pokopia_auth_token`,服务端 session 有效期为 30 天。 - 勾选时 session 有效期为 30 天。
- 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。 - SSR 认证使用 HTTP-only cookie session
- 用户可退出登录,退出时删除对应 session - 登录成功后后端设置 HTTP-only `pokopia_session` cookiecookie 只保存明文 session token数据库只保存 session token hash
- 登录响应只返回当前用户必要字段,不返回明文 session token、session token hash 或内部 session 元数据。
- Remember me 通过 HTTP-only session cookie 有效期实现:未勾选时有效期为 1 天,勾选时有效期为 30 天。
- 受保护 API 只接受 HTTP-only cookie session不接受前端 JavaScript 保存的 legacy Bearer token。
- 前端 API 请求携带 credentials以便浏览器自动发送 HTTP-only session cookieJavaScript 不读取该 cookie。
- 用户可退出登录,退出时删除对应 session 并清除 HTTP-only session cookie。
- 对外用户字段只包含必要信息: - 对外用户字段只包含必要信息:
- 当前用户:`id``email``displayName``emailVerified` - 当前用户:`id``email``displayName``emailVerified`
- 编辑署名:`id``displayName` - 编辑署名:`id``displayName`
@@ -194,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
@@ -214,9 +227,9 @@
- Items 与 Recipes 存在依赖关系;选择 Items 进行导出、导入或 Wipe 时,系统必须自动同时纳入 Recipes前端确认内容也必须显示 Recipes。 - Items 与 Recipes 存在依赖关系;选择 Items 进行导出、导入或 Wipe 时,系统必须自动同时纳入 Recipes前端确认内容也必须显示 Recipes。
- Wipe 行为: - Wipe 行为:
- 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。 - 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。 - Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联 / Trading 观察,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。 - Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
- Wipe Items 会先删除 Recipes再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项Pokemon 掉落关联。 - Wipe Items 会先删除 Recipes再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项Pokemon 掉落关联和 Trading 观察
- Wipe Ancient Artifacts 会清空物品上的 Ancient Artifact 分类并删除对应 Ancient Artifact 讨论;物品本身仍保留在 Items / Event Items 中。 - Wipe Ancient Artifacts 会清空物品上的 Ancient Artifact 分类并删除对应 Ancient Artifact 讨论;物品本身仍保留在 Items / Event Items 中。
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。 - Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。 - Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
@@ -225,14 +238,23 @@
- 导出为版本化 JSON bundle包含 `version``exportedAt``scopes` 和对应范围数据。 - 导出为版本化 JSON bundle包含 `version``exportedAt``scopes` 和对应范围数据。
- JSON bundle 用于系统导入,不作为前台展示内容。 - JSON bundle 用于系统导入,不作为前台展示内容。
- 导出包含所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。 - 导出包含所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
- 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。 - 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落、Trading 观察和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。
- JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。 - JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。
- Import 行为: - Import 行为:
- 当前只支持 Replace selected scopes导入前先 Wipe bundle 中包含的范围,再在同一事务中还原 bundle 数据。 - 当前只支持 Replace selected scopes导入前先 Wipe bundle 中包含的范围,再在同一事务中还原 bundle 数据。
- Import 不自动覆盖系统配置、语言、用户、角色、权限、系统文案或 Life 内容。 - Import 不自动覆盖系统配置、语言、用户、角色、权限、系统文案或 Life 内容。
- 导入数据引用的 System config、Languages、Users 或上传文件路径必须已存在;缺失依赖会导致导入失败并回滚。 - 导入数据引用的 System config、Languages、Users 或上传文件路径必须已存在;缺失依赖会导致导入失败并回滚。
- Import 完成后重置相关 identity sequence 到当前最大 ID 之后。 - Import 完成后重置相关 identity sequence 到当前最大 ID 之后。
- 前端导入和 Wipe 必须使用确认 Modal并要求输入固定确认词后才能执行 - Data Tools 额外支持 Items CSV 导入,用于在 Wipe Items 后按 CSV 顺序批量新增普通 ItemsCSV 导入只新增 Items不自动 Wipe不创建 Recipes、入手方式、标签或翻译
- Items CSV 必须包含 `name``category``description``image_file_name``not_registered_in_collection``cannot_grow_again_today` 列。
- Items CSV 的 `category` 必须匹配系统固定物品分类;支持 `Misc.` 匹配内置 `Misc`,其他值按固定分类英文名匹配。
- Items CSV 导入时,`description` 写入物品介绍;若 `not_registered_in_collection` 为 true追加 `Note: Not registered in collection`;若 `cannot_grow_again_today` 为 true追加 `Note: Cannot have Grow used on it again today`;原介绍非空时 Note 前使用换行分隔。
- Items CSV 导入时,图片路径保存为 `/pokopia/items/{image_file_name}`API 对外图片 URL 解析为 `https://pokesprite.tootaio.com/pokopia/items/{image_file_name}`
- Data Tools 额外支持 Habitats CSV 导入,用于在 Wipe Habitats 后按 CSV 顺序批量新增 HabitatsCSV 导入只新增 Habitats不自动 Wipe不创建配方项、Pokemon 出现配置或翻译。
- Habitats CSV 必须包含 `id``name``image_file_name` 列。
- Habitats CSV 的 `id` 仅用于识别导入行与 Event 标记,不写入数据库主键;`id` 前缀为 `E``E-` 时导入为 Event Habitat否则导入为 Main Game Habitat。
- Habitats CSV 导入时,图片路径保存为 `/pokopia/habitats/{image_file_name}`API 对外图片 URL 解析为 `https://pokesprite.tootaio.com/pokopia/habitats/{image_file_name}`
- 前端 JSON bundle Import 和 Wipe 必须使用确认 Modal并要求输入固定确认词后才能执行Items CSV 和 Habitats CSV 导入只新增对应内容,不执行删除,可直接从 CSV 文件选择触发。
## Referral ## Referral
@@ -287,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 未读数量。
## 滥用防护与限流 ## 滥用防护与限流
@@ -313,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 编辑与审计
@@ -343,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 图片上传
@@ -435,8 +458,10 @@
- 名称 - 名称
- 是否有掉落物:`has_item_drop` - 是否有掉落物:`has_item_drop`
- 是否支持 Trading`has_trading`
- 已移除 `subcategory` 字段。 - 已移除 `subcategory` 字段。
- 当特长允许掉落物时Pokemon 编辑中可为该 Pokemon + 特长配置一个掉落物品。 - 当特长允许掉落物时Pokemon 编辑中可为该 Pokemon + 特长配置一个掉落物品。
- 当 Pokemon 选择了至少一个支持 Trading 的特长时Pokemon 详情页可直接维护该 Pokemon 对物品的 Trading 偏好观察。
### Pokemon Types ### Pokemon Types
@@ -447,10 +472,13 @@
### 喜欢的环境 ### 喜欢的环境
- 名称 - 名称
- Description可为空用于解释该 Ideal Habitat 对 Pokemon 栖息地选择的意义
- Opposite可为空双向关联另一个喜欢的环境作为反义关系每个喜欢的环境最多只能属于一组 Opposite 配对;设置、替换或清空一侧时,系统必须在同一事务中同步维护另一侧
### 喜欢的东西 / 标签 ### 喜欢的东西 / 标签
- 名称 - 名称
- Opposite可为空双向关联另一个喜欢的东西 / 标签作为反义关系;每个喜欢的东西 / 标签最多只能属于一组 Opposite 配对;设置、替换或清空一侧时,系统必须在同一事务中同步维护另一侧
- 同时用于: - 同时用于:
- Pokemon 喜欢的东西 - Pokemon 喜欢的东西
- 物品标签 - 物品标签
@@ -465,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
- 版本号 / 名称 - 版本号 / 名称
@@ -499,6 +520,10 @@ Pokemon 可配置:
- 特长:可多选,最多 2 个 - 特长:可多选,最多 2 个
- 特长掉落物品:按 Pokemon + 特长配置,单选物品 - 特长掉落物品:按 Pokemon + 特长配置,单选物品
- 喜欢的东西:可多选,最多 6 个 - 喜欢的东西:可多选,最多 6 个
- Trading由所选特长是否支持 Trading 决定;当至少一个所选特长支持 Trading 时,可维护该 Pokemon 对物品的 Trading 偏好观察,分为 Likes 与 Neutral
- Likes该 Pokemon 喜欢交易该物品,交易价格触发 1.5x 加成;用于物品隐藏标签推断的正向证据
- Neutral该 Pokemon 对交易该物品无加成;用于物品隐藏标签推断的硬排除证据
- 每个物品在同一个 Pokemon 的 Trading 列表中只能出现一次,只能属于 Likes 或 Neutral 其中一组
- 六维: - 六维:
- HP - HP
- Attack - Attack
@@ -508,7 +533,6 @@ Pokemon 可配置:
- Speed - Speed
- 出现的栖息地:由栖息地出现配置反向展示 - 出现的栖息地:由栖息地出现配置反向展示
- 翻译 - 翻译
- 排序
普通 Pokemon 与 Event Pokemon 分开展示: 普通 Pokemon 与 Event Pokemon 分开展示:
@@ -546,6 +570,7 @@ Pokemon 编辑表单使用标签页组织字段:
- 第二行:喜欢的环境、特长 - 第二行:喜欢的环境、特长
- 第三行:喜欢的东西 - 第三行:喜欢的东西
- 特长掉落物品随已选择且支持掉落物的特长显示 - 特长掉落物品随已选择且支持掉落物的特长显示
- 编辑表单不直接维护 Trading 观察Trading 由详情页的 Manage Trading 入口维护
- Pokemon 图片选择区 - Pokemon 图片选择区
- Advance 标签页: - Advance 标签页:
- 第一行Genus - 第一行Genus
@@ -564,7 +589,8 @@ Pokemon 列表功能:
- 按喜欢的东西筛选: - 按喜欢的东西筛选:
- 满足任意条件 - 满足任意条件
- 满足全部条件 - 满足全部条件
-自定义排序展示 - Pokopia 展示 ID`display_id`)升序展示;内部 `id` 仅用于路由、外键和稳定排序兜底
- 列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Pokemon。
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。 - Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。 - Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
- Event Pokemon 列表功能与 Pokemon 列表相同,但只展示 `is_event_item = true` 的 PokemonPokemon 列表只展示 `is_event_item = false` 的 Pokemon。 - Event Pokemon 列表功能与 Pokemon 列表相同,但只展示 `is_event_item = true` 的 PokemonPokemon 列表只展示 `is_event_item = false` 的 Pokemon。
@@ -572,17 +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 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标,未配置时展示空状态
- 特长掉落物品:展示掉落物品图标;未配置图标时显示默认物品标记占位符 - Trading当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品Likes 表示交易价格 1.5xNeutral 表示无加成,未配置观察时展示空状态;详情页双列列表高度受限,内容较多时每列内部独立滚动,避免 Section 被大量物品无限拉长
- 喜欢的环境 - 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与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西;当相关 Pokemon 的 Ideal Habitat 或 Favourite Things 与当前 Pokemon 配置的 Opposite 反义关系命中时,只使用红色标记,不展示反义词或额外 Opposite 文案
- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示;每项展示物品图标,未配置图标时显示默认物品标记占位符 - 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示;每项展示物品图标,未配置图标时显示默认物品标记占位符
- 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符 - 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符
- 最后编辑信息 - 最后编辑信息
@@ -621,12 +647,16 @@ Pokemon 详情页展示:
- Road - Road
- 入手方式:可多选 - 入手方式:可多选
- 客制化: - 客制化:
- 染色 - 染色能力:`dyeability`,使用互斥枚举值维护:
-双区染色 - `0`:不可染色
- `1`:可染色
- `2`:可双区染色
- `3`:可三区染色
- 可改花纹 - 可改花纹
- 无材料单:`no_recipe` - 无材料单:`no_recipe`
- 标签:使用喜欢的东西配置,可多选 - 标签:使用喜欢的东西配置,可多选
- 图标图片:通过通用 Wiki 图片上传维护当前图标和历史上传记录 - 图标图片:通过通用 Wiki 图片上传维护当前图标和历史上传记录
- Data Tools 的 Items CSV 导入可为物品写入静态图标路径 `/pokopia/items/{image_file_name}`;静态图标展示 URL 为 `https://pokesprite.tootaio.com/pokopia/items/{image_file_name}`,用户后续仍可在编辑页切换为社区上传图片
- 翻译 - 翻译
- 排序 - 排序
@@ -644,6 +674,7 @@ Items 与 Event Items 使用相同数据模型:
- 按用途筛选 - 按用途筛选
- 按标签筛选 - 按标签筛选
- 按自定义排序展示 - 按自定义排序展示
- 公开列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Items 或 Event Items。
- All 视图在满足写入权限时支持对 Grid Item 右键插入新物品到前/后,并支持直接拖曳 Item 调整排序;插入与拖曳只作用于当前展示的 Items 列表,不影响 Event Items 入口。 - All 视图在满足写入权限时支持对 Grid Item 右键插入新物品到前/后,并支持直接拖曳 Item 调整排序;插入与拖曳只作用于当前展示的 Items 列表,不影响 Event Items 入口。
- 新增物品入口支持当前浏览器 Session 的默认值菜单;用户可为新建物品预设分类、用途、客制化勾选项和入手方式。默认值只影响 `/items/new``/event-items/new` 的新建表单初始值,不影响编辑已有物品,不改变 API、数据库模型、权限或审计行为Event Items 仍由 `/event-items/new` 入口决定 `is_event_item` - 新增物品入口支持当前浏览器 Session 的默认值菜单;用户可为新建物品预设分类、用途、客制化勾选项和入手方式。默认值只影响 `/items/new``/event-items/new` 的新建表单初始值,不影响编辑已有物品,不改变 API、数据库模型、权限或审计行为Event Items 仍由 `/event-items/new` 入口决定 `is_event_item`
- 物品列表桌面端使用 12 列紧凑 Grid每个格子只展示物品图标有用途的物品在卡片左上角以斜 Ribbon 展示用途名称;物品名称通过 hover / focus Tooltip 展示。 - 物品列表桌面端使用 12 列紧凑 Grid每个格子只展示物品图标有用途的物品在卡片左上角以斜 Ribbon 展示用途名称;物品名称通过 hover / focus Tooltip 展示。
@@ -664,6 +695,13 @@ Items 与 Event Items 使用相同数据模型:
- 入手方式 - 入手方式
- 客制化 - 客制化
- 标签 - 标签
- Possible Tags根据所有拥有支持 Trading 特长的 Pokemon Trading 观察推断该物品可能包含的隐藏标签
- 每个 Pokemon 的“喜欢的东西”视为该 Pokemon 已知的 6 个隐藏标签集合;不完整数据仍参与展示,但不会强行补足缺失标签
- 若物品被 Pokemon 标记为 Likes则该物品至少包含该 Pokemon 标签集合中的一个标签,属于 OR 正向证据
- 若物品被 Pokemon 标记为 Neutral则该物品不包含该 Pokemon 标签集合中的任何标签属于硬排除证据Neutral 排除优先于 Likes 正向证据
- 推断流程必须确定性执行:从所有“喜欢的东西 / 标签”开始,先移除所有 Neutral Pokemon 提供的标签,再用 Likes Pokemon 的标签集合收窄候选;多个 Likes 观察的共同候选归为 Highly likely其余正向候选归为 Possible被排除或被约束移出的标签归为 Excluded
- 没有可用 Likes 观察时,未被 Neutral 排除的标签保持 Possible没有任何观察时所有标签保持 Possible
- Possible Tags 区块必须展示 Likes 与 Neutral 证据来源,包含贡献 Pokemon 及其已知标签,不展示内部字段、调试信息或推断中间状态
- 关联材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符 - 关联材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符
- 作为材料出现的材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符 - 作为材料出现的材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符
- 相关栖息地:展示栖息地图片或默认栖息地标记占位符,并展示配方材料物品图标 - 相关栖息地:展示栖息地图片或默认栖息地标记占位符,并展示配方材料物品图标
@@ -849,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 最多一条评分,重复评分会替换原评分。
前台行为: 前台行为:
@@ -865,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 的评论区,并使用独立分页接口继续加载完整评论列表。
@@ -878,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 审核;未审核通过的内容不向普通访客公开。
@@ -902,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`;不返回点赞用户列表、邮箱、角色、权限或内部审计。
@@ -918,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、编辑表单、管理入口或排序能力
@@ -971,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。
@@ -1008,8 +1151,8 @@ API 暴露边界:
- `favicon.ico` - `favicon.ico`
- 默认社交分享图 - 默认社交分享图
- 品牌 Logo 素材 - 品牌 Logo 素材
- `VITE_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015` - `NUXT_PUBLIC_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`
- 前端入口 `index.html` 提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon客户端路由切换后根据当前路由更新页面 metadata - 前端 Nuxt app head 配置提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon路由 metadata 与详情页公开业务数据通过 Nuxt `useHead` 更新页面 metadata避免直接操作 `document.head`
- 主要公开浏览入口可索引: - 主要公开浏览入口可索引:
- `/pokemon` - `/pokemon`
- `/event-pokemon` - `/event-pokemon`
@@ -1022,20 +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 服务端 API base URL 由 `NUXT_SERVER_API_BASE_URL` 提供;在 Docker 内默认使用 `http://backend:3001`,本地非 Docker 运行可使用 `http://localhost:3001`。服务端公开数据读取使用该内部地址,浏览器请求继续使用 `NUXT_PUBLIC_API_BASE_URL`
- 前端 Docker 构建使用 Nuxt server output`frontend` 服务通过 Node 运行 `.output/server/index.mjs`Nuxt SSR server 监听容器内 `0.0.0.0:20015`,公开流量仍由 `frontend_gateway` 代理。
- `frontend``docker compose up -d --build` 重建、启动中或临时不可达时,`frontend_gateway` 返回静态升级维护页并保持公开端口可访问;后端 `/health` 不可用时,前端网关也返回同一维护页,避免用户看到静态页面后遇到 API 不可用。 - `frontend``docker compose up -d --build` 重建、启动中或临时不可达时,`frontend_gateway` 返回静态升级维护页并保持公开端口可访问;后端 `/health` 不可用时,前端网关也返回同一维护页,避免用户看到静态页面后遇到 API 不可用。
- 升级维护页是基础设施级静态 fallback不依赖 Vue、Vue I18n、后端 API 或数据库;页面只展示正式用户文案和品牌视觉,不展示构建日志、调试信息、内部字段或实现说明。 - 升级维护页是基础设施级静态 fallback不依赖 Vue、Vue I18n、后端 API 或数据库;页面只展示正式用户文案和品牌视觉,不展示构建日志、调试信息、内部字段或实现说明。
- 升级维护页使用 `503``Retry-After: 300``Cache-Control: no-store``noindex`,提示用户 Pokopia Wiki 正在升级并将在约 5 分钟内恢复。 - 升级维护页使用 `503``Retry-After: 300``Cache-Control: no-store``noindex`,提示用户 Pokopia Wiki 正在升级并将在约 5 分钟内恢复。
- 本地 Docker 调试使用 `docker-compose.debug.yml`,通过 bind mount 运行 Nuxt dev server 与 backend `tsx watch`,支持前后端热重载;该调试入口不经过 `frontend_gateway` 维护页,不代表生产部署行为。
## API 概览 ## API 概览
@@ -1045,23 +1203,29 @@ API 暴露边界:
- `GET /api/system-wordings` - `GET /api/system-wordings`
- `GET /api/options` - `GET /api/options`
- `GET /api/project-updates`:读取站点项目公开更新信息;支持 `cursor` / `limit` 分页读取最近提交;仅返回净化后的仓库、最近提交和发布版本展示字段。 - `GET /api/project-updates`:读取站点项目公开更新信息;支持 `cursor` / `limit` 分页读取最近提交;仅返回净化后的仓库、最近提交和发布版本展示字段。
- `GET /api/daily-checklist` - `GET /api/daily-checklist`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容管理端排序。
- `GET /api/pokemon`:支持 `isEventItem=true|false` 按普通 Pokemon / Event Pokemon 拆分列表;未传时返回全部 Pokemon 以兼容管理端和实体选择器 - `GET /api/pokemon`:支持 `isEventItem=true|false` 按普通 Pokemon / Event Pokemon 拆分列表;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回全部 Pokemon 以兼容管理端和实体选择器
- `GET /api/pokemon/:id` - `GET /api/pokemon/:id`
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;未传时返回全部栖息地以兼容管理端和实体选择器 - `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回全部栖息地以兼容管理端和实体选择器
- `GET /api/habitats/:id` - `GET /api/habitats/:id`
- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;默认返回所有物品,包括已配置 Ancient Artifact 分类的物品;传入 `ancientArtifactCategoryId` 时可额外筛选对应 Ancient Artifact 分类下的物品 - `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;默认返回所有物品,包括已配置 Ancient Artifact 分类的物品;传入 `ancientArtifactCategoryId` 时可额外筛选对应 Ancient Artifact 分类下的物品;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容管理端、实体选择器和排序。
- `GET /api/items/:id` - `GET /api/items/:id`
- `GET /api/ancient-artifacts`:支持 `search``categoryId``tagIds` 筛选 - `GET /api/ancient-artifacts`:支持 `search``categoryId``tagIds` 筛选;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
- `GET /api/ancient-artifacts/:id` - `GET /api/ancient-artifacts/:id`
- `GET /api/recipes` - `GET /api/recipes`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
- `GET /api/recipes/:id` - `GET /api/recipes/:id`
- `GET /api/dish` - `GET /api/dish`
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort``latest``oldest``top-rated` - `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `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。
@@ -1134,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 权限控制:
@@ -1152,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 手动排序入口
## 开发与验证 ## 开发与验证
@@ -1162,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`

File diff suppressed because it is too large Load Diff

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,11 +93,15 @@ import {
getPokemon, getPokemon,
getPublicUserProfile, getPublicUserProfile,
getRecipe, getRecipe,
getThread,
globalSearch, globalSearch,
importAdminData, importAdminData,
importAdminHabitatsCsv,
importAdminItemsCsv,
isConfigType, isConfigType,
listAncientArtifacts, listAncientArtifacts,
listEntityDiscussionComments, listEntityDiscussionComments,
listAdminThreadChannels,
listConfig, listConfig,
listDailyChecklistItems, listDailyChecklistItems,
listHabitats, listHabitats,
@@ -98,6 +114,9 @@ import {
listPokemon, listPokemon,
listPokemonFetchOptions, listPokemonFetchOptions,
listRecipes, listRecipes,
listThreadChannels,
listThreadMessages,
listThreads,
listUserCommentActivities, listUserCommentActivities,
listUserLifePosts, listUserLifePosts,
listUserReactionActivities, listUserReactionActivities,
@@ -109,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,
@@ -130,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 {
@@ -159,11 +185,15 @@ import {
markNotificationRead, markNotificationRead,
setupNotificationWebSocketServer setupNotificationWebSocketServer
} from './notifications.ts'; } from './notifications.ts';
import { setupThreadWebSocketServer } from './threadsRealtime.ts';
const app = Fastify({ const app = Fastify({
logger: true, logger: true,
trustProxy: process.env.TRUST_PROXY === 'true' trustProxy: process.env.TRUST_PROXY === 'true'
}); });
const sessionCookieName = 'pokopia_session';
const rememberedSessionDays = 30;
const sessionOnlySessionDays = 1;
function configuredCorsOrigin(): true | string | string[] { function configuredCorsOrigin(): true | string | string[] {
const rawOrigin = process.env.FRONTEND_ORIGIN?.trim(); const rawOrigin = process.env.FRONTEND_ORIGIN?.trim();
@@ -180,8 +210,9 @@ function configuredCorsOrigin(): true | string | string[] {
} }
await app.register(cors, { await app.register(cors, {
allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'], allowedHeaders: ['Content-Type', 'X-Locale'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
origin: configuredCorsOrigin() origin: configuredCorsOrigin()
}); });
@@ -241,9 +272,52 @@ app.get('/api/search', async (request) =>
globalSearch(request.query as Record<string, string | string[] | undefined>, requestLocale(request)) globalSearch(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
); );
function getBearerToken(authorization: string | undefined): string | null { function getCookieValue(cookieHeader: string | undefined, name: string): string | null {
const [scheme, token] = authorization?.split(' ') ?? []; if (!cookieHeader) {
return scheme === 'Bearer' && token ? token : null; return null;
}
for (const cookiePart of cookieHeader.split(';')) {
const [rawName, ...rawValue] = cookiePart.trim().split('=');
if (rawName === name) {
try {
return decodeURIComponent(rawValue.join('='));
} catch {
return rawValue.join('=');
}
}
}
return null;
}
function getSessionToken(request: FastifyRequest): string | null {
return getCookieValue(request.headers.cookie, sessionCookieName);
}
function sessionCookieSecure(): boolean {
const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? '';
return origin.split(',').some((value) => value.trim().startsWith('https://'));
}
function sessionCookie(value: string, maxAgeSeconds: number): string {
return [
`${sessionCookieName}=${encodeURIComponent(value)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
`Max-Age=${maxAgeSeconds}`,
...(sessionCookieSecure() ? ['Secure'] : [])
].join('; ');
}
function setSessionCookie(reply: FastifyReply, token: string, rememberMe: boolean): void {
const sessionDays = rememberMe ? rememberedSessionDays : sessionOnlySessionDays;
reply.header('Set-Cookie', sessionCookie(token, sessionDays * 24 * 60 * 60));
}
function clearSessionCookie(reply: FastifyReply): void {
reply.header('Set-Cookie', `${sessionCookie('', 0)}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`);
} }
function requestLocale(request: FastifyRequest): string { function requestLocale(request: FastifyRequest): string {
@@ -866,7 +940,7 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
return null; return null;
} }
const token = getBearerToken(request.headers.authorization); const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null; const user = token ? await getUserBySessionToken(token) : null;
const locale = requestLocale(request); const locale = requestLocale(request);
@@ -948,7 +1022,7 @@ async function requireAnyPermissionWithRateLimits(
} }
async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> { async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
const token = getBearerToken(request.headers.authorization); const token = getSessionToken(request);
if (!token) { if (!token) {
return null; return null;
} }
@@ -981,7 +1055,10 @@ app.post('/api/auth/login', async (request, reply) => {
return; return;
} }
return loginUser(request.body as Record<string, unknown>, requestLocale(request)); const payload = request.body as Record<string, unknown>;
const response = await loginUser(payload, requestLocale(request));
setSessionCookie(reply, response.token, payload.rememberMe === true);
return { user: response.user };
}); });
app.post('/api/auth/request-password-reset', async (request, reply) => { app.post('/api/auth/request-password-reset', async (request, reply) => {
@@ -1005,7 +1082,7 @@ app.get('/api/auth/me', async (request, reply) => {
return; return;
} }
const token = getBearerToken(request.headers.authorization); const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null; const user = token ? await getUserBySessionToken(token) : null;
if (!user) { if (!user) {
@@ -1015,12 +1092,53 @@ app.get('/api/auth/me', async (request, reply) => {
return { user }; return { user };
}); });
app.post('/api/auth/view-as/user', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getSessionToken(request);
if (!token) {
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
}
const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
return { user: await startViewAsUser(token, payload) };
});
app.post('/api/auth/view-as/role', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getSessionToken(request);
if (!token) {
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
}
const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
return { user: await startViewAsRole(token, payload) };
});
app.post('/api/auth/view-as/stop', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return;
}
const token = getSessionToken(request);
if (!token) {
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
}
return { user: await stopViewAs(token) };
});
app.patch('/api/auth/me', async (request, reply) => { app.patch('/api/auth/me', async (request, reply) => {
if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) { if (!(await enforceRateLimits(request, reply, [protectedRouteIpRateLimit]))) {
return; return;
} }
const token = getBearerToken(request.headers.authorization); const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null; const user = token ? await getUserBySessionToken(token) : null;
if (!user) { if (!user) {
@@ -1040,7 +1158,7 @@ app.patch('/api/auth/me/password', async (request, reply) => {
return; return;
} }
const token = getBearerToken(request.headers.authorization); const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null; const user = token ? await getUserBySessionToken(token) : null;
if (!user || !token) { if (!user || !token) {
@@ -1060,7 +1178,7 @@ app.get('/api/auth/referral', async (request, reply) => {
return; return;
} }
const token = getBearerToken(request.headers.authorization); const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null; const user = token ? await getUserBySessionToken(token) : null;
if (!user) { if (!user) {
@@ -1097,11 +1215,12 @@ app.post('/api/notifications/:id/read', async (request, reply) => {
}); });
app.post('/api/auth/logout', async (request, reply) => { app.post('/api/auth/logout', async (request, reply) => {
const token = getBearerToken(request.headers.authorization); const token = getSessionToken(request);
if (token) { if (token) {
await logoutSession(token); await logoutSession(token);
} }
clearSessionCookie(reply);
return reply.code(204).send(); return reply.code(204).send();
}); });
@@ -1196,7 +1315,9 @@ app.get('/api/project-updates', async (request) =>
getProjectUpdates(request.query as Record<string, string | string[] | undefined>) getProjectUpdates(request.query as Record<string, string | string[] | undefined>)
); );
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request))); app.get('/api/daily-checklist', async (request) =>
listDailyChecklistItems(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
app.get('/api/users/:id/profile', async (request, reply) => { app.get('/api/users/:id/profile', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
@@ -1423,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,
@@ -1635,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))
); );
@@ -2037,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;
@@ -2151,11 +2413,84 @@ app.post('/api/admin/data-tools/import', async (request, reply) => {
return user ? importAdminData(request.body as Record<string, unknown>) : undefined; return user ? importAdminData(request.body as Record<string, unknown>) : undefined;
}); });
app.post('/api/admin/data-tools/import-items-csv', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
return user ? importAdminItemsCsv(request.body as Record<string, unknown>, user.id) : undefined;
});
app.post('/api/admin/data-tools/import-habitats-csv', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
return user ? importAdminHabitatsCsv(request.body as Record<string, unknown>, user.id) : undefined;
});
app.post('/api/admin/data-tools/wipe', async (request, reply) => { app.post('/api/admin/data-tools/wipe', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite'); const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined; return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
}); });
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) {
@@ -2227,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
@@ -40,10 +42,14 @@ services:
context: . context: .
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
args: args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:20016} NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
VITE_SITE_URL: ${VITE_SITE_URL:-https://pokopiawiki.tootaio.com} NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://pokopiawiki.tootaio.com}
environment: environment:
PORT: 20015 PORT: 20015
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://pokopiawiki.tootaio.com}
expose: expose:
- "20015" - "20015"
depends_on: depends_on:

View File

@@ -8,21 +8,23 @@ RUN corepack enable && corepack prepare pnpm@10.33.2 --activate && pnpm install
COPY frontend ./frontend COPY frontend ./frontend
COPY system-wordings.ts ./system-wordings.ts COPY system-wordings.ts ./system-wordings.ts
ARG VITE_API_BASE_URL=http://localhost:3001 ARG NUXT_PUBLIC_API_BASE_URL=http://localhost:3001
ARG VITE_SITE_URL=https://pokopiawiki.tootaio.com ARG NUXT_SERVER_API_BASE_URL=http://localhost:3001
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL ARG NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
ENV VITE_SITE_URL=$VITE_SITE_URL ENV NUXT_PUBLIC_API_BASE_URL=$NUXT_PUBLIC_API_BASE_URL
ENV NUXT_SERVER_API_BASE_URL=$NUXT_SERVER_API_BASE_URL
ENV NUXT_PUBLIC_SITE_URL=$NUXT_PUBLIC_SITE_URL
RUN pnpm --filter @pokopia/frontend build RUN pnpm --filter @pokopia/frontend build
FROM node:22-alpine FROM node:22-alpine
ENV NODE_ENV=production ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=20015 ENV PORT=20015
WORKDIR /app WORKDIR /app
COPY --from=build /app/frontend/dist ./dist COPY --from=build /app/frontend/.output ./.output
COPY frontend/static-server.mjs ./static-server.mjs
USER node USER node
EXPOSE 20015 EXPOSE 20015
CMD ["node", "static-server.mjs"] CMD ["node", ".output/server/index.mjs"]

View File

@@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'; import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import AppShell from './src/components/AppShell.vue';
import AppShell from './components/AppShell.vue';
import { import {
iconAction, iconAction,
iconAdmin, iconAdmin,
@@ -19,15 +18,16 @@ import {
iconLife, iconLife,
iconPokemon, iconPokemon,
iconRecipe, iconRecipe,
iconThreads,
type AppIcon type AppIcon
} from './icons'; } from './src/icons';
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './i18n'; import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api'; import { api, notifyAuthChange, onAuthChange, type AuthUser, type Language } from './src/services/api';
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const router = useRouter(); const router = useRouter();
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const viewAsBusy = ref(false);
const languages = ref<Language[]>([ const languages = ref<Language[]>([
{ code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 10 }, { code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 10 },
{ code: 'zh-CN', name: '简体中文', enabled: true, isDefault: false, sortOrder: 20 } { code: 'zh-CN', name: '简体中文', enabled: true, isDefault: false, sortOrder: 20 }
@@ -103,7 +103,8 @@ const navItems = computed<NavItem[]>(() => {
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() }, { label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() }, { label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist }, { label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
{ label: t('nav.life'), to: '/life', icon: iconLife } { label: t('nav.life'), to: '/life', icon: iconLife },
{ label: t('nav.threads'), to: '/threads', icon: iconThreads }
]; ];
if (can('admin.access')) { if (can('admin.access')) {
@@ -114,17 +115,11 @@ const navItems = computed<NavItem[]>(() => {
}); });
async function loadCurrentUser() { async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try { try {
const response = await api.me(); const response = await api.me();
currentUser.value = response.user; currentUser.value = response.user;
} catch { } catch {
currentUser.value = null; currentUser.value = null;
setAuthToken(null);
} }
} }
@@ -136,10 +131,25 @@ async function logout() {
} }
currentUser.value = null; currentUser.value = null;
setAuthToken(null); notifyAuthChange();
await router.push('/'); await router.push('/');
} }
async function stopViewAs() {
if (viewAsBusy.value) {
return;
}
viewAsBusy.value = true;
try {
const response = await api.stopViewAs();
currentUser.value = response.user;
notifyAuthChange();
} finally {
viewAsBusy.value = false;
}
}
async function loadLanguages() { async function loadLanguages() {
try { try {
const loadedLanguages = await api.languages(); const loadedLanguages = await api.languages();
@@ -165,7 +175,7 @@ async function updateLocale(value: string) {
onMounted(() => { onMounted(() => {
void loadLanguages(); void loadLanguages();
void loadCurrentUser(); void loadCurrentUser();
removeAuthListener = onAuthTokenChange(() => { removeAuthListener = onAuthChange(() => {
void loadCurrentUser(); void loadCurrentUser();
}); });
removeLocaleListener = onLocaleChange(() => { removeLocaleListener = onLocaleChange(() => {
@@ -185,9 +195,11 @@ onUnmounted(() => {
:languages="languages" :languages="languages"
:locale="locale" :locale="locale"
:nav-items="navItems" :nav-items="navItems"
:view-as-busy="viewAsBusy"
@logout="logout" @logout="logout"
@stop-view-as="stopViewAs"
@update:locale="updateLocale" @update:locale="updateLocale"
> >
<RouterView :key="locale" /> <NuxtPage :key="locale" />
</AppShell> </AppShell>
</template> </template>

View File

@@ -0,0 +1,9 @@
import type { RouterConfig } from '@nuxt/schema';
export default <RouterConfig>{
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition;
if (to.meta.editorModal === true || from.meta.editorModal === true) return false;
return { top: 0 };
}
};

View File

@@ -1,49 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
/>
<meta name="robots" content="index, follow" />
<meta name="theme-color" content="#6ccf32" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="canonical" href="%POKOPIA_SITE_URL%/pokemon" />
<meta property="og:site_name" content="Pokopia Wiki" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
<meta
property="og:description"
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
/>
<meta property="og:url" content="%POKOPIA_SITE_URL%/pokemon" />
<meta property="og:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
<meta
name="twitter:description"
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
/>
<meta name="twitter:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
<script>
(function () {
const UMAMI_SCRIPT_JS = "https://umami.tootaio.com/script.js";
const UMAMI_ID = "6c00a2e5-dc72-41f3-9d5d-aac93aaaf1cb";
var script = document.createElement("script");
script.async = true;
script.src = UMAMI_SCRIPT_JS;
script.setAttribute("data-website-id", UMAMI_ID);
document.head.appendChild(script);
})();
</script>
<title>Pokopia Wiki - Pokemon Pokopia Guide</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,35 @@
import { api } from '../src/services/api';
export default defineNuxtRouteMiddleware(async (to) => {
const requiredPermissions = to.matched
.map((record) => record.meta.requiredPermission)
.filter((permission): permission is string => typeof permission === 'string');
const requiredAnyPermissions = to.matched.flatMap((record) =>
Array.isArray(record.meta.requiredAnyPermission)
? record.meta.requiredAnyPermission.filter((permission): permission is string => typeof permission === 'string')
: []
);
const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true) || requiredPermissions.length > 0 || requiredAnyPermissions.length > 0;
const requiresAuth = requiresVerified || to.matched.some((record) => record.meta.requiresAuth === true);
if (!requiresAuth) {
return;
}
try {
const response = await api.me(import.meta.server ? { headers: useRequestHeaders(['cookie']) } : undefined);
if (requiresVerified && !response.user.emailVerified) {
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
}
const permissionSet = new Set(response.user.permissions);
if (requiredPermissions.some((permission) => !permissionSet.has(permission))) {
return navigateTo('/pokemon');
}
if (requiredAnyPermissions.length && !requiredAnyPermissions.some((permission) => permissionSet.has(permission))) {
return navigateTo('/pokemon');
}
} catch {
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
}
});

50
frontend/nuxt.config.ts Normal file
View File

@@ -0,0 +1,50 @@
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
function normalizeSiteUrl(value: string | undefined): string {
return (value?.trim() || fallbackSiteUrl).replace(/\/+$/, '');
}
export default defineNuxtConfig({
ssr: true,
devtools: { enabled: false },
css: ['~/src/styles/main.css'],
compatibilityDate: '2026-05-06',
runtimeConfig: {
serverApiBaseUrl:
process.env.NUXT_SERVER_API_BASE_URL ??
process.env.NUXT_PUBLIC_API_BASE_URL ??
'http://localhost:3001',
public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3001',
siteUrl: normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL)
}
},
app: {
head: {
htmlAttrs: {
lang: 'en'
},
title: 'Pokopia Wiki - Pokemon Pokopia Guide',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
{ name: 'theme-color', content: '#6ccf32' }
],
link: [
{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' }
],
script: [
{
async: true,
src: 'https://umami.tootaio.com/script.js',
'data-website-id': '6c00a2e5-dc72-41f3-9d5d-aac93aaaf1cb'
}
]
}
},
nitro: {
prerender: {
routes: ['/robots.txt', '/sitemap.xml']
}
}
});

View File

@@ -5,16 +5,15 @@
"packageManager": "pnpm@10.33.2", "packageManager": "pnpm@10.33.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0 --port 20015", "dev": "nuxt dev --host 0.0.0.0 --port 20015",
"build": "vue-tsc --noEmit && vite build", "build": "nuxt build",
"lint": "vue-tsc --noEmit", "lint": "nuxt typecheck",
"typecheck": "vue-tsc --noEmit", "typecheck": "nuxt typecheck",
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@iconify/vue": "5.0.0", "@iconify/vue": "5.0.0",
"@vitejs/plugin-vue": "6.0.6", "nuxt": "4.4.4",
"vite": "8.0.10",
"vue": "3.5.33", "vue": "3.5.33",
"vue-i18n": "11.4.0", "vue-i18n": "11.4.0",
"vue-router": "5.0.6" "vue-router": "5.0.6"
@@ -22,6 +21,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "25.6.0", "@types/node": "25.6.0",
"@vue/tsconfig": "0.9.1", "@vue/tsconfig": "0.9.1",
"postcss": "8.5.13",
"typescript": "6.0.3", "typescript": "6.0.3",
"vitest": "4.1.5", "vitest": "4.1.5",
"vue-tsc": "3.2.7" "vue-tsc": "3.2.7"

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import ComingSoonView from '../src/views/ComingSoonView.vue';
definePageMeta({
name: 'actions',
seo: { titleKey: 'pages.comingSoon.sections.actions.title', descriptionKey: 'pages.comingSoon.sections.actions.subtitle', noindex: true }
});
</script>
<template>
<ComingSoonView page="actions" />
</template>

13
frontend/pages/admin.vue Normal file
View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import AdminView from '../src/views/AdminView.vue';
definePageMeta({
name: 'admin',
requiredPermission: 'admin.access',
seo: { titleKey: 'pages.admin.title', descriptionKey: 'pages.admin.subtitle', noindex: true }
});
</script>
<template>
<AdminView />
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import ItemDetail from '../../../src/views/ItemDetail.vue';
definePageMeta({
name: 'ancient-artifact-edit',
requiredPermission: 'items.update',
editorModal: true,
seo: {
titleKey: 'pages.ancientArtifacts.editKicker',
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/ancient-artifacts/${String(route.params.id)}`,
noindex: true
}
});
</script>
<template>
<ItemDetail />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import ItemDetail from '../../../src/views/ItemDetail.vue';
definePageMeta({
name: 'ancient-artifact-detail',
seo: { titleKey: 'pages.ancientArtifacts.detailKicker', descriptionKey: 'pages.ancientArtifacts.subtitle' }
});
</script>
<template>
<ItemDetail />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import AncientArtifactList from '../../src/views/AncientArtifactList.vue';
definePageMeta({
name: 'ancient-artifact-list',
seo: { titleKey: 'pages.ancientArtifacts.title', descriptionKey: 'pages.ancientArtifacts.subtitle' }
});
</script>
<template>
<AncientArtifactList />
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import AncientArtifactList from '../../src/views/AncientArtifactList.vue';
definePageMeta({
name: 'ancient-artifact-new',
requiredPermission: 'items.create',
editorModal: true,
seo: {
titleKey: 'pages.ancientArtifacts.newTitle',
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
canonicalPath: '/ancient-artifacts',
noindex: true
}
});
</script>
<template>
<AncientArtifactList />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import ComingSoonView from '../src/views/ComingSoonView.vue';
definePageMeta({
name: 'automation',
seo: { titleKey: 'pages.comingSoon.sections.automation.title', descriptionKey: 'pages.comingSoon.sections.automation.subtitle', noindex: true }
});
</script>
<template>
<ComingSoonView page="automation" />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import DailyChecklistView from '../src/views/DailyChecklistView.vue';
definePageMeta({
name: 'checklist',
seo: { titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }
});
</script>
<template>
<DailyChecklistView />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import ComingSoonView from '../src/views/ComingSoonView.vue';
definePageMeta({
name: 'clothes',
seo: { titleKey: 'pages.comingSoon.sections.clothes.title', descriptionKey: 'pages.comingSoon.sections.clothes.subtitle', noindex: true }
});
</script>
<template>
<ComingSoonView page="clothes" />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import LegalView from '../src/views/LegalView.vue';
definePageMeta({
name: 'disclaimers',
seo: { titleKey: 'pages.legal.disclaimers.title', descriptionKey: 'pages.legal.disclaimers.subtitle', canonicalPath: '/disclaimers' }
});
</script>
<template>
<LegalView page="disclaimers" />
</template>

12
frontend/pages/dish.vue Normal file
View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import DishView from '../src/views/DishView.vue';
definePageMeta({
name: 'dish',
seo: { titleKey: 'pages.dish.title', descriptionKey: 'pages.dish.subtitle' }
});
</script>
<template>
<DishView />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import ComingSoonView from '../src/views/ComingSoonView.vue';
definePageMeta({
name: 'dream-island',
seo: { titleKey: 'pages.comingSoon.sections.dreamIsland.title', descriptionKey: 'pages.comingSoon.sections.dreamIsland.subtitle', noindex: true }
});
</script>
<template>
<ComingSoonView page="dreamIsland" />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import HabitatList from '../../src/views/HabitatList.vue';
definePageMeta({
name: 'event-habitat-list',
seo: { titleKey: 'pages.eventHabitats.title', descriptionKey: 'pages.eventHabitats.subtitle', canonicalPath: '/event-habitats' }
});
</script>
<template>
<HabitatList :event-only="true" />
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import HabitatList from '../../src/views/HabitatList.vue';
definePageMeta({
name: 'event-habitat-new',
requiredPermission: 'habitats.create',
editorModal: true,
seo: { titleKey: 'pages.eventHabitats.newTitle', descriptionKey: 'pages.eventHabitats.editSubtitle', canonicalPath: '/event-habitats', noindex: true }
});
</script>
<template>
<HabitatList :event-only="true" />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import ItemsList from '../../src/views/ItemsList.vue';
definePageMeta({
name: 'event-item-list',
seo: { titleKey: 'pages.eventItems.title', descriptionKey: 'pages.eventItems.subtitle', canonicalPath: '/event-items' }
});
</script>
<template>
<ItemsList :event-only="true" />
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import ItemsList from '../../src/views/ItemsList.vue';
definePageMeta({
name: 'event-item-new',
requiredPermission: 'items.create',
editorModal: true,
seo: { titleKey: 'pages.eventItems.newTitle', descriptionKey: 'pages.eventItems.editSubtitle', canonicalPath: '/event-items', noindex: true }
});
</script>
<template>
<ItemsList :event-only="true" />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import PokemonList from '../../src/views/PokemonList.vue';
definePageMeta({
name: 'event-pokemon-list',
seo: { titleKey: 'pages.eventPokemon.title', descriptionKey: 'pages.eventPokemon.subtitle', canonicalPath: '/event-pokemon' }
});
</script>
<template>
<PokemonList :event-only="true" />
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import PokemonList from '../../src/views/PokemonList.vue';
definePageMeta({
name: 'event-pokemon-new',
requiredPermission: 'pokemon.create',
editorModal: true,
seo: { titleKey: 'pages.eventPokemon.newTitle', descriptionKey: 'pages.eventPokemon.editSubtitle', canonicalPath: '/event-pokemon', noindex: true }
});
</script>
<template>
<PokemonList :event-only="true" />
</template>

12
frontend/pages/events.vue Normal file
View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import ComingSoonView from '../src/views/ComingSoonView.vue';
definePageMeta({
name: 'events',
seo: { titleKey: 'pages.comingSoon.sections.events.title', descriptionKey: 'pages.comingSoon.sections.events.subtitle', noindex: true }
});
</script>
<template>
<ComingSoonView page="events" />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import ForgotPasswordView from '../src/views/ForgotPasswordView.vue';
definePageMeta({
name: 'forgot-password',
seo: { titleKey: 'auth.requestResetTitle', descriptionKey: 'auth.requestResetSubtitle', noindex: true }
});
</script>
<template>
<ForgotPasswordView />
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import HabitatDetail from '../../../src/views/HabitatDetail.vue';
definePageMeta({
name: 'habitat-edit',
requiredPermission: 'habitats.update',
editorModal: true,
seo: {
titleKey: 'pages.habitats.detailKicker',
descriptionKey: 'pages.habitats.editSubtitle',
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/habitats/${String(route.params.id)}`,
noindex: true
}
});
</script>
<template>
<HabitatDetail />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import HabitatDetail from '../../../src/views/HabitatDetail.vue';
definePageMeta({
name: 'habitat-detail',
seo: { titleKey: 'pages.habitats.detailKicker', descriptionKey: 'pages.habitats.subtitle' }
});
</script>
<template>
<HabitatDetail />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import HabitatList from '../../src/views/HabitatList.vue';
definePageMeta({
name: 'habitat-list',
seo: { titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }
});
</script>
<template>
<HabitatList :event-only="false" />
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import HabitatList from '../../src/views/HabitatList.vue';
definePageMeta({
name: 'habitat-new',
requiredPermission: 'habitats.create',
editorModal: true,
seo: { titleKey: 'pages.habitats.newTitle', descriptionKey: 'pages.habitats.editSubtitle', canonicalPath: '/habitats', noindex: true }
});
</script>
<template>
<HabitatList :event-only="false" />
</template>

12
frontend/pages/index.vue Normal file
View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import HomeView from '../src/views/HomeView.vue';
definePageMeta({
name: 'home',
seo: { titleKey: 'pages.home.title', descriptionKey: 'pages.home.subtitle', canonicalPath: '/' }
});
</script>
<template>
<HomeView />
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import ItemDetail from '../../../src/views/ItemDetail.vue';
definePageMeta({
name: 'item-edit',
requiredPermission: 'items.update',
editorModal: true,
seo: {
titleKey: 'pages.items.editKicker',
descriptionKey: 'pages.items.editSubtitle',
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/items/${String(route.params.id)}`,
noindex: true
}
});
</script>
<template>
<ItemDetail />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import ItemDetail from '../../../src/views/ItemDetail.vue';
definePageMeta({
name: 'item-detail',
seo: { titleKey: 'pages.items.detailKicker', descriptionKey: 'pages.items.subtitle' }
});
</script>
<template>
<ItemDetail />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import ItemsList from '../../src/views/ItemsList.vue';
definePageMeta({
name: 'item-list',
seo: { titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }
});
</script>
<template>
<ItemsList :event-only="false" />
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import ItemsList from '../../src/views/ItemsList.vue';
definePageMeta({
name: 'item-new',
requiredPermission: 'items.create',
editorModal: true,
seo: { titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/items', noindex: true }
});
</script>
<template>
<ItemsList :event-only="false" />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import LifePostDetail from '../../src/views/LifePostDetail.vue';
definePageMeta({
name: 'life-id',
seo: { titleKey: 'pages.life.detailTitle', descriptionKey: 'pages.life.detailSubtitle' }
});
</script>
<template>
<LifePostDetail />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import LifeView from '../../src/views/LifeView.vue';
definePageMeta({
name: 'life',
seo: { titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }
});
</script>
<template>
<LifeView />
</template>

12
frontend/pages/login.vue Normal file
View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import LoginView from '../src/views/LoginView.vue';
definePageMeta({
name: 'login',
seo: { titleKey: 'auth.loginTitle', descriptionKey: 'auth.loginSubtitle', noindex: true }
});
</script>
<template>
<LoginView />
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import PokemonDetail from '../../../src/views/PokemonDetail.vue';
definePageMeta({
name: 'pokemon-edit',
requiredPermission: 'pokemon.update',
editorModal: true,
seo: {
titleKey: 'pages.pokemon.editKicker',
descriptionKey: 'pages.pokemon.editSubtitle',
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/pokemon/${String(route.params.id)}`,
noindex: true
}
});
</script>
<template>
<PokemonDetail />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import PokemonDetail from '../../../src/views/PokemonDetail.vue';
definePageMeta({
name: 'pokemon-detail',
seo: { titleKey: 'pages.pokemon.detailKicker', descriptionKey: 'pages.pokemon.subtitle' }
});
</script>
<template>
<PokemonDetail />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import PokemonList from '../../src/views/PokemonList.vue';
definePageMeta({
name: 'pokemon-list',
seo: { titleKey: 'pages.pokemon.title', descriptionKey: 'pages.pokemon.subtitle' }
});
</script>
<template>
<PokemonList :event-only="false" />
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import PokemonList from '../../src/views/PokemonList.vue';
definePageMeta({
name: 'pokemon-new',
requiredPermission: 'pokemon.create',
editorModal: true,
seo: { titleKey: 'pages.pokemon.newTitle', descriptionKey: 'pages.pokemon.editSubtitle', canonicalPath: '/pokemon', noindex: true }
});
</script>
<template>
<PokemonList :event-only="false" />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import LegalView from '../src/views/LegalView.vue';
definePageMeta({
name: 'privacy-policy',
seo: { titleKey: 'pages.legal.privacy.title', descriptionKey: 'pages.legal.privacy.subtitle', canonicalPath: '/privacy-policy' }
});
</script>
<template>
<LegalView page="privacy" />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import UserProfileView from '../../src/views/UserProfileView.vue';
definePageMeta({
name: 'profile-id',
seo: { titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.publicSubtitle' }
});
</script>
<template>
<UserProfileView />
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import UserProfileView from '../../src/views/UserProfileView.vue';
definePageMeta({
name: 'profile',
requiresAuth: true,
seo: { titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.subtitle', noindex: true }
});
</script>
<template>
<UserProfileView />
</template>

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import ProjectUpdatesView from '../src/views/ProjectUpdatesView.vue';
definePageMeta({
name: 'project-updates',
seo: {
titleKey: 'pages.projectUpdates.title',
descriptionKey: 'pages.projectUpdates.subtitle',
canonicalPath: '/project-updates'
}
});
</script>
<template>
<ProjectUpdatesView />
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import RecipeDetail from '../../../src/views/RecipeDetail.vue';
definePageMeta({
name: 'recipe-edit',
requiredPermission: 'recipes.update',
editorModal: true,
seo: {
titleKey: 'pages.recipes.editKicker',
descriptionKey: 'pages.recipes.editSubtitle',
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/recipes/${String(route.params.id)}`,
noindex: true
}
});
</script>
<template>
<RecipeDetail />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import RecipeDetail from '../../../src/views/RecipeDetail.vue';
definePageMeta({
name: 'recipe-detail',
seo: { titleKey: 'pages.recipes.detailKicker', descriptionKey: 'pages.recipes.subtitle' }
});
</script>
<template>
<RecipeDetail />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import RecipeList from '../../src/views/RecipeList.vue';
definePageMeta({
name: 'recipe-list',
seo: { titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }
});
</script>
<template>
<RecipeList />
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import RecipeList from '../../src/views/RecipeList.vue';
definePageMeta({
name: 'recipe-new',
requiredPermission: 'recipes.create',
editorModal: true,
seo: { titleKey: 'pages.recipes.newTitle', descriptionKey: 'pages.recipes.editSubtitle', canonicalPath: '/recipes', noindex: true }
});
</script>
<template>
<RecipeList />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import RegisterView from '../src/views/RegisterView.vue';
definePageMeta({
name: 'register',
seo: { titleKey: 'auth.registerTitle', descriptionKey: 'auth.registerSubtitle', noindex: true }
});
</script>
<template>
<RegisterView />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import ResetPasswordView from '../src/views/ResetPasswordView.vue';
definePageMeta({
name: 'reset-password',
seo: { titleKey: 'auth.resetTitle', descriptionKey: 'auth.resetSubtitle', noindex: true }
});
</script>
<template>
<ResetPasswordView />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import LegalView from '../src/views/LegalView.vue';
definePageMeta({
name: 'terms-of-service',
seo: { titleKey: 'pages.legal.terms.title', descriptionKey: 'pages.legal.terms.subtitle', canonicalPath: '/terms-of-service' }
});
</script>
<template>
<LegalView page="terms" />
</template>

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

@@ -0,0 +1,12 @@
<script setup lang="ts">
import VerifyEmailView from '../src/views/VerifyEmailView.vue';
definePageMeta({
name: 'verify-email',
seo: { titleKey: 'auth.verifyTitle', descriptionKey: 'auth.verifySubtitle', noindex: true }
});
</script>
<template>
<VerifyEmailView />
</template>

View File

@@ -0,0 +1,15 @@
import { setSystemWordingsApiBaseUrls } from '../src/i18n';
import { setConfiguredSiteUrl } from '../src/seo';
import { setApiBaseUrls } from '../src/services/api';
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig();
const apiBaseUrls = {
browser: config.public.apiBaseUrl,
server: config.serverApiBaseUrl
};
setApiBaseUrls(apiBaseUrls);
setSystemWordingsApiBaseUrls(apiBaseUrls);
setConfiguredSiteUrl(config.public.siteUrl);
});

View File

@@ -0,0 +1,15 @@
import { createPokopiaI18n, setActiveI18n } from '../src/i18n';
export default defineNuxtPlugin((nuxtApp) => {
const i18n = createPokopiaI18n();
if (import.meta.client) {
setActiveI18n(i18n);
}
nuxtApp.vueApp.use(i18n);
return {
provide: {
pokopiaI18n: i18n
}
};
});

View File

@@ -0,0 +1,32 @@
import { computed, ref } from 'vue';
import { onLocaleChange } from '../src/i18n';
import { applyRouteSeo, onSeoChange, resolvedSeoHead, resolveRouteSeo, setSeoTranslator, type ResolvedSeoConfig } from '../src/seo';
export default defineNuxtPlugin(() => {
const router = useRouter();
const nuxtApp = useNuxtApp();
const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
const dynamicSeo = ref<ResolvedSeoConfig | null>(null);
const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t));
useHead(() => resolvedSeoHead(activeSeo.value));
if (import.meta.server) {
return;
}
setSeoTranslator(t);
onSeoChange((seo) => {
dynamicSeo.value = seo;
});
onLocaleChange(() => {
dynamicSeo.value = null;
applyRouteSeo(router.currentRoute.value);
});
router.afterEach((to) => {
dynamicSeo.value = null;
applyRouteSeo(to);
});
applyRouteSeo(router.currentRoute.value);
});

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 { normalizeSiteUrl, robotsTxt } from '../utils/seo-files';
export default defineEventHandler((event) => {
const config = useRuntimeConfig(event);
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8');
return robotsTxt(normalizeSiteUrl(config.public.siteUrl));
});

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

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

View File

@@ -0,0 +1,273 @@
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
const fallbackApiBaseUrl = 'http://localhost:3001';
const staticLastmod = new Date().toISOString();
const sitemapPageSize = 72;
type ChangeFrequency = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
export type SitemapUrl = {
path: string;
lastmod?: string | null;
changefreq?: ChangeFrequency;
priority?: number;
};
type SitemapEntity = {
id: number;
createdAt?: string | null;
updatedAt?: string | null;
lastActiveAt?: string | null;
ancientArtifactCategory?: unknown;
};
type ListPage<T> = {
items: T[];
nextCursor: string | null;
hasMore: boolean;
};
const sitemapFiles = [
'/sitemap-static.xml',
'/sitemap-pokedex.xml',
'/sitemap-habitats.xml',
'/sitemap-collections.xml',
'/sitemap-life.xml',
'/sitemap-threads.xml'
];
const staticSitemapUrls: SitemapUrl[] = [
{ path: '/', changefreq: 'weekly', priority: 1 },
{ path: '/pokemon', changefreq: 'weekly', priority: 0.95 },
{ path: '/event-pokemon', changefreq: 'weekly', priority: 0.85 },
{ path: '/habitats', changefreq: 'weekly', priority: 0.9 },
{ path: '/event-habitats', changefreq: 'weekly', priority: 0.8 },
{ path: '/items', changefreq: 'weekly', priority: 0.9 },
{ path: '/event-items', changefreq: 'weekly', priority: 0.8 },
{ path: '/ancient-artifacts', changefreq: 'weekly', priority: 0.85 },
{ path: '/recipes', changefreq: 'weekly', priority: 0.85 },
{ path: '/dish', changefreq: 'weekly', priority: 0.8 },
{ path: '/checklist', changefreq: 'weekly', priority: 0.8 },
{ path: '/life', changefreq: 'daily', priority: 0.75 },
{ path: '/threads', changefreq: 'daily', priority: 0.75 },
{ path: '/project-updates', changefreq: 'weekly', priority: 0.6 },
{ path: '/privacy-policy', changefreq: 'yearly', priority: 0.3 },
{ path: '/terms-of-service', changefreq: 'yearly', priority: 0.3 },
{ path: '/disclaimers', changefreq: 'yearly', priority: 0.3 }
];
const robotsDisallowPaths = [
'/admin',
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/verify-email',
'/pokemon/new',
'/event-pokemon/new',
'/pokemon/*/edit',
'/habitats/new',
'/event-habitats/new',
'/habitats/*/edit',
'/items/new',
'/event-items/new',
'/items/*/edit',
'/ancient-artifacts/new',
'/ancient-artifacts/*/edit',
'/recipes/new',
'/recipes/*/edit',
'/automation',
'/events',
'/actions',
'/dream-island',
'/clothes'
];
export function normalizeSiteUrl(value: unknown): string {
return (typeof value === 'string' && value.trim() ? value.trim() : fallbackSiteUrl).replace(/\/+$/, '');
}
export function normalizeApiBaseUrl(value: unknown): string {
return (typeof value === 'string' && value.trim() ? value.trim() : fallbackApiBaseUrl).replace(/\/+$/, '');
}
export function robotsTxt(siteUrl: string): string {
const disallowLines = robotsDisallowPaths.map((path) => `Disallow: ${path}`).join('\n');
return `User-agent: *\nAllow: /\n${disallowLines}\nSitemap: ${siteUrl}/sitemap.xml\n`;
}
export function sitemapIndexXml(siteUrl: string): string {
const sitemaps = sitemapFiles
.map(
(path) => ` <sitemap>
<loc>${xmlEscape(siteUrl + path)}</loc>
<lastmod>${formatLastmod(staticLastmod)}</lastmod>
</sitemap>`
)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${sitemaps}
</sitemapindex>
`;
}
export function staticSitemapXml(siteUrl: string): string {
return sitemapXml(
siteUrl,
staticSitemapUrls.map((url) => ({ ...url, lastmod: staticLastmod }))
);
}
export async function pokedexSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
const pokemon = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/pokemon');
return sitemapXml(
siteUrl,
pokemon.map((item) => ({
path: `/pokemon/${item.id}`,
lastmod: entityLastmod(item),
changefreq: 'weekly',
priority: 0.8
}))
);
}
export async function habitatsSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
const habitats = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/habitats');
return sitemapXml(
siteUrl,
habitats.map((item) => ({
path: `/habitats/${item.id}`,
lastmod: entityLastmod(item),
changefreq: 'weekly',
priority: 0.75
}))
);
}
export async function collectionsSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
const [items, artifacts, recipes] = await Promise.all([
fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/items'),
fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/ancient-artifacts'),
fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/recipes')
]);
return sitemapXml(siteUrl, [
...items
.filter((item) => !item.ancientArtifactCategory)
.map((item) => ({
path: `/items/${item.id}`,
lastmod: entityLastmod(item),
changefreq: 'weekly' as const,
priority: 0.75
})),
...artifacts.map((item) => ({
path: `/ancient-artifacts/${item.id}`,
lastmod: entityLastmod(item),
changefreq: 'weekly' as const,
priority: 0.75
})),
...recipes.map((item) => ({
path: `/recipes/${item.id}`,
lastmod: entityLastmod(item),
changefreq: 'weekly' as const,
priority: 0.7
}))
]);
}
export async function lifeSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
const posts = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/life-posts');
return sitemapXml(
siteUrl,
posts.map((item) => ({
path: `/life/${item.id}`,
lastmod: entityLastmod(item),
changefreq: 'daily',
priority: 0.65
}))
);
}
export async function threadsSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
const threads = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/threads');
return sitemapXml(
siteUrl,
threads.map((item) => ({
path: `/threads/${item.id}`,
lastmod: entityLastmod(item),
changefreq: 'daily',
priority: 0.65
}))
);
}
function sitemapXml(siteUrl: string, urls: SitemapUrl[]): string {
const body = urls.map((url) => sitemapUrlXml(siteUrl, url)).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${body}
</urlset>
`;
}
function sitemapUrlXml(siteUrl: string, url: SitemapUrl): string {
return [
' <url>',
` <loc>${xmlEscape(siteUrl + normalizePath(url.path))}</loc>`,
...(url.lastmod ? [` <lastmod>${formatLastmod(url.lastmod)}</lastmod>`] : []),
...(url.changefreq ? [` <changefreq>${url.changefreq}</changefreq>`] : []),
...(url.priority !== undefined ? [` <priority>${formatPriority(url.priority)}</priority>`] : []),
' </url>'
].join('\n');
}
async function fetchAllPages<T extends SitemapEntity>(apiBaseUrl: string, path: string): Promise<T[]> {
const items: T[] = [];
let cursor: string | null = null;
do {
const url = new URL(path, `${apiBaseUrl}/`);
url.searchParams.set('limit', String(sitemapPageSize));
if (cursor) {
url.searchParams.set('cursor', cursor);
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Sitemap source request failed: ${path} (${response.status})`);
}
const page = (await response.json()) as ListPage<T>;
items.push(...page.items);
cursor = page.hasMore ? page.nextCursor : null;
} while (cursor);
return items;
}
function entityLastmod(entity: SitemapEntity): string | null {
return entity.lastActiveAt ?? entity.updatedAt ?? entity.createdAt ?? null;
}
function normalizePath(path: string): string {
return path.startsWith('/') ? path : `/${path}`;
}
function formatLastmod(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? xmlEscape(value) : date.toISOString();
}
function formatPriority(value: number): string {
return Math.max(0, Math.min(1, value)).toFixed(2).replace(/0$/, '').replace(/\.0$/, '.0');
}
function xmlEscape(value: string): string {
return value
.replace(/&/g, '&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<{
@@ -45,6 +46,8 @@ const changeLabelKeys: Record<string, string> = {
'Speciality drops': 'pages.pokemon.skillDrops', 'Speciality drops': 'pages.pokemon.skillDrops',
'Skill drops': 'pages.pokemon.skillDrops', 'Skill drops': 'pages.pokemon.skillDrops',
特长掉落物: 'pages.pokemon.skillDrops', 特长掉落物: 'pages.pokemon.skillDrops',
Trading: 'pages.pokemon.trading',
'Trading items': 'pages.pokemon.tradingItems',
Category: 'pages.items.category', Category: 'pages.items.category',
分类: 'pages.items.category', 分类: 'pages.items.category',
Usage: 'pages.items.usage', Usage: 'pages.items.usage',
@@ -52,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',
@@ -76,10 +83,8 @@ const changeLabelKeys: Record<string, string> = {
排序: 'pages.admin.sortOrder', 排序: 'pages.admin.sortOrder',
'Has item drop': 'pages.admin.hasItemDrop', 'Has item drop': 'pages.admin.hasItemDrop',
有掉落物: 'pages.admin.hasItemDrop', 有掉落物: 'pages.admin.hasItemDrop',
'Default category': 'pages.admin.defaultCategory', 'Has trading': 'pages.admin.hasTrading',
默认分类: 'pages.admin.defaultCategory', '有 Trading': 'pages.admin.hasTrading',
Rateable: 'pages.admin.rateableCategory',
可评分: 'pages.admin.rateableCategory',
ChangeLog: 'pages.admin.changeLog' ChangeLog: 'pages.admin.changeLog'
}; };
@@ -112,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',
@@ -165,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,15 +2,15 @@
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import ConfirmDialog from './ConfirmDialog.vue';
import LoadMoreSentinel from './LoadMoreSentinel.vue';
import StatusBadge from './StatusBadge.vue'; import StatusBadge from './StatusBadge.vue';
import Tabs, { type TabOption } from './Tabs.vue'; import Tabs, { type TabOption } from './Tabs.vue';
import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons'; import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons';
import { import {
api, api,
getAuthToken,
moderationUpdateEvent, moderationUpdateEvent,
onAuthTokenChange, onAuthChange,
setAuthToken,
type AiModerationStatus, type AiModerationStatus,
type AuthUser, type AuthUser,
type CommentSort, type CommentSort,
@@ -53,6 +53,8 @@ let removeAuthListener: (() => void) | null = null;
const nextCursor = ref<string | null>(null); const nextCursor = ref<string | null>(null);
const hasMoreComments = ref(false); const hasMoreComments = ref(false);
const commentTotal = ref(0); const commentTotal = ref(0);
const pendingDeleteComment = ref<EntityDiscussionComment | null>(null);
const deleteConfirmBusy = ref(false);
function can(permissionKey: string) { function can(permissionKey: string) {
return currentUser.value?.permissions.includes(permissionKey) === true; return currentUser.value?.permissions.includes(permissionKey) === true;
@@ -76,18 +78,11 @@ const sortOptions = computed<Array<{ value: CommentSort; label: string }>>(() =>
async function loadCurrentUser() { async function loadCurrentUser() {
authReady.value = false; authReady.value = false;
if (!getAuthToken()) {
currentUser.value = null;
authReady.value = true;
return;
}
try { try {
const response = await api.me(); const response = await api.me();
currentUser.value = response.user; currentUser.value = response.user;
} catch { } catch {
currentUser.value = null; currentUser.value = null;
setAuthToken(null);
} finally { } finally {
authReady.value = true; authReady.value = true;
} }
@@ -470,11 +465,34 @@ function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolea
return false; return false;
} }
async function deleteComment(comment: EntityDiscussionComment) { function requestDeleteComment(comment: EntityDiscussionComment) {
if (!window.confirm(t('discussion.deleteConfirm'))) { pendingDeleteComment.value = comment;
}
function closeDeleteConfirm() {
if (deleteConfirmBusy.value) {
return; return;
} }
pendingDeleteComment.value = null;
}
async function confirmDeleteComment() {
const comment = pendingDeleteComment.value;
if (!comment) {
return;
}
deleteConfirmBusy.value = true;
try {
await deleteComment(comment);
pendingDeleteComment.value = null;
} finally {
deleteConfirmBusy.value = false;
}
}
async function deleteComment(comment: EntityDiscussionComment) {
const key = commentKey(comment.id); const key = commentKey(comment.id);
clearCommentError(key); clearCommentError(key);
@@ -514,7 +532,7 @@ onMounted(() => {
void loadCurrentUser(); void loadCurrentUser();
void loadLanguages(); void loadLanguages();
void loadDiscussion(); void loadDiscussion();
removeAuthListener = onAuthTokenChange(() => { removeAuthListener = onAuthChange(() => {
void loadCurrentUser(); void loadCurrentUser();
}); });
}); });
@@ -656,7 +674,7 @@ onUnmounted(() => {
class="life-icon-button life-icon-button--flat life-icon-button--danger" class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button" type="button"
:aria-label="t('discussion.deleteComment')" :aria-label="t('discussion.deleteComment')"
@click="deleteComment(comment)" @click="requestDeleteComment(comment)"
> >
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
@@ -758,7 +776,7 @@ onUnmounted(() => {
class="life-icon-button life-icon-button--flat life-icon-button--danger" class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button" type="button"
:aria-label="t('discussion.deleteComment')" :aria-label="t('discussion.deleteComment')"
@click="deleteComment(reply)" @click="requestDeleteComment(reply)"
> >
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
@@ -776,17 +794,7 @@ onUnmounted(() => {
</div> </div>
</article> </article>
<div v-if="hasMoreComments" class="life-feed__retry"> <LoadMoreSentinel :active="hasMoreComments" :disabled="loading || loadingMore" @load="loadDiscussion(false)" />
<button
class="ui-button ui-button--ghost ui-button--small"
type="button"
:disabled="loadingMore"
@click="loadDiscussion(false)"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
{{ loadingMore ? t('common.loading') : t('discussion.loadMore') }}
</button>
</div>
</div> </div>
<div v-else class="entity-discussion-empty"> <div v-else class="entity-discussion-empty">
@@ -796,5 +804,17 @@ onUnmounted(() => {
<p>{{ t('discussion.emptyHint') }}</p> <p>{{ t('discussion.emptyHint') }}</p>
</div> </div>
</div> </div>
<ConfirmDialog
v-if="pendingDeleteComment"
:title="t('discussion.deleteComment')"
:message="t('discussion.deleteConfirm')"
:confirm-label="t('common.delete')"
:cancel-label="t('common.cancel')"
:close-label="t('common.close')"
:busy="deleteConfirmBusy"
@cancel="closeDeleteConfirm"
@confirm="confirmDeleteComment"
/>
</section> </section>
</template> </template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
active: boolean;
disabled?: boolean;
rootMargin?: string;
}>(),
{
disabled: false,
rootMargin: '360px 0px'
}
);
const emit = defineEmits<{
load: [];
}>();
const sentinel = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
function disconnectObserver() {
observer?.disconnect();
observer = null;
}
function observeSentinel() {
disconnectObserver();
if (!props.active || props.disabled || !sentinel.value) {
return;
}
if (typeof IntersectionObserver === 'undefined') {
emit('load');
return;
}
observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
emit('load');
}
},
{ rootMargin: props.rootMargin }
);
observer.observe(sentinel.value);
}
onMounted(() => {
void nextTick(observeSentinel);
});
onBeforeUnmount(disconnectObserver);
watch(
() => [props.active, props.disabled, props.rootMargin, sentinel.value],
() => {
void nextTick(observeSentinel);
},
{ flush: 'post' }
);
</script>
<template>
<div v-if="active" ref="sentinel" class="load-more-sentinel" aria-hidden="true"></div>
</template>

View File

@@ -1,6 +1,10 @@
<script lang="ts">
let openModalCount = 0;
</script>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue'; import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, useId, watch } from 'vue';
import { iconClose } from '../icons'; import { iconClose } from '../icons';
const props = withDefaults( const props = withDefaults(
@@ -25,7 +29,7 @@ const emit = defineEmits<{
close: []; close: [];
}>(); }>();
const titleId = `modal-title-${Math.random().toString(36).slice(2)}`; const titleId = useId();
const dialog = ref<HTMLElement | null>(null); const dialog = ref<HTMLElement | null>(null);
const modalBody = ref<HTMLElement | null>(null); const modalBody = ref<HTMLElement | null>(null);
const closeButton = ref<HTMLButtonElement | null>(null); const closeButton = ref<HTMLButtonElement | null>(null);
@@ -54,11 +58,15 @@ const bodyFallbackSelector = [
].join(','); ].join(',');
function lockPage() { function lockPage() {
openModalCount += 1;
document.body.classList.add('lock-scroll'); document.body.classList.add('lock-scroll');
} }
function unlockPage() { function unlockPage() {
document.body.classList.remove('lock-scroll'); openModalCount = Math.max(0, openModalCount - 1);
if (openModalCount === 0) {
document.body.classList.remove('lock-scroll');
}
} }
function restoreFocus() { function restoreFocus() {

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

@@ -2,7 +2,8 @@ import { createI18n } from 'vue-i18n';
import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings'; import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings';
export { defaultLocale } from '../../system-wordings'; export { defaultLocale } from '../../system-wordings';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001'; let browserApiBaseUrl = 'http://localhost:3001';
let serverApiBaseUrl = 'http://localhost:3001';
const localeStorageKey = 'pokopia_locale'; const localeStorageKey = 'pokopia_locale';
const localeChangeEvent = 'pokopia-locale-change'; const localeChangeEvent = 'pokopia-locale-change';
@@ -17,15 +18,52 @@ type SystemWordingsResponse = {
export type MessageKey = keyof typeof messages.en; export type MessageKey = keyof typeof messages.en;
export const i18n = createI18n({ export function createPokopiaI18n(initialLocale = readStoredLocale()) {
legacy: false, return createI18n({
globalInjection: true, legacy: false,
locale: readStoredLocale(), globalInjection: true,
fallbackLocale: defaultLocale, locale: initialLocale || defaultLocale,
messages fallbackLocale: defaultLocale,
}); messages
});
}
function readStoredLocale(): string { type PokopiaI18n = ReturnType<typeof createPokopiaI18n>;
let activeI18n: PokopiaI18n | null = null;
export function setActiveI18n(instance: PokopiaI18n): void {
activeI18n = instance;
}
export function setSystemWordingsApiBaseUrl(value: unknown): void {
setSystemWordingsApiBaseUrls({ browser: value, server: value });
}
export function setSystemWordingsApiBaseUrls(value: { browser?: unknown; server?: unknown }): void {
const browserBaseUrl = normalizeApiBaseUrl(value.browser);
const serverBaseUrl = normalizeApiBaseUrl(value.server);
if (browserBaseUrl) {
browserApiBaseUrl = browserBaseUrl;
}
if (serverBaseUrl) {
serverApiBaseUrl = serverBaseUrl;
}
}
function normalizeApiBaseUrl(value: unknown): string | null {
if (typeof value === 'string' && value.trim() !== '') {
return value.trim().replace(/\/+$/, '');
}
return null;
}
function activeApiBaseUrl(): string {
return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl;
}
export function readStoredLocale(): string {
if (typeof localStorage === 'undefined') { if (typeof localStorage === 'undefined') {
return defaultLocale; return defaultLocale;
} }
@@ -35,11 +73,11 @@ function readStoredLocale(): string {
} }
function globalLocaleRef() { function globalLocaleRef() {
return i18n.global.locale as unknown as { value: string }; return activeI18n?.global.locale as unknown as { value: string } | undefined;
} }
export function getCurrentLocale(): string { export function getCurrentLocale(): string {
return globalLocaleRef().value || defaultLocale; return globalLocaleRef()?.value || defaultLocale;
} }
function isMessageTree(value: SystemWordingTree[string] | undefined): value is SystemWordingTree { function isMessageTree(value: SystemWordingTree[string] | undefined): value is SystemWordingTree {
@@ -68,6 +106,11 @@ function builtInMessagesFor(locale: string): SystemWordingTree {
} }
export async function loadSystemWordings(locale = getCurrentLocale(), force = false): Promise<void> { export async function loadSystemWordings(locale = getCurrentLocale(), force = false): Promise<void> {
if (!activeI18n) {
return;
}
const targetI18n = activeI18n;
const targetLocale = locale || defaultLocale; const targetLocale = locale || defaultLocale;
if (!force && loadedWordingLocales.has(targetLocale)) { if (!force && loadedWordingLocales.has(targetLocale)) {
return; return;
@@ -81,19 +124,19 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa
const loadPromise = (async () => { const loadPromise = (async () => {
try { try {
const response = await fetch(`${apiBaseUrl}/api/system-wordings?locale=${encodeURIComponent(targetLocale)}`); const response = await fetch(`${activeApiBaseUrl()}/api/system-wordings?locale=${encodeURIComponent(targetLocale)}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`System wordings failed (${response.status})`); throw new Error(`System wordings failed (${response.status})`);
} }
const data = (await response.json()) as SystemWordingsResponse; const data = (await response.json()) as SystemWordingsResponse;
i18n.global.setLocaleMessage( targetI18n.global.setLocaleMessage(
targetLocale, targetLocale,
mergeMessageTrees(messages[defaultLocale], messages[targetLocale], data.messages) as never mergeMessageTrees(messages[defaultLocale], messages[targetLocale], data.messages) as never
); );
loadedWordingLocales.add(targetLocale); loadedWordingLocales.add(targetLocale);
} catch { } catch {
i18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never); targetI18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never);
} finally { } finally {
pendingWordingLoads.delete(targetLocale); pendingWordingLoads.delete(targetLocale);
} }
@@ -105,7 +148,10 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa
export function setCurrentLocale(locale: string): void { export function setCurrentLocale(locale: string): void {
const nextLocale = locale || defaultLocale; const nextLocale = locale || defaultLocale;
globalLocaleRef().value = nextLocale; const localeRef = globalLocaleRef();
if (localeRef) {
localeRef.value = nextLocale;
}
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.documentElement.lang = nextLocale; document.documentElement.lang = nextLocale;
@@ -121,8 +167,10 @@ export function setCurrentLocale(locale: string): void {
} }
export function onLocaleChange(callback: () => void): () => void { export function onLocaleChange(callback: () => void): () => void {
if (typeof window === 'undefined') {
return () => {};
}
window.addEventListener(localeChangeEvent, callback); window.addEventListener(localeChangeEvent, callback);
return () => window.removeEventListener(localeChangeEvent, callback); return () => window.removeEventListener(localeChangeEvent, callback);
} }
setCurrentLocale(getCurrentLocale());

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

@@ -1,9 +0,0 @@
import { createApp } from 'vue';
import App from './App.vue';
import { i18n } from './i18n';
import { router } from './router';
import { setupSeo } from './seo';
import './styles/main.css';
setupSeo(router);
createApp(App).use(i18n).use(router).mount('#app');

View File

@@ -1,388 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import PokemonList from '../views/PokemonList.vue';
import PokemonDetail from '../views/PokemonDetail.vue';
import HabitatList from '../views/HabitatList.vue';
import HabitatDetail from '../views/HabitatDetail.vue';
import ItemsList from '../views/ItemsList.vue';
import ItemDetail from '../views/ItemDetail.vue';
import AncientArtifactList from '../views/AncientArtifactList.vue';
import RecipeList from '../views/RecipeList.vue';
import RecipeDetail from '../views/RecipeDetail.vue';
import DailyChecklistView from '../views/DailyChecklistView.vue';
import LifePostDetail from '../views/LifePostDetail.vue';
import LifeView from '../views/LifeView.vue';
import DishView from '../views/DishView.vue';
import ProjectUpdatesView from '../views/ProjectUpdatesView.vue';
import LegalView from '../views/LegalView.vue';
import ComingSoonView from '../views/ComingSoonView.vue';
import AdminView from '../views/AdminView.vue';
import ForgotPasswordView from '../views/ForgotPasswordView.vue';
import LoginView from '../views/LoginView.vue';
import UserProfileView from '../views/UserProfileView.vue';
import RegisterView from '../views/RegisterView.vue';
import ResetPasswordView from '../views/ResetPasswordView.vue';
import VerifyEmailView from '../views/VerifyEmailView.vue';
import { api, getAuthToken, setAuthToken } from '../services/api';
import type { RouteSeoConfig } from '../seo';
const seo = (config: RouteSeoConfig) => config;
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView, meta: { seo: seo({ titleKey: 'pages.home.title', descriptionKey: 'pages.home.subtitle', canonicalPath: '/' }) } },
{
path: '/pokemon',
name: 'pokemon-list',
component: PokemonList,
props: { eventOnly: false },
meta: { seo: seo({ titleKey: 'pages.pokemon.title', descriptionKey: 'pages.pokemon.subtitle' }) }
},
{
path: '/pokemon/new',
name: 'pokemon-new',
component: PokemonList,
props: { eventOnly: false },
meta: {
requiredPermission: 'pokemon.create',
editorModal: true,
seo: seo({ titleKey: 'pages.pokemon.newTitle', descriptionKey: 'pages.pokemon.editSubtitle', canonicalPath: '/pokemon', noindex: true })
}
},
{
path: '/event-pokemon',
name: 'event-pokemon-list',
component: PokemonList,
props: { eventOnly: true },
meta: { seo: seo({ titleKey: 'pages.eventPokemon.title', descriptionKey: 'pages.eventPokemon.subtitle', canonicalPath: '/event-pokemon' }) }
},
{
path: '/event-pokemon/new',
name: 'event-pokemon-new',
component: PokemonList,
props: { eventOnly: true },
meta: {
requiredPermission: 'pokemon.create',
editorModal: true,
seo: seo({ titleKey: 'pages.eventPokemon.newTitle', descriptionKey: 'pages.eventPokemon.editSubtitle', canonicalPath: '/event-pokemon', noindex: true })
}
},
{
path: '/pokemon/:id/edit',
name: 'pokemon-edit',
component: PokemonDetail,
meta: {
requiredPermission: 'pokemon.update',
editorModal: true,
seo: seo({
titleKey: 'pages.pokemon.editKicker',
descriptionKey: 'pages.pokemon.editSubtitle',
canonicalPath: (route) => `/pokemon/${String(route.params.id)}`,
noindex: true
})
}
},
{ path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail, meta: { seo: seo({ titleKey: 'pages.pokemon.detailKicker', descriptionKey: 'pages.pokemon.subtitle' }) } },
{
path: '/habitats',
name: 'habitat-list',
component: HabitatList,
props: { eventOnly: false },
meta: { seo: seo({ titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }) }
},
{
path: '/habitats/new',
name: 'habitat-new',
component: HabitatList,
props: { eventOnly: false },
meta: {
requiredPermission: 'habitats.create',
editorModal: true,
seo: seo({ titleKey: 'pages.habitats.newTitle', descriptionKey: 'pages.habitats.editSubtitle', canonicalPath: '/habitats', noindex: true })
}
},
{
path: '/event-habitats',
name: 'event-habitat-list',
component: HabitatList,
props: { eventOnly: true },
meta: { seo: seo({ titleKey: 'pages.eventHabitats.title', descriptionKey: 'pages.eventHabitats.subtitle', canonicalPath: '/event-habitats' }) }
},
{
path: '/event-habitats/new',
name: 'event-habitat-new',
component: HabitatList,
props: { eventOnly: true },
meta: {
requiredPermission: 'habitats.create',
editorModal: true,
seo: seo({ titleKey: 'pages.eventHabitats.newTitle', descriptionKey: 'pages.eventHabitats.editSubtitle', canonicalPath: '/event-habitats', noindex: true })
}
},
{
path: '/habitats/:id/edit',
name: 'habitat-edit',
component: HabitatDetail,
meta: {
requiredPermission: 'habitats.update',
editorModal: true,
seo: seo({
titleKey: 'pages.habitats.detailKicker',
descriptionKey: 'pages.habitats.editSubtitle',
canonicalPath: (route) => `/habitats/${String(route.params.id)}`,
noindex: true
})
}
},
{ path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail, meta: { seo: seo({ titleKey: 'pages.habitats.detailKicker', descriptionKey: 'pages.habitats.subtitle' }) } },
{
path: '/items',
name: 'item-list',
component: ItemsList,
props: { eventOnly: false },
meta: { seo: seo({ titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }) }
},
{
path: '/items/new',
name: 'item-new',
component: ItemsList,
props: { eventOnly: false },
meta: {
requiredPermission: 'items.create',
editorModal: true,
seo: seo({ titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/items', noindex: true })
}
},
{
path: '/event-items',
name: 'event-item-list',
component: ItemsList,
props: { eventOnly: true },
meta: { seo: seo({ titleKey: 'pages.eventItems.title', descriptionKey: 'pages.eventItems.subtitle', canonicalPath: '/event-items' }) }
},
{
path: '/event-items/new',
name: 'event-item-new',
component: ItemsList,
props: { eventOnly: true },
meta: {
requiredPermission: 'items.create',
editorModal: true,
seo: seo({ titleKey: 'pages.eventItems.newTitle', descriptionKey: 'pages.eventItems.editSubtitle', canonicalPath: '/event-items', noindex: true })
}
},
{
path: '/items/:id/edit',
name: 'item-edit',
component: ItemDetail,
meta: {
requiredPermission: 'items.update',
editorModal: true,
seo: seo({
titleKey: 'pages.items.editKicker',
descriptionKey: 'pages.items.editSubtitle',
canonicalPath: (route) => `/items/${String(route.params.id)}`,
noindex: true
})
}
},
{ path: '/items/:id', name: 'item-detail', component: ItemDetail, meta: { seo: seo({ titleKey: 'pages.items.detailKicker', descriptionKey: 'pages.items.subtitle' }) } },
{
path: '/ancient-artifacts',
name: 'ancient-artifact-list',
component: AncientArtifactList,
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.title', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
},
{
path: '/ancient-artifacts/new',
name: 'ancient-artifact-new',
component: AncientArtifactList,
meta: {
requiredPermission: 'items.create',
editorModal: true,
seo: seo({
titleKey: 'pages.ancientArtifacts.newTitle',
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
canonicalPath: '/ancient-artifacts',
noindex: true
})
}
},
{
path: '/ancient-artifacts/:id/edit',
name: 'ancient-artifact-edit',
component: ItemDetail,
meta: {
requiredPermission: 'items.update',
editorModal: true,
seo: seo({
titleKey: 'pages.ancientArtifacts.editKicker',
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
canonicalPath: (route) => `/ancient-artifacts/${String(route.params.id)}`,
noindex: true
})
}
},
{
path: '/ancient-artifacts/:id',
name: 'ancient-artifact-detail',
component: ItemDetail,
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.detailKicker', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
},
{ path: '/recipes', name: 'recipe-list', component: RecipeList, meta: { seo: seo({ titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }) } },
{
path: '/recipes/new',
name: 'recipe-new',
component: RecipeList,
meta: {
requiredPermission: 'recipes.create',
editorModal: true,
seo: seo({ titleKey: 'pages.recipes.newTitle', descriptionKey: 'pages.recipes.editSubtitle', canonicalPath: '/recipes', noindex: true })
}
},
{
path: '/recipes/:id/edit',
name: 'recipe-edit',
component: RecipeDetail,
meta: {
requiredPermission: 'recipes.update',
editorModal: true,
seo: seo({
titleKey: 'pages.recipes.editKicker',
descriptionKey: 'pages.recipes.editSubtitle',
canonicalPath: (route) => `/recipes/${String(route.params.id)}`,
noindex: true
})
}
},
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail, meta: { seo: seo({ titleKey: 'pages.recipes.detailKicker', descriptionKey: 'pages.recipes.subtitle' }) } },
{
path: '/automation',
name: 'automation',
component: ComingSoonView,
props: { page: 'automation' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.automation.title', descriptionKey: 'pages.comingSoon.sections.automation.subtitle', noindex: true }) }
},
{
path: '/dish',
name: 'dish',
component: DishView,
meta: { seo: seo({ titleKey: 'pages.dish.title', descriptionKey: 'pages.dish.subtitle' }) }
},
{
path: '/events',
name: 'events',
component: ComingSoonView,
props: { page: 'events' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.events.title', descriptionKey: 'pages.comingSoon.sections.events.subtitle', noindex: true }) }
},
{
path: '/actions',
name: 'actions',
component: ComingSoonView,
props: { page: 'actions' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.actions.title', descriptionKey: 'pages.comingSoon.sections.actions.subtitle', noindex: true }) }
},
{
path: '/dream-island',
name: 'dream-island',
component: ComingSoonView,
props: { page: 'dreamIsland' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.dreamIsland.title', descriptionKey: 'pages.comingSoon.sections.dreamIsland.subtitle', noindex: true }) }
},
{
path: '/clothes',
name: 'clothes',
component: ComingSoonView,
props: { page: 'clothes' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.clothes.title', descriptionKey: 'pages.comingSoon.sections.clothes.subtitle', noindex: true }) }
},
{ path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } },
{ path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } },
{ path: '/life/:id', component: LifePostDetail, meta: { seo: seo({ titleKey: 'pages.life.detailTitle', descriptionKey: 'pages.life.detailSubtitle' }) } },
{
path: '/project-updates',
component: ProjectUpdatesView,
meta: {
seo: seo({
titleKey: 'pages.projectUpdates.title',
descriptionKey: 'pages.projectUpdates.subtitle',
canonicalPath: '/project-updates'
})
}
},
{
path: '/privacy-policy',
component: LegalView,
props: { page: 'privacy' },
meta: { seo: seo({ titleKey: 'pages.legal.privacy.title', descriptionKey: 'pages.legal.privacy.subtitle', canonicalPath: '/privacy-policy' }) }
},
{
path: '/terms-of-service',
component: LegalView,
props: { page: 'terms' },
meta: { seo: seo({ titleKey: 'pages.legal.terms.title', descriptionKey: 'pages.legal.terms.subtitle', canonicalPath: '/terms-of-service' }) }
},
{
path: '/disclaimers',
component: LegalView,
props: { page: 'disclaimers' },
meta: { seo: seo({ titleKey: 'pages.legal.disclaimers.title', descriptionKey: 'pages.legal.disclaimers.subtitle', canonicalPath: '/disclaimers' }) }
},
{ path: '/admin', component: AdminView, meta: { requiredPermission: 'admin.access', seo: seo({ titleKey: 'pages.admin.title', descriptionKey: 'pages.admin.subtitle', noindex: true }) } },
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true, seo: seo({ titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.subtitle', noindex: true }) } },
{ path: '/profile/:id', component: UserProfileView, meta: { seo: seo({ titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.publicSubtitle' }) } },
{ path: '/login', component: LoginView, meta: { seo: seo({ titleKey: 'auth.loginTitle', descriptionKey: 'auth.loginSubtitle', noindex: true }) } },
{ path: '/forgot-password', component: ForgotPasswordView, meta: { seo: seo({ titleKey: 'auth.requestResetTitle', descriptionKey: 'auth.requestResetSubtitle', noindex: true }) } },
{ path: '/reset-password', component: ResetPasswordView, meta: { seo: seo({ titleKey: 'auth.resetTitle', descriptionKey: 'auth.resetSubtitle', noindex: true }) } },
{ path: '/register', component: RegisterView, meta: { seo: seo({ titleKey: 'auth.registerTitle', descriptionKey: 'auth.registerSubtitle', noindex: true }) } },
{ path: '/verify-email', component: VerifyEmailView, meta: { seo: seo({ titleKey: 'auth.verifyTitle', descriptionKey: 'auth.verifySubtitle', noindex: true }) } }
],
scrollBehavior(to, from, savedPosition) {
if (savedPosition) return savedPosition;
if (to.meta.editorModal === true || from.meta.editorModal === true) return false;
return { top: 0 };
}
});
router.beforeEach(async (to) => {
const requiredPermissions = to.matched
.map((record) => record.meta.requiredPermission)
.filter((permission): permission is string => typeof permission === 'string');
const requiredAnyPermissions = to.matched.flatMap((record) =>
Array.isArray(record.meta.requiredAnyPermission)
? record.meta.requiredAnyPermission.filter((permission): permission is string => typeof permission === 'string')
: []
);
const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true) || requiredPermissions.length > 0 || requiredAnyPermissions.length > 0;
const requiresAuth = requiresVerified || to.matched.some((record) => record.meta.requiresAuth === true);
if (!requiresAuth) {
return true;
}
if (!getAuthToken()) {
return { path: '/login', query: { redirect: to.fullPath } };
}
try {
const response = await api.me();
if (requiresVerified && !response.user.emailVerified) {
return { path: '/login', query: { redirect: to.fullPath } };
}
const permissionSet = new Set(response.user.permissions);
if (requiredPermissions.some((permission) => !permissionSet.has(permission))) {
return { path: '/pokemon' };
}
if (requiredAnyPermissions.length && !requiredAnyPermissions.some((permission) => permissionSet.has(permission))) {
return { path: '/pokemon' };
}
return true;
} catch {
setAuthToken(null);
return { path: '/login', query: { redirect: to.fullPath } };
}
});

View File

@@ -1,12 +1,15 @@
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'; import type { RouteLocationNormalizedLoaded } from 'vue-router';
import { getCurrentLocale, i18n, onLocaleChange } from './i18n'; import { getCurrentLocale } from './i18n';
import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings';
const siteName = 'Pokopia Wiki'; const siteName = 'Pokopia Wiki';
const defaultCanonicalPath = '/'; const defaultCanonicalPath = '/';
const defaultImagePath = '/seo/pokopia-hero.jpg'; const defaultImagePath = '/seo/pokopia-hero.jpg';
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com'; const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
let runtimeSiteUrl: string | null = null;
type TranslationValues = Record<string, string | number>; type TranslationValues = Record<string, string | number>;
export type Translator = (key: string, values?: TranslationValues) => string;
export type RouteSeoConfig = { export type RouteSeoConfig = {
title?: string; title?: string;
@@ -24,14 +27,50 @@ export type SeoConfig = {
canonicalPath?: string; canonicalPath?: string;
image?: string | null; image?: string | null;
noindex?: boolean; noindex?: boolean;
openGraphType?: 'website' | 'article';
structuredData?: Record<string, unknown>;
}; };
const translate = i18n.global.t as (key: string, values?: TranslationValues) => string; export type ResolvedSeoConfig = {
title: string;
description: string;
canonicalUrl: string;
imageUrl: string;
robots: string;
locale: string;
openGraphType: 'website' | 'article';
structuredData: Record<string, unknown>;
};
export type ThreadSeoSummary = {
id: number;
title: string;
languageCode: string;
tags: Array<{ name: string }>;
messageCount: number;
createdAt: string;
lastActiveAt: string;
author: { displayName: string } | null;
};
const messages = systemWordingMessages as unknown as Record<string, SystemWordingTree>;
let activeTranslator: Translator | null = null;
let currentSeo: ResolvedSeoConfig | null = null;
const seoListeners = new Set<(seo: ResolvedSeoConfig) => void>();
export function setSeoTranslator(translator: Translator): void {
activeTranslator = translator;
}
export function setConfiguredSiteUrl(value: unknown): void {
if (typeof value === 'string' && value.trim() !== '') {
runtimeSiteUrl = normalizeSiteUrl(value);
}
}
function configuredSiteUrl(): string { function configuredSiteUrl(): string {
const fromEnv = import.meta.env.VITE_SITE_URL; if (runtimeSiteUrl) {
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') { return runtimeSiteUrl;
return normalizeSiteUrl(fromEnv);
} }
if (typeof window !== 'undefined' && window.location.origin) { if (typeof window !== 'undefined' && window.location.origin) {
@@ -68,115 +107,167 @@ function metaTitle(title?: string): string {
} }
function metaDescription(description?: string): string { function metaDescription(description?: string): string {
return description?.trim() || translate('seo.siteDescription'); return description?.trim() || translateSeo('seo.siteDescription');
} }
function localeForOpenGraph(locale: string): string { function builtInTranslate(key: string, values: TranslationValues = {}): string {
if (locale === 'en') { let message: SystemWordingTree[string] | undefined = messages[defaultLocale];
return 'en_US'; for (const part of key.split('.')) {
message = typeof message === 'object' && message !== null ? message[part] : undefined;
} }
return locale.replace('-', '_'); if (typeof message !== 'string') {
return key;
}
return Object.entries(values).reduce((nextMessage, [name, value]) => nextMessage.replaceAll(`{${name}}`, String(value)), message);
} }
function setMeta(attribute: 'name' | 'property', key: string, content: string): void { function translateSeo(key: string, values?: TranslationValues, translator = activeTranslator): string {
let element = document.head.querySelector<HTMLMetaElement>(`meta[${attribute}="${key}"]`); return translator ? translator(key, values) : builtInTranslate(key, values);
if (!element) {
element = document.createElement('meta');
element.setAttribute(attribute, key);
document.head.appendChild(element);
}
element.setAttribute('content', content);
} }
function setCanonical(href: string): void { export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
let element = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!element) {
element = document.createElement('link');
element.setAttribute('rel', 'canonical');
document.head.appendChild(element);
}
element.setAttribute('href', href);
}
function setStructuredData(title: string, description: string, canonicalUrl: string): void {
let element = document.getElementById('pokopia-structured-data') as HTMLScriptElement | null;
if (!element) {
element = document.createElement('script');
element.id = 'pokopia-structured-data';
element.type = 'application/ld+json';
document.head.appendChild(element);
}
element.textContent = JSON.stringify({
'@context': 'https://schema.org',
'@type': 'WebPage',
name: title,
description,
url: canonicalUrl,
isPartOf: {
'@type': 'WebSite',
name: siteName,
url: absoluteUrl('/')
}
});
}
export function applySeo(config: SeoConfig = {}): void {
if (typeof document === 'undefined') {
return;
}
const title = metaTitle(config.title); const title = metaTitle(config.title);
const description = metaDescription(config.description); const description = metaDescription(config.description);
const canonicalUrl = absoluteUrl(normalizePath(config.canonicalPath)); const canonicalUrl = absoluteUrl(normalizePath(config.canonicalPath));
const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath); const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath);
const noindex = config.noindex === true; const robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow';
const robots = noindex ? 'noindex, nofollow' : 'index, follow';
const locale = getCurrentLocale(); const locale = getCurrentLocale();
const openGraphType = config.openGraphType ?? 'website';
document.title = title; return {
setMeta('name', 'description', description); title,
setMeta('name', 'robots', robots); description,
setMeta('name', 'twitter:card', 'summary_large_image'); canonicalUrl,
setMeta('name', 'twitter:title', title); imageUrl,
setMeta('name', 'twitter:description', description); robots,
setMeta('name', 'twitter:image', imageUrl); locale,
setMeta('property', 'og:site_name', siteName); openGraphType,
setMeta('property', 'og:type', 'website'); structuredData: config.structuredData ?? {
setMeta('property', 'og:title', title); '@context': 'https://schema.org',
setMeta('property', 'og:description', description); '@type': 'WebPage',
setMeta('property', 'og:url', canonicalUrl); name: title,
setMeta('property', 'og:image', imageUrl); description,
setMeta('property', 'og:locale', localeForOpenGraph(locale)); url: canonicalUrl,
setCanonical(canonicalUrl); isPartOf: {
setStructuredData(title, description, canonicalUrl); '@type': 'WebSite',
name: siteName,
url: absoluteUrl('/')
}
}
};
} }
export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void { export function resolvedSeoHead(seo: ResolvedSeoConfig) {
return {
title: seo.title,
htmlAttrs: {
lang: seo.locale
},
meta: [
{ key: 'description', name: 'description', content: seo.description },
{ key: 'robots', name: 'robots', content: seo.robots },
{ key: 'twitter-card', name: 'twitter:card', content: 'summary_large_image' },
{ key: 'twitter-title', name: 'twitter:title', content: seo.title },
{ key: 'twitter-description', name: 'twitter:description', content: seo.description },
{ key: 'twitter-image', name: 'twitter:image', content: seo.imageUrl },
{ key: 'og-site-name', property: 'og:site_name', content: siteName },
{ key: 'og-type', property: 'og:type', content: seo.openGraphType },
{ key: 'og-title', property: 'og:title', content: seo.title },
{ key: 'og-description', property: 'og:description', content: seo.description },
{ key: 'og-url', property: 'og:url', content: seo.canonicalUrl },
{ key: 'og-image', property: 'og:image', content: seo.imageUrl },
{ key: 'og-locale', property: 'og:locale', content: seo.locale === 'en' ? 'en_US' : seo.locale.replace('-', '_') }
],
link: [{ key: 'canonical', rel: 'canonical', href: seo.canonicalUrl }],
script: [
{
key: 'pokopia-structured-data',
id: 'pokopia-structured-data',
type: 'application/ld+json',
innerHTML: JSON.stringify(seo.structuredData).replace(/</g, '\\u003C')
}
]
};
}
export function routeSeoConfig(route: RouteLocationNormalizedLoaded, translator?: Translator): SeoConfig {
const routeSeo = route.meta.seo as RouteSeoConfig | undefined; const routeSeo = route.meta.seo as RouteSeoConfig | undefined;
const canonicalPath = const canonicalPath =
typeof routeSeo?.canonicalPath === 'function' typeof routeSeo?.canonicalPath === 'function'
? routeSeo.canonicalPath(route) ? routeSeo.canonicalPath(route)
: routeSeo?.canonicalPath ?? route.path ?? defaultCanonicalPath; : routeSeo?.canonicalPath ?? route.path ?? defaultCanonicalPath;
const requiresPrivateAccess = route.matched.some(
(record) =>
record.meta.requiresAuth === true ||
record.meta.requiresVerified === true ||
typeof record.meta.requiredPermission === 'string' ||
(Array.isArray(record.meta.requiredAnyPermission) && record.meta.requiredAnyPermission.length > 0)
);
applySeo({ return {
title: routeSeo?.titleKey ? translate(routeSeo.titleKey) : routeSeo?.title, title: routeSeo?.titleKey ? translateSeo(routeSeo.titleKey, undefined, translator) : routeSeo?.title,
description: routeSeo?.descriptionKey ? translate(routeSeo.descriptionKey) : routeSeo?.description, description: routeSeo?.descriptionKey ? translateSeo(routeSeo.descriptionKey, undefined, translator) : routeSeo?.description,
canonicalPath, canonicalPath,
image: routeSeo?.image, image: routeSeo?.image,
noindex: routeSeo?.noindex noindex: routeSeo?.noindex === true || requiresPrivateAccess
}); };
} }
export function setupSeo(router: Router): void { export function resolveRouteSeo(route: RouteLocationNormalizedLoaded, translator?: Translator): ResolvedSeoConfig {
router.afterEach((to) => { return resolveSeo(routeSeoConfig(route, translator));
applyRouteSeo(to); }
});
if (typeof window !== 'undefined') { export function onSeoChange(callback: (seo: ResolvedSeoConfig) => void): () => void {
onLocaleChange(() => { seoListeners.add(callback);
applyRouteSeo(router.currentRoute.value); callback(currentSeo ?? resolveSeo());
}); return () => seoListeners.delete(callback);
}
export function applySeo(config: SeoConfig = {}): void {
currentSeo = resolveSeo(config);
for (const listener of seoListeners) {
listener(currentSeo);
} }
} }
export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void {
applySeo(routeSeoConfig(route));
}
export function threadSeoConfig(thread: ThreadSeoSummary, translator: Translator): SeoConfig {
const title = thread.title.trim() || translator('pages.threads.title');
const canonicalPath = `/threads/${thread.id}`;
const keywords = thread.tags.map((tag) => tag.name.trim()).filter(Boolean).join(', ');
const description = translator('seo.threadDetailDescription', { title, count: thread.messageCount });
return {
title: `${title} - ${translator('pages.threads.title')}`,
description,
canonicalPath,
openGraphType: 'article',
structuredData: {
'@context': 'https://schema.org',
'@type': 'DiscussionForumPosting',
headline: title,
description,
url: absoluteUrl(canonicalPath),
datePublished: thread.createdAt,
dateModified: thread.lastActiveAt,
inLanguage: thread.languageCode,
keywords: keywords || undefined,
author: thread.author ? { '@type': 'Person', name: thread.author.displayName } : undefined,
interactionStatistic: {
'@type': 'InteractionCounter',
interactionType: { '@type': 'CommentAction' },
userInteractionCount: thread.messageCount
},
isPartOf: {
'@type': 'WebPage',
name: translator('pages.threads.title'),
url: absoluteUrl('/threads')
}
}
};
}

Some files were not shown because too many files have changed in this diff Show More