Compare commits

..

11 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
47 changed files with 61238 additions and 687 deletions

View File

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

View File

@@ -27,7 +27,7 @@
- 全局搜索 API 只返回公开浏览所需的最小结果字段结果类型、ID、展示标题、目标 URL、可选摘要和可选图片用户搜索结果只使用公开 Profile 所需的 `id``displayName` 和目标 URL不返回邮箱、角色、权限、Referral、编辑审计、审核原因、token/hash、内部字段或调试信息。 - 全局搜索 API 只返回公开浏览所需的最小结果字段结果类型、ID、展示标题、目标 URL、可选摘要和可选图片用户搜索结果只使用公开 Profile 所需的 `id``displayName` 和目标 URL不返回邮箱、角色、权限、Referral、编辑审计、审核原因、token/hash、内部字段或调试信息。
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。 - 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。 - 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
- 列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序。 - 除 Pokemon 外,列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序Pokemon 列表按内部 `id` 升序展示,不提供手动排序
## 国际化 ## 国际化
@@ -123,12 +123,13 @@
- 登录页提供 Remember me - 登录页提供 Remember me
- 未勾选时 session 有效期为 1 天。 - 未勾选时 session 有效期为 1 天。
- 勾选时 session 有效期为 30 天。 - 勾选时 session 有效期为 30 天。
- SSR 迁移期认证使用 hybrid session model - SSR 认证使用 HTTP-only cookie session
- 登录成功后后端设置 HTTP-only `pokopia_session` cookiecookie 只保存明文 session token数据库只保存 session token hash。 - 登录成功后后端设置 HTTP-only `pokopia_session` cookiecookie 只保存明文 session token数据库只保存 session token hash。
- 迁移期登录响应返回明文 session token,前端继续按 Remember me 语义保存到 `sessionStorage``localStorage``pokopia_auth_token`,用于保持现有 SPA 客户端流程兼容 - 登录响应只返回当前用户必要字段,不返回明文 session tokensession token hash 或内部 session 元数据
- 受保护 API 优先接受 HTTP-only cookie session并继续兼容 `Authorization: Bearer` legacy token - Remember me 通过 HTTP-only session cookie 有效期实现:未勾选时有效期为 1 天,勾选时有效期为 30 天
- 受保护 API 只接受 HTTP-only cookie session不接受前端 JavaScript 保存的 legacy Bearer token。
- 前端 API 请求携带 credentials以便浏览器自动发送 HTTP-only session cookieJavaScript 不读取该 cookie。 - 前端 API 请求携带 credentials以便浏览器自动发送 HTTP-only session cookieJavaScript 不读取该 cookie。
- 用户可退出登录,退出时删除对应 session清除 HTTP-only session cookie,并清理前端 legacy token storage - 用户可退出登录,退出时删除对应 session清除 HTTP-only session cookie。
- 对外用户字段只包含必要信息: - 对外用户字段只包含必要信息:
- 当前用户:`id``email``displayName``emailVerified` - 当前用户:`id``email``displayName``emailVerified`
- 编辑署名:`id``displayName` - 编辑署名:`id``displayName`
@@ -357,7 +358,7 @@
- `created_at` - `created_at`
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。 - 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
- 编辑历史中的用户信息只展示必要署名不暴露邮箱、token、hash 或内部元数据。 - 编辑历史中的用户信息只展示必要署名不暴露邮箱、token、hash 或内部元数据。
- 排序操作仍更新列表顺序、最后编辑者和最后编辑时间,但 `sort_order` / Sort order 字段变更不写入或展示在详情页编辑历史面板中。 - 非 Pokemon 列表排序操作仍更新列表顺序、最后编辑者和最后编辑时间,但 `sort_order` / Sort order 字段变更不写入或展示在详情页编辑历史面板中。
- 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。 - 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。
## Wiki 图片上传 ## Wiki 图片上传
@@ -528,7 +529,6 @@ Pokemon 可配置:
- Speed - Speed
- 出现的栖息地:由栖息地出现配置反向展示 - 出现的栖息地:由栖息地出现配置反向展示
- 翻译 - 翻译
- 排序
普通 Pokemon 与 Event Pokemon 分开展示: 普通 Pokemon 与 Event Pokemon 分开展示:
@@ -585,7 +585,7 @@ Pokemon 列表功能:
- 按喜欢的东西筛选: - 按喜欢的东西筛选:
- 满足任意条件 - 满足任意条件
- 满足全部条件 - 满足全部条件
-自定义排序展示 - Pokemon 内部 `id`序展示
- 列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Pokemon。 - 列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Pokemon。
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。 - Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。 - Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
@@ -602,8 +602,8 @@ Pokemon 详情页展示:
- 六维使用 ProgressBar 展示,最大值按 150 计算。 - 六维使用 ProgressBar 展示,最大值按 150 计算。
- 特长 - 特长
- 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标,未配置时展示空状态 - 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标,未配置时展示空状态
- Trading当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品Likes 表示交易价格 1.5xNeutral 表示无加成,未配置观察时展示空状态 - Trading当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品Likes 表示交易价格 1.5xNeutral 表示无加成,未配置观察时展示空状态;详情页双列列表高度受限,内容较多时每列内部独立滚动,避免 Section 被大量物品无限拉长
- Trading 可在详情页通过 Manage Trading Modal 维护Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动 - Trading 可在详情页通过 Manage Trading Modal 维护Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;搜索结果优先展示名称精确匹配、名称开头匹配和单词匹配的物品再展示名称包含、分类或用途包含的物品搜索框支持上下键切换当前结果、左右键切换默认加入组、Enter 将当前结果加入默认目标组;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动
- 喜欢的环境 - 喜欢的环境
- 喜欢的东西 - 喜欢的东西
- 相关 Pokemon与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西 - 相关 Pokemon与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
@@ -1004,7 +1004,7 @@ API 暴露边界:
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。 - 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块: - 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
- 配置System config。 - 配置System config。
- 内容Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口以及 Data Tools。 - 内容Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口以及 Data ToolsPokemon 在 Admin 中可删除但不提供手动排序
- 内容管理包含 Items、Event Items 与 Ancient ArtifactsItems / Event Items 使用同一物品数据模型,通过 `is_event_item` 拆分入口。 - 内容管理包含 Items、Event Items 与 Ancient ArtifactsItems / Event Items 使用同一物品数据模型,通过 `is_event_item` 拆分入口。
- 本地化Languages、System wordings。 - 本地化Languages、System wordings。
- 访问权限Users、Roles、Permissions、Rate limits。 - 访问权限Users、Roles、Permissions、Rate limits。
@@ -1041,7 +1041,7 @@ API 暴露边界:
- `favicon.ico` - `favicon.ico`
- 默认社交分享图 - 默认社交分享图
- 品牌 Logo 素材 - 品牌 Logo 素材
- `NUXT_PUBLIC_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`Nuxt 配置仍兼容读取旧的 `VITE_SITE_URL` 作为 fallback。 - `NUXT_PUBLIC_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`
- 前端 Nuxt app head 配置提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon路由 metadata 与详情页公开业务数据通过 Nuxt `useHead` 更新页面 metadata避免直接操作 `document.head` - 前端 Nuxt app head 配置提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon路由 metadata 与详情页公开业务数据通过 Nuxt `useHead` 更新页面 metadata避免直接操作 `document.head`
- 主要公开浏览入口可索引: - 主要公开浏览入口可索引:
- `/pokemon` - `/pokemon`
@@ -1066,12 +1066,13 @@ API 暴露边界:
## 部署与升级维护 ## 部署与升级维护
- Docker 部署时公开前端端口由 `frontend_gateway` 承载,正常流量代理到 `frontend` 服务。 - Docker 部署时公开前端端口由 `frontend_gateway` 承载,正常流量代理到 `frontend` 服务。
- 前端浏览器 API base URL 由 `NUXT_PUBLIC_API_BASE_URL` 提供Nuxt 配置仍兼容读取旧的 `VITE_API_BASE_URL` 作为 fallback - 前端浏览器 API base URL 由 `NUXT_PUBLIC_API_BASE_URL` 提供。
- Nuxt 服务端 API base URL 由 `NUXT_SERVER_API_BASE_URL` 提供;在 Docker 内默认使用 `http://backend:3001`,本地非 Docker 运行可使用 `http://localhost:3001`。服务端公开数据读取使用该内部地址,浏览器请求继续使用 `NUXT_PUBLIC_API_BASE_URL` - Nuxt 服务端 API base URL 由 `NUXT_SERVER_API_BASE_URL` 提供;在 Docker 内默认使用 `http://backend:3001`,本地非 Docker 运行可使用 `http://localhost:3001`。服务端公开数据读取使用该内部地址,浏览器请求继续使用 `NUXT_PUBLIC_API_BASE_URL`
- 前端 Docker 构建使用 Nuxt server output`frontend` 服务通过 Node 运行 `.output/server/index.mjs`Nuxt SSR server 监听容器内 `0.0.0.0:20015`,公开流量仍由 `frontend_gateway` 代理。 - 前端 Docker 构建使用 Nuxt server output`frontend` 服务通过 Node 运行 `.output/server/index.mjs`Nuxt SSR server 监听容器内 `0.0.0.0:20015`,公开流量仍由 `frontend_gateway` 代理。
- `frontend``docker compose up -d --build` 重建、启动中或临时不可达时,`frontend_gateway` 返回静态升级维护页并保持公开端口可访问;后端 `/health` 不可用时,前端网关也返回同一维护页,避免用户看到静态页面后遇到 API 不可用。 - `frontend``docker compose up -d --build` 重建、启动中或临时不可达时,`frontend_gateway` 返回静态升级维护页并保持公开端口可访问;后端 `/health` 不可用时,前端网关也返回同一维护页,避免用户看到静态页面后遇到 API 不可用。
- 升级维护页是基础设施级静态 fallback不依赖 Vue、Vue I18n、后端 API 或数据库;页面只展示正式用户文案和品牌视觉,不展示构建日志、调试信息、内部字段或实现说明。 - 升级维护页是基础设施级静态 fallback不依赖 Vue、Vue I18n、后端 API 或数据库;页面只展示正式用户文案和品牌视觉,不展示构建日志、调试信息、内部字段或实现说明。
- 升级维护页使用 `503``Retry-After: 300``Cache-Control: no-store``noindex`,提示用户 Pokopia Wiki 正在升级并将在约 5 分钟内恢复。 - 升级维护页使用 `503``Retry-After: 300``Cache-Control: no-store``noindex`,提示用户 Pokopia Wiki 正在升级并将在约 5 分钟内恢复。
- 本地 Docker 调试使用 `docker-compose.debug.yml`,通过 bind mount 运行 Nuxt dev server 与 backend `tsx watch`,支持前后端热重载;该调试入口不经过 `frontend_gateway` 维护页,不代表生产部署行为。
## API 概览 ## API 概览
@@ -1188,7 +1189,7 @@ API 暴露边界:
- `GET /api/admin/ai-moderation` - `GET /api/admin/ai-moderation`
- `PUT /api/admin/ai-moderation` - `PUT /api/admin/ai-moderation`
- `PUT /api/admin/system-wordings/:key` - `PUT /api/admin/system-wordings/:key`
- Pokemon、物品、材料单、栖息地的列表排序需要对应实体的 `order` 权限。 - 物品、材料单、栖息地的列表排序需要对应实体的 `order` 权限Pokemon 按内部 `id` 排序,不提供列表排序 API 或 Admin 手动排序入口
## 开发与验证 ## 开发与验证
@@ -1198,3 +1199,4 @@ API 暴露边界:
- `pnpm typecheck` - `pnpm typecheck`
- 不在 WSL 中运行测试作为完成任务的前置条件。 - 不在 WSL 中运行测试作为完成任务的前置条件。
- Docker 运行问题以用户提供的 `docker compose up --build` 输出为准进行后续修复。 - Docker 运行问题以用户提供的 `docker compose up --build` 输出为准进行后续修复。
- 本地热重载调试可运行 `pnpm docker:debug``docker compose -f docker-compose.debug.yml up --build`;生产 SSR runtime 验证仍使用 `pnpm docker:prod``docker compose up --build`

View File

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

View File

@@ -231,7 +231,6 @@ VALUES
('pokemon.create', 'Create Pokemon', 'Create Pokemon records.', 'Pokemon', true), ('pokemon.create', 'Create Pokemon', 'Create Pokemon records.', 'Pokemon', true),
('pokemon.update', 'Update Pokemon', 'Edit Pokemon records.', 'Pokemon', true), ('pokemon.update', 'Update Pokemon', 'Edit Pokemon records.', 'Pokemon', true),
('pokemon.delete', 'Delete Pokemon', 'Delete Pokemon records.', 'Pokemon', true), ('pokemon.delete', 'Delete Pokemon', 'Delete Pokemon records.', 'Pokemon', true),
('pokemon.order', 'Order Pokemon', 'Reorder Pokemon records.', 'Pokemon', true),
('pokemon.fetch', 'Fetch Pokemon data', 'Fetch Pokemon data and sprite candidates.', 'Pokemon', true), ('pokemon.fetch', 'Fetch Pokemon data', 'Fetch Pokemon data and sprite candidates.', 'Pokemon', true),
('pokemon.upload', 'Upload Pokemon images', 'Upload Pokemon images.', 'Pokemon', true), ('pokemon.upload', 'Upload Pokemon images', 'Upload Pokemon images.', 'Pokemon', true),
('habitats.create', 'Create habitats', 'Create habitat records.', 'Habitats', true), ('habitats.create', 'Create habitats', 'Create habitat records.', 'Habitats', true),
@@ -275,6 +274,9 @@ VALUES
('discussions.comments.like', 'Like discussion comments', 'Like and unlike entity discussion comments.', 'Discussions', true) ('discussions.comments.like', 'Like discussion comments', 'Like and unlike entity discussion comments.', 'Discussions', true)
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
DELETE FROM permissions
WHERE key = 'pokemon.order';
INSERT INTO roles (key, name, description, level, enabled, system_role) INSERT INTO roles (key, name, description, level, enabled, system_role)
VALUES VALUES
('owner', 'Owner', 'Highest-level system owner with all permissions.', 1000, true, true), ('owner', 'Owner', 'Highest-level system owner with all permissions.', 1000, true, true),
@@ -329,7 +331,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'pokemon.create', 'pokemon.create',
'pokemon.update', 'pokemon.update',
'pokemon.delete', 'pokemon.delete',
'pokemon.order',
'pokemon.fetch', 'pokemon.fetch',
'pokemon.upload', 'pokemon.upload',
'habitats.create', 'habitats.create',
@@ -411,7 +412,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'checklist.order', 'checklist.order',
'pokemon.create', 'pokemon.create',
'pokemon.update', 'pokemon.update',
'pokemon.order',
'pokemon.fetch', 'pokemon.fetch',
'pokemon.upload', 'pokemon.upload',
'habitats.create', 'habitats.create',

View File

@@ -108,7 +108,7 @@ type ConfigDefinition = {
hasRateable?: boolean; hasRateable?: boolean;
hasChangeLog?: boolean; hasChangeLog?: boolean;
}; };
type SortableContentType = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats'; type SortableContentType = 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
type SortableContentDefinition = { type SortableContentDefinition = {
table: string; table: string;
entityType: SortableContentType; entityType: SortableContentType;
@@ -691,7 +691,6 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
}; };
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = { const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
pokemon: { table: 'pokemon', entityType: 'pokemon' },
items: { table: 'items', entityType: 'items' }, items: { table: 'items', entityType: 'items' },
'ancient-artifacts': { table: 'items', entityType: 'ancient-artifacts' }, 'ancient-artifacts': { table: 'items', entityType: 'ancient-artifacts' },
recipes: { table: 'recipes', entityType: 'recipes' }, recipes: { table: 'recipes', entityType: 'recipes' },
@@ -2809,7 +2808,7 @@ export async function globalSearch(paramsQuery: QueryParams = {}, locale = defau
${pokemonImageJson('p')} AS image ${pokemonImageJson('p')} AS image
FROM pokemon p FROM pokemon p
WHERE ${pokemonName} ILIKE $1 WHERE ${pokemonName} ILIKE $1
ORDER BY ${orderByEntity('p')} ORDER BY p.id
LIMIT $2 LIMIT $2
`, `,
[pattern, limit] [pattern, limit]
@@ -5746,11 +5745,6 @@ async function reorderContent(type: SortableContentType, payload: Record<string,
}); });
} }
export async function reorderPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
await reorderContent('pokemon', payload, userId);
return listPokemon({}, locale);
}
export async function reorderItems(payload: Record<string, unknown>, userId: number, locale = defaultLocale) { export async function reorderItems(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
await reorderContent('items', payload, userId); await reorderContent('items', payload, userId);
return listItems({}, locale); return listItems({}, locale);
@@ -5822,7 +5816,7 @@ export async function listPokemon(paramsQuery: QueryParams, locale = defaultLoca
} }
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
return queryMaybePaged(`${pokemonProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('p')}`, params, paramsQuery); return queryMaybePaged(`${pokemonProjection(locale)} ${whereClause} ORDER BY p.id`, params, paramsQuery);
} }
export async function getPokemon(id: number, locale = defaultLocale) { export async function getPokemon(id: number, locale = defaultLocale) {
@@ -5927,7 +5921,6 @@ export async function getPokemon(id: number, locale = defaultLocale) {
scored_pokemon AS ( scored_pokemon AS (
SELECT SELECT
related_pokemon.id, related_pokemon.id,
related_pokemon.sort_order,
(related_pokemon.environment_id = current_pokemon.environment_id) AS "environmentMatches", (related_pokemon.environment_id = current_pokemon.environment_id) AS "environmentMatches",
COUNT(current_favourites.favorite_thing_id)::integer AS "favoriteThingMatchCount" COUNT(current_favourites.favorite_thing_id)::integer AS "favoriteThingMatchCount"
FROM current_pokemon FROM current_pokemon
@@ -5936,7 +5929,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
ON related_pokemon_favourite.pokemon_id = related_pokemon.id ON related_pokemon_favourite.pokemon_id = related_pokemon.id
LEFT JOIN current_favourites LEFT JOIN current_favourites
ON current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id ON current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
GROUP BY related_pokemon.id, related_pokemon.sort_order, related_pokemon.environment_id, current_pokemon.environment_id GROUP BY related_pokemon.id, related_pokemon.environment_id, current_pokemon.environment_id
HAVING related_pokemon.environment_id = current_pokemon.environment_id HAVING related_pokemon.environment_id = current_pokemon.environment_id
OR COUNT(current_favourites.favorite_thing_id) > 0 OR COUNT(current_favourites.favorite_thing_id) > 0
) )
@@ -5981,7 +5974,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
FROM scored_pokemon FROM scored_pokemon
JOIN pokemon related_pokemon ON related_pokemon.id = scored_pokemon.id JOIN pokemon related_pokemon ON related_pokemon.id = scored_pokemon.id
JOIN environments related_environment ON related_environment.id = related_pokemon.environment_id JOIN environments related_environment ON related_environment.id = related_pokemon.environment_id
ORDER BY scored_pokemon."environmentMatches" DESC, scored_pokemon."favoriteThingMatchCount" DESC, scored_pokemon.sort_order, related_pokemon.id ORDER BY scored_pokemon."environmentMatches" DESC, scored_pokemon."favoriteThingMatchCount" DESC, related_pokemon.id
`, `,
[id] [id]
), ),
@@ -6369,10 +6362,10 @@ export async function listHabitats(paramsQuery: QueryParams = {}, locale = defau
'name', pokemon_rows.name, 'name', pokemon_rows.name,
'isEventItem', pokemon_rows.is_event_item 'isEventItem', pokemon_rows.is_event_item
) )
ORDER BY pokemon_rows.sort_order, pokemon_rows.id ORDER BY pokemon_rows.id
) )
FROM ( FROM (
SELECT DISTINCT p.id, p.display_id, ${pokemonName} AS name, p.is_event_item, p.sort_order SELECT DISTINCT p.id, p.display_id, ${pokemonName} AS name, p.is_event_item
FROM habitat_pokemon hp FROM habitat_pokemon hp
JOIN pokemon p ON p.id = hp.pokemon_id JOIN pokemon p ON p.id = hp.pokemon_id
WHERE hp.habitat_id = h.id WHERE hp.habitat_id = h.id
@@ -6443,7 +6436,7 @@ export async function getHabitat(id: number, locale = defaultLocale) {
JOIN pokemon p ON p.id = hp.pokemon_id JOIN pokemon p ON p.id = hp.pokemon_id
JOIN maps m ON m.id = hp.map_id JOIN maps m ON m.id = hp.map_id
WHERE hp.habitat_id = $1 WHERE hp.habitat_id = $1
ORDER BY hp.rarity, ${orderByEntity('p')}, ${orderByEntity('m')} ORDER BY hp.rarity, p.id, ${orderByEntity('m')}
`, `,
[id] [id]
), ),
@@ -6855,7 +6848,7 @@ export async function getItem(id: number, locale = defaultLocale) {
JOIN skills s ON s.id = psid.skill_id JOIN skills s ON s.id = psid.skill_id
WHERE psid.item_id = $1 WHERE psid.item_id = $1
AND s.has_item_drop = true AND s.has_item_drop = true
ORDER BY ${orderByEntity('p')}, ${orderByEntity('s')} ORDER BY p.id, ${orderByEntity('s')}
`, `,
[id] [id]
), ),
@@ -6893,7 +6886,7 @@ export async function getItem(id: number, locale = defaultLocale) {
WHERE ps.pokemon_id = p.id WHERE ps.pokemon_id = p.id
AND trading_skill.has_trading = true AND trading_skill.has_trading = true
) )
ORDER BY pti.preference DESC, ${orderByEntity('p')} ORDER BY pti.preference DESC, p.id
`, `,
[id] [id]
), ),
@@ -8493,7 +8486,7 @@ async function exportGenericScopeData(client: DbClient, entityType: string, incl
async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<DataToolScopeData> { async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<DataToolScopeData> {
if (scope === 'pokemon') { if (scope === 'pokemon') {
return { return {
pokemon: await tableRows(client, 'SELECT * FROM pokemon ORDER BY sort_order, id'), pokemon: await tableRows(client, 'SELECT * FROM pokemon ORDER BY id'),
pokemonTypeLinks: await tableRows(client, 'SELECT * FROM pokemon_pokemon_types ORDER BY pokemon_id, slot_order'), pokemonTypeLinks: await tableRows(client, 'SELECT * FROM pokemon_pokemon_types ORDER BY pokemon_id, slot_order'),
pokemonSkills: await tableRows(client, 'SELECT * FROM pokemon_skills ORDER BY pokemon_id, skill_id'), pokemonSkills: await tableRows(client, 'SELECT * FROM pokemon_skills ORDER BY pokemon_id, skill_id'),
pokemonFavoriteThings: await tableRows(client, 'SELECT * FROM pokemon_favorite_things ORDER BY pokemon_id, favorite_thing_id'), pokemonFavoriteThings: await tableRows(client, 'SELECT * FROM pokemon_favorite_things ORDER BY pokemon_id, favorite_thing_id'),

View File

@@ -111,7 +111,6 @@ import {
reorderHabitats, reorderHabitats,
reorderItems, reorderItems,
reorderLanguages, reorderLanguages,
reorderPokemon,
reorderRecipes, reorderRecipes,
retryEntityDiscussionCommentModeration, retryEntityDiscussionCommentModeration,
retryLifeCommentModeration, retryLifeCommentModeration,
@@ -185,7 +184,7 @@ function configuredCorsOrigin(): true | string | string[] {
} }
await app.register(cors, { await app.register(cors, {
allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'], allowedHeaders: ['Content-Type', 'X-Locale'],
credentials: true, credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
origin: configuredCorsOrigin() origin: configuredCorsOrigin()
@@ -247,11 +246,6 @@ app.get('/api/search', async (request) =>
globalSearch(request.query as Record<string, string | string[] | undefined>, requestLocale(request)) globalSearch(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
); );
function getBearerToken(authorization: string | undefined): string | null {
const [scheme, token] = authorization?.split(' ') ?? [];
return scheme === 'Bearer' && token ? token : null;
}
function getCookieValue(cookieHeader: string | undefined, name: string): string | null { function getCookieValue(cookieHeader: string | undefined, name: string): string | null {
if (!cookieHeader) { if (!cookieHeader) {
return null; return null;
@@ -272,7 +266,7 @@ function getCookieValue(cookieHeader: string | undefined, name: string): string
} }
function getSessionToken(request: FastifyRequest): string | null { function getSessionToken(request: FastifyRequest): string | null {
return getCookieValue(request.headers.cookie, sessionCookieName) ?? getBearerToken(request.headers.authorization); return getCookieValue(request.headers.cookie, sessionCookieName);
} }
function sessionCookieSecure(): boolean { function sessionCookieSecure(): boolean {
@@ -1038,7 +1032,7 @@ app.post('/api/auth/login', async (request, reply) => {
const payload = request.body as Record<string, unknown>; const payload = request.body as Record<string, unknown>;
const response = await loginUser(payload, requestLocale(request)); const response = await loginUser(payload, requestLocale(request));
setSessionCookie(reply, response.token, payload.rememberMe === true); setSessionCookie(reply, response.token, payload.rememberMe === true);
return response; return { user: response.user };
}); });
app.post('/api/auth/request-password-reset', async (request, reply) => { app.post('/api/auth/request-password-reset', async (request, reply) => {
@@ -2097,11 +2091,6 @@ app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
return deleted ? reply.code(204).send() : notFound(reply, request); return deleted ? reply.code(204).send() : notFound(reply, request);
}); });
app.put('/api/admin/pokemon/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.order', 'wikiWrite');
return user ? reorderPokemon(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/items/order', async (request, reply) => { app.put('/api/admin/items/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'items.order', 'wikiWrite'); const user = await requirePermissionWithRateLimits(request, reply, 'items.order', 'wikiWrite');
return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined; return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;

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

@@ -21,7 +21,7 @@ import {
type AppIcon type AppIcon
} from './src/icons'; } from './src/icons';
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n'; import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './src/services/api'; import { api, notifyAuthChange, onAuthChange, type AuthUser, type Language } from './src/services/api';
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const router = useRouter(); const router = useRouter();
@@ -117,9 +117,6 @@ async function loadCurrentUser() {
currentUser.value = response.user; currentUser.value = response.user;
} catch { } catch {
currentUser.value = null; currentUser.value = null;
if (getAuthToken()) {
setAuthToken(null);
}
} }
} }
@@ -131,7 +128,7 @@ async function logout() {
} }
currentUser.value = null; currentUser.value = null;
setAuthToken(null); notifyAuthChange();
await router.push('/'); await router.push('/');
} }
@@ -160,7 +157,7 @@ async function updateLocale(value: string) {
onMounted(() => { onMounted(() => {
void loadLanguages(); void loadLanguages();
void loadCurrentUser(); void loadCurrentUser();
removeAuthListener = onAuthTokenChange(() => { removeAuthListener = onAuthChange(() => {
void loadCurrentUser(); void loadCurrentUser();
}); });
removeLocaleListener = onLocaleChange(() => { removeLocaleListener = onLocaleChange(() => {

View File

@@ -1,4 +1,4 @@
import { api, setAuthToken } from '../src/services/api'; import { api } from '../src/services/api';
export default defineNuxtRouteMiddleware(async (to) => { export default defineNuxtRouteMiddleware(async (to) => {
const requiredPermissions = to.matched const requiredPermissions = to.matched
@@ -30,7 +30,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
return navigateTo('/pokemon'); return navigateTo('/pokemon');
} }
} catch { } catch {
setAuthToken(null);
return navigateTo({ path: '/login', query: { redirect: to.fullPath } }); return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
} }
}); });

View File

@@ -12,13 +12,11 @@ export default defineNuxtConfig({
runtimeConfig: { runtimeConfig: {
serverApiBaseUrl: serverApiBaseUrl:
process.env.NUXT_SERVER_API_BASE_URL ?? process.env.NUXT_SERVER_API_BASE_URL ??
process.env.NUXT_API_BASE_URL ??
process.env.NUXT_PUBLIC_API_BASE_URL ?? process.env.NUXT_PUBLIC_API_BASE_URL ??
process.env.VITE_API_BASE_URL ??
'http://localhost:3001', 'http://localhost:3001',
public: { public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? process.env.VITE_API_BASE_URL ?? 'http://localhost:3001', apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3001',
siteUrl: normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL) siteUrl: normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL)
} }
}, },
app: { app: {

View File

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

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,47 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import Modal from './Modal.vue';
import { iconCancel, iconDelete } from '../icons';
withDefaults(
defineProps<{
title: string;
message: string;
confirmLabel: string;
cancelLabel: string;
closeLabel: string;
busy?: boolean;
}>(),
{
busy: false
}
);
const emit = defineEmits<{
cancel: [];
confirm: [];
}>();
</script>
<template>
<Modal
:title="title"
:close-label="closeLabel"
:close-on-backdrop="!busy"
:close-on-escape="!busy"
@close="emit('cancel')"
>
<p class="confirm-dialog__message">{{ message }}</p>
<template #footer>
<button type="button" class="link-button link-button--danger" :disabled="busy" @click="emit('confirm')">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ confirmLabel }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="emit('cancel')">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ cancelLabel }}
</button>
</template>
</Modal>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import EditMeta from './EditMeta.vue';
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api'; import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
const props = defineProps<{ const props = defineProps<{
@@ -169,11 +170,7 @@ function formatDateTime(value: string): string {
<div> <div>
<dt>{{ t('history.lastEdited') }}</dt> <dt>{{ t('history.lastEdited') }}</dt>
<dd> <dd>
<RouterLink v-if="props.entity.updatedBy" class="user-profile-link" :to="`/profile/${props.entity.updatedBy.id}`"> <EditMeta :entity="props.entity" :show-label="false" />
{{ props.entity.updatedBy.displayName }}
</RouterLink>
<strong v-else>{{ displayName(props.entity.updatedBy) }}</strong>
<time :datetime="props.entity.updatedAt">{{ formatDateTime(props.entity.updatedAt) }}</time>
</dd> </dd>
</div> </div>
</dl> </dl>

View File

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

View File

@@ -2,16 +2,15 @@
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import ConfirmDialog from './ConfirmDialog.vue';
import LoadMoreSentinel from './LoadMoreSentinel.vue'; import LoadMoreSentinel from './LoadMoreSentinel.vue';
import StatusBadge from './StatusBadge.vue'; import StatusBadge from './StatusBadge.vue';
import Tabs, { type TabOption } from './Tabs.vue'; import Tabs, { type TabOption } from './Tabs.vue';
import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons'; import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons';
import { import {
api, api,
getAuthToken,
moderationUpdateEvent, moderationUpdateEvent,
onAuthTokenChange, onAuthChange,
setAuthToken,
type AiModerationStatus, type AiModerationStatus,
type AuthUser, type AuthUser,
type CommentSort, type CommentSort,
@@ -54,6 +53,8 @@ let removeAuthListener: (() => void) | null = null;
const nextCursor = ref<string | null>(null); const nextCursor = ref<string | null>(null);
const hasMoreComments = ref(false); const hasMoreComments = ref(false);
const commentTotal = ref(0); const commentTotal = ref(0);
const pendingDeleteComment = ref<EntityDiscussionComment | null>(null);
const deleteConfirmBusy = ref(false);
function can(permissionKey: string) { function can(permissionKey: string) {
return currentUser.value?.permissions.includes(permissionKey) === true; return currentUser.value?.permissions.includes(permissionKey) === true;
@@ -77,18 +78,11 @@ const sortOptions = computed<Array<{ value: CommentSort; label: string }>>(() =>
async function loadCurrentUser() { async function loadCurrentUser() {
authReady.value = false; authReady.value = false;
if (!getAuthToken()) {
currentUser.value = null;
authReady.value = true;
return;
}
try { try {
const response = await api.me(); const response = await api.me();
currentUser.value = response.user; currentUser.value = response.user;
} catch { } catch {
currentUser.value = null; currentUser.value = null;
setAuthToken(null);
} finally { } finally {
authReady.value = true; authReady.value = true;
} }
@@ -471,11 +465,34 @@ function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolea
return false; return false;
} }
async function deleteComment(comment: EntityDiscussionComment) { function requestDeleteComment(comment: EntityDiscussionComment) {
if (!window.confirm(t('discussion.deleteConfirm'))) { pendingDeleteComment.value = comment;
}
function closeDeleteConfirm() {
if (deleteConfirmBusy.value) {
return; return;
} }
pendingDeleteComment.value = null;
}
async function confirmDeleteComment() {
const comment = pendingDeleteComment.value;
if (!comment) {
return;
}
deleteConfirmBusy.value = true;
try {
await deleteComment(comment);
pendingDeleteComment.value = null;
} finally {
deleteConfirmBusy.value = false;
}
}
async function deleteComment(comment: EntityDiscussionComment) {
const key = commentKey(comment.id); const key = commentKey(comment.id);
clearCommentError(key); clearCommentError(key);
@@ -515,7 +532,7 @@ onMounted(() => {
void loadCurrentUser(); void loadCurrentUser();
void loadLanguages(); void loadLanguages();
void loadDiscussion(); void loadDiscussion();
removeAuthListener = onAuthTokenChange(() => { removeAuthListener = onAuthChange(() => {
void loadCurrentUser(); void loadCurrentUser();
}); });
}); });
@@ -657,7 +674,7 @@ onUnmounted(() => {
class="life-icon-button life-icon-button--flat life-icon-button--danger" class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button" type="button"
:aria-label="t('discussion.deleteComment')" :aria-label="t('discussion.deleteComment')"
@click="deleteComment(comment)" @click="requestDeleteComment(comment)"
> >
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
@@ -759,7 +776,7 @@ onUnmounted(() => {
class="life-icon-button life-icon-button--flat life-icon-button--danger" class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button" type="button"
:aria-label="t('discussion.deleteComment')" :aria-label="t('discussion.deleteComment')"
@click="deleteComment(reply)" @click="requestDeleteComment(reply)"
> >
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
@@ -787,5 +804,17 @@ onUnmounted(() => {
<p>{{ t('discussion.emptyHint') }}</p> <p>{{ t('discussion.emptyHint') }}</p>
</div> </div>
</div> </div>
<ConfirmDialog
v-if="pendingDeleteComment"
:title="t('discussion.deleteComment')"
:message="t('discussion.deleteConfirm')"
:confirm-label="t('common.delete')"
:cancel-label="t('common.cancel')"
:close-label="t('common.close')"
:busy="deleteConfirmBusy"
@cancel="closeDeleteConfirm"
@confirm="confirmDeleteComment"
/>
</section> </section>
</template> </template>

View File

@@ -4,7 +4,7 @@ let openModalCount = 0;
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue'; import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, useId, watch } from 'vue';
import { iconClose } from '../icons'; import { iconClose } from '../icons';
const props = withDefaults( const props = withDefaults(
@@ -29,7 +29,7 @@ const emit = defineEmits<{
close: []; close: [];
}>(); }>();
const titleId = `modal-title-${Math.random().toString(36).slice(2)}`; const titleId = useId();
const dialog = ref<HTMLElement | null>(null); const dialog = ref<HTMLElement | null>(null);
const modalBody = ref<HTMLElement | null>(null); const modalBody = ref<HTMLElement | null>(null);
const closeButton = ref<HTMLButtonElement | null>(null); const closeButton = ref<HTMLButtonElement | null>(null);

View File

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

View File

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

View File

@@ -143,6 +143,39 @@ export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
}; };
} }
export function resolvedSeoHead(seo: ResolvedSeoConfig) {
return {
title: seo.title,
htmlAttrs: {
lang: seo.locale
},
meta: [
{ key: 'description', name: 'description', content: seo.description },
{ key: 'robots', name: 'robots', content: seo.robots },
{ key: 'twitter-card', name: 'twitter:card', content: 'summary_large_image' },
{ key: 'twitter-title', name: 'twitter:title', content: seo.title },
{ key: 'twitter-description', name: 'twitter:description', content: seo.description },
{ key: 'twitter-image', name: 'twitter:image', content: seo.imageUrl },
{ key: 'og-site-name', property: 'og:site_name', content: siteName },
{ key: 'og-type', property: 'og:type', content: '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 { export function routeSeoConfig(route: RouteLocationNormalizedLoaded, translator?: Translator): SeoConfig {
const routeSeo = route.meta.seo as RouteSeoConfig | undefined; const routeSeo = route.meta.seo as RouteSeoConfig | undefined;
const canonicalPath = const canonicalPath =

View File

@@ -2,7 +2,6 @@ import { getCurrentLocale } from '../i18n';
let browserApiBaseUrl = 'http://localhost:3001'; let browserApiBaseUrl = 'http://localhost:3001';
let serverApiBaseUrl = 'http://localhost:3001'; let serverApiBaseUrl = 'http://localhost:3001';
const authTokenKey = 'pokopia_auth_token';
const authChangeEvent = 'pokopia-auth-change'; const authChangeEvent = 'pokopia-auth-change';
export interface ApiRequestOptions { export interface ApiRequestOptions {
@@ -807,7 +806,6 @@ export interface RegisterPayload extends LoginPayload {
} }
export interface AuthResponse { export interface AuthResponse {
token: string;
user: AuthUser; user: AuthUser;
} }
@@ -1061,40 +1059,7 @@ export function buildQuery(params: Record<string, string | number | boolean | nu
return query ? `?${query}` : ''; return query ? `?${query}` : '';
} }
function authStorage(type: 'local' | 'session'): Storage | null { export function onAuthChange(callback: () => void): () => void {
if (typeof window === 'undefined') {
return null;
}
return type === 'local' ? window.localStorage : window.sessionStorage;
}
export function getAuthToken(): string | null {
const sessionToken = authStorage('session')?.getItem(authTokenKey);
return sessionToken ?? authStorage('local')?.getItem(authTokenKey) ?? null;
}
export function setAuthToken(token: string | null, options: { persistent?: boolean } = {}): void {
const local = authStorage('local');
const session = authStorage('session');
if (token) {
if (options.persistent === false) {
session?.setItem(authTokenKey, token);
local?.removeItem(authTokenKey);
} else {
local?.setItem(authTokenKey, token);
session?.removeItem(authTokenKey);
}
} else {
local?.removeItem(authTokenKey);
session?.removeItem(authTokenKey);
}
notifyAuthChange();
}
export function onAuthTokenChange(callback: () => void): () => void {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return () => {}; return () => {};
} }
@@ -1111,12 +1076,7 @@ export function notifyAuthChange(): void {
function requestHeaders(extraHeaders?: HeadersInit): Headers { function requestHeaders(extraHeaders?: HeadersInit): Headers {
const headers = new Headers(extraHeaders); const headers = new Headers(extraHeaders);
const token = getAuthToken();
headers.set('X-Locale', headers.get('X-Locale') ?? getCurrentLocale()); headers.set('X-Locale', headers.get('X-Locale') ?? getCurrentLocale());
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers; return headers;
} }
@@ -1500,7 +1460,6 @@ export const api = {
updatePokemon: (id: string | number, payload: PokemonPayload) => updatePokemon: (id: string | number, payload: PokemonPayload) =>
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload), sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`), deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`),
reorderPokemon: (ids: number[]) => sendJson<Pokemon[]>('/api/admin/pokemon/order', 'PUT', { ids }),
habitats: (params: Record<string, string | number | boolean | undefined> = {}) => habitats: (params: Record<string, string | number | boolean | undefined> = {}) =>
getJson<Habitat[]>(`/api/habitats${buildQuery(params)}`), getJson<Habitat[]>(`/api/habitats${buildQuery(params)}`),
habitatsPage: (params: PublicListQueryParams = {}) => habitatsPage: (params: PublicListQueryParams = {}) =>

View File

@@ -1318,6 +1318,11 @@ svg {
--btn-fg: #ffffff; --btn-fg: #ffffff;
} }
.link-button--danger {
--btn-bg: var(--danger);
--btn-fg: #ffffff;
}
.ui-button--ghost, .ui-button--ghost,
.plain-button, .plain-button,
.row-actions button, .row-actions button,
@@ -2762,6 +2767,14 @@ button:disabled,
font-weight: 850; font-weight: 850;
} }
.confirm-dialog__message {
margin: 0;
color: var(--ink-soft);
font-size: 15px;
font-weight: 750;
line-height: 1.5;
}
.checklist-list { .checklist-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -5130,6 +5143,14 @@ button:disabled,
flex-wrap: wrap; flex-wrap: wrap;
} }
.trading-detail-list {
max-height: 360px;
overflow: auto;
padding-right: 4px;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.trading-manager { .trading-manager {
min-height: 640px; min-height: 640px;
display: grid; display: grid;
@@ -5219,6 +5240,11 @@ button:disabled,
background: var(--surface-soft); background: var(--surface-soft);
} }
.trading-pick-row--active {
border-color: var(--pokemon-blue);
box-shadow: 0 0 0 2px rgba(42, 117, 187, .16);
}
.trading-pick-row__copy, .trading-pick-row__copy,
.trading-selected-list__copy { .trading-selected-list__copy {
min-width: 0; min-width: 0;
@@ -5307,6 +5333,10 @@ button:disabled,
max-height: 240px; max-height: 240px;
} }
.trading-detail-list {
max-height: 280px;
}
.trading-preference-toggle, .trading-preference-toggle,
.trading-selected-list .plain-button--icon { .trading-selected-list .plain-button--icon {
grid-column: 2; grid-column: 2;
@@ -7534,6 +7564,12 @@ button:disabled,
align-items: center; align-items: center;
} }
.switch-group__options--grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
align-items: stretch;
}
.switch-control { .switch-control {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
@@ -7553,7 +7589,29 @@ button:disabled,
gap: 6px; gap: 6px;
} }
.switch-group__options--grid .switch-control--stacked {
min-height: 52px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
justify-items: stretch;
gap: 10px;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
}
.switch-control--disabled {
cursor: not-allowed;
opacity: 0.58;
}
.switch-control__copy {
min-width: 0;
}
.switch-control__label { .switch-control__label {
display: block;
color: var(--ink-soft); color: var(--ink-soft);
font-size: 13px; font-size: 13px;
line-height: 1.2; line-height: 1.2;
@@ -7561,6 +7619,21 @@ button:disabled,
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.switch-control__description {
display: block;
margin-top: 2px;
color: var(--muted);
font-size: 12px;
font-weight: 650;
line-height: 1.3;
overflow-wrap: anywhere;
}
.switch-group__options--grid .switch-control__label,
.switch-group__options--grid .switch-control__description {
text-align: left;
}
.switch-control input { .switch-control input {
position: absolute; position: absolute;
inline-size: 1px; inline-size: 1px;

View File

@@ -7,6 +7,8 @@ import PageHeader from '../components/PageHeader.vue';
import ReorderableList from '../components/ReorderableList.vue'; import ReorderableList from '../components/ReorderableList.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import SwitchGroup, { type SwitchGroupOption } from '../components/SwitchGroup.vue';
import TagsSelect, { type TagsSelectOption } from '../components/TagsSelect.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import TranslationFields from '../components/TranslationFields.vue'; import TranslationFields from '../components/TranslationFields.vue';
import { import {
@@ -154,7 +156,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
label: t('pages.admin.contentGroup'), label: t('pages.admin.contentGroup'),
items: [ items: [
{ key: 'checklist', label: t('pages.admin.checklist'), permission: ['checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order'] }, { key: 'checklist', label: t('pages.admin.checklist'), permission: ['checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order'] },
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.delete'] }, { key: 'pokemon', label: t('pages.admin.pokemonList'), permission: 'pokemon.delete' },
{ key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] }, { key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
{ {
key: 'ancientArtifacts', key: 'ancientArtifacts',
@@ -369,6 +371,31 @@ const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDis
const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes)); const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes));
const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null); const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null);
const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2); const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2);
const dishItemSelectOptions = computed<TagsSelectOption[]>(() => dishItemRows.value.map((item) => ({ id: item.id, name: item.name })));
const optionalDishItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...dishItemSelectOptions.value]);
const dishCategorySelectOptions = computed<TagsSelectOption[]>(() =>
dishCategoryRows.value.map((category) => ({ id: category.id, name: category.name }))
);
const dishFlavorSelectOptions = computed<TagsSelectOption[]>(() => dishFlavorRows.value.map((flavor) => ({ id: flavor.id, name: flavor.name })));
const optionalDishSkillSelectOptions = computed<TagsSelectOption[]>(() => [
{ id: '', name: t('common.none') },
...dishSkillRows.value.map((skill) => ({ id: skill.id, name: skill.name }))
]);
const dishCategoryFormValid = computed(
() =>
dishCategoryForm.value.name.trim() !== '' &&
dishCategoryForm.value.effect.trim() !== '' &&
dishCategoryForm.value.cookwareItemId !== '' &&
dishCategoryForm.value.mainMaterialItemId !== '' &&
Number(dishCategoryForm.value.totalMaterialQuantity) >= 2
);
const dishFormValid = computed(
() =>
dishForm.value.categoryId !== '' &&
dishForm.value.itemId !== '' &&
dishForm.value.flavorId !== '' &&
dishForm.value.mosslaxEffect.trim() !== ''
);
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage'))); const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
const wordingModalTitle = computed(() => t('pages.admin.editWording')); const wordingModalTitle = computed(() => t('pages.admin.editWording'));
const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole'))); const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole')));
@@ -386,6 +413,26 @@ const permissionGroups = computed(() => {
} }
return [...groups.entries()].map(([category, permissions]) => ({ category, permissions })); return [...groups.entries()].map(([category, permissions]) => ({ category, permissions }));
}); });
const userRoleSwitchOptions = computed<SwitchGroupOption[]>(() =>
roleRows.value.map((role) => ({
value: role.id,
label: role.name,
description: role.description,
disabled: busy.value || !role.enabled
}))
);
const userRoleSwitchValue = computed<Array<string | number>>({
get: () => userRoleForm.value.roleIds,
set: (values) => {
userRoleForm.value.roleIds = values.map((value) => Number(value)).sort((a, b) => a - b);
}
});
const rolePermissionSwitchValue = computed<Array<string | number>>({
get: () => rolePermissionForm.value.permissionIds,
set: (values) => {
rolePermissionForm.value.permissionIds = values.map((value) => Number(value)).sort((a, b) => a - b);
}
});
const wordingLocaleOptions = computed(() => const wordingLocaleOptions = computed(() =>
languageRows.value.length languageRows.value.length
? languageRows.value ? languageRows.value
@@ -455,8 +502,6 @@ const languageKey = (item: Language) => item.code;
const languageLabel = (item: Language) => item.name; const languageLabel = (item: Language) => item.name;
const configKey = (item: EditableConfig) => item.id; const configKey = (item: EditableConfig) => item.id;
const configLabel = (item: EditableConfig) => item.name; const configLabel = (item: EditableConfig) => item.name;
const pokemonKey = (item: Pokemon) => item.id;
const pokemonLabel = (item: Pokemon) => `#${item.displayId} ${item.name}`;
const itemKey = (item: Item) => item.id; const itemKey = (item: Item) => item.id;
const itemLabel = (item: Item) => item.name; const itemLabel = (item: Item) => item.name;
const ancientArtifactKey = (item: AncientArtifact) => item.id; const ancientArtifactKey = (item: AncientArtifact) => item.id;
@@ -525,24 +570,13 @@ function rolePermissionCount(role: RoleDetail) {
return t('pages.admin.permissionCount', { count: role.permissionIds.length }); return t('pages.admin.permissionCount', { count: role.permissionIds.length });
} }
function toggleUserRole(roleId: number) { function permissionSwitchOptions(permissions: Permission[]): SwitchGroupOption[] {
const roleIds = new Set(userRoleForm.value.roleIds); return permissions.map((permission) => ({
if (roleIds.has(roleId)) { value: permission.id,
roleIds.delete(roleId); label: permission.name,
} else { description: permission.key,
roleIds.add(roleId); disabled: busy.value || !permission.enabled
} }));
userRoleForm.value.roleIds = [...roleIds].sort((a, b) => a - b);
}
function toggleRolePermission(permissionId: number) {
const permissionIds = new Set(rolePermissionForm.value.permissionIds);
if (permissionIds.has(permissionId)) {
permissionIds.delete(permissionId);
} else {
permissionIds.add(permissionId);
}
rolePermissionForm.value.permissionIds = [...permissionIds].sort((a, b) => a - b);
} }
function errorText(error: unknown, fallback: string) { function errorText(error: unknown, fallback: string) {
@@ -896,10 +930,6 @@ function previewConfigOrder(rows: EditableConfig[]) {
configRows.value = rows; configRows.value = rows;
} }
function previewPokemonOrder(rows: Pokemon[]) {
pokemonRows.value = rows;
}
function previewItemOrder(rows: Item[]) { function previewItemOrder(rows: Item[]) {
itemRows.value = rows; itemRows.value = rows;
} }
@@ -968,18 +998,6 @@ async function persistConfigOrder(nextRows: EditableConfig[], fallbackRows: Edit
}); });
} }
async function persistPokemonOrder(nextRows: Pokemon[], fallbackRows: Pokemon[]) {
pokemonRows.value = nextRows;
await run(async () => {
try {
pokemonRows.value = await api.reorderPokemon(nextRows.map((item) => item.id));
} catch (error) {
pokemonRows.value = fallbackRows;
throw error;
}
});
}
async function persistItemOrder(nextRows: Item[], fallbackRows: Item[]) { async function persistItemOrder(nextRows: Item[], fallbackRows: Item[]) {
itemRows.value = nextRows; itemRows.value = nextRows;
await run(async () => { await run(async () => {
@@ -1129,6 +1147,10 @@ function dishPayloadForSave() {
} }
async function saveDishCategory() { async function saveDishCategory() {
if (!dishCategoryFormValid.value) {
return;
}
await run(async () => { await run(async () => {
const payload = dishCategoryPayloadForSave(); const payload = dishCategoryPayloadForSave();
if (dishCategoryForm.value.id) { if (dishCategoryForm.value.id) {
@@ -1142,6 +1164,10 @@ async function saveDishCategory() {
} }
async function saveDish() { async function saveDish() {
if (!dishFormValid.value) {
return;
}
await run(async () => { await run(async () => {
const payload = dishPayloadForSave(); const payload = dishPayloadForSave();
if (dishForm.value.id) { if (dishForm.value.id) {
@@ -2275,20 +2301,8 @@ onMounted(() => {
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
<h2>{{ t('pages.admin.pokemonList') }}</h2> <h2>{{ t('pages.admin.pokemonList') }}</h2>
<ReorderableList <ul v-if="pokemonRows.length" class="row-list">
v-if="pokemonRows.length" <li v-for="item in pokemonRows" :key="item.id">
:items="pokemonRows"
:item-key="pokemonKey"
:item-label="pokemonLabel"
list-key-prefix="pokemon"
:disabled="busy || !can('pokemon.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewPokemonOrder"
@cancel="previewPokemonOrder"
@reorder="persistPokemonOrder"
>
<template #default="{ item }">
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink> <RouterLink :to="`/pokemon/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
<span class="row-actions"> <span class="row-actions">
<button v-if="can('pokemon.delete')" type="button" :disabled="busy" @click="removePokemon(item.id)"> <button v-if="can('pokemon.delete')" type="button" :disabled="busy" @click="removePokemon(item.id)">
@@ -2296,8 +2310,8 @@ onMounted(() => {
{{ t('common.delete') }} {{ t('common.delete') }}
</button> </button>
</span> </span>
</template> </li>
</ReorderableList> </ul>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
@@ -2537,20 +2551,7 @@ onMounted(() => {
<strong>{{ editingUser.displayName }}</strong> <strong>{{ editingUser.displayName }}</strong>
<span class="meta-line">{{ editingUser.email }}</span> <span class="meta-line">{{ editingUser.email }}</span>
</div> </div>
<div class="permission-grid" role="group" :aria-label="t('pages.admin.roles')"> <SwitchGroup id="admin-user-roles" v-model="userRoleSwitchValue" :label="t('pages.admin.roles')" :options="userRoleSwitchOptions" layout="grid" />
<label v-for="role in roleRows" :key="role.id" class="permission-toggle">
<input
type="checkbox"
:checked="userRoleForm.roleIds.includes(role.id)"
:disabled="busy || !role.enabled"
@change="toggleUserRole(role.id)"
/>
<span>
<strong>{{ role.name }}</strong>
<small>{{ role.description }}</small>
</span>
</label>
</div>
</form> </form>
<template #footer> <template #footer>
@@ -2607,22 +2608,14 @@ onMounted(() => {
<span class="meta-line">{{ editingRole.description }}</span> <span class="meta-line">{{ editingRole.description }}</span>
</div> </div>
<div class="permission-groups"> <div class="permission-groups">
<section v-for="group in permissionGroups" :key="group.category" class="permission-group"> <section v-for="(group, index) in permissionGroups" :key="group.category" class="permission-group">
<h3>{{ group.category }}</h3> <SwitchGroup
<div class="permission-grid" role="group" :aria-label="group.category"> :id="`admin-role-permissions-${index}`"
<label v-for="permission in group.permissions" :key="permission.id" class="permission-toggle"> v-model="rolePermissionSwitchValue"
<input :label="group.category"
type="checkbox" :options="permissionSwitchOptions(group.permissions)"
:checked="rolePermissionForm.permissionIds.includes(permission.id)" layout="grid"
:disabled="busy || !permission.enabled" />
@change="toggleRolePermission(permission.id)"
/>
<span>
<strong>{{ permission.name }}</strong>
<small>{{ permission.key }}</small>
</span>
</label>
</div>
</section> </section>
</div> </div>
</form> </form>
@@ -2713,10 +2706,14 @@ onMounted(() => {
/> />
<div class="field"> <div class="field">
<label for="dish-category-cookware">{{ t('pages.dish.cookware') }}</label> <label for="dish-category-cookware">{{ t('pages.dish.cookware') }}</label>
<select id="dish-category-cookware" v-model="dishCategoryForm.cookwareItemId" required> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-category-cookware"
<option v-for="item in dishItemRows" :key="`cookware-${item.id}`" :value="String(item.id)">{{ item.name }}</option> v-model="dishCategoryForm.cookwareItemId"
</select> :options="dishItemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div> </div>
<div class="field"> <div class="field">
<label for="dish-category-total-material-quantity">{{ t('pages.dish.totalMaterialQuantity') }}</label> <label for="dish-category-total-material-quantity">{{ t('pages.dish.totalMaterialQuantity') }}</label>
@@ -2724,10 +2721,14 @@ onMounted(() => {
</div> </div>
<div class="field"> <div class="field">
<label for="dish-category-main-material">{{ t('pages.dish.mainMaterial') }}</label> <label for="dish-category-main-material">{{ t('pages.dish.mainMaterial') }}</label>
<select id="dish-category-main-material" v-model="dishCategoryForm.mainMaterialItemId" required> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-category-main-material"
<option v-for="item in dishItemRows" :key="`category-main-material-${item.id}`" :value="String(item.id)">{{ item.name }}</option> v-model="dishCategoryForm.mainMaterialItemId"
</select> :options="dishItemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div> </div>
</div> </div>
<TranslationFields <TranslationFields
@@ -2742,7 +2743,7 @@ onMounted(() => {
</form> </form>
<template #footer> <template #footer>
<button type="submit" form="admin-dish-category-form" class="link-button" :disabled="busy"> <button type="submit" form="admin-dish-category-form" class="link-button" :disabled="busy || !dishCategoryFormValid">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }} {{ busy ? t('common.saving') : t('common.save') }}
</button> </button>
@@ -2758,47 +2759,71 @@ onMounted(() => {
<div class="dish-form-row dish-form-row--3"> <div class="dish-form-row dish-form-row--3">
<div class="field"> <div class="field">
<label for="dish-category">{{ t('pages.dish.category') }}</label> <label for="dish-category">{{ t('pages.dish.category') }}</label>
<select id="dish-category" v-model="dishForm.categoryId" required> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-category"
<option v-for="category in dishCategoryRows" :key="`dish-category-option-${category.id}`" :value="String(category.id)">{{ category.name }}</option> v-model="dishForm.categoryId"
</select> :options="dishCategorySelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.dish.category')"
/>
</div> </div>
<div class="field"> <div class="field">
<label for="dish-item">{{ t('pages.dish.dishItem') }}</label> <label for="dish-item">{{ t('pages.dish.dishItem') }}</label>
<select id="dish-item" v-model="dishForm.itemId" required> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-item"
<option v-for="item in dishItemRows" :key="`dish-item-${item.id}`" :value="String(item.id)">{{ item.name }}</option> v-model="dishForm.itemId"
</select> :options="dishItemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div> </div>
<div class="field"> <div class="field">
<label for="dish-flavor">{{ t('pages.dish.flavor') }}</label> <label for="dish-flavor">{{ t('pages.dish.flavor') }}</label>
<select id="dish-flavor" v-model="dishForm.flavorId" required> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-flavor"
<option v-for="flavor in dishFlavorRows" :key="`dish-flavor-${flavor.id}`" :value="String(flavor.id)">{{ flavor.name }}</option> v-model="dishForm.flavorId"
</select> :options="dishFlavorSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.dish.flavor')"
/>
</div> </div>
</div> </div>
<div class="dish-form-row dish-form-row--3"> <div class="dish-form-row dish-form-row--3">
<div class="field"> <div class="field">
<label for="dish-secondary-material-1">{{ t('pages.dish.secondaryMaterial') }}</label> <label for="dish-secondary-material-1">{{ t('pages.dish.secondaryMaterial') }}</label>
<select id="dish-secondary-material-1" v-model="dishForm.secondaryMaterialItemIds[0]"> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-secondary-material-1"
<option v-for="item in dishItemRows" :key="`dish-secondary-material-1-${item.id}`" :value="String(item.id)">{{ item.name }}</option> v-model="dishForm.secondaryMaterialItemIds[0]"
</select> :options="optionalDishItemSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div> </div>
<div v-if="dishAllowsSecondSecondaryMaterial" class="field"> <div v-if="dishAllowsSecondSecondaryMaterial" class="field">
<label for="dish-secondary-material-2">{{ t('pages.dish.secondSecondaryMaterial') }}</label> <label for="dish-secondary-material-2">{{ t('pages.dish.secondSecondaryMaterial') }}</label>
<select id="dish-secondary-material-2" v-model="dishForm.secondaryMaterialItemIds[1]"> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-secondary-material-2"
<option v-for="item in dishItemRows" :key="`dish-secondary-material-2-${item.id}`" :value="String(item.id)">{{ item.name }}</option> v-model="dishForm.secondaryMaterialItemIds[1]"
</select> :options="optionalDishItemSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div> </div>
<div class="field"> <div class="field">
<label for="dish-pokemon-skill">{{ t('pages.dish.pokemonSkill') }}</label> <label for="dish-pokemon-skill">{{ t('pages.dish.pokemonSkill') }}</label>
<select id="dish-pokemon-skill" v-model="dishForm.pokemonSkillId"> <TagsSelect
<option value="">{{ t('common.none') }}</option> id="dish-pokemon-skill"
<option v-for="skill in dishSkillRows" :key="`dish-skill-${skill.id}`" :value="String(skill.id)">{{ skill.name }}</option> v-model="dishForm.pokemonSkillId"
</select> :options="optionalDishSkillSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.dish.pokemonSkill')"
/>
</div> </div>
</div> </div>
<TranslationFields <TranslationFields
@@ -2813,7 +2838,7 @@ onMounted(() => {
</form> </form>
<template #footer> <template #footer>
<button type="submit" form="admin-dish-form" class="link-button" :disabled="busy"> <button type="submit" form="admin-dish-form" class="link-button" :disabled="busy || !dishFormValid">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }} {{ busy ? t('common.saving') : t('common.save') }}
</button> </button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconChevronDown, iconChevronUp, iconItem } from '../icons'; import { iconAdd, iconChevronDown, iconChevronUp, iconItem } from '../icons';
import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api'; import { api, type AuthUser, type Item, type ListPage, type Options } from '../services/api';
import ItemEdit from './ItemEdit.vue'; import ItemEdit from './ItemEdit.vue';
const props = defineProps<{ const props = defineProps<{
@@ -21,7 +21,7 @@ const props = defineProps<{
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { t } = useI18n(); const { t, locale } = useI18n();
const items = ref<Item[]>([]); const items = ref<Item[]>([]);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const loading = ref(true); const loading = ref(true);
@@ -104,6 +104,52 @@ const itemQuery = computed(() => ({
tagIds: tagIds.value.join(','), tagIds: tagIds.value.join(','),
isEventItem: props.eventOnly isEventItem: props.eventOnly
})); }));
type ItemListInitialData = {
options: Options | null;
page: ListPage<Item> | null;
};
const { data: initialData } = useAsyncData<ItemListInitialData>(
`${props.eventOnly ? 'event-item-list-initial' : 'item-list-initial'}:${locale.value}`,
async () => {
const [optionsResult, itemsResult] = await Promise.allSettled([
api.options(),
api.itemsPage({
...itemQuery.value,
cursor: null,
limit: listPageSize
})
]);
return {
options: optionsResult.status === 'fulfilled' ? optionsResult.value : null,
page: itemsResult.status === 'fulfilled' ? itemsResult.value : null
};
},
{ default: () => ({ options: null, page: null }) }
);
const initialPageLoaded = ref(false);
function applyInitialData(data: ItemListInitialData | null | undefined) {
if (!data) return;
if (!options.value && data.options) {
options.value = data.options;
}
if (initialPageLoaded.value || !data.page) {
return;
}
items.value = data.page.items;
nextCursor.value = data.page.nextCursor;
hasMoreItems.value = data.page.hasMore;
initialPageLoaded.value = true;
loading.value = false;
}
const showEditor = computed(() => route.name === 'item-new' || route.name === 'event-item-new'); const showEditor = computed(() => route.name === 'item-new' || route.name === 'event-item-new');
const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true); const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true);
const hasItemCreateDefaults = computed( const hasItemCreateDefaults = computed(
@@ -458,6 +504,14 @@ async function loadItems(reset = true) {
} }
nextCursor.value = page.nextCursor; nextCursor.value = page.nextCursor;
hasMoreItems.value = page.hasMore; hasMoreItems.value = page.hasMore;
initialPageLoaded.value = true;
} catch {
if (requestId === loadRequestId && reset) {
items.value = [];
nextCursor.value = null;
hasMoreItems.value = false;
initialPageLoaded.value = true;
}
} finally { } finally {
if (requestId === loadRequestId) { if (requestId === loadRequestId) {
loading.value = false; loading.value = false;
@@ -473,16 +527,22 @@ function loadMoreItems() {
onMounted(async () => { onMounted(async () => {
document.addEventListener('pointerdown', onCreateDefaultsDocumentPointerDown); document.addEventListener('pointerdown', onCreateDefaultsDocumentPointerDown);
document.addEventListener('keydown', onDocumentKeydown); document.addEventListener('keydown', onDocumentKeydown);
if (getAuthToken()) { try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
if (!options.value) {
try { try {
currentUser.value = (await api.me()).user; options.value = await api.options();
} catch { } catch {
currentUser.value = null; options.value = null;
} }
} }
options.value = await api.options();
sanitizeItemCreateDefaults(); sanitizeItemCreateDefaults();
await loadItems(); if (!initialPageLoaded.value) {
await loadItems();
}
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -493,6 +553,8 @@ onBeforeUnmount(() => {
watch(itemQuery, () => { watch(itemQuery, () => {
void loadItems(); void loadItems();
}); });
watch(initialData, applyInitialData, { immediate: true });
watch(itemCreateDefaults, persistItemCreateDefaults, { deep: true }); watch(itemCreateDefaults, persistItemCreateDefaults, { deep: true });
watch(showEditor, () => { watch(showEditor, () => {
closeCreateDefaultsMenu(); closeCreateDefaultsMenu();

View File

@@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import LifeRatingControl from '../components/LifeRatingControl.vue'; import LifeRatingControl from '../components/LifeRatingControl.vue';
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue'; import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue'; import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
@@ -28,10 +29,8 @@ import {
} from '../icons'; } from '../icons';
import { import {
api, api,
getAuthToken,
moderationUpdateEvent, moderationUpdateEvent,
onAuthTokenChange, onAuthChange,
setAuthToken,
type AiModerationStatus, type AiModerationStatus,
type AuthUser, type AuthUser,
type CommentSort, type CommentSort,
@@ -40,6 +39,7 @@ import {
type LifeReactionType, type LifeReactionType,
type ModerationUpdateDetail type ModerationUpdateDetail
} from '../services/api'; } from '../services/api';
import { resolvedSeoHead, resolveSeo } from '../seo';
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const route = useRoute(); const route = useRoute();
@@ -69,6 +69,8 @@ const ratingErrors = ref<Record<number, string>>({});
const moderationBusyPostId = ref<number | null>(null); const moderationBusyPostId = ref<number | null>(null);
const moderationErrors = ref<Record<number, string>>({}); const moderationErrors = ref<Record<number, string>>({});
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null); const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
const pendingDeleteComment = ref<LifeComment | null>(null);
const deleteConfirmBusy = ref(false);
const lifeCommentPageSize = 20; const lifeCommentPageSize = 20;
const commentMaxLength = 1000; const commentMaxLength = 1000;
let removeAuthListener: (() => void) | null = null; let removeAuthListener: (() => void) | null = null;
@@ -101,18 +103,21 @@ function routePostId() {
return Array.isArray(value) ? value[0] : value; return Array.isArray(value) ? value[0] : value;
} }
async function loadCurrentUser() { function summaryText(value: string, maxLength: number) {
if (!getAuthToken()) { const normalized = value.replace(/\s+/g, ' ').trim();
currentUser.value = null; if (normalized.length <= maxLength) {
return; return normalized;
} }
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}...`;
}
async function loadCurrentUser() {
try { try {
const response = await api.me(); const response = await api.me();
currentUser.value = response.user; currentUser.value = response.user;
} catch { } catch {
currentUser.value = null; currentUser.value = null;
setAuthToken(null);
} }
} }
@@ -133,6 +138,41 @@ function resetCommentsFromPost(nextPost: LifePost) {
commentErrors.value = {}; commentErrors.value = {};
} }
const { data: initialPost } = await useAsyncData<LifePost | null>(
`life-post-detail:${String(routePostId())}:${locale.value}`,
async () => {
const id = routePostId();
if (!id) {
return null;
}
try {
return await api.lifePost(id);
} catch {
return null;
}
},
{ default: () => null }
);
if (initialPost.value) {
post.value = initialPost.value;
resetCommentsFromPost(initialPost.value);
}
const initialPostLoaded = ref(initialPost.value !== null);
loading.value = !initialPostLoaded.value;
const postSeo = computed(() =>
post.value
? resolveSeo({
title: `${summaryText(post.value.body, 64) || t('pages.life.detailTitle')} - ${t('pages.life.title')}`,
description: summaryText(post.value.body, 155) || t('pages.life.detailSubtitle'),
canonicalPath: `/life/${post.value.id}`
})
: null
);
useHead(() => (postSeo.value ? resolvedSeoHead(postSeo.value) : {}));
async function loadPost() { async function loadPost() {
const id = routePostId(); const id = routePostId();
if (!id) { if (!id) {
@@ -147,9 +187,11 @@ async function loadPost() {
const nextPost = await api.lifePost(id); const nextPost = await api.lifePost(id);
post.value = nextPost; post.value = nextPost;
resetCommentsFromPost(nextPost); resetCommentsFromPost(nextPost);
initialPostLoaded.value = true;
void loadComments(true); void loadComments(true);
} catch (error) { } catch (error) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed'); loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
initialPostLoaded.value = true;
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -668,10 +710,6 @@ function markOwnCommentDeleted(items: LifeComment[], id: number): boolean {
} }
async function deleteComment(comment: LifeComment) { async function deleteComment(comment: LifeComment) {
if (!window.confirm(t('pages.life.deleteCommentConfirm'))) {
return;
}
const key = replyKey(comment.id); const key = replyKey(comment.id);
clearCommentError(key); clearCommentError(key);
@@ -698,6 +736,33 @@ async function deleteComment(comment: LifeComment) {
} }
} }
function requestDeleteComment(comment: LifeComment) {
pendingDeleteComment.value = comment;
}
function closeDeleteConfirm() {
if (deleteConfirmBusy.value) {
return;
}
pendingDeleteComment.value = null;
}
async function confirmDeleteComment() {
const comment = pendingDeleteComment.value;
if (!comment) {
return;
}
deleteConfirmBusy.value = true;
try {
await deleteComment(comment);
pendingDeleteComment.value = null;
} finally {
deleteConfirmBusy.value = false;
}
}
async function restoreComment(comment: LifeComment) { async function restoreComment(comment: LifeComment) {
const key = replyKey(comment.id); const key = replyKey(comment.id);
commentBusyKey.value = key; commentBusyKey.value = key;
@@ -793,9 +858,13 @@ onMounted(() => {
document.addEventListener('click', closeReactionPickerFromDocument); document.addEventListener('click', closeReactionPickerFromDocument);
document.addEventListener('keydown', closeReactionPickerFromKeyboard); document.addEventListener('keydown', closeReactionPickerFromKeyboard);
window.addEventListener(moderationUpdateEvent, handleModerationUpdate); window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
void loadCurrentUser(); void (async () => {
void loadPost(); await loadCurrentUser();
removeAuthListener = onAuthTokenChange(() => { if (!initialPostLoaded.value || currentUser.value) {
await loadPost();
}
})();
removeAuthListener = onAuthChange(() => {
void loadCurrentUser(); void loadCurrentUser();
void loadPost(); void loadPost();
}); });
@@ -1117,7 +1186,7 @@ onUnmounted(() => {
class="life-icon-button life-icon-button--flat life-icon-button--danger" class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button" type="button"
:aria-label="t('pages.life.deleteComment')" :aria-label="t('pages.life.deleteComment')"
@click="deleteComment(comment)" @click="requestDeleteComment(comment)"
> >
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
@@ -1234,7 +1303,7 @@ onUnmounted(() => {
class="life-icon-button life-icon-button--flat life-icon-button--danger" class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button" type="button"
:aria-label="t('pages.life.deleteComment')" :aria-label="t('pages.life.deleteComment')"
@click="deleteComment(reply)" @click="requestDeleteComment(reply)"
> >
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
@@ -1290,6 +1359,18 @@ onUnmounted(() => {
<h2>{{ t('pages.life.empty') }}</h2> <h2>{{ t('pages.life.empty') }}</h2>
</div> </div>
</div> </div>
<ConfirmDialog
v-if="pendingDeleteComment"
:title="t('pages.life.deleteComment')"
:message="t('pages.life.deleteCommentConfirm')"
:confirm-label="t('common.delete')"
:cancel-label="t('common.cancel')"
:close-label="t('common.close')"
:busy="deleteConfirmBusy"
@cancel="closeDeleteConfirm"
@confirm="confirmDeleteComment"
/>
</div> </div>
</section> </section>
</template> </template>

View File

@@ -2,6 +2,7 @@
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import FilterPanel from '../components/FilterPanel.vue'; import FilterPanel from '../components/FilterPanel.vue';
import LifeRatingControl from '../components/LifeRatingControl.vue'; import LifeRatingControl from '../components/LifeRatingControl.vue';
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue'; import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
@@ -35,10 +36,8 @@ import {
} from '../icons'; } from '../icons';
import { import {
api, api,
getAuthToken,
moderationUpdateEvent, moderationUpdateEvent,
onAuthTokenChange, onAuthChange,
setAuthToken,
type AiModerationStatus, type AiModerationStatus,
type AuthUser, type AuthUser,
type CommentSort, type CommentSort,
@@ -47,6 +46,7 @@ import {
type LifeCategory, type LifeCategory,
type LifeComment, type LifeComment,
type LifePost, type LifePost,
type LifePostsPage,
type LifeReactionType, type LifeReactionType,
type ModerationUpdateDetail type ModerationUpdateDetail
} from '../services/api'; } from '../services/api';
@@ -64,6 +64,7 @@ type LifeCommentPageState = {
type LifePostSort = 'latest' | 'oldest' | 'top-rated'; type LifePostSort = 'latest' | 'oldest' | 'top-rated';
type LifeFeedScope = 'all' | 'following'; type LifeFeedScope = 'all' | 'following';
type PendingLifeDelete = { type: 'post'; post: LifePost } | { type: 'comment'; post: LifePost; comment: LifeComment };
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const posts = ref<LifePost[]>([]); const posts = ref<LifePost[]>([]);
@@ -106,6 +107,8 @@ const ratingErrors = ref<Record<number, string>>({});
const moderationBusyPostId = ref<number | null>(null); const moderationBusyPostId = ref<number | null>(null);
const moderationErrors = ref<Record<number, string>>({}); const moderationErrors = ref<Record<number, string>>({});
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null); const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
const pendingDelete = ref<PendingLifeDelete | null>(null);
const deleteConfirmBusy = ref(false);
const bodyInput = ref<HTMLTextAreaElement | null>(null); const bodyInput = ref<HTMLTextAreaElement | null>(null);
const loadMoreSentinel = ref<HTMLElement | null>(null); const loadMoreSentinel = ref<HTMLElement | null>(null);
const lifePostPageSize = 20; const lifePostPageSize = 20;
@@ -123,6 +126,53 @@ const loadMorePaused = ref(false);
const allCategoryValue = 'all'; const allCategoryValue = 'all';
const allLanguageValue = 'all'; const allLanguageValue = 'all';
const allGameVersionValue = 'all'; const allGameVersionValue = 'all';
const deleteConfirmTitle = computed(() =>
pendingDelete.value?.type === 'comment' ? t('pages.life.deleteComment') : t('pages.life.deletePost')
);
const deleteConfirmMessage = computed(() =>
pendingDelete.value?.type === 'comment' ? t('pages.life.deleteCommentConfirm') : t('pages.life.deleteConfirm')
);
type LifeInitialData = {
options: { lifeCategories: LifeCategory[]; gameVersions: GameVersion[] } | null;
languages: Language[] | null;
posts: LifePostsPage | null;
};
const { data: initialData } = await useAsyncData<LifeInitialData>(
`life-feed-initial:${locale.value}`,
async () => {
const [optionsResult, languagesResult, postsResult] = await Promise.allSettled([
api.options(),
api.languages(),
api.lifePosts({
limit: lifePostPageSize,
sort: 'latest'
})
]);
return {
options:
optionsResult.status === 'fulfilled'
? { lifeCategories: optionsResult.value.lifeCategories, gameVersions: optionsResult.value.gameVersions }
: null,
languages: languagesResult.status === 'fulfilled' ? languagesResult.value.filter((language) => language.enabled) : null,
posts: postsResult.status === 'fulfilled' ? postsResult.value : null
};
},
{ default: () => ({ options: null, languages: null, posts: null }) }
);
lifeCategories.value = initialData.value.options?.lifeCategories ?? [];
gameVersions.value = initialData.value.options?.gameVersions ?? [];
languages.value = initialData.value.languages ?? [];
posts.value = initialData.value.posts?.items ?? [];
nextCursor.value = initialData.value.posts?.nextCursor ?? null;
hasMorePosts.value = initialData.value.posts?.hasMore ?? false;
const initialOptionsLoaded = ref(initialData.value.options !== null);
const initialLanguagesLoaded = ref(initialData.value.languages !== null);
const initialPostsLoaded = ref(initialData.value.posts !== null);
loading.value = !initialPostsLoaded.value;
const reactionOptions = [ const reactionOptions = [
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' }, { type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
@@ -210,20 +260,12 @@ const submitLabel = computed(() => {
async function loadCurrentUser() { async function loadCurrentUser() {
authReady.value = false; authReady.value = false;
if (!getAuthToken()) {
currentUser.value = null;
activeFeedScope.value = 'all';
authReady.value = true;
return;
}
try { try {
const response = await api.me(); const response = await api.me();
currentUser.value = response.user; currentUser.value = response.user;
} catch { } catch {
currentUser.value = null; currentUser.value = null;
activeFeedScope.value = 'all'; activeFeedScope.value = 'all';
setAuthToken(null);
} finally { } finally {
authReady.value = true; authReady.value = true;
} }
@@ -1017,10 +1059,6 @@ function startEdit(post: LifePost) {
} }
async function deletePost(post: LifePost) { async function deletePost(post: LifePost) {
if (!window.confirm(t('pages.life.deleteConfirm'))) {
return;
}
loadError.value = ''; loadError.value = '';
try { try {
@@ -1035,6 +1073,10 @@ async function deletePost(post: LifePost) {
} }
} }
function requestDeletePost(post: LifePost) {
pendingDelete.value = { type: 'post', post };
}
function startReply(comment: LifeComment) { function startReply(comment: LifeComment) {
replyTargetId.value = comment.id; replyTargetId.value = comment.id;
clearCommentError(replyKey(comment.id)); clearCommentError(replyKey(comment.id));
@@ -1159,10 +1201,6 @@ function markOwnCommentDeleted(comments: LifeComment[], id: number): boolean {
} }
async function deleteComment(post: LifePost, comment: LifeComment) { async function deleteComment(post: LifePost, comment: LifeComment) {
if (!window.confirm(t('pages.life.deleteCommentConfirm'))) {
return;
}
const key = replyKey(comment.id); const key = replyKey(comment.id);
clearCommentError(key); clearCommentError(key);
@@ -1194,6 +1232,37 @@ async function deleteComment(post: LifePost, comment: LifeComment) {
} }
} }
function requestDeleteComment(post: LifePost, comment: LifeComment) {
pendingDelete.value = { type: 'comment', post, comment };
}
function closeDeleteConfirm() {
if (deleteConfirmBusy.value) {
return;
}
pendingDelete.value = null;
}
async function confirmDelete() {
const target = pendingDelete.value;
if (!target) {
return;
}
deleteConfirmBusy.value = true;
try {
if (target.type === 'post') {
await deletePost(target.post);
} else {
await deleteComment(target.post, target.comment);
}
pendingDelete.value = null;
} finally {
deleteConfirmBusy.value = false;
}
}
async function restoreComment(post: LifePost, comment: LifeComment) { async function restoreComment(post: LifePost, comment: LifeComment) {
const key = replyKey(comment.id); const key = replyKey(comment.id);
commentBusyKey.value = key; commentBusyKey.value = key;
@@ -1334,11 +1403,22 @@ onMounted(() => {
document.addEventListener('click', closeReactionPickerFromDocument); document.addEventListener('click', closeReactionPickerFromDocument);
document.addEventListener('keydown', closeReactionPickerFromKeyboard); document.addEventListener('keydown', closeReactionPickerFromKeyboard);
window.addEventListener(moderationUpdateEvent, handleModerationUpdate); window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
void loadCurrentUser(); void (async () => {
void loadLanguages(); await loadCurrentUser();
void loadLifeCategories(); if (!initialLanguagesLoaded.value) {
void loadPosts(); await loadLanguages();
removeAuthListener = onAuthTokenChange(() => { initialLanguagesLoaded.value = true;
}
if (!initialOptionsLoaded.value) {
await loadLifeCategories();
initialOptionsLoaded.value = true;
}
if (!initialPostsLoaded.value || currentUser.value) {
await loadPosts();
initialPostsLoaded.value = true;
}
})();
removeAuthListener = onAuthChange(() => {
void (async () => { void (async () => {
await loadCurrentUser(); await loadCurrentUser();
await loadPosts(); await loadPosts();
@@ -1558,7 +1638,7 @@ onUnmounted(() => {
class="life-icon-button life-icon-button--danger" class="life-icon-button life-icon-button--danger"
type="button" type="button"
:aria-label="t('pages.life.deletePost')" :aria-label="t('pages.life.deletePost')"
@click="deletePost(post)" @click="requestDeletePost(post)"
> >
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deletePost') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deletePost') }}</span>
@@ -1853,7 +1933,7 @@ onUnmounted(() => {
class="life-icon-button life-icon-button--flat life-icon-button--danger" class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button" type="button"
:aria-label="t('pages.life.deleteComment')" :aria-label="t('pages.life.deleteComment')"
@click="deleteComment(post, comment)" @click="requestDeleteComment(post, comment)"
> >
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
@@ -1970,7 +2050,7 @@ onUnmounted(() => {
class="life-icon-button life-icon-button--flat life-icon-button--danger" class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button" type="button"
:aria-label="t('pages.life.deleteComment')" :aria-label="t('pages.life.deleteComment')"
@click="deleteComment(post, reply)" @click="requestDeleteComment(post, reply)"
> >
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
@@ -2061,6 +2141,18 @@ onUnmounted(() => {
{{ t('pages.life.newPost') }} {{ t('pages.life.newPost') }}
</button> </button>
</div> </div>
<ConfirmDialog
v-if="pendingDelete"
:title="deleteConfirmTitle"
:message="deleteConfirmMessage"
:confirm-label="t('common.delete')"
:cancel-label="t('common.cancel')"
:close-label="t('common.close')"
:busy="deleteConfirmBusy"
@cancel="closeDeleteConfirm"
@confirm="confirmDelete"
/>
</section> </section>
</section> </section>
</template> </template>

View File

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

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
@@ -15,12 +15,12 @@ import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconAdd, iconBack, iconCancel, iconCheck, iconEdit, iconHabitat, iconItem } from '../icons'; import { iconAdd, iconBack, iconCancel, iconCheck, iconEdit, iconHabitat, iconItem } from '../icons';
import { applySeo } from '../seo'; import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
import { api, getAuthToken, type AuthUser, type Item, type PokemonDetail, type PokemonPayload, type TradingPreference } from '../services/api'; import { api, type AuthUser, type Item, type PokemonDetail, type PokemonPayload, type TradingPreference } from '../services/api';
import PokemonEdit from './PokemonEdit.vue'; import PokemonEdit from './PokemonEdit.vue';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t, locale } = useI18n();
const pokemon = ref<PokemonDetail | null>(null); const pokemon = ref<PokemonDetail | null>(null);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const itemCategoryTab = ref(''); const itemCategoryTab = ref('');
@@ -36,9 +36,50 @@ const tradingCategoryId = ref('');
const tradingDefaultPreference = ref<TradingPreference>('like'); const tradingDefaultPreference = ref<TradingPreference>('like');
const tradingItemChoices = ref<Item[]>([]); const tradingItemChoices = ref<Item[]>([]);
const tradingDraftItems = ref<Array<{ itemId: number; preference: TradingPreference }>>([]); const tradingDraftItems = ref<Array<{ itemId: number; preference: TradingPreference }>>([]);
const tradingActiveItemIndex = ref(0);
const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天']; const weathers = ['晴天', '阴天', '雨天'];
const relatedPokemonLimit = 6; const relatedPokemonLimit = 6;
const pokemonDetailRouteNames = new Set(['pokemon-detail', 'pokemon-edit']);
const { data: initialPokemon } = useAsyncData<PokemonDetail | null>(
`pokemon-detail:${activePokemonRouteId() ?? 'none'}:${locale.value}`,
async () => {
const routeId = activePokemonRouteId();
if (!routeId) {
return null;
}
try {
return await api.pokemonDetail(routeId);
} catch {
return null;
}
},
{ default: () => null }
);
const initialPokemonLoaded = ref(false);
const pokemonSeo = computed(() =>
pokemon.value && route.meta.editorModal !== true
? resolveSeo({
title: `${pokemon.value.name} - ${t(pokemon.value.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`,
description: t('seo.pokemonDetailDescription', { name: pokemon.value.name }),
canonicalPath: `/pokemon/${pokemon.value.id}`,
image: pokemon.value.image?.url
})
: null
);
useHead(() => (pokemonSeo.value ? resolvedSeoHead(pokemonSeo.value) : {}));
function applyInitialPokemon(value: PokemonDetail | null | undefined) {
if (!value || initialPokemonLoaded.value) return;
pokemon.value = value;
relatedHabitatTab.value = habitatTabValue(value.environment.id);
initialPokemonLoaded.value = true;
}
type HabitatRow = { type HabitatRow = {
id: number; id: number;
@@ -65,6 +106,15 @@ function habitatTabValue(id: number): string {
return `habitat-${id}`; return `habitat-${id}`;
} }
function activePokemonRouteId(): string | null {
return typeof route.name === 'string' &&
pokemonDetailRouteNames.has(route.name) &&
typeof route.params.id === 'string' &&
route.params.id.trim() !== ''
? route.params.id
: null;
}
function timeLabel(value: string): string { function timeLabel(value: string): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
早晨: t('appearance.morning'), 早晨: t('appearance.morning'),
@@ -149,20 +199,58 @@ const tradingCategoryOptions = computed(() => {
return [{ value: '', label: t('common.all') }, ...[...categories.entries()].map(([value, label]) => ({ value, label }))]; return [{ value: '', label: t('common.all') }, ...[...categories.entries()].map(([value, label]) => ({ value, label }))];
}); });
const tradingDraftPreferenceByItemId = computed(() => new Map(tradingDraftItems.value.map((item) => [String(item.itemId), item.preference]))); const tradingDraftPreferenceByItemId = computed(() => new Map(tradingDraftItems.value.map((item) => [String(item.itemId), item.preference])));
const filteredTradingItems = computed(() => { function normalizedTradingValue(value: string) {
const search = tradingSearch.value.trim().toLocaleLowerCase(); return value.trim().toLocaleLowerCase();
}
return tradingItemChoices.value.filter((item) => { function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function tradingSearchScore(item: Item, search: string) {
const name = normalizedTradingValue(item.name);
const category = normalizedTradingValue(item.category.name);
const usage = normalizedTradingValue(item.usage?.name ?? '');
if (name === search) {
return 0;
}
if (name.startsWith(search)) {
return 1;
}
if (new RegExp(`(^|\\s)${escapeRegExp(search)}`).test(name)) {
return 2;
}
if (name.includes(search)) {
return 3;
}
if (category.includes(search)) {
return 4;
}
if (usage.includes(search)) {
return 5;
}
return -1;
}
const filteredTradingItems = computed(() => {
const search = normalizedTradingValue(tradingSearch.value);
const rows = tradingItemChoices.value.flatMap((item, index) => {
if (tradingCategoryId.value && String(item.category.id) !== tradingCategoryId.value) { if (tradingCategoryId.value && String(item.category.id) !== tradingCategoryId.value) {
return false; return [];
} }
if (!search) { if (!search) {
return true; return [{ item, index, score: 0 }];
} }
return [item.name, item.category.name, item.usage?.name ?? ''].some((value) => value.toLocaleLowerCase().includes(search)); const score = tradingSearchScore(item, search);
return score >= 0 ? [{ item, index, score }] : [];
}); });
return rows.sort((a, b) => a.score - b.score || a.index - b.index).map((row) => row.item);
}); });
const tradingDraftGroups = computed(() => { const tradingDraftGroups = computed(() => {
const itemsById = new Map(tradingItemChoices.value.map((item) => [item.id, item])); const itemsById = new Map(tradingItemChoices.value.map((item) => [item.id, item]));
@@ -343,6 +431,7 @@ function resetTradingDraft() {
tradingDefaultPreference.value = 'like'; tradingDefaultPreference.value = 'like';
tradingSearch.value = ''; tradingSearch.value = '';
tradingCategoryId.value = ''; tradingCategoryId.value = '';
tradingActiveItemIndex.value = 0;
tradingMessage.value = ''; tradingMessage.value = '';
} }
@@ -350,13 +439,72 @@ function isTradingItemSelected(itemId: string | number) {
return tradingDraftPreferenceByItemId.value.has(String(itemId)); return tradingDraftPreferenceByItemId.value.has(String(itemId));
} }
function addTradingItem(item: Item) { function firstAddableTradingItemIndex(items = filteredTradingItems.value, startIndex = 0, direction: -1 | 1 = 1) {
if (!items.length) {
return 0;
}
const start = Math.min(Math.max(startIndex, 0), items.length - 1);
for (let offset = 0; offset < items.length; offset += 1) {
const index = (start + direction * offset + items.length) % items.length;
if (!isTradingItemSelected(items[index].id)) {
return index;
}
}
return start;
}
function setTradingActiveItemIndex(index: number) {
const maxIndex = filteredTradingItems.value.length - 1;
tradingActiveItemIndex.value = maxIndex >= 0 ? Math.min(Math.max(index, 0), maxIndex) : 0;
}
function moveTradingActiveItem(direction: -1 | 1) {
const items = filteredTradingItems.value;
if (!items.length) {
tradingActiveItemIndex.value = 0;
return;
}
const startIndex = Math.min(Math.max(tradingActiveItemIndex.value, 0), items.length - 1);
for (let offset = 1; offset <= items.length; offset += 1) {
const index = (startIndex + direction * offset + items.length) % items.length;
if (!isTradingItemSelected(items[index].id)) {
tradingActiveItemIndex.value = index;
return;
}
}
tradingActiveItemIndex.value = startIndex;
}
function activeTradingItemId() {
const item = filteredTradingItems.value[tradingActiveItemIndex.value];
return item ? `pokemon-trading-item-${item.id}` : undefined;
}
function scrollActiveTradingItemIntoView() {
if (typeof document === 'undefined') {
return;
}
nextTick(() => {
const activeId = activeTradingItemId();
if (activeId) {
document.getElementById(activeId)?.scrollIntoView({ block: 'nearest' });
}
});
}
function addTradingItem(item: Item, index = tradingActiveItemIndex.value) {
const itemId = String(item.id); const itemId = String(item.id);
if (isTradingItemSelected(itemId)) { if (isTradingItemSelected(itemId)) {
return; return;
} }
tradingDraftItems.value.push({ itemId: item.id, preference: tradingDefaultPreference.value }); tradingDraftItems.value.push({ itemId: item.id, preference: tradingDefaultPreference.value });
tradingActiveItemIndex.value = firstAddableTradingItemIndex(filteredTradingItems.value, index, 1);
} }
function removeTradingItem(itemId: string | number) { function removeTradingItem(itemId: string | number) {
@@ -372,6 +520,61 @@ function setTradingPreference(itemId: string | number, preference: TradingPrefer
} }
} }
function handleTradingSearchKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
moveTradingActiveItem(1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
moveTradingActiveItem(-1);
return;
}
if (event.key === 'ArrowLeft') {
event.preventDefault();
tradingDefaultPreference.value = 'like';
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
tradingDefaultPreference.value = 'neutral';
return;
}
if (event.key === 'Enter') {
event.preventDefault();
const item = filteredTradingItems.value[tradingActiveItemIndex.value];
if (item) {
addTradingItem(item, tradingActiveItemIndex.value);
}
}
}
watch([tradingSearch, tradingCategoryId], () => {
tradingActiveItemIndex.value = firstAddableTradingItemIndex(filteredTradingItems.value, 0, 1);
scrollActiveTradingItemIntoView();
});
watch([filteredTradingItems, tradingDraftPreferenceByItemId], () => {
const items = filteredTradingItems.value;
if (!items.length) {
tradingActiveItemIndex.value = 0;
return;
}
const currentIndex = Math.min(Math.max(tradingActiveItemIndex.value, 0), items.length - 1);
tradingActiveItemIndex.value = isTradingItemSelected(items[currentIndex].id)
? firstAddableTradingItemIndex(items, currentIndex, 1)
: currentIndex;
scrollActiveTradingItemIntoView();
});
watch(tradingActiveItemIndex, scrollActiveTradingItemIntoView);
async function openTradingModal() { async function openTradingModal() {
if (!pokemon.value) { if (!pokemon.value) {
return; return;
@@ -411,29 +614,43 @@ async function saveTradingItems() {
} }
async function loadPokemonDetail() { async function loadPokemonDetail() {
const nextPokemon = await api.pokemonDetail(String(route.params.id)); const routeId = activePokemonRouteId();
pokemon.value = nextPokemon; if (!routeId) {
relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id); initialPokemonLoaded.value = true;
return;
}
if (route.meta.editorModal !== true) { try {
applySeo({ const nextPokemon = await api.pokemonDetail(routeId);
title: `${nextPokemon.name} - ${t(nextPokemon.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`, pokemon.value = nextPokemon;
description: t('seo.pokemonDetailDescription', { name: nextPokemon.name }), relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id);
canonicalPath: `/pokemon/${nextPokemon.id}`, initialPokemonLoaded.value = true;
image: nextPokemon.image?.url
}); if (route.meta.editorModal !== true) {
applySeo({
title: `${nextPokemon.name} - ${t(nextPokemon.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`,
description: t('seo.pokemonDetailDescription', { name: nextPokemon.name }),
canonicalPath: `/pokemon/${nextPokemon.id}`,
image: nextPokemon.image?.url
});
}
} catch {
pokemon.value = null;
relatedHabitatTab.value = '';
initialPokemonLoaded.value = true;
} }
} }
onMounted(async () => { onMounted(async () => {
if (getAuthToken()) { try {
try { currentUser.value = (await api.me()).user;
currentUser.value = (await api.me()).user; } catch {
} catch { currentUser.value = null;
currentUser.value = null; }
}
if (!initialPokemonLoaded.value) {
await loadPokemonDetail();
} }
await loadPokemonDetail();
}); });
watch( watch(
@@ -448,6 +665,10 @@ watch(
watch( watch(
() => route.params.id, () => route.params.id,
() => { () => {
if (!activePokemonRouteId()) {
return;
}
pokemon.value = null; pokemon.value = null;
relatedHabitatTab.value = ''; relatedHabitatTab.value = '';
detailTab.value = 'details'; detailTab.value = 'details';
@@ -457,6 +678,8 @@ watch(
void loadPokemonDetail(); void loadPokemonDetail();
} }
); );
watch(initialPokemon, applyInitialPokemon, { immediate: true });
</script> </script>
<template> <template>
@@ -808,7 +1031,13 @@ watch(
id="pokemon-trading-search" id="pokemon-trading-search"
v-model="tradingSearch" v-model="tradingSearch"
type="search" type="search"
autocomplete="off"
role="combobox"
:aria-expanded="filteredTradingItems.length > 0"
aria-controls="pokemon-trading-results"
:aria-activedescendant="activeTradingItemId()"
:placeholder="t('pages.pokemon.searchItems')" :placeholder="t('pages.pokemon.searchItems')"
@keydown="handleTradingSearchKeydown"
/> />
</div> </div>
@@ -840,14 +1069,19 @@ watch(
<Skeleton variant="box" height="58px" /> <Skeleton variant="box" height="58px" />
</li> </li>
</ul> </ul>
<ul v-else-if="filteredTradingItems.length" class="trading-item-list"> <ul v-else-if="filteredTradingItems.length" id="pokemon-trading-results" class="trading-item-list">
<li v-for="item in filteredTradingItems" :key="item.id"> <li v-for="(item, index) in filteredTradingItems" :id="`pokemon-trading-item-${item.id}`" :key="item.id">
<button <button
type="button" type="button"
class="trading-pick-row" class="trading-pick-row"
:class="{ 'trading-pick-row--selected': isTradingItemSelected(item.id) }" :class="{
'trading-pick-row--active': tradingActiveItemIndex === index,
'trading-pick-row--selected': isTradingItemSelected(item.id)
}"
:disabled="isTradingItemSelected(item.id)" :disabled="isTradingItemSelected(item.id)"
@click="addTradingItem(item)" @mouseenter="setTradingActiveItemIndex(index)"
@focus="setTradingActiveItemIndex(index)"
@click="addTradingItem(item, index)"
> >
<span class="related-entity-media related-entity-media--inline" aria-hidden="true"> <span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" /> <img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />

View File

@@ -14,7 +14,6 @@ import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave, iconSearch } from '../icons'; import { iconCancel, iconSave, iconSearch } from '../icons';
import { import {
api, api,
getAuthToken,
type AuthUser, type AuthUser,
type ConfigType, type ConfigType,
type EntityImage, type EntityImage,
@@ -195,11 +194,6 @@ async function loadOptions() {
} }
async function loadCurrentUser() { async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try { try {
currentUser.value = (await api.me()).user; currentUser.value = (await api.me()).user;
} catch { } catch {

View File

@@ -10,7 +10,7 @@ import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd } from '../icons'; import { iconAdd } from '../icons';
import { api, getAuthToken, type AuthUser, type ListPage, type Options, type Pokemon } from '../services/api'; import { api, type AuthUser, type ListPage, type Options, type Pokemon } from '../services/api';
import PokemonEdit from './PokemonEdit.vue'; import PokemonEdit from './PokemonEdit.vue';
const props = defineProps<{ const props = defineProps<{
@@ -45,7 +45,7 @@ const query = computed(() => ({
favoriteThingMode: favoriteThingMode.value favoriteThingMode: favoriteThingMode.value
})); }));
const { data: initialData } = await useAsyncData<PokemonListInitialData>( const { data: initialData } = useAsyncData<PokemonListInitialData>(
`${props.eventOnly ? 'event-pokemon-list-initial' : 'pokemon-list-initial'}:${locale.value}`, `${props.eventOnly ? 'event-pokemon-list-initial' : 'pokemon-list-initial'}:${locale.value}`,
async () => { async () => {
const [optionsResult, pokemonResult] = await Promise.allSettled([ const [optionsResult, pokemonResult] = await Promise.allSettled([
@@ -65,15 +65,14 @@ const { data: initialData } = await useAsyncData<PokemonListInitialData>(
{ default: () => ({ options: null, page: null }) } { default: () => ({ options: null, page: null }) }
); );
const initialPage = initialData.value?.page ?? null; const options = ref<Options | null>(null);
const options = ref<Options | null>(initialData.value?.options ?? null); const pokemon = ref<Pokemon[]>([]);
const pokemon = ref<Pokemon[]>(initialPage?.items ?? []);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const initialPageLoaded = ref(initialPage !== null); const initialPageLoaded = ref(false);
const loading = ref(!initialPageLoaded.value); const loading = ref(true);
const loadingMore = ref(false); const loadingMore = ref(false);
const nextCursor = ref<string | null>(initialPage?.nextCursor ?? null); const nextCursor = ref<string | null>(null);
const hasMorePokemon = ref(initialPage?.hasMore ?? false); const hasMorePokemon = ref(false);
const showEditor = computed(() => route.name === 'pokemon-new' || route.name === 'event-pokemon-new'); const showEditor = computed(() => route.name === 'pokemon-new' || route.name === 'event-pokemon-new');
const canCreatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.create') === true); const canCreatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.create') === true);
const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventPokemon.title' : 'pages.pokemon.title')); const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventPokemon.title' : 'pages.pokemon.title'));
@@ -82,6 +81,24 @@ const pageKicker = computed(() => t(props.eventOnly ? 'pages.eventPokemon.kicker
const newPokemonPath = computed(() => (props.eventOnly ? '/event-pokemon/new' : '/pokemon/new')); const newPokemonPath = computed(() => (props.eventOnly ? '/event-pokemon/new' : '/pokemon/new'));
const loadingListLabel = computed(() => t(props.eventOnly ? 'pages.eventPokemon.loadingList' : 'pages.pokemon.loadingList')); const loadingListLabel = computed(() => t(props.eventOnly ? 'pages.eventPokemon.loadingList' : 'pages.pokemon.loadingList'));
function applyInitialData(data: PokemonListInitialData | null | undefined) {
if (!data) return;
if (!options.value && data.options) {
options.value = data.options;
}
if (initialPageLoaded.value || !data.page) {
return;
}
pokemon.value = data.page.items;
nextCursor.value = data.page.nextCursor;
hasMorePokemon.value = data.page.hasMore;
initialPageLoaded.value = true;
loading.value = false;
}
async function loadPokemon(reset = true) { async function loadPokemon(reset = true) {
if (!reset && (loading.value || loadingMore.value || !hasMorePokemon.value)) { if (!reset && (loading.value || loadingMore.value || !hasMorePokemon.value)) {
return; return;
@@ -141,12 +158,10 @@ function pokemonCardImage(item: Pokemon) {
} }
onMounted(async () => { onMounted(async () => {
if (getAuthToken()) { try {
try { currentUser.value = (await api.me()).user;
currentUser.value = (await api.me()).user; } catch {
} catch { currentUser.value = null;
currentUser.value = null;
}
} }
if (!options.value) { if (!options.value) {
try { try {
@@ -163,6 +178,8 @@ onMounted(async () => {
watch(query, () => { watch(query, () => {
void loadPokemon(); void loadPokemon();
}); });
watch(initialData, applyInitialData, { immediate: true });
</script> </script>
<template> <template>

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconCancel, iconDelete, iconSave } from '../icons'; import { iconAdd, iconCancel, iconDelete, iconSave } from '../icons';
import { api, getAuthToken, type AuthUser, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api'; import { api, type AuthUser, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -105,11 +105,6 @@ async function loadOptions() {
} }
async function loadCurrentUser() { async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try { try {
currentUser.value = (await api.me()).user; currentUser.value = (await api.me()).user;
} catch { } catch {

View File

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

View File

@@ -26,12 +26,11 @@ import {
} from '../icons'; } from '../icons';
import { import {
api, api,
getAuthToken,
notifyAuthChange, notifyAuthChange,
setAuthToken,
type AuthUser, type AuthUser,
type DiscussionEntityType, type DiscussionEntityType,
type LifePost, type LifePost,
type LifePostsPage,
type LifeReactionType, type LifeReactionType,
type ProfileCommentSource, type ProfileCommentSource,
type PublicUserProfile, type PublicUserProfile,
@@ -39,6 +38,7 @@ import {
type UserCommentActivity, type UserCommentActivity,
type UserReactionActivity type UserReactionActivity
} from '../services/api'; } from '../services/api';
import { resolvedSeoHead, resolveSeo } from '../seo';
type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account'; type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account';
type PrimaryContributionFilter = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats' | 'daily-checklist'; type PrimaryContributionFilter = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats' | 'daily-checklist';
@@ -199,6 +199,47 @@ const socialStats = computed(() => {
{ label: t('pages.profile.friends'), value: social?.friendCount ?? 0 } { label: t('pages.profile.friends'), value: social?.friendCount ?? 0 }
]; ];
}); });
type PublicProfileInitialData = {
profile: PublicUserProfile | null;
feeds: LifePostsPage | null;
};
const { data: initialPublicProfile } = await useAsyncData<PublicProfileInitialData>(
`public-profile:${String(routeProfileId.value ?? '')}:${locale.value}`,
async () => {
const targetId = routeProfileId.value;
if (!targetId) {
return { profile: null, feeds: null };
}
const profileResult = await Promise.allSettled([api.publicProfile(targetId), api.userLifePosts(targetId, { limit: activityLimit })]);
return {
profile: profileResult[0].status === 'fulfilled' ? profileResult[0].value.profile : null,
feeds: profileResult[1].status === 'fulfilled' ? profileResult[1].value : null
};
},
{ default: () => ({ profile: null, feeds: null }) }
);
profile.value = initialPublicProfile.value.profile;
feeds.value = initialPublicProfile.value.feeds?.items ?? [];
feedsCursor.value = initialPublicProfile.value.feeds?.nextCursor ?? null;
feedsHasMore.value = initialPublicProfile.value.feeds?.hasMore ?? false;
const initialPublicProfileLoaded = ref(initialPublicProfile.value.profile !== null);
loading.value = !initialPublicProfileLoaded.value;
const profileSeo = computed(() =>
profile.value && !isAccountRoute.value
? resolveSeo({
title: `${profile.value.user.displayName} - ${t('pages.profile.title')}`,
description: t('pages.profile.publicSubtitle'),
canonicalPath: `/profile/${profile.value.user.id}`
})
: null
);
useHead(() => (profileSeo.value ? resolvedSeoHead(profileSeo.value) : {}));
const filteredContributions = computed(() => { const filteredContributions = computed(() => {
const items = profile.value?.contributions ?? []; const items = profile.value?.contributions ?? [];
if (contributionFilter.value === 'all') { if (contributionFilter.value === 'all') {
@@ -280,18 +321,12 @@ function resetActivity() {
} }
async function loadOptionalCurrentUser() { async function loadOptionalCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return null;
}
try { try {
const response = await api.me(); const response = await api.me();
currentUser.value = response.user; currentUser.value = response.user;
return response.user; return response.user;
} catch { } catch {
currentUser.value = null; currentUser.value = null;
setAuthToken(null);
return null; return null;
} }
} }

View File

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

View File

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

59408
repomix-output.xml Normal file

File diff suppressed because it is too large Load Diff