Compare commits

..

30 Commits

Author SHA1 Message Date
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
839a24566b refactor(items): improve edit form layout with responsive grid rows
Group related fields like name/price and category/usage.
Stack fields vertically on screens smaller than 720px.
2026-05-05 09:05:53 +08:00
9312156a3c feat(items): add base price and support usage in creation defaults
Add `base_price` to items schema, API, and edit history
Display and edit base price in item details and forms
Add `clearable` prop to TagsSelect for optional single selections
Include usage in item creation session defaults
2026-05-05 08:59:36 +08:00
8ee29e9549 feat(history): exclude sort order changes from edit history
Stop recording sort order changes in the backend edit log
Filter out existing sort order changes from the frontend edit history panel
2026-05-05 07:15:18 +08:00
357dc061d6 feat(items): support drag-and-drop reordering and contextual insert
Implement drag-and-drop sorting in the items grid
Add right-click context menu to insert new items before or after
Update backend to process insertion anchors during item creation
2026-05-05 07:01:21 +08:00
a17344d216 feat(ui): add session defaults menu for item creation
Support presetting category, checkboxes, and acquisition methods.
Persist defaults in sessionStorage to streamline repetitive data entry.
2026-05-04 22:45:32 +08:00
121 changed files with 71999 additions and 2479 deletions

View File

@@ -7,8 +7,9 @@ TRUST_PROXY=false
FRONTEND_ORIGIN=http://localhost:20015
APP_ORIGIN=http://localhost:20015
BACKEND_PUBLIC_ORIGIN=http://localhost:20016
VITE_API_BASE_URL=http://localhost:20016
VITE_SITE_URL=https://pokopiawiki.tootaio.com
NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
NUXT_SERVER_API_BASE_URL=http://localhost:3001
NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
RESEND_API_KEY=
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
RESEND_DAILY_QUOTA_LIMIT=100
@@ -17,8 +18,16 @@ RESEND_QUOTA_RESERVE=5
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
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:
# FRONTEND_ORIGIN=https://pokopiawiki.tootaio.com,http://localhost:20015
# APP_ORIGIN=https://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

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
node_modules/
.pnpm-store/
dist/
.nuxt/
.output/
.env
.env.*
!.env.example

1
.repomixignore Normal file
View File

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

View File

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

120
DESIGN.md
View File

@@ -15,7 +15,7 @@
## 技术栈
- 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。
- 运维Docker / docker compose。
- 依赖版本遵循现有 `package.json`,新增依赖时优先使用当前主流稳定版本,并保持项目结构简单。
@@ -27,12 +27,13 @@
- 全局搜索 API 只返回公开浏览所需的最小结果字段结果类型、ID、展示标题、目标 URL、可选摘要和可选图片用户搜索结果只使用公开 Profile 所需的 `id``displayName` 和目标 URL不返回邮箱、角色、权限、Referral、编辑审计、审核原因、token/hash、内部字段或调试信息。
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
- 列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序。
- 除 Pokemon 外,列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序Pokemon 列表按内部 `id` 升序展示,不提供手动排序
## 国际化
- 前端使用 Vue I18n 管理界面文案,并通过 `X-Locale` 请求头告知后端当前语言。
- 前端当前语言保存在 `localStorage``pokopia_locale`
- Nuxt SSR 运行时每个 Nuxt app/request 创建独立 Vue I18n 实例,避免跨请求共享 locale 或系统文案状态;服务端默认使用 `en`,客户端 hydration 后按 `pokopia_locale` 恢复用户语言。
- 后端默认语言为 `en`
- 语言配置存储在 `languages`
- `code`
@@ -58,8 +59,7 @@
- 喜欢的环境
- 喜欢的东西 / 标签
- 入手方式
- 物品
- Ancient Artifacts
- 物品(包含 Ancient Artifacts 视图中的物品)
- 地图
- 栖息地
- 每日 CheckList Task
@@ -71,7 +71,7 @@
- 支持翻译的字段:
- `name`
- `title`
- `details`Pokemon、物品和 Ancient Artifacts 的介绍 / 说明
- `details`Pokemon 和物品的介绍 / 说明
- `genus`:仅 Pokemon Genus 使用
- `effect`Dish Category 的吃后效果
- `mosslaxEffect`Dish 给 Mosslax 吃之后的效果
@@ -121,10 +121,15 @@
- 重置 token 只保存 hash并带过期时间和使用状态。
- 密码重置成功后不自动登录,并删除该用户已有 session。
- 登录页提供 Remember me
- 未勾选时前端将登录 token 保存在 `sessionStorage``pokopia_auth_token`,服务端 session 有效期为 1 天。
- 勾选时前端将登录 token 保存在 `localStorage``pokopia_auth_token`,服务端 session 有效期为 30 天。
- 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。
- 用户可退出登录,退出时删除对应 session
- 未勾选时 session 有效期为 1 天。
- 勾选时 session 有效期为 30 天。
- SSR 认证使用 HTTP-only cookie 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``displayName`
@@ -215,10 +220,10 @@
- Items 与 Recipes 存在依赖关系;选择 Items 进行导出、导入或 Wipe 时,系统必须自动同时纳入 Recipes前端确认内容也必须显示 Recipes。
- Wipe 行为:
- 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联 / Trading 观察,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
- Wipe Items 会先删除 Recipes再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项Pokemon 掉落关联。
- Wipe Ancient Artifacts 会删除 Ancient Artifacts、标签关联、实体翻译、编辑历史和实体讨论评论
- Wipe Items 会先删除 Recipes再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项Pokemon 掉落关联和 Trading 观察
- Wipe Ancient Artifacts 会清空物品上的 Ancient Artifact 分类并删除对应 Ancient Artifact 讨论;物品本身仍保留在 Items / Event Items 中
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
- 对被清空的 identity 主表重置自增 IDPokemon 内部 ID 不是 identity未关联官方 data 的自定义 Pokemon 系统分配区间仍按当前数据库最大值继续。
@@ -226,14 +231,23 @@
- 导出为版本化 JSON bundle包含 `version``exportedAt``scopes` 和对应范围数据。
- JSON bundle 用于系统导入,不作为前台展示内容。
- 导出包含所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
- 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。
- 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落、Trading 观察和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。
- JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。
- Import 行为:
- 当前只支持 Replace selected scopes导入前先 Wipe bundle 中包含的范围,再在同一事务中还原 bundle 数据。
- Import 不自动覆盖系统配置、语言、用户、角色、权限、系统文案或 Life 内容。
- 导入数据引用的 System config、Languages、Users 或上传文件路径必须已存在;缺失依赖会导致导入失败并回滚。
- 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
@@ -344,6 +358,7 @@
- `created_at`
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
- 编辑历史中的用户信息只展示必要署名不暴露邮箱、token、hash 或内部元数据。
- 非 Pokemon 列表排序操作仍更新列表顺序、最后编辑者和最后编辑时间,但 `sort_order` / Sort order 字段变更不写入或展示在详情页编辑历史面板中。
- 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。
## Wiki 图片上传
@@ -435,8 +450,10 @@
- 名称
- 是否有掉落物:`has_item_drop`
- 是否支持 Trading`has_trading`
- 已移除 `subcategory` 字段。
- 当特长允许掉落物时Pokemon 编辑中可为该 Pokemon + 特长配置一个掉落物品。
- 当 Pokemon 选择了至少一个支持 Trading 的特长时Pokemon 详情页可直接维护该 Pokemon 对物品的 Trading 偏好观察。
### Pokemon Types
@@ -499,6 +516,10 @@ Pokemon 可配置:
- 特长:可多选,最多 2 个
- 特长掉落物品:按 Pokemon + 特长配置,单选物品
- 喜欢的东西:可多选,最多 6 个
- Trading由所选特长是否支持 Trading 决定;当至少一个所选特长支持 Trading 时,可维护该 Pokemon 对物品的 Trading 偏好观察,分为 Likes 与 Neutral
- Likes该 Pokemon 喜欢交易该物品,交易价格触发 1.5x 加成;用于物品隐藏标签推断的正向证据
- Neutral该 Pokemon 对交易该物品无加成;用于物品隐藏标签推断的硬排除证据
- 每个物品在同一个 Pokemon 的 Trading 列表中只能出现一次,只能属于 Likes 或 Neutral 其中一组
- 六维:
- HP
- Attack
@@ -508,7 +529,6 @@ Pokemon 可配置:
- Speed
- 出现的栖息地:由栖息地出现配置反向展示
- 翻译
- 排序
普通 Pokemon 与 Event Pokemon 分开展示:
@@ -546,6 +566,7 @@ Pokemon 编辑表单使用标签页组织字段:
- 第二行:喜欢的环境、特长
- 第三行:喜欢的东西
- 特长掉落物品随已选择且支持掉落物的特长显示
- 编辑表单不直接维护 Trading 观察Trading 由详情页的 Manage Trading 入口维护
- Pokemon 图片选择区
- Advance 标签页:
- 第一行Genus
@@ -564,7 +585,8 @@ Pokemon 列表功能:
- 按喜欢的东西筛选:
- 满足任意条件
- 满足全部条件
-自定义排序展示
- Pokemon 内部 `id`序展示
- 列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Pokemon。
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
- Event Pokemon 列表功能与 Pokemon 列表相同,但只展示 `is_event_item = true` 的 PokemonPokemon 列表只展示 `is_event_item = false` 的 Pokemon。
@@ -579,7 +601,9 @@ Pokemon 详情页展示:
- 右侧:六维 Stats图片或默认占位符展示在 Stats 右侧
- 六维使用 ProgressBar 展示,最大值按 150 计算。
- 特长
- 特长掉落物品:展示掉落物品图标未配置图标时显示默认物品标记占位符
- 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标未配置时展示空状态
- Trading当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品Likes 表示交易价格 1.5xNeutral 表示无加成,未配置观察时展示空状态;详情页双列列表高度受限,内容较多时每列内部独立滚动,避免 Section 被大量物品无限拉长
- Trading 可在详情页通过 Manage Trading Modal 维护Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换搜索结果优先展示名称精确匹配、名称开头匹配和单词匹配的物品再展示名称包含、分类或用途包含的物品搜索框支持上下键切换当前结果、左右键切换默认加入组、Enter 将当前结果加入默认目标组;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动
- 喜欢的环境
- 喜欢的东西
- 相关 Pokemon与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
@@ -595,6 +619,11 @@ Pokemon 详情页展示:
- 名称
- 介绍
- Base Price可为空
- Ancient Artifact可为空Items Edit 使用单选框维护;`No` 表示普通物品,其他值使用系统固定列表:
- Lost Relics (L)
- Lost Relics (S)
- Fossils
- 是否为 Event Item`is_event_item`
- 分类:必填,使用系统固定列表,不在管理端配置:
- Furniture
@@ -622,6 +651,7 @@ Pokemon 详情页展示:
- 无材料单:`no_recipe`
- 标签:使用喜欢的东西配置,可多选
- 图标图片:通过通用 Wiki 图片上传维护当前图标和历史上传记录
- Data Tools 的 Items CSV 导入可为物品写入静态图标路径 `/pokopia/items/{image_file_name}`;静态图标展示 URL 为 `https://pokesprite.tootaio.com/pokopia/items/{image_file_name}`,用户后续仍可在编辑页切换为社区上传图片
- 翻译
- 排序
@@ -630,6 +660,7 @@ Items 与 Event Items 使用相同数据模型:
- Items 列表只展示 `is_event_item = false` 的物品。
- Event Items 列表只展示 `is_event_item = true` 的物品。
- Event Items 与 Items 共用物品分类、用途、入手方式、标签、图片、翻译和材料单逻辑。
- 已选择 Ancient Artifact 分类的物品仍显示在 Items / Event Items 列表中,并额外进入 Ancient Artifacts 对应分类列表。
物品列表功能:
@@ -638,6 +669,9 @@ Items 与 Event Items 使用相同数据模型:
- 按用途筛选
- 按标签筛选
- 按自定义排序展示
- 公开列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 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`
- 物品列表桌面端使用 12 列紧凑 Grid每个格子只展示物品图标有用途的物品在卡片左上角以斜 Ribbon 展示用途名称;物品名称通过 hover / focus Tooltip 展示。
- 物品列表移动端保持常规卡片布局,展示物品图标、名称和分类。
- 物品列表不展示标签、入手方式或编辑元信息。
@@ -649,11 +683,20 @@ Items 与 Event Items 使用相同数据模型:
- 当前图标图片;未配置图标时展示默认物品标记占位符
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
- 介绍
- Base Price
- Ancient Artifact 分类:仅在物品已配置 Ancient Artifact 分类时展示
- 分类
- 用途
- 入手方式
- 客制化
- 标签
- Possible Tags根据所有拥有支持 Trading 特长的 Pokemon Trading 观察推断该物品可能包含的隐藏标签
- 每个 Pokemon 的“喜欢的东西”视为该 Pokemon 已知的 6 个隐藏标签集合;不完整数据仍参与展示,但不会强行补足缺失标签
- 若物品被 Pokemon 标记为 Likes则该物品至少包含该 Pokemon 标签集合中的一个标签,属于 OR 正向证据
- 若物品被 Pokemon 标记为 Neutral则该物品不包含该 Pokemon 标签集合中的任何标签属于硬排除证据Neutral 排除优先于 Likes 正向证据
- 推断流程必须确定性执行:从所有“喜欢的东西 / 标签”开始,先移除所有 Neutral Pokemon 提供的标签,再用 Likes Pokemon 的标签集合收窄候选;多个 Likes 观察的共同候选归为 Highly likely其余正向候选归为 Possible被排除或被约束移出的标签归为 Excluded
- 没有可用 Likes 观察时,未被 Neutral 排除的标签保持 Possible没有任何观察时所有标签保持 Possible
- Possible Tags 区块必须展示 Likes 与 Neutral 证据来源,包含贡献 Pokemon 及其已知标签,不展示内部字段、调试信息或推断中间状态
- 关联材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符
- 作为材料出现的材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符
- 相关栖息地:展示栖息地图片或默认栖息地标记占位符,并展示配方材料物品图标
@@ -664,12 +707,12 @@ Items 与 Event Items 使用相同数据模型:
## Ancient Artifacts
Ancient Artifacts 是独立 Wiki 内容类型,可配置:
Ancient Artifacts 是 Items 的可选分类视图,不再维护独立主数据结构或独立表;列表、详情和排序从 `items.ancient_artifact_category_key IS NOT NULL` 的物品获取。已配置 Ancient Artifact 分类的物品仍保留在 Items / Event Items 列表中,并额外出现在 Ancient Artifacts 对应分类列表。Ancient Artifact 路由继续保留,用于浏览、编辑和导航对应的物品记录。
- 名称
- 介绍
- 图片:使用 Ancient Artifacts 上传目录,支持图片历史
- 分类:必填,使用系统固定列表,不在管理端配置:
- 图片:使用 Items 编辑器和上传目录,支持图片历史
- 分类:在 Items Edit 的 Ancient Artifact 单选框中维护;`No` 表示不进入 Ancient Artifacts 列表,其他选项使用系统固定列表,不在管理端配置:
- Lost Relics (L)
- Lost Relics (S)
- Fossils
@@ -687,16 +730,7 @@ Ancient Artifacts 列表功能:
- 列表移动端保持常规卡片布局,展示图片 / 默认 Ancient Artifact 标记、名称和分类。
- 列表不展示编辑元信息。
Ancient Artifacts 详情页展示:
- 名称
- 图片;未配置图片时展示默认 Ancient Artifact 标记
- 介绍
- 分类
- 标签
- 最后编辑信息
- 讨论
- 编辑历史
Ancient Artifacts 详情页使用同一套 Item Details 视图展示同一条 `items` 记录顶部、图片、基础信息、Base Price、物品分类、用途、入手方式、客制化、标签、材料单关联、讨论和编辑历史均按物品详情页规则展示并额外展示 Ancient Artifact 分类。通过 `/ancient-artifacts/:id` 打开的普通非 Ancient Artifact 物品会回到对应 `/items/:id`
## 材料单
@@ -970,7 +1004,7 @@ API 暴露边界:
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
- 配置System config。
- 内容Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口以及 Data Tools。
- 内容Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口以及 Data ToolsPokemon 在 Admin 中可删除但不提供手动排序
- 内容管理包含 Items、Event Items 与 Ancient ArtifactsItems / Event Items 使用同一物品数据模型,通过 `is_event_item` 拆分入口。
- 本地化Languages、System wordings。
- 访问权限Users、Roles、Permissions、Rate limits。
@@ -995,6 +1029,7 @@ API 暴露边界:
- `/ancient-artifacts/:id/edit`
- `/recipes/new`
- `/recipes/:id/edit`
- `/ancient-artifacts/new``/ancient-artifacts/:id/edit` 使用 Items 编辑器与 Items create/update 权限;保存的是同一条 `items` 记录。
- Life 使用信息流顶部 New Post / 编辑按钮打开普通 Modal 发布与编辑,不使用路由驱动 Modal。
- 进入或关闭编辑 Modal 时应保留底层页面上下文,不进行不必要的滚动跳转。
- 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。
@@ -1006,8 +1041,8 @@ API 暴露边界:
- `favicon.ico`
- 默认社交分享图
- 品牌 Logo 素材
- `VITE_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_PUBLIC_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`
- 前端 Nuxt app head 配置提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon路由 metadata 与详情页公开业务数据通过 Nuxt `useHead` 更新页面 metadata避免直接操作 `document.head`
- 主要公开浏览入口可索引:
- `/pokemon`
- `/event-pokemon`
@@ -1031,9 +1066,13 @@ API 暴露边界:
## 部署与升级维护
- 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 不可用。
- 升级维护页是基础设施级静态 fallback不依赖 Vue、Vue I18n、后端 API 或数据库;页面只展示正式用户文案和品牌视觉,不展示构建日志、调试信息、内部字段或实现说明。
- 升级维护页使用 `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 概览
@@ -1043,16 +1082,16 @@ API 暴露边界:
- `GET /api/system-wordings`
- `GET /api/options`
- `GET /api/project-updates`:读取站点项目公开更新信息;支持 `cursor` / `limit` 分页读取最近提交;仅返回净化后的仓库、最近提交和发布版本展示字段。
- `GET /api/daily-checklist`
- `GET /api/pokemon`:支持 `isEventItem=true|false` 按普通 Pokemon / Event Pokemon 拆分列表;未传时返回全部 Pokemon 以兼容管理端和实体选择器
- `GET /api/daily-checklist`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容管理端排序。
- `GET /api/pokemon`:支持 `isEventItem=true|false` 按普通 Pokemon / Event Pokemon 拆分列表;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回全部 Pokemon 以兼容管理端和实体选择器
- `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/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;未传时返回全部 Items 以兼容管理端实体选择器
- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;默认返回所有物品,包括已配置 Ancient Artifact 分类的物品;传入 `ancientArtifactCategoryId` 时可额外筛选对应 Ancient Artifact 分类下的物品;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容管理端实体选择器和排序。
- `GET /api/items/:id`
- `GET /api/ancient-artifacts`:支持 `search``categoryId``tagIds` 筛选
- `GET /api/ancient-artifacts`:支持 `search``categoryId``tagIds` 筛选;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
- `GET /api/ancient-artifacts/:id`
- `GET /api/recipes`
- `GET /api/recipes`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
- `GET /api/recipes/:id`
- `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`
@@ -1150,7 +1189,7 @@ API 暴露边界:
- `GET /api/admin/ai-moderation`
- `PUT /api/admin/ai-moderation`
- `PUT /api/admin/system-wordings/:key`
- Pokemon、物品、材料单、栖息地的列表排序需要对应实体的 `order` 权限。
- 物品、材料单、栖息地的列表排序需要对应实体的 `order` 权限Pokemon 按内部 `id` 排序,不提供列表排序 API 或 Admin 手动排序入口
## 开发与验证
@@ -1160,3 +1199,4 @@ API 暴露边界:
- `pnpm typecheck`
- 不在 WSL 中运行测试作为完成任务的前置条件。
- Docker 运行问题以用户提供的 `docker compose up --build` 输出为准进行后续修复。
- 本地热重载调试可运行 `pnpm docker:debug``docker compose -f docker-compose.debug.yml up --build`;生产 SSR runtime 验证仍使用 `pnpm docker:prod``docker compose up --build`

60
SSR_MIGRATION_TASKLIST.md Normal file
View File

@@ -0,0 +1,60 @@
# SSR Migration Remaining Tasks
This temporary file tracks only the work still required before the Nuxt SSR migration can be considered complete.
Delete this file only after all items below are complete and `AGENTS.md` no longer needs the temporary SSR migration workflow.
## Remaining Work
- [ ] Run production Docker validation with `docker compose up --build`.
- [ ] Fix any Docker runtime errors from the production SSR container, frontend gateway, backend API, or SSR server-to-backend API connection.
- [ ] Verify anonymous SSR HTML for public routes contains meaningful public business content and route/detail metadata:
- `/`
- `/pokemon`
- `/event-pokemon`
- `/habitats`
- `/event-habitats`
- `/items`
- `/event-items`
- `/ancient-artifacts`
- `/recipes`
- `/checklist`
- `/dish`
- `/life`
- `/life/:id`
- `/profile/:id`
- `/project-updates`
- [ ] Verify generated HTML, Nuxt payloads, API responses used by SSR, metadata, and logs do not expose password hashes, session token hashes, verification/reset token hashes, private current-user data on public pages, role internals, permission internals, internal audit payloads, debug fields, stack traces, or implementation notes.
- [ ] Verify localized SSR reads and metadata follow the `DESIGN.md` fallback order: requested locale, default-language translation, then base field.
- [ ] Verify auth and permission route behavior with SSR enabled:
- anonymous users redirect from protected routes to login
- unverified users cannot access verified-only write flows
- users missing permissions cannot access permissioned routes
- current-user reads expose only fields allowed by `DESIGN.md`
- [ ] Verify hydrated logged-in flows still work:
- login
- logout
- Remember me
- `/profile`
- notifications
- route-backed create/edit modals
- uploads
- Life comments/reactions
- entity discussion comments
- admin access
- [ ] Verify browser-only UI behavior runs only on the client and remains stable after hydration:
- modal focus and body locking
- dropdown positioning
- scroll/resize listeners
- infinite-scroll sentinels
- clipboard actions
- `window.confirm` actions
- notification WebSocket
- upload file APIs
- [ ] Verify route-backed modal pages preserve underlying page context and avoid unwanted scroll jumps.
- [ ] Verify `robots.txt`, `sitemap.xml`, canonical URLs, `noindex` routes, Open Graph, Twitter card, and public detail metadata in the production runtime.
- [x] Remove legacy SPA-only compatibility paths once SSR behavior is stable.
- [x] Remove obsolete `VITE_*` fallback support after deployment has fully moved to documented `NUXT_*` variables.
- [x] Update `DESIGN.md` if final behavior differs from the current documented SSR deployment, auth, SEO, or environment-variable model.
- [ ] Update `AGENTS.md` to remove the temporary SSR migration workflow and the requirement to read this task list.
- [ ] Delete `SSR_MIGRATION_TASKLIST.md`.

View File

@@ -26,8 +26,6 @@ CREATE TABLE IF NOT EXISTS entity_translations (
'skills',
'environments',
'favorite-things',
'item-categories',
'item-usages',
'acquisition-methods',
'items',
'ancient-artifacts',
@@ -51,41 +49,6 @@ CREATE TABLE IF NOT EXISTS entity_translations (
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
ON entity_translations (entity_type, entity_id, field_name, locale);
ALTER TABLE entity_translations
DROP CONSTRAINT IF EXISTS entity_translations_entity_type_check;
ALTER TABLE entity_translations
ADD CONSTRAINT entity_translations_entity_type_check CHECK (
entity_type IN (
'pokemon',
'pokemon-types',
'skills',
'environments',
'favorite-things',
'item-categories',
'item-usages',
'acquisition-methods',
'items',
'ancient-artifacts',
'maps',
'habitats',
'daily-checklist-items',
'life-tags',
'game-versions',
'dish-categories',
'dish-flavors',
'dishes'
)
);
ALTER TABLE entity_translations
DROP CONSTRAINT IF EXISTS entity_translations_field_name_check;
ALTER TABLE entity_translations
ADD CONSTRAINT entity_translations_field_name_check CHECK (
field_name IN ('name', 'title', 'details', 'genus', 'effect', 'mosslaxEffect')
);
CREATE TABLE IF NOT EXISTS users (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
@@ -201,22 +164,10 @@ CREATE TABLE IF NOT EXISTS ai_moderation_settings (
CHECK (length(model) BETWEEN 1 AND 120)
);
ALTER TABLE ai_moderation_settings
ADD COLUMN IF NOT EXISTS api_format text NOT NULL DEFAULT 'gemini-generate-content' CHECK (api_format IN ('gemini-generate-content', 'openai-chat-completions')),
ADD COLUMN IF NOT EXISTS auth_mode text NOT NULL DEFAULT 'bearer-token' CHECK (auth_mode IN ('query-key', 'bearer-token'));
INSERT INTO ai_moderation_settings (id)
VALUES (true)
ON CONFLICT (id) DO NOTHING;
UPDATE ai_moderation_settings
SET api_format = 'gemini-generate-content',
auth_mode = 'bearer-token',
updated_at = now()
WHERE api_format = 'openai-chat-completions'
AND auth_mode = 'query-key'
AND endpoint ~* '/v1beta/?$';
CREATE TABLE IF NOT EXISTS ai_moderation_cache (
content_hash text NOT NULL,
model text NOT NULL,
@@ -229,9 +180,6 @@ CREATE TABLE IF NOT EXISTS ai_moderation_cache (
CHECK (length(model) BETWEEN 1 AND 120)
);
ALTER TABLE ai_moderation_cache
ADD COLUMN IF NOT EXISTS reason text;
CREATE TABLE IF NOT EXISTS rate_limit_settings (
id boolean PRIMARY KEY DEFAULT true CHECK (id = true),
settings jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(settings) = 'object'),
@@ -283,7 +231,6 @@ VALUES
('pokemon.create', 'Create Pokemon', 'Create Pokemon records.', 'Pokemon', true),
('pokemon.update', 'Update Pokemon', 'Edit Pokemon records.', 'Pokemon', true),
('pokemon.delete', 'Delete Pokemon', 'Delete Pokemon records.', 'Pokemon', true),
('pokemon.order', 'Order Pokemon', 'Reorder Pokemon records.', 'Pokemon', true),
('pokemon.fetch', 'Fetch Pokemon data', 'Fetch Pokemon data and sprite candidates.', 'Pokemon', true),
('pokemon.upload', 'Upload Pokemon images', 'Upload Pokemon images.', 'Pokemon', true),
('habitats.create', 'Create habitats', 'Create habitat records.', 'Habitats', true),
@@ -327,6 +274,9 @@ VALUES
('discussions.comments.like', 'Like discussion comments', 'Like and unlike entity discussion comments.', 'Discussions', true)
ON CONFLICT (key) DO NOTHING;
DELETE FROM permissions
WHERE key = 'pokemon.order';
INSERT INTO roles (key, name, description, level, enabled, system_role)
VALUES
('owner', 'Owner', 'Highest-level system owner with all permissions.', 1000, true, true),
@@ -381,7 +331,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'pokemon.create',
'pokemon.update',
'pokemon.delete',
'pokemon.order',
'pokemon.fetch',
'pokemon.upload',
'habitats.create',
@@ -463,7 +412,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'checklist.order',
'pokemon.create',
'pokemon.update',
'pokemon.order',
'pokemon.fetch',
'pokemon.upload',
'habitats.create',
@@ -861,6 +809,7 @@ CREATE TABLE IF NOT EXISTS skills (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
has_item_drop boolean NOT NULL DEFAULT false,
has_trading boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
@@ -918,10 +867,6 @@ CREATE TABLE IF NOT EXISTS pokemon (
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE pokemon
ADD COLUMN IF NOT EXISTS data_id integer CHECK (data_id > 0),
ADD COLUMN IF NOT EXISTS data_identifier text NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS pokemon_pokemon_types (
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE,
@@ -942,26 +887,6 @@ CREATE TABLE IF NOT EXISTS pokemon_favorite_things (
PRIMARY KEY (pokemon_id, favorite_thing_id)
);
CREATE TABLE IF NOT EXISTS item_categories (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS item_usages (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS acquisition_methods (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
@@ -976,10 +901,10 @@ CREATE TABLE IF NOT EXISTS items (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
details text NOT NULL DEFAULT '',
base_price integer,
ancient_artifact_category_key text,
category_key text NOT NULL DEFAULT 'other',
usage_key text,
category_id integer REFERENCES item_categories(id),
usage_id integer REFERENCES item_usages(id),
dyeable boolean NOT NULL DEFAULT false,
dual_dyeable boolean NOT NULL DEFAULT false,
pattern_editable boolean NOT NULL DEFAULT false,
@@ -1005,22 +930,13 @@ CREATE TABLE IF NOT EXISTS items (
'key-items',
'other'
)),
CHECK (
ancient_artifact_category_key IS NULL
OR ancient_artifact_category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils')
),
CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'))
);
CREATE TABLE IF NOT EXISTS ancient_artifacts (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
details text NOT NULL DEFAULT '',
category_key text NOT NULL CHECK (category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils')),
image_path text NOT NULL DEFAULT '',
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS recipes (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
item_id integer NOT NULL UNIQUE REFERENCES items(id),
@@ -1049,12 +965,16 @@ CREATE TABLE IF NOT EXISTS item_favorite_things (
PRIMARY KEY (item_id, favorite_thing_id)
);
CREATE TABLE IF NOT EXISTS ancient_artifact_favorite_things (
ancient_artifact_id integer NOT NULL REFERENCES ancient_artifacts(id) ON DELETE CASCADE,
favorite_thing_id integer NOT NULL REFERENCES favorite_things(id),
PRIMARY KEY (ancient_artifact_id, favorite_thing_id)
CREATE TABLE IF NOT EXISTS pokemon_trading_items (
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
preference text NOT NULL CHECK (preference IN ('like', 'neutral')),
PRIMARY KEY (pokemon_id, item_id)
);
CREATE INDEX IF NOT EXISTS pokemon_trading_items_item_idx
ON pokemon_trading_items(item_id, preference, pokemon_id);
CREATE TABLE IF NOT EXISTS pokemon_skill_item_drops (
pokemon_id integer NOT NULL,
skill_id integer NOT NULL,
@@ -1084,58 +1004,6 @@ CREATE TABLE IF NOT EXISTS dish_categories (
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE dish_categories
ADD COLUMN IF NOT EXISTS main_material_item_id integer REFERENCES items(id);
ALTER TABLE dish_categories
ADD COLUMN IF NOT EXISTS total_material_quantity integer NOT NULL DEFAULT 2;
DO $$
BEGIN
IF to_regclass('public.dishes') IS NOT NULL
AND EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'dishes'
AND column_name = 'main_material_item_id'
)
THEN
EXECUTE '
UPDATE dish_categories dc
SET main_material_item_id = source.main_material_item_id
FROM (
SELECT DISTINCT ON (category_id) category_id, main_material_item_id
FROM dishes
WHERE main_material_item_id IS NOT NULL
ORDER BY category_id, sort_order, id
) AS source
WHERE dc.id = source.category_id
AND dc.main_material_item_id IS NULL
';
END IF;
END $$;
UPDATE dish_categories
SET main_material_item_id = cookware_item_id
WHERE main_material_item_id IS NULL;
ALTER TABLE dish_categories
ALTER COLUMN main_material_item_id SET NOT NULL;
ALTER TABLE dish_categories
ALTER COLUMN total_material_quantity SET DEFAULT 2;
UPDATE dish_categories
SET total_material_quantity = 2
WHERE total_material_quantity < 2;
ALTER TABLE dish_categories
DROP CONSTRAINT IF EXISTS dish_categories_total_material_quantity_check;
ALTER TABLE dish_categories
ADD CONSTRAINT dish_categories_total_material_quantity_check CHECK (total_material_quantity >= 2);
CREATE TABLE IF NOT EXISTS dish_flavors (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
@@ -1167,15 +1035,6 @@ CREATE TABLE IF NOT EXISTS dishes (
)
);
ALTER TABLE dishes
ADD COLUMN IF NOT EXISTS flavor_id integer REFERENCES dish_flavors(id);
ALTER TABLE dishes
ALTER COLUMN secondary_material_1_item_id DROP NOT NULL;
ALTER TABLE dishes
DROP COLUMN IF EXISTS main_material_item_id;
CREATE TABLE IF NOT EXISTS maps (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
@@ -1215,126 +1074,6 @@ CREATE TABLE IF NOT EXISTS habitat_pokemon (
PRIMARY KEY (habitat_id, pokemon_id, map_id, time_of_day, weather)
);
ALTER TABLE life_tags
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
ALTER TABLE items
ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS category_key text,
ADD COLUMN IF NOT EXISTS usage_key text;
ALTER TABLE ancient_artifacts
ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT '';
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'items'
AND column_name = 'category_id'
AND table_schema = current_schema()
) THEN
ALTER TABLE items ALTER COLUMN category_id DROP NOT NULL;
END IF;
END $$;
UPDATE items i
SET category_key = CASE lower(trim(c.name))
WHEN 'furniture' THEN 'furniture'
WHEN 'misc' THEN 'misc'
WHEN 'outdoor' THEN 'outdoor'
WHEN 'utilities' THEN 'utilities'
WHEN 'buildings' THEN 'buildings'
WHEN 'blocks' THEN 'blocks'
WHEN 'kits' THEN 'kits'
WHEN 'nature' THEN 'nature'
WHEN 'food' THEN 'food'
WHEN 'materials' THEN 'materials'
WHEN 'key items' THEN 'key-items'
WHEN 'key-items' THEN 'key-items'
WHEN 'other' THEN 'other'
ELSE 'other'
END
FROM item_categories c
WHERE i.category_id = c.id
AND (i.category_key IS NULL OR i.category_key = '');
UPDATE items i
SET usage_key = CASE lower(trim(u.name))
WHEN 'decoration' THEN 'decoration'
WHEN 'relaxation' THEN 'relaxation'
WHEN 'toy' THEN 'toy'
WHEN 'road' THEN 'road'
ELSE NULL
END
FROM item_usages u
WHERE i.usage_id = u.id
AND i.usage_key IS NULL;
UPDATE items
SET category_key = 'other'
WHERE category_key IS NULL
OR category_key NOT IN (
'furniture',
'misc',
'outdoor',
'utilities',
'buildings',
'blocks',
'kits',
'nature',
'food',
'materials',
'key-items',
'other'
);
UPDATE items
SET usage_key = NULL
WHERE usage_key IS NOT NULL
AND usage_key NOT IN ('decoration', 'relaxation', 'toy', 'road');
ALTER TABLE items
ALTER COLUMN category_key SET NOT NULL,
ALTER COLUMN category_key SET DEFAULT 'other';
ALTER TABLE items
DROP CONSTRAINT IF EXISTS items_display_id_positive,
DROP CONSTRAINT IF EXISTS items_category_key_check,
DROP CONSTRAINT IF EXISTS items_usage_key_check;
ALTER TABLE items
ADD CONSTRAINT items_category_key_check CHECK (category_key IN (
'furniture',
'misc',
'outdoor',
'utilities',
'buildings',
'blocks',
'kits',
'nature',
'food',
'materials',
'key-items',
'other'
)),
ADD CONSTRAINT items_usage_key_check CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'));
DROP INDEX IF EXISTS items_display_event_item_key;
DROP INDEX IF EXISTS items_display_order_idx;
DROP INDEX IF EXISTS ancient_artifacts_display_order_idx;
ALTER TABLE ancient_artifacts
DROP CONSTRAINT IF EXISTS ancient_artifacts_display_id_key,
DROP CONSTRAINT IF EXISTS ancient_artifacts_display_id_check;
ALTER TABLE items
DROP COLUMN IF EXISTS display_id;
ALTER TABLE ancient_artifacts
DROP COLUMN IF EXISTS display_id;
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
@@ -1343,11 +1082,8 @@ CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item);
CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id);
CREATE UNIQUE INDEX IF NOT EXISTS life_tags_single_default_idx ON life_tags(is_default) WHERE is_default = true;
CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sort_order, id);
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id);
CREATE INDEX IF NOT EXISTS ancient_artifacts_sort_order_idx ON ancient_artifacts(sort_order, id);
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
CREATE INDEX IF NOT EXISTS dish_categories_sort_order_idx ON dish_categories(sort_order, id);
CREATE INDEX IF NOT EXISTS dish_flavors_sort_order_idx ON dish_flavors(sort_order, id);
@@ -1387,14 +1123,6 @@ CREATE TABLE IF NOT EXISTS entity_image_uploads (
CHECK (path !~ '(^/|\\.\\.)')
);
ALTER TABLE entity_image_uploads
DROP CONSTRAINT IF EXISTS entity_image_uploads_entity_type_check;
ALTER TABLE entity_image_uploads
ADD CONSTRAINT entity_image_uploads_entity_type_check CHECK (
entity_type IN ('pokemon', 'items', 'habitats', 'ancient-artifacts')
);
CREATE INDEX IF NOT EXISTS entity_image_uploads_entity_idx
ON entity_image_uploads(entity_type, entity_id, created_at DESC, id DESC);
@@ -1443,14 +1171,6 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_comment_idx
CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_user_idx
ON entity_discussion_comment_likes(user_id, created_at DESC, comment_id DESC);
ALTER TABLE entity_discussion_comments
DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check;
ALTER TABLE entity_discussion_comments
ADD CONSTRAINT entity_discussion_comments_entity_type_check CHECK (
entity_type IN ('pokemon', 'items', 'recipes', 'habitats', 'ancient-artifacts')
);
CREATE TABLE IF NOT EXISTS notifications (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
recipient_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -1506,9 +1226,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS notifications_life_post_reaction_unique_idx
ON notifications(recipient_user_id, actor_user_id, life_post_id)
WHERE type = 'life_post_reaction' AND actor_user_id IS NOT NULL AND life_post_id IS NOT NULL;
ALTER TABLE notifications
ADD COLUMN IF NOT EXISTS profile_user_id integer REFERENCES users(id) ON DELETE CASCADE;
CREATE UNIQUE INDEX IF NOT EXISTS notifications_user_follow_unique_idx
ON notifications(recipient_user_id, actor_user_id, profile_user_id)
WHERE type = 'user_follow' AND actor_user_id IS NOT NULL AND profile_user_id IS NOT NULL;
@@ -1525,66 +1242,6 @@ CREATE TABLE IF NOT EXISTS notification_ws_tickets (
CREATE INDEX IF NOT EXISTS notification_ws_tickets_user_idx
ON notification_ws_tickets(user_id, expires_at DESC);
ALTER TABLE notifications
ADD COLUMN IF NOT EXISTS moderation_reason text;
ALTER TABLE notifications
ADD COLUMN IF NOT EXISTS profile_user_id integer REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE notifications
DROP CONSTRAINT IF EXISTS notifications_type_check;
ALTER TABLE notifications
ADD CONSTRAINT notifications_type_check CHECK (
type IN (
'life_post_comment',
'life_comment_reply',
'discussion_comment_reply',
'life_post_reaction',
'user_follow',
'moderation_result'
)
);
ALTER TABLE life_tags
ADD COLUMN IF NOT EXISTS is_rateable boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS game_versions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
change_log text NOT NULL DEFAULT '',
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS game_versions_sort_order_idx
ON game_versions(sort_order, id);
ALTER TABLE life_posts
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
ADD COLUMN IF NOT EXISTS category_id integer REFERENCES life_tags(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS game_version_id integer REFERENCES game_versions(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS ai_moderation_reason text,
ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text,
ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz,
ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now();
UPDATE life_posts lp
SET category_id = selected.tag_id
FROM (
SELECT DISTINCT ON (lpt.post_id) lpt.post_id, lpt.tag_id
FROM life_post_tags lpt
JOIN life_tags lt ON lt.id = lpt.tag_id
ORDER BY lpt.post_id, lt.sort_order, lt.id
) selected
WHERE lp.id = selected.post_id
AND lp.category_id IS NULL;
CREATE INDEX IF NOT EXISTS life_posts_category_idx
ON life_posts(category_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;
@@ -1593,39 +1250,6 @@ CREATE INDEX IF NOT EXISTS life_posts_game_version_idx
ON life_posts(game_version_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS life_post_ratings (
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rating integer NOT NULL CHECK (rating BETWEEN 1 AND 5),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (post_id, user_id)
);
CREATE INDEX IF NOT EXISTS life_post_ratings_post_idx
ON life_post_ratings(post_id, rating);
CREATE INDEX IF NOT EXISTS life_post_ratings_user_idx
ON life_post_ratings(user_id, updated_at DESC, post_id DESC);
ALTER TABLE life_post_comments
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS ai_moderation_reason text,
ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text,
ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz,
ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE entity_discussion_comments
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS ai_moderation_reason text,
ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text,
ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz,
ADD COLUMN IF NOT EXISTS ai_moderation_retry_count integer NOT NULL DEFAULT 0 CHECK (ai_moderation_retry_count >= 0),
ADD COLUMN IF NOT EXISTS ai_moderation_updated_at timestamptz NOT NULL DEFAULT now();
CREATE INDEX IF NOT EXISTS life_posts_ai_moderation_status_idx
ON life_posts(ai_moderation_status, ai_moderation_updated_at, id)
WHERE deleted_at IS NULL;
@@ -1649,3 +1273,6 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_status_idx
CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_language_idx
ON entity_discussion_comments(entity_type, entity_id, ai_moderation_language_code, created_at, id)
WHERE deleted_at IS NULL;
ALTER TABLE skills
ADD COLUMN IF NOT EXISTS has_trading boolean NOT NULL DEFAULT false;

File diff suppressed because it is too large Load Diff

View File

@@ -83,6 +83,8 @@ import {
getRecipe,
globalSearch,
importAdminData,
importAdminHabitatsCsv,
importAdminItemsCsv,
isConfigType,
listAncientArtifacts,
listEntityDiscussionComments,
@@ -109,7 +111,6 @@ import {
reorderHabitats,
reorderItems,
reorderLanguages,
reorderPokemon,
reorderRecipes,
retryEntityDiscussionCommentModeration,
retryLifeCommentModeration,
@@ -164,6 +165,9 @@ const app = Fastify({
logger: true,
trustProxy: process.env.TRUST_PROXY === 'true'
});
const sessionCookieName = 'pokopia_session';
const rememberedSessionDays = 30;
const sessionOnlySessionDays = 1;
function configuredCorsOrigin(): true | string | string[] {
const rawOrigin = process.env.FRONTEND_ORIGIN?.trim();
@@ -180,8 +184,9 @@ function configuredCorsOrigin(): true | string | string[] {
}
await app.register(cors, {
allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'X-Locale'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
origin: configuredCorsOrigin()
});
@@ -241,9 +246,52 @@ app.get('/api/search', async (request) =>
globalSearch(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
function getBearerToken(authorization: string | undefined): string | null {
const [scheme, token] = authorization?.split(' ') ?? [];
return scheme === 'Bearer' && token ? token : null;
function getCookieValue(cookieHeader: string | undefined, name: string): string | null {
if (!cookieHeader) {
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 {
@@ -866,7 +914,7 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
return null;
}
const token = getBearerToken(request.headers.authorization);
const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null;
const locale = requestLocale(request);
@@ -948,7 +996,7 @@ async function requireAnyPermissionWithRateLimits(
}
async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
const token = getBearerToken(request.headers.authorization);
const token = getSessionToken(request);
if (!token) {
return null;
}
@@ -981,7 +1029,10 @@ app.post('/api/auth/login', async (request, reply) => {
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) => {
@@ -1005,7 +1056,7 @@ app.get('/api/auth/me', async (request, reply) => {
return;
}
const token = getBearerToken(request.headers.authorization);
const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null;
if (!user) {
@@ -1020,7 +1071,7 @@ app.patch('/api/auth/me', async (request, reply) => {
return;
}
const token = getBearerToken(request.headers.authorization);
const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null;
if (!user) {
@@ -1040,7 +1091,7 @@ app.patch('/api/auth/me/password', async (request, reply) => {
return;
}
const token = getBearerToken(request.headers.authorization);
const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null;
if (!user || !token) {
@@ -1060,7 +1111,7 @@ app.get('/api/auth/referral', async (request, reply) => {
return;
}
const token = getBearerToken(request.headers.authorization);
const token = getSessionToken(request);
const user = token ? await getUserBySessionToken(token) : null;
if (!user) {
@@ -1097,11 +1148,12 @@ app.post('/api/notifications/:id/read', async (request, reply) => {
});
app.post('/api/auth/logout', async (request, reply) => {
const token = getBearerToken(request.headers.authorization);
const token = getSessionToken(request);
if (token) {
await logoutSession(token);
}
clearSessionCookie(reply);
return reply.code(204).send();
});
@@ -1196,7 +1248,9 @@ app.get('/api/project-updates', async (request) =>
getProjectUpdates(request.query as Record<string, string | string[] | undefined>)
);
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) => {
const { id } = request.params as { id: string };
@@ -1796,9 +1850,21 @@ app.get('/api/items/:id', async (request, reply) => {
app.post('/api/items', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'items.create', 'wikiWrite');
return user
? reply.code(201).send(await createItem(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
if (!user) {
return undefined;
}
const payload = request.body as Record<string, unknown>;
const hasInsertAnchor =
(payload.insertBeforeItemId !== undefined && payload.insertBeforeItemId !== null && payload.insertBeforeItemId !== '') ||
(payload.insertAfterItemId !== undefined && payload.insertAfterItemId !== null && payload.insertAfterItemId !== '');
if (hasInsertAnchor && !userHasPermission(user, 'items.order')) {
reply.code(403).send({ message: await serverMessage(requestLocale(request), 'permissionDenied') });
return undefined;
}
return reply.code(201).send(await createItem(payload, user.id, requestLocale(request)));
});
app.put('/api/items/:id', async (request, reply) => {
@@ -2025,11 +2091,6 @@ app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
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) => {
const user = await requirePermissionWithRateLimits(request, reply, 'items.order', 'wikiWrite');
return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
@@ -2139,6 +2200,16 @@ app.post('/api/admin/data-tools/import', async (request, reply) => {
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) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;

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

@@ -40,10 +40,14 @@ services:
context: .
dockerfile: frontend/Dockerfile
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:20016}
VITE_SITE_URL: ${VITE_SITE_URL:-https://pokopiawiki.tootaio.com}
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}
environment:
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:
- "20015"
depends_on:

View File

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

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import AppShell from './components/AppShell.vue';
import AppShell from './src/components/AppShell.vue';
import {
iconAction,
iconAdmin,
@@ -20,12 +19,11 @@ import {
iconPokemon,
iconRecipe,
type AppIcon
} from './icons';
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './i18n';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
} from './src/icons';
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
import { api, notifyAuthChange, onAuthChange, type AuthUser, type Language } from './src/services/api';
const { t, locale } = useI18n();
const router = useRouter();
const currentUser = ref<AuthUser | null>(null);
const languages = ref<Language[]>([
@@ -114,17 +112,11 @@ const navItems = computed<NavItem[]>(() => {
});
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
setAuthToken(null);
}
}
@@ -136,7 +128,7 @@ async function logout() {
}
currentUser.value = null;
setAuthToken(null);
notifyAuthChange();
await router.push('/');
}
@@ -165,7 +157,7 @@ async function updateLocale(value: string) {
onMounted(() => {
void loadLanguages();
void loadCurrentUser();
removeAuthListener = onAuthTokenChange(() => {
removeAuthListener = onAuthChange(() => {
void loadCurrentUser();
});
removeLocaleListener = onLocaleChange(() => {
@@ -188,6 +180,6 @@ onUnmounted(() => {
@logout="logout"
@update:locale="updateLocale"
>
<RouterView :key="locale" />
<NuxtPage :key="locale" />
</AppShell>
</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",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 20015",
"build": "vue-tsc --noEmit && vite build",
"lint": "vue-tsc --noEmit",
"typecheck": "vue-tsc --noEmit",
"dev": "nuxt dev --host 0.0.0.0 --port 20015",
"build": "nuxt build",
"lint": "nuxt typecheck",
"typecheck": "nuxt typecheck",
"test": "vitest run"
},
"dependencies": {
"@iconify/vue": "5.0.0",
"@vitejs/plugin-vue": "6.0.6",
"vite": "8.0.10",
"nuxt": "4.4.4",
"vue": "3.5.33",
"vue-i18n": "11.4.0",
"vue-router": "5.0.6"
@@ -22,6 +21,7 @@
"devDependencies": {
"@types/node": "25.6.0",
"@vue/tsconfig": "0.9.1",
"postcss": "8.5.13",
"typescript": "6.0.3",
"vitest": "4.1.5",
"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,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,76 @@
import { resolvedSeoHead, resolveSeo, type SeoConfig } from '../src/seo';
import { api } from '../src/services/api';
export default defineNuxtPlugin(async () => {
const route = useRoute();
const routeId = typeof route.params.id === 'string' && route.params.id.trim() !== '' ? route.params.id : null;
if (!routeId || typeof route.name !== 'string') {
return;
}
const nuxtApp = useNuxtApp();
const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
const seo = await detailSeo(String(route.name), routeId, t);
if (seo) {
useHead(resolvedSeoHead(resolveSeo(seo)));
}
});
async function detailSeo(
routeName: string,
routeId: string,
t: (key: string, values?: Record<string, string | number>) => string
): Promise<SeoConfig | null> {
try {
if (routeName === 'pokemon-detail') {
const pokemon = await api.pokemonDetail(routeId);
return {
title: `${pokemon.name} - ${t(pokemon.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`,
description: t('seo.pokemonDetailDescription', { name: pokemon.name }),
canonicalPath: `/pokemon/${pokemon.id}`,
image: pokemon.image?.url
};
}
if (routeName === 'habitat-detail') {
const habitat = await api.habitatDetail(routeId);
return {
title: `${habitat.name} - ${t(habitat.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`,
description: t('seo.habitatDetailDescription', { name: habitat.name }),
canonicalPath: `/habitats/${habitat.id}`,
image: habitat.image?.url
};
}
if (routeName === 'item-detail' || routeName === 'ancient-artifact-detail') {
const item = await api.itemDetail(routeId);
const ancientArtifactRoute = routeName === 'ancient-artifact-detail';
if (ancientArtifactRoute && !item.ancientArtifactCategory) {
return null;
}
const titleKey = ancientArtifactRoute ? 'pages.ancientArtifacts.title' : item.isEventItem ? 'pages.eventItems.title' : 'pages.items.title';
const descriptionKey = ancientArtifactRoute ? 'seo.ancientArtifactDetailDescription' : 'seo.itemDetailDescription';
return {
title: `${item.name} - ${t(titleKey)}`,
description: t(descriptionKey, { name: item.name }),
canonicalPath: ancientArtifactRoute ? `/ancient-artifacts/${item.id}` : `/items/${item.id}`,
image: item.image?.url
};
}
if (routeName === 'recipe-detail') {
const recipe = await api.recipeDetail(routeId);
return {
title: `${recipe.name} - ${t('pages.recipes.title')}`,
description: t('seo.recipeDetailDescription', { name: recipe.name }),
canonicalPath: `/recipes/${recipe.id}`,
image: recipe.item.image?.url
};
}
} catch {
return null;
}
return null;
}

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

View File

@@ -0,0 +1,73 @@
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
const sitemapPaths = [
'/',
'/pokemon',
'/event-pokemon',
'/habitats',
'/event-habitats',
'/items',
'/event-items',
'/ancient-artifacts',
'/recipes',
'/dish',
'/checklist',
'/life',
'/project-updates',
'/privacy-policy',
'/terms-of-service',
'/disclaimers'
];
const robotsDisallowPaths = [
'/admin',
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/verify-email',
'/pokemon/new',
'/event-pokemon/new',
'/pokemon/*/edit',
'/habitats/new',
'/event-habitats/new',
'/habitats/*/edit',
'/items/new',
'/event-items/new',
'/items/*/edit',
'/ancient-artifacts/new',
'/ancient-artifacts/*/edit',
'/recipes/new',
'/recipes/*/edit',
'/automation',
'/events',
'/actions',
'/dream-island',
'/clothes'
];
export function normalizeSiteUrl(value: unknown): string {
return (typeof value === 'string' && value.trim() ? value.trim() : fallbackSiteUrl).replace(/\/+$/, '');
}
export function robotsTxt(siteUrl: string): string {
const disallowLines = robotsDisallowPaths.map((path) => `Disallow: ${path}`).join('\n');
return `User-agent: *\nAllow: /\n${disallowLines}\nSitemap: ${siteUrl}/sitemap.xml\n`;
}
export function sitemapXml(siteUrl: string): string {
const urls = sitemapPaths
.map(
(path) => ` <url>
<loc>${siteUrl}${path}</loc>
<changefreq>weekly</changefreq>
</url>`
)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>
`;
}

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,8 +1,9 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import EditMeta from './EditMeta.vue';
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
defineProps<{
const props = defineProps<{
entity: EditInfo;
history: EditHistoryEntry[];
}>();
@@ -45,10 +46,15 @@ const changeLabelKeys: Record<string, string> = {
'Speciality drops': 'pages.pokemon.skillDrops',
'Skill drops': 'pages.pokemon.skillDrops',
特长掉落物: 'pages.pokemon.skillDrops',
Trading: 'pages.pokemon.trading',
'Trading items': 'pages.pokemon.tradingItems',
Category: 'pages.items.category',
分类: 'pages.items.category',
Usage: 'pages.items.usage',
用途: 'pages.items.usage',
'Base Price': 'pages.items.basePrice',
'Base price': 'pages.items.basePrice',
基础价格: 'pages.items.basePrice',
Dyeable: 'pages.items.dyeable',
可染色: 'pages.items.dyeable',
'Dual dyeable': 'pages.items.dualDyeable',
@@ -73,6 +79,8 @@ const changeLabelKeys: Record<string, string> = {
排序: 'pages.admin.sortOrder',
'Has item drop': 'pages.admin.hasItemDrop',
有掉落物: 'pages.admin.hasItemDrop',
'Has trading': 'pages.admin.hasTrading',
'有 Trading': 'pages.admin.hasTrading',
'Default category': 'pages.admin.defaultCategory',
默认分类: 'pages.admin.defaultCategory',
Rateable: 'pages.admin.rateableCategory',
@@ -118,7 +126,11 @@ function changeValue(value: string): string {
}
function visibleChanges(entry: EditHistoryEntry) {
return entry.changes.filter((change) => change.label !== 'Display ID');
return entry.changes.filter((change) => change.label !== 'Display ID' && change.label !== 'Sort order' && change.label !== '排序');
}
function visibleHistoryEntries() {
return props.history.filter((entry) => entry.action !== 'update' || visibleChanges(entry).length > 0);
}
function historySummary(entry: EditHistoryEntry): string {
@@ -148,29 +160,25 @@ function formatDateTime(value: string): string {
<div>
<dt>{{ t('history.createdBy') }}</dt>
<dd>
<RouterLink v-if="entity.createdBy" class="user-profile-link" :to="`/profile/${entity.createdBy.id}`">
{{ entity.createdBy.displayName }}
<RouterLink v-if="props.entity.createdBy" class="user-profile-link" :to="`/profile/${props.entity.createdBy.id}`">
{{ props.entity.createdBy.displayName }}
</RouterLink>
<strong v-else>{{ displayName(entity.createdBy) }}</strong>
<time :datetime="entity.createdAt">{{ formatDateTime(entity.createdAt) }}</time>
<strong v-else>{{ displayName(props.entity.createdBy) }}</strong>
<time :datetime="props.entity.createdAt">{{ formatDateTime(props.entity.createdAt) }}</time>
</dd>
</div>
<div>
<dt>{{ t('history.lastEdited') }}</dt>
<dd>
<RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`">
{{ entity.updatedBy.displayName }}
</RouterLink>
<strong v-else>{{ displayName(entity.updatedBy) }}</strong>
<time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
<EditMeta :entity="props.entity" :show-label="false" />
</dd>
</div>
</dl>
<section class="edit-history-list" aria-labelledby="edit-history-list-title">
<h3 id="edit-history-list-title">{{ t('history.editHistory') }}</h3>
<ol v-if="history.length" class="edit-timeline">
<li v-for="entry in history" :key="`${entry.action}-${entry.createdAt}-${entry.user?.id ?? 'system'}`">
<ol v-if="visibleHistoryEntries().length" class="edit-timeline">
<li v-for="entry in visibleHistoryEntries()" :key="`${entry.action}-${entry.createdAt}-${entry.user?.id ?? 'system'}`">
<span class="edit-timeline__avatar" aria-hidden="true">{{ actionMark(entry.action) }}</span>
<div class="edit-timeline__body">
<details class="edit-history-entry">

View File

@@ -2,9 +2,15 @@
import { useI18n } from 'vue-i18n';
import type { EditInfo } from '../services/api';
defineProps<{
entity: EditInfo;
}>();
withDefaults(
defineProps<{
entity: EditInfo;
showLabel?: boolean;
}>(),
{
showLabel: true
}
);
const { locale, t } = useI18n();
@@ -18,11 +24,11 @@ function formatDateTime(value: string): string {
<template>
<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}`">
{{ entity.updatedBy.displayName }}
</RouterLink>
<span v-else>{{ t('common.system') }}</span>
/ {{ formatDateTime(entity.updatedAt) }}
/ <time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
</p>
</template>

View File

@@ -2,15 +2,15 @@
import { Icon } from '@iconify/vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import ConfirmDialog from './ConfirmDialog.vue';
import LoadMoreSentinel from './LoadMoreSentinel.vue';
import StatusBadge from './StatusBadge.vue';
import Tabs, { type TabOption } from './Tabs.vue';
import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons';
import {
api,
getAuthToken,
moderationUpdateEvent,
onAuthTokenChange,
setAuthToken,
onAuthChange,
type AiModerationStatus,
type AuthUser,
type CommentSort,
@@ -53,6 +53,8 @@ let removeAuthListener: (() => void) | null = null;
const nextCursor = ref<string | null>(null);
const hasMoreComments = ref(false);
const commentTotal = ref(0);
const pendingDeleteComment = ref<EntityDiscussionComment | null>(null);
const deleteConfirmBusy = ref(false);
function can(permissionKey: string) {
return currentUser.value?.permissions.includes(permissionKey) === true;
@@ -76,18 +78,11 @@ const sortOptions = computed<Array<{ value: CommentSort; label: string }>>(() =>
async function loadCurrentUser() {
authReady.value = false;
if (!getAuthToken()) {
currentUser.value = null;
authReady.value = true;
return;
}
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
setAuthToken(null);
} finally {
authReady.value = true;
}
@@ -470,11 +465,34 @@ function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolea
return false;
}
async function deleteComment(comment: EntityDiscussionComment) {
if (!window.confirm(t('discussion.deleteConfirm'))) {
function requestDeleteComment(comment: EntityDiscussionComment) {
pendingDeleteComment.value = comment;
}
function closeDeleteConfirm() {
if (deleteConfirmBusy.value) {
return;
}
pendingDeleteComment.value = null;
}
async function confirmDeleteComment() {
const comment = pendingDeleteComment.value;
if (!comment) {
return;
}
deleteConfirmBusy.value = true;
try {
await deleteComment(comment);
pendingDeleteComment.value = null;
} finally {
deleteConfirmBusy.value = false;
}
}
async function deleteComment(comment: EntityDiscussionComment) {
const key = commentKey(comment.id);
clearCommentError(key);
@@ -514,7 +532,7 @@ onMounted(() => {
void loadCurrentUser();
void loadLanguages();
void loadDiscussion();
removeAuthListener = onAuthTokenChange(() => {
removeAuthListener = onAuthChange(() => {
void loadCurrentUser();
});
});
@@ -656,7 +674,7 @@ onUnmounted(() => {
class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button"
:aria-label="t('discussion.deleteComment')"
@click="deleteComment(comment)"
@click="requestDeleteComment(comment)"
>
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<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"
type="button"
:aria-label="t('discussion.deleteComment')"
@click="deleteComment(reply)"
@click="requestDeleteComment(reply)"
>
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
@@ -776,17 +794,7 @@ onUnmounted(() => {
</div>
</article>
<div v-if="hasMoreComments" class="life-feed__retry">
<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>
<LoadMoreSentinel :active="hasMoreComments" :disabled="loading || loadingMore" @load="loadDiscussion(false)" />
</div>
<div v-else class="entity-discussion-empty">
@@ -796,5 +804,17 @@ onUnmounted(() => {
<p>{{ t('discussion.emptyHint') }}</p>
</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>
</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">
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';
const props = withDefaults(
@@ -25,7 +29,7 @@ const emit = defineEmits<{
close: [];
}>();
const titleId = `modal-title-${Math.random().toString(36).slice(2)}`;
const titleId = useId();
const dialog = ref<HTMLElement | null>(null);
const modalBody = ref<HTMLElement | null>(null);
const closeButton = ref<HTMLButtonElement | null>(null);
@@ -54,11 +58,15 @@ const bodyFallbackSelector = [
].join(',');
function lockPage() {
openModalCount += 1;
document.body.classList.add('lock-scroll');
}
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() {

View File

@@ -17,7 +17,6 @@ import {
} from '../icons';
import {
api,
getAuthToken,
moderationUpdateEvent,
notificationWebSocketUrl,
type AuthUser,
@@ -92,7 +91,7 @@ function disconnectNotifications() {
function scheduleReconnect() {
clearReconnectTimer();
if (stopped || !props.currentUser || !getAuthToken()) {
if (stopped || !props.currentUser) {
return;
}
@@ -118,7 +117,7 @@ function isNotificationWsMessage(value: unknown): value is NotificationWsMessage
}
async function connectNotifications() {
if (!props.currentUser || !getAuthToken() || typeof WebSocket === 'undefined') {
if (!props.currentUser || typeof WebSocket === 'undefined') {
return;
}

View File

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

View File

@@ -33,12 +33,14 @@ const props = withDefaults(
creating?: boolean;
createLabel?: string;
dropdownStrategy?: DropdownStrategy;
clearable?: boolean;
}>(),
{
multiple: true,
max: 0,
allowCreate: false,
creating: false
creating: false,
clearable: false
}
);
@@ -167,6 +169,12 @@ function updateValue(values: string[]) {
function selectOption(value: string) {
if (!props.multiple) {
if (props.clearable && selectedValues.value.has(value)) {
updateValue([]);
closeDropdown();
return;
}
updateValue([value]);
closeDropdown();
return;

View File

@@ -2,7 +2,8 @@ import { createI18n } from 'vue-i18n';
import { defaultLocale, systemWordingMessages, type SystemWordingTree } 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 localeChangeEvent = 'pokopia-locale-change';
@@ -17,15 +18,52 @@ type SystemWordingsResponse = {
export type MessageKey = keyof typeof messages.en;
export const i18n = createI18n({
legacy: false,
globalInjection: true,
locale: readStoredLocale(),
fallbackLocale: defaultLocale,
messages
});
export function createPokopiaI18n(initialLocale = readStoredLocale()) {
return createI18n({
legacy: false,
globalInjection: true,
locale: initialLocale || defaultLocale,
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') {
return defaultLocale;
}
@@ -35,11 +73,11 @@ function readStoredLocale(): string {
}
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 {
return globalLocaleRef().value || defaultLocale;
return globalLocaleRef()?.value || defaultLocale;
}
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> {
if (!activeI18n) {
return;
}
const targetI18n = activeI18n;
const targetLocale = locale || defaultLocale;
if (!force && loadedWordingLocales.has(targetLocale)) {
return;
@@ -81,19 +124,19 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa
const loadPromise = (async () => {
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) {
throw new Error(`System wordings failed (${response.status})`);
}
const data = (await response.json()) as SystemWordingsResponse;
i18n.global.setLocaleMessage(
targetI18n.global.setLocaleMessage(
targetLocale,
mergeMessageTrees(messages[defaultLocale], messages[targetLocale], data.messages) as never
);
loadedWordingLocales.add(targetLocale);
} catch {
i18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never);
targetI18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never);
} finally {
pendingWordingLoads.delete(targetLocale);
}
@@ -105,7 +148,10 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa
export function setCurrentLocale(locale: string): void {
const nextLocale = locale || defaultLocale;
globalLocaleRef().value = nextLocale;
const localeRef = globalLocaleRef();
if (localeRef) {
localeRef.value = nextLocale;
}
if (typeof document !== 'undefined') {
document.documentElement.lang = nextLocale;
@@ -121,8 +167,10 @@ export function setCurrentLocale(locale: string): void {
}
export function onLocaleChange(callback: () => void): () => void {
if (typeof window === 'undefined') {
return () => {};
}
window.addEventListener(localeChangeEvent, callback);
return () => window.removeEventListener(localeChangeEvent, callback);
}
setCurrentLocale(getCurrentLocale());

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

View File

@@ -1,12 +1,15 @@
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router';
import { getCurrentLocale, i18n, onLocaleChange } from './i18n';
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import { getCurrentLocale } from './i18n';
import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings';
const siteName = 'Pokopia Wiki';
const defaultCanonicalPath = '/';
const defaultImagePath = '/seo/pokopia-hero.jpg';
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
let runtimeSiteUrl: string | null = null;
type TranslationValues = Record<string, string | number>;
type Translator = (key: string, values?: TranslationValues) => string;
export type RouteSeoConfig = {
title?: string;
@@ -26,12 +29,34 @@ export type SeoConfig = {
noindex?: boolean;
};
const translate = i18n.global.t as (key: string, values?: TranslationValues) => string;
export type ResolvedSeoConfig = {
title: string;
description: string;
canonicalUrl: string;
imageUrl: string;
robots: string;
locale: string;
structuredData: Record<string, unknown>;
};
const messages = systemWordingMessages as unknown as Record<string, SystemWordingTree>;
let activeTranslator: Translator | null = null;
let currentSeo: ResolvedSeoConfig | null = null;
const seoListeners = new Set<(seo: ResolvedSeoConfig) => void>();
export function setSeoTranslator(translator: Translator): void {
activeTranslator = translator;
}
export function setConfiguredSiteUrl(value: unknown): void {
if (typeof value === 'string' && value.trim() !== '') {
runtimeSiteUrl = normalizeSiteUrl(value);
}
}
function configuredSiteUrl(): string {
const fromEnv = import.meta.env.VITE_SITE_URL;
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') {
return normalizeSiteUrl(fromEnv);
if (runtimeSiteUrl) {
return runtimeSiteUrl;
}
if (typeof window !== 'undefined' && window.location.origin) {
@@ -68,115 +93,129 @@ function metaTitle(title?: string): string {
}
function metaDescription(description?: string): string {
return description?.trim() || translate('seo.siteDescription');
return description?.trim() || translateSeo('seo.siteDescription');
}
function localeForOpenGraph(locale: string): string {
if (locale === 'en') {
return 'en_US';
function builtInTranslate(key: string, values: TranslationValues = {}): string {
let message: SystemWordingTree[string] | undefined = messages[defaultLocale];
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 {
let element = document.head.querySelector<HTMLMetaElement>(`meta[${attribute}="${key}"]`);
if (!element) {
element = document.createElement('meta');
element.setAttribute(attribute, key);
document.head.appendChild(element);
}
element.setAttribute('content', content);
function translateSeo(key: string, values?: TranslationValues, translator = activeTranslator): string {
return translator ? translator(key, values) : builtInTranslate(key, values);
}
function setCanonical(href: string): void {
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;
}
export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
const title = metaTitle(config.title);
const description = metaDescription(config.description);
const canonicalUrl = absoluteUrl(normalizePath(config.canonicalPath));
const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath);
const noindex = config.noindex === true;
const robots = noindex ? 'noindex, nofollow' : 'index, follow';
const robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow';
const locale = getCurrentLocale();
document.title = title;
setMeta('name', 'description', description);
setMeta('name', 'robots', robots);
setMeta('name', 'twitter:card', 'summary_large_image');
setMeta('name', 'twitter:title', title);
setMeta('name', 'twitter:description', description);
setMeta('name', 'twitter:image', imageUrl);
setMeta('property', 'og:site_name', siteName);
setMeta('property', 'og:type', 'website');
setMeta('property', 'og:title', title);
setMeta('property', 'og:description', description);
setMeta('property', 'og:url', canonicalUrl);
setMeta('property', 'og:image', imageUrl);
setMeta('property', 'og:locale', localeForOpenGraph(locale));
setCanonical(canonicalUrl);
setStructuredData(title, description, canonicalUrl);
return {
title,
description,
canonicalUrl,
imageUrl,
robots,
locale,
structuredData: {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: title,
description,
url: canonicalUrl,
isPartOf: {
'@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: 'website' },
{ key: 'og-title', property: 'og:title', content: seo.title },
{ key: 'og-description', property: 'og:description', content: seo.description },
{ key: 'og-url', property: 'og:url', content: seo.canonicalUrl },
{ key: 'og-image', property: 'og:image', content: seo.imageUrl },
{ key: 'og-locale', property: 'og:locale', content: seo.locale === 'en' ? 'en_US' : seo.locale.replace('-', '_') }
],
link: [{ key: 'canonical', rel: 'canonical', href: seo.canonicalUrl }],
script: [
{
key: 'pokopia-structured-data',
id: 'pokopia-structured-data',
type: 'application/ld+json',
innerHTML: JSON.stringify(seo.structuredData).replace(/</g, '\\u003C')
}
]
};
}
export function routeSeoConfig(route: RouteLocationNormalizedLoaded, translator?: Translator): SeoConfig {
const routeSeo = route.meta.seo as RouteSeoConfig | undefined;
const canonicalPath =
typeof routeSeo?.canonicalPath === 'function'
? routeSeo.canonicalPath(route)
: 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({
title: routeSeo?.titleKey ? translate(routeSeo.titleKey) : routeSeo?.title,
description: routeSeo?.descriptionKey ? translate(routeSeo.descriptionKey) : routeSeo?.description,
return {
title: routeSeo?.titleKey ? translateSeo(routeSeo.titleKey, undefined, translator) : routeSeo?.title,
description: routeSeo?.descriptionKey ? translateSeo(routeSeo.descriptionKey, undefined, translator) : routeSeo?.description,
canonicalPath,
image: routeSeo?.image,
noindex: routeSeo?.noindex
});
noindex: routeSeo?.noindex === true || requiresPrivateAccess
};
}
export function setupSeo(router: Router): void {
router.afterEach((to) => {
applyRouteSeo(to);
});
export function resolveRouteSeo(route: RouteLocationNormalizedLoaded, translator?: Translator): ResolvedSeoConfig {
return resolveSeo(routeSeoConfig(route, translator));
}
if (typeof window !== 'undefined') {
onLocaleChange(() => {
applyRouteSeo(router.currentRoute.value);
});
export function onSeoChange(callback: (seo: ResolvedSeoConfig) => void): () => void {
seoListeners.add(callback);
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));
}

View File

@@ -1,9 +1,14 @@
import { getCurrentLocale } from '../i18n';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
const authTokenKey = 'pokopia_auth_token';
let browserApiBaseUrl = 'http://localhost:3001';
let serverApiBaseUrl = 'http://localhost:3001';
const authChangeEvent = 'pokopia-auth-change';
export interface ApiRequestOptions {
signal?: AbortSignal;
headers?: HeadersInit;
}
export type TranslationField = 'name' | 'title' | 'details' | 'genus' | 'effect' | 'mosslaxEffect';
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
@@ -15,6 +20,38 @@ export interface Language {
sortOrder: number;
}
export function setApiBaseUrl(value: unknown): void {
setApiBaseUrls({ browser: value, server: value });
}
export function setApiBaseUrls(value: { browser?: unknown; server?: unknown }): void {
const browserBaseUrl = normalizeApiBaseUrl(value.browser);
const serverBaseUrl = normalizeApiBaseUrl(value.server);
if (browserBaseUrl) {
browserApiBaseUrl = browserBaseUrl;
}
if (serverBaseUrl) {
serverApiBaseUrl = serverBaseUrl;
}
}
function normalizeApiBaseUrl(value: unknown): string | null {
if (typeof value === 'string' && value.trim() !== '') {
return value.trim().replace(/\/+$/, '');
}
return null;
}
function activeApiBaseUrl(): string {
return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl;
}
function apiUrl(path: string): string {
return `${activeApiBaseUrl()}${path}`;
}
export type SystemWordingSurface = 'frontend' | 'backend' | 'email';
export interface SystemWording {
@@ -48,8 +85,11 @@ export interface GameVersion extends NamedEntity {
export interface Skill extends NamedEntity {
hasItemDrop: boolean;
hasTrading: boolean;
}
export type TradingPreference = 'like' | 'neutral';
export interface PokemonStats {
hp: number;
attack: number;
@@ -106,6 +146,19 @@ export interface ProjectUpdatesParams {
limit?: number;
}
export interface ListPage<T> {
items: T[];
nextCursor: string | null;
hasMore: boolean;
}
export interface PublicListParams {
cursor?: string | null;
limit?: number;
}
export type PublicListQueryParams = Record<string, string | number | boolean | null | undefined> & PublicListParams;
export interface EntityImage {
path: string;
url: string;
@@ -174,6 +227,12 @@ export interface Pokemon extends EditInfo {
favorite_things: NamedEntity[];
}
export interface PokemonTradingItem extends NamedEntity {
itemId: number;
preference: TradingPreference;
image?: EntityImage | null;
}
export interface RelatedPokemon {
id: number;
displayId: number;
@@ -188,6 +247,7 @@ export interface RelatedPokemon {
export interface PokemonDetail extends Pokemon {
skills: Array<Skill & { itemDrop: (NamedEntity & { image?: EntityImage | null }) | null }>;
favoriteThingItems: Array<NamedEntity & { image?: EntityImage | null; category: NamedEntity; tags: NamedEntity[] }>;
tradingItems: PokemonTradingItem[];
relatedPokemon: RelatedPokemon[];
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
@@ -257,6 +317,8 @@ export interface Item extends EditInfo {
baseName?: string;
details: string;
baseDetails?: string;
basePrice: number | null;
ancientArtifactCategory: NamedEntity | null;
isEventItem: boolean;
translations?: TranslationMap;
image: EntityImage | null;
@@ -294,6 +356,7 @@ export interface ItemDetail extends Item {
recipe: RecipeDetail | null;
relatedRecipes: RecipeUsage[];
relatedHabitats: HabitatUsage[];
possibleTags: ItemPossibleTags;
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
droppedByPokemon: Array<{
@@ -302,6 +365,22 @@ export interface ItemDetail extends Item {
}>;
}
export interface ItemPossibleTags {
highlyLikely: NamedEntity[];
possible: NamedEntity[];
excluded: NamedEntity[];
evidence: {
likes: ItemPossibleTagEvidence[];
neutral: ItemPossibleTagEvidence[];
};
}
export interface ItemPossibleTagEvidence {
pokemon: NamedEntity & { displayId: number; isEventItem: boolean; image?: PokemonImage | null };
preference: TradingPreference;
tags: NamedEntity[];
}
export interface Recipe extends EditInfo {
id: number;
name: string;
@@ -727,7 +806,6 @@ export interface RegisterPayload extends LoginPayload {
}
export interface AuthResponse {
token: string;
user: AuthUser;
}
@@ -759,6 +837,7 @@ export interface PokemonPayload {
skillIds: number[];
favoriteThingIds: number[];
skillItemDrops: Array<{ skillId: number; itemId: number }>;
tradingItems: Array<{ itemId: number; preference: TradingPreference }>;
imagePath: string;
}
@@ -789,6 +868,8 @@ export interface PokemonImageOptionsResult {
export interface ItemPayload {
name: string;
details: string;
basePrice: number | null;
ancientArtifactCategoryId: number | null;
translations?: TranslationMap;
categoryId: number;
usageId: number | null;
@@ -800,6 +881,8 @@ export interface ItemPayload {
acquisitionMethodIds: number[];
tagIds: number[];
imagePath: string;
insertBeforeItemId?: number | null;
insertAfterItemId?: number | null;
}
export interface AncientArtifactPayload {
@@ -963,11 +1046,11 @@ export interface RateLimitSettingsPayload {
policies: Record<RateLimitPolicyKey, RateLimitPolicySettings>;
}
export function buildQuery(params: Record<string, string | number | boolean | undefined>): string {
export function buildQuery(params: Record<string, string | number | boolean | null | undefined>): string {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== '') {
if (value !== undefined && value !== null && value !== '') {
search.set(key, String(value));
}
});
@@ -976,40 +1059,11 @@ export function buildQuery(params: Record<string, string | number | boolean | un
return query ? `?${query}` : '';
}
function authStorage(type: 'local' | 'session'): Storage | null {
export function onAuthChange(callback: () => void): () => void {
if (typeof window === 'undefined') {
return null;
return () => {};
}
return type === 'local' ? window.localStorage : window.sessionStorage;
}
export function getAuthToken(): string | null {
const sessionToken = authStorage('session')?.getItem(authTokenKey);
return sessionToken ?? authStorage('local')?.getItem(authTokenKey) ?? null;
}
export function setAuthToken(token: string | null, options: { persistent?: boolean } = {}): void {
const local = authStorage('local');
const session = authStorage('session');
if (token) {
if (options.persistent === false) {
session?.setItem(authTokenKey, token);
local?.removeItem(authTokenKey);
} else {
local?.setItem(authTokenKey, token);
session?.removeItem(authTokenKey);
}
} else {
local?.removeItem(authTokenKey);
session?.removeItem(authTokenKey);
}
notifyAuthChange();
}
export function onAuthTokenChange(callback: () => void): () => void {
window.addEventListener(authChangeEvent, callback);
return () => window.removeEventListener(authChangeEvent, callback);
}
@@ -1020,16 +1074,14 @@ export function notifyAuthChange(): void {
}
}
function requestHeaders(): HeadersInit {
const token = getAuthToken();
return {
'X-Locale': getCurrentLocale(),
...(token ? { Authorization: `Bearer ${token}` } : {})
};
function requestHeaders(extraHeaders?: HeadersInit): Headers {
const headers = new Headers(extraHeaders);
headers.set('X-Locale', headers.get('X-Locale') ?? getCurrentLocale());
return headers;
}
export function notificationWebSocketUrl(ticket: string): string {
const base = new URL(apiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
const base = new URL(browserApiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
base.pathname = '/api/notifications/ws';
base.search = '';
@@ -1050,10 +1102,24 @@ async function getErrorMessage(response: Response): Promise<string> {
return `Request failed (${response.status})`;
}
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
headers: requestHeaders(),
signal
function normalizeRequestOptions(options?: AbortSignal | ApiRequestOptions): ApiRequestOptions {
if (!options) {
return {};
}
if ('aborted' in options && 'addEventListener' in options) {
return { signal: options };
}
return options;
}
async function getJson<T>(path: string, options?: AbortSignal | ApiRequestOptions): Promise<T> {
const requestOptions = normalizeRequestOptions(options);
const response = await fetch(apiUrl(path), {
credentials: 'include',
headers: requestHeaders(requestOptions.headers),
signal: requestOptions.signal
});
if (!response.ok) {
@@ -1064,12 +1130,13 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
}
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
const headers = requestHeaders();
headers.set('Content-Type', 'application/json');
const response = await fetch(apiUrl(path), {
credentials: 'include',
method,
headers: {
'Content-Type': 'application/json',
...requestHeaders()
},
headers,
body: JSON.stringify(body)
});
@@ -1081,7 +1148,8 @@ async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body:
}
async function sendFormData<T>(path: string, body: FormData): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
const response = await fetch(apiUrl(path), {
credentials: 'include',
method: 'POST',
headers: requestHeaders(),
body
@@ -1095,7 +1163,8 @@ async function sendFormData<T>(path: string, body: FormData): Promise<T> {
}
async function postEmpty(path: string): Promise<void> {
const response = await fetch(`${apiBaseUrl}${path}`, {
const response = await fetch(apiUrl(path), {
credentials: 'include',
method: 'POST',
headers: requestHeaders()
});
@@ -1106,7 +1175,8 @@ async function postEmpty(path: string): Promise<void> {
}
async function deleteJson(path: string): Promise<void> {
const response = await fetch(`${apiBaseUrl}${path}`, {
const response = await fetch(apiUrl(path), {
credentials: 'include',
method: 'DELETE',
headers: requestHeaders()
});
@@ -1117,7 +1187,8 @@ async function deleteJson(path: string): Promise<void> {
}
async function deleteAndGetJson<T>(path: string): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
const response = await fetch(apiUrl(path), {
credentials: 'include',
method: 'DELETE',
headers: requestHeaders()
});
@@ -1160,6 +1231,8 @@ export const api = {
dataToolsSummary: () => getJson<DataToolsSummary>('/api/admin/data-tools/summary'),
exportDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsBundle>('/api/admin/data-tools/export', 'POST', { scopes }),
importDataTools: (bundle: DataToolsBundle) => sendJson<DataToolsSummary>('/api/admin/data-tools/import', 'POST', { bundle }),
importItemsCsvDataTools: (csv: string) => sendJson<DataToolsSummary>('/api/admin/data-tools/import-items-csv', 'POST', { csv }),
importHabitatsCsvDataTools: (csv: string) => sendJson<DataToolsSummary>('/api/admin/data-tools/import-habitats-csv', 'POST', { csv }),
wipeDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsSummary>('/api/admin/data-tools/wipe', 'POST', { scopes }),
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
verifyEmail: (token: string) =>
@@ -1169,7 +1242,7 @@ export const api = {
sendJson<{ message: string }>('/api/auth/request-password-reset', 'POST', payload),
resetPassword: (payload: { token: string; password: string }) =>
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
me: (options?: ApiRequestOptions) => getJson<{ user: AuthUser }>('/api/auth/me', options),
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
changePassword: (payload: ChangePasswordPayload) =>
sendJson<{ message: string }>('/api/auth/me/password', 'PATCH', payload),
@@ -1243,6 +1316,13 @@ export const api = {
deletePermission: (id: string | number) => deleteJson(`/api/admin/permissions/${id}`),
options: () => getJson<Options>('/api/options'),
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
dailyChecklistPage: (params: PublicListParams = {}) =>
getJson<ListPage<DailyChecklistItem>>(
`/api/daily-checklist${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit
})}`
),
lifePosts: (params: LifePostsParams = {}) =>
getJson<LifePostsPage>(
`/api/life-posts${buildQuery({
@@ -1345,7 +1425,7 @@ export const api = {
config: (type: ConfigType) => getJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: (
type: ConfigType,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
) =>
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
reorderConfig: (type: ConfigType, ids: number[]) =>
@@ -1353,12 +1433,20 @@ export const api = {
updateConfig: (
type: ConfigType,
id: number,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
) =>
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
pokemon: (params: Record<string, string | number | boolean | undefined>) =>
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
pokemonPage: (params: PublicListQueryParams) =>
getJson<ListPage<Pokemon>>(
`/api/pokemon${buildQuery({
...params,
cursor: params.cursor ?? undefined,
limit: params.limit
})}`
),
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
pokemonFetchOptions: (search: string, signal?: AbortSignal, all = false) =>
getJson<PokemonFetchOption[]>(
@@ -1372,9 +1460,16 @@ export const api = {
updatePokemon: (id: string | number, payload: PokemonPayload) =>
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`),
reorderPokemon: (ids: number[]) => sendJson<Pokemon[]>('/api/admin/pokemon/order', 'PUT', { ids }),
habitats: (params: Record<string, string | number | boolean | undefined> = {}) =>
getJson<Habitat[]>(`/api/habitats${buildQuery(params)}`),
habitatsPage: (params: PublicListQueryParams = {}) =>
getJson<ListPage<Habitat>>(
`/api/habitats${buildQuery({
...params,
cursor: params.cursor ?? undefined,
limit: params.limit
})}`
),
habitatDetail: (id: string | number) => getJson<HabitatDetail>(`/api/habitats/${id}`),
createHabitat: (payload: HabitatPayload) => sendJson<HabitatDetail>('/api/habitats', 'POST', payload),
updateHabitat: (id: string | number, payload: HabitatPayload) =>
@@ -1383,6 +1478,14 @@ export const api = {
reorderHabitats: (ids: number[]) => sendJson<Habitat[]>('/api/admin/habitats/order', 'PUT', { ids }),
items: (params: Record<string, string | number | boolean | undefined>) =>
getJson<Item[]>(`/api/items${buildQuery(params)}`),
itemsPage: (params: PublicListQueryParams) =>
getJson<ListPage<Item>>(
`/api/items${buildQuery({
...params,
cursor: params.cursor ?? undefined,
limit: params.limit
})}`
),
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
@@ -1390,6 +1493,14 @@ export const api = {
reorderItems: (ids: number[]) => sendJson<Item[]>('/api/admin/items/order', 'PUT', { ids }),
ancientArtifacts: (params: Record<string, string | number | undefined> = {}) =>
getJson<AncientArtifact[]>(`/api/ancient-artifacts${buildQuery(params)}`),
ancientArtifactsPage: (params: PublicListQueryParams = {}) =>
getJson<ListPage<AncientArtifact>>(
`/api/ancient-artifacts${buildQuery({
...params,
cursor: params.cursor ?? undefined,
limit: params.limit
})}`
),
ancientArtifactDetail: (id: string | number) => getJson<AncientArtifactDetail>(`/api/ancient-artifacts/${id}`),
createAncientArtifact: (payload: AncientArtifactPayload) =>
sendJson<AncientArtifactDetail>('/api/ancient-artifacts', 'POST', payload),
@@ -1400,6 +1511,14 @@ export const api = {
sendJson<AncientArtifact[]>('/api/admin/ancient-artifacts/order', 'PUT', { ids }),
recipes: (params: Record<string, string | number | undefined> = {}) =>
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
recipesPage: (params: PublicListQueryParams = {}) =>
getJson<ListPage<Recipe>>(
`/api/recipes${buildQuery({
...params,
cursor: params.cursor ?? undefined,
limit: params.limit
})}`
),
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
createRecipe: (payload: RecipePayload) => sendJson<RecipeDetail>('/api/recipes', 'POST', payload),
updateRecipe: (id: string | number, payload: RecipePayload) =>

View File

@@ -1318,6 +1318,11 @@ svg {
--btn-fg: #ffffff;
}
.link-button--danger {
--btn-bg: var(--danger);
--btn-fg: #ffffff;
}
.ui-button--ghost,
.plain-button,
.row-actions button,
@@ -1335,6 +1340,13 @@ svg {
box-shadow: 0 2px 0 var(--line-strong);
}
.plain-button--icon {
width: 38px;
min-width: 38px;
height: 38px;
padding: 0;
}
button:disabled,
.ui-button:disabled,
.primary-button:disabled,
@@ -1346,6 +1358,96 @@ button:disabled,
box-shadow: 0 2px 0 var(--line);
}
.item-create-action {
position: relative;
display: inline-flex;
align-items: flex-start;
}
.item-create-action__control {
display: inline-flex;
align-items: stretch;
border-radius: var(--radius-control);
box-shadow: 0 2px 0 var(--line-strong);
}
.item-create-action__control .ui-button {
box-shadow: none;
}
.item-create-action__control .ui-button:hover,
.item-create-action__control .ui-button:active {
transform: none;
box-shadow: none;
}
.item-create-action__control .ui-button:disabled {
box-shadow: none;
}
.item-create-action__primary {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.item-create-action__menu-button {
position: relative;
min-width: 38px;
padding-inline: 8px;
border-left-width: 1px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.item-create-action__control.has-defaults .item-create-action__menu-button::after {
content: '';
position: absolute;
top: 6px;
right: 6px;
width: 7px;
height: 7px;
border: 1px solid var(--line-strong);
border-radius: 50%;
background: var(--pokemon-blue);
}
.item-create-defaults-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 45;
width: min(360px, calc(100vw - 32px));
display: grid;
gap: 14px;
padding: 12px;
border: 2px solid var(--line-strong);
border-radius: var(--radius-card);
background: var(--surface);
box-shadow: var(--shadow-raised);
}
.item-create-defaults-menu__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.item-create-defaults-menu__header strong {
color: var(--ink);
font-size: 14px;
font-weight: 900;
}
.item-create-defaults-menu .field {
min-width: 0;
}
.item-create-defaults-menu__checks {
display: grid;
gap: 8px;
}
.filter-panel,
.toolbar {
display: grid;
@@ -2525,6 +2627,121 @@ button:disabled,
opacity: 1;
}
.item-grid-slot {
position: relative;
min-width: 0;
}
.item-grid-slot .entity-card {
width: 100%;
height: 100%;
}
.item-grid-slot .entity-card,
.item-grid-slot .entity-card__image {
-webkit-user-drag: none;
}
.item-grid-card--interactive {
cursor: grab;
touch-action: manipulation;
user-select: none;
}
.item-grid-card--interactive:active {
cursor: grabbing;
}
.item-grid-slot.is-dragging {
z-index: 4;
opacity: 0.72;
transform: scale(0.99);
}
.item-grid-slot.is-dragging .entity-card {
background: color-mix(in srgb, var(--pokemon-yellow) 12%, var(--surface));
box-shadow: var(--shadow-soft);
}
.item-grid-slot.is-drop-target::before {
content: "";
position: absolute;
right: 0;
left: 0;
z-index: 6;
height: 3px;
border-radius: 999px;
background: var(--pokemon-blue);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--pokemon-blue) 18%, transparent);
}
.item-grid-slot.is-drop-before::before {
top: 0;
}
.item-grid-slot.is-drop-after::before {
bottom: 0;
}
.item-grid-move,
.item-grid-enter-active,
.item-grid-leave-active {
transition:
transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1),
opacity 0.18s ease;
}
.item-grid-enter-from,
.item-grid-leave-to {
opacity: 0;
transform: scale(0.94);
}
.item-grid-leave-active {
position: absolute;
}
.item-context-menu {
position: fixed;
z-index: 60;
width: min(216px, calc(100vw - 32px));
display: grid;
gap: 4px;
padding: 8px;
border: 2px solid var(--line-strong);
border-radius: var(--radius-card);
background: var(--surface);
box-shadow: var(--shadow-raised);
}
.item-context-menu__option {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-soft);
color: var(--ink-soft);
font-weight: 850;
cursor: pointer;
text-align: left;
}
.item-context-menu__option:hover,
.item-context-menu__option:focus-visible {
border-color: var(--pokemon-blue);
background: color-mix(in srgb, var(--pokemon-blue) 9%, var(--surface-soft));
color: var(--pokemon-blue-deep);
}
.item-context-menu__option .ui-icon {
width: 20px;
height: 20px;
}
.catalog-card-action {
min-height: 36px;
max-width: 100%;
@@ -2550,6 +2767,14 @@ button:disabled,
font-weight: 850;
}
.confirm-dialog__message {
margin: 0;
color: var(--ink-soft);
font-size: 15px;
font-weight: 750;
line-height: 1.5;
}
.checklist-list {
display: grid;
gap: 10px;
@@ -2734,6 +2959,10 @@ button:disabled,
min-height: 1px;
}
.load-more-sentinel {
min-height: 1px;
}
.life-feed__retry {
display: flex;
justify-content: center;
@@ -3971,6 +4200,10 @@ button:disabled,
.sidebar-tooltip,
.side-nav__link,
.side-nav__chevron,
.item-grid-slot,
.item-grid-move,
.item-grid-enter-active,
.item-grid-leave-active,
.reorderable-row,
.reorderable-list-move,
.drag-handle {
@@ -3978,6 +4211,9 @@ button:disabled,
}
.life-page .ui-button:hover,
.item-grid-enter-from,
.item-grid-leave-to,
.item-grid-slot.is-dragging,
.reorderable-row.is-dragging,
.drag-handle:active {
transform: none;
@@ -4870,6 +5106,244 @@ button:disabled,
justify-content: flex-end;
}
.trading-manager__panel,
.trading-selected-group,
.possible-tags-evidence {
display: grid;
gap: 12px;
min-width: 0;
}
.trading-detail-grid,
.possible-tags-grid,
.possible-tags-evidence__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
min-width: 0;
}
.trading-detail-group,
.possible-tags-group,
.possible-tags-evidence__group {
min-width: 0;
display: grid;
gap: 9px;
align-content: start;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
}
.trading-detail-group h3 {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.trading-detail-list {
max-height: 360px;
overflow: auto;
padding-right: 4px;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.trading-manager {
min-height: 640px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
gap: 16px;
align-items: stretch;
}
.trading-manager__panel {
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
align-content: start;
}
.trading-manager__toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) 180px;
gap: 12px;
align-items: end;
}
.trading-manager__target {
display: grid;
gap: 8px;
}
.trading-manager__list-frame {
min-height: 420px;
display: grid;
gap: 12px;
}
.trading-manager__list-frame--selected {
align-content: start;
}
.trading-default-toggle {
justify-content: flex-start;
}
.trading-item-list,
.trading-selected-list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
overflow: auto;
list-style: none;
}
.trading-item-list {
min-height: 360px;
max-height: 420px;
}
.trading-selected-list {
max-height: 220px;
}
.trading-item-list--loading {
align-content: start;
}
.trading-pick-row,
.trading-selected-list li {
width: 100%;
min-width: 0;
display: grid;
align-items: center;
gap: 10px;
padding: 9px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface);
}
.trading-pick-row {
grid-template-columns: auto minmax(0, 1fr) auto;
color: var(--ink);
text-align: left;
cursor: pointer;
}
.trading-pick-row--selected {
background: var(--surface-soft);
}
.trading-pick-row--active {
border-color: var(--pokemon-blue);
box-shadow: 0 0 0 2px rgba(42, 117, 187, .16);
}
.trading-pick-row__copy,
.trading-selected-list__copy {
min-width: 0;
display: grid;
gap: 3px;
}
.trading-pick-row__copy strong,
.trading-selected-list__copy strong,
.possible-tags-evidence__group h4 {
margin: 0;
color: var(--ink);
font-size: 14px;
font-weight: 900;
line-height: 1.2;
overflow-wrap: anywhere;
}
.trading-pick-row__copy span,
.trading-selected-list__copy span {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.trading-pick-row__state {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--pokemon-blue-deep);
font-size: 12px;
font-weight: 950;
}
.trading-selected-list li {
grid-template-columns: auto minmax(0, 1fr) auto auto;
}
.trading-preference-toggle {
justify-content: flex-end;
}
.trading-preference-toggle button {
min-height: 34px;
padding: 7px 9px;
font-size: 12px;
}
.trading-item-list__skeleton {
padding: 9px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface);
}
.possible-tags-evidence__list li {
align-items: flex-start;
}
.possible-tags-evidence__list .chips {
justify-content: flex-end;
}
@media (max-width: 760px) {
.trading-manager {
grid-template-columns: 1fr;
min-height: 0;
}
.trading-manager__toolbar {
grid-template-columns: 1fr;
}
.trading-selected-list li {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
}
.trading-manager__list-frame,
.trading-item-list {
min-height: 280px;
max-height: 360px;
}
.trading-selected-list {
max-height: 240px;
}
.trading-detail-list {
max-height: 280px;
}
.trading-preference-toggle,
.trading-selected-list .plain-button--icon {
grid-column: 2;
justify-self: start;
}
}
.pokemon-related-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -7090,6 +7564,12 @@ button:disabled,
align-items: center;
}
.switch-group__options--grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
align-items: stretch;
}
.switch-control {
position: relative;
display: inline-flex;
@@ -7109,7 +7589,29 @@ button:disabled,
gap: 6px;
}
.switch-group__options--grid .switch-control--stacked {
min-height: 52px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
justify-items: stretch;
gap: 10px;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
}
.switch-control--disabled {
cursor: not-allowed;
opacity: 0.58;
}
.switch-control__copy {
min-width: 0;
}
.switch-control__label {
display: block;
color: var(--ink-soft);
font-size: 13px;
line-height: 1.2;
@@ -7117,6 +7619,21 @@ button:disabled,
overflow-wrap: anywhere;
}
.switch-control__description {
display: block;
margin-top: 2px;
color: var(--muted);
font-size: 12px;
font-weight: 650;
line-height: 1.3;
overflow-wrap: anywhere;
}
.switch-group__options--grid .switch-control__label,
.switch-group__options--grid .switch-control__description {
text-align: left;
}
.switch-control input {
position: absolute;
inline-size: 1px;

View File

@@ -7,6 +7,8 @@ import PageHeader from '../components/PageHeader.vue';
import ReorderableList from '../components/ReorderableList.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import SwitchGroup, { type SwitchGroupOption } from '../components/SwitchGroup.vue';
import TagsSelect, { type TagsSelectOption } from '../components/TagsSelect.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TranslationFields from '../components/TranslationFields.vue';
import {
@@ -90,6 +92,7 @@ type AdminNavItem = { key: AdminTab; label: string; permission: string | string[
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
type EditableConfig = (NamedEntity | Skill | LifeCategory | GameVersion) & {
hasItemDrop?: boolean;
hasTrading?: boolean;
isDefault?: boolean;
isRateable?: boolean;
changeLog?: string;
@@ -153,7 +156,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
label: t('pages.admin.contentGroup'),
items: [
{ key: 'checklist', label: t('pages.admin.checklist'), permission: ['checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order'] },
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.delete'] },
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: 'pokemon.delete' },
{ key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
{
key: 'ancientArtifacts',
@@ -194,10 +197,18 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
const tabs = computed<AdminNavItem[]>(() => adminNavigationGroups.value.flatMap((group) => group.items));
const configTypes = computed<
Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean; supportsDefault?: boolean; supportsRateable?: boolean; supportsChangeLog?: boolean }>
Array<{
key: ConfigType;
label: string;
supportsItemDrop?: boolean;
supportsTrading?: boolean;
supportsDefault?: boolean;
supportsRateable?: boolean;
supportsChangeLog?: boolean;
}>
>(() => [
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true },
{ key: 'environments', label: t('config.environments') },
{ key: 'favorite-things', label: t('config.favoriteThings') },
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
@@ -237,6 +248,7 @@ const configForm = ref({
name: '',
translations: {} as TranslationMap,
hasItemDrop: false,
hasTrading: false,
isDefault: false,
isRateable: false,
changeLog: ''
@@ -359,6 +371,31 @@ const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDis
const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes));
const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null);
const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2);
const dishItemSelectOptions = computed<TagsSelectOption[]>(() => dishItemRows.value.map((item) => ({ id: item.id, name: item.name })));
const optionalDishItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...dishItemSelectOptions.value]);
const dishCategorySelectOptions = computed<TagsSelectOption[]>(() =>
dishCategoryRows.value.map((category) => ({ id: category.id, name: category.name }))
);
const dishFlavorSelectOptions = computed<TagsSelectOption[]>(() => dishFlavorRows.value.map((flavor) => ({ id: flavor.id, name: flavor.name })));
const optionalDishSkillSelectOptions = computed<TagsSelectOption[]>(() => [
{ id: '', name: t('common.none') },
...dishSkillRows.value.map((skill) => ({ id: skill.id, name: skill.name }))
]);
const dishCategoryFormValid = computed(
() =>
dishCategoryForm.value.name.trim() !== '' &&
dishCategoryForm.value.effect.trim() !== '' &&
dishCategoryForm.value.cookwareItemId !== '' &&
dishCategoryForm.value.mainMaterialItemId !== '' &&
Number(dishCategoryForm.value.totalMaterialQuantity) >= 2
);
const dishFormValid = computed(
() =>
dishForm.value.categoryId !== '' &&
dishForm.value.itemId !== '' &&
dishForm.value.flavorId !== '' &&
dishForm.value.mosslaxEffect.trim() !== ''
);
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
const wordingModalTitle = computed(() => t('pages.admin.editWording'));
const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole')));
@@ -376,6 +413,26 @@ const permissionGroups = computed(() => {
}
return [...groups.entries()].map(([category, permissions]) => ({ category, permissions }));
});
const userRoleSwitchOptions = computed<SwitchGroupOption[]>(() =>
roleRows.value.map((role) => ({
value: role.id,
label: role.name,
description: role.description,
disabled: busy.value || !role.enabled
}))
);
const userRoleSwitchValue = computed<Array<string | number>>({
get: () => userRoleForm.value.roleIds,
set: (values) => {
userRoleForm.value.roleIds = values.map((value) => Number(value)).sort((a, b) => a - b);
}
});
const rolePermissionSwitchValue = computed<Array<string | number>>({
get: () => rolePermissionForm.value.permissionIds,
set: (values) => {
rolePermissionForm.value.permissionIds = values.map((value) => Number(value)).sort((a, b) => a - b);
}
});
const wordingLocaleOptions = computed(() =>
languageRows.value.length
? languageRows.value
@@ -445,8 +502,6 @@ const languageKey = (item: Language) => item.code;
const languageLabel = (item: Language) => item.name;
const configKey = (item: EditableConfig) => item.id;
const configLabel = (item: EditableConfig) => item.name;
const pokemonKey = (item: Pokemon) => item.id;
const pokemonLabel = (item: Pokemon) => `#${item.displayId} ${item.name}`;
const itemKey = (item: Item) => item.id;
const itemLabel = (item: Item) => item.name;
const ancientArtifactKey = (item: AncientArtifact) => item.id;
@@ -515,24 +570,13 @@ function rolePermissionCount(role: RoleDetail) {
return t('pages.admin.permissionCount', { count: role.permissionIds.length });
}
function toggleUserRole(roleId: number) {
const roleIds = new Set(userRoleForm.value.roleIds);
if (roleIds.has(roleId)) {
roleIds.delete(roleId);
} else {
roleIds.add(roleId);
}
userRoleForm.value.roleIds = [...roleIds].sort((a, b) => a - b);
}
function toggleRolePermission(permissionId: number) {
const permissionIds = new Set(rolePermissionForm.value.permissionIds);
if (permissionIds.has(permissionId)) {
permissionIds.delete(permissionId);
} else {
permissionIds.add(permissionId);
}
rolePermissionForm.value.permissionIds = [...permissionIds].sort((a, b) => a - b);
function permissionSwitchOptions(permissions: Permission[]): SwitchGroupOption[] {
return permissions.map((permission) => ({
value: permission.id,
label: permission.name,
description: permission.key,
disabled: busy.value || !permission.enabled
}));
}
function errorText(error: unknown, fallback: string) {
@@ -561,7 +605,7 @@ async function loadLanguages() {
}
function resetConfigForm() {
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, isDefault: false, isRateable: false, changeLog: '' };
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, hasTrading: false, isDefault: false, isRateable: false, changeLog: '' };
}
function resetChecklistForm() {
@@ -667,6 +711,7 @@ function editConfig(item: EditableConfig) {
name: item.baseName ?? item.name,
translations: item.translations ?? {},
hasItemDrop: item.hasItemDrop === true,
hasTrading: item.hasTrading === true,
isDefault: item.isDefault === true,
isRateable: item.isRateable === true,
changeLog: item.changeLog ?? ''
@@ -885,10 +930,6 @@ function previewConfigOrder(rows: EditableConfig[]) {
configRows.value = rows;
}
function previewPokemonOrder(rows: Pokemon[]) {
pokemonRows.value = rows;
}
function previewItemOrder(rows: Item[]) {
itemRows.value = rows;
}
@@ -957,18 +998,6 @@ async function persistConfigOrder(nextRows: EditableConfig[], fallbackRows: Edit
});
}
async function persistPokemonOrder(nextRows: Pokemon[], fallbackRows: Pokemon[]) {
pokemonRows.value = nextRows;
await run(async () => {
try {
pokemonRows.value = await api.reorderPokemon(nextRows.map((item) => item.id));
} catch (error) {
pokemonRows.value = fallbackRows;
throw error;
}
});
}
async function persistItemOrder(nextRows: Item[], fallbackRows: Item[]) {
itemRows.value = nextRows;
await run(async () => {
@@ -1047,6 +1076,7 @@ async function saveConfig() {
name: configBaseNameForSave(),
translations: configForm.value.translations,
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
hasTrading: selectedConfig.value.supportsTrading ? configForm.value.hasTrading : undefined,
isDefault: selectedConfig.value.supportsDefault ? configForm.value.isDefault : undefined,
isRateable: selectedConfig.value.supportsRateable ? configForm.value.isRateable : undefined,
changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined
@@ -1117,6 +1147,10 @@ function dishPayloadForSave() {
}
async function saveDishCategory() {
if (!dishCategoryFormValid.value) {
return;
}
await run(async () => {
const payload = dishCategoryPayloadForSave();
if (dishCategoryForm.value.id) {
@@ -1130,6 +1164,10 @@ async function saveDishCategory() {
}
async function saveDish() {
if (!dishFormValid.value) {
return;
}
await run(async () => {
const payload = dishPayloadForSave();
if (dishForm.value.id) {
@@ -1594,6 +1632,40 @@ async function selectImportDataToolsFile(event: Event) {
}
}
async function selectImportItemsCsvFile(event: Event) {
const input = event.target instanceof HTMLInputElement ? event.target : null;
const file = input?.files?.[0];
if (input) {
input.value = '';
}
if (!file) {
return;
}
await run(async () => {
const csv = await file.text();
dataToolsSummary.value = await api.importItemsCsvDataTools(csv);
message.value = t('pages.admin.dataToolItemsCsvImported');
});
}
async function selectImportHabitatsCsvFile(event: Event) {
const input = event.target instanceof HTMLInputElement ? event.target : null;
const file = input?.files?.[0];
if (input) {
input.value = '';
}
if (!file) {
return;
}
await run(async () => {
const csv = await file.text();
dataToolsSummary.value = await api.importHabitatsCsvDataTools(csv);
message.value = t('pages.admin.dataToolHabitatsCsvImported');
});
}
function closeImportDataToolsModal() {
dataToolImportModalOpen.value = false;
pendingImportBundle.value = null;
@@ -1925,6 +1997,16 @@ onMounted(() => {
</div>
<p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</p>
<p class="meta-line">{{ t('pages.admin.dataToolImportMode') }}</p>
<div class="field">
<label for="data-tools-items-csv-file">{{ t('pages.admin.dataToolItemsCsvFile') }}</label>
<input id="data-tools-items-csv-file" type="file" accept="text/csv,.csv" :disabled="busy || !can('admin.data.import')" @change="selectImportItemsCsvFile" />
</div>
<p class="meta-line">{{ t('pages.admin.dataToolItemsCsvMode') }}</p>
<div class="field">
<label for="data-tools-habitats-csv-file">{{ t('pages.admin.dataToolHabitatsCsvFile') }}</label>
<input id="data-tools-habitats-csv-file" type="file" accept="text/csv,.csv" :disabled="busy || !can('admin.data.import')" @change="selectImportHabitatsCsvFile" />
</div>
<p class="meta-line">{{ t('pages.admin.dataToolHabitatsCsvMode') }}</p>
</section>
<section class="data-tool-panel data-tool-panel--danger" :aria-label="t('pages.admin.dataToolWipe')">
@@ -1980,6 +2062,7 @@ onMounted(() => {
<span class="reorderable-row-title">
{{ item.name }}
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
<span v-if="item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span>
<span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultCategory') }}</span>
<span v-if="item.isRateable" class="config-flag">{{ t('pages.admin.rateableCategory') }}</span>
</span>
@@ -2218,20 +2301,8 @@ onMounted(() => {
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
<h2>{{ t('pages.admin.pokemonList') }}</h2>
<ReorderableList
v-if="pokemonRows.length"
:items="pokemonRows"
:item-key="pokemonKey"
:item-label="pokemonLabel"
list-key-prefix="pokemon"
:disabled="busy || !can('pokemon.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewPokemonOrder"
@cancel="previewPokemonOrder"
@reorder="persistPokemonOrder"
>
<template #default="{ item }">
<ul v-if="pokemonRows.length" class="row-list">
<li v-for="item in pokemonRows" :key="item.id">
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
<span class="row-actions">
<button v-if="can('pokemon.delete')" type="button" :disabled="busy" @click="removePokemon(item.id)">
@@ -2239,8 +2310,8 @@ onMounted(() => {
{{ t('common.delete') }}
</button>
</span>
</template>
</ReorderableList>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
@@ -2480,20 +2551,7 @@ onMounted(() => {
<strong>{{ editingUser.displayName }}</strong>
<span class="meta-line">{{ editingUser.email }}</span>
</div>
<div class="permission-grid" role="group" :aria-label="t('pages.admin.roles')">
<label v-for="role in roleRows" :key="role.id" class="permission-toggle">
<input
type="checkbox"
:checked="userRoleForm.roleIds.includes(role.id)"
:disabled="busy || !role.enabled"
@change="toggleUserRole(role.id)"
/>
<span>
<strong>{{ role.name }}</strong>
<small>{{ role.description }}</small>
</span>
</label>
</div>
<SwitchGroup id="admin-user-roles" v-model="userRoleSwitchValue" :label="t('pages.admin.roles')" :options="userRoleSwitchOptions" layout="grid" />
</form>
<template #footer>
@@ -2550,22 +2608,14 @@ onMounted(() => {
<span class="meta-line">{{ editingRole.description }}</span>
</div>
<div class="permission-groups">
<section v-for="group in permissionGroups" :key="group.category" class="permission-group">
<h3>{{ group.category }}</h3>
<div class="permission-grid" role="group" :aria-label="group.category">
<label v-for="permission in group.permissions" :key="permission.id" class="permission-toggle">
<input
type="checkbox"
:checked="rolePermissionForm.permissionIds.includes(permission.id)"
:disabled="busy || !permission.enabled"
@change="toggleRolePermission(permission.id)"
/>
<span>
<strong>{{ permission.name }}</strong>
<small>{{ permission.key }}</small>
</span>
</label>
</div>
<section v-for="(group, index) in permissionGroups" :key="group.category" class="permission-group">
<SwitchGroup
:id="`admin-role-permissions-${index}`"
v-model="rolePermissionSwitchValue"
:label="group.category"
:options="permissionSwitchOptions(group.permissions)"
layout="grid"
/>
</section>
</div>
</form>
@@ -2656,10 +2706,14 @@ onMounted(() => {
/>
<div class="field">
<label for="dish-category-cookware">{{ t('pages.dish.cookware') }}</label>
<select id="dish-category-cookware" v-model="dishCategoryForm.cookwareItemId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`cookware-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
</select>
<TagsSelect
id="dish-category-cookware"
v-model="dishCategoryForm.cookwareItemId"
:options="dishItemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div class="field">
<label for="dish-category-total-material-quantity">{{ t('pages.dish.totalMaterialQuantity') }}</label>
@@ -2667,10 +2721,14 @@ onMounted(() => {
</div>
<div class="field">
<label for="dish-category-main-material">{{ t('pages.dish.mainMaterial') }}</label>
<select id="dish-category-main-material" v-model="dishCategoryForm.mainMaterialItemId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`category-main-material-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
</select>
<TagsSelect
id="dish-category-main-material"
v-model="dishCategoryForm.mainMaterialItemId"
:options="dishItemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
</div>
<TranslationFields
@@ -2685,7 +2743,7 @@ onMounted(() => {
</form>
<template #footer>
<button type="submit" form="admin-dish-category-form" class="link-button" :disabled="busy">
<button type="submit" form="admin-dish-category-form" class="link-button" :disabled="busy || !dishCategoryFormValid">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
@@ -2701,47 +2759,71 @@ onMounted(() => {
<div class="dish-form-row dish-form-row--3">
<div class="field">
<label for="dish-category">{{ t('pages.dish.category') }}</label>
<select id="dish-category" v-model="dishForm.categoryId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="category in dishCategoryRows" :key="`dish-category-option-${category.id}`" :value="String(category.id)">{{ category.name }}</option>
</select>
<TagsSelect
id="dish-category"
v-model="dishForm.categoryId"
:options="dishCategorySelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.dish.category')"
/>
</div>
<div class="field">
<label for="dish-item">{{ t('pages.dish.dishItem') }}</label>
<select id="dish-item" v-model="dishForm.itemId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`dish-item-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
</select>
<TagsSelect
id="dish-item"
v-model="dishForm.itemId"
:options="dishItemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div class="field">
<label for="dish-flavor">{{ t('pages.dish.flavor') }}</label>
<select id="dish-flavor" v-model="dishForm.flavorId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="flavor in dishFlavorRows" :key="`dish-flavor-${flavor.id}`" :value="String(flavor.id)">{{ flavor.name }}</option>
</select>
<TagsSelect
id="dish-flavor"
v-model="dishForm.flavorId"
:options="dishFlavorSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.dish.flavor')"
/>
</div>
</div>
<div class="dish-form-row dish-form-row--3">
<div class="field">
<label for="dish-secondary-material-1">{{ t('pages.dish.secondaryMaterial') }}</label>
<select id="dish-secondary-material-1" v-model="dishForm.secondaryMaterialItemIds[0]">
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`dish-secondary-material-1-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
</select>
<TagsSelect
id="dish-secondary-material-1"
v-model="dishForm.secondaryMaterialItemIds[0]"
:options="optionalDishItemSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div v-if="dishAllowsSecondSecondaryMaterial" class="field">
<label for="dish-secondary-material-2">{{ t('pages.dish.secondSecondaryMaterial') }}</label>
<select id="dish-secondary-material-2" v-model="dishForm.secondaryMaterialItemIds[1]">
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`dish-secondary-material-2-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
</select>
<TagsSelect
id="dish-secondary-material-2"
v-model="dishForm.secondaryMaterialItemIds[1]"
:options="optionalDishItemSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div class="field">
<label for="dish-pokemon-skill">{{ t('pages.dish.pokemonSkill') }}</label>
<select id="dish-pokemon-skill" v-model="dishForm.pokemonSkillId">
<option value="">{{ t('common.none') }}</option>
<option v-for="skill in dishSkillRows" :key="`dish-skill-${skill.id}`" :value="String(skill.id)">{{ skill.name }}</option>
</select>
<TagsSelect
id="dish-pokemon-skill"
v-model="dishForm.pokemonSkillId"
:options="optionalDishSkillSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.dish.pokemonSkill')"
/>
</div>
</div>
<TranslationFields
@@ -2756,7 +2838,7 @@ onMounted(() => {
</form>
<template #footer>
<button type="submit" form="admin-dish-form" class="link-button" :disabled="busy">
<button type="submit" form="admin-dish-form" class="link-button" :disabled="busy || !dishFormValid">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
@@ -2779,6 +2861,12 @@ onMounted(() => {
{{ t('pages.admin.hasItemDrop') }}
</label>
</div>
<div v-if="selectedConfig.supportsTrading" class="check-row">
<label>
<input v-model="configForm.hasTrading" type="checkbox" />
{{ t('pages.admin.hasTrading') }}
</label>
</div>
<div v-if="selectedConfig.supportsDefault" class="check-row">
<label>
<input v-model="configForm.isDefault" type="checkbox" />

View File

@@ -1,166 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityChips from '../components/EntityChips.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconArtifact, iconBack, iconEdit } from '../icons';
import { applySeo } from '../seo';
import { api, getAuthToken, type AncientArtifactDetail, type AuthUser } from '../services/api';
import AncientArtifactEdit from './AncientArtifactEdit.vue';
const route = useRoute();
const { t } = useI18n();
const artifact = ref<AncientArtifactDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const detailTab = ref('details');
const showEditor = computed(() => route.name === 'ancient-artifact-edit');
const canUpdateArtifact = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.update') === true);
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') }
]);
async function loadArtifactDetail() {
const nextArtifact = await api.ancientArtifactDetail(String(route.params.id));
artifact.value = nextArtifact;
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextArtifact.name} - ${t('pages.ancientArtifacts.title')}`,
description: t('seo.ancientArtifactDetailDescription', { name: nextArtifact.name }),
canonicalPath: `/ancient-artifacts/${nextArtifact.id}`,
image: nextArtifact.image?.url
});
}
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await loadArtifactDetail();
});
watch(
() => route.name,
(name, oldName) => {
if (oldName === 'ancient-artifact-edit' && name === 'ancient-artifact-detail') {
void loadArtifactDetail();
}
}
);
watch(
() => route.params.id,
() => {
artifact.value = null;
detailTab.value = 'details';
void loadArtifactDetail();
}
);
</script>
<template>
<section v-if="!artifact" class="page-stack" aria-busy="true" :aria-label="t('pages.ancientArtifacts.loadingDetail')">
<div class="page-header page-header--skeleton" aria-hidden="true">
<div class="page-header__copy">
<Skeleton width="132px" />
<Skeleton width="260px" height="46px" />
<Skeleton width="220px" />
</div>
<div class="page-header__actions">
<Skeleton variant="box" width="88px" height="36px" />
</div>
</div>
<section class="detail-section skeleton-detail-section" aria-hidden="true">
<div class="detail-section__header">
<Skeleton width="112px" height="24px" />
</div>
<div class="detail-section__body">
<Skeleton width="45%" />
<Skeleton variant="box" height="120px" />
</div>
</section>
</section>
<section v-else class="page-stack">
<PageHeader :title="artifact.name" :subtitle="artifact.category.name">
<template #kicker>{{ t('pages.ancientArtifacts.detailKicker') }}</template>
<template #actions>
<RouterLink v-if="canUpdateArtifact" class="ui-button ui-button--primary ui-button--small" :to="`/ancient-artifacts/${artifact.id}/edit`">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/ancient-artifacts">
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
{{ t('common.backToList') }}
</RouterLink>
</template>
</PageHeader>
<div class="detail-tabs">
<Tabs id="artifact-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
<div v-if="detailTab === 'details'" class="detail-grid">
<DetailSection :title="t('common.details')">
<dl class="entity-profile-facts">
<div>
<dt>{{ t('pages.ancientArtifacts.category') }}</dt>
<dd>{{ artifact.category.name }}</dd>
</div>
</dl>
</DetailSection>
<DetailSection :title="t('media.image')">
<div class="entity-detail-image">
<div class="entity-detail-image__frame" :class="{ 'entity-detail-image__frame--placeholder': !artifact.image }">
<img v-if="artifact.image" :src="artifact.image.url" :alt="t('media.imageAlt', { name: artifact.name })" />
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
<Icon :icon="iconArtifact" class="entity-card__icon" aria-hidden="true" />
</span>
</div>
</div>
</DetailSection>
<DetailSection :title="t('pages.ancientArtifacts.description')">
<p v-if="artifact.details" class="preserve-lines">{{ artifact.details }}</p>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
<DetailSection :title="t('pages.ancientArtifacts.category')">
<span class="chip">
<Icon :icon="iconArtifact" class="ui-icon" aria-hidden="true" />
{{ artifact.category.name }}
</span>
</DetailSection>
<DetailSection :title="t('pages.ancientArtifacts.tags')">
<EntityChips v-if="artifact.tags.length" :items="artifact.tags" />
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
</div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="ancient-artifacts" :entity-id="artifact.id" />
</div>
<div v-else class="detail-tab-panel">
<EditHistoryPanel :entity="artifact" :history="artifact.editHistory" />
</div>
</div>
</section>
<AncientArtifactEdit v-if="showEditor" />
</template>

View File

@@ -1,261 +0,0 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import ImageUploadField from '../components/ImageUploadField.vue';
import Modal from '../components/Modal.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave } from '../icons';
import {
api,
getAuthToken,
type AncientArtifactPayload,
type AuthUser,
type ConfigType,
type EntityImage,
type EntityImageUpload,
type Language,
type Options,
type TranslationMap
} from '../services/api';
const route = useRoute();
const router = useRouter();
const { locale, t } = useI18n();
const options = ref<Options | null>(null);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const currentImage = ref<EntityImage | null>(null);
const imageHistory = ref<EntityImageUpload[]>([]);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const creatingSelect = ref('');
const artifactForm = ref({
name: '',
details: '',
translations: {} as TranslationMap,
categoryId: '',
tagIds: [] as string[],
imagePath: ''
});
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const pageTitle = computed(() =>
isEditing.value
? t('pages.ancientArtifacts.editTitle', { name: artifactForm.value.name || t('pages.ancientArtifacts.fallbackName') })
: t('pages.ancientArtifacts.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/ancient-artifacts/${routeId.value}` : '/ancient-artifacts'));
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
const canUploadImage = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.upload') === true);
const imageEntityName = computed(() => artifactNameForSave().trim());
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
}
function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
function closeEditor() {
void router.push(cancelTo.value);
}
function artifactNameForSave() {
const baseName = artifactForm.value.name.trim();
if (baseName !== '') {
return artifactForm.value.name;
}
return artifactForm.value.translations[String(locale.value || '')]?.name ?? '';
}
async function loadEditor() {
loading.value = true;
message.value = '';
try {
const [loadedOptions, loadedLanguages] = await Promise.all([api.options(), api.languages()]);
options.value = loadedOptions;
languages.value = loadedLanguages;
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
if (isEditing.value) {
const artifact = await api.ancientArtifactDetail(routeId.value);
artifactForm.value = {
name: artifact.baseName ?? artifact.name,
details: artifact.baseDetails ?? artifact.details,
translations: artifact.translations ?? {},
categoryId: String(artifact.category.id),
tagIds: artifact.tags.map((tag) => String(tag.id)),
imagePath: artifact.image?.path ?? ''
};
currentImage.value = artifact.image;
imageHistory.value = artifact.imageHistory;
}
} catch (error) {
message.value = errorText(error, t('errors.loadFailed'));
} finally {
loading.value = false;
}
}
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
const cleanName = name.trim();
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
try {
const created = await api.createConfig(type, { name: cleanName });
options.value = await api.options();
const value = String(created.id);
if (!values.includes(value)) {
values.push(value);
}
} catch (error) {
message.value = errorText(error, t('errors.addFailed'));
} finally {
creatingSelect.value = '';
}
}
async function saveArtifact() {
busy.value = true;
message.value = '';
try {
const payload: AncientArtifactPayload = {
name: artifactNameForSave(),
details: artifactForm.value.details,
translations: artifactForm.value.translations,
categoryId: Number(artifactForm.value.categoryId),
tagIds: toIds(artifactForm.value.tagIds),
imagePath: artifactForm.value.imagePath
};
const saved = isEditing.value
? await api.updateAncientArtifact(routeId.value, payload)
: await api.createAncientArtifact(payload);
await router.push(`/ancient-artifacts/${saved.id}`);
} catch (error) {
message.value = errorText(error, t('errors.saveFailed'));
} finally {
busy.value = false;
}
}
function handleImageSelected(image: EntityImage) {
currentImage.value = image;
}
function handleImageUploaded(image: EntityImageUpload) {
currentImage.value = image;
imageHistory.value = [image, ...imageHistory.value.filter((item) => item.path !== image.path)];
}
onMounted(() => {
void loadEditor();
});
</script>
<template>
<Modal :title="pageTitle" :subtitle="t('pages.ancientArtifacts.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" id="artifact-edit-form" class="modal-edit-form" @submit.prevent="saveArtifact">
<TranslationFields
id-prefix="artifact-name"
v-model:base-value="artifactForm.name"
v-model:translations="artifactForm.translations"
field="name"
:label="t('common.name')"
:languages="languages"
required
/>
<TranslationFields
id-prefix="artifact-details"
v-model:base-value="artifactForm.details"
v-model:translations="artifactForm.translations"
field="details"
:label="t('pages.ancientArtifacts.description')"
:languages="languages"
multiline
:rows="4"
/>
<div class="field">
<label for="artifact-category">{{ t('pages.ancientArtifacts.category') }}</label>
<TagsSelect
id="artifact-category"
v-model="artifactForm.categoryId"
:options="options.ancientArtifactCategories"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.ancientArtifacts.searchCategory')"
/>
</div>
<ImageUploadField
v-model="artifactForm.imagePath"
entity-type="ancient-artifacts"
:entity-id="isEditing ? routeId : null"
:entity-name="imageEntityName"
:label="t('media.image')"
:current-image="currentImage"
:history="imageHistory"
:disabled="busy"
:allow-upload="canUploadImage"
@selected="handleImageSelected"
@uploaded="handleImageUploaded"
@error="message = $event"
/>
<div class="field">
<label for="artifact-tags">{{ t('pages.ancientArtifacts.tags') }}</label>
<TagsSelect
id="artifact-tags"
v-model="artifactForm.tagIds"
:options="options.itemTags"
:allow-create="canCreateConfig"
:creating="creatingSelect === 'artifact-tags'"
:placeholder="t('pages.ancientArtifacts.searchTags')"
@create="createMultiOption('artifact-tags', 'favorite-things', $event, artifactForm.tagIds)"
/>
</div>
</form>
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.ancientArtifacts.loadingEdit')">
<Skeleton width="160px" />
<Skeleton variant="box" height="44px" />
<Skeleton width="140px" />
<Skeleton variant="box" height="120px" />
<Skeleton width="120px" />
<Skeleton variant="box" height="44px" />
</section>
<template #footer>
<button type="button" class="ui-button ui-button--ghost" @click="closeEditor">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
<button type="submit" form="artifact-edit-form" class="ui-button ui-button--primary" :disabled="busy || loading">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
</template>
</Modal>
</template>

View File

@@ -5,20 +5,24 @@ import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import EntityCard from '../components/EntityCard.vue';
import FilterPanel from '../components/FilterPanel.vue';
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconArtifact } from '../icons';
import { api, getAuthToken, type AncientArtifact, type AuthUser, type Options } from '../services/api';
import AncientArtifactEdit from './AncientArtifactEdit.vue';
import { api, type AncientArtifact, type AuthUser, type ListPage, type Options } from '../services/api';
import ItemEdit from './ItemEdit.vue';
const route = useRoute();
const { t } = useI18n();
const { t, locale } = useI18n();
const options = ref<Options | null>(null);
const artifacts = ref<AncientArtifact[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const loadingMore = ref(false);
const nextCursor = ref<string | null>(null);
const hasMoreArtifacts = ref(false);
const search = ref('');
const categoryId = ref('');
const tagIds = ref<string[]>([]);
@@ -26,6 +30,8 @@ const tagIds = ref<string[]>([]);
const categorySkeletonWidths = ['64px', '132px', '132px', '86px'];
const filterSkeletonWidths = ['52px', '36px'];
const skeletonCardCount = 6;
const listPageSize = 24;
let loadRequestId = 0;
const categoryTabs = computed<TabOption[]>(() => [
{ value: '', label: t('common.all') },
@@ -36,32 +42,136 @@ const artifactQuery = computed(() => ({
categoryId: categoryId.value,
tagIds: tagIds.value.join(',')
}));
type AncientArtifactListInitialData = {
options: Options | null;
page: ListPage<AncientArtifact> | null;
};
const { data: initialData } = useAsyncData<AncientArtifactListInitialData>(
`ancient-artifact-list-initial:${locale.value}`,
async () => {
const [optionsResult, artifactsResult] = await Promise.allSettled([
api.options(),
api.ancientArtifactsPage({
...artifactQuery.value,
cursor: null,
limit: listPageSize
})
]);
return {
options: optionsResult.status === 'fulfilled' ? optionsResult.value : null,
page: artifactsResult.status === 'fulfilled' ? artifactsResult.value : null
};
},
{ default: () => ({ options: null, page: null }) }
);
const initialPageLoaded = ref(false);
function applyInitialData(data: AncientArtifactListInitialData | null | undefined) {
if (!data) return;
if (!options.value && data.options) {
options.value = data.options;
}
if (initialPageLoaded.value || !data.page) {
return;
}
artifacts.value = data.page.items;
nextCursor.value = data.page.nextCursor;
hasMoreArtifacts.value = data.page.hasMore;
initialPageLoaded.value = true;
loading.value = false;
}
const showEditor = computed(() => route.name === 'ancient-artifact-new');
const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.create') === true);
const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('items.create') === true);
function artifactCardImage(artifact: AncientArtifact) {
return artifact.image ? { src: artifact.image.url, alt: t('media.imageAlt', { name: artifact.name }) } : undefined;
}
async function loadArtifacts() {
loading.value = true;
artifacts.value = await api.ancientArtifacts(artifactQuery.value);
loading.value = false;
async function loadArtifacts(reset = true) {
if (!reset && (loading.value || loadingMore.value || !hasMoreArtifacts.value)) {
return;
}
const requestId = ++loadRequestId;
if (reset) {
loading.value = true;
loadingMore.value = false;
nextCursor.value = null;
hasMoreArtifacts.value = false;
} else {
loadingMore.value = true;
}
try {
const page = await api.ancientArtifactsPage({
...artifactQuery.value,
cursor: reset ? null : nextCursor.value,
limit: listPageSize
});
if (requestId !== loadRequestId) {
return;
}
if (reset) {
artifacts.value = page.items;
} else {
const existingIds = new Set(artifacts.value.map((item) => item.id));
artifacts.value = [...artifacts.value, ...page.items.filter((item) => !existingIds.has(item.id))];
}
nextCursor.value = page.nextCursor;
hasMoreArtifacts.value = page.hasMore;
initialPageLoaded.value = true;
} catch {
if (requestId === loadRequestId && reset) {
artifacts.value = [];
nextCursor.value = null;
hasMoreArtifacts.value = false;
initialPageLoaded.value = true;
}
} finally {
if (requestId === loadRequestId) {
loading.value = false;
loadingMore.value = false;
}
}
}
function loadMoreArtifacts() {
void loadArtifacts(false);
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
if (!options.value) {
try {
currentUser.value = (await api.me()).user;
options.value = await api.options();
} catch {
currentUser.value = null;
options.value = null;
}
}
options.value = await api.options();
await loadArtifacts();
if (!initialPageLoaded.value) {
await loadArtifacts();
}
});
watch(artifactQuery, loadArtifacts);
watch(artifactQuery, () => {
void loadArtifacts();
});
watch(initialData, applyInitialData, { immediate: true });
</script>
<template>
@@ -138,7 +248,21 @@ watch(artifactQuery, loadArtifacts);
compact-tooltip
/>
</div>
<div v-if="loadingMore" class="entity-grid catalog-card-grid collections-card-grid" aria-hidden="true">
<article
v-for="index in 2"
:key="`artifact-more-${index}`"
class="entity-card entity-card--skeleton entity-card--collection-compact"
>
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
<div class="entity-card__content">
<Skeleton width="128px" height="24px" />
<Skeleton width="92px" />
</div>
</article>
</div>
<LoadMoreSentinel :active="hasMoreArtifacts" :disabled="loading || loadingMore" @load="loadMoreArtifacts" />
<AncientArtifactEdit v-if="showEditor" />
<ItemEdit v-if="showEditor" />
</section>
</template>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import { api, type DailyChecklistItem } from '../services/api';
import { api, type DailyChecklistItem, type ListPage } from '../services/api';
type ChecklistState = {
date: string;
@@ -11,13 +12,40 @@ type ChecklistState = {
};
const checklistStateKey = 'pokopia_daily_checklist_state';
const { t } = useI18n();
const { t, locale } = useI18n();
const stateRefreshIntervalMs = 60_000;
const checklistItems = ref<DailyChecklistItem[]>([]);
const checkedTaskIds = ref<Set<number>>(new Set());
const loading = ref(true);
const loadingMore = ref(false);
const nextCursor = ref<string | null>(null);
const hasMoreItems = ref(false);
const skeletonRows = 5;
const listPageSize = 20;
let stateRefreshTimer: number | null = null;
let loadRequestId = 0;
const { data: initialData } = await useAsyncData<ListPage<DailyChecklistItem> | null>(
`daily-checklist-initial:${locale.value}`,
async () => {
try {
return await api.dailyChecklistPage({
cursor: null,
limit: listPageSize
});
} catch {
return null;
}
},
{ default: () => null }
);
const initialPage = initialData.value;
checklistItems.value = initialPage?.items ?? [];
const initialPageLoaded = ref(initialPage !== null);
loading.value = !initialPageLoaded.value;
nextCursor.value = initialPage?.nextCursor ?? null;
hasMoreItems.value = initialPage?.hasMore ?? false;
function todayKey() {
const today = new Date();
@@ -85,20 +113,71 @@ function handleTaskChange(id: number, event: Event) {
toggleTask(id, checkbox?.checked === true);
}
async function loadDailyChecklist() {
loading.value = true;
try {
checklistItems.value = await api.dailyChecklist();
syncChecklistState();
} finally {
loading.value = false;
async function loadDailyChecklist(reset = true) {
if (!reset && (loading.value || loadingMore.value || !hasMoreItems.value)) {
return;
}
const requestId = ++loadRequestId;
if (reset) {
loading.value = true;
loadingMore.value = false;
nextCursor.value = null;
hasMoreItems.value = false;
} else {
loadingMore.value = true;
}
try {
const page = await api.dailyChecklistPage({
cursor: reset ? null : nextCursor.value,
limit: listPageSize
});
if (requestId !== loadRequestId) {
return;
}
if (reset) {
checklistItems.value = page.items;
} else {
const existingIds = new Set(checklistItems.value.map((item) => item.id));
checklistItems.value = [...checklistItems.value, ...page.items.filter((item) => !existingIds.has(item.id))];
}
nextCursor.value = page.nextCursor;
hasMoreItems.value = page.hasMore;
initialPageLoaded.value = true;
if (!page.hasMore) {
syncChecklistState();
}
} catch {
if (requestId === loadRequestId && reset) {
checklistItems.value = [];
nextCursor.value = null;
hasMoreItems.value = false;
initialPageLoaded.value = true;
}
} finally {
if (requestId === loadRequestId) {
loading.value = false;
loadingMore.value = false;
}
}
}
function loadMoreDailyChecklist() {
void loadDailyChecklist(false);
}
onMounted(() => {
loadChecklistState();
if (initialPageLoaded.value && !hasMoreItems.value) {
syncChecklistState();
}
stateRefreshTimer = window.setInterval(loadChecklistState, stateRefreshIntervalMs);
void loadDailyChecklist();
if (!initialPageLoaded.value) {
void loadDailyChecklist();
}
});
onUnmounted(() => {
@@ -135,9 +214,14 @@ onUnmounted(() => {
<span>{{ item.title }}</span>
</label>
</li>
<li v-for="index in loadingMore ? 2 : 0" :key="`checklist-more-${index}`" class="checklist-item checklist-check" aria-hidden="true">
<Skeleton variant="box" width="34px" height="34px" />
<Skeleton :width="index % 2 === 0 ? '220px' : '160px'" />
</li>
</ul>
<p v-else class="meta-line">{{ t('pages.checklist.empty') }}</p>
<LoadMoreSentinel :active="hasMoreItems" :disabled="loading || loadingMore" @load="loadMoreDailyChecklist" />
</section>
</section>
</template>

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ import TranslationFields from '../components/TranslationFields.vue';
import { iconAdd, iconCancel, iconDelete, iconPokemon, iconSave } from '../icons';
import {
api,
getAuthToken,
type AuthUser,
type ConfigType,
type EntityImage,
@@ -156,11 +155,6 @@ function habitatNameForSave() {
}
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
currentUser.value = (await api.me()).user;
} catch {

View File

@@ -4,10 +4,11 @@ import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import EntityCard from '../components/EntityCard.vue';
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import { iconAdd, iconHabitat } from '../icons';
import { api, getAuthToken, type AuthUser, type Habitat } from '../services/api';
import { api, type AuthUser, type Habitat, type ListPage } from '../services/api';
import HabitatEdit from './HabitatEdit.vue';
const props = defineProps<{
@@ -17,12 +18,46 @@ const props = defineProps<{
const habitats = ref<Habitat[]>([]);
const currentUser = ref<AuthUser | null>(null);
const route = useRoute();
const { t } = useI18n();
const loading = ref(true);
const { t, locale } = useI18n();
const loadingMore = ref(false);
const nextCursor = ref<string | null>(null);
const hasMoreHabitats = ref(false);
const skeletonCardCount = 6;
const listPageSize = 24;
let loadRequestId = 0;
const query = computed(() => ({
isEventItem: props.eventOnly ? 'true' : 'false'
}));
const { data: initialData } = useAsyncData<ListPage<Habitat> | null>(
`${props.eventOnly ? 'event-habitat-list-initial' : 'habitat-list-initial'}:${locale.value}`,
async () => {
try {
return await api.habitatsPage({
...query.value,
cursor: null,
limit: listPageSize
});
} catch {
return null;
}
},
{ default: () => null }
);
const initialPageLoaded = ref(false);
const loading = ref(true);
function applyInitialData(page: ListPage<Habitat> | null | undefined) {
if (!page || initialPageLoaded.value) return;
habitats.value = page.items;
nextCursor.value = page.nextCursor;
hasMoreHabitats.value = page.hasMore;
initialPageLoaded.value = true;
loading.value = false;
}
const showEditor = computed(() => route.name === 'habitat-new' || route.name === 'event-habitat-new');
const canCreateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.create') === true);
const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventHabitats.title' : 'pages.habitats.title'));
@@ -35,24 +70,76 @@ function habitatCardImage(item: Habitat) {
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
}
async function loadHabitats() {
loading.value = true;
habitats.value = await api.habitats(query.value);
loading.value = false;
async function loadHabitats(reset = true) {
if (!reset && (loading.value || loadingMore.value || !hasMoreHabitats.value)) {
return;
}
const requestId = ++loadRequestId;
if (reset) {
loading.value = true;
loadingMore.value = false;
nextCursor.value = null;
hasMoreHabitats.value = false;
} else {
loadingMore.value = true;
}
try {
const page = await api.habitatsPage({
...query.value,
cursor: reset ? null : nextCursor.value,
limit: listPageSize
});
if (requestId !== loadRequestId) {
return;
}
if (reset) {
habitats.value = page.items;
} else {
const existingIds = new Set(habitats.value.map((item) => item.id));
habitats.value = [...habitats.value, ...page.items.filter((item) => !existingIds.has(item.id))];
}
nextCursor.value = page.nextCursor;
hasMoreHabitats.value = page.hasMore;
initialPageLoaded.value = true;
} catch {
if (requestId === loadRequestId && reset) {
habitats.value = [];
nextCursor.value = null;
hasMoreHabitats.value = false;
initialPageLoaded.value = true;
}
} finally {
if (requestId === loadRequestId) {
loading.value = false;
loadingMore.value = false;
}
}
}
function loadMoreHabitats() {
void loadHabitats(false);
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
if (!initialPageLoaded.value) {
await loadHabitats();
}
await loadHabitats();
});
watch(query, loadHabitats);
watch(query, () => {
void loadHabitats();
});
watch(initialData, applyInitialData, { immediate: true });
</script>
<template>
@@ -85,6 +172,15 @@ watch(query, loadHabitats);
:image="habitatCardImage(item)"
/>
</div>
<div v-if="loadingMore" class="entity-grid pokemon-list-grid" aria-hidden="true">
<article v-for="index in 2" :key="`habitat-more-${index}`" class="entity-card entity-card--skeleton">
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
<div class="entity-card__content">
<Skeleton width="128px" height="24px" />
</div>
</article>
</div>
<LoadMoreSentinel :active="hasMoreHabitats" :disabled="loading || loadingMore" @load="loadMoreHabitats" />
<HabitatEdit v-if="showEditor" />
</section>

View File

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

View File

@@ -2,7 +2,7 @@
import { Icon } from '@iconify/vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
@@ -12,16 +12,19 @@ import PokeBallMark from '../components/PokeBallMark.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
import { applySeo } from '../seo';
import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/api';
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
import { api, type AuthUser, type ItemDetail } from '../services/api';
import ItemEdit from './ItemEdit.vue';
const route = useRoute();
const { t } = useI18n();
const router = useRouter();
const { locale, t } = useI18n();
const item = ref<ItemDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const detailTab = ref('details');
const showEditor = computed(() => route.name === 'item-edit');
const itemDetailRouteNames = new Set(['item-detail', 'item-edit', 'ancient-artifact-detail', 'ancient-artifact-edit']);
const isAncientArtifactRoute = computed(() => route.name === 'ancient-artifact-detail' || route.name === 'ancient-artifact-edit');
const showEditor = computed(() => route.name === 'item-edit' || route.name === 'ancient-artifact-edit');
const canUpdateItem = computed(() => currentUser.value?.permissions.includes('items.update') === true);
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
const detailTabs = computed<TabOption[]>(() => [
@@ -36,8 +39,78 @@ const itemSubtitle = computed(() => {
return item.value.usage ? `${item.value.category.name} · ${item.value.usage.name}` : item.value.category.name;
});
const detailKicker = computed(() => (item.value?.isEventItem ? t('pages.eventItems.detailKicker') : t('pages.items.detailKicker')));
const listTarget = computed(() => (item.value?.isEventItem ? '/event-items' : '/items'));
const detailKicker = computed(() =>
isAncientArtifactRoute.value
? t('pages.ancientArtifacts.detailKicker')
: item.value?.isEventItem
? t('pages.eventItems.detailKicker')
: t('pages.items.detailKicker')
);
const listTarget = computed(() => (isAncientArtifactRoute.value ? '/ancient-artifacts' : item.value?.isEventItem ? '/event-items' : '/items'));
const editTarget = computed(() =>
item.value ? (isAncientArtifactRoute.value ? `/ancient-artifacts/${item.value.id}/edit` : `/items/${item.value.id}/edit`) : ''
);
const detailCanonicalPath = computed(() =>
item.value ? (isAncientArtifactRoute.value ? `/ancient-artifacts/${item.value.id}` : `/items/${item.value.id}`) : ''
);
const detailTitleKey = computed(() =>
isAncientArtifactRoute.value ? 'pages.ancientArtifacts.title' : item.value?.isEventItem ? 'pages.eventItems.title' : 'pages.items.title'
);
const detailDescriptionKey = computed(() =>
isAncientArtifactRoute.value ? 'seo.ancientArtifactDetailDescription' : 'seo.itemDetailDescription'
);
const basePriceDisplay = computed(() => {
const price = item.value?.basePrice;
return price === null || price === undefined ? t('common.none') : new Intl.NumberFormat(locale.value).format(price);
});
const possibleTagSections = computed(() => [
{ key: 'highlyLikely', title: t('pages.items.highlyLikelyTags'), tags: item.value?.possibleTags?.highlyLikely ?? [] },
{ key: 'possible', title: t('pages.items.possibleTagsPossible'), tags: item.value?.possibleTags?.possible ?? [] },
{ key: 'excluded', title: t('pages.items.excludedTags'), tags: item.value?.possibleTags?.excluded ?? [] }
]);
const possibleTagEvidenceSections = computed(() => [
{ key: 'likes', title: t('pages.pokemon.tradingLikes'), rows: item.value?.possibleTags?.evidence.likes ?? [] },
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] }
]);
const { data: initialItem } = useAsyncData<ItemDetail | null>(
`item-detail:${String(route.name)}:${activeItemRouteId() ?? 'none'}:${locale.value}`,
async () => {
const routeId = activeItemRouteId();
if (!routeId) {
return null;
}
try {
const nextItem = await api.itemDetail(routeId);
return isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory ? null : nextItem;
} catch {
return null;
}
},
{ default: () => null }
);
const initialItemLoaded = ref(false);
const itemSeo = computed(() =>
item.value && route.meta.editorModal !== true
? resolveSeo({
title: `${item.value.name} - ${t(detailTitleKey.value)}`,
description: t(detailDescriptionKey.value, { name: item.value.name }),
canonicalPath: detailCanonicalPath.value,
image: item.value.image?.url
})
: null
);
useHead(() => (itemSeo.value ? resolvedSeoHead(itemSeo.value) : {}));
function applyInitialItem(value: ItemDetail | null | undefined) {
if (!value || initialItemLoaded.value) return;
item.value = value;
initialItemLoaded.value = true;
}
const customization = computed(() => {
if (!item.value) {
@@ -52,34 +125,65 @@ const customization = computed(() => {
});
async function loadItemDetail() {
const nextItem = await api.itemDetail(String(route.params.id));
item.value = nextItem;
const routeId = activeItemRouteId();
if (!routeId) {
initialItemLoaded.value = true;
return;
}
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextItem.name} - ${t(nextItem.isEventItem ? 'pages.eventItems.title' : 'pages.items.title')}`,
description: t('seo.itemDetailDescription', { name: nextItem.name }),
canonicalPath: `/items/${nextItem.id}`,
image: nextItem.image?.url
});
try {
const nextItem = await api.itemDetail(routeId);
if (isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory) {
await router.replace(route.name === 'ancient-artifact-edit' ? `/items/${nextItem.id}/edit` : `/items/${nextItem.id}`);
return;
}
item.value = nextItem;
initialItemLoaded.value = true;
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextItem.name} - ${t(detailTitleKey.value)}`,
description: t(detailDescriptionKey.value, { name: nextItem.name }),
canonicalPath: detailCanonicalPath.value,
image: nextItem.image?.url
});
}
} catch {
item.value = null;
initialItemLoaded.value = true;
}
}
function isItemDetailRouteName(value: unknown) {
return typeof value === 'string' && itemDetailRouteNames.has(value);
}
function activeItemRouteId(): string | null {
return isItemDetailRouteName(route.name) && typeof route.params.id === 'string' && route.params.id.trim() !== ''
? route.params.id
: null;
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
if (!initialItemLoaded.value) {
await loadItemDetail();
}
await loadItemDetail();
});
watch(
() => route.name,
(name, oldName) => {
if (oldName === 'item-edit' && name === 'item-detail') {
if (name !== oldName && isItemDetailRouteName(name) && isItemDetailRouteName(oldName)) {
item.value = null;
detailTab.value = 'details';
void loadItemDetail();
}
}
@@ -88,11 +192,17 @@ watch(
watch(
() => route.params.id,
() => {
if (!activeItemRouteId()) {
return;
}
item.value = null;
detailTab.value = 'details';
void loadItemDetail();
}
);
watch(initialItem, applyInitialItem, { immediate: true });
</script>
<template>
@@ -152,7 +262,7 @@ watch(
<PageHeader :title="item.name" :subtitle="itemSubtitle">
<template #kicker>{{ detailKicker }}</template>
<template #actions>
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="editTarget">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</RouterLink>
@@ -190,6 +300,14 @@ watch(
<dt>{{ t('pages.items.usage') }}</dt>
<dd>{{ item.usage?.name ?? t('common.none') }}</dd>
</div>
<div>
<dt>{{ t('pages.items.basePrice') }}</dt>
<dd>{{ basePriceDisplay }}</dd>
</div>
<div v-if="item.ancientArtifactCategory">
<dt>{{ t('pages.items.ancientArtifact') }}</dt>
<dd>{{ item.ancientArtifactCategory.name }}</dd>
</div>
<div>
<dt>{{ t('pages.items.recipeInfo') }}</dt>
<dd>{{ item.noRecipe ? t('pages.items.noRecipe') : item.recipe ? item.recipe.name : t('common.none') }}</dd>
@@ -224,6 +342,39 @@ watch(
</div>
</div>
<DetailSection :title="t('pages.items.possibleTags')">
<div class="possible-tags-grid">
<div v-for="section in possibleTagSections" :key="section.key" class="possible-tags-group">
<h3 class="section-subtitle">{{ section.title }}</h3>
<EntityChips v-if="section.tags.length" :items="section.tags" />
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
</div>
<div class="possible-tags-evidence">
<h3 class="section-subtitle">{{ t('pages.items.possibleTagsEvidence') }}</h3>
<div class="possible-tags-evidence__grid">
<div v-for="section in possibleTagEvidenceSections" :key="section.key" class="possible-tags-evidence__group">
<h4>{{ section.title }}</h4>
<ul v-if="section.rows.length" class="row-list possible-tags-evidence__list">
<li v-for="entry in section.rows" :key="`${section.key}-${entry.pokemon.id}`">
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/pokemon/${entry.pokemon.id}`">
<span class="related-entity-media related-entity-media--inline related-entity-media--pokemon" aria-hidden="true">
<img v-if="entry.pokemon.image" :src="entry.pokemon.image.url" alt="" loading="lazy" />
<PokeBallMark v-else size="22px" />
</span>
<span>#{{ entry.pokemon.displayId }} {{ entry.pokemon.name }}</span>
</RouterLink>
<EntityChips v-if="entry.tags.length" :items="entry.tags" />
<p v-else class="meta-line">{{ t('common.none') }}</p>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
</div>
</div>
</DetailSection>
<div class="detail-grid">
<DetailSection :title="t('pages.items.recipeInfo')">
<template v-if="item.recipe">

View File

@@ -12,7 +12,6 @@ import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave } from '../icons';
import {
api,
getAuthToken,
type AuthUser,
type ConfigType,
type EntityImage,
@@ -38,6 +37,8 @@ const creatingSelect = ref('');
const itemForm = ref({
name: '',
details: '',
basePrice: '',
ancientArtifactCategoryId: '',
translations: {} as TranslationMap,
categoryId: '',
usageId: '',
@@ -51,19 +52,54 @@ const itemForm = ref({
imagePath: ''
});
type ItemCreateDefaults = {
categoryId: string;
usageId: string;
dyeable: boolean;
dualDyeable: boolean;
patternEditable: boolean;
noRecipe: boolean;
acquisitionMethodIds: string[];
};
const itemCreateDefaultsStorageKey = 'pokopia_item_create_defaults';
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const isEventCreate = computed(() => route.name === 'event-item-new');
const isAncientArtifactRoute = computed(() => route.name === 'ancient-artifact-new' || route.name === 'ancient-artifact-edit');
const isAncientArtifactCreate = computed(() => route.name === 'ancient-artifact-new');
const insertBeforeItemId = computed(() => queryItemId(route.query.insertBeforeItemId));
const insertAfterItemId = computed(() => queryItemId(route.query.insertAfterItemId));
const pageTitle = computed(() =>
isEditing.value
? t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') })
? isAncientArtifactRoute.value
? t('pages.ancientArtifacts.editTitle', { name: itemForm.value.name || t('pages.ancientArtifacts.fallbackName') })
: t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') })
: isAncientArtifactCreate.value
? t('pages.ancientArtifacts.newTitle')
: isEventCreate.value
? t('pages.eventItems.newTitle')
: t('pages.items.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : isEventCreate.value ? '/event-items' : '/items'));
const pageSubtitle = computed(() => (isAncientArtifactRoute.value ? t('pages.ancientArtifacts.editSubtitle') : t('pages.items.editSubtitle')));
const cancelTo = computed(() =>
isEditing.value
? isAncientArtifactRoute.value
? `/ancient-artifacts/${routeId.value}`
: `/items/${routeId.value}`
: isAncientArtifactCreate.value
? '/ancient-artifacts'
: isEventCreate.value
? '/event-items'
: '/items'
);
const hasRecipe = ref(false);
const imageEntityName = computed(() => itemNameForSave().trim());
const ancientArtifactOptions = computed(() => [
{ value: '', label: t('common.no') },
...(options.value?.ancientArtifactCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
]);
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
const canUploadImage = computed(() => currentUser.value?.permissions.includes('items.upload') === true);
@@ -71,10 +107,93 @@ function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
}
function queryItemId(value: unknown): number | null {
const rawValue = Array.isArray(value) ? value[0] : value;
const id = Number(rawValue);
return Number.isInteger(id) && id > 0 ? id : null;
}
function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
function readItemCreateDefaults(): ItemCreateDefaults {
if (typeof sessionStorage === 'undefined') {
return {
categoryId: '',
usageId: '',
dyeable: false,
dualDyeable: false,
patternEditable: false,
noRecipe: false,
acquisitionMethodIds: []
};
}
try {
const rawValue = sessionStorage.getItem(itemCreateDefaultsStorageKey);
if (!rawValue) {
return {
categoryId: '',
usageId: '',
dyeable: false,
dualDyeable: false,
patternEditable: false,
noRecipe: false,
acquisitionMethodIds: []
};
}
const parsedValue = JSON.parse(rawValue) as Partial<ItemCreateDefaults>;
return {
categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '',
usageId: typeof parsedValue.usageId === 'string' ? parsedValue.usageId : '',
dyeable: parsedValue.dyeable === true,
dualDyeable: parsedValue.dualDyeable === true,
patternEditable: parsedValue.patternEditable === true,
noRecipe: parsedValue.noRecipe === true,
acquisitionMethodIds: Array.isArray(parsedValue.acquisitionMethodIds)
? parsedValue.acquisitionMethodIds.filter((item) => typeof item === 'string')
: []
};
} catch {
return {
categoryId: '',
usageId: '',
dyeable: false,
dualDyeable: false,
patternEditable: false,
noRecipe: false,
acquisitionMethodIds: []
};
}
}
function applyItemCreateDefaults(isEventItem: boolean) {
const loadedOptions = options.value;
if (!loadedOptions) {
itemForm.value.isEventItem = isEventItem;
return;
}
const defaults = readItemCreateDefaults();
const categoryIds = new Set(loadedOptions.itemCategories.map((item) => String(item.id)));
const usageIds = new Set(loadedOptions.itemUsages.map((item) => String(item.id)));
const methodIds = new Set(loadedOptions.acquisitionMethods.map((item) => String(item.id)));
itemForm.value = {
...itemForm.value,
categoryId: categoryIds.has(defaults.categoryId) ? defaults.categoryId : '',
usageId: usageIds.has(defaults.usageId) ? defaults.usageId : '',
ancientArtifactCategoryId: isAncientArtifactCreate.value ? String(loadedOptions.ancientArtifactCategories[0]?.id ?? '') : '',
dyeable: defaults.dyeable,
dualDyeable: defaults.dualDyeable,
patternEditable: defaults.patternEditable,
noRecipe: defaults.noRecipe,
isEventItem,
acquisitionMethodIds: defaults.acquisitionMethodIds.filter((item) => methodIds.has(item))
};
}
function closeEditor() {
void router.push(cancelTo.value);
}
@@ -95,11 +214,6 @@ async function loadOptions() {
}
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
currentUser.value = (await api.me()).user;
} catch {
@@ -118,6 +232,8 @@ async function loadEditor() {
itemForm.value = {
name: item.baseName ?? item.name,
details: item.baseDetails ?? item.details,
basePrice: item.basePrice === null || item.basePrice === undefined ? '' : String(item.basePrice),
ancientArtifactCategoryId: item.ancientArtifactCategory ? String(item.ancientArtifactCategory.id) : '',
translations: item.translations ?? {},
categoryId: String(item.category.id),
usageId: item.usage ? String(item.usage.id) : '',
@@ -133,10 +249,8 @@ async function loadEditor() {
currentImage.value = item.image;
imageHistory.value = item.imageHistory;
hasRecipe.value = item.recipe !== null;
} else if (isEventCreate.value) {
itemForm.value.isEventItem = true;
} else {
itemForm.value.isEventItem = false;
applyItemCreateDefaults(isEventCreate.value);
}
} catch (error) {
message.value = errorText(error, t('errors.loadFailed'));
@@ -173,6 +287,9 @@ async function saveItem() {
const payload: ItemPayload = {
name: itemNameForSave(),
details: itemForm.value.details,
basePrice: itemForm.value.basePrice.trim() === '' ? null : Number(itemForm.value.basePrice),
ancientArtifactCategoryId:
itemForm.value.ancientArtifactCategoryId.trim() === '' ? null : Number(itemForm.value.ancientArtifactCategoryId),
translations: itemForm.value.translations,
categoryId: Number(itemForm.value.categoryId),
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
@@ -185,8 +302,14 @@ async function saveItem() {
tagIds: toIds(itemForm.value.tagIds),
imagePath: itemForm.value.imagePath
};
if (!isEditing.value && insertBeforeItemId.value !== null) {
payload.insertBeforeItemId = insertBeforeItemId.value;
}
if (!isEditing.value && insertAfterItemId.value !== null) {
payload.insertAfterItemId = insertAfterItemId.value;
}
const saved = isEditing.value ? await api.updateItem(routeId.value, payload) : await api.createItem(payload);
await router.push(`/items/${saved.id}`);
await router.push(isAncientArtifactRoute.value ? `/ancient-artifacts/${saved.id}` : `/items/${saved.id}`);
} catch (error) {
message.value = errorText(error, t('errors.saveFailed'));
} finally {
@@ -209,19 +332,26 @@ onMounted(() => {
</script>
<template>
<Modal :title="pageTitle" :subtitle="t('pages.items.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
<Modal :title="pageTitle" :subtitle="pageSubtitle" :close-label="t('common.close')" size="wide" @close="closeEditor">
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" id="item-edit-form" class="modal-edit-form" @submit.prevent="saveItem">
<TranslationFields
id-prefix="item-name"
v-model:base-value="itemForm.name"
v-model:translations="itemForm.translations"
field="name"
:label="t('common.name')"
:languages="languages"
required
/>
<div class="item-edit-row item-edit-row--name-price">
<TranslationFields
id-prefix="item-name"
v-model:base-value="itemForm.name"
v-model:translations="itemForm.translations"
field="name"
:label="t('common.name')"
:languages="languages"
required
/>
<div class="field">
<label for="item-base-price">{{ t('pages.items.basePrice') }}</label>
<input id="item-base-price" v-model="itemForm.basePrice" type="number" min="0" step="1" inputmode="numeric" />
</div>
</div>
<TranslationFields
id-prefix="item-details"
@@ -249,28 +379,43 @@ onMounted(() => {
@error="message = $event"
/>
<div class="field">
<label for="item-category">{{ t('pages.items.category') }}</label>
<TagsSelect
id="item-category"
v-model="itemForm.categoryId"
:options="options.itemCategories"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.items.searchCategory')"
/>
<div class="item-edit-row item-edit-row--category-usage">
<div class="field">
<label for="item-category">{{ t('pages.items.category') }}</label>
<TagsSelect
id="item-category"
v-model="itemForm.categoryId"
:options="options.itemCategories"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.items.searchCategory')"
/>
</div>
<div class="field">
<label for="item-usage">{{ t('pages.items.usage') }}</label>
<TagsSelect
id="item-usage"
v-model="itemForm.usageId"
:options="options.itemUsages"
:multiple="false"
clearable
:placeholder="t('common.none')"
:search-placeholder="t('pages.items.searchUsage')"
/>
</div>
</div>
<div class="field">
<label for="item-usage">{{ t('pages.items.usage') }}</label>
<TagsSelect
id="item-usage"
v-model="itemForm.usageId"
:options="options.itemUsages"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.items.searchUsage')"
/>
<fieldset class="radio-group">
<legend>{{ t('pages.items.ancientArtifact') }}</legend>
<div class="radio-group__options">
<label v-for="option in ancientArtifactOptions" :key="option.value" class="radio-group__option">
<input v-model="itemForm.ancientArtifactCategoryId" type="radio" name="item-ancient-artifact" :value="option.value" />
<span>{{ option.label }}</span>
</label>
</div>
</fieldset>
</div>
<div class="check-row">
@@ -309,7 +454,7 @@ onMounted(() => {
</form>
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.items.loadingEdit')">
<div v-for="index in 6" :key="index" class="field">
<div v-for="index in 7" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '88px'" />
<Skeleton variant="box" height="44px" />
</div>
@@ -327,3 +472,70 @@ onMounted(() => {
</template>
</Modal>
</template>
<style scoped>
.item-edit-row {
display: grid;
gap: 12px;
align-items: start;
}
.item-edit-row--name-price {
grid-template-columns: minmax(0, 1fr) minmax(180px, 240px);
}
.item-edit-row--category-usage {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.item-edit-row > * {
min-width: 0;
}
.radio-group {
display: grid;
gap: 7px;
min-width: 0;
min-inline-size: 0;
margin: 0;
padding: 0;
border: 0;
}
.radio-group legend {
padding: 0;
color: var(--ink-soft);
font-size: 14px;
font-weight: 850;
}
.radio-group__options {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
align-items: center;
}
.radio-group__option {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 36px;
color: var(--ink-soft);
font-weight: 850;
cursor: pointer;
}
.radio-group__option input {
width: 18px;
height: 18px;
accent-color: var(--pokemon-blue);
}
@media (max-width: 720px) {
.item-edit-row--name-price,
.item-edit-row--category-usage {
grid-template-columns: 1fr;
}
}
</style>

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