Compare commits
19 Commits
c821e9ebba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 82f08c1684 | |||
| df78685dc3 | |||
| cc440ea949 | |||
| 5ef1f4ecc9 | |||
| 4dc73d42cb | |||
| fa656a8d02 | |||
| f26cfdc830 | |||
| 71b35b9cc6 | |||
| 70f7a73e6d | |||
| f92e97b747 | |||
| d66124862a | |||
| f7986ca520 | |||
| 425f2f4d5f | |||
| 35ee164794 | |||
| cf1eb6965e | |||
| 337a6bda1f | |||
| fd1f3ef636 | |||
| afed409127 | |||
| 6e8edbbb09 |
15
.env.example
15
.env.example
@@ -7,8 +7,9 @@ TRUST_PROXY=false
|
||||
FRONTEND_ORIGIN=http://localhost:20015
|
||||
APP_ORIGIN=http://localhost:20015
|
||||
BACKEND_PUBLIC_ORIGIN=http://localhost:20016
|
||||
VITE_API_BASE_URL=http://localhost:20016
|
||||
VITE_SITE_URL=https://pokopiawiki.tootaio.com
|
||||
NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
|
||||
NUXT_SERVER_API_BASE_URL=http://localhost:3001
|
||||
NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
|
||||
RESEND_API_KEY=
|
||||
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
|
||||
RESEND_DAILY_QUOTA_LIMIT=100
|
||||
@@ -17,8 +18,16 @@ RESEND_QUOTA_RESERVE=5
|
||||
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
|
||||
AI_MODERATION_API_KEY=
|
||||
|
||||
# Local Docker debug defaults:
|
||||
# docker compose -f docker-compose.debug.yml up --build
|
||||
# NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
|
||||
# NUXT_SERVER_API_BASE_URL=http://backend:3001
|
||||
# NUXT_PUBLIC_SITE_URL=http://localhost:20015
|
||||
|
||||
# Cloudflared tunnel deployment example:
|
||||
# FRONTEND_ORIGIN=https://pokopiawiki.tootaio.com,http://localhost:20015
|
||||
# APP_ORIGIN=https://pokopiawiki.tootaio.com
|
||||
# BACKEND_PUBLIC_ORIGIN=https://api-pokopiawiki.tootaio.com
|
||||
# VITE_API_BASE_URL=https://api-pokopiawiki.tootaio.com
|
||||
# NUXT_PUBLIC_API_BASE_URL=https://api-pokopiawiki.tootaio.com
|
||||
# NUXT_SERVER_API_BASE_URL=http://backend:3001
|
||||
# NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
dist/
|
||||
.nuxt/
|
||||
.output/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -8,4 +10,4 @@ coverage/
|
||||
*.log
|
||||
.DS_Store
|
||||
.agents/
|
||||
skills-lock.json
|
||||
skills-lock.json
|
||||
|
||||
23
AGENTS.md
23
AGENTS.md
@@ -15,11 +15,12 @@
|
||||
For any non-trivial task:
|
||||
|
||||
1. **Read `DESIGN.md`**
|
||||
2. For UI, component, layout, or styling tasks, **also read `DesignGuidelines.html`**
|
||||
3. **Produce a short plan (no code)**
|
||||
4. Wait for approval
|
||||
5. Implement in small steps
|
||||
6. Run lightweight validation when practical (lint/typecheck). Do not run tests in WSL.
|
||||
2. While `SSR_MIGRATION_TASKLIST.md` exists, **also read `SSR_MIGRATION_TASKLIST.md`** and keep SSR migration work aligned with it.
|
||||
3. For UI, component, layout, or styling tasks, **also read `DesignGuidelines.html`**
|
||||
4. **Produce a short plan (no code)**
|
||||
5. Wait for approval
|
||||
6. Implement in small steps
|
||||
7. Run lightweight validation when practical (lint/typecheck). Do not run tests in WSL.
|
||||
|
||||
Do NOT skip planning.
|
||||
|
||||
@@ -27,6 +28,16 @@ For documentation-only tasks, still follow the planning workflow, but do not run
|
||||
|
||||
---
|
||||
|
||||
## Temporary SSR Migration Workflow
|
||||
|
||||
* `SSR_MIGRATION_TASKLIST.md` is the active task list for completing the Nuxt SSR migration.
|
||||
* Until that migration is fully implemented and validated, every task that touches frontend routing, auth, API fetching, i18n, SEO, Docker frontend deployment, Nuxt config, or SSR/client runtime behavior must read and follow `SSR_MIGRATION_TASKLIST.md`.
|
||||
* Update task checkboxes in `SSR_MIGRATION_TASKLIST.md` only when the corresponding implementation is actually complete and validated.
|
||||
* Do not delete `SSR_MIGRATION_TASKLIST.md` early. Delete it only after the project is fully migrated to the final SSR deployment model, validation is complete, and `DESIGN.md` reflects the final behavior.
|
||||
* When deleting `SSR_MIGRATION_TASKLIST.md`, also remove this Temporary SSR Migration Workflow section and the mandatory workflow step that requires reading the task list.
|
||||
|
||||
---
|
||||
|
||||
## Project Context
|
||||
|
||||
* Goal: Pokopia Wiki, a community-editable game wiki.
|
||||
@@ -34,8 +45,8 @@ For documentation-only tasks, still follow the planning workflow, but do not run
|
||||
* Runtime baseline: Node.js >= 22.
|
||||
* Frontend:
|
||||
|
||||
* Nuxt SPA mode currently (`ssr: false`), with SSR migration tracked in `SSR_MIGRATION_TASKLIST.md`
|
||||
* Vue
|
||||
* Vite
|
||||
* Vue Router
|
||||
* Vue I18n
|
||||
* Iconify
|
||||
|
||||
40
DESIGN.md
40
DESIGN.md
@@ -15,7 +15,7 @@
|
||||
## 技术栈
|
||||
|
||||
- Monorepo:pnpm workspace,Node.js >= 22,TypeScript。
|
||||
- 前端:Vue、Vite、Vue Router、Vue I18n、Iconify。
|
||||
- 前端:Nuxt(`ssr: true`)、Vue、Vue Router、Vue I18n、Iconify。
|
||||
- 后端:Node.js、Fastify、pg、PostgreSQL。
|
||||
- 运维:Docker / docker compose。
|
||||
- 依赖版本遵循现有 `package.json`,新增依赖时优先使用当前主流稳定版本,并保持项目结构简单。
|
||||
@@ -27,12 +27,13 @@
|
||||
- 全局搜索 API 只返回公开浏览所需的最小结果字段:结果类型、ID、展示标题、目标 URL、可选摘要和可选图片;用户搜索结果只使用公开 Profile 所需的 `id`、`displayName` 和目标 URL,不返回邮箱、角色、权限、Referral、编辑审计、审核原因、token/hash、内部字段或调试信息。
|
||||
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
|
||||
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
|
||||
- 列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序。
|
||||
- 除 Pokemon 外,列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序;Pokemon 列表按内部 `id` 升序展示,不提供手动排序。
|
||||
|
||||
## 国际化
|
||||
|
||||
- 前端使用 Vue I18n 管理界面文案,并通过 `X-Locale` 请求头告知后端当前语言。
|
||||
- 前端当前语言保存在 `localStorage` 的 `pokopia_locale`。
|
||||
- Nuxt SSR 运行时每个 Nuxt app/request 创建独立 Vue I18n 实例,避免跨请求共享 locale 或系统文案状态;服务端默认使用 `en`,客户端 hydration 后按 `pokopia_locale` 恢复用户语言。
|
||||
- 后端默认语言为 `en`。
|
||||
- 语言配置存储在 `languages`:
|
||||
- `code`
|
||||
@@ -120,10 +121,15 @@
|
||||
- 重置 token 只保存 hash,并带过期时间和使用状态。
|
||||
- 密码重置成功后不自动登录,并删除该用户已有 session。
|
||||
- 登录页提供 Remember me:
|
||||
- 未勾选时前端将登录 token 保存在 `sessionStorage` 的 `pokopia_auth_token`,服务端 session 有效期为 1 天。
|
||||
- 勾选时前端将登录 token 保存在 `localStorage` 的 `pokopia_auth_token`,服务端 session 有效期为 30 天。
|
||||
- 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。
|
||||
- 用户可退出登录,退出时删除对应 session。
|
||||
- 未勾选时 session 有效期为 1 天。
|
||||
- 勾选时 session 有效期为 30 天。
|
||||
- SSR 认证使用 HTTP-only cookie session:
|
||||
- 登录成功后后端设置 HTTP-only `pokopia_session` cookie;cookie 只保存明文 session token,数据库只保存 session token hash。
|
||||
- 登录响应只返回当前用户必要字段,不返回明文 session token、session token hash 或内部 session 元数据。
|
||||
- Remember me 通过 HTTP-only session cookie 有效期实现:未勾选时有效期为 1 天,勾选时有效期为 30 天。
|
||||
- 受保护 API 只接受 HTTP-only cookie session,不接受前端 JavaScript 保存的 legacy Bearer token。
|
||||
- 前端 API 请求携带 credentials,以便浏览器自动发送 HTTP-only session cookie;JavaScript 不读取该 cookie。
|
||||
- 用户可退出登录,退出时删除对应 session 并清除 HTTP-only session cookie。
|
||||
- 对外用户字段只包含必要信息:
|
||||
- 当前用户:`id`、`email`、`displayName`、`emailVerified`
|
||||
- 编辑署名:`id`、`displayName`
|
||||
@@ -352,7 +358,7 @@
|
||||
- `created_at`
|
||||
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
|
||||
- 编辑历史中的用户信息只展示必要署名,不暴露邮箱、token、hash 或内部元数据。
|
||||
- 排序操作仍更新列表顺序、最后编辑者和最后编辑时间,但 `sort_order` / Sort order 字段变更不写入或展示在详情页编辑历史面板中。
|
||||
- 非 Pokemon 列表排序操作仍更新列表顺序、最后编辑者和最后编辑时间,但 `sort_order` / Sort order 字段变更不写入或展示在详情页编辑历史面板中。
|
||||
- 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。
|
||||
|
||||
## Wiki 图片上传
|
||||
@@ -523,7 +529,6 @@ Pokemon 可配置:
|
||||
- Speed
|
||||
- 出现的栖息地:由栖息地出现配置反向展示
|
||||
- 翻译
|
||||
- 排序
|
||||
|
||||
普通 Pokemon 与 Event Pokemon 分开展示:
|
||||
|
||||
@@ -580,7 +585,7 @@ Pokemon 列表功能:
|
||||
- 按喜欢的东西筛选:
|
||||
- 满足任意条件
|
||||
- 满足全部条件
|
||||
- 按自定义排序展示
|
||||
- 按 Pokemon 内部 `id` 升序展示
|
||||
- 列表首屏只读取一页数据;滚动到列表底部时继续读取下一页,不一次性加载全部 Pokemon。
|
||||
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
|
||||
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
|
||||
@@ -597,8 +602,8 @@ Pokemon 详情页展示:
|
||||
- 六维使用 ProgressBar 展示,最大值按 150 计算。
|
||||
- 特长
|
||||
- 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标,未配置时展示空状态
|
||||
- Trading:当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品,Likes 表示交易价格 1.5x,Neutral 表示无加成,未配置观察时展示空状态
|
||||
- Trading 可在详情页通过 Manage Trading Modal 维护;Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动
|
||||
- Trading:当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品,Likes 表示交易价格 1.5x,Neutral 表示无加成,未配置观察时展示空状态;详情页双列列表高度受限,内容较多时每列内部独立滚动,避免 Section 被大量物品无限拉长
|
||||
- Trading 可在详情页通过 Manage Trading Modal 维护;Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;搜索结果优先展示名称精确匹配、名称开头匹配和单词匹配的物品,再展示名称包含、分类或用途包含的物品;搜索框支持上下键切换当前结果、左右键切换默认加入组、Enter 将当前结果加入默认目标组;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动
|
||||
- 喜欢的环境
|
||||
- 喜欢的东西
|
||||
- 相关 Pokemon:与关联喜欢的东西的物品在桌面端左右并排展示;按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
|
||||
@@ -999,7 +1004,7 @@ API 暴露边界:
|
||||
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
|
||||
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
|
||||
- 配置:System config。
|
||||
- 内容:Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口,以及 Data Tools。
|
||||
- 内容:Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口,以及 Data Tools;Pokemon 在 Admin 中可删除但不提供手动排序。
|
||||
- 内容管理包含 Items、Event Items 与 Ancient Artifacts;Items / Event Items 使用同一物品数据模型,通过 `is_event_item` 拆分入口。
|
||||
- 本地化:Languages、System wordings。
|
||||
- 访问权限:Users、Roles、Permissions、Rate limits。
|
||||
@@ -1036,8 +1041,8 @@ API 暴露边界:
|
||||
- `favicon.ico`
|
||||
- 默认社交分享图
|
||||
- 品牌 Logo 素材
|
||||
- `VITE_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`。
|
||||
- 前端入口 `index.html` 提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon;客户端路由切换后根据当前路由更新页面 metadata。
|
||||
- `NUXT_PUBLIC_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`。
|
||||
- 前端 Nuxt app head 配置提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon;路由 metadata 与详情页公开业务数据通过 Nuxt `useHead` 更新页面 metadata,避免直接操作 `document.head`。
|
||||
- 主要公开浏览入口可索引:
|
||||
- `/pokemon`
|
||||
- `/event-pokemon`
|
||||
@@ -1061,9 +1066,13 @@ API 暴露边界:
|
||||
## 部署与升级维护
|
||||
|
||||
- Docker 部署时公开前端端口由 `frontend_gateway` 承载,正常流量代理到 `frontend` 服务。
|
||||
- 前端浏览器 API base URL 由 `NUXT_PUBLIC_API_BASE_URL` 提供。
|
||||
- Nuxt 服务端 API base URL 由 `NUXT_SERVER_API_BASE_URL` 提供;在 Docker 内默认使用 `http://backend:3001`,本地非 Docker 运行可使用 `http://localhost:3001`。服务端公开数据读取使用该内部地址,浏览器请求继续使用 `NUXT_PUBLIC_API_BASE_URL`。
|
||||
- 前端 Docker 构建使用 Nuxt server output,`frontend` 服务通过 Node 运行 `.output/server/index.mjs`;Nuxt SSR server 监听容器内 `0.0.0.0:20015`,公开流量仍由 `frontend_gateway` 代理。
|
||||
- `frontend` 因 `docker compose up -d --build` 重建、启动中或临时不可达时,`frontend_gateway` 返回静态升级维护页并保持公开端口可访问;后端 `/health` 不可用时,前端网关也返回同一维护页,避免用户看到静态页面后遇到 API 不可用。
|
||||
- 升级维护页是基础设施级静态 fallback,不依赖 Vue、Vue I18n、后端 API 或数据库;页面只展示正式用户文案和品牌视觉,不展示构建日志、调试信息、内部字段或实现说明。
|
||||
- 升级维护页使用 `503`、`Retry-After: 300`、`Cache-Control: no-store` 和 `noindex`,提示用户 Pokopia Wiki 正在升级并将在约 5 分钟内恢复。
|
||||
- 本地 Docker 调试使用 `docker-compose.debug.yml`,通过 bind mount 运行 Nuxt dev server 与 backend `tsx watch`,支持前后端热重载;该调试入口不经过 `frontend_gateway` 维护页,不代表生产部署行为。
|
||||
|
||||
## API 概览
|
||||
|
||||
@@ -1180,7 +1189,7 @@ API 暴露边界:
|
||||
- `GET /api/admin/ai-moderation`
|
||||
- `PUT /api/admin/ai-moderation`
|
||||
- `PUT /api/admin/system-wordings/:key`
|
||||
- Pokemon、物品、材料单、栖息地的列表排序需要对应实体的 `order` 权限。
|
||||
- 物品、材料单、栖息地的列表排序需要对应实体的 `order` 权限;Pokemon 按内部 `id` 排序,不提供列表排序 API 或 Admin 手动排序入口。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
@@ -1190,3 +1199,4 @@ API 暴露边界:
|
||||
- `pnpm typecheck`
|
||||
- 不在 WSL 中运行测试作为完成任务的前置条件。
|
||||
- Docker 运行问题以用户提供的 `docker compose up --build` 输出为准进行后续修复。
|
||||
- 本地热重载调试可运行 `pnpm docker:debug` 或 `docker compose -f docker-compose.debug.yml up --build`;生产 SSR runtime 验证仍使用 `pnpm docker:prod` 或 `docker compose up --build`。
|
||||
|
||||
60
SSR_MIGRATION_TASKLIST.md
Normal file
60
SSR_MIGRATION_TASKLIST.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# SSR Migration Remaining Tasks
|
||||
|
||||
This temporary file tracks only the work still required before the Nuxt SSR migration can be considered complete.
|
||||
|
||||
Delete this file only after all items below are complete and `AGENTS.md` no longer needs the temporary SSR migration workflow.
|
||||
|
||||
## Remaining Work
|
||||
|
||||
- [ ] Run production Docker validation with `docker compose up --build`.
|
||||
- [ ] Fix any Docker runtime errors from the production SSR container, frontend gateway, backend API, or SSR server-to-backend API connection.
|
||||
- [ ] Verify anonymous SSR HTML for public routes contains meaningful public business content and route/detail metadata:
|
||||
- `/`
|
||||
- `/pokemon`
|
||||
- `/event-pokemon`
|
||||
- `/habitats`
|
||||
- `/event-habitats`
|
||||
- `/items`
|
||||
- `/event-items`
|
||||
- `/ancient-artifacts`
|
||||
- `/recipes`
|
||||
- `/checklist`
|
||||
- `/dish`
|
||||
- `/life`
|
||||
- `/life/:id`
|
||||
- `/profile/:id`
|
||||
- `/project-updates`
|
||||
- [ ] Verify generated HTML, Nuxt payloads, API responses used by SSR, metadata, and logs do not expose password hashes, session token hashes, verification/reset token hashes, private current-user data on public pages, role internals, permission internals, internal audit payloads, debug fields, stack traces, or implementation notes.
|
||||
- [ ] Verify localized SSR reads and metadata follow the `DESIGN.md` fallback order: requested locale, default-language translation, then base field.
|
||||
- [ ] Verify auth and permission route behavior with SSR enabled:
|
||||
- anonymous users redirect from protected routes to login
|
||||
- unverified users cannot access verified-only write flows
|
||||
- users missing permissions cannot access permissioned routes
|
||||
- current-user reads expose only fields allowed by `DESIGN.md`
|
||||
- [ ] Verify hydrated logged-in flows still work:
|
||||
- login
|
||||
- logout
|
||||
- Remember me
|
||||
- `/profile`
|
||||
- notifications
|
||||
- route-backed create/edit modals
|
||||
- uploads
|
||||
- Life comments/reactions
|
||||
- entity discussion comments
|
||||
- admin access
|
||||
- [ ] Verify browser-only UI behavior runs only on the client and remains stable after hydration:
|
||||
- modal focus and body locking
|
||||
- dropdown positioning
|
||||
- scroll/resize listeners
|
||||
- infinite-scroll sentinels
|
||||
- clipboard actions
|
||||
- `window.confirm` actions
|
||||
- notification WebSocket
|
||||
- upload file APIs
|
||||
- [ ] Verify route-backed modal pages preserve underlying page context and avoid unwanted scroll jumps.
|
||||
- [ ] Verify `robots.txt`, `sitemap.xml`, canonical URLs, `noindex` routes, Open Graph, Twitter card, and public detail metadata in the production runtime.
|
||||
- [x] Remove legacy SPA-only compatibility paths once SSR behavior is stable.
|
||||
- [x] Remove obsolete `VITE_*` fallback support after deployment has fully moved to documented `NUXT_*` variables.
|
||||
- [x] Update `DESIGN.md` if final behavior differs from the current documented SSR deployment, auth, SEO, or environment-variable model.
|
||||
- [ ] Update `AGENTS.md` to remove the temporary SSR migration workflow and the requirement to read this task list.
|
||||
- [ ] Delete `SSR_MIGRATION_TASKLIST.md`.
|
||||
@@ -231,7 +231,6 @@ VALUES
|
||||
('pokemon.create', 'Create Pokemon', 'Create Pokemon records.', 'Pokemon', true),
|
||||
('pokemon.update', 'Update Pokemon', 'Edit Pokemon records.', 'Pokemon', true),
|
||||
('pokemon.delete', 'Delete Pokemon', 'Delete Pokemon records.', 'Pokemon', true),
|
||||
('pokemon.order', 'Order Pokemon', 'Reorder Pokemon records.', 'Pokemon', true),
|
||||
('pokemon.fetch', 'Fetch Pokemon data', 'Fetch Pokemon data and sprite candidates.', 'Pokemon', true),
|
||||
('pokemon.upload', 'Upload Pokemon images', 'Upload Pokemon images.', 'Pokemon', true),
|
||||
('habitats.create', 'Create habitats', 'Create habitat records.', 'Habitats', true),
|
||||
@@ -275,6 +274,9 @@ VALUES
|
||||
('discussions.comments.like', 'Like discussion comments', 'Like and unlike entity discussion comments.', 'Discussions', true)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
DELETE FROM permissions
|
||||
WHERE key = 'pokemon.order';
|
||||
|
||||
INSERT INTO roles (key, name, description, level, enabled, system_role)
|
||||
VALUES
|
||||
('owner', 'Owner', 'Highest-level system owner with all permissions.', 1000, true, true),
|
||||
@@ -329,7 +331,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
||||
'pokemon.create',
|
||||
'pokemon.update',
|
||||
'pokemon.delete',
|
||||
'pokemon.order',
|
||||
'pokemon.fetch',
|
||||
'pokemon.upload',
|
||||
'habitats.create',
|
||||
@@ -411,7 +412,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
||||
'checklist.order',
|
||||
'pokemon.create',
|
||||
'pokemon.update',
|
||||
'pokemon.order',
|
||||
'pokemon.fetch',
|
||||
'pokemon.upload',
|
||||
'habitats.create',
|
||||
|
||||
@@ -108,7 +108,7 @@ type ConfigDefinition = {
|
||||
hasRateable?: boolean;
|
||||
hasChangeLog?: boolean;
|
||||
};
|
||||
type SortableContentType = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
|
||||
type SortableContentType = 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
|
||||
type SortableContentDefinition = {
|
||||
table: string;
|
||||
entityType: SortableContentType;
|
||||
@@ -691,7 +691,6 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
||||
};
|
||||
|
||||
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
|
||||
pokemon: { table: 'pokemon', entityType: 'pokemon' },
|
||||
items: { table: 'items', entityType: 'items' },
|
||||
'ancient-artifacts': { table: 'items', entityType: 'ancient-artifacts' },
|
||||
recipes: { table: 'recipes', entityType: 'recipes' },
|
||||
@@ -2809,7 +2808,7 @@ export async function globalSearch(paramsQuery: QueryParams = {}, locale = defau
|
||||
${pokemonImageJson('p')} AS image
|
||||
FROM pokemon p
|
||||
WHERE ${pokemonName} ILIKE $1
|
||||
ORDER BY ${orderByEntity('p')}
|
||||
ORDER BY p.id
|
||||
LIMIT $2
|
||||
`,
|
||||
[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) {
|
||||
await reorderContent('items', payload, userId);
|
||||
return listItems({}, locale);
|
||||
@@ -5822,7 +5816,7 @@ export async function listPokemon(paramsQuery: QueryParams, locale = defaultLoca
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -5927,7 +5921,6 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
scored_pokemon AS (
|
||||
SELECT
|
||||
related_pokemon.id,
|
||||
related_pokemon.sort_order,
|
||||
(related_pokemon.environment_id = current_pokemon.environment_id) AS "environmentMatches",
|
||||
COUNT(current_favourites.favorite_thing_id)::integer AS "favoriteThingMatchCount"
|
||||
FROM current_pokemon
|
||||
@@ -5936,7 +5929,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
ON related_pokemon_favourite.pokemon_id = related_pokemon.id
|
||||
LEFT JOIN current_favourites
|
||||
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
|
||||
OR COUNT(current_favourites.favorite_thing_id) > 0
|
||||
)
|
||||
@@ -5981,7 +5974,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
FROM scored_pokemon
|
||||
JOIN pokemon related_pokemon ON related_pokemon.id = scored_pokemon.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]
|
||||
),
|
||||
@@ -6369,10 +6362,10 @@ export async function listHabitats(paramsQuery: QueryParams = {}, locale = defau
|
||||
'name', pokemon_rows.name,
|
||||
'isEventItem', pokemon_rows.is_event_item
|
||||
)
|
||||
ORDER BY pokemon_rows.sort_order, pokemon_rows.id
|
||||
ORDER BY pokemon_rows.id
|
||||
)
|
||||
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
|
||||
JOIN pokemon p ON p.id = hp.pokemon_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 maps m ON m.id = hp.map_id
|
||||
WHERE hp.habitat_id = $1
|
||||
ORDER BY hp.rarity, ${orderByEntity('p')}, ${orderByEntity('m')}
|
||||
ORDER BY hp.rarity, p.id, ${orderByEntity('m')}
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
@@ -6855,7 +6848,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
JOIN skills s ON s.id = psid.skill_id
|
||||
WHERE psid.item_id = $1
|
||||
AND s.has_item_drop = true
|
||||
ORDER BY ${orderByEntity('p')}, ${orderByEntity('s')}
|
||||
ORDER BY p.id, ${orderByEntity('s')}
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
@@ -6893,7 +6886,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
WHERE ps.pokemon_id = p.id
|
||||
AND trading_skill.has_trading = true
|
||||
)
|
||||
ORDER BY pti.preference DESC, ${orderByEntity('p')}
|
||||
ORDER BY pti.preference DESC, p.id
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
@@ -8493,7 +8486,7 @@ async function exportGenericScopeData(client: DbClient, entityType: string, incl
|
||||
async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<DataToolScopeData> {
|
||||
if (scope === 'pokemon') {
|
||||
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'),
|
||||
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'),
|
||||
|
||||
@@ -111,7 +111,6 @@ import {
|
||||
reorderHabitats,
|
||||
reorderItems,
|
||||
reorderLanguages,
|
||||
reorderPokemon,
|
||||
reorderRecipes,
|
||||
retryEntityDiscussionCommentModeration,
|
||||
retryLifeCommentModeration,
|
||||
@@ -166,6 +165,9 @@ const app = Fastify({
|
||||
logger: true,
|
||||
trustProxy: process.env.TRUST_PROXY === 'true'
|
||||
});
|
||||
const sessionCookieName = 'pokopia_session';
|
||||
const rememberedSessionDays = 30;
|
||||
const sessionOnlySessionDays = 1;
|
||||
|
||||
function configuredCorsOrigin(): true | string | string[] {
|
||||
const rawOrigin = process.env.FRONTEND_ORIGIN?.trim();
|
||||
@@ -182,8 +184,9 @@ function configuredCorsOrigin(): true | string | string[] {
|
||||
}
|
||||
|
||||
await app.register(cors, {
|
||||
allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'X-Locale'],
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
origin: configuredCorsOrigin()
|
||||
});
|
||||
|
||||
@@ -243,9 +246,52 @@ app.get('/api/search', async (request) =>
|
||||
globalSearch(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||
);
|
||||
|
||||
function getBearerToken(authorization: string | undefined): string | null {
|
||||
const [scheme, token] = authorization?.split(' ') ?? [];
|
||||
return scheme === 'Bearer' && token ? token : null;
|
||||
function getCookieValue(cookieHeader: string | undefined, name: string): string | null {
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const cookiePart of cookieHeader.split(';')) {
|
||||
const [rawName, ...rawValue] = cookiePart.trim().split('=');
|
||||
if (rawName === name) {
|
||||
try {
|
||||
return decodeURIComponent(rawValue.join('='));
|
||||
} catch {
|
||||
return rawValue.join('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSessionToken(request: FastifyRequest): string | null {
|
||||
return getCookieValue(request.headers.cookie, sessionCookieName);
|
||||
}
|
||||
|
||||
function sessionCookieSecure(): boolean {
|
||||
const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? '';
|
||||
return origin.split(',').some((value) => value.trim().startsWith('https://'));
|
||||
}
|
||||
|
||||
function sessionCookie(value: string, maxAgeSeconds: number): string {
|
||||
return [
|
||||
`${sessionCookieName}=${encodeURIComponent(value)}`,
|
||||
'Path=/',
|
||||
'HttpOnly',
|
||||
'SameSite=Lax',
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
...(sessionCookieSecure() ? ['Secure'] : [])
|
||||
].join('; ');
|
||||
}
|
||||
|
||||
function setSessionCookie(reply: FastifyReply, token: string, rememberMe: boolean): void {
|
||||
const sessionDays = rememberMe ? rememberedSessionDays : sessionOnlySessionDays;
|
||||
reply.header('Set-Cookie', sessionCookie(token, sessionDays * 24 * 60 * 60));
|
||||
}
|
||||
|
||||
function clearSessionCookie(reply: FastifyReply): void {
|
||||
reply.header('Set-Cookie', `${sessionCookie('', 0)}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`);
|
||||
}
|
||||
|
||||
function requestLocale(request: FastifyRequest): string {
|
||||
@@ -868,7 +914,7 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
const token = getSessionToken(request);
|
||||
const user = token ? await getUserBySessionToken(token) : null;
|
||||
const locale = requestLocale(request);
|
||||
|
||||
@@ -950,7 +996,7 @@ async function requireAnyPermissionWithRateLimits(
|
||||
}
|
||||
|
||||
async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
const token = getSessionToken(request);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
@@ -983,7 +1029,10 @@ app.post('/api/auth/login', async (request, reply) => {
|
||||
return;
|
||||
}
|
||||
|
||||
return loginUser(request.body as Record<string, unknown>, requestLocale(request));
|
||||
const payload = request.body as Record<string, unknown>;
|
||||
const response = await loginUser(payload, requestLocale(request));
|
||||
setSessionCookie(reply, response.token, payload.rememberMe === true);
|
||||
return { user: response.user };
|
||||
});
|
||||
|
||||
app.post('/api/auth/request-password-reset', async (request, reply) => {
|
||||
@@ -1007,7 +1056,7 @@ app.get('/api/auth/me', async (request, reply) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
const token = getSessionToken(request);
|
||||
const user = token ? await getUserBySessionToken(token) : null;
|
||||
|
||||
if (!user) {
|
||||
@@ -1022,7 +1071,7 @@ app.patch('/api/auth/me', async (request, reply) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
const token = getSessionToken(request);
|
||||
const user = token ? await getUserBySessionToken(token) : null;
|
||||
|
||||
if (!user) {
|
||||
@@ -1042,7 +1091,7 @@ app.patch('/api/auth/me/password', async (request, reply) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
const token = getSessionToken(request);
|
||||
const user = token ? await getUserBySessionToken(token) : null;
|
||||
|
||||
if (!user || !token) {
|
||||
@@ -1062,7 +1111,7 @@ app.get('/api/auth/referral', async (request, reply) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
const token = getSessionToken(request);
|
||||
const user = token ? await getUserBySessionToken(token) : null;
|
||||
|
||||
if (!user) {
|
||||
@@ -1099,11 +1148,12 @@ app.post('/api/notifications/:id/read', async (request, reply) => {
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (request, reply) => {
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
const token = getSessionToken(request);
|
||||
if (token) {
|
||||
await logoutSession(token);
|
||||
}
|
||||
|
||||
clearSessionCookie(reply);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
@@ -2041,11 +2091,6 @@ app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
|
||||
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.put('/api/admin/pokemon/order', async (request, reply) => {
|
||||
const user = await requirePermissionWithRateLimits(request, reply, 'pokemon.order', 'wikiWrite');
|
||||
return user ? reorderPokemon(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||
});
|
||||
|
||||
app.put('/api/admin/items/order', async (request, reply) => {
|
||||
const user = await requirePermissionWithRateLimits(request, reply, 'items.order', 'wikiWrite');
|
||||
return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||
|
||||
108
docker-compose.debug.yml
Normal file
108
docker-compose.debug.yml
Normal 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:
|
||||
@@ -40,10 +40,14 @@ services:
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
args:
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:20016}
|
||||
VITE_SITE_URL: ${VITE_SITE_URL:-https://pokopiawiki.tootaio.com}
|
||||
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
|
||||
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
|
||||
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://pokopiawiki.tootaio.com}
|
||||
environment:
|
||||
PORT: 20015
|
||||
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
|
||||
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
|
||||
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://pokopiawiki.tootaio.com}
|
||||
expose:
|
||||
- "20015"
|
||||
depends_on:
|
||||
|
||||
@@ -8,21 +8,23 @@ RUN corepack enable && corepack prepare pnpm@10.33.2 --activate && pnpm install
|
||||
COPY frontend ./frontend
|
||||
COPY system-wordings.ts ./system-wordings.ts
|
||||
|
||||
ARG VITE_API_BASE_URL=http://localhost:3001
|
||||
ARG VITE_SITE_URL=https://pokopiawiki.tootaio.com
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
ENV VITE_SITE_URL=$VITE_SITE_URL
|
||||
ARG NUXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
||||
ARG NUXT_SERVER_API_BASE_URL=http://localhost:3001
|
||||
ARG NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
|
||||
ENV NUXT_PUBLIC_API_BASE_URL=$NUXT_PUBLIC_API_BASE_URL
|
||||
ENV NUXT_SERVER_API_BASE_URL=$NUXT_SERVER_API_BASE_URL
|
||||
ENV NUXT_PUBLIC_SITE_URL=$NUXT_PUBLIC_SITE_URL
|
||||
RUN pnpm --filter @pokopia/frontend build
|
||||
|
||||
FROM node:22-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=20015
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/frontend/dist ./dist
|
||||
COPY frontend/static-server.mjs ./static-server.mjs
|
||||
COPY --from=build /app/frontend/.output ./.output
|
||||
|
||||
USER node
|
||||
EXPOSE 20015
|
||||
CMD ["node", "static-server.mjs"]
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import AppShell from './components/AppShell.vue';
|
||||
import AppShell from './src/components/AppShell.vue';
|
||||
import {
|
||||
iconAction,
|
||||
iconAdmin,
|
||||
@@ -20,12 +19,11 @@ import {
|
||||
iconPokemon,
|
||||
iconRecipe,
|
||||
type AppIcon
|
||||
} from './icons';
|
||||
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './i18n';
|
||||
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
|
||||
} from './src/icons';
|
||||
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
|
||||
import { api, notifyAuthChange, onAuthChange, type AuthUser, type Language } from './src/services/api';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const router = useRouter();
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const languages = ref<Language[]>([
|
||||
@@ -114,17 +112,11 @@ const navItems = computed<NavItem[]>(() => {
|
||||
});
|
||||
|
||||
async function loadCurrentUser() {
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.me();
|
||||
currentUser.value = response.user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
setAuthToken(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +128,7 @@ async function logout() {
|
||||
}
|
||||
|
||||
currentUser.value = null;
|
||||
setAuthToken(null);
|
||||
notifyAuthChange();
|
||||
await router.push('/');
|
||||
}
|
||||
|
||||
@@ -165,7 +157,7 @@ async function updateLocale(value: string) {
|
||||
onMounted(() => {
|
||||
void loadLanguages();
|
||||
void loadCurrentUser();
|
||||
removeAuthListener = onAuthTokenChange(() => {
|
||||
removeAuthListener = onAuthChange(() => {
|
||||
void loadCurrentUser();
|
||||
});
|
||||
removeLocaleListener = onLocaleChange(() => {
|
||||
@@ -188,6 +180,6 @@ onUnmounted(() => {
|
||||
@logout="logout"
|
||||
@update:locale="updateLocale"
|
||||
>
|
||||
<RouterView :key="locale" />
|
||||
<NuxtPage :key="locale" />
|
||||
</AppShell>
|
||||
</template>
|
||||
9
frontend/app/router.options.ts
Normal file
9
frontend/app/router.options.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { RouterConfig } from '@nuxt/schema';
|
||||
|
||||
export default <RouterConfig>{
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) return savedPosition;
|
||||
if (to.meta.editorModal === true || from.meta.editorModal === true) return false;
|
||||
return { top: 0 };
|
||||
}
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
/>
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="theme-color" content="#6ccf32" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||
<link rel="canonical" href="%POKOPIA_SITE_URL%/pokemon" />
|
||||
<meta property="og:site_name" content="Pokopia Wiki" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
/>
|
||||
<meta property="og:url" content="%POKOPIA_SITE_URL%/pokemon" />
|
||||
<meta property="og:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||
/>
|
||||
<meta name="twitter:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
|
||||
<script>
|
||||
(function () {
|
||||
const UMAMI_SCRIPT_JS = "https://umami.tootaio.com/script.js";
|
||||
const UMAMI_ID = "6c00a2e5-dc72-41f3-9d5d-aac93aaaf1cb";
|
||||
|
||||
var script = document.createElement("script");
|
||||
script.async = true;
|
||||
script.src = UMAMI_SCRIPT_JS;
|
||||
script.setAttribute("data-website-id", UMAMI_ID);
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
<title>Pokopia Wiki - Pokemon Pokopia Guide</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
frontend/middleware/auth.global.ts
Normal file
35
frontend/middleware/auth.global.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { api } from '../src/services/api';
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const requiredPermissions = to.matched
|
||||
.map((record) => record.meta.requiredPermission)
|
||||
.filter((permission): permission is string => typeof permission === 'string');
|
||||
const requiredAnyPermissions = to.matched.flatMap((record) =>
|
||||
Array.isArray(record.meta.requiredAnyPermission)
|
||||
? record.meta.requiredAnyPermission.filter((permission): permission is string => typeof permission === 'string')
|
||||
: []
|
||||
);
|
||||
const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true) || requiredPermissions.length > 0 || requiredAnyPermissions.length > 0;
|
||||
const requiresAuth = requiresVerified || to.matched.some((record) => record.meta.requiresAuth === true);
|
||||
|
||||
if (!requiresAuth) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.me(import.meta.server ? { headers: useRequestHeaders(['cookie']) } : undefined);
|
||||
if (requiresVerified && !response.user.emailVerified) {
|
||||
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
|
||||
}
|
||||
|
||||
const permissionSet = new Set(response.user.permissions);
|
||||
if (requiredPermissions.some((permission) => !permissionSet.has(permission))) {
|
||||
return navigateTo('/pokemon');
|
||||
}
|
||||
if (requiredAnyPermissions.length && !requiredAnyPermissions.some((permission) => permissionSet.has(permission))) {
|
||||
return navigateTo('/pokemon');
|
||||
}
|
||||
} catch {
|
||||
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
|
||||
}
|
||||
});
|
||||
50
frontend/nuxt.config.ts
Normal file
50
frontend/nuxt.config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||
|
||||
function normalizeSiteUrl(value: string | undefined): string {
|
||||
return (value?.trim() || fallbackSiteUrl).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export default defineNuxtConfig({
|
||||
ssr: true,
|
||||
devtools: { enabled: false },
|
||||
css: ['~/src/styles/main.css'],
|
||||
compatibilityDate: '2026-05-06',
|
||||
runtimeConfig: {
|
||||
serverApiBaseUrl:
|
||||
process.env.NUXT_SERVER_API_BASE_URL ??
|
||||
process.env.NUXT_PUBLIC_API_BASE_URL ??
|
||||
'http://localhost:3001',
|
||||
public: {
|
||||
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3001',
|
||||
siteUrl: normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL)
|
||||
}
|
||||
},
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: {
|
||||
lang: 'en'
|
||||
},
|
||||
title: 'Pokopia Wiki - Pokemon Pokopia Guide',
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
|
||||
{ name: 'theme-color', content: '#6ccf32' }
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' }
|
||||
],
|
||||
script: [
|
||||
{
|
||||
async: true,
|
||||
src: 'https://umami.tootaio.com/script.js',
|
||||
'data-website-id': '6c00a2e5-dc72-41f3-9d5d-aac93aaaf1cb'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
nitro: {
|
||||
prerender: {
|
||||
routes: ['/robots.txt', '/sitemap.xml']
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -5,16 +5,15 @@
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 20015",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"lint": "vue-tsc --noEmit",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"dev": "nuxt dev --host 0.0.0.0 --port 20015",
|
||||
"build": "nuxt build",
|
||||
"lint": "nuxt typecheck",
|
||||
"typecheck": "nuxt typecheck",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/vue": "5.0.0",
|
||||
"@vitejs/plugin-vue": "6.0.6",
|
||||
"vite": "8.0.10",
|
||||
"nuxt": "4.4.4",
|
||||
"vue": "3.5.33",
|
||||
"vue-i18n": "11.4.0",
|
||||
"vue-router": "5.0.6"
|
||||
@@ -22,6 +21,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "25.6.0",
|
||||
"@vue/tsconfig": "0.9.1",
|
||||
"postcss": "8.5.13",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.5",
|
||||
"vue-tsc": "3.2.7"
|
||||
|
||||
12
frontend/pages/actions.vue
Normal file
12
frontend/pages/actions.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'actions',
|
||||
seo: { titleKey: 'pages.comingSoon.sections.actions.title', descriptionKey: 'pages.comingSoon.sections.actions.subtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoonView page="actions" />
|
||||
</template>
|
||||
13
frontend/pages/admin.vue
Normal file
13
frontend/pages/admin.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import AdminView from '../src/views/AdminView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'admin',
|
||||
requiredPermission: 'admin.access',
|
||||
seo: { titleKey: 'pages.admin.title', descriptionKey: 'pages.admin.subtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminView />
|
||||
</template>
|
||||
20
frontend/pages/ancient-artifacts/[id]/edit.vue
Normal file
20
frontend/pages/ancient-artifacts/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'ancient-artifact-edit',
|
||||
requiredPermission: 'items.update',
|
||||
editorModal: true,
|
||||
seo: {
|
||||
titleKey: 'pages.ancientArtifacts.editKicker',
|
||||
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
|
||||
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/ancient-artifacts/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemDetail />
|
||||
</template>
|
||||
12
frontend/pages/ancient-artifacts/[id]/index.vue
Normal file
12
frontend/pages/ancient-artifacts/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'ancient-artifact-detail',
|
||||
seo: { titleKey: 'pages.ancientArtifacts.detailKicker', descriptionKey: 'pages.ancientArtifacts.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemDetail />
|
||||
</template>
|
||||
12
frontend/pages/ancient-artifacts/index.vue
Normal file
12
frontend/pages/ancient-artifacts/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import AncientArtifactList from '../../src/views/AncientArtifactList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'ancient-artifact-list',
|
||||
seo: { titleKey: 'pages.ancientArtifacts.title', descriptionKey: 'pages.ancientArtifacts.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AncientArtifactList />
|
||||
</template>
|
||||
19
frontend/pages/ancient-artifacts/new.vue
Normal file
19
frontend/pages/ancient-artifacts/new.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import AncientArtifactList from '../../src/views/AncientArtifactList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'ancient-artifact-new',
|
||||
requiredPermission: 'items.create',
|
||||
editorModal: true,
|
||||
seo: {
|
||||
titleKey: 'pages.ancientArtifacts.newTitle',
|
||||
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
|
||||
canonicalPath: '/ancient-artifacts',
|
||||
noindex: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AncientArtifactList />
|
||||
</template>
|
||||
12
frontend/pages/automation.vue
Normal file
12
frontend/pages/automation.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'automation',
|
||||
seo: { titleKey: 'pages.comingSoon.sections.automation.title', descriptionKey: 'pages.comingSoon.sections.automation.subtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoonView page="automation" />
|
||||
</template>
|
||||
12
frontend/pages/checklist.vue
Normal file
12
frontend/pages/checklist.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import DailyChecklistView from '../src/views/DailyChecklistView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'checklist',
|
||||
seo: { titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DailyChecklistView />
|
||||
</template>
|
||||
12
frontend/pages/clothes.vue
Normal file
12
frontend/pages/clothes.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'clothes',
|
||||
seo: { titleKey: 'pages.comingSoon.sections.clothes.title', descriptionKey: 'pages.comingSoon.sections.clothes.subtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoonView page="clothes" />
|
||||
</template>
|
||||
12
frontend/pages/disclaimers.vue
Normal file
12
frontend/pages/disclaimers.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import LegalView from '../src/views/LegalView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'disclaimers',
|
||||
seo: { titleKey: 'pages.legal.disclaimers.title', descriptionKey: 'pages.legal.disclaimers.subtitle', canonicalPath: '/disclaimers' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LegalView page="disclaimers" />
|
||||
</template>
|
||||
12
frontend/pages/dish.vue
Normal file
12
frontend/pages/dish.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import DishView from '../src/views/DishView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'dish',
|
||||
seo: { titleKey: 'pages.dish.title', descriptionKey: 'pages.dish.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DishView />
|
||||
</template>
|
||||
12
frontend/pages/dream-island.vue
Normal file
12
frontend/pages/dream-island.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'dream-island',
|
||||
seo: { titleKey: 'pages.comingSoon.sections.dreamIsland.title', descriptionKey: 'pages.comingSoon.sections.dreamIsland.subtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoonView page="dreamIsland" />
|
||||
</template>
|
||||
12
frontend/pages/event-habitats/index.vue
Normal file
12
frontend/pages/event-habitats/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import HabitatList from '../../src/views/HabitatList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'event-habitat-list',
|
||||
seo: { titleKey: 'pages.eventHabitats.title', descriptionKey: 'pages.eventHabitats.subtitle', canonicalPath: '/event-habitats' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HabitatList :event-only="true" />
|
||||
</template>
|
||||
14
frontend/pages/event-habitats/new.vue
Normal file
14
frontend/pages/event-habitats/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import HabitatList from '../../src/views/HabitatList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'event-habitat-new',
|
||||
requiredPermission: 'habitats.create',
|
||||
editorModal: true,
|
||||
seo: { titleKey: 'pages.eventHabitats.newTitle', descriptionKey: 'pages.eventHabitats.editSubtitle', canonicalPath: '/event-habitats', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HabitatList :event-only="true" />
|
||||
</template>
|
||||
12
frontend/pages/event-items/index.vue
Normal file
12
frontend/pages/event-items/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ItemsList from '../../src/views/ItemsList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'event-item-list',
|
||||
seo: { titleKey: 'pages.eventItems.title', descriptionKey: 'pages.eventItems.subtitle', canonicalPath: '/event-items' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemsList :event-only="true" />
|
||||
</template>
|
||||
14
frontend/pages/event-items/new.vue
Normal file
14
frontend/pages/event-items/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import ItemsList from '../../src/views/ItemsList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'event-item-new',
|
||||
requiredPermission: 'items.create',
|
||||
editorModal: true,
|
||||
seo: { titleKey: 'pages.eventItems.newTitle', descriptionKey: 'pages.eventItems.editSubtitle', canonicalPath: '/event-items', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemsList :event-only="true" />
|
||||
</template>
|
||||
12
frontend/pages/event-pokemon/index.vue
Normal file
12
frontend/pages/event-pokemon/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import PokemonList from '../../src/views/PokemonList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'event-pokemon-list',
|
||||
seo: { titleKey: 'pages.eventPokemon.title', descriptionKey: 'pages.eventPokemon.subtitle', canonicalPath: '/event-pokemon' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PokemonList :event-only="true" />
|
||||
</template>
|
||||
14
frontend/pages/event-pokemon/new.vue
Normal file
14
frontend/pages/event-pokemon/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import PokemonList from '../../src/views/PokemonList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'event-pokemon-new',
|
||||
requiredPermission: 'pokemon.create',
|
||||
editorModal: true,
|
||||
seo: { titleKey: 'pages.eventPokemon.newTitle', descriptionKey: 'pages.eventPokemon.editSubtitle', canonicalPath: '/event-pokemon', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PokemonList :event-only="true" />
|
||||
</template>
|
||||
12
frontend/pages/events.vue
Normal file
12
frontend/pages/events.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'events',
|
||||
seo: { titleKey: 'pages.comingSoon.sections.events.title', descriptionKey: 'pages.comingSoon.sections.events.subtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoonView page="events" />
|
||||
</template>
|
||||
12
frontend/pages/forgot-password.vue
Normal file
12
frontend/pages/forgot-password.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ForgotPasswordView from '../src/views/ForgotPasswordView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'forgot-password',
|
||||
seo: { titleKey: 'auth.requestResetTitle', descriptionKey: 'auth.requestResetSubtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ForgotPasswordView />
|
||||
</template>
|
||||
20
frontend/pages/habitats/[id]/edit.vue
Normal file
20
frontend/pages/habitats/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import HabitatDetail from '../../../src/views/HabitatDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'habitat-edit',
|
||||
requiredPermission: 'habitats.update',
|
||||
editorModal: true,
|
||||
seo: {
|
||||
titleKey: 'pages.habitats.detailKicker',
|
||||
descriptionKey: 'pages.habitats.editSubtitle',
|
||||
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/habitats/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HabitatDetail />
|
||||
</template>
|
||||
12
frontend/pages/habitats/[id]/index.vue
Normal file
12
frontend/pages/habitats/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import HabitatDetail from '../../../src/views/HabitatDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'habitat-detail',
|
||||
seo: { titleKey: 'pages.habitats.detailKicker', descriptionKey: 'pages.habitats.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HabitatDetail />
|
||||
</template>
|
||||
12
frontend/pages/habitats/index.vue
Normal file
12
frontend/pages/habitats/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import HabitatList from '../../src/views/HabitatList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'habitat-list',
|
||||
seo: { titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HabitatList :event-only="false" />
|
||||
</template>
|
||||
14
frontend/pages/habitats/new.vue
Normal file
14
frontend/pages/habitats/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import HabitatList from '../../src/views/HabitatList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'habitat-new',
|
||||
requiredPermission: 'habitats.create',
|
||||
editorModal: true,
|
||||
seo: { titleKey: 'pages.habitats.newTitle', descriptionKey: 'pages.habitats.editSubtitle', canonicalPath: '/habitats', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HabitatList :event-only="false" />
|
||||
</template>
|
||||
12
frontend/pages/index.vue
Normal file
12
frontend/pages/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import HomeView from '../src/views/HomeView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'home',
|
||||
seo: { titleKey: 'pages.home.title', descriptionKey: 'pages.home.subtitle', canonicalPath: '/' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HomeView />
|
||||
</template>
|
||||
20
frontend/pages/items/[id]/edit.vue
Normal file
20
frontend/pages/items/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'item-edit',
|
||||
requiredPermission: 'items.update',
|
||||
editorModal: true,
|
||||
seo: {
|
||||
titleKey: 'pages.items.editKicker',
|
||||
descriptionKey: 'pages.items.editSubtitle',
|
||||
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/items/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemDetail />
|
||||
</template>
|
||||
12
frontend/pages/items/[id]/index.vue
Normal file
12
frontend/pages/items/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'item-detail',
|
||||
seo: { titleKey: 'pages.items.detailKicker', descriptionKey: 'pages.items.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemDetail />
|
||||
</template>
|
||||
12
frontend/pages/items/index.vue
Normal file
12
frontend/pages/items/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ItemsList from '../../src/views/ItemsList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'item-list',
|
||||
seo: { titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemsList :event-only="false" />
|
||||
</template>
|
||||
14
frontend/pages/items/new.vue
Normal file
14
frontend/pages/items/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import ItemsList from '../../src/views/ItemsList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'item-new',
|
||||
requiredPermission: 'items.create',
|
||||
editorModal: true,
|
||||
seo: { titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/items', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ItemsList :event-only="false" />
|
||||
</template>
|
||||
12
frontend/pages/life/[id].vue
Normal file
12
frontend/pages/life/[id].vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import LifePostDetail from '../../src/views/LifePostDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'life-id',
|
||||
seo: { titleKey: 'pages.life.detailTitle', descriptionKey: 'pages.life.detailSubtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LifePostDetail />
|
||||
</template>
|
||||
12
frontend/pages/life/index.vue
Normal file
12
frontend/pages/life/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import LifeView from '../../src/views/LifeView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'life',
|
||||
seo: { titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LifeView />
|
||||
</template>
|
||||
12
frontend/pages/login.vue
Normal file
12
frontend/pages/login.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import LoginView from '../src/views/LoginView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'login',
|
||||
seo: { titleKey: 'auth.loginTitle', descriptionKey: 'auth.loginSubtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoginView />
|
||||
</template>
|
||||
20
frontend/pages/pokemon/[id]/edit.vue
Normal file
20
frontend/pages/pokemon/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import PokemonDetail from '../../../src/views/PokemonDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'pokemon-edit',
|
||||
requiredPermission: 'pokemon.update',
|
||||
editorModal: true,
|
||||
seo: {
|
||||
titleKey: 'pages.pokemon.editKicker',
|
||||
descriptionKey: 'pages.pokemon.editSubtitle',
|
||||
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/pokemon/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PokemonDetail />
|
||||
</template>
|
||||
12
frontend/pages/pokemon/[id]/index.vue
Normal file
12
frontend/pages/pokemon/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import PokemonDetail from '../../../src/views/PokemonDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'pokemon-detail',
|
||||
seo: { titleKey: 'pages.pokemon.detailKicker', descriptionKey: 'pages.pokemon.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PokemonDetail />
|
||||
</template>
|
||||
12
frontend/pages/pokemon/index.vue
Normal file
12
frontend/pages/pokemon/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import PokemonList from '../../src/views/PokemonList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'pokemon-list',
|
||||
seo: { titleKey: 'pages.pokemon.title', descriptionKey: 'pages.pokemon.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PokemonList :event-only="false" />
|
||||
</template>
|
||||
14
frontend/pages/pokemon/new.vue
Normal file
14
frontend/pages/pokemon/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import PokemonList from '../../src/views/PokemonList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'pokemon-new',
|
||||
requiredPermission: 'pokemon.create',
|
||||
editorModal: true,
|
||||
seo: { titleKey: 'pages.pokemon.newTitle', descriptionKey: 'pages.pokemon.editSubtitle', canonicalPath: '/pokemon', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PokemonList :event-only="false" />
|
||||
</template>
|
||||
12
frontend/pages/privacy-policy.vue
Normal file
12
frontend/pages/privacy-policy.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import LegalView from '../src/views/LegalView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'privacy-policy',
|
||||
seo: { titleKey: 'pages.legal.privacy.title', descriptionKey: 'pages.legal.privacy.subtitle', canonicalPath: '/privacy-policy' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LegalView page="privacy" />
|
||||
</template>
|
||||
12
frontend/pages/profile/[id].vue
Normal file
12
frontend/pages/profile/[id].vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import UserProfileView from '../../src/views/UserProfileView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'profile-id',
|
||||
seo: { titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.publicSubtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserProfileView />
|
||||
</template>
|
||||
13
frontend/pages/profile/index.vue
Normal file
13
frontend/pages/profile/index.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import UserProfileView from '../../src/views/UserProfileView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'profile',
|
||||
requiresAuth: true,
|
||||
seo: { titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.subtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserProfileView />
|
||||
</template>
|
||||
16
frontend/pages/project-updates.vue
Normal file
16
frontend/pages/project-updates.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import ProjectUpdatesView from '../src/views/ProjectUpdatesView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'project-updates',
|
||||
seo: {
|
||||
titleKey: 'pages.projectUpdates.title',
|
||||
descriptionKey: 'pages.projectUpdates.subtitle',
|
||||
canonicalPath: '/project-updates'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProjectUpdatesView />
|
||||
</template>
|
||||
20
frontend/pages/recipes/[id]/edit.vue
Normal file
20
frontend/pages/recipes/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import RecipeDetail from '../../../src/views/RecipeDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'recipe-edit',
|
||||
requiredPermission: 'recipes.update',
|
||||
editorModal: true,
|
||||
seo: {
|
||||
titleKey: 'pages.recipes.editKicker',
|
||||
descriptionKey: 'pages.recipes.editSubtitle',
|
||||
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/recipes/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RecipeDetail />
|
||||
</template>
|
||||
12
frontend/pages/recipes/[id]/index.vue
Normal file
12
frontend/pages/recipes/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import RecipeDetail from '../../../src/views/RecipeDetail.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'recipe-detail',
|
||||
seo: { titleKey: 'pages.recipes.detailKicker', descriptionKey: 'pages.recipes.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RecipeDetail />
|
||||
</template>
|
||||
12
frontend/pages/recipes/index.vue
Normal file
12
frontend/pages/recipes/index.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import RecipeList from '../../src/views/RecipeList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'recipe-list',
|
||||
seo: { titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RecipeList />
|
||||
</template>
|
||||
14
frontend/pages/recipes/new.vue
Normal file
14
frontend/pages/recipes/new.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import RecipeList from '../../src/views/RecipeList.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'recipe-new',
|
||||
requiredPermission: 'recipes.create',
|
||||
editorModal: true,
|
||||
seo: { titleKey: 'pages.recipes.newTitle', descriptionKey: 'pages.recipes.editSubtitle', canonicalPath: '/recipes', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RecipeList />
|
||||
</template>
|
||||
12
frontend/pages/register.vue
Normal file
12
frontend/pages/register.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import RegisterView from '../src/views/RegisterView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'register',
|
||||
seo: { titleKey: 'auth.registerTitle', descriptionKey: 'auth.registerSubtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RegisterView />
|
||||
</template>
|
||||
12
frontend/pages/reset-password.vue
Normal file
12
frontend/pages/reset-password.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import ResetPasswordView from '../src/views/ResetPasswordView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'reset-password',
|
||||
seo: { titleKey: 'auth.resetTitle', descriptionKey: 'auth.resetSubtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ResetPasswordView />
|
||||
</template>
|
||||
12
frontend/pages/terms-of-service.vue
Normal file
12
frontend/pages/terms-of-service.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import LegalView from '../src/views/LegalView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'terms-of-service',
|
||||
seo: { titleKey: 'pages.legal.terms.title', descriptionKey: 'pages.legal.terms.subtitle', canonicalPath: '/terms-of-service' }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LegalView page="terms" />
|
||||
</template>
|
||||
12
frontend/pages/verify-email.vue
Normal file
12
frontend/pages/verify-email.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import VerifyEmailView from '../src/views/VerifyEmailView.vue';
|
||||
|
||||
definePageMeta({
|
||||
name: 'verify-email',
|
||||
seo: { titleKey: 'auth.verifyTitle', descriptionKey: 'auth.verifySubtitle', noindex: true }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VerifyEmailView />
|
||||
</template>
|
||||
15
frontend/plugins/00-runtime-config.ts
Normal file
15
frontend/plugins/00-runtime-config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { setSystemWordingsApiBaseUrls } from '../src/i18n';
|
||||
import { setConfiguredSiteUrl } from '../src/seo';
|
||||
import { setApiBaseUrls } from '../src/services/api';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const config = useRuntimeConfig();
|
||||
const apiBaseUrls = {
|
||||
browser: config.public.apiBaseUrl,
|
||||
server: config.serverApiBaseUrl
|
||||
};
|
||||
|
||||
setApiBaseUrls(apiBaseUrls);
|
||||
setSystemWordingsApiBaseUrls(apiBaseUrls);
|
||||
setConfiguredSiteUrl(config.public.siteUrl);
|
||||
});
|
||||
15
frontend/plugins/01-i18n.ts
Normal file
15
frontend/plugins/01-i18n.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createPokopiaI18n, setActiveI18n } from '../src/i18n';
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const i18n = createPokopiaI18n();
|
||||
if (import.meta.client) {
|
||||
setActiveI18n(i18n);
|
||||
}
|
||||
|
||||
nuxtApp.vueApp.use(i18n);
|
||||
return {
|
||||
provide: {
|
||||
pokopiaI18n: i18n
|
||||
}
|
||||
};
|
||||
});
|
||||
32
frontend/plugins/02-seo.ts
Normal file
32
frontend/plugins/02-seo.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { onLocaleChange } from '../src/i18n';
|
||||
import { applyRouteSeo, onSeoChange, resolvedSeoHead, resolveRouteSeo, setSeoTranslator, type ResolvedSeoConfig } from '../src/seo';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const router = useRouter();
|
||||
const nuxtApp = useNuxtApp();
|
||||
const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
|
||||
const dynamicSeo = ref<ResolvedSeoConfig | null>(null);
|
||||
const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t));
|
||||
useHead(() => resolvedSeoHead(activeSeo.value));
|
||||
|
||||
if (import.meta.server) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSeoTranslator(t);
|
||||
onSeoChange((seo) => {
|
||||
dynamicSeo.value = seo;
|
||||
});
|
||||
onLocaleChange(() => {
|
||||
dynamicSeo.value = null;
|
||||
applyRouteSeo(router.currentRoute.value);
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
dynamicSeo.value = null;
|
||||
applyRouteSeo(to);
|
||||
});
|
||||
|
||||
applyRouteSeo(router.currentRoute.value);
|
||||
});
|
||||
76
frontend/plugins/03-detail-seo.server.ts
Normal file
76
frontend/plugins/03-detail-seo.server.ts
Normal 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;
|
||||
}
|
||||
7
frontend/server/routes/robots.txt.ts
Normal file
7
frontend/server/routes/robots.txt.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { normalizeSiteUrl, robotsTxt } from '../utils/seo-files';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig(event);
|
||||
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8');
|
||||
return robotsTxt(normalizeSiteUrl(config.public.siteUrl));
|
||||
});
|
||||
7
frontend/server/routes/sitemap.xml.ts
Normal file
7
frontend/server/routes/sitemap.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { normalizeSiteUrl, sitemapXml } from '../utils/seo-files';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig(event);
|
||||
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||
return sitemapXml(normalizeSiteUrl(config.public.siteUrl));
|
||||
});
|
||||
73
frontend/server/utils/seo-files.ts
Normal file
73
frontend/server/utils/seo-files.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||
|
||||
const sitemapPaths = [
|
||||
'/',
|
||||
'/pokemon',
|
||||
'/event-pokemon',
|
||||
'/habitats',
|
||||
'/event-habitats',
|
||||
'/items',
|
||||
'/event-items',
|
||||
'/ancient-artifacts',
|
||||
'/recipes',
|
||||
'/dish',
|
||||
'/checklist',
|
||||
'/life',
|
||||
'/project-updates',
|
||||
'/privacy-policy',
|
||||
'/terms-of-service',
|
||||
'/disclaimers'
|
||||
];
|
||||
|
||||
const robotsDisallowPaths = [
|
||||
'/admin',
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/reset-password',
|
||||
'/verify-email',
|
||||
'/pokemon/new',
|
||||
'/event-pokemon/new',
|
||||
'/pokemon/*/edit',
|
||||
'/habitats/new',
|
||||
'/event-habitats/new',
|
||||
'/habitats/*/edit',
|
||||
'/items/new',
|
||||
'/event-items/new',
|
||||
'/items/*/edit',
|
||||
'/ancient-artifacts/new',
|
||||
'/ancient-artifacts/*/edit',
|
||||
'/recipes/new',
|
||||
'/recipes/*/edit',
|
||||
'/automation',
|
||||
'/events',
|
||||
'/actions',
|
||||
'/dream-island',
|
||||
'/clothes'
|
||||
];
|
||||
|
||||
export function normalizeSiteUrl(value: unknown): string {
|
||||
return (typeof value === 'string' && value.trim() ? value.trim() : fallbackSiteUrl).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function robotsTxt(siteUrl: string): string {
|
||||
const disallowLines = robotsDisallowPaths.map((path) => `Disallow: ${path}`).join('\n');
|
||||
return `User-agent: *\nAllow: /\n${disallowLines}\nSitemap: ${siteUrl}/sitemap.xml\n`;
|
||||
}
|
||||
|
||||
export function sitemapXml(siteUrl: string): string {
|
||||
const urls = sitemapPaths
|
||||
.map(
|
||||
(path) => ` <url>
|
||||
<loc>${siteUrl}${path}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
</url>`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls}
|
||||
</urlset>
|
||||
`;
|
||||
}
|
||||
47
frontend/src/components/ConfirmDialog.vue
Normal file
47
frontend/src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import Modal from './Modal.vue';
|
||||
import { iconCancel, iconDelete } from '../icons';
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
closeLabel: string;
|
||||
busy?: boolean;
|
||||
}>(),
|
||||
{
|
||||
busy: false
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
cancel: [];
|
||||
confirm: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:title="title"
|
||||
:close-label="closeLabel"
|
||||
:close-on-backdrop="!busy"
|
||||
:close-on-escape="!busy"
|
||||
@close="emit('cancel')"
|
||||
>
|
||||
<p class="confirm-dialog__message">{{ message }}</p>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="link-button link-button--danger" :disabled="busy" @click="emit('confirm')">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
{{ confirmLabel }}
|
||||
</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="emit('cancel')">
|
||||
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||
{{ cancelLabel }}
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import EditMeta from './EditMeta.vue';
|
||||
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -169,11 +170,7 @@ function formatDateTime(value: string): string {
|
||||
<div>
|
||||
<dt>{{ t('history.lastEdited') }}</dt>
|
||||
<dd>
|
||||
<RouterLink v-if="props.entity.updatedBy" class="user-profile-link" :to="`/profile/${props.entity.updatedBy.id}`">
|
||||
{{ props.entity.updatedBy.displayName }}
|
||||
</RouterLink>
|
||||
<strong v-else>{{ displayName(props.entity.updatedBy) }}</strong>
|
||||
<time :datetime="props.entity.updatedAt">{{ formatDateTime(props.entity.updatedAt) }}</time>
|
||||
<EditMeta :entity="props.entity" :show-label="false" />
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { EditInfo } from '../services/api';
|
||||
|
||||
defineProps<{
|
||||
entity: EditInfo;
|
||||
}>();
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
entity: EditInfo;
|
||||
showLabel?: boolean;
|
||||
}>(),
|
||||
{
|
||||
showLabel: true
|
||||
}
|
||||
);
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
@@ -18,11 +24,11 @@ function formatDateTime(value: string): string {
|
||||
|
||||
<template>
|
||||
<p class="edit-meta">
|
||||
{{ t('history.lastEdited') }}:
|
||||
<template v-if="showLabel">{{ t('history.lastEdited') }}: </template>
|
||||
<RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`">
|
||||
{{ entity.updatedBy.displayName }}
|
||||
</RouterLink>
|
||||
<span v-else>{{ t('common.system') }}</span>
|
||||
/ {{ formatDateTime(entity.updatedAt) }}
|
||||
/ <time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ConfirmDialog from './ConfirmDialog.vue';
|
||||
import LoadMoreSentinel from './LoadMoreSentinel.vue';
|
||||
import StatusBadge from './StatusBadge.vue';
|
||||
import Tabs, { type TabOption } from './Tabs.vue';
|
||||
import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
moderationUpdateEvent,
|
||||
onAuthTokenChange,
|
||||
setAuthToken,
|
||||
onAuthChange,
|
||||
type AiModerationStatus,
|
||||
type AuthUser,
|
||||
type CommentSort,
|
||||
@@ -54,6 +53,8 @@ let removeAuthListener: (() => void) | null = null;
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMoreComments = ref(false);
|
||||
const commentTotal = ref(0);
|
||||
const pendingDeleteComment = ref<EntityDiscussionComment | null>(null);
|
||||
const deleteConfirmBusy = ref(false);
|
||||
|
||||
function can(permissionKey: string) {
|
||||
return currentUser.value?.permissions.includes(permissionKey) === true;
|
||||
@@ -77,18 +78,11 @@ const sortOptions = computed<Array<{ value: CommentSort; label: string }>>(() =>
|
||||
async function loadCurrentUser() {
|
||||
authReady.value = false;
|
||||
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
authReady.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.me();
|
||||
currentUser.value = response.user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
setAuthToken(null);
|
||||
} finally {
|
||||
authReady.value = true;
|
||||
}
|
||||
@@ -471,11 +465,34 @@ function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolea
|
||||
return false;
|
||||
}
|
||||
|
||||
async function deleteComment(comment: EntityDiscussionComment) {
|
||||
if (!window.confirm(t('discussion.deleteConfirm'))) {
|
||||
function requestDeleteComment(comment: EntityDiscussionComment) {
|
||||
pendingDeleteComment.value = comment;
|
||||
}
|
||||
|
||||
function closeDeleteConfirm() {
|
||||
if (deleteConfirmBusy.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingDeleteComment.value = null;
|
||||
}
|
||||
|
||||
async function confirmDeleteComment() {
|
||||
const comment = pendingDeleteComment.value;
|
||||
if (!comment) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteConfirmBusy.value = true;
|
||||
try {
|
||||
await deleteComment(comment);
|
||||
pendingDeleteComment.value = null;
|
||||
} finally {
|
||||
deleteConfirmBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(comment: EntityDiscussionComment) {
|
||||
const key = commentKey(comment.id);
|
||||
clearCommentError(key);
|
||||
|
||||
@@ -515,7 +532,7 @@ onMounted(() => {
|
||||
void loadCurrentUser();
|
||||
void loadLanguages();
|
||||
void loadDiscussion();
|
||||
removeAuthListener = onAuthTokenChange(() => {
|
||||
removeAuthListener = onAuthChange(() => {
|
||||
void loadCurrentUser();
|
||||
});
|
||||
});
|
||||
@@ -657,7 +674,7 @@ onUnmounted(() => {
|
||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||
type="button"
|
||||
:aria-label="t('discussion.deleteComment')"
|
||||
@click="deleteComment(comment)"
|
||||
@click="requestDeleteComment(comment)"
|
||||
>
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
|
||||
@@ -759,7 +776,7 @@ onUnmounted(() => {
|
||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||
type="button"
|
||||
:aria-label="t('discussion.deleteComment')"
|
||||
@click="deleteComment(reply)"
|
||||
@click="requestDeleteComment(reply)"
|
||||
>
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
|
||||
@@ -787,5 +804,17 @@ onUnmounted(() => {
|
||||
<p>{{ t('discussion.emptyHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-if="pendingDeleteComment"
|
||||
:title="t('discussion.deleteComment')"
|
||||
:message="t('discussion.deleteConfirm')"
|
||||
:confirm-label="t('common.delete')"
|
||||
:cancel-label="t('common.cancel')"
|
||||
:close-label="t('common.close')"
|
||||
:busy="deleteConfirmBusy"
|
||||
@cancel="closeDeleteConfirm"
|
||||
@confirm="confirmDeleteComment"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,7 @@ let openModalCount = 0;
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue';
|
||||
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, useId, watch } from 'vue';
|
||||
import { iconClose } from '../icons';
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -29,7 +29,7 @@ const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const titleId = `modal-title-${Math.random().toString(36).slice(2)}`;
|
||||
const titleId = useId();
|
||||
const dialog = ref<HTMLElement | null>(null);
|
||||
const modalBody = ref<HTMLElement | null>(null);
|
||||
const closeButton = ref<HTMLButtonElement | null>(null);
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
moderationUpdateEvent,
|
||||
notificationWebSocketUrl,
|
||||
type AuthUser,
|
||||
@@ -92,7 +91,7 @@ function disconnectNotifications() {
|
||||
|
||||
function scheduleReconnect() {
|
||||
clearReconnectTimer();
|
||||
if (stopped || !props.currentUser || !getAuthToken()) {
|
||||
if (stopped || !props.currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -118,7 +117,7 @@ function isNotificationWsMessage(value: unknown): value is NotificationWsMessage
|
||||
}
|
||||
|
||||
async function connectNotifications() {
|
||||
if (!props.currentUser || !getAuthToken() || typeof WebSocket === 'undefined') {
|
||||
if (!props.currentUser || typeof WebSocket === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
export type SwitchGroupOption = {
|
||||
value: string;
|
||||
value: string | number;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
id: string;
|
||||
label: string;
|
||||
modelValue: string[];
|
||||
modelValue: Array<string | number>;
|
||||
options: SwitchGroupOption[];
|
||||
layout?: 'inline' | 'grid';
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]];
|
||||
'update:modelValue': [value: Array<string | number>];
|
||||
}>();
|
||||
|
||||
function optionId(index: number) {
|
||||
return `${props.id}-${index}`;
|
||||
}
|
||||
|
||||
function isSelected(value: string) {
|
||||
function isSelected(value: string | number) {
|
||||
return props.modelValue.includes(value);
|
||||
}
|
||||
|
||||
function updateOption(value: string, event: Event) {
|
||||
function updateOption(value: string | number, event: Event) {
|
||||
if (!(event.target instanceof HTMLInputElement)) return;
|
||||
|
||||
const { checked } = event.target;
|
||||
@@ -43,14 +46,23 @@ function updateOption(value: string, event: Event) {
|
||||
<template>
|
||||
<fieldset class="switch-group">
|
||||
<legend>{{ label }}</legend>
|
||||
<div class="switch-group__options">
|
||||
<label v-for="(option, index) in options" :key="option.value" class="switch-control switch-control--stacked">
|
||||
<span class="switch-control__label">{{ option.label }}</span>
|
||||
<div class="switch-group__options" :class="{ 'switch-group__options--grid': layout === 'grid' }">
|
||||
<label
|
||||
v-for="(option, index) in options"
|
||||
:key="option.value"
|
||||
class="switch-control switch-control--stacked"
|
||||
:class="{ 'switch-control--disabled': option.disabled }"
|
||||
>
|
||||
<span class="switch-control__copy">
|
||||
<span class="switch-control__label">{{ option.label }}</span>
|
||||
<span v-if="option.description" class="switch-control__description">{{ option.description }}</span>
|
||||
</span>
|
||||
<input
|
||||
:id="optionId(index)"
|
||||
type="checkbox"
|
||||
:checked="isSelected(option.value)"
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
@change="updateOption(option.value, $event)"
|
||||
/>
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { createI18n } from 'vue-i18n';
|
||||
import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings';
|
||||
|
||||
export { defaultLocale } from '../../system-wordings';
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
|
||||
let browserApiBaseUrl = 'http://localhost:3001';
|
||||
let serverApiBaseUrl = 'http://localhost:3001';
|
||||
const localeStorageKey = 'pokopia_locale';
|
||||
const localeChangeEvent = 'pokopia-locale-change';
|
||||
|
||||
@@ -17,15 +18,52 @@ type SystemWordingsResponse = {
|
||||
|
||||
export type MessageKey = keyof typeof messages.en;
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
globalInjection: true,
|
||||
locale: readStoredLocale(),
|
||||
fallbackLocale: defaultLocale,
|
||||
messages
|
||||
});
|
||||
export function createPokopiaI18n(initialLocale = readStoredLocale()) {
|
||||
return createI18n({
|
||||
legacy: false,
|
||||
globalInjection: true,
|
||||
locale: initialLocale || defaultLocale,
|
||||
fallbackLocale: defaultLocale,
|
||||
messages
|
||||
});
|
||||
}
|
||||
|
||||
function readStoredLocale(): string {
|
||||
type PokopiaI18n = ReturnType<typeof createPokopiaI18n>;
|
||||
let activeI18n: PokopiaI18n | null = null;
|
||||
|
||||
export function setActiveI18n(instance: PokopiaI18n): void {
|
||||
activeI18n = instance;
|
||||
}
|
||||
|
||||
export function setSystemWordingsApiBaseUrl(value: unknown): void {
|
||||
setSystemWordingsApiBaseUrls({ browser: value, server: value });
|
||||
}
|
||||
|
||||
export function setSystemWordingsApiBaseUrls(value: { browser?: unknown; server?: unknown }): void {
|
||||
const browserBaseUrl = normalizeApiBaseUrl(value.browser);
|
||||
const serverBaseUrl = normalizeApiBaseUrl(value.server);
|
||||
|
||||
if (browserBaseUrl) {
|
||||
browserApiBaseUrl = browserBaseUrl;
|
||||
}
|
||||
if (serverBaseUrl) {
|
||||
serverApiBaseUrl = serverBaseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeApiBaseUrl(value: unknown): string | null {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function activeApiBaseUrl(): string {
|
||||
return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl;
|
||||
}
|
||||
|
||||
export function readStoredLocale(): string {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return defaultLocale;
|
||||
}
|
||||
@@ -35,11 +73,11 @@ function readStoredLocale(): string {
|
||||
}
|
||||
|
||||
function globalLocaleRef() {
|
||||
return i18n.global.locale as unknown as { value: string };
|
||||
return activeI18n?.global.locale as unknown as { value: string } | undefined;
|
||||
}
|
||||
|
||||
export function getCurrentLocale(): string {
|
||||
return globalLocaleRef().value || defaultLocale;
|
||||
return globalLocaleRef()?.value || defaultLocale;
|
||||
}
|
||||
|
||||
function isMessageTree(value: SystemWordingTree[string] | undefined): value is SystemWordingTree {
|
||||
@@ -68,6 +106,11 @@ function builtInMessagesFor(locale: string): SystemWordingTree {
|
||||
}
|
||||
|
||||
export async function loadSystemWordings(locale = getCurrentLocale(), force = false): Promise<void> {
|
||||
if (!activeI18n) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetI18n = activeI18n;
|
||||
const targetLocale = locale || defaultLocale;
|
||||
if (!force && loadedWordingLocales.has(targetLocale)) {
|
||||
return;
|
||||
@@ -81,19 +124,19 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa
|
||||
|
||||
const loadPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/system-wordings?locale=${encodeURIComponent(targetLocale)}`);
|
||||
const response = await fetch(`${activeApiBaseUrl()}/api/system-wordings?locale=${encodeURIComponent(targetLocale)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`System wordings failed (${response.status})`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as SystemWordingsResponse;
|
||||
i18n.global.setLocaleMessage(
|
||||
targetI18n.global.setLocaleMessage(
|
||||
targetLocale,
|
||||
mergeMessageTrees(messages[defaultLocale], messages[targetLocale], data.messages) as never
|
||||
);
|
||||
loadedWordingLocales.add(targetLocale);
|
||||
} catch {
|
||||
i18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never);
|
||||
targetI18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never);
|
||||
} finally {
|
||||
pendingWordingLoads.delete(targetLocale);
|
||||
}
|
||||
@@ -105,7 +148,10 @@ export async function loadSystemWordings(locale = getCurrentLocale(), force = fa
|
||||
|
||||
export function setCurrentLocale(locale: string): void {
|
||||
const nextLocale = locale || defaultLocale;
|
||||
globalLocaleRef().value = nextLocale;
|
||||
const localeRef = globalLocaleRef();
|
||||
if (localeRef) {
|
||||
localeRef.value = nextLocale;
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = nextLocale;
|
||||
@@ -121,8 +167,10 @@ export function setCurrentLocale(locale: string): void {
|
||||
}
|
||||
|
||||
export function onLocaleChange(callback: () => void): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
window.addEventListener(localeChangeEvent, callback);
|
||||
return () => window.removeEventListener(localeChangeEvent, callback);
|
||||
}
|
||||
|
||||
setCurrentLocale(getCurrentLocale());
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import { i18n } from './i18n';
|
||||
import { router } from './router';
|
||||
import { setupSeo } from './seo';
|
||||
import './styles/main.css';
|
||||
|
||||
setupSeo(router);
|
||||
createApp(App).use(i18n).use(router).mount('#app');
|
||||
@@ -1,388 +0,0 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import HomeView from '../views/HomeView.vue';
|
||||
import PokemonList from '../views/PokemonList.vue';
|
||||
import PokemonDetail from '../views/PokemonDetail.vue';
|
||||
import HabitatList from '../views/HabitatList.vue';
|
||||
import HabitatDetail from '../views/HabitatDetail.vue';
|
||||
import ItemsList from '../views/ItemsList.vue';
|
||||
import ItemDetail from '../views/ItemDetail.vue';
|
||||
import AncientArtifactList from '../views/AncientArtifactList.vue';
|
||||
import RecipeList from '../views/RecipeList.vue';
|
||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||
import LifePostDetail from '../views/LifePostDetail.vue';
|
||||
import LifeView from '../views/LifeView.vue';
|
||||
import DishView from '../views/DishView.vue';
|
||||
import ProjectUpdatesView from '../views/ProjectUpdatesView.vue';
|
||||
import LegalView from '../views/LegalView.vue';
|
||||
import ComingSoonView from '../views/ComingSoonView.vue';
|
||||
import AdminView from '../views/AdminView.vue';
|
||||
import ForgotPasswordView from '../views/ForgotPasswordView.vue';
|
||||
import LoginView from '../views/LoginView.vue';
|
||||
import UserProfileView from '../views/UserProfileView.vue';
|
||||
import RegisterView from '../views/RegisterView.vue';
|
||||
import ResetPasswordView from '../views/ResetPasswordView.vue';
|
||||
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
||||
import { api, getAuthToken, setAuthToken } from '../services/api';
|
||||
import type { RouteSeoConfig } from '../seo';
|
||||
|
||||
const seo = (config: RouteSeoConfig) => config;
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: HomeView, meta: { seo: seo({ titleKey: 'pages.home.title', descriptionKey: 'pages.home.subtitle', canonicalPath: '/' }) } },
|
||||
{
|
||||
path: '/pokemon',
|
||||
name: 'pokemon-list',
|
||||
component: PokemonList,
|
||||
props: { eventOnly: false },
|
||||
meta: { seo: seo({ titleKey: 'pages.pokemon.title', descriptionKey: 'pages.pokemon.subtitle' }) }
|
||||
},
|
||||
{
|
||||
path: '/pokemon/new',
|
||||
name: 'pokemon-new',
|
||||
component: PokemonList,
|
||||
props: { eventOnly: false },
|
||||
meta: {
|
||||
requiredPermission: 'pokemon.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.pokemon.newTitle', descriptionKey: 'pages.pokemon.editSubtitle', canonicalPath: '/pokemon', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/event-pokemon',
|
||||
name: 'event-pokemon-list',
|
||||
component: PokemonList,
|
||||
props: { eventOnly: true },
|
||||
meta: { seo: seo({ titleKey: 'pages.eventPokemon.title', descriptionKey: 'pages.eventPokemon.subtitle', canonicalPath: '/event-pokemon' }) }
|
||||
},
|
||||
{
|
||||
path: '/event-pokemon/new',
|
||||
name: 'event-pokemon-new',
|
||||
component: PokemonList,
|
||||
props: { eventOnly: true },
|
||||
meta: {
|
||||
requiredPermission: 'pokemon.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.eventPokemon.newTitle', descriptionKey: 'pages.eventPokemon.editSubtitle', canonicalPath: '/event-pokemon', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/pokemon/:id/edit',
|
||||
name: 'pokemon-edit',
|
||||
component: PokemonDetail,
|
||||
meta: {
|
||||
requiredPermission: 'pokemon.update',
|
||||
editorModal: true,
|
||||
seo: seo({
|
||||
titleKey: 'pages.pokemon.editKicker',
|
||||
descriptionKey: 'pages.pokemon.editSubtitle',
|
||||
canonicalPath: (route) => `/pokemon/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
})
|
||||
}
|
||||
},
|
||||
{ path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail, meta: { seo: seo({ titleKey: 'pages.pokemon.detailKicker', descriptionKey: 'pages.pokemon.subtitle' }) } },
|
||||
{
|
||||
path: '/habitats',
|
||||
name: 'habitat-list',
|
||||
component: HabitatList,
|
||||
props: { eventOnly: false },
|
||||
meta: { seo: seo({ titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }) }
|
||||
},
|
||||
{
|
||||
path: '/habitats/new',
|
||||
name: 'habitat-new',
|
||||
component: HabitatList,
|
||||
props: { eventOnly: false },
|
||||
meta: {
|
||||
requiredPermission: 'habitats.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.habitats.newTitle', descriptionKey: 'pages.habitats.editSubtitle', canonicalPath: '/habitats', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/event-habitats',
|
||||
name: 'event-habitat-list',
|
||||
component: HabitatList,
|
||||
props: { eventOnly: true },
|
||||
meta: { seo: seo({ titleKey: 'pages.eventHabitats.title', descriptionKey: 'pages.eventHabitats.subtitle', canonicalPath: '/event-habitats' }) }
|
||||
},
|
||||
{
|
||||
path: '/event-habitats/new',
|
||||
name: 'event-habitat-new',
|
||||
component: HabitatList,
|
||||
props: { eventOnly: true },
|
||||
meta: {
|
||||
requiredPermission: 'habitats.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.eventHabitats.newTitle', descriptionKey: 'pages.eventHabitats.editSubtitle', canonicalPath: '/event-habitats', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/habitats/:id/edit',
|
||||
name: 'habitat-edit',
|
||||
component: HabitatDetail,
|
||||
meta: {
|
||||
requiredPermission: 'habitats.update',
|
||||
editorModal: true,
|
||||
seo: seo({
|
||||
titleKey: 'pages.habitats.detailKicker',
|
||||
descriptionKey: 'pages.habitats.editSubtitle',
|
||||
canonicalPath: (route) => `/habitats/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
})
|
||||
}
|
||||
},
|
||||
{ path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail, meta: { seo: seo({ titleKey: 'pages.habitats.detailKicker', descriptionKey: 'pages.habitats.subtitle' }) } },
|
||||
{
|
||||
path: '/items',
|
||||
name: 'item-list',
|
||||
component: ItemsList,
|
||||
props: { eventOnly: false },
|
||||
meta: { seo: seo({ titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }) }
|
||||
},
|
||||
{
|
||||
path: '/items/new',
|
||||
name: 'item-new',
|
||||
component: ItemsList,
|
||||
props: { eventOnly: false },
|
||||
meta: {
|
||||
requiredPermission: 'items.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/items', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/event-items',
|
||||
name: 'event-item-list',
|
||||
component: ItemsList,
|
||||
props: { eventOnly: true },
|
||||
meta: { seo: seo({ titleKey: 'pages.eventItems.title', descriptionKey: 'pages.eventItems.subtitle', canonicalPath: '/event-items' }) }
|
||||
},
|
||||
{
|
||||
path: '/event-items/new',
|
||||
name: 'event-item-new',
|
||||
component: ItemsList,
|
||||
props: { eventOnly: true },
|
||||
meta: {
|
||||
requiredPermission: 'items.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.eventItems.newTitle', descriptionKey: 'pages.eventItems.editSubtitle', canonicalPath: '/event-items', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/items/:id/edit',
|
||||
name: 'item-edit',
|
||||
component: ItemDetail,
|
||||
meta: {
|
||||
requiredPermission: 'items.update',
|
||||
editorModal: true,
|
||||
seo: seo({
|
||||
titleKey: 'pages.items.editKicker',
|
||||
descriptionKey: 'pages.items.editSubtitle',
|
||||
canonicalPath: (route) => `/items/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
})
|
||||
}
|
||||
},
|
||||
{ path: '/items/:id', name: 'item-detail', component: ItemDetail, meta: { seo: seo({ titleKey: 'pages.items.detailKicker', descriptionKey: 'pages.items.subtitle' }) } },
|
||||
{
|
||||
path: '/ancient-artifacts',
|
||||
name: 'ancient-artifact-list',
|
||||
component: AncientArtifactList,
|
||||
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.title', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
|
||||
},
|
||||
{
|
||||
path: '/ancient-artifacts/new',
|
||||
name: 'ancient-artifact-new',
|
||||
component: AncientArtifactList,
|
||||
meta: {
|
||||
requiredPermission: 'items.create',
|
||||
editorModal: true,
|
||||
seo: seo({
|
||||
titleKey: 'pages.ancientArtifacts.newTitle',
|
||||
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
|
||||
canonicalPath: '/ancient-artifacts',
|
||||
noindex: true
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/ancient-artifacts/:id/edit',
|
||||
name: 'ancient-artifact-edit',
|
||||
component: ItemDetail,
|
||||
meta: {
|
||||
requiredPermission: 'items.update',
|
||||
editorModal: true,
|
||||
seo: seo({
|
||||
titleKey: 'pages.ancientArtifacts.editKicker',
|
||||
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
|
||||
canonicalPath: (route) => `/ancient-artifacts/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/ancient-artifacts/:id',
|
||||
name: 'ancient-artifact-detail',
|
||||
component: ItemDetail,
|
||||
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.detailKicker', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
|
||||
},
|
||||
{ path: '/recipes', name: 'recipe-list', component: RecipeList, meta: { seo: seo({ titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }) } },
|
||||
{
|
||||
path: '/recipes/new',
|
||||
name: 'recipe-new',
|
||||
component: RecipeList,
|
||||
meta: {
|
||||
requiredPermission: 'recipes.create',
|
||||
editorModal: true,
|
||||
seo: seo({ titleKey: 'pages.recipes.newTitle', descriptionKey: 'pages.recipes.editSubtitle', canonicalPath: '/recipes', noindex: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/recipes/:id/edit',
|
||||
name: 'recipe-edit',
|
||||
component: RecipeDetail,
|
||||
meta: {
|
||||
requiredPermission: 'recipes.update',
|
||||
editorModal: true,
|
||||
seo: seo({
|
||||
titleKey: 'pages.recipes.editKicker',
|
||||
descriptionKey: 'pages.recipes.editSubtitle',
|
||||
canonicalPath: (route) => `/recipes/${String(route.params.id)}`,
|
||||
noindex: true
|
||||
})
|
||||
}
|
||||
},
|
||||
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail, meta: { seo: seo({ titleKey: 'pages.recipes.detailKicker', descriptionKey: 'pages.recipes.subtitle' }) } },
|
||||
{
|
||||
path: '/automation',
|
||||
name: 'automation',
|
||||
component: ComingSoonView,
|
||||
props: { page: 'automation' },
|
||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.automation.title', descriptionKey: 'pages.comingSoon.sections.automation.subtitle', noindex: true }) }
|
||||
},
|
||||
{
|
||||
path: '/dish',
|
||||
name: 'dish',
|
||||
component: DishView,
|
||||
meta: { seo: seo({ titleKey: 'pages.dish.title', descriptionKey: 'pages.dish.subtitle' }) }
|
||||
},
|
||||
{
|
||||
path: '/events',
|
||||
name: 'events',
|
||||
component: ComingSoonView,
|
||||
props: { page: 'events' },
|
||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.events.title', descriptionKey: 'pages.comingSoon.sections.events.subtitle', noindex: true }) }
|
||||
},
|
||||
{
|
||||
path: '/actions',
|
||||
name: 'actions',
|
||||
component: ComingSoonView,
|
||||
props: { page: 'actions' },
|
||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.actions.title', descriptionKey: 'pages.comingSoon.sections.actions.subtitle', noindex: true }) }
|
||||
},
|
||||
{
|
||||
path: '/dream-island',
|
||||
name: 'dream-island',
|
||||
component: ComingSoonView,
|
||||
props: { page: 'dreamIsland' },
|
||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.dreamIsland.title', descriptionKey: 'pages.comingSoon.sections.dreamIsland.subtitle', noindex: true }) }
|
||||
},
|
||||
{
|
||||
path: '/clothes',
|
||||
name: 'clothes',
|
||||
component: ComingSoonView,
|
||||
props: { page: 'clothes' },
|
||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.clothes.title', descriptionKey: 'pages.comingSoon.sections.clothes.subtitle', noindex: true }) }
|
||||
},
|
||||
{ path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } },
|
||||
{ path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } },
|
||||
{ path: '/life/:id', component: LifePostDetail, meta: { seo: seo({ titleKey: 'pages.life.detailTitle', descriptionKey: 'pages.life.detailSubtitle' }) } },
|
||||
{
|
||||
path: '/project-updates',
|
||||
component: ProjectUpdatesView,
|
||||
meta: {
|
||||
seo: seo({
|
||||
titleKey: 'pages.projectUpdates.title',
|
||||
descriptionKey: 'pages.projectUpdates.subtitle',
|
||||
canonicalPath: '/project-updates'
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/privacy-policy',
|
||||
component: LegalView,
|
||||
props: { page: 'privacy' },
|
||||
meta: { seo: seo({ titleKey: 'pages.legal.privacy.title', descriptionKey: 'pages.legal.privacy.subtitle', canonicalPath: '/privacy-policy' }) }
|
||||
},
|
||||
{
|
||||
path: '/terms-of-service',
|
||||
component: LegalView,
|
||||
props: { page: 'terms' },
|
||||
meta: { seo: seo({ titleKey: 'pages.legal.terms.title', descriptionKey: 'pages.legal.terms.subtitle', canonicalPath: '/terms-of-service' }) }
|
||||
},
|
||||
{
|
||||
path: '/disclaimers',
|
||||
component: LegalView,
|
||||
props: { page: 'disclaimers' },
|
||||
meta: { seo: seo({ titleKey: 'pages.legal.disclaimers.title', descriptionKey: 'pages.legal.disclaimers.subtitle', canonicalPath: '/disclaimers' }) }
|
||||
},
|
||||
{ path: '/admin', component: AdminView, meta: { requiredPermission: 'admin.access', seo: seo({ titleKey: 'pages.admin.title', descriptionKey: 'pages.admin.subtitle', noindex: true }) } },
|
||||
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true, seo: seo({ titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.subtitle', noindex: true }) } },
|
||||
{ path: '/profile/:id', component: UserProfileView, meta: { seo: seo({ titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.publicSubtitle' }) } },
|
||||
{ path: '/login', component: LoginView, meta: { seo: seo({ titleKey: 'auth.loginTitle', descriptionKey: 'auth.loginSubtitle', noindex: true }) } },
|
||||
{ path: '/forgot-password', component: ForgotPasswordView, meta: { seo: seo({ titleKey: 'auth.requestResetTitle', descriptionKey: 'auth.requestResetSubtitle', noindex: true }) } },
|
||||
{ path: '/reset-password', component: ResetPasswordView, meta: { seo: seo({ titleKey: 'auth.resetTitle', descriptionKey: 'auth.resetSubtitle', noindex: true }) } },
|
||||
{ path: '/register', component: RegisterView, meta: { seo: seo({ titleKey: 'auth.registerTitle', descriptionKey: 'auth.registerSubtitle', noindex: true }) } },
|
||||
{ path: '/verify-email', component: VerifyEmailView, meta: { seo: seo({ titleKey: 'auth.verifyTitle', descriptionKey: 'auth.verifySubtitle', noindex: true }) } }
|
||||
],
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) return savedPosition;
|
||||
if (to.meta.editorModal === true || from.meta.editorModal === true) return false;
|
||||
return { top: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const requiredPermissions = to.matched
|
||||
.map((record) => record.meta.requiredPermission)
|
||||
.filter((permission): permission is string => typeof permission === 'string');
|
||||
const requiredAnyPermissions = to.matched.flatMap((record) =>
|
||||
Array.isArray(record.meta.requiredAnyPermission)
|
||||
? record.meta.requiredAnyPermission.filter((permission): permission is string => typeof permission === 'string')
|
||||
: []
|
||||
);
|
||||
const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true) || requiredPermissions.length > 0 || requiredAnyPermissions.length > 0;
|
||||
const requiresAuth = requiresVerified || to.matched.some((record) => record.meta.requiresAuth === true);
|
||||
|
||||
if (!requiresAuth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!getAuthToken()) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.me();
|
||||
if (requiresVerified && !response.user.emailVerified) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
|
||||
const permissionSet = new Set(response.user.permissions);
|
||||
if (requiredPermissions.some((permission) => !permissionSet.has(permission))) {
|
||||
return { path: '/pokemon' };
|
||||
}
|
||||
if (requiredAnyPermissions.length && !requiredAnyPermissions.some((permission) => permissionSet.has(permission))) {
|
||||
return { path: '/pokemon' };
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
setAuthToken(null);
|
||||
return { path: '/login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
});
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router';
|
||||
import { getCurrentLocale, i18n, onLocaleChange } from './i18n';
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
import { getCurrentLocale } from './i18n';
|
||||
import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings';
|
||||
|
||||
const siteName = 'Pokopia Wiki';
|
||||
const defaultCanonicalPath = '/';
|
||||
const defaultImagePath = '/seo/pokopia-hero.jpg';
|
||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||
let runtimeSiteUrl: string | null = null;
|
||||
|
||||
type TranslationValues = Record<string, string | number>;
|
||||
type Translator = (key: string, values?: TranslationValues) => string;
|
||||
|
||||
export type RouteSeoConfig = {
|
||||
title?: string;
|
||||
@@ -26,12 +29,34 @@ export type SeoConfig = {
|
||||
noindex?: boolean;
|
||||
};
|
||||
|
||||
const translate = i18n.global.t as (key: string, values?: TranslationValues) => string;
|
||||
export type ResolvedSeoConfig = {
|
||||
title: string;
|
||||
description: string;
|
||||
canonicalUrl: string;
|
||||
imageUrl: string;
|
||||
robots: string;
|
||||
locale: string;
|
||||
structuredData: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const messages = systemWordingMessages as unknown as Record<string, SystemWordingTree>;
|
||||
let activeTranslator: Translator | null = null;
|
||||
let currentSeo: ResolvedSeoConfig | null = null;
|
||||
const seoListeners = new Set<(seo: ResolvedSeoConfig) => void>();
|
||||
|
||||
export function setSeoTranslator(translator: Translator): void {
|
||||
activeTranslator = translator;
|
||||
}
|
||||
|
||||
export function setConfiguredSiteUrl(value: unknown): void {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
runtimeSiteUrl = normalizeSiteUrl(value);
|
||||
}
|
||||
}
|
||||
|
||||
function configuredSiteUrl(): string {
|
||||
const fromEnv = import.meta.env.VITE_SITE_URL;
|
||||
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') {
|
||||
return normalizeSiteUrl(fromEnv);
|
||||
if (runtimeSiteUrl) {
|
||||
return runtimeSiteUrl;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.location.origin) {
|
||||
@@ -68,115 +93,129 @@ function metaTitle(title?: string): string {
|
||||
}
|
||||
|
||||
function metaDescription(description?: string): string {
|
||||
return description?.trim() || translate('seo.siteDescription');
|
||||
return description?.trim() || translateSeo('seo.siteDescription');
|
||||
}
|
||||
|
||||
function localeForOpenGraph(locale: string): string {
|
||||
if (locale === 'en') {
|
||||
return 'en_US';
|
||||
function builtInTranslate(key: string, values: TranslationValues = {}): string {
|
||||
let message: SystemWordingTree[string] | undefined = messages[defaultLocale];
|
||||
for (const part of key.split('.')) {
|
||||
message = typeof message === 'object' && message !== null ? message[part] : undefined;
|
||||
}
|
||||
|
||||
return locale.replace('-', '_');
|
||||
if (typeof message !== 'string') {
|
||||
return key;
|
||||
}
|
||||
|
||||
return Object.entries(values).reduce((nextMessage, [name, value]) => nextMessage.replaceAll(`{${name}}`, String(value)), message);
|
||||
}
|
||||
|
||||
function setMeta(attribute: 'name' | 'property', key: string, content: string): void {
|
||||
let element = document.head.querySelector<HTMLMetaElement>(`meta[${attribute}="${key}"]`);
|
||||
if (!element) {
|
||||
element = document.createElement('meta');
|
||||
element.setAttribute(attribute, key);
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
element.setAttribute('content', content);
|
||||
function translateSeo(key: string, values?: TranslationValues, translator = activeTranslator): string {
|
||||
return translator ? translator(key, values) : builtInTranslate(key, values);
|
||||
}
|
||||
|
||||
function setCanonical(href: string): void {
|
||||
let element = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
|
||||
if (!element) {
|
||||
element = document.createElement('link');
|
||||
element.setAttribute('rel', 'canonical');
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
element.setAttribute('href', href);
|
||||
}
|
||||
|
||||
function setStructuredData(title: string, description: string, canonicalUrl: string): void {
|
||||
let element = document.getElementById('pokopia-structured-data') as HTMLScriptElement | null;
|
||||
if (!element) {
|
||||
element = document.createElement('script');
|
||||
element.id = 'pokopia-structured-data';
|
||||
element.type = 'application/ld+json';
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
|
||||
element.textContent = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: title,
|
||||
description,
|
||||
url: canonicalUrl,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: siteName,
|
||||
url: absoluteUrl('/')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function applySeo(config: SeoConfig = {}): void {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
export function resolveSeo(config: SeoConfig = {}): ResolvedSeoConfig {
|
||||
const title = metaTitle(config.title);
|
||||
const description = metaDescription(config.description);
|
||||
const canonicalUrl = absoluteUrl(normalizePath(config.canonicalPath));
|
||||
const imageUrl = absoluteUrl(config.image?.trim() || defaultImagePath);
|
||||
const noindex = config.noindex === true;
|
||||
const robots = noindex ? 'noindex, nofollow' : 'index, follow';
|
||||
const robots = config.noindex === true ? 'noindex, nofollow' : 'index, follow';
|
||||
const locale = getCurrentLocale();
|
||||
|
||||
document.title = title;
|
||||
setMeta('name', 'description', description);
|
||||
setMeta('name', 'robots', robots);
|
||||
setMeta('name', 'twitter:card', 'summary_large_image');
|
||||
setMeta('name', 'twitter:title', title);
|
||||
setMeta('name', 'twitter:description', description);
|
||||
setMeta('name', 'twitter:image', imageUrl);
|
||||
setMeta('property', 'og:site_name', siteName);
|
||||
setMeta('property', 'og:type', 'website');
|
||||
setMeta('property', 'og:title', title);
|
||||
setMeta('property', 'og:description', description);
|
||||
setMeta('property', 'og:url', canonicalUrl);
|
||||
setMeta('property', 'og:image', imageUrl);
|
||||
setMeta('property', 'og:locale', localeForOpenGraph(locale));
|
||||
setCanonical(canonicalUrl);
|
||||
setStructuredData(title, description, canonicalUrl);
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
canonicalUrl,
|
||||
imageUrl,
|
||||
robots,
|
||||
locale,
|
||||
structuredData: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: title,
|
||||
description,
|
||||
url: canonicalUrl,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: siteName,
|
||||
url: absoluteUrl('/')
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void {
|
||||
export function resolvedSeoHead(seo: ResolvedSeoConfig) {
|
||||
return {
|
||||
title: seo.title,
|
||||
htmlAttrs: {
|
||||
lang: seo.locale
|
||||
},
|
||||
meta: [
|
||||
{ key: 'description', name: 'description', content: seo.description },
|
||||
{ key: 'robots', name: 'robots', content: seo.robots },
|
||||
{ key: 'twitter-card', name: 'twitter:card', content: 'summary_large_image' },
|
||||
{ key: 'twitter-title', name: 'twitter:title', content: seo.title },
|
||||
{ key: 'twitter-description', name: 'twitter:description', content: seo.description },
|
||||
{ key: 'twitter-image', name: 'twitter:image', content: seo.imageUrl },
|
||||
{ key: 'og-site-name', property: 'og:site_name', content: siteName },
|
||||
{ key: 'og-type', property: 'og:type', content: 'website' },
|
||||
{ key: 'og-title', property: 'og:title', content: seo.title },
|
||||
{ key: 'og-description', property: 'og:description', content: seo.description },
|
||||
{ key: 'og-url', property: 'og:url', content: seo.canonicalUrl },
|
||||
{ key: 'og-image', property: 'og:image', content: seo.imageUrl },
|
||||
{ key: 'og-locale', property: 'og:locale', content: seo.locale === 'en' ? 'en_US' : seo.locale.replace('-', '_') }
|
||||
],
|
||||
link: [{ key: 'canonical', rel: 'canonical', href: seo.canonicalUrl }],
|
||||
script: [
|
||||
{
|
||||
key: 'pokopia-structured-data',
|
||||
id: 'pokopia-structured-data',
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify(seo.structuredData).replace(/</g, '\\u003C')
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function routeSeoConfig(route: RouteLocationNormalizedLoaded, translator?: Translator): SeoConfig {
|
||||
const routeSeo = route.meta.seo as RouteSeoConfig | undefined;
|
||||
const canonicalPath =
|
||||
typeof routeSeo?.canonicalPath === 'function'
|
||||
? routeSeo.canonicalPath(route)
|
||||
: routeSeo?.canonicalPath ?? route.path ?? defaultCanonicalPath;
|
||||
const requiresPrivateAccess = route.matched.some(
|
||||
(record) =>
|
||||
record.meta.requiresAuth === true ||
|
||||
record.meta.requiresVerified === true ||
|
||||
typeof record.meta.requiredPermission === 'string' ||
|
||||
(Array.isArray(record.meta.requiredAnyPermission) && record.meta.requiredAnyPermission.length > 0)
|
||||
);
|
||||
|
||||
applySeo({
|
||||
title: routeSeo?.titleKey ? translate(routeSeo.titleKey) : routeSeo?.title,
|
||||
description: routeSeo?.descriptionKey ? translate(routeSeo.descriptionKey) : routeSeo?.description,
|
||||
return {
|
||||
title: routeSeo?.titleKey ? translateSeo(routeSeo.titleKey, undefined, translator) : routeSeo?.title,
|
||||
description: routeSeo?.descriptionKey ? translateSeo(routeSeo.descriptionKey, undefined, translator) : routeSeo?.description,
|
||||
canonicalPath,
|
||||
image: routeSeo?.image,
|
||||
noindex: routeSeo?.noindex
|
||||
});
|
||||
noindex: routeSeo?.noindex === true || requiresPrivateAccess
|
||||
};
|
||||
}
|
||||
|
||||
export function setupSeo(router: Router): void {
|
||||
router.afterEach((to) => {
|
||||
applyRouteSeo(to);
|
||||
});
|
||||
export function resolveRouteSeo(route: RouteLocationNormalizedLoaded, translator?: Translator): ResolvedSeoConfig {
|
||||
return resolveSeo(routeSeoConfig(route, translator));
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
onLocaleChange(() => {
|
||||
applyRouteSeo(router.currentRoute.value);
|
||||
});
|
||||
export function onSeoChange(callback: (seo: ResolvedSeoConfig) => void): () => void {
|
||||
seoListeners.add(callback);
|
||||
callback(currentSeo ?? resolveSeo());
|
||||
return () => seoListeners.delete(callback);
|
||||
}
|
||||
|
||||
export function applySeo(config: SeoConfig = {}): void {
|
||||
currentSeo = resolveSeo(config);
|
||||
for (const listener of seoListeners) {
|
||||
listener(currentSeo);
|
||||
}
|
||||
}
|
||||
|
||||
export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void {
|
||||
applySeo(routeSeoConfig(route));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { getCurrentLocale } from '../i18n';
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
|
||||
const authTokenKey = 'pokopia_auth_token';
|
||||
let browserApiBaseUrl = 'http://localhost:3001';
|
||||
let serverApiBaseUrl = 'http://localhost:3001';
|
||||
const authChangeEvent = 'pokopia-auth-change';
|
||||
|
||||
export interface ApiRequestOptions {
|
||||
signal?: AbortSignal;
|
||||
headers?: HeadersInit;
|
||||
}
|
||||
|
||||
export type TranslationField = 'name' | 'title' | 'details' | 'genus' | 'effect' | 'mosslaxEffect';
|
||||
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
|
||||
|
||||
@@ -15,6 +20,38 @@ export interface Language {
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export function setApiBaseUrl(value: unknown): void {
|
||||
setApiBaseUrls({ browser: value, server: value });
|
||||
}
|
||||
|
||||
export function setApiBaseUrls(value: { browser?: unknown; server?: unknown }): void {
|
||||
const browserBaseUrl = normalizeApiBaseUrl(value.browser);
|
||||
const serverBaseUrl = normalizeApiBaseUrl(value.server);
|
||||
|
||||
if (browserBaseUrl) {
|
||||
browserApiBaseUrl = browserBaseUrl;
|
||||
}
|
||||
if (serverBaseUrl) {
|
||||
serverApiBaseUrl = serverBaseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeApiBaseUrl(value: unknown): string | null {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function activeApiBaseUrl(): string {
|
||||
return typeof window === 'undefined' ? serverApiBaseUrl : browserApiBaseUrl;
|
||||
}
|
||||
|
||||
function apiUrl(path: string): string {
|
||||
return `${activeApiBaseUrl()}${path}`;
|
||||
}
|
||||
|
||||
export type SystemWordingSurface = 'frontend' | 'backend' | 'email';
|
||||
|
||||
export interface SystemWording {
|
||||
@@ -769,7 +806,6 @@ export interface RegisterPayload extends LoginPayload {
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
@@ -1023,40 +1059,11 @@ export function buildQuery(params: Record<string, string | number | boolean | nu
|
||||
return query ? `?${query}` : '';
|
||||
}
|
||||
|
||||
function authStorage(type: 'local' | 'session'): Storage | null {
|
||||
export function onAuthChange(callback: () => void): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
return type === 'local' ? window.localStorage : window.sessionStorage;
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
const sessionToken = authStorage('session')?.getItem(authTokenKey);
|
||||
return sessionToken ?? authStorage('local')?.getItem(authTokenKey) ?? null;
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string | null, options: { persistent?: boolean } = {}): void {
|
||||
const local = authStorage('local');
|
||||
const session = authStorage('session');
|
||||
|
||||
if (token) {
|
||||
if (options.persistent === false) {
|
||||
session?.setItem(authTokenKey, token);
|
||||
local?.removeItem(authTokenKey);
|
||||
} else {
|
||||
local?.setItem(authTokenKey, token);
|
||||
session?.removeItem(authTokenKey);
|
||||
}
|
||||
} else {
|
||||
local?.removeItem(authTokenKey);
|
||||
session?.removeItem(authTokenKey);
|
||||
}
|
||||
|
||||
notifyAuthChange();
|
||||
}
|
||||
|
||||
export function onAuthTokenChange(callback: () => void): () => void {
|
||||
window.addEventListener(authChangeEvent, callback);
|
||||
return () => window.removeEventListener(authChangeEvent, callback);
|
||||
}
|
||||
@@ -1067,16 +1074,14 @@ export function notifyAuthChange(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function requestHeaders(): HeadersInit {
|
||||
const token = getAuthToken();
|
||||
return {
|
||||
'X-Locale': getCurrentLocale(),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
};
|
||||
function requestHeaders(extraHeaders?: HeadersInit): Headers {
|
||||
const headers = new Headers(extraHeaders);
|
||||
headers.set('X-Locale', headers.get('X-Locale') ?? getCurrentLocale());
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function notificationWebSocketUrl(ticket: string): string {
|
||||
const base = new URL(apiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
|
||||
const base = new URL(browserApiBaseUrl, typeof window === 'undefined' ? 'http://localhost' : window.location.origin);
|
||||
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
base.pathname = '/api/notifications/ws';
|
||||
base.search = '';
|
||||
@@ -1097,10 +1102,24 @@ async function getErrorMessage(response: Response): Promise<string> {
|
||||
return `Request failed (${response.status})`;
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
headers: requestHeaders(),
|
||||
signal
|
||||
function normalizeRequestOptions(options?: AbortSignal | ApiRequestOptions): ApiRequestOptions {
|
||||
if (!options) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if ('aborted' in options && 'addEventListener' in options) {
|
||||
return { signal: options };
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string, options?: AbortSignal | ApiRequestOptions): Promise<T> {
|
||||
const requestOptions = normalizeRequestOptions(options);
|
||||
const response = await fetch(apiUrl(path), {
|
||||
credentials: 'include',
|
||||
headers: requestHeaders(requestOptions.headers),
|
||||
signal: requestOptions.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -1111,12 +1130,13 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
}
|
||||
|
||||
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
const headers = requestHeaders();
|
||||
headers.set('Content-Type', 'application/json');
|
||||
|
||||
const response = await fetch(apiUrl(path), {
|
||||
credentials: 'include',
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...requestHeaders()
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
@@ -1128,7 +1148,8 @@ async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body:
|
||||
}
|
||||
|
||||
async function sendFormData<T>(path: string, body: FormData): Promise<T> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: requestHeaders(),
|
||||
body
|
||||
@@ -1142,7 +1163,8 @@ async function sendFormData<T>(path: string, body: FormData): Promise<T> {
|
||||
}
|
||||
|
||||
async function postEmpty(path: string): Promise<void> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: requestHeaders()
|
||||
});
|
||||
@@ -1153,7 +1175,8 @@ async function postEmpty(path: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function deleteJson(path: string): Promise<void> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
credentials: 'include',
|
||||
method: 'DELETE',
|
||||
headers: requestHeaders()
|
||||
});
|
||||
@@ -1164,7 +1187,8 @@ async function deleteJson(path: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function deleteAndGetJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
const response = await fetch(apiUrl(path), {
|
||||
credentials: 'include',
|
||||
method: 'DELETE',
|
||||
headers: requestHeaders()
|
||||
});
|
||||
@@ -1218,7 +1242,7 @@ export const api = {
|
||||
sendJson<{ message: string }>('/api/auth/request-password-reset', 'POST', payload),
|
||||
resetPassword: (payload: { token: string; password: string }) =>
|
||||
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
|
||||
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
||||
me: (options?: ApiRequestOptions) => getJson<{ user: AuthUser }>('/api/auth/me', options),
|
||||
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
|
||||
changePassword: (payload: ChangePasswordPayload) =>
|
||||
sendJson<{ message: string }>('/api/auth/me/password', 'PATCH', payload),
|
||||
@@ -1436,7 +1460,6 @@ export const api = {
|
||||
updatePokemon: (id: string | number, payload: PokemonPayload) =>
|
||||
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
|
||||
deletePokemon: (id: string | number) => deleteJson(`/api/pokemon/${id}`),
|
||||
reorderPokemon: (ids: number[]) => sendJson<Pokemon[]>('/api/admin/pokemon/order', 'PUT', { ids }),
|
||||
habitats: (params: Record<string, string | number | boolean | undefined> = {}) =>
|
||||
getJson<Habitat[]>(`/api/habitats${buildQuery(params)}`),
|
||||
habitatsPage: (params: PublicListQueryParams = {}) =>
|
||||
|
||||
@@ -1318,6 +1318,11 @@ svg {
|
||||
--btn-fg: #ffffff;
|
||||
}
|
||||
|
||||
.link-button--danger {
|
||||
--btn-bg: var(--danger);
|
||||
--btn-fg: #ffffff;
|
||||
}
|
||||
|
||||
.ui-button--ghost,
|
||||
.plain-button,
|
||||
.row-actions button,
|
||||
@@ -2762,6 +2767,14 @@ button:disabled,
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.confirm-dialog__message {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 15px;
|
||||
font-weight: 750;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.checklist-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -5130,6 +5143,14 @@ button:disabled,
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.trading-detail-list {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.trading-manager {
|
||||
min-height: 640px;
|
||||
display: grid;
|
||||
@@ -5219,6 +5240,11 @@ button:disabled,
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.trading-pick-row--active {
|
||||
border-color: var(--pokemon-blue);
|
||||
box-shadow: 0 0 0 2px rgba(42, 117, 187, .16);
|
||||
}
|
||||
|
||||
.trading-pick-row__copy,
|
||||
.trading-selected-list__copy {
|
||||
min-width: 0;
|
||||
@@ -5307,6 +5333,10 @@ button:disabled,
|
||||
max-height: 240px;
|
||||
}
|
||||
|
||||
.trading-detail-list {
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.trading-preference-toggle,
|
||||
.trading-selected-list .plain-button--icon {
|
||||
grid-column: 2;
|
||||
@@ -7534,6 +7564,12 @@ button:disabled,
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch-group__options--grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.switch-control {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -7553,7 +7589,29 @@ button:disabled,
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.switch-group__options--grid .switch-control--stacked {
|
||||
min-height: 52px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
justify-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.switch-control--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.switch-control__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.switch-control__label {
|
||||
display: block;
|
||||
color: var(--ink-soft);
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
@@ -7561,6 +7619,21 @@ button:disabled,
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.switch-control__description {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.switch-group__options--grid .switch-control__label,
|
||||
.switch-group__options--grid .switch-control__description {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.switch-control input {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
|
||||
@@ -7,6 +7,8 @@ import PageHeader from '../components/PageHeader.vue';
|
||||
import ReorderableList from '../components/ReorderableList.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import SwitchGroup, { type SwitchGroupOption } from '../components/SwitchGroup.vue';
|
||||
import TagsSelect, { type TagsSelectOption } from '../components/TagsSelect.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import TranslationFields from '../components/TranslationFields.vue';
|
||||
import {
|
||||
@@ -154,7 +156,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
|
||||
label: t('pages.admin.contentGroup'),
|
||||
items: [
|
||||
{ key: 'checklist', label: t('pages.admin.checklist'), permission: ['checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order'] },
|
||||
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.delete'] },
|
||||
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: 'pokemon.delete' },
|
||||
{ key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
|
||||
{
|
||||
key: 'ancientArtifacts',
|
||||
@@ -369,6 +371,31 @@ const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDis
|
||||
const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes));
|
||||
const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null);
|
||||
const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2);
|
||||
const dishItemSelectOptions = computed<TagsSelectOption[]>(() => dishItemRows.value.map((item) => ({ id: item.id, name: item.name })));
|
||||
const optionalDishItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...dishItemSelectOptions.value]);
|
||||
const dishCategorySelectOptions = computed<TagsSelectOption[]>(() =>
|
||||
dishCategoryRows.value.map((category) => ({ id: category.id, name: category.name }))
|
||||
);
|
||||
const dishFlavorSelectOptions = computed<TagsSelectOption[]>(() => dishFlavorRows.value.map((flavor) => ({ id: flavor.id, name: flavor.name })));
|
||||
const optionalDishSkillSelectOptions = computed<TagsSelectOption[]>(() => [
|
||||
{ id: '', name: t('common.none') },
|
||||
...dishSkillRows.value.map((skill) => ({ id: skill.id, name: skill.name }))
|
||||
]);
|
||||
const dishCategoryFormValid = computed(
|
||||
() =>
|
||||
dishCategoryForm.value.name.trim() !== '' &&
|
||||
dishCategoryForm.value.effect.trim() !== '' &&
|
||||
dishCategoryForm.value.cookwareItemId !== '' &&
|
||||
dishCategoryForm.value.mainMaterialItemId !== '' &&
|
||||
Number(dishCategoryForm.value.totalMaterialQuantity) >= 2
|
||||
);
|
||||
const dishFormValid = computed(
|
||||
() =>
|
||||
dishForm.value.categoryId !== '' &&
|
||||
dishForm.value.itemId !== '' &&
|
||||
dishForm.value.flavorId !== '' &&
|
||||
dishForm.value.mosslaxEffect.trim() !== ''
|
||||
);
|
||||
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
|
||||
const wordingModalTitle = computed(() => t('pages.admin.editWording'));
|
||||
const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole')));
|
||||
@@ -386,6 +413,26 @@ const permissionGroups = computed(() => {
|
||||
}
|
||||
return [...groups.entries()].map(([category, permissions]) => ({ category, permissions }));
|
||||
});
|
||||
const userRoleSwitchOptions = computed<SwitchGroupOption[]>(() =>
|
||||
roleRows.value.map((role) => ({
|
||||
value: role.id,
|
||||
label: role.name,
|
||||
description: role.description,
|
||||
disabled: busy.value || !role.enabled
|
||||
}))
|
||||
);
|
||||
const userRoleSwitchValue = computed<Array<string | number>>({
|
||||
get: () => userRoleForm.value.roleIds,
|
||||
set: (values) => {
|
||||
userRoleForm.value.roleIds = values.map((value) => Number(value)).sort((a, b) => a - b);
|
||||
}
|
||||
});
|
||||
const rolePermissionSwitchValue = computed<Array<string | number>>({
|
||||
get: () => rolePermissionForm.value.permissionIds,
|
||||
set: (values) => {
|
||||
rolePermissionForm.value.permissionIds = values.map((value) => Number(value)).sort((a, b) => a - b);
|
||||
}
|
||||
});
|
||||
const wordingLocaleOptions = computed(() =>
|
||||
languageRows.value.length
|
||||
? languageRows.value
|
||||
@@ -455,8 +502,6 @@ const languageKey = (item: Language) => item.code;
|
||||
const languageLabel = (item: Language) => item.name;
|
||||
const configKey = (item: EditableConfig) => item.id;
|
||||
const configLabel = (item: EditableConfig) => item.name;
|
||||
const pokemonKey = (item: Pokemon) => item.id;
|
||||
const pokemonLabel = (item: Pokemon) => `#${item.displayId} ${item.name}`;
|
||||
const itemKey = (item: Item) => item.id;
|
||||
const itemLabel = (item: Item) => item.name;
|
||||
const ancientArtifactKey = (item: AncientArtifact) => item.id;
|
||||
@@ -525,24 +570,13 @@ function rolePermissionCount(role: RoleDetail) {
|
||||
return t('pages.admin.permissionCount', { count: role.permissionIds.length });
|
||||
}
|
||||
|
||||
function toggleUserRole(roleId: number) {
|
||||
const roleIds = new Set(userRoleForm.value.roleIds);
|
||||
if (roleIds.has(roleId)) {
|
||||
roleIds.delete(roleId);
|
||||
} else {
|
||||
roleIds.add(roleId);
|
||||
}
|
||||
userRoleForm.value.roleIds = [...roleIds].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function toggleRolePermission(permissionId: number) {
|
||||
const permissionIds = new Set(rolePermissionForm.value.permissionIds);
|
||||
if (permissionIds.has(permissionId)) {
|
||||
permissionIds.delete(permissionId);
|
||||
} else {
|
||||
permissionIds.add(permissionId);
|
||||
}
|
||||
rolePermissionForm.value.permissionIds = [...permissionIds].sort((a, b) => a - b);
|
||||
function permissionSwitchOptions(permissions: Permission[]): SwitchGroupOption[] {
|
||||
return permissions.map((permission) => ({
|
||||
value: permission.id,
|
||||
label: permission.name,
|
||||
description: permission.key,
|
||||
disabled: busy.value || !permission.enabled
|
||||
}));
|
||||
}
|
||||
|
||||
function errorText(error: unknown, fallback: string) {
|
||||
@@ -896,10 +930,6 @@ function previewConfigOrder(rows: EditableConfig[]) {
|
||||
configRows.value = rows;
|
||||
}
|
||||
|
||||
function previewPokemonOrder(rows: Pokemon[]) {
|
||||
pokemonRows.value = rows;
|
||||
}
|
||||
|
||||
function previewItemOrder(rows: Item[]) {
|
||||
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[]) {
|
||||
itemRows.value = nextRows;
|
||||
await run(async () => {
|
||||
@@ -1129,6 +1147,10 @@ function dishPayloadForSave() {
|
||||
}
|
||||
|
||||
async function saveDishCategory() {
|
||||
if (!dishCategoryFormValid.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await run(async () => {
|
||||
const payload = dishCategoryPayloadForSave();
|
||||
if (dishCategoryForm.value.id) {
|
||||
@@ -1142,6 +1164,10 @@ async function saveDishCategory() {
|
||||
}
|
||||
|
||||
async function saveDish() {
|
||||
if (!dishFormValid.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await run(async () => {
|
||||
const payload = dishPayloadForSave();
|
||||
if (dishForm.value.id) {
|
||||
@@ -2275,20 +2301,8 @@ onMounted(() => {
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.pokemonList') }}</h2>
|
||||
<ReorderableList
|
||||
v-if="pokemonRows.length"
|
||||
:items="pokemonRows"
|
||||
:item-key="pokemonKey"
|
||||
:item-label="pokemonLabel"
|
||||
list-key-prefix="pokemon"
|
||||
:disabled="busy || !can('pokemon.order')"
|
||||
:handle-label="dragSortLabel"
|
||||
:handle-title="t('pages.admin.dragSortTitle')"
|
||||
@preview="previewPokemonOrder"
|
||||
@cancel="previewPokemonOrder"
|
||||
@reorder="persistPokemonOrder"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<ul v-if="pokemonRows.length" class="row-list">
|
||||
<li v-for="item in pokemonRows" :key="item.id">
|
||||
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
|
||||
<span class="row-actions">
|
||||
<button v-if="can('pokemon.delete')" type="button" :disabled="busy" @click="removePokemon(item.id)">
|
||||
@@ -2296,8 +2310,8 @@ onMounted(() => {
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
</ReorderableList>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
@@ -2537,20 +2551,7 @@ onMounted(() => {
|
||||
<strong>{{ editingUser.displayName }}</strong>
|
||||
<span class="meta-line">{{ editingUser.email }}</span>
|
||||
</div>
|
||||
<div class="permission-grid" role="group" :aria-label="t('pages.admin.roles')">
|
||||
<label v-for="role in roleRows" :key="role.id" class="permission-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="userRoleForm.roleIds.includes(role.id)"
|
||||
:disabled="busy || !role.enabled"
|
||||
@change="toggleUserRole(role.id)"
|
||||
/>
|
||||
<span>
|
||||
<strong>{{ role.name }}</strong>
|
||||
<small>{{ role.description }}</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<SwitchGroup id="admin-user-roles" v-model="userRoleSwitchValue" :label="t('pages.admin.roles')" :options="userRoleSwitchOptions" layout="grid" />
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
@@ -2607,22 +2608,14 @@ onMounted(() => {
|
||||
<span class="meta-line">{{ editingRole.description }}</span>
|
||||
</div>
|
||||
<div class="permission-groups">
|
||||
<section v-for="group in permissionGroups" :key="group.category" class="permission-group">
|
||||
<h3>{{ group.category }}</h3>
|
||||
<div class="permission-grid" role="group" :aria-label="group.category">
|
||||
<label v-for="permission in group.permissions" :key="permission.id" class="permission-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="rolePermissionForm.permissionIds.includes(permission.id)"
|
||||
:disabled="busy || !permission.enabled"
|
||||
@change="toggleRolePermission(permission.id)"
|
||||
/>
|
||||
<span>
|
||||
<strong>{{ permission.name }}</strong>
|
||||
<small>{{ permission.key }}</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<section v-for="(group, index) in permissionGroups" :key="group.category" class="permission-group">
|
||||
<SwitchGroup
|
||||
:id="`admin-role-permissions-${index}`"
|
||||
v-model="rolePermissionSwitchValue"
|
||||
:label="group.category"
|
||||
:options="permissionSwitchOptions(group.permissions)"
|
||||
layout="grid"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
@@ -2713,10 +2706,14 @@ onMounted(() => {
|
||||
/>
|
||||
<div class="field">
|
||||
<label for="dish-category-cookware">{{ t('pages.dish.cookware') }}</label>
|
||||
<select id="dish-category-cookware" v-model="dishCategoryForm.cookwareItemId" required>
|
||||
<option value="">{{ t('common.none') }}</option>
|
||||
<option v-for="item in dishItemRows" :key="`cookware-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="dish-category-cookware"
|
||||
v-model="dishCategoryForm.cookwareItemId"
|
||||
:options="dishItemSelectOptions"
|
||||
:multiple="false"
|
||||
:placeholder="t('common.select')"
|
||||
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="dish-category-total-material-quantity">{{ t('pages.dish.totalMaterialQuantity') }}</label>
|
||||
@@ -2724,10 +2721,14 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="dish-category-main-material">{{ t('pages.dish.mainMaterial') }}</label>
|
||||
<select id="dish-category-main-material" v-model="dishCategoryForm.mainMaterialItemId" required>
|
||||
<option value="">{{ t('common.none') }}</option>
|
||||
<option v-for="item in dishItemRows" :key="`category-main-material-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="dish-category-main-material"
|
||||
v-model="dishCategoryForm.mainMaterialItemId"
|
||||
:options="dishItemSelectOptions"
|
||||
:multiple="false"
|
||||
:placeholder="t('common.select')"
|
||||
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TranslationFields
|
||||
@@ -2742,7 +2743,7 @@ onMounted(() => {
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="submit" form="admin-dish-category-form" class="link-button" :disabled="busy">
|
||||
<button type="submit" form="admin-dish-category-form" class="link-button" :disabled="busy || !dishCategoryFormValid">
|
||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
@@ -2758,47 +2759,71 @@ onMounted(() => {
|
||||
<div class="dish-form-row dish-form-row--3">
|
||||
<div class="field">
|
||||
<label for="dish-category">{{ t('pages.dish.category') }}</label>
|
||||
<select id="dish-category" v-model="dishForm.categoryId" required>
|
||||
<option value="">{{ t('common.none') }}</option>
|
||||
<option v-for="category in dishCategoryRows" :key="`dish-category-option-${category.id}`" :value="String(category.id)">{{ category.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="dish-category"
|
||||
v-model="dishForm.categoryId"
|
||||
:options="dishCategorySelectOptions"
|
||||
:multiple="false"
|
||||
:placeholder="t('common.select')"
|
||||
:search-placeholder="t('pages.dish.category')"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="dish-item">{{ t('pages.dish.dishItem') }}</label>
|
||||
<select id="dish-item" v-model="dishForm.itemId" required>
|
||||
<option value="">{{ t('common.none') }}</option>
|
||||
<option v-for="item in dishItemRows" :key="`dish-item-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="dish-item"
|
||||
v-model="dishForm.itemId"
|
||||
:options="dishItemSelectOptions"
|
||||
:multiple="false"
|
||||
:placeholder="t('common.select')"
|
||||
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="dish-flavor">{{ t('pages.dish.flavor') }}</label>
|
||||
<select id="dish-flavor" v-model="dishForm.flavorId" required>
|
||||
<option value="">{{ t('common.none') }}</option>
|
||||
<option v-for="flavor in dishFlavorRows" :key="`dish-flavor-${flavor.id}`" :value="String(flavor.id)">{{ flavor.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="dish-flavor"
|
||||
v-model="dishForm.flavorId"
|
||||
:options="dishFlavorSelectOptions"
|
||||
:multiple="false"
|
||||
:placeholder="t('common.select')"
|
||||
:search-placeholder="t('pages.dish.flavor')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dish-form-row dish-form-row--3">
|
||||
<div class="field">
|
||||
<label for="dish-secondary-material-1">{{ t('pages.dish.secondaryMaterial') }}</label>
|
||||
<select id="dish-secondary-material-1" v-model="dishForm.secondaryMaterialItemIds[0]">
|
||||
<option value="">{{ t('common.none') }}</option>
|
||||
<option v-for="item in dishItemRows" :key="`dish-secondary-material-1-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="dish-secondary-material-1"
|
||||
v-model="dishForm.secondaryMaterialItemIds[0]"
|
||||
:options="optionalDishItemSelectOptions"
|
||||
:multiple="false"
|
||||
:placeholder="t('common.none')"
|
||||
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="dishAllowsSecondSecondaryMaterial" class="field">
|
||||
<label for="dish-secondary-material-2">{{ t('pages.dish.secondSecondaryMaterial') }}</label>
|
||||
<select id="dish-secondary-material-2" v-model="dishForm.secondaryMaterialItemIds[1]">
|
||||
<option value="">{{ t('common.none') }}</option>
|
||||
<option v-for="item in dishItemRows" :key="`dish-secondary-material-2-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="dish-secondary-material-2"
|
||||
v-model="dishForm.secondaryMaterialItemIds[1]"
|
||||
:options="optionalDishItemSelectOptions"
|
||||
:multiple="false"
|
||||
:placeholder="t('common.none')"
|
||||
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="dish-pokemon-skill">{{ t('pages.dish.pokemonSkill') }}</label>
|
||||
<select id="dish-pokemon-skill" v-model="dishForm.pokemonSkillId">
|
||||
<option value="">{{ t('common.none') }}</option>
|
||||
<option v-for="skill in dishSkillRows" :key="`dish-skill-${skill.id}`" :value="String(skill.id)">{{ skill.name }}</option>
|
||||
</select>
|
||||
<TagsSelect
|
||||
id="dish-pokemon-skill"
|
||||
v-model="dishForm.pokemonSkillId"
|
||||
:options="optionalDishSkillSelectOptions"
|
||||
:multiple="false"
|
||||
:placeholder="t('common.none')"
|
||||
:search-placeholder="t('pages.dish.pokemonSkill')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TranslationFields
|
||||
@@ -2813,7 +2838,7 @@ onMounted(() => {
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="submit" form="admin-dish-form" class="link-button" :disabled="busy">
|
||||
<button type="submit" form="admin-dish-form" class="link-button" :disabled="busy || !dishFormValid">
|
||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
|
||||
@@ -11,11 +11,11 @@ import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import { iconAdd, iconArtifact } from '../icons';
|
||||
import { api, getAuthToken, type AncientArtifact, type AuthUser, type Options } from '../services/api';
|
||||
import { api, type AncientArtifact, type AuthUser, type ListPage, type Options } from '../services/api';
|
||||
import ItemEdit from './ItemEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const options = ref<Options | null>(null);
|
||||
const artifacts = ref<AncientArtifact[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
@@ -42,6 +42,52 @@ const artifactQuery = computed(() => ({
|
||||
categoryId: categoryId.value,
|
||||
tagIds: tagIds.value.join(',')
|
||||
}));
|
||||
|
||||
type AncientArtifactListInitialData = {
|
||||
options: Options | null;
|
||||
page: ListPage<AncientArtifact> | null;
|
||||
};
|
||||
|
||||
const { data: initialData } = useAsyncData<AncientArtifactListInitialData>(
|
||||
`ancient-artifact-list-initial:${locale.value}`,
|
||||
async () => {
|
||||
const [optionsResult, artifactsResult] = await Promise.allSettled([
|
||||
api.options(),
|
||||
api.ancientArtifactsPage({
|
||||
...artifactQuery.value,
|
||||
cursor: null,
|
||||
limit: listPageSize
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
options: optionsResult.status === 'fulfilled' ? optionsResult.value : null,
|
||||
page: artifactsResult.status === 'fulfilled' ? artifactsResult.value : null
|
||||
};
|
||||
},
|
||||
{ default: () => ({ options: null, page: null }) }
|
||||
);
|
||||
|
||||
const initialPageLoaded = ref(false);
|
||||
|
||||
function applyInitialData(data: AncientArtifactListInitialData | null | undefined) {
|
||||
if (!data) return;
|
||||
|
||||
if (!options.value && data.options) {
|
||||
options.value = data.options;
|
||||
}
|
||||
|
||||
if (initialPageLoaded.value || !data.page) {
|
||||
return;
|
||||
}
|
||||
|
||||
artifacts.value = data.page.items;
|
||||
nextCursor.value = data.page.nextCursor;
|
||||
hasMoreArtifacts.value = data.page.hasMore;
|
||||
initialPageLoaded.value = true;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const showEditor = computed(() => route.name === 'ancient-artifact-new');
|
||||
const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('items.create') === true);
|
||||
|
||||
@@ -83,6 +129,14 @@ async function loadArtifacts(reset = true) {
|
||||
}
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMoreArtifacts.value = page.hasMore;
|
||||
initialPageLoaded.value = true;
|
||||
} catch {
|
||||
if (requestId === loadRequestId && reset) {
|
||||
artifacts.value = [];
|
||||
nextCursor.value = null;
|
||||
hasMoreArtifacts.value = false;
|
||||
initialPageLoaded.value = true;
|
||||
}
|
||||
} finally {
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false;
|
||||
@@ -96,20 +150,28 @@ function loadMoreArtifacts() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
if (!options.value) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
options.value = await api.options();
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
options.value = null;
|
||||
}
|
||||
}
|
||||
options.value = await api.options();
|
||||
await loadArtifacts();
|
||||
if (!initialPageLoaded.value) {
|
||||
await loadArtifacts();
|
||||
}
|
||||
});
|
||||
|
||||
watch(artifactQuery, () => {
|
||||
void loadArtifacts();
|
||||
});
|
||||
|
||||
watch(initialData, applyInitialData, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import { api, type DailyChecklistItem } from '../services/api';
|
||||
import { api, type DailyChecklistItem, type ListPage } from '../services/api';
|
||||
|
||||
type ChecklistState = {
|
||||
date: string;
|
||||
@@ -12,7 +12,7 @@ type ChecklistState = {
|
||||
};
|
||||
|
||||
const checklistStateKey = 'pokopia_daily_checklist_state';
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const stateRefreshIntervalMs = 60_000;
|
||||
const checklistItems = ref<DailyChecklistItem[]>([]);
|
||||
const checkedTaskIds = ref<Set<number>>(new Set());
|
||||
@@ -25,6 +25,28 @@ const listPageSize = 20;
|
||||
let stateRefreshTimer: number | null = null;
|
||||
let loadRequestId = 0;
|
||||
|
||||
const { data: initialData } = await useAsyncData<ListPage<DailyChecklistItem> | null>(
|
||||
`daily-checklist-initial:${locale.value}`,
|
||||
async () => {
|
||||
try {
|
||||
return await api.dailyChecklistPage({
|
||||
cursor: null,
|
||||
limit: listPageSize
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{ default: () => null }
|
||||
);
|
||||
|
||||
const initialPage = initialData.value;
|
||||
checklistItems.value = initialPage?.items ?? [];
|
||||
const initialPageLoaded = ref(initialPage !== null);
|
||||
loading.value = !initialPageLoaded.value;
|
||||
nextCursor.value = initialPage?.nextCursor ?? null;
|
||||
hasMoreItems.value = initialPage?.hasMore ?? false;
|
||||
|
||||
function todayKey() {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
@@ -124,9 +146,17 @@ async function loadDailyChecklist(reset = true) {
|
||||
}
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMoreItems.value = page.hasMore;
|
||||
initialPageLoaded.value = true;
|
||||
if (!page.hasMore) {
|
||||
syncChecklistState();
|
||||
}
|
||||
} catch {
|
||||
if (requestId === loadRequestId && reset) {
|
||||
checklistItems.value = [];
|
||||
nextCursor.value = null;
|
||||
hasMoreItems.value = false;
|
||||
initialPageLoaded.value = true;
|
||||
}
|
||||
} finally {
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false;
|
||||
@@ -141,8 +171,13 @@ function loadMoreDailyChecklist() {
|
||||
|
||||
onMounted(() => {
|
||||
loadChecklistState();
|
||||
if (initialPageLoaded.value && !hasMoreItems.value) {
|
||||
syncChecklistState();
|
||||
}
|
||||
stateRefreshTimer = window.setInterval(loadChecklistState, stateRefreshIntervalMs);
|
||||
void loadDailyChecklist();
|
||||
if (!initialPageLoaded.value) {
|
||||
void loadDailyChecklist();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import TranslationFields from '../components/TranslationFields.vue';
|
||||
import { iconAdd, iconCancel, iconDelete, iconDish, iconEdit, iconItem, iconSave } from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
type AuthUser,
|
||||
type Dish,
|
||||
type DishCategory,
|
||||
@@ -25,7 +24,7 @@ import {
|
||||
type TranslationMap
|
||||
} from '../services/api';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const categories = ref<DishCategory[]>([]);
|
||||
const activeCategoryId = ref('');
|
||||
const loading = ref(true);
|
||||
@@ -96,6 +95,24 @@ const dishFormValid = computed(
|
||||
dishForm.value.mosslaxEffect.trim() !== ''
|
||||
);
|
||||
|
||||
const { data: initialData } = await useAsyncData<DishCategory[] | null>(
|
||||
`dish-initial:${locale.value}`,
|
||||
async () => {
|
||||
try {
|
||||
return await api.dish();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{ default: () => null }
|
||||
);
|
||||
|
||||
const initialCategories = initialData.value;
|
||||
categories.value = initialCategories ?? [];
|
||||
activeCategoryId.value = categories.value[0] ? String(categories.value[0].id) : '';
|
||||
const initialCategoriesLoaded = ref(initialCategories !== null);
|
||||
loading.value = !initialCategoriesLoaded.value;
|
||||
|
||||
function itemImage(item: ItemLink) {
|
||||
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : null;
|
||||
}
|
||||
@@ -221,6 +238,7 @@ async function loadDish(showSkeleton = false) {
|
||||
}
|
||||
categories.value = await api.dish();
|
||||
activeCategoryId.value = categories.value[0] ? String(categories.value[0].id) : '';
|
||||
initialCategoriesLoaded.value = true;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -282,14 +300,12 @@ async function loadEditorOptions() {
|
||||
|
||||
async function loadPage() {
|
||||
loading.value = true;
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
await Promise.all([loadDish(), loadEditorOptions()]);
|
||||
await Promise.all([initialCategoriesLoaded.value ? Promise.resolve() : loadDish(), loadEditorOptions()]);
|
||||
}
|
||||
|
||||
watch(categories, (nextCategories) => {
|
||||
|
||||
@@ -12,17 +12,18 @@ import PokeBallMark from '../components/PokeBallMark.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit, iconHabitat } from '../icons';
|
||||
import { applySeo } from '../seo';
|
||||
import { api, getAuthToken, type AuthUser, type HabitatDetail } from '../services/api';
|
||||
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
|
||||
import { api, type AuthUser, type HabitatDetail } from '../services/api';
|
||||
import HabitatEdit from './HabitatEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const habitat = ref<HabitatDetail | null>(null);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const detailTab = ref('details');
|
||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||
const weathers = ['晴天', '阴天', '雨天'];
|
||||
const habitatDetailRouteNames = new Set(['habitat-detail', 'habitat-edit']);
|
||||
const showEditor = computed(() => route.name === 'habitat-edit');
|
||||
const canUpdateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.update') === true);
|
||||
const listPath = computed(() => (habitat.value?.isEventItem ? '/event-habitats' : '/habitats'));
|
||||
@@ -33,6 +34,44 @@ const detailTabs = computed<TabOption[]>(() => [
|
||||
{ value: 'history', label: t('history.editHistory') }
|
||||
]);
|
||||
|
||||
const { data: initialHabitat } = useAsyncData<HabitatDetail | null>(
|
||||
`habitat-detail:${activeHabitatRouteId() ?? 'none'}:${locale.value}`,
|
||||
async () => {
|
||||
const routeId = activeHabitatRouteId();
|
||||
if (!routeId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await api.habitatDetail(routeId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{ default: () => null }
|
||||
);
|
||||
|
||||
const initialHabitatLoaded = ref(false);
|
||||
const habitatSeo = computed(() =>
|
||||
habitat.value && route.meta.editorModal !== true
|
||||
? resolveSeo({
|
||||
title: `${habitat.value.name} - ${t(habitat.value.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`,
|
||||
description: t('seo.habitatDetailDescription', { name: habitat.value.name }),
|
||||
canonicalPath: `/habitats/${habitat.value.id}`,
|
||||
image: habitat.value.image?.url
|
||||
})
|
||||
: null
|
||||
);
|
||||
|
||||
useHead(() => (habitatSeo.value ? resolvedSeoHead(habitatSeo.value) : {}));
|
||||
|
||||
function applyInitialHabitat(value: HabitatDetail | null | undefined) {
|
||||
if (!value || initialHabitatLoaded.value) return;
|
||||
|
||||
habitat.value = value;
|
||||
initialHabitatLoaded.value = true;
|
||||
}
|
||||
|
||||
type PokemonRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -73,6 +112,15 @@ function weatherLabel(value: string): string {
|
||||
return labels[value] ?? value;
|
||||
}
|
||||
|
||||
function activeHabitatRouteId(): string | null {
|
||||
return typeof route.name === 'string' &&
|
||||
habitatDetailRouteNames.has(route.name) &&
|
||||
typeof route.params.id === 'string' &&
|
||||
route.params.id.trim() !== ''
|
||||
? route.params.id
|
||||
: null;
|
||||
}
|
||||
|
||||
const pokemonRows = computed<PokemonRow[]>(() => {
|
||||
if (!habitat.value) return [];
|
||||
|
||||
@@ -119,28 +167,41 @@ const pokemonRows = computed<PokemonRow[]>(() => {
|
||||
});
|
||||
|
||||
async function loadHabitatDetail() {
|
||||
const nextHabitat = await api.habitatDetail(String(route.params.id));
|
||||
habitat.value = nextHabitat;
|
||||
const routeId = activeHabitatRouteId();
|
||||
if (!routeId) {
|
||||
initialHabitatLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.meta.editorModal !== true) {
|
||||
applySeo({
|
||||
title: `${nextHabitat.name} - ${t(nextHabitat.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`,
|
||||
description: t('seo.habitatDetailDescription', { name: nextHabitat.name }),
|
||||
canonicalPath: `/habitats/${nextHabitat.id}`,
|
||||
image: nextHabitat.image?.url
|
||||
});
|
||||
try {
|
||||
const nextHabitat = await api.habitatDetail(routeId);
|
||||
habitat.value = nextHabitat;
|
||||
initialHabitatLoaded.value = true;
|
||||
|
||||
if (route.meta.editorModal !== true) {
|
||||
applySeo({
|
||||
title: `${nextHabitat.name} - ${t(nextHabitat.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`,
|
||||
description: t('seo.habitatDetailDescription', { name: nextHabitat.name }),
|
||||
canonicalPath: `/habitats/${nextHabitat.id}`,
|
||||
image: nextHabitat.image?.url
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
habitat.value = null;
|
||||
initialHabitatLoaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
|
||||
if (!initialHabitatLoaded.value) {
|
||||
await loadHabitatDetail();
|
||||
}
|
||||
await loadHabitatDetail();
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -155,11 +216,17 @@ watch(
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
if (!activeHabitatRouteId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
habitat.value = null;
|
||||
detailTab.value = 'details';
|
||||
void loadHabitatDetail();
|
||||
}
|
||||
);
|
||||
|
||||
watch(initialHabitat, applyInitialHabitat, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -13,7 +13,6 @@ import TranslationFields from '../components/TranslationFields.vue';
|
||||
import { iconAdd, iconCancel, iconDelete, iconPokemon, iconSave } from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
type AuthUser,
|
||||
type ConfigType,
|
||||
type EntityImage,
|
||||
@@ -156,11 +155,6 @@ function habitatNameForSave() {
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
|
||||
@@ -8,7 +8,7 @@ import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import { iconAdd, iconHabitat } from '../icons';
|
||||
import { api, getAuthToken, type AuthUser, type Habitat } from '../services/api';
|
||||
import { api, type AuthUser, type Habitat, type ListPage } from '../services/api';
|
||||
import HabitatEdit from './HabitatEdit.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -18,8 +18,7 @@ const props = defineProps<{
|
||||
const habitats = ref<Habitat[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const loading = ref(true);
|
||||
const { t, locale } = useI18n();
|
||||
const loadingMore = ref(false);
|
||||
const nextCursor = ref<string | null>(null);
|
||||
const hasMoreHabitats = ref(false);
|
||||
@@ -29,6 +28,36 @@ let loadRequestId = 0;
|
||||
const query = computed(() => ({
|
||||
isEventItem: props.eventOnly ? 'true' : 'false'
|
||||
}));
|
||||
|
||||
const { data: initialData } = useAsyncData<ListPage<Habitat> | null>(
|
||||
`${props.eventOnly ? 'event-habitat-list-initial' : 'habitat-list-initial'}:${locale.value}`,
|
||||
async () => {
|
||||
try {
|
||||
return await api.habitatsPage({
|
||||
...query.value,
|
||||
cursor: null,
|
||||
limit: listPageSize
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{ default: () => null }
|
||||
);
|
||||
|
||||
const initialPageLoaded = ref(false);
|
||||
const loading = ref(true);
|
||||
|
||||
function applyInitialData(page: ListPage<Habitat> | null | undefined) {
|
||||
if (!page || initialPageLoaded.value) return;
|
||||
|
||||
habitats.value = page.items;
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMoreHabitats.value = page.hasMore;
|
||||
initialPageLoaded.value = true;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const showEditor = computed(() => route.name === 'habitat-new' || route.name === 'event-habitat-new');
|
||||
const canCreateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.create') === true);
|
||||
const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventHabitats.title' : 'pages.habitats.title'));
|
||||
@@ -75,6 +104,14 @@ async function loadHabitats(reset = true) {
|
||||
}
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMoreHabitats.value = page.hasMore;
|
||||
initialPageLoaded.value = true;
|
||||
} catch {
|
||||
if (requestId === loadRequestId && reset) {
|
||||
habitats.value = [];
|
||||
nextCursor.value = null;
|
||||
hasMoreHabitats.value = false;
|
||||
initialPageLoaded.value = true;
|
||||
}
|
||||
} finally {
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false;
|
||||
@@ -88,19 +125,21 @@ function loadMoreHabitats() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
if (!initialPageLoaded.value) {
|
||||
await loadHabitats();
|
||||
}
|
||||
await loadHabitats();
|
||||
});
|
||||
|
||||
watch(query, () => {
|
||||
void loadHabitats();
|
||||
});
|
||||
|
||||
watch(initialData, applyInitialData, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -63,8 +63,27 @@ const showProjectUpdates = computed(
|
||||
const showProjectUpdatesViewAll = computed(() => projectCommits.value.length > 0 || latestReleases.value.length > 0);
|
||||
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
|
||||
|
||||
const { data: initialProjectUpdates } = await useAsyncData<ProjectUpdates | null>(
|
||||
`home-project-updates:${locale.value}`,
|
||||
async () => {
|
||||
try {
|
||||
return await api.projectUpdates({ limit: projectCommitPageSize });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{ default: () => null }
|
||||
);
|
||||
|
||||
projectUpdates.value = initialProjectUpdates.value;
|
||||
projectCommits.value = initialProjectUpdates.value?.commits.items ?? [];
|
||||
const initialProjectUpdatesLoaded = ref(initialProjectUpdates.value !== null);
|
||||
projectUpdatesLoading.value = !initialProjectUpdatesLoaded.value;
|
||||
|
||||
onMounted(() => {
|
||||
void loadProjectUpdates();
|
||||
if (!initialProjectUpdatesLoaded.value) {
|
||||
void loadProjectUpdates();
|
||||
}
|
||||
});
|
||||
|
||||
function sectionTitleKey(key: string) {
|
||||
@@ -81,9 +100,11 @@ async function loadProjectUpdates(): Promise<void> {
|
||||
const updates = await api.projectUpdates({ limit: projectCommitPageSize });
|
||||
projectUpdates.value = updates;
|
||||
projectCommits.value = updates.commits.items;
|
||||
initialProjectUpdatesLoaded.value = true;
|
||||
} catch {
|
||||
projectUpdates.value = null;
|
||||
projectCommits.value = [];
|
||||
initialProjectUpdatesLoaded.value = true;
|
||||
} finally {
|
||||
projectUpdatesLoading.value = false;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import PokeBallMark from '../components/PokeBallMark.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
|
||||
import { applySeo } from '../seo';
|
||||
import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/api';
|
||||
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
|
||||
import { api, type AuthUser, type ItemDetail } from '../services/api';
|
||||
import ItemEdit from './ItemEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
@@ -73,6 +73,45 @@ const possibleTagEvidenceSections = computed(() => [
|
||||
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] }
|
||||
]);
|
||||
|
||||
const { data: initialItem } = useAsyncData<ItemDetail | null>(
|
||||
`item-detail:${String(route.name)}:${activeItemRouteId() ?? 'none'}:${locale.value}`,
|
||||
async () => {
|
||||
const routeId = activeItemRouteId();
|
||||
if (!routeId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextItem = await api.itemDetail(routeId);
|
||||
return isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory ? null : nextItem;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
{ default: () => null }
|
||||
);
|
||||
|
||||
const initialItemLoaded = ref(false);
|
||||
const itemSeo = computed(() =>
|
||||
item.value && route.meta.editorModal !== true
|
||||
? resolveSeo({
|
||||
title: `${item.value.name} - ${t(detailTitleKey.value)}`,
|
||||
description: t(detailDescriptionKey.value, { name: item.value.name }),
|
||||
canonicalPath: detailCanonicalPath.value,
|
||||
image: item.value.image?.url
|
||||
})
|
||||
: null
|
||||
);
|
||||
|
||||
useHead(() => (itemSeo.value ? resolvedSeoHead(itemSeo.value) : {}));
|
||||
|
||||
function applyInitialItem(value: ItemDetail | null | undefined) {
|
||||
if (!value || initialItemLoaded.value) return;
|
||||
|
||||
item.value = value;
|
||||
initialItemLoaded.value = true;
|
||||
}
|
||||
|
||||
const customization = computed(() => {
|
||||
if (!item.value) {
|
||||
return [];
|
||||
@@ -86,22 +125,34 @@ const customization = computed(() => {
|
||||
});
|
||||
|
||||
async function loadItemDetail() {
|
||||
const nextItem = await api.itemDetail(String(route.params.id));
|
||||
|
||||
if (isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory) {
|
||||
await router.replace(route.name === 'ancient-artifact-edit' ? `/items/${nextItem.id}/edit` : `/items/${nextItem.id}`);
|
||||
const routeId = activeItemRouteId();
|
||||
if (!routeId) {
|
||||
initialItemLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
item.value = nextItem;
|
||||
try {
|
||||
const nextItem = await api.itemDetail(routeId);
|
||||
|
||||
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
|
||||
});
|
||||
if (isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory) {
|
||||
await router.replace(route.name === 'ancient-artifact-edit' ? `/items/${nextItem.id}/edit` : `/items/${nextItem.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
item.value = nextItem;
|
||||
initialItemLoaded.value = true;
|
||||
|
||||
if (route.meta.editorModal !== true) {
|
||||
applySeo({
|
||||
title: `${nextItem.name} - ${t(detailTitleKey.value)}`,
|
||||
description: t(detailDescriptionKey.value, { name: nextItem.name }),
|
||||
canonicalPath: detailCanonicalPath.value,
|
||||
image: nextItem.image?.url
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
item.value = null;
|
||||
initialItemLoaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,15 +160,22 @@ function isItemDetailRouteName(value: unknown) {
|
||||
return typeof value === 'string' && itemDetailRouteNames.has(value);
|
||||
}
|
||||
|
||||
function activeItemRouteId(): string | null {
|
||||
return isItemDetailRouteName(route.name) && typeof route.params.id === 'string' && route.params.id.trim() !== ''
|
||||
? route.params.id
|
||||
: null;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
|
||||
if (!initialItemLoaded.value) {
|
||||
await loadItemDetail();
|
||||
}
|
||||
await loadItemDetail();
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -134,11 +192,17 @@ watch(
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
if (!activeItemRouteId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.value = null;
|
||||
detailTab.value = 'details';
|
||||
void loadItemDetail();
|
||||
}
|
||||
);
|
||||
|
||||
watch(initialItem, applyInitialItem, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -12,7 +12,6 @@ import TranslationFields from '../components/TranslationFields.vue';
|
||||
import { iconCancel, iconSave } from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
type AuthUser,
|
||||
type ConfigType,
|
||||
type EntityImage,
|
||||
@@ -215,11 +214,6 @@ async function loadOptions() {
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
|
||||
@@ -11,7 +11,7 @@ import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
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';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -21,7 +21,7 @@ const props = defineProps<{
|
||||
const options = ref<Options | null>(null);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const items = ref<Item[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const loading = ref(true);
|
||||
@@ -104,6 +104,52 @@ const itemQuery = computed(() => ({
|
||||
tagIds: tagIds.value.join(','),
|
||||
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 canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true);
|
||||
const hasItemCreateDefaults = computed(
|
||||
@@ -458,6 +504,14 @@ async function loadItems(reset = true) {
|
||||
}
|
||||
nextCursor.value = page.nextCursor;
|
||||
hasMoreItems.value = page.hasMore;
|
||||
initialPageLoaded.value = true;
|
||||
} catch {
|
||||
if (requestId === loadRequestId && reset) {
|
||||
items.value = [];
|
||||
nextCursor.value = null;
|
||||
hasMoreItems.value = false;
|
||||
initialPageLoaded.value = true;
|
||||
}
|
||||
} finally {
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false;
|
||||
@@ -473,16 +527,22 @@ function loadMoreItems() {
|
||||
onMounted(async () => {
|
||||
document.addEventListener('pointerdown', onCreateDefaultsDocumentPointerDown);
|
||||
document.addEventListener('keydown', onDocumentKeydown);
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
if (!options.value) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
options.value = await api.options();
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
options.value = null;
|
||||
}
|
||||
}
|
||||
options.value = await api.options();
|
||||
sanitizeItemCreateDefaults();
|
||||
await loadItems();
|
||||
if (!initialPageLoaded.value) {
|
||||
await loadItems();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -493,6 +553,8 @@ onBeforeUnmount(() => {
|
||||
watch(itemQuery, () => {
|
||||
void loadItems();
|
||||
});
|
||||
|
||||
watch(initialData, applyInitialData, { immediate: true });
|
||||
watch(itemCreateDefaults, persistItemCreateDefaults, { deep: true });
|
||||
watch(showEditor, () => {
|
||||
closeCreateDefaultsMenu();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
||||
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
||||
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
|
||||
@@ -28,10 +29,8 @@ import {
|
||||
} from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
moderationUpdateEvent,
|
||||
onAuthTokenChange,
|
||||
setAuthToken,
|
||||
onAuthChange,
|
||||
type AiModerationStatus,
|
||||
type AuthUser,
|
||||
type CommentSort,
|
||||
@@ -40,6 +39,7 @@ import {
|
||||
type LifeReactionType,
|
||||
type ModerationUpdateDetail
|
||||
} from '../services/api';
|
||||
import { resolvedSeoHead, resolveSeo } from '../seo';
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const route = useRoute();
|
||||
@@ -69,6 +69,8 @@ const ratingErrors = ref<Record<number, string>>({});
|
||||
const moderationBusyPostId = ref<number | null>(null);
|
||||
const moderationErrors = ref<Record<number, string>>({});
|
||||
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
|
||||
const pendingDeleteComment = ref<LifeComment | null>(null);
|
||||
const deleteConfirmBusy = ref(false);
|
||||
const lifeCommentPageSize = 20;
|
||||
const commentMaxLength = 1000;
|
||||
let removeAuthListener: (() => void) | null = null;
|
||||
@@ -101,18 +103,21 @@ function routePostId() {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
return;
|
||||
function summaryText(value: string, maxLength: number) {
|
||||
const normalized = value.replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}...`;
|
||||
}
|
||||
|
||||
async function loadCurrentUser() {
|
||||
try {
|
||||
const response = await api.me();
|
||||
currentUser.value = response.user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
setAuthToken(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +138,41 @@ function resetCommentsFromPost(nextPost: LifePost) {
|
||||
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() {
|
||||
const id = routePostId();
|
||||
if (!id) {
|
||||
@@ -147,9 +187,11 @@ async function loadPost() {
|
||||
const nextPost = await api.lifePost(id);
|
||||
post.value = nextPost;
|
||||
resetCommentsFromPost(nextPost);
|
||||
initialPostLoaded.value = true;
|
||||
void loadComments(true);
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||
initialPostLoaded.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -668,10 +710,6 @@ function markOwnCommentDeleted(items: LifeComment[], id: number): boolean {
|
||||
}
|
||||
|
||||
async function deleteComment(comment: LifeComment) {
|
||||
if (!window.confirm(t('pages.life.deleteCommentConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = replyKey(comment.id);
|
||||
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) {
|
||||
const key = replyKey(comment.id);
|
||||
commentBusyKey.value = key;
|
||||
@@ -793,9 +858,13 @@ onMounted(() => {
|
||||
document.addEventListener('click', closeReactionPickerFromDocument);
|
||||
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
|
||||
void loadCurrentUser();
|
||||
void loadPost();
|
||||
removeAuthListener = onAuthTokenChange(() => {
|
||||
void (async () => {
|
||||
await loadCurrentUser();
|
||||
if (!initialPostLoaded.value || currentUser.value) {
|
||||
await loadPost();
|
||||
}
|
||||
})();
|
||||
removeAuthListener = onAuthChange(() => {
|
||||
void loadCurrentUser();
|
||||
void loadPost();
|
||||
});
|
||||
@@ -1117,7 +1186,7 @@ onUnmounted(() => {
|
||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||
type="button"
|
||||
:aria-label="t('pages.life.deleteComment')"
|
||||
@click="deleteComment(comment)"
|
||||
@click="requestDeleteComment(comment)"
|
||||
>
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
<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"
|
||||
type="button"
|
||||
:aria-label="t('pages.life.deleteComment')"
|
||||
@click="deleteComment(reply)"
|
||||
@click="requestDeleteComment(reply)"
|
||||
>
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
||||
@@ -1290,6 +1359,18 @@ onUnmounted(() => {
|
||||
<h2>{{ t('pages.life.empty') }}</h2>
|
||||
</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>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
import FilterPanel from '../components/FilterPanel.vue';
|
||||
import LifeRatingControl from '../components/LifeRatingControl.vue';
|
||||
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
|
||||
@@ -35,10 +36,8 @@ import {
|
||||
} from '../icons';
|
||||
import {
|
||||
api,
|
||||
getAuthToken,
|
||||
moderationUpdateEvent,
|
||||
onAuthTokenChange,
|
||||
setAuthToken,
|
||||
onAuthChange,
|
||||
type AiModerationStatus,
|
||||
type AuthUser,
|
||||
type CommentSort,
|
||||
@@ -47,6 +46,7 @@ import {
|
||||
type LifeCategory,
|
||||
type LifeComment,
|
||||
type LifePost,
|
||||
type LifePostsPage,
|
||||
type LifeReactionType,
|
||||
type ModerationUpdateDetail
|
||||
} from '../services/api';
|
||||
@@ -64,6 +64,7 @@ type LifeCommentPageState = {
|
||||
|
||||
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
|
||||
type LifeFeedScope = 'all' | 'following';
|
||||
type PendingLifeDelete = { type: 'post'; post: LifePost } | { type: 'comment'; post: LifePost; comment: LifeComment };
|
||||
|
||||
const { locale, t } = useI18n();
|
||||
const posts = ref<LifePost[]>([]);
|
||||
@@ -106,6 +107,8 @@ const ratingErrors = ref<Record<number, string>>({});
|
||||
const moderationBusyPostId = ref<number | null>(null);
|
||||
const moderationErrors = ref<Record<number, string>>({});
|
||||
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 loadMoreSentinel = ref<HTMLElement | null>(null);
|
||||
const lifePostPageSize = 20;
|
||||
@@ -123,6 +126,53 @@ const loadMorePaused = ref(false);
|
||||
const allCategoryValue = 'all';
|
||||
const allLanguageValue = '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 = [
|
||||
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
|
||||
@@ -210,20 +260,12 @@ const submitLabel = computed(() => {
|
||||
async function loadCurrentUser() {
|
||||
authReady.value = false;
|
||||
|
||||
if (!getAuthToken()) {
|
||||
currentUser.value = null;
|
||||
activeFeedScope.value = 'all';
|
||||
authReady.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.me();
|
||||
currentUser.value = response.user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
activeFeedScope.value = 'all';
|
||||
setAuthToken(null);
|
||||
} finally {
|
||||
authReady.value = true;
|
||||
}
|
||||
@@ -1017,10 +1059,6 @@ function startEdit(post: LifePost) {
|
||||
}
|
||||
|
||||
async function deletePost(post: LifePost) {
|
||||
if (!window.confirm(t('pages.life.deleteConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadError.value = '';
|
||||
|
||||
try {
|
||||
@@ -1035,6 +1073,10 @@ async function deletePost(post: LifePost) {
|
||||
}
|
||||
}
|
||||
|
||||
function requestDeletePost(post: LifePost) {
|
||||
pendingDelete.value = { type: 'post', post };
|
||||
}
|
||||
|
||||
function startReply(comment: LifeComment) {
|
||||
replyTargetId.value = comment.id;
|
||||
clearCommentError(replyKey(comment.id));
|
||||
@@ -1159,10 +1201,6 @@ function markOwnCommentDeleted(comments: LifeComment[], id: number): boolean {
|
||||
}
|
||||
|
||||
async function deleteComment(post: LifePost, comment: LifeComment) {
|
||||
if (!window.confirm(t('pages.life.deleteCommentConfirm'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = replyKey(comment.id);
|
||||
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) {
|
||||
const key = replyKey(comment.id);
|
||||
commentBusyKey.value = key;
|
||||
@@ -1334,11 +1403,22 @@ onMounted(() => {
|
||||
document.addEventListener('click', closeReactionPickerFromDocument);
|
||||
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
|
||||
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
|
||||
void loadCurrentUser();
|
||||
void loadLanguages();
|
||||
void loadLifeCategories();
|
||||
void loadPosts();
|
||||
removeAuthListener = onAuthTokenChange(() => {
|
||||
void (async () => {
|
||||
await loadCurrentUser();
|
||||
if (!initialLanguagesLoaded.value) {
|
||||
await loadLanguages();
|
||||
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 () => {
|
||||
await loadCurrentUser();
|
||||
await loadPosts();
|
||||
@@ -1558,7 +1638,7 @@ onUnmounted(() => {
|
||||
class="life-icon-button life-icon-button--danger"
|
||||
type="button"
|
||||
:aria-label="t('pages.life.deletePost')"
|
||||
@click="deletePost(post)"
|
||||
@click="requestDeletePost(post)"
|
||||
>
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
<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"
|
||||
type="button"
|
||||
:aria-label="t('pages.life.deleteComment')"
|
||||
@click="deleteComment(post, comment)"
|
||||
@click="requestDeleteComment(post, comment)"
|
||||
>
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
<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"
|
||||
type="button"
|
||||
:aria-label="t('pages.life.deleteComment')"
|
||||
@click="deleteComment(post, reply)"
|
||||
@click="requestDeleteComment(post, reply)"
|
||||
>
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
||||
@@ -2061,6 +2141,18 @@ onUnmounted(() => {
|
||||
{{ t('pages.life.newPost') }}
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import { iconLogin } from '../icons';
|
||||
import { api, setAuthToken } from '../services/api';
|
||||
import { api, notifyAuthChange } from '../services/api';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -22,12 +22,12 @@ async function submitLogin() {
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const response = await api.login({
|
||||
await api.login({
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
rememberMe: rememberMe.value
|
||||
});
|
||||
setAuthToken(response.token, { persistent: rememberMe.value });
|
||||
notifyAuthChange();
|
||||
|
||||
const redirect =
|
||||
typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
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 { useRoute } from 'vue-router';
|
||||
import DetailSection from '../components/DetailSection.vue';
|
||||
@@ -15,12 +15,12 @@ import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconAdd, iconBack, iconCancel, iconCheck, iconEdit, iconHabitat, iconItem } from '../icons';
|
||||
import { applySeo } from '../seo';
|
||||
import { api, getAuthToken, type AuthUser, type Item, type PokemonDetail, type PokemonPayload, type TradingPreference } from '../services/api';
|
||||
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
|
||||
import { api, type AuthUser, type Item, type PokemonDetail, type PokemonPayload, type TradingPreference } from '../services/api';
|
||||
import PokemonEdit from './PokemonEdit.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const pokemon = ref<PokemonDetail | null>(null);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const itemCategoryTab = ref('');
|
||||
@@ -36,9 +36,50 @@ const tradingCategoryId = ref('');
|
||||
const tradingDefaultPreference = ref<TradingPreference>('like');
|
||||
const tradingItemChoices = ref<Item[]>([]);
|
||||
const tradingDraftItems = ref<Array<{ itemId: number; preference: TradingPreference }>>([]);
|
||||
const tradingActiveItemIndex = ref(0);
|
||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||
const weathers = ['晴天', '阴天', '雨天'];
|
||||
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 = {
|
||||
id: number;
|
||||
@@ -65,6 +106,15 @@ function habitatTabValue(id: number): string {
|
||||
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 {
|
||||
const labels: Record<string, string> = {
|
||||
早晨: t('appearance.morning'),
|
||||
@@ -149,20 +199,58 @@ const tradingCategoryOptions = computed(() => {
|
||||
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 filteredTradingItems = computed(() => {
|
||||
const search = tradingSearch.value.trim().toLocaleLowerCase();
|
||||
function normalizedTradingValue(value: string) {
|
||||
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) {
|
||||
return false;
|
||||
return [];
|
||||
}
|
||||
|
||||
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 itemsById = new Map(tradingItemChoices.value.map((item) => [item.id, item]));
|
||||
@@ -343,6 +431,7 @@ function resetTradingDraft() {
|
||||
tradingDefaultPreference.value = 'like';
|
||||
tradingSearch.value = '';
|
||||
tradingCategoryId.value = '';
|
||||
tradingActiveItemIndex.value = 0;
|
||||
tradingMessage.value = '';
|
||||
}
|
||||
|
||||
@@ -350,13 +439,72 @@ function isTradingItemSelected(itemId: string | number) {
|
||||
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);
|
||||
if (isTradingItemSelected(itemId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
tradingDraftItems.value.push({ itemId: item.id, preference: tradingDefaultPreference.value });
|
||||
tradingActiveItemIndex.value = firstAddableTradingItemIndex(filteredTradingItems.value, index, 1);
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!pokemon.value) {
|
||||
return;
|
||||
@@ -411,29 +614,43 @@ async function saveTradingItems() {
|
||||
}
|
||||
|
||||
async function loadPokemonDetail() {
|
||||
const nextPokemon = await api.pokemonDetail(String(route.params.id));
|
||||
pokemon.value = nextPokemon;
|
||||
relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id);
|
||||
const routeId = activePokemonRouteId();
|
||||
if (!routeId) {
|
||||
initialPokemonLoaded.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
try {
|
||||
const nextPokemon = await api.pokemonDetail(routeId);
|
||||
pokemon.value = nextPokemon;
|
||||
relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id);
|
||||
initialPokemonLoaded.value = true;
|
||||
|
||||
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 () => {
|
||||
if (getAuthToken()) {
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
try {
|
||||
currentUser.value = (await api.me()).user;
|
||||
} catch {
|
||||
currentUser.value = null;
|
||||
}
|
||||
|
||||
if (!initialPokemonLoaded.value) {
|
||||
await loadPokemonDetail();
|
||||
}
|
||||
await loadPokemonDetail();
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -448,6 +665,10 @@ watch(
|
||||
watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
if (!activePokemonRouteId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
pokemon.value = null;
|
||||
relatedHabitatTab.value = '';
|
||||
detailTab.value = 'details';
|
||||
@@ -457,6 +678,8 @@ watch(
|
||||
void loadPokemonDetail();
|
||||
}
|
||||
);
|
||||
|
||||
watch(initialPokemon, applyInitialPokemon, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -808,7 +1031,13 @@ watch(
|
||||
id="pokemon-trading-search"
|
||||
v-model="tradingSearch"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
role="combobox"
|
||||
:aria-expanded="filteredTradingItems.length > 0"
|
||||
aria-controls="pokemon-trading-results"
|
||||
:aria-activedescendant="activeTradingItemId()"
|
||||
:placeholder="t('pages.pokemon.searchItems')"
|
||||
@keydown="handleTradingSearchKeydown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -840,14 +1069,19 @@ watch(
|
||||
<Skeleton variant="box" height="58px" />
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-else-if="filteredTradingItems.length" class="trading-item-list">
|
||||
<li v-for="item in filteredTradingItems" :key="item.id">
|
||||
<ul v-else-if="filteredTradingItems.length" id="pokemon-trading-results" class="trading-item-list">
|
||||
<li v-for="(item, index) in filteredTradingItems" :id="`pokemon-trading-item-${item.id}`" :key="item.id">
|
||||
<button
|
||||
type="button"
|
||||
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)"
|
||||
@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">
|
||||
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user