Compare commits

...

30 Commits

Author SHA1 Message Date
b0e2464c24 feat(auth): implement Resend email quota and rate limit protection
Track Resend API usage via response headers to prevent quota exhaustion
Block auth requests with 503 when email delivery limits are reached
2026-05-03 19:42:41 +08:00
40f85ae85c feat(auth): implement branded HTML templates for auth emails
Add a standardized HTML shell for verification and password reset emails.
Update system wordings with new email copy, buttons, and fallback links.
Strip standalone action links from content to use styled buttons.
2026-05-03 19:33:25 +08:00
3a8a61487a feat(config): support multiple CORS origins and dynamic docker env vars
Parse comma-separated origins in FRONTEND_ORIGIN for Fastify CORS
Use host environment variables with fallbacks in docker-compose
Add Cloudflared tunnel deployment examples to .env.example
2026-05-03 19:22:38 +08:00
72ddae6f9d chore: add Umami analytics and adjust docker-compose ports
Inject Umami analytics script into frontend index.html
Map backend port to 20016 and remove exposed postgres port
2026-05-03 19:14:57 +08:00
fcb9b57aa3 feat(history): track translation and config changes in edit history
Record localized field updates across all translatable entities.
Track sort order modifications and specific config properties.
Support parsing and translating localized field labels in the UI.
2026-05-03 19:09:49 +08:00
d80c9325cd refactor(life): extract rating control and reorganize post actions
Extract 5-star rating UI into a dedicated LifeRatingControl component
Move moderation status and retry button into the engagement actions bar
2026-05-03 19:06:02 +08:00
105274eec8 feat(life): add game versions and 5-star ratings to posts
Support associating life posts with specific game versions
Allow 1-5 star ratings on posts in rateable categories
Add feed filters for game version, rateable status, and top-rated sorting
2026-05-03 18:38:33 +08:00
4ebb45aa94 feat(legal): add legal pages and global footer
Introduce Privacy Policy, Terms of Service, and Disclaimers views
Add site footer with copyright, legal links, and attribution notices
Update system wordings with comprehensive legal content in EN/ZH
2026-05-03 18:04:33 +08:00
6758aaaa7e feat(home): add home page as main entry point
Introduce HomeView with quick links to wiki sections and community features
Update navigation, routing, and logo links to point to the new home page
2026-05-03 17:46:36 +08:00
6782ddd101 feat(life): replace multiple tags with single category for posts
Add default category support and enforce one category per Life Post
Update UI filters, forms, and translations to reflect category semantics
2026-05-03 17:34:32 +08:00
18baf7b513 feat(moderation): add AI moderation for user-generated content
Add AI moderation settings, caching, and status tracking
Require AI approval for Life Posts, Comments, and Discussions
Implement language filtering and moderation status UI
Add retry mechanism for failed moderation checks
2026-05-03 17:08:51 +08:00
590bd6a0ae build: optimize Dockerfiles for production and pin dependencies
Implement multi-stage build and static server for frontend
Run containers as non-root user and set production environment
Pin all package dependencies to exact versions
2026-05-03 15:35:00 +08:00
7aa80430d9 refactor(api): remove internal metadata from image upload responses
Omit entity details, original filename, MIME type, and file size from payloads
Update backend SQL queries and frontend interfaces to align with design specs
2026-05-03 15:24:27 +08:00
960898c858 feat(comments): paginate life post and entity discussion comments
Implement cursor-based pagination for Life and Entity comments
Optimize Life Post queries to return comment counts and previews
Add "Load more" functionality to frontend discussion panels
2026-05-03 15:20:05 +08:00
0c76d6bfc8 feat(api): implement rate limiting for abuse prevention
Add @fastify/rate-limit with granular policies for different routes
Support TRUST_PROXY environment variable for reverse proxies
2026-05-03 15:04:07 +08:00
8f55db9061 feat(auth): enforce role level boundaries and owner assignment rules
Add `admin.users.assign-owner` permission to control Owner role assignment.
Restrict role assignment to roles strictly below the assigner's highest level.
2026-05-03 14:50:52 +08:00
1dab650c2c feat(seo): implement dynamic metadata, sitemap, and robots.txt
Add dynamic meta tags for routes and entity detail pages
Generate sitemap.xml and robots.txt dynamically in Vite
Change default frontend port from 3000 to 20015
2026-05-03 14:31:22 +08:00
282481bbcc feat(profile): add password change and activity filters
Implement password change API and UI in the Account tab
Add secondary filters for contributions, reactions, and comments
Display referral summary in the profile header
2026-05-03 13:52:35 +08:00
0e835f9c03 feat(profile): add public user profiles with activity tabs and stats
Add API routes for user stats, posts, reactions, and comments
Implement profile view with Feeds, Contributions, Reactions tabs
Link to user profiles from edit history, discussions, and life posts
Add database indexes to optimize user-centric queries
2026-05-03 13:14:29 +08:00
b9ec8076ac feat(auth): assign default editor role to verified users without roles
Update bootstrap rules to grant 'editor' role to verified users
Backfill existing verified users without roles in schema.sql
Apply default role automatically during email verification
2026-05-03 12:41:00 +08:00
043ebe392a refactor(backend): localize validation errors and consolidate schema
Replace hardcoded validation error messages with i18n keys.
Merge ALTER TABLE statements into initial CREATE TABLE definitions.
Clean up obsolete data migration scripts from schema file.
2026-05-03 12:16:26 +08:00
ef82fc805d feat(automation): add coming soon page and navigation entry
Add Automation route and navigation item with in-development badge
Include localized wordings, icon, and design docs for the new section
2026-05-03 12:05:29 +08:00
95d76522df feat(life): require at least one tag for life posts
Update design spec to mandate tag selection
Add frontend and backend validation for tag requirement
Add localization strings for tag required errors
2026-05-03 11:47:08 +08:00
accd6f98cf fix(ui): prevent dropdown clipping with fixed positioning
Automatically use fixed dropdown strategy for TagsSelect inside modals
Dynamically calculate fixed coordinates for Pokemon fetch results dropdown
2026-05-03 11:38:32 +08:00
3ca66d7124 docs(agents): add git diff hygiene rules for CSV files
Instruct agents to ignore data/**/*.csv files in git diffs by default.
Prevents false positives caused by WSL file system quirks.
2026-05-03 11:30:55 +08:00
8bc311916d feat(admin): redesign navigation with grouped secondary sidebar
Replace flat tabs with categorized navigation groups (Content, Config, etc.).
Update layout styles to support a responsive secondary sidebar.
2026-05-03 11:27:43 +08:00
05f531ddf2 feat(auth): implement role-based access control (RBAC)
Add roles, permissions, and user_roles tables with default seed data
Protect backend API endpoints with granular permission checks
Add admin UI for managing users, roles, and permissions
Update frontend views to conditionally render actions based on permissions
2026-05-03 11:16:58 +08:00
05898f9441 feat(auth): add user referral system with invite codes
Generate unique referral codes for users
Allow new users to register with a referral code
Display referral stats and invite link in user profile
2026-05-03 10:27:45 +08:00
3d99f00c75 feat(wiki): add event item flag and decouple pokemon display ID
Add `is_event_item` to pokemon, items, and habitats.
Separate internal `id` and `display_id` for pokemon to allow event variants.
Update frontend forms and views to support the new fields.
2026-05-03 10:11:04 +08:00
4d05618530 feat: add images and profile grid layout to entity detail pages
Return image data for related entities across all backend detail queries
Display images or default placeholders in detail headers, chips, and lists
Standardize Item, Recipe, and Habitat detail views with a new profile grid
2026-05-03 09:51:45 +08:00
57 changed files with 13124 additions and 1587 deletions

View File

@@ -3,8 +3,22 @@ POSTGRES_USER=pokopia
POSTGRES_PASSWORD=pokopia
DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia
BACKEND_PORT=3001
FRONTEND_ORIGIN=http://localhost:3000
APP_ORIGIN=http://localhost:3000
VITE_API_BASE_URL=http://localhost:3001
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
RESEND_API_KEY=
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
RESEND_DAILY_QUOTA_LIMIT=100
RESEND_MONTHLY_QUOTA_LIMIT=3000
RESEND_QUOTA_RESERVE=5
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
AI_MODERATION_API_KEY=
# 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

View File

@@ -128,6 +128,15 @@ If a task grows beyond scope, STOP and ask.
---
## Git Diff Hygiene
* Do not inspect, summarize, or report diffs for `data/**/*.csv` by default.
* In WSL, CSV files under `data` may appear changed even when their content has not meaningfully changed.
* Ignore `data/**/*.csv` entries in `git status` / `git diff` unless the task explicitly involves CSV data, import/seed data, or the user asks to check them.
* Only mention CSV files in final change summaries if you intentionally changed them or verified they are relevant to the current task.
---
## UI Safety Rules (CRITICAL)
User-facing UI must NEVER contain:

391
DESIGN.md
View File

@@ -4,8 +4,9 @@
- Pokopia Wiki 是一个面向 Pokopia 游戏资料的社区 Wiki。
- 所有人都可以浏览 Wiki 内容。
- 已注册并完成邮箱验证的用户可以创建、编辑、删除 Wiki 内容。
- 前台以 Pokemon、栖息地、物品、材料单、每日 CheckList、Life、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
- 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。
- 前台以 Home 首页、Pokemon、栖息地、物品、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口Logo 导航回到 Home用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
## 技术栈
@@ -59,7 +60,8 @@
- 地图
- 栖息地
- 每日 CheckList Task
- Life 标签
- Life Category
- Game Version
- 支持翻译的字段:
- `name`
- `title`
@@ -99,6 +101,9 @@
- `RESEND_API_KEY`
- `EMAIL_FROM`
- `APP_ORIGIN``FRONTEND_ORIGIN`
- 认证邮件和密码重置邮件使用标准化 Pokopia Wiki 品牌 HTML 外壳;正文、按钮文案、兜底链接提示和纯文本版本仍通过 `surface=email` 的系统级文案维护。
- 后端从 Resend 邮件发送响应 headers 读取日/月发送额度和 rate limit 状态,并维护短期内存 snapshot当 Resend 已报告额度接近用尽、额度耗尽或 API 限流时,认证邮件发送会暂时停止并返回本地化用户提示。
- Resend 额度保护不使用本项目自增发送计数;默认按 Free 计划 `100/day``3000/month` 和 5 封保留量判断,可通过 `RESEND_DAILY_QUOTA_LIMIT``RESEND_MONTHLY_QUOTA_LIMIT``RESEND_QUOTA_RESERVE``RESEND_QUOTA_SNAPSHOT_TTL_MINUTES` 调整。
- 验证邮件包含一次性验证链接。
- 验证 token 只保存 hash并带过期时间和使用状态。
- 只有邮箱已验证的用户可以登录。
@@ -116,14 +121,120 @@
- 当前用户:`id``email``displayName``emailVerified`
- 编辑署名:`id``displayName`
- User Profile
- 登录用户可通过 `/profile` 查看自己的邮箱、邮箱验证状态和显示名
- 当前版本只允许用户更新自己的 `displayName`,不支持头像、公开个人主页、邮箱修改或直接密码修改
- 登录用户可通过 `/profile` 查看自己的账号资料、邮箱验证状态、Referral 信息和公开主页内容
- 任意用户可通过 `/profile/:id` 访问其他用户的公开 Profile
- 公开 Profile 展示用户公开摘要、Life Feeds、Wiki 贡献统计、Like / Reaction 过的 Life Post 和评论过的内容。
- Profile 使用 Tabs 组织Feeds、Contributions、Reactions、Comments仅自己的 `/profile` 额外展示 Account。
- Contributions、Reactions、Comments 在对应 Tab 内提供二级分类Contributions 可按主要内容类型或配置类查看Reactions 可按 reaction 类型查看Comments 可按 Life / Wiki discussion 来源查看。
- 公开用户摘要只包含 `id``displayName` 和公开展示需要的加入时间不公开邮箱、角色、权限、Referral Code、邀请链接、session、token/hash 或内部审计 payload。
- 当前用户可在自己的 `/profile` Account Tab 更新 `displayName`、查看 Referral 信息、复制 Referral 邀请链接,并修改密码;当前版本不支持头像或邮箱修改。
- 当前用户自己的 Profile 顶部摘要区可显示简化 Referral Code 和 Copy Link 入口;完整 Referral 卡片保留 Referral Code、邀请链接复制入口和有效邀请数量这些字段不在公开 Profile 展示。
- 修改密码必须提交当前密码和新密码;成功后更新 password hash、作废未使用的密码重置 token并保留当前 session、删除该用户其他 session。
- 修改密码 API 只返回本地化结果 message不返回 user、session、token/hash 或内部审计 payload。
- 更新显示名后API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。
- 显示名用于编辑署名、讨论和 Life 内容作者展示。
## 用户角色与权限
- Pokopia 使用 RBAC 权限模型:
- 用户通过 `user_roles` 关联到一个或多个角色。
- 角色通过 `role_permissions` 关联到一个或多个权限。
- 后端只按启用状态的权限 key 做访问控制;前端只用于展示或隐藏操作入口,不能作为权限边界。
- 邮箱验证仍是所有写入能力的基础门槛;未验证用户即使拥有角色也不能执行受保护写操作。
- 对外当前用户字段只包含必要信息:
- `id`
- `email`
- `displayName`
- `emailVerified`
- `roles`:只包含 `id``key``name``level`
- `permissions`:当前用户启用权限 key 列表
- 编辑署名仍只展示用户 `id``displayName`不展示角色、权限、邮箱、token/hash 或内部元数据。
- 权限记录存储在 `permissions`
- `key`:稳定权限 key例如 `pokemon.create`
- `name`
- `description`
- `category`
- `enabled`
- `system_permission`:系统初始化权限标记,仅用于管理端识别默认权限
- 角色记录存储在 `roles`
- `key`
- `name`
- `description`
- `level`:用于表达管理层级,数值越大层级越高
- `enabled`
- `system_role`:系统初始化角色标记,仅用于管理端识别默认角色
- 初始角色包含:
- `owner`:最高层级,拥有所有系统权限。
- `admin`:拥有内容、系统配置、用户、角色和权限管理能力。
- `editor`:拥有主要 Wiki 内容创建、更新、排序、上传和社区互动能力,不默认拥有删除、用户、角色或权限管理能力。
- `member`:拥有 Life、讨论发布和删除本人内容的社区能力。
- `viewer`:无写入权限,仅用于显式只读分组。
- Bootstrap 规则:
- 启动时若已有已验证用户但没有任何 `owner` 用户,系统自动将最早完成验证的用户加入 `owner` 角色。
- 若系统还没有 `owner` 用户,首个完成邮箱验证的用户自动加入 `owner` 角色。
- 已完成邮箱验证且没有任何角色的用户默认加入 `editor` 角色;已有角色关系的用户不被覆盖。
- 系统初始化只补齐默认角色、默认权限、Owner 关联和无角色已验证用户的默认 Editor 关联;不覆盖管理员对默认角色/权限元数据或角色权限分配的配置。
- 新建权限会自动关联到 `owner` 角色,确保 Owner 始终拥有可用权限全集;`owner` 角色的权限分配不能在管理端被手动删改。
- 系统必须始终至少保留一个拥有 `admin.permissions.update` 且可管理权限的有效用户;核心 RBAC 管理权限(`admin.access``admin.users.*``admin.roles.*``admin.permissions.*`)不能被禁用或删除;不能删除最后一个 Owner不能移除最后一个 Owner 的关键权限能力。
- 权限管理能力本身也通过权限控制;只有拥有相应管理权限的用户可以查看、新增、编辑、删除权限、角色和用户角色关系。
- 用户角色分配必须同时满足层级边界:
- `PUT /api/admin/users/:id/roles` 的基础权限为 `admin.users.update`
- 调用者只能分配或移除 `roles.level` 严格低于自己最高启用角色等级的角色。
- `owner` 角色只能由当前拥有启用 `owner` 角色且拥有 `admin.users.assign-owner` 权限的调用者分配或移除。
- 非 Owner 即使拥有 `admin.users.update` 或自定义高等级角色,也不能分配或移除 `owner` 角色。
- 管理 API 只返回权限管理所需字段不返回密码、session token hash、verification/reset token hash、内部审计 payload 或调试字段。
## Referral
- Referral 是账号功能,用于让已注册用户邀请新用户加入 Pokopia Wiki。
- 每个用户都有一个稳定的 Referral Code
- 由系统生成。
- 全局唯一。
- 只包含大写英文字母和数字。
- 现有用户在首次读取 Referral 信息或重新注册未验证账号时自动补齐。
- 登录用户可在 `/profile` Account Tab 查看自己的 Referral Code、邀请链接复制入口和有效邀请数量。
- 邀请链接使用前端注册页路径:`/register?ref=CODE`
- 注册页支持:
-`ref` query 自动填入 Referral Code。
- 用户手动输入 Referral Code。
- Referral Code 可为空。
- 注册提交时后端校验 Referral Code
- 无效 Referral Code 拒绝注册并返回本地化错误。
- 用户不能使用自己的 Referral Code如邮箱已存在且该账号已有 Referral Code注册时不能将自己设为邀请人。
- 已存在未验证账号重新注册时,不覆盖已有邀请关系。
- Referral 只有在被邀请用户完成邮箱验证后才计入有效邀请数量。
- Referral 不改变现有邮箱验证要求;用户仍必须验证邮箱后才能登录和编辑。
- 当前版本不提供积分奖励、排行榜、邀请邮件发送、邀请制注册限制、后台统计或公开邀请人资料页。
- Referral API 对外只返回当前用户自己的 Referral 摘要不返回被邀请用户邮箱、token/hash、内部审计字段或被邀请用户明细。
## 滥用防护与限流
- 后端使用 `@fastify/rate-limit` 在应用层执行限流;默认内存存储适用于当前单实例运行,后续多实例部署需要切换到共享存储或反向代理层限流。
- Fastify 默认不信任代理转发 IP部署在可信反向代理后方时可设置 `TRUST_PROXY=true`,让 IP 限流使用代理解析后的客户端 IP。
- 限流 key 不对外暴露;邮箱限流使用规范化小写邮箱生成内部 key用户限流使用当前登录用户 ID路由限流使用 HTTP method + route pattern。
- 触发限流时 API 返回 429 和本地化通用错误文案,并带 `Retry-After` 与 rate limit headers响应不得返回邮箱、用户 ID、内部 key、token/hash 或调试信息。
- 认证入口限流:
- 注册、登录、验证邮箱、请求重置密码、提交重置密码均按 IP + 路由限制为 20 次 / 10 分钟。
- 登录额外按邮箱限制为 5 次 / 15 分钟。
- 注册额外按邮箱限制为 3 次 / 1 小时。
- 请求重置密码额外按邮箱限制为 3 次 / 1 小时,并按 IP + 路由限制为 10 次 / 15 分钟。
- 提交重置密码额外按 IP + 路由限制为 10 次 / 15 分钟。
- 已登录保护路由按 IP + 路由限制为 120 次 / 10 分钟,避免单一来源反复触发鉴权查询。
- 写入路由通用限流:
- 写入路由按 IP + 路由限制为 90 次 / 10 分钟。
- 写入路由按用户 ID + 路由限制为 30 次 / 10 分钟。
- 用户账号资料写入按用户 ID 限制为 20 次 / 1 小时,并有 5 秒冷却时间。
- Wiki 内容写入Pokemon、物品、材料单、栖息地、每日 CheckList、配置项和排序按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间。
- 管理写入(用户角色、角色、权限、语言和系统文案)按用户 ID 限制为 120 次 / 1 小时,并有 2 秒冷却时间。
- 上传按用户 ID 限制为 20 次 / 1 小时,并有 30 秒冷却时间。
- Community 写入:
- Life Post、Life 评论、Wiki 讨论评论和对应删除 / 更新操作按用户 ID 限制为 60 次 / 1 小时,并有 5 秒冷却时间。
- Life reaction 写入按用户 ID 限制为 120 次 / 1 小时,并有 1 秒冷却时间。
- Pokemon Fetch 数据和图片候选查询按 IP + 路由限制为 60 次 / 10 分钟,按用户 ID 限制为 60 次 / 10 分钟,按用户 ID + 路由限制为 30 次 / 10 分钟,并有 1 秒冷却时间。
## Community 编辑与审计
- 已验证用户可以通过前台或管理入口编辑 Wiki 内容。
- 已验证且拥有对应权限的用户可以通过前台或管理入口编辑 Wiki 内容。
- 新增、修改、删除 Wiki 内容时必须写入审计信息。
- 可编辑实体包含:
- Pokemon
@@ -147,10 +258,11 @@
- `created_at`
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
- 编辑历史中的用户信息只展示必要署名不暴露邮箱、token、hash 或内部元数据。
- 编辑署名、编辑历史署名、Life 作者和讨论作者可链接到对应公开 Profile。
## Wiki 图片上传
- 已验证用户可以为以下 Wiki 实体上传图片:
- 已验证且拥有对应上传权限的用户可以为以下 Wiki 实体上传图片:
- Pokemon
- 物品图标
- 栖息地
@@ -172,7 +284,7 @@
- `created_by_user_id`
- `created_at`
- 实体表只保存当前显示图片的相对路径;历史上传记录不会因为切换当前图片而删除。
- API 对外返回图片展示所需字段:`path``url`、上传时间和上传者必要署名;不返回服务器绝对文件路径或内部存储元数据
- 公共 API 对外返回图片上传历史只包含:`id``path``url``uploadedAt` 和上传者必要署名 `uploadedBy`;不返回 `entity_name`、原始文件名、MIME、文件大小、服务器绝对文件路径或内部存储元数据。若编辑接口确需实体关联只能在受保护编辑接口返回 `entityId`
- 图片上传本身不直接改变实体内容;用户仍需保存实体编辑表单后,当前图片选择才成为实体行为并写入现有编辑审计。
- Docker 运行时上传目录必须使用 volume 持久化,避免重新 build 后丢失用户上传图片。
@@ -180,14 +292,49 @@
- Pokemon、物品、材料单、栖息地详情页支持讨论。
- 所有人都可以浏览实体讨论。
- 已注册并完成邮箱验证的用户可以发表评论,并回复顶层评论。
- 已注册并完成邮箱验证且拥有 `discussions.comments.create` 权限的用户可以发表评论,并回复顶层评论。
- 讨论回复只支持一层回复,不做无限嵌套。
- 评论作者可以删除自己的评论;删除后正文不再展示,已有回复保留在原位置。
- 评论作者拥有 `discussions.comments.delete` 权限时可以删除自己的评论;拥有 `discussions.comments.delete-any` 权限的用户可以删除其他用户评论;删除后正文不再展示,已有回复保留在原位置。
- 被删除实体的讨论会随实体删除一并清理。
- 讨论按创建时间正序展示。
- 讨论列表按顶层评论分页读取,支持 `limit` / `cursor`;每页顶层评论携带其一层回复,响应包含 `items``nextCursor``hasMore``total`
- 实体讨论评论和回复必须进入 AI 审核;未审核通过的评论不向普通访客公开。
- 作者本人和拥有 `discussions.comments.delete-any` 权限的管理用户可看到相关评论的审核状态,并可触发重新审核。
- 审核状态包括:`unreviewed``reviewing``approved``rejected``failed`;前端面向用户展示为未审核、审核中、审核通过、审核不通过、审核失败。
- 新增或更新审核目标时先进入不可公开状态;只有 AI 审核通过后才进入普通公开讨论列表。
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
- AI 审核会自动识别评论适合的语言区,语言区使用启用状态的 `languages.code`,但不影响系统 UI 语言。
- 讨论列表支持按语言区读取;`language=all` 或不传语言参数时读取全部已公开语言区,传入具体语言 code 时只读取对应语言区。
- 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`
- API 对外只返回评论作者的 `id``displayName`
- API 不返回邮箱、token/hash、内部调试字段、`deleted_at``deleted_by_user_id` 等内部删除字段。
- API 不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误、`deleted_at``deleted_by_user_id` 等内部字段。
## AI 审核
- Life Post、Life Comment、实体讨论评论和实体讨论回复都是用户生成内容必须经过 AI 审核。
- AI 审核支持 Gemini-compatible `generateContent` API 和 OpenAI-compatible `chat/completions` APIEnd Point、API Key、模型、API 格式、鉴权方式、RPM 限流和启用状态可由拥有 `admin.ai-moderation.*` 权限的管理员配置。
- 默认使用 Gemini-compatible `generateContent` API 和 Bearer token 鉴权,以兼容 NewAPI 等转发服务;鉴权方式仍支持 Gemini 原生 query `key`
- 后端日志必须对 API Key 脱敏,且不回显给前端。
- 默认 End Point 为 `https://ai.example.com/v1beta`API Key 不写入前端包,不回显给前端,管理 API 只返回是否已配置。
- 管理配置存储在后端受控表中API 不返回 API Key 明文、模型原始响应、prompt、请求体、内部错误堆栈或调试字段。
- 后端日志可以记录安全脱敏后的第三方 HTTP 状态和错误摘要,用于排查 Endpoint、模型或鉴权配置问题日志不得包含 API Key、审核 prompt 或用户正文。
- 服务端审核请求必须限流,按配置的每分钟请求数串行发送,避免触发第三方 API RPM 限制。
- 为节省 Token
- 审核只发送待审核正文、允许的语言 code 和最小必要规则,不发送用户资料、页面上下文、审计 payload 或无关业务数据。
- 对相同正文和相同 API 配置/模型使用内容 hash 缓存审核结果,避免重复调用 AI。
- 审核请求使用结构化 JSON 输出、低温度和较小输出 token 上限。
- 安全要求:
- 用户正文必须作为不可信内容处理,不能作为系统指令或开发指令执行。
- 不允许通过用户正文关闭、绕过或降低安全审核。
- 不使用会关闭 Gemini 安全拦截的配置;如果 Gemini 安全机制拦截 prompt 或候选结果,该内容按审核不通过处理。
- OpenAI-compatible 转发模式下仍必须使用独立系统指令和结构化 JSON 解析;模型未返回明确合法结果时按审核失败处理。
- 模型返回格式不合法、网络失败、超时或限流失败时,内容标记为审核失败,不得公开。
- 只有 `approved` 状态可向普通访客公开;`unreviewed``reviewing``rejected``failed` 均不可公开。
- 审核语言区独立于系统 UI 语言:
- 前台可选择 All languages 或具体语言区浏览内容。
- 发布时客户端可传当前语言区作为 hint但最终语言区由服务端 AI 审核结果决定。
- 如果 AI 无法识别到启用语言区,回退到默认语言。
- 审核状态对普通访客不用于解释内部流程;只在作者本人或有管理权限的用户需要处理内容时展示。
## 全局配置数据
@@ -237,16 +384,28 @@
- 名称
- 用于栖息地中 Pokemon 出现地点。
### Life 标签
### Life Category
- 名称
- 是否默认选中:最多一个 Life Category 可设为默认;新建 Life Post 时默认选中该分类。
- 是否可评分Rateable Life Category 下的 Life Post 可由用户进行 1-5 星评分。
- 用于 Life Post 分类展示和 Feed 筛选。
### Game Version
- 版本号 / 名称
- ChangeLog可为空用于说明该版本主要变化。
- 用于 Life Post 发布时选择关联的游戏版本。
- Life Post 可不选择游戏版本;未选择时前台不展示版本号。
- Game Version 支持管理端创建、编辑、删除和排序。
## Pokemon
Pokemon 可配置:
- ID
- 内部 ID`id`,系统唯一,用于路由、外键和实体关联;普通 Pokemon 新建时优先与展示 ID 一致,活动 Pokemon 由系统分配唯一内部 ID
- 展示 ID`display_id`,详情页、列表卡片和选择器中显示为 `#ID`
- 是否为活动物品:`is_event_item`
- 名称
- Genus可为空支持翻译
- 介绍 / Details可为空支持翻译
@@ -269,10 +428,12 @@ Pokemon 可配置:
- 翻译
- 排序
Pokemon 的展示 ID 在普通 Pokemon 和活动 Pokemon 之间可以重复,例如允许同时存在普通 `#1 妙蛙种子` 和活动 `#1 毽子草`。数据库只要求同一个 `display_id + is_event_item` 组合唯一;前端路由和实体关联必须继续使用内部 `id`,不能使用展示 ID 作为路由或外键。
Pokemon 编辑表单使用标签页组织字段:
- 编辑表单提供 Fetch data 功能:
- 已验证用户可输入 data identifier 或 Pokemon ID从同一个搜索输入查询基础资料或图片候选。
- 已验证且拥有 `pokemon.fetch` 权限的用户可输入 data identifier 或 Pokemon ID从同一个搜索输入查询基础资料或图片候选。
- Fetch data 从仓库 `data/` CSV 查询基础资料并填入当前表单。
- Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称结果只展示 `#ID`、名称和 identifier。
- Fetch 搜索结果默认关闭只在用户主动点击输入框或输入内容时展开Escape、失焦 / 点击外部、选择结果后关闭。
@@ -283,7 +444,7 @@ Pokemon 编辑表单使用标签页组织字段:
- Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`Type ID 与 `data/localized_type_name.csv``frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。
- Type 展示使用 `frontend/public/types/small/{typeId}.png` 图标并保留文字名称。
- 编辑表单提供 Pokemon 图片选择功能:
- 已验证用户通过 Fetch data 的同一个 data identifier / Pokemon ID 输入框,从 `https://pokesprite.tootaio.com/sprites/` 静态图片树查询对应 Pokemon 的可用图片候选。
- 已验证且拥有 `pokemon.fetch` 权限的用户通过 Fetch data 的同一个 data identifier / Pokemon ID 输入框,从 `https://pokesprite.tootaio.com/sprites/` 静态图片树查询对应 Pokemon 的可用图片候选。
- 图片候选只使用 `/sprites/pokemon/...` 相对路径,后端按固定资源族生成候选并用 `HEAD` 校验存在性;不保存任意外部 URL。
- 图片选择不直接创建或更新 Pokemon用户仍需通过 Save 保存,保存时沿用现有编辑审计。
- 图片选择界面使用 Pokédex 风格:上方显示当前选择的大图,大图下方显示版本、状态和描述,再下方以缩略图网格展示同一 Pokemon 的不同风格 / 版本 / 状态。
@@ -319,19 +480,19 @@ Pokemon 列表功能:
Pokemon 详情页展示:
- 基本信息
- 已配置图片时,详情主内容在六维 Stats 右侧展示正方形居中的 Pokédex 风格图片;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情;未配置图片时不显示图片区
- 详情主内容在六维 Stats 右侧始终保留正方形图片区;已配置图片时展示居中的 Pokédex 风格图片,未配置图片时展示默认 Poké Ball 占位符;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情。
- 主内容顶部按以下布局展示:
- 左上Genus & Details无区块标题如有 Genus先展示 Genus再以分割线连接 Details 内容
- 左下Height / Weight 与 Types 按 2:1 比例并排Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开每组按英制、分割线、公制、标签上下排列Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示
- 右侧:六维 Stats已配置图片时图片展示在 Stats 右侧
- 右侧:六维 Stats图片或默认占位符展示在 Stats 右侧
- 六维使用 ProgressBar 展示,最大值按 150 计算。
- 特长
- 特长掉落物品
- 特长掉落物品:展示掉落物品图标;未配置图标时显示默认物品标记占位符
- 喜欢的环境
- 喜欢的东西
- 相关 Pokemon与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示
- 出现的栖息地
- 相关 Pokemon与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示;每项展示物品图标,未配置图标时显示默认物品标记占位符
- 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符
- 最后编辑信息
- 讨论
- 编辑历史:通过详情页 Tabs 展示
@@ -341,6 +502,7 @@ Pokemon 详情页展示:
物品可配置:
- 名称
- 是否为活动物品:`is_event_item`
- 分类:必填
- 用途:可为空
- 入手方式:可多选
@@ -368,16 +530,17 @@ Pokemon 详情页展示:
物品详情页展示:
- 基本信息
- 图标图片和图片上传历史
- 当前图标图片;未配置图标时展示默认物品标记占位符
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
- 分类
- 用途
- 入手方式
- 客制化
- 标签
- 关联材料单
- 作为材料出现的材料单
- 相关栖息地
- 相关 Pokemon 掉落
- 关联材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符
- 作为材料出现的材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符
- 相关栖息地:展示栖息地图片或默认栖息地标记占位符,并展示配方材料物品图标
- 相关 Pokemon 掉落:展示 Pokemon 图片;未配置图片时显示默认 Poké Ball 占位符
- 最后编辑信息
- 讨论
- 编辑历史
@@ -409,9 +572,10 @@ Pokemon 详情页展示:
材料单详情页展示:
- 结果物品
- 结果物品图片或默认材料单标记占位符;顶部概览卡片不显示 `Image` / `Details` 通用区块标题
- 结果物品名称、分类和用途;`GET /api/recipes/:id``item` 字段返回展示所需的 `id``name``image``category``usage`
- 入手方式
- 需要材料列表
- 需要材料列表:展示材料物品图标;未配置图标时显示默认物品标记占位符
- 最后编辑信息
- 讨论
- 编辑历史
@@ -421,6 +585,7 @@ Pokemon 详情页展示:
栖息地可配置:
- 名称
- 是否为活动物品:`is_event_item`
- 配方:多项物品 + 数量
- 可出现的 Pokemon
- 图片:通过通用 Wiki 图片上传维护当前图片和历史上传记录
@@ -450,9 +615,10 @@ Pokemon 出现配置:
栖息地详情页展示:
- 图片和图片上传历史
- 配方列表
- 可能出现的 Pokemon 列表
- 当前图片;未配置图片时展示默认栖息地标记占位符
- 顶部按图片 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
- 配方列表:展示材料物品图标;未配置图标时显示默认物品标记占位符
- 可能出现的 Pokemon 列表:展示 Pokemon 图片;未配置图片时显示默认 Poké Ball 占位符
- 出现时间
- 出现天气
- 稀有度
@@ -479,8 +645,8 @@ Pokemon 出现配置:
管理行为:
- 已验证用户可新增、编辑、删除 Task。
- 已验证用户可通过 Handle 拖拽排序。
- 已验证且拥有对应 CheckList 权限的用户可新增、编辑、删除 Task。
- 已验证且拥有 `checklist.order` 权限的用户可通过 Handle 拖拽排序。
## Life
@@ -489,47 +655,67 @@ Life 是社区生活分享信息流,类似轻量社交动态。
Life Post 可配置:
- Post 内容正文
- 标签:使用 Life 标签配置,可多选
- Category:使用 Life Category 配置,必须且只能选择 1 个
- Game Version可为空使用 Game Version 配置;有值时在 Post 卡片展示版本号。
- 创建者、最后编辑者、创建时间、最后编辑时间
- 评论
- 评论回复:仅支持回复顶层评论,不做无限嵌套
- Reactions`like``helpful``fun``thanks`
- RatingsRateable Category 下的 Post 支持 1-5 星评分;每个用户每条 Post 最多一条评分,重复评分会替换原评分。
前台行为:
- 所有人都可以浏览 Life 信息流。
- 信息流按创建时间倒序展示。
- 已注册并完成邮箱验证的用户可以发布 Life Post。
- 作者本人可以编辑、删除自己的 Life Post删除 Life Post 使用软删除。
- 已注册并完成邮箱验证的用户发布或编辑 Life Post 时可以选择一个或多个 Life 标签
- 已注册并完成邮箱验证的用户可以评论 Life Post并回复顶层评论
- 评论作者可以删除自己的评论;删除评论后正文不再展示,已有回复保留在原位置
- 已软删除的 Life Post 不出现在信息流、搜索或标签筛选结果中,也不能继续编辑、评论或设置 Reaction
- 已注册并完成邮箱验证且拥有 `life.posts.create` 权限的用户可以发布 Life Post。
- 作者本人拥有 `life.posts.update` / `life.posts.delete` 权限时可以编辑、删除自己的 Life Post删除 Life Post 使用软删除。
- 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post
- 已注册并完成邮箱验证且拥有 `life.posts.create``life.posts.update` 权限的用户发布或编辑 Life Post 时必须选择 1 个 Life Category
- 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post并回复顶层评论
- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除评论后正文不再展示,已有回复保留在原位置
- 已软删除的 Life Post 不出现在信息流、搜索或 Category 筛选结果中,也不能继续编辑、评论或设置 Reaction。
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
- 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`
- Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口按顶层评论正序读取,每页顶层评论携带其一层回复
- 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
- 已注册并完成邮箱验证且拥有 `life.ratings.set` 权限的用户可以对 Rateable Life Post 设置或取消 1-5 星评分;非 Rateable Category 下的 Post 不显示评分控件,也不能通过 API 评分。
- Life Post 展示评分时只展示平均分、评分人数和当前用户自己的评分;不展示其他用户的评分明细。
- 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。
- Feed 使用 Tabs 展示 Life 标签筛选;包含 All 和后台配置的 Life 标签;点击标签后按该标签筛选,搜索和标签筛选可以同时生效。
- Feed 使用 Tabs 展示 Life Category 筛选;包含 All 和后台配置的 Life Category点击 Category 后按该 Category 筛选,搜索和 Category 筛选可以同时生效。
- Feed 使用语言筛选展示 All languages 和启用语言;语言区筛选独立于系统 UI 语言搜索、Category 和语言筛选可以同时生效。
- Feed 支持按 Game Version 筛选All versions 表示不过滤版本。
- Feed 支持 Rateable 筛选All 表示不过滤Rateable only 只展示可评分 Category 下的 Post。
- Feed 支持排序Latest 默认按创建时间倒序Oldest 按创建时间正序Top rated 按平均评分倒序,同分时按创建时间倒序。
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
- 当前没有图片上传、转发置顶或单独审核流程
- 当前没有图片上传、转发置顶。
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
- 作者本人和拥有对应 `*-any` 管理权限的用户可以看到相关内容的审核状态,并可触发重新审核。
- Life Post 必须展示审核状态:审核中、未审核、审核失败、审核不通过;审核通过也可作为状态展示。
- 新增或更新 Life Post 后先进入不可公开状态AI 审核通过后才出现在普通公开 Feed。
- Life Comment 和回复审核通过后才出现在普通公开评论列表、评论数量和评论预览中。
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`
API 暴露边界:
- Life Post 作者信息只返回 `id``displayName`
- Life Post 标签只返回 `id` 和按当前语言解析后的 `name`
- Life Post Category 只返回 `id` 和按当前语言解析后的 `name`
- Life Post Game Version 只返回 `id`、展示用 `name` 和可展示 `changeLog`;未选择版本时返回 `null`
- Life Post Rating 只返回 `ratingAverage``ratingCount` 和当前用户自己的 `myRating`;不返回其他用户的评分明细。
- Life Post 可返回面向用户展示所需的审核状态、审核语言区和是否可重审不返回内部错误、AI prompt、模型响应或 retry 细节。
- Life Comment 作者信息只返回 `id``displayName`
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction不返回其他用户的 Reaction 明细。
- Life Post 列表 API 返回分页结果:`items``nextCursor``hasMore``cursor` 是不透明分页令牌。
- API 返回邮箱、token/hash、内部调试字段或不必要的审计 payload
- Life Post 列表 API 返回分页结果:`items``nextCursor``hasMore``cursor` 是不透明分页令牌。每个 Life Post 的评论字段只包含已公开或当前用户可见评论的 `commentCount``commentPreview`,不内嵌完整评论列表。
- Life Comment 列表 API 返回分页结果:`items``nextCursor``hasMore``total``cursor` 是不透明分页令牌;普通访客只读取审核通过评论
- API 不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误或不必要的审计 payload。
- API 不返回 Life Post 的 `deleted_at``deleted_by_user_id` 等内部软删除字段。
- 非作者能编辑或删除其他用户的 Life Post。
- 非作者不能删除其他用户的 Life Comment。
- 非作者只有拥有对应 `*-any` 管理权限时才能编辑或删除其他用户的 Life Post 或 Life Comment
## 开发中入口
以下前台公开入口当前仅展示“正在开发中”占位页,不提供数据模型、后端 API、编辑表单、管理入口或排序能力
- Automation未来用于分享自动化基地亦称工厂创建方案、材料产出、所需 Pokemon、生产顺序和共同喜好物品。
- Dish
- Events
- Actions游戏内快捷动作例如挥手、跳舞等。
@@ -538,11 +724,32 @@ API 暴露边界:
这些开发中入口在主导航和占位页中显示状态 Badge便于用户识别当前功能状态。
## 法律页面、版权与来源声明
- 前台提供公开静态法律页面:
- `/privacy-policy`:隐私政策。
- `/terms-of-service`:服务条款。
- `/disclaimers`:免责声明、第三方来源和权利归属说明。
- 法律页面只展示站点政策、来源和版权相关文案,不提供编辑表单、后端 API、数据库模型、管理入口或用户提交流程。
- 全局 `AppShell` 页脚展示:
- `Copyright {year} Tootaio Studio. All rights reserved.`
- Privacy Policy、Terms of Service、Disclaimers 链接。
- PokeAPI 数据与图片资源、社区贡献和 Pokemon 相关权利归属的简短说明。
- Pokopia Wiki 不是 Nintendo、The Pokemon Company、Game Freak、Creatures、PokeAPI 或 `pokopiawiki.com` 的官方、附属、赞助或背书项目。
- Pokopia Wiki 会使用或参考 PokeAPI 数据、PokeAPI 图片资源、`https://www.pokopiawiki.com/` 和其他公开资料;页面必须清楚说明引用来源不代表从属、赞助、背书或官方认可。
- Pokemon 相关名称、图片、标志、角色和游戏素材归其各自权利人所有。
- 法律页面和页脚文案必须通过系统级文案 catalog 管理,并支持现有语言回退机制。
## 前端交互与 UI
- UI 风格以 `DesignGuidelines.html` 为准。
- 页面结构以 `AppShell``PageHeader`、列表、详情区和管理区为核心。
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
- 配置System config。
- 内容Daily CheckList、Pokemon、物品、材料单和栖息地的维护、排序或删除入口。
- 本地化Languages、System wordings。
- 访问权限Users、Roles、Permissions。
- 登录用户的侧边栏账号入口进入 `/profile`User Profile 属于账号入口,不作为 Wiki 主内容导航项。
- 页面级分类、筛选或辅助内容切换使用 Tabs避免在内容页继续增加侧边栏。
- 导航和主要操作使用图标增强识别。
@@ -562,6 +769,29 @@ API 暴露边界:
- Life 使用信息流顶部 New Post / 编辑按钮打开普通 Modal 发布与编辑,不使用路由驱动 Modal。
- 进入或关闭编辑 Modal 时应保留底层页面上下文,不进行不必要的滚动跳转。
- 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。
- 权限不足时前端可以隐藏或禁用对应操作;后端必须返回本地化 403并且不得在 UI 暴露内部权限 key 作为普通用户提示。
## Technical SEO
- 前端发布基础 SEO 静态资源:
- `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。
- 主要公开浏览入口可索引:
- `/pokemon`
- `/habitats`
- `/items`
- `/recipes`
- `/checklist`
- `/life`
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
- 新建页面 canonical 指向对应列表页;编辑 Modal 路由 canonical 指向对应实体详情页。
- SEO metadata 只能使用公开业务数据和系统文案;不得暴露邮箱、权限 key、token/hash、内部审计 payload、调试信息或实现说明。
- 多语言 metadata 使用当前前端语言和系统文案回退机制;当前没有语言专属 URL因此暂不输出 `hreflang`
## API 概览
@@ -579,8 +809,13 @@ API 暴露边界:
- `GET /api/items/:id`
- `GET /api/recipes`
- `GET /api/recipes/:id`
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `tagId` 按 Life 标签筛选
- `GET /api/discussions/:entityType/:entityId/comments`:读取实体讨论;`entityType` 支持 `pokemon``items``recipes``habitats`
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort``latest``oldest``top-rated`
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
- `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。
- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;支持 `language` 按审核语言区筛选;`entityType` 支持 `pokemon``items``recipes``habitats`
认证 API
@@ -591,37 +826,61 @@ API 暴露边界:
- `POST /api/auth/reset-password`
- `GET /api/auth/me`
- `PATCH /api/auth/me`:更新当前用户显示名;需要登录;只接收并返回当前用户必要字段。
- `GET /api/auth/referral`:读取当前用户 Referral 摘要;需要登录;返回 `referral`,其中只包含 `code``url``verifiedReferralCount`
- `POST /api/auth/logout`
已验证用户编辑 API
权限管理 API
- Pokemon、栖息地、物品、材料单的创建、更新、删除。
- `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;需要已验证用户;只返回 `id``identifier``name`
- `POST /api/pokemon/fetch`:按 data identifier 或 Pokemon ID 查询 CSV 资料并填充 Pokemon 编辑表单;需要已验证用户;不直接保存 Pokemon。
- `POST /api/pokemon/image-options`:按 data identifier 或 Pokemon ID 查询 pokesprite 可用图片候选;需要已验证用户;只返回 `id``identifier` 和图片候选列表。
- `POST /api/uploads/:entityType`:上传 Wiki 图片;需要已验证用户;`entityType` 支持 `pokemon``items``habitats`;返回图片历史记录项和可展示 URL。
- Life Post 的创建,以及作者本人对 Life Post 的更新、删除。
- `GET /api/admin/users`:需要 `admin.users.read`
- `PUT /api/admin/users/:id/roles`:需要 `admin.users.update`;分配或移除 `owner` 还需要调用者本身是 Owner 且拥有 `admin.users.assign-owner`;所有角色变更受 `roles.level` 层级限制
- `GET /api/admin/roles`:需要 `admin.roles.read`
- `POST /api/admin/roles`:需要 `admin.roles.create`
- `PUT /api/admin/roles/:id`:需要 `admin.roles.update`
- `DELETE /api/admin/roles/:id`:需要 `admin.roles.delete`
- `PUT /api/admin/roles/:id/permissions`:需要 `admin.roles.update`
- `GET /api/admin/permissions`:需要 `admin.permissions.read`
- `POST /api/admin/permissions`:需要 `admin.permissions.create`
- `PUT /api/admin/permissions/:id`:需要 `admin.permissions.update`
- `DELETE /api/admin/permissions/:id`:需要 `admin.permissions.delete`
受权限保护的编辑 API
- Pokemon、栖息地、物品、材料单的创建、更新、删除分别需要对应实体的 `create``update``delete` 权限。
- `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;需要 `pokemon.fetch`;只返回 `id``identifier``name`
- `POST /api/pokemon/fetch`:按 data identifier 或 Pokemon ID 查询 CSV 资料并填充 Pokemon 编辑表单;需要 `pokemon.fetch`;不直接保存 Pokemon。
- `POST /api/pokemon/image-options`:按 data identifier 或 Pokemon ID 查询 pokesprite 可用图片候选;需要 `pokemon.fetch`;只返回 `id``identifier` 和图片候选列表。
- `POST /api/uploads/:entityType`:上传 Wiki 图片;需要对应实体上传权限;`entityType` 支持 `pokemon``items``habitats`;返回图片历史记录项和可展示 URL。
- Life Post 的创建,以及作者本人对 Life Post 的更新、删除,需要对应 `life.posts.*` 权限;管理他人内容需要对应 `*-any` 权限。
- `POST /api/life-posts`
- `PUT /api/life-posts/:id`
- `DELETE /api/life-posts/:id`
- Life Comment 的创建,以及作者本人对 Life Comment 的删除。
- `POST /api/life-posts/:id/moderation/retry`
- Life Comment 的创建,以及作者本人对 Life Comment 的删除,需要对应 `life.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。
- `POST /api/life-posts/:postId/comments`
- `POST /api/life-posts/:postId/comments/:commentId/replies`
- `DELETE /api/life-comments/:id`
- 实体讨论评论的创建、回复,以及作者本人对评论的删除。
- `POST /api/life-comments/:id/moderation/retry`
- 实体讨论评论的创建、回复,以及作者本人对评论的删除,需要对应 `discussions.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。
- `POST /api/discussions/:entityType/:entityId/comments`
- `POST /api/discussions/:entityType/:entityId/comments/:commentId/replies`
- `DELETE /api/discussions/comments/:id`
- `POST /api/discussions/comments/:id/moderation/retry`
- Life Reaction 的设置、替换和取消。
- `PUT /api/life-posts/:id/reaction`
- `DELETE /api/life-posts/:id/reaction`
- 每日 CheckList 的创建、更新、删除、排序
- 全局配置项的创建、更新、删除、排序。
- 语言的创建、更新、删除、排序。
- 系统级文案的查看和更新
- `GET /api/admin/system-wordings`
- `PUT /api/admin/system-wordings/:key`
- Pokemon、物品、材料单、栖息地的列表排序
- Life Rating 的设置、替换和取消
- `PUT /api/life-posts/:id/rating`
- `DELETE /api/life-posts/:id/rating`
- 每日 CheckList 的创建、更新、删除、排序需要对应 `checklist.*` 权限
- 全局配置项的查看、创建、更新、删除、排序需要对应 `admin.config.*` 权限。
- 语言的查看、创建、更新、删除、排序需要对应 `admin.languages.*` 权限。
- 系统级文案的查看和更新需要对应 `admin.wordings.*` 权限
- `GET /api/admin/system-wordings`
- AI 审核配置的查看和更新需要对应 `admin.ai-moderation.*` 权限。
- `GET /api/admin/ai-moderation`
- `PUT /api/admin/ai-moderation`
- `PUT /api/admin/system-wordings/:key`
- Pokemon、物品、材料单、栖息地的列表排序需要对应实体的 `order` 权限。
## 开发与验证

View File

@@ -1,11 +1,17 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY backend/package.json ./backend/package.json
COPY frontend/package.json ./frontend/package.json
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate && pnpm install --frozen-lockfile --filter @pokopia/backend...
COPY backend ./backend
COPY data ./data
COPY system-wordings.ts ./system-wordings.ts
RUN mkdir -p /app/uploads && chown -R node:node /app
ENV NODE_ENV=production
WORKDIR /app/backend
COPY backend/package.json ./
RUN corepack enable && pnpm install
COPY backend/. .
COPY data /app/data
COPY package.json /app/package.json
COPY system-wordings.ts /app/system-wordings.ts
USER node
EXPOSE 3001
CMD ["pnpm", "run", "start"]

File diff suppressed because it is too large Load Diff

View File

@@ -13,16 +13,17 @@
"test": "node --test --import tsx tests/*.test.ts"
},
"dependencies": {
"@fastify/cors": "latest",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"fastify": "latest",
"pg": "latest"
"@fastify/cors": "11.2.0",
"@fastify/multipart": "10.0.0",
"@fastify/rate-limit": "10.3.0",
"@fastify/static": "9.1.3",
"fastify": "5.8.5",
"pg": "8.20.0"
},
"devDependencies": {
"@types/node": "latest",
"@types/pg": "latest",
"tsx": "latest",
"typescript": "latest"
"@types/node": "25.6.0",
"@types/pg": "8.20.0",
"tsx": "4.21.0",
"typescript": "6.0.3"
}
}

1008
backend/src/aiModeration.ts Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -21,56 +21,6 @@ const wordingKeyPattern = /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$/;
const placeholderPattern = /\{([A-Za-z0-9_]+)\}/g;
const surfaces = new Set<SystemWordingSurface>(['frontend', 'backend', 'email']);
const legacyMessageKeys = new Map<string, string>([
['Record does not exist', 'server.validation.recordMissing'],
['Language code is invalid', 'server.validation.languageCodeInvalid'],
['Language name is required', 'server.validation.languageNameRequired'],
['Default language must be English', 'server.validation.defaultLanguageMustBeEnglish'],
['Default language must be enabled', 'server.validation.defaultLanguageMustBeEnabled'],
['Language not found', 'server.validation.languageNotFound'],
['A default language is required', 'server.validation.defaultLanguageRequired'],
['Default language cannot be deleted', 'server.validation.defaultLanguageCannotBeDeleted'],
['Please select a language', 'server.validation.selectLanguage'],
['Language does not exist', 'server.validation.languageDoesNotExist'],
['Pokemon identifier is required', 'server.validation.pokemonIdentifierRequired'],
['Pokemon type data is unavailable', 'server.validation.pokemonTypeDataUnavailable'],
['Pokemon data was not found', 'server.validation.pokemonDataNotFound'],
['Pokemon image path is invalid', 'server.validation.pokemonImagePathInvalid'],
['Please enter a task', 'server.validation.taskRequired'],
['Please select a task', 'server.validation.selectTask'],
['Task does not exist', 'server.validation.taskDoesNotExist'],
['Please enter a post', 'server.validation.postRequired'],
['Post is too long', 'server.validation.postTooLong'],
['Please enter a comment', 'server.validation.commentRequired'],
['Comment is too long', 'server.validation.commentTooLong'],
['Reaction is invalid', 'server.validation.reactionInvalid'],
['Cursor is invalid', 'server.validation.cursorInvalid'],
['Tag is invalid', 'server.validation.tagInvalid'],
['Entity type is invalid', 'server.validation.entityTypeInvalid'],
['Record is invalid', 'server.validation.recordInvalid'],
['Comment is invalid', 'server.validation.commentInvalid'],
['Please select a record', 'server.validation.selectRecord'],
['Choose at least 1 type', 'server.validation.typeMin'],
['Choose at most 2 types', 'server.validation.typeMax'],
['Choose at most 2 specialities', 'server.validation.skillMax'],
['Choose at most 6 favourites', 'server.validation.favoriteMax'],
['Drop items must be linked to selected specialities', 'server.validation.dropItemSelectedSkill'],
['Pokemon ID is required', 'server.validation.pokemonIdRequired'],
['Pokemon name is required', 'server.validation.pokemonNameRequired'],
['Height must be a non-negative number', 'server.validation.heightNonNegative'],
['Weight must be a non-negative number', 'server.validation.weightNonNegative'],
['Ideal Habitat is required', 'server.validation.environmentRequired'],
['This speciality cannot have a drop item', 'server.validation.skillNoDrop'],
['Habitat name is required', 'server.validation.habitatNameRequired'],
['Usage is required', 'server.validation.usageRequired'],
['Item name is required', 'server.validation.itemNameRequired'],
['Category is required', 'server.validation.categoryRequired'],
['An item with a recipe cannot be marked as recipe-free', 'server.validation.recipeFreeWithRecipe'],
['Item is required', 'server.validation.itemRequired'],
['This item is marked as recipe-free', 'server.validation.recipeFreeItem'],
['Name is required', 'server.validation.nameRequired']
]);
function validationError(message: string): ValidationError {
const error = new Error(message) as ValidationError;
error.statusCode = 400;
@@ -145,22 +95,6 @@ function normalizePlaceholders(value: unknown): string[] {
return Array.isArray(value) ? value.map((item) => String(item)).sort() : [];
}
function legacyMessageKey(message: string): string | null {
if (message.startsWith('server.') || message.startsWith('email.')) {
return message;
}
if (message.endsWith(' must be a non-negative integer')) {
return 'server.validation.statNonNegative';
}
if (message.endsWith(' is empty')) {
return 'server.validation.pokemonDataFileEmpty';
}
if (message.startsWith('Pokemon data file ') && message.endsWith(' is unavailable')) {
return 'server.validation.pokemonDataFileUnavailable';
}
return legacyMessageKeys.get(message) ?? null;
}
export async function syncSystemWordingCatalog(): Promise<void> {
const entries = systemWordingCatalogEntries();
const client = await pool.connect();
@@ -232,8 +166,7 @@ export async function systemMessage(
}
export async function localizedStatusMessage(locale: string, message: string): Promise<string> {
const key = legacyMessageKey(message);
return key ? systemMessage(locale, key) : message;
return message.startsWith('server.') || message.startsWith('email.') ? systemMessage(locale, message) : message;
}
export async function getSystemWordings(locale: string) {

View File

@@ -9,27 +9,15 @@ export type UploadEntityType = 'pokemon' | 'items' | 'habitats';
export type EntityImageUpload = {
id: number;
entityType: UploadEntityType;
entityId: number | null;
entityName: string;
path: string;
url: string;
originalFilename: string;
mimeType: string;
byteSize: number;
uploadedAt: Date;
uploadedBy: { id: number; displayName: string } | null;
};
type UploadRow = {
id: number;
entityType: UploadEntityType;
entityId: number | null;
entityName: string;
path: string;
originalFilename: string;
mimeType: string;
byteSize: number;
uploadedAt: Date;
uploadedBy: { id: number; displayName: string } | null;
};
@@ -164,7 +152,10 @@ function hasValidImageSignature(mimeType: string, buffer: Buffer): boolean {
function mapUploadRow(row: UploadRow): EntityImageUpload {
return {
...row,
id: row.id,
path: row.path,
uploadedAt: row.uploadedAt,
uploadedBy: row.uploadedBy,
url: uploadImageUrl(row.path)
};
}
@@ -213,13 +204,7 @@ export async function saveEntityImageUpload(
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING
id,
entity_type AS "entityType",
entity_id AS "entityId",
entity_name AS "entityName",
path,
original_filename AS "originalFilename",
mime_type AS "mimeType",
byte_size AS "byteSize",
created_at AS "uploadedAt",
json_build_object('id', $8::integer, 'displayName', $9::text) AS "uploadedBy"
`,
@@ -238,13 +223,7 @@ export async function listEntityImageUploads(entityType: UploadEntityType, entit
`
SELECT
upload.id,
upload.entity_type AS "entityType",
upload.entity_id AS "entityId",
upload.entity_name AS "entityName",
upload.path,
upload.original_filename AS "originalFilename",
upload.mime_type AS "mimeType",
upload.byte_size AS "byteSize",
upload.created_at AS "uploadedAt",
CASE
WHEN u.id IS NULL THEN NULL

View File

@@ -5,8 +5,6 @@ services:
POSTGRES_DB: pokopia
POSTGRES_USER: pokopia
POSTGRES_PASSWORD: pokopia
ports:
- "5432:5432"
volumes:
- postgres18_data:/var/lib/postgresql
healthcheck:
@@ -22,14 +20,15 @@ services:
environment:
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
BACKEND_PORT: 3001
FRONTEND_ORIGIN: http://localhost:3000
APP_ORIGIN: http://localhost:3000
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: http://localhost:3001
BACKEND_PUBLIC_ORIGIN: ${BACKEND_PUBLIC_ORIGIN:-http://localhost:20016}
RESEND_API_KEY: ${RESEND_API_KEY:-}
EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki <onboarding@resend.dev>}"
ports:
- "3001:3001"
- "20016:3001"
volumes:
- backend_uploads:/app/uploads
depends_on:
@@ -40,10 +39,13 @@ services:
build:
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}
environment:
VITE_API_BASE_URL: http://localhost:3001
PORT: 20015
ports:
- "3000:3000"
- "20015:20015"
depends_on:
- backend

View File

@@ -1,10 +1,28 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY backend/package.json ./backend/package.json
COPY frontend/package.json ./frontend/package.json
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate && pnpm install --frozen-lockfile --filter @pokopia/frontend...
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
RUN pnpm --filter @pokopia/frontend build
FROM node:22-alpine
WORKDIR /app/frontend
COPY frontend/package.json ./
RUN corepack enable && pnpm install
COPY frontend/. .
COPY package.json /app/package.json
COPY system-wordings.ts /app/system-wordings.ts
EXPOSE 3000
CMD ["pnpm", "run", "dev"]
ENV NODE_ENV=production
ENV PORT=20015
WORKDIR /app
COPY --from=build /app/frontend/dist ./dist
COPY frontend/static-server.mjs ./static-server.mjs
USER node
EXPOSE 20015
CMD ["node", "static-server.mjs"]

View File

@@ -3,7 +3,44 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pokopia Wiki</title>
<meta
name="description"
content="Browse Pokopia Wiki for Pokemon, habitats, items, 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, habitats, items, 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, habitats, items, 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>

View File

@@ -5,25 +5,25 @@
"packageManager": "pnpm@10.33.2",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 3000",
"dev": "vite --host 0.0.0.0 --port 20015",
"build": "vue-tsc --noEmit && vite build",
"lint": "vue-tsc --noEmit",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@iconify/vue": "^5.0.0",
"@vitejs/plugin-vue": "latest",
"vite": "latest",
"vue": "latest",
"vue-i18n": "^11.4.0",
"vue-router": "latest"
"@iconify/vue": "5.0.0",
"@vitejs/plugin-vue": "6.0.6",
"vite": "8.0.10",
"vue": "3.5.33",
"vue-i18n": "11.4.0",
"vue-router": "5.0.6"
},
"devDependencies": {
"@types/node": "latest",
"@vue/tsconfig": "latest",
"typescript": "latest",
"vitest": "latest",
"vue-tsc": "latest"
"@types/node": "25.6.0",
"@vue/tsconfig": "0.9.1",
"typescript": "6.0.3",
"vitest": "4.1.5",
"vue-tsc": "3.2.7"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View File

@@ -6,12 +6,14 @@ import AppShell from './components/AppShell.vue';
import {
iconAction,
iconAdmin,
iconAutomation,
iconChecklist,
iconClothes,
iconDish,
iconDreamIsland,
iconEvent,
iconHabitat,
iconHome,
iconItem,
iconLife,
iconPokemon,
@@ -35,20 +37,33 @@ function inDevBadge() {
return { label: t('common.inDev'), tone: 'info' as const };
}
const navItems = computed(() => [
function can(permissionKey: string) {
return currentUser.value?.permissions.includes(permissionKey) === true;
}
const navItems = computed(() => {
const items = [
{ label: t('nav.home'), to: '/', icon: iconHome },
{ label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon },
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
{ label: t('nav.items'), to: '/items', icon: iconItem },
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
{ label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() },
{ label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() },
{ label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() },
{ label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() },
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
{ label: t('nav.life'), to: '/life', icon: iconLife },
{ label: t('nav.admin'), to: '/admin', icon: iconAdmin }
]);
{ label: t('nav.life'), to: '/life', icon: iconLife }
];
if (can('admin.access')) {
items.push({ label: t('nav.admin'), to: '/admin', icon: iconAdmin });
}
return items;
});
async function loadCurrentUser() {
if (!getAuthToken()) {
@@ -74,7 +89,7 @@ async function logout() {
currentUser.value = null;
setAuthToken(null);
await router.push('/pokemon');
await router.push('/');
}
async function loadLanguages() {

View File

@@ -30,6 +30,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const route = useRoute();
const copyrightYear = new Date().getFullYear();
const languageMenu = ref<HTMLElement | null>(null);
const languageMenuButton = ref<HTMLButtonElement | null>(null);
const languageMenuOpen = ref(false);
@@ -110,7 +111,7 @@ onBeforeUnmount(() => {
<Icon :icon="sidebarOpen ? iconClose : iconMenu" class="ui-icon" aria-hidden="true" />
</button>
<RouterLink class="brand-lockup brand-lockup--mobile" to="/pokemon" aria-label="Pokopia Wiki" @click="closeSidebar">
<RouterLink class="brand-lockup brand-lockup--mobile" to="/" aria-label="Pokopia Wiki" @click="closeSidebar">
<PokeBallMark size="34px" />
<span>
<span class="pokemon-word">Pokopia</span>
@@ -123,7 +124,7 @@ onBeforeUnmount(() => {
<aside id="app-sidebar" class="site-sidebar" :aria-label="t('nav.main')">
<div class="site-sidebar__inner">
<RouterLink class="brand-lockup" to="/pokemon" aria-label="Pokopia Wiki" @click="closeSidebar">
<RouterLink class="brand-lockup" to="/" aria-label="Pokopia Wiki" @click="closeSidebar">
<PokeBallMark size="42px" />
<span>
<span class="pokemon-word">Pokopia</span>
@@ -210,5 +211,19 @@ onBeforeUnmount(() => {
<main class="page">
<slot></slot>
</main>
<footer class="site-footer">
<div class="site-footer__inner">
<p class="site-footer__copyright">
{{ t('legal.footer.copyright', { year: copyrightYear }) }}
</p>
<nav class="site-footer__links" :aria-label="t('legal.footer.linksLabel')">
<RouterLink to="/privacy-policy" @click="closeSidebar">{{ t('legal.footer.privacy') }}</RouterLink>
<RouterLink to="/terms-of-service" @click="closeSidebar">{{ t('legal.footer.terms') }}</RouterLink>
<RouterLink to="/disclaimers" @click="closeSidebar">{{ t('legal.footer.disclaimers') }}</RouterLink>
</nav>
<p class="site-footer__notice">{{ t('legal.footer.notice') }}</p>
</div>
</footer>
</div>
</template>

View File

@@ -12,6 +12,10 @@ const changeLabelKeys: Record<string, string> = {
Name: 'common.name',
名字: 'common.name',
名称: 'common.name',
Title: 'pages.checklist.task',
标题: 'pages.checklist.task',
'Pokemon ID': 'pages.pokemon.id',
'Event item': 'common.eventItem',
Genus: 'pages.pokemon.genus',
Details: 'pages.pokemon.details',
介绍: 'pages.pokemon.details',
@@ -60,7 +64,16 @@ const changeLabelKeys: Record<string, string> = {
Item: 'pages.recipes.item',
物品: 'pages.recipes.item',
Materials: 'pages.recipes.materials',
需要材料: 'pages.recipes.materials'
需要材料: 'pages.recipes.materials',
'Sort order': 'pages.admin.sortOrder',
排序: 'pages.admin.sortOrder',
'Has item drop': 'pages.admin.hasItemDrop',
有掉落物: 'pages.admin.hasItemDrop',
'Default category': 'pages.admin.defaultCategory',
默认分类: 'pages.admin.defaultCategory',
Rateable: 'pages.admin.rateableCategory',
可评分: 'pages.admin.rateableCategory',
ChangeLog: 'pages.admin.changeLog'
};
function displayName(user: UserSummary | null): string {
@@ -76,6 +89,14 @@ function actionMark(action: EditHistoryAction): string {
}
function changeLabel(label: string): string {
const localizedFieldMatch = label.match(/^(.+) \(([^()]+)\)$/);
if (localizedFieldMatch) {
const [, fieldLabel, languageCode] = localizedFieldMatch;
if (fieldLabel && languageCode) {
return `${changeLabel(fieldLabel)} (${languageCode})`;
}
}
const key = changeLabelKeys[label];
return key ? t(key) : label;
}
@@ -118,14 +139,20 @@ function formatDateTime(value: string): string {
<div>
<dt>{{ t('history.createdBy') }}</dt>
<dd>
<strong>{{ displayName(entity.createdBy) }}</strong>
<RouterLink v-if="entity.createdBy" class="user-profile-link" :to="`/profile/${entity.createdBy.id}`">
{{ entity.createdBy.displayName }}
</RouterLink>
<strong v-else>{{ displayName(entity.createdBy) }}</strong>
<time :datetime="entity.createdAt">{{ formatDateTime(entity.createdAt) }}</time>
</dd>
</div>
<div>
<dt>{{ t('history.lastEdited') }}</dt>
<dd>
<strong>{{ displayName(entity.updatedBy) }}</strong>
<RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`">
{{ entity.updatedBy.displayName }}
</RouterLink>
<strong v-else>{{ displayName(entity.updatedBy) }}</strong>
<time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
</dd>
</div>
@@ -158,7 +185,12 @@ function formatDateTime(value: string): string {
<dl class="edit-history-detail-meta">
<div>
<dt>{{ t('history.author') }}</dt>
<dd>{{ displayName(entry.user) }}</dd>
<dd>
<RouterLink v-if="entry.user" class="user-profile-link" :to="`/profile/${entry.user.id}`">
{{ entry.user.displayName }}
</RouterLink>
<span v-else>{{ displayName(entry.user) }}</span>
</dd>
</div>
<div>
<dt>{{ t('history.time') }}</dt>

View File

@@ -18,6 +18,11 @@ function formatDateTime(value: string): string {
<template>
<p class="edit-meta">
{{ t('history.lastEdited') }}: {{ entity.updatedBy?.displayName ?? t('common.system') }} / {{ formatDateTime(entity.updatedAt) }}
{{ t('history.lastEdited') }}:
<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) }}
</p>
</template>

View File

@@ -1,14 +1,29 @@
<script setup lang="ts">
import type { NamedEntity } from '../services/api';
import { Icon } from '@iconify/vue';
import { iconItem } from '../icons';
import type { EntityImage, NamedEntity, PokemonImage } from '../services/api';
type ChipItem = NamedEntity & {
image?: EntityImage | PokemonImage | null;
quantity?: number;
};
defineProps<{
items: Array<NamedEntity & { quantity?: number }>;
items: ChipItem[];
}>();
function hasImageSlot(item: ChipItem) {
return Object.prototype.hasOwnProperty.call(item, 'image');
}
</script>
<template>
<div class="chips">
<span v-for="item in items" :key="`${item.id}-${item.name}`" class="chip">
<span v-for="item in items" :key="`${item.id}-${item.name}`" class="chip" :class="{ 'chip--with-media': hasImageSlot(item) }">
<span v-if="hasImageSlot(item)" class="chip__media" aria-hidden="true">
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconItem" class="chip__icon" aria-hidden="true" />
</span>
{{ item.name }}<span v-if="item.quantity"> × {{ item.quantity }}</span>
</span>
</div>

View File

@@ -2,15 +2,19 @@
import { Icon } from '@iconify/vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { iconCancel, iconComment, iconDelete, iconReply } from '../icons';
import StatusBadge from './StatusBadge.vue';
import Tabs, { type TabOption } from './Tabs.vue';
import { iconCancel, iconComment, iconDelete, iconReply, iconWarning } from '../icons';
import {
api,
getAuthToken,
onAuthTokenChange,
setAuthToken,
type AiModerationStatus,
type AuthUser,
type DiscussionEntityType,
type EntityDiscussionComment
type EntityDiscussionComment,
type Language
} from '../services/api';
import Skeleton from './Skeleton.vue';
@@ -21,8 +25,10 @@ const props = defineProps<{
const { locale, t } = useI18n();
const comments = ref<EntityDiscussionComment[]>([]);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const loadingMore = ref(false);
const authReady = ref(false);
const body = ref('');
const replyBodies = ref<Record<number, string>>({});
@@ -32,13 +38,28 @@ const loadError = ref('');
const formError = ref('');
const commentErrors = ref<Record<string, string>>({});
const commentInput = ref<HTMLTextAreaElement | null>(null);
const activeLanguageCode = ref('all');
const moderationBusyId = ref<number | null>(null);
const commentMaxLength = 1000;
const discussionPageSize = 20;
const allLanguageValue = 'all';
let requestId = 0;
let removeAuthListener: (() => void) | null = null;
const nextCursor = ref<string | null>(null);
const hasMoreComments = ref(false);
const commentTotal = ref(0);
const canComment = computed(() => currentUser.value?.emailVerified === true);
function can(permissionKey: string) {
return currentUser.value?.permissions.includes(permissionKey) === true;
}
const canComment = computed(() => can('discussions.comments.create'));
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
const commentTotal = computed(() => comments.value.reduce((total, comment) => total + 1 + comment.replies.length, 0));
const selectedLanguageCode = computed(() => (activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value));
const languageTabs = computed<TabOption[]>(() => [
{ value: allLanguageValue, label: t('discussion.allLanguages') },
...languages.value.map((language) => ({ value: language.code, label: language.name }))
]);
async function loadCurrentUser() {
authReady.value = false;
@@ -60,15 +81,49 @@ async function loadCurrentUser() {
}
}
async function loadDiscussion() {
async function loadLanguages() {
try {
languages.value = (await api.languages()).filter((language) => language.enabled);
if (
activeLanguageCode.value !== allLanguageValue &&
!languages.value.some((language) => language.code === activeLanguageCode.value)
) {
activeLanguageCode.value = allLanguageValue;
}
} catch {
languages.value = [];
}
}
function mergeComments(existing: EntityDiscussionComment[], incoming: EntityDiscussionComment[]) {
const ids = new Set(existing.map((comment) => comment.id));
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
}
async function loadDiscussion(reset = true) {
if (!reset && (loadingMore.value || !hasMoreComments.value)) {
return;
}
const nextRequestId = ++requestId;
if (reset) {
loading.value = true;
} else {
loadingMore.value = true;
}
loadError.value = '';
try {
const rows = await api.entityDiscussion(props.entityType, props.entityId);
const page = await api.entityDiscussion(props.entityType, props.entityId, {
limit: discussionPageSize,
cursor: reset ? null : nextCursor.value,
language: selectedLanguageCode.value
});
if (nextRequestId === requestId) {
comments.value = rows;
comments.value = reset ? page.items : mergeComments(comments.value, page.items);
nextCursor.value = page.nextCursor;
hasMoreComments.value = page.hasMore;
commentTotal.value = page.total;
}
} catch (error) {
if (nextRequestId === requestId) {
@@ -77,6 +132,7 @@ async function loadDiscussion() {
} finally {
if (nextRequestId === requestId) {
loading.value = false;
loadingMore.value = false;
}
}
}
@@ -108,7 +164,41 @@ function clearCommentError(key: string) {
}
function canManageComment(comment: EntityDiscussionComment) {
return !comment.deleted && currentUser.value?.id === comment.author?.id;
return (
!comment.deleted &&
((currentUser.value?.id === comment.author?.id && can('discussions.comments.delete')) ||
can('discussions.comments.delete-any'))
);
}
function canSeeModeration(comment: EntityDiscussionComment) {
return currentUser.value?.id === comment.author?.id || can('discussions.comments.delete-any');
}
function canRetryModeration(comment: EntityDiscussionComment) {
return !comment.deleted && comment.moderationStatus !== 'approved' && canSeeModeration(comment);
}
function moderationLabel(status: AiModerationStatus) {
const labels: Record<AiModerationStatus, string> = {
unreviewed: t('discussion.moderationUnreviewed'),
reviewing: t('discussion.moderationReviewing'),
approved: t('discussion.moderationApproved'),
rejected: t('discussion.moderationRejected'),
failed: t('discussion.moderationFailed')
};
return labels[status];
}
function moderationTone(status: AiModerationStatus) {
const tones: Record<AiModerationStatus, 'info' | 'success' | 'warning' | 'danger' | 'neutral'> = {
unreviewed: 'neutral',
reviewing: 'info',
approved: 'success',
rejected: 'danger',
failed: 'warning'
};
return tones[status];
}
function commentAuthorName(comment: EntityDiscussionComment) {
@@ -158,8 +248,12 @@ async function submitComment() {
formError.value = '';
try {
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, { body: nextBody });
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, {
body: nextBody,
languageCode: selectedLanguageCode.value ?? null
});
comments.value = [...comments.value, comment];
commentTotal.value += 1;
body.value = '';
} catch (error) {
formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed');
@@ -180,8 +274,12 @@ async function submitReply(comment: EntityDiscussionComment) {
clearCommentError(key);
try {
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, { body: nextBody });
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, {
body: nextBody,
languageCode: selectedLanguageCode.value ?? comment.moderationLanguageCode
});
comment.replies.push(reply);
commentTotal.value += 1;
cancelReply(comment.id);
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed'));
@@ -190,6 +288,22 @@ async function submitReply(comment: EntityDiscussionComment) {
}
}
async function retryModeration(comment: EntityDiscussionComment) {
const key = commentKey(comment.id);
moderationBusyId.value = comment.id;
clearCommentError(key);
try {
const updated = await api.retryEntityDiscussionModeration(comment.id);
comment.moderationStatus = updated.moderationStatus;
comment.moderationLanguageCode = updated.moderationLanguageCode;
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.moderationRetryFailed'));
} finally {
moderationBusyId.value = null;
}
}
function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolean {
for (const comment of rows) {
if (comment.id === id) {
@@ -231,12 +345,24 @@ watch(
() => {
resetComposer();
comments.value = [];
nextCursor.value = null;
hasMoreComments.value = false;
commentTotal.value = 0;
void loadDiscussion();
}
);
watch(activeLanguageCode, () => {
comments.value = [];
nextCursor.value = null;
hasMoreComments.value = false;
commentTotal.value = 0;
void loadDiscussion();
});
onMounted(() => {
void loadCurrentUser();
void loadLanguages();
void loadDiscussion();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
@@ -257,6 +383,8 @@ onUnmounted(() => {
</div>
</div>
<Tabs id="entity-discussion-language" v-model="activeLanguageCode" :tabs="languageTabs" :label="t('discussion.languages')" />
<div v-if="!authReady" class="entity-discussion-skeleton" aria-hidden="true">
<Skeleton variant="box" height="112px" />
</div>
@@ -310,8 +438,17 @@ onUnmounted(() => {
<div class="entity-discussion-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
<div class="entity-discussion-comment__content">
<div class="entity-discussion-comment__meta">
<strong>{{ commentAuthorName(comment) }}</strong>
<RouterLink v-if="!comment.deleted && comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
{{ comment.author.displayName }}
</RouterLink>
<strong v-else>{{ commentAuthorName(comment) }}</strong>
<time :datetime="comment.createdAt">{{ formatDateTime(comment.createdAt) }}</time>
<StatusBadge
v-if="canSeeModeration(comment)"
:label="moderationLabel(comment.moderationStatus)"
:tone="moderationTone(comment.moderationStatus)"
compact
/>
</div>
<p v-if="!comment.deleted" class="entity-discussion-comment__body">{{ comment.body }}</p>
@@ -326,6 +463,19 @@ onUnmounted(() => {
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.reply') }}</span>
</button>
<button
v-if="canRetryModeration(comment)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('discussion.moderationRetry')"
:disabled="moderationBusyId === comment.id"
@click="retryModeration(comment)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ moderationBusyId === comment.id ? t('discussion.moderationRetrying') : t('discussion.moderationRetry') }}
</span>
</button>
<button
v-if="canManageComment(comment)"
class="life-icon-button life-icon-button--flat life-icon-button--danger"
@@ -382,11 +532,33 @@ onUnmounted(() => {
<div class="entity-discussion-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
<div class="entity-discussion-comment__content">
<div class="entity-discussion-comment__meta">
<strong>{{ commentAuthorName(reply) }}</strong>
<RouterLink v-if="!reply.deleted && reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
{{ reply.author.displayName }}
</RouterLink>
<strong v-else>{{ commentAuthorName(reply) }}</strong>
<time :datetime="reply.createdAt">{{ formatDateTime(reply.createdAt) }}</time>
<StatusBadge
v-if="canSeeModeration(reply)"
:label="moderationLabel(reply.moderationStatus)"
:tone="moderationTone(reply.moderationStatus)"
compact
/>
</div>
<p v-if="!reply.deleted" class="entity-discussion-comment__body">{{ reply.body }}</p>
<div v-if="canManageComment(reply)" class="entity-discussion-comment__actions">
<div v-if="canManageComment(reply) || canRetryModeration(reply)" class="entity-discussion-comment__actions">
<button
v-if="canRetryModeration(reply)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('discussion.moderationRetry')"
:disabled="moderationBusyId === reply.id"
@click="retryModeration(reply)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ moderationBusyId === reply.id ? t('discussion.moderationRetrying') : t('discussion.moderationRetry') }}
</span>
</button>
<button
class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button"
@@ -405,6 +577,18 @@ onUnmounted(() => {
</div>
</div>
</article>
<div v-if="hasMoreComments" class="life-feed__retry">
<button
class="ui-button ui-button--ghost ui-button--small"
type="button"
:disabled="loadingMore"
@click="loadDiscussion(false)"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
{{ loadingMore ? t('common.loading') : t('discussion.loadMore') }}
</button>
</div>
</div>
<div v-else class="entity-discussion-empty">

View File

@@ -15,6 +15,7 @@ const props = withDefaults(
currentImage?: EntityImage | null;
history?: EntityImageUpload[];
disabled?: boolean;
allowUpload?: boolean;
showPreview?: boolean;
}>(),
{
@@ -22,6 +23,7 @@ const props = withDefaults(
currentImage: null,
history: () => [],
disabled: false,
allowUpload: true,
showPreview: true
}
);
@@ -39,7 +41,7 @@ const uploadBusy = ref(false);
const localUploads = ref<EntityImageUpload[]>([]);
const imageLabel = computed(() => props.label || t('media.image'));
const uploadDisabled = computed(() => props.disabled || uploadBusy.value || props.entityName.trim() === '');
const uploadDisabled = computed(() => !props.allowUpload || props.disabled || uploadBusy.value || props.entityName.trim() === '');
const imageOptions = computed<EntityImage[]>(() => {
const images = [
...localUploads.value,
@@ -115,6 +117,7 @@ async function uploadImage(event: Event) {
<span class="field-label">{{ imageLabel }}</span>
<div class="image-upload-field__actions">
<input
v-if="allowUpload"
ref="fileInput"
class="image-upload-field__input"
type="file"
@@ -122,7 +125,7 @@ async function uploadImage(event: Event) {
:disabled="uploadDisabled"
@change="uploadImage"
/>
<button type="button" class="ui-button ui-button--blue ui-button--small" :disabled="uploadDisabled" @click="openFilePicker">
<button v-if="allowUpload" type="button" class="ui-button ui-button--blue ui-button--small" :disabled="uploadDisabled" @click="openFilePicker">
<Icon :icon="iconUpload" class="ui-icon" aria-hidden="true" />
{{ uploadBusy ? t('media.uploading') : t('media.uploadImage') }}
</button>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { iconStar, iconStarOutline } from '../icons';
const props = withDefaults(
defineProps<{
ratingAverage: number | null;
ratingCount: number;
myRating: number | null;
disabled?: boolean;
busy?: boolean;
}>(),
{
disabled: false,
busy: false
}
);
const emit = defineEmits<{
rate: [rating: number];
}>();
const { locale, t } = useI18n();
const ratings = [1, 2, 3, 4, 5] as const;
const formattedAverage = computed(() => {
if (props.ratingAverage === null || props.ratingCount === 0) {
return '-';
}
return new Intl.NumberFormat(locale.value, { maximumFractionDigits: 2 }).format(props.ratingAverage);
});
const summaryLabel = computed(() => {
if (props.ratingAverage === null || props.ratingCount === 0) {
return t('pages.life.noRatings');
}
return t('pages.life.ratingAverage', {
average: formattedAverage.value,
count: props.ratingCount
});
});
function buttonLabel(rating: number) {
return props.myRating === rating ? t('pages.life.removeRating') : t('pages.life.setRating', { count: rating });
}
</script>
<template>
<div class="life-rating-control" role="group" :aria-busy="busy" :aria-label="t('pages.life.rating')">
<div class="life-rating-control__stars" role="group" :aria-label="t('pages.life.rating')">
<button
v-for="rating in ratings"
:key="rating"
class="life-rating-control__star"
:class="{ 'is-active': myRating !== null && rating <= myRating }"
type="button"
:aria-label="buttonLabel(rating)"
:aria-pressed="myRating === rating"
:disabled="disabled || busy"
@click="emit('rate', rating)"
>
<Icon :icon="myRating !== null && rating <= myRating ? iconStar : iconStarOutline" class="ui-icon" aria-hidden="true" />
</button>
</div>
<span class="life-rating-control__summary" :aria-label="summaryLabel">{{ formattedAverage }}</span>
</div>
</template>

View File

@@ -38,8 +38,7 @@ const props = withDefaults(
multiple: true,
max: 0,
allowCreate: false,
creating: false,
dropdownStrategy: 'absolute'
creating: false
}
);
@@ -57,6 +56,7 @@ const search = ref('');
const activeIndex = ref(-1);
const dropdownStyle = ref<CSSProperties>({});
const dropdownPlacement = ref<'top' | 'bottom'>('bottom');
const isInsideModal = ref(false);
let positionFrame = 0;
const optionRows = computed(() =>
@@ -111,7 +111,8 @@ const candidateRows = computed<CandidateRow[]>(() => {
});
const activeCandidate = computed(() => candidateRows.value[activeIndex.value]);
const activeDescendant = computed(() => activeCandidate.value?.id);
const usesFixedDropdown = computed(() => props.dropdownStrategy === 'fixed');
const resolvedDropdownStrategy = computed<DropdownStrategy>(() => props.dropdownStrategy ?? (isInsideModal.value ? 'fixed' : 'absolute'));
const usesFixedDropdown = computed(() => resolvedDropdownStrategy.value === 'fixed');
function setDefaultActiveIndex() {
const keyword = createName.value.toLowerCase();
@@ -311,6 +312,7 @@ function removePositionListeners() {
}
onMounted(() => {
isInsideModal.value = root.value?.closest('.modal') !== null;
document.addEventListener('pointerdown', onDocumentPointerDown);
});

View File

@@ -3,6 +3,7 @@ export type AppIcon = string;
export const iconAdd: AppIcon = 'mdi:plus';
export const iconAdmin: AppIcon = 'mdi:tune-variant';
export const iconAction: AppIcon = 'mdi:gesture-tap-button';
export const iconAutomation: AppIcon = 'mdi:factory';
export const iconBack: AppIcon = 'mdi:arrow-left';
export const iconCancel: AppIcon = 'mdi:close';
export const iconCheck: AppIcon = 'mdi:check';
@@ -10,6 +11,7 @@ export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
export const iconChevronDown: AppIcon = 'mdi:chevron-down';
export const iconClose: AppIcon = 'mdi:close';
export const iconComment: AppIcon = 'mdi:comment-outline';
export const iconCopy: AppIcon = 'mdi:content-copy';
export const iconDelete: AppIcon = 'mdi:trash-can-outline';
export const iconDish: AppIcon = 'mdi:silverware-fork-knife';
export const iconDragHandle: AppIcon = 'mdi:drag';
@@ -18,6 +20,7 @@ export const iconEdit: AppIcon = 'mdi:pencil-outline';
export const iconError: AppIcon = 'mdi:close-circle-outline';
export const iconEvent: AppIcon = 'mdi:calendar-star';
export const iconHabitat: AppIcon = 'mdi:pine-tree';
export const iconHome: AppIcon = 'mdi:home-variant-outline';
export const iconImage: AppIcon = 'mdi:image-outline';
export const iconInfo: AppIcon = 'mdi:information-outline';
export const iconItem: AppIcon = 'mdi:bag-personal-outline';
@@ -32,6 +35,7 @@ export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
export const iconPokemon: AppIcon = 'mdi:pokeball';
export const iconProfile: AppIcon = 'mdi:account-circle-outline';
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
export const iconReferral: AppIcon = 'mdi:account-multiple-plus-outline';
export const iconRegister: AppIcon = 'mdi:account-plus-outline';
export const iconReply: AppIcon = 'mdi:reply-outline';
export const iconReactionFun: AppIcon = 'mdi:party-popper';
@@ -40,7 +44,10 @@ export const iconReactionLike: AppIcon = 'mdi:thumb-up-outline';
export const iconReactionThanks: AppIcon = 'mdi:hand-heart-outline';
export const iconSave: AppIcon = 'mdi:content-save-outline';
export const iconSearch: AppIcon = 'mdi:magnify';
export const iconStar: AppIcon = 'mdi:star';
export const iconStarOutline: AppIcon = 'mdi:star-outline';
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
export const iconTranslate: AppIcon = 'mdi:translate';
export const iconUpload: AppIcon = 'mdi:upload-outline';
export const iconVersion: AppIcon = 'mdi:tag-outline';
export const iconWarning: AppIcon = 'mdi:alert-outline';

View File

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

View File

@@ -1,4 +1,5 @@
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';
@@ -9,6 +10,7 @@ import RecipeList from '../views/RecipeList.vue';
import RecipeDetail from '../views/RecipeDetail.vue';
import DailyChecklistView from '../views/DailyChecklistView.vue';
import LifeView from '../views/LifeView.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';
@@ -18,41 +20,192 @@ 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: '/', redirect: '/pokemon' },
{ path: '/pokemon', name: 'pokemon-list', component: PokemonList },
{ path: '/pokemon/new', name: 'pokemon-new', component: PokemonList, meta: { requiresVerified: true, editorModal: true } },
{ path: '/pokemon/:id/edit', name: 'pokemon-edit', component: PokemonDetail, meta: { requiresVerified: true, editorModal: true } },
{ path: '/pokemon/:id', name: 'pokemon-detail', component: PokemonDetail },
{ path: '/habitats', name: 'habitat-list', component: HabitatList },
{ path: '/habitats/new', name: 'habitat-new', component: HabitatList, meta: { requiresVerified: true, editorModal: true } },
{ path: '/habitats/:id/edit', name: 'habitat-edit', component: HabitatDetail, meta: { requiresVerified: true, editorModal: true } },
{ path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail },
{ path: '/items', name: 'item-list', component: ItemsList },
{ path: '/items/new', name: 'item-new', component: ItemsList, meta: { requiresVerified: true, editorModal: true } },
{ path: '/items/:id/edit', name: 'item-edit', component: ItemDetail, meta: { requiresVerified: true, editorModal: true } },
{ path: '/items/:id', name: 'item-detail', component: ItemDetail },
{ path: '/recipes', name: 'recipe-list', component: RecipeList },
{ path: '/recipes/new', name: 'recipe-new', component: RecipeList, meta: { requiresVerified: true, editorModal: true } },
{ path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiresVerified: true, editorModal: true } },
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail },
{ path: '/dish', name: 'dish', component: ComingSoonView, props: { page: 'dish' } },
{ path: '/events', name: 'events', component: ComingSoonView, props: { page: 'events' } },
{ path: '/actions', name: 'actions', component: ComingSoonView, props: { page: 'actions' } },
{ path: '/dream-island', name: 'dream-island', component: ComingSoonView, props: { page: 'dreamIsland' } },
{ path: '/clothes', name: 'clothes', component: ComingSoonView, props: { page: 'clothes' } },
{ path: '/checklist', component: DailyChecklistView },
{ path: '/life', component: LifeView },
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true } },
{ path: '/login', component: LoginView },
{ path: '/forgot-password', component: ForgotPasswordView },
{ path: '/reset-password', component: ResetPasswordView },
{ path: '/register', component: RegisterView },
{ path: '/verify-email', component: VerifyEmailView }
{ path: '/', name: 'home', component: HomeView, meta: { seo: seo({ titleKey: 'pages.home.title', descriptionKey: 'pages.home.subtitle', canonicalPath: '/' }) } },
{ path: '/pokemon', name: 'pokemon-list', component: PokemonList, meta: { seo: seo({ titleKey: 'pages.pokemon.title', descriptionKey: 'pages.pokemon.subtitle' }) } },
{
path: '/pokemon/new',
name: 'pokemon-new',
component: PokemonList,
meta: {
requiredPermission: 'pokemon.create',
editorModal: true,
seo: seo({ titleKey: 'pages.pokemon.newTitle', descriptionKey: 'pages.pokemon.editSubtitle', canonicalPath: '/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, meta: { seo: seo({ titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }) } },
{
path: '/habitats/new',
name: 'habitat-new',
component: HabitatList,
meta: {
requiredPermission: 'habitats.create',
editorModal: true,
seo: seo({ titleKey: 'pages.habitats.newTitle', descriptionKey: 'pages.habitats.editSubtitle', canonicalPath: '/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, meta: { seo: seo({ titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }) } },
{
path: '/items/new',
name: 'item-new',
component: ItemsList,
meta: {
requiredPermission: 'items.create',
editorModal: true,
seo: seo({ titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/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: '/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: ComingSoonView,
props: { page: 'dish' },
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.dish.title', descriptionKey: 'pages.comingSoon.sections.dish.subtitle', noindex: true }) }
},
{
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: '/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;
@@ -62,7 +215,15 @@ export const router = createRouter({
});
router.beforeEach(async (to) => {
const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true);
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) {
@@ -75,7 +236,19 @@ router.beforeEach(async (to) => {
try {
const response = await api.me();
return !requiresVerified || response.user.emailVerified ? true : { path: '/login', query: { redirect: to.fullPath } };
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 } };

182
frontend/src/seo.ts Normal file
View File

@@ -0,0 +1,182 @@
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router';
import { getCurrentLocale, i18n, onLocaleChange } from './i18n';
const siteName = 'Pokopia Wiki';
const defaultCanonicalPath = '/';
const defaultImagePath = '/seo/pokopia-hero.jpg';
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
type TranslationValues = Record<string, string | number>;
export type RouteSeoConfig = {
title?: string;
titleKey?: string;
description?: string;
descriptionKey?: string;
canonicalPath?: string | ((route: RouteLocationNormalizedLoaded) => string);
image?: string;
noindex?: boolean;
};
export type SeoConfig = {
title?: string;
description?: string;
canonicalPath?: string;
image?: string | null;
noindex?: boolean;
};
const translate = i18n.global.t as (key: string, values?: TranslationValues) => string;
function configuredSiteUrl(): string {
const fromEnv = import.meta.env.VITE_SITE_URL;
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') {
return normalizeSiteUrl(fromEnv);
}
if (typeof window !== 'undefined' && window.location.origin) {
return normalizeSiteUrl(window.location.origin);
}
return fallbackSiteUrl;
}
function normalizeSiteUrl(value: string): string {
return value.trim().replace(/\/+$/, '') || fallbackSiteUrl;
}
function normalizePath(value: string | undefined): string {
const path = value?.trim() || defaultCanonicalPath;
return path.startsWith('/') ? path : `/${path}`;
}
export function absoluteUrl(value: string): string {
try {
return new URL(value, `${configuredSiteUrl()}/`).toString();
} catch {
return `${configuredSiteUrl()}${normalizePath(value)}`;
}
}
function metaTitle(title?: string): string {
const cleanTitle = title?.trim();
if (!cleanTitle || cleanTitle === siteName) {
return siteName;
}
return `${cleanTitle} | ${siteName}`;
}
function metaDescription(description?: string): string {
return description?.trim() || translate('seo.siteDescription');
}
function localeForOpenGraph(locale: string): string {
if (locale === 'en') {
return 'en_US';
}
return locale.replace('-', '_');
}
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 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;
}
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 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);
}
export function applyRouteSeo(route: RouteLocationNormalizedLoaded): void {
const routeSeo = route.meta.seo as RouteSeoConfig | undefined;
const canonicalPath =
typeof routeSeo?.canonicalPath === 'function'
? routeSeo.canonicalPath(route)
: routeSeo?.canonicalPath ?? route.path ?? defaultCanonicalPath;
applySeo({
title: routeSeo?.titleKey ? translate(routeSeo.titleKey) : routeSeo?.title,
description: routeSeo?.descriptionKey ? translate(routeSeo.descriptionKey) : routeSeo?.description,
canonicalPath,
image: routeSeo?.image,
noindex: routeSeo?.noindex
});
}
export function setupSeo(router: Router): void {
router.afterEach((to) => {
applyRouteSeo(to);
});
if (typeof window !== 'undefined') {
onLocaleChange(() => {
applyRouteSeo(router.currentRoute.value);
});
}
}

View File

@@ -37,6 +37,15 @@ export interface NamedEntity {
translations?: TranslationMap;
}
export interface LifeCategory extends NamedEntity {
isDefault: boolean;
isRateable: boolean;
}
export interface GameVersion extends NamedEntity {
changeLog: string;
}
export interface Skill extends NamedEntity {
hasItemDrop: boolean;
}
@@ -62,12 +71,6 @@ export interface EntityImage {
export interface EntityImageUpload extends EntityImage {
id: number;
entityType: ImageUploadEntityType;
entityId: number | null;
entityName: string;
originalFilename: string;
mimeType: string;
byteSize: number;
uploadedAt: string;
uploadedBy: UserSummary | null;
}
@@ -106,8 +109,10 @@ export interface EditHistoryEntry {
export interface Pokemon extends EditInfo {
id: number;
displayId: number;
name: string;
baseName?: string;
isEventItem: boolean;
genus: string;
baseGenus?: string;
details: string;
@@ -127,21 +132,25 @@ export interface Pokemon extends EditInfo {
export interface RelatedPokemon {
id: number;
displayId: number;
name: string;
isEventItem: boolean;
image?: PokemonImage | null;
environment: NamedEntity;
skills: Skill[];
favorite_things: Array<NamedEntity & { matches: boolean }>;
}
export interface PokemonDetail extends Pokemon {
skills: Array<Skill & { itemDrop: NamedEntity | null }>;
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
skills: Array<Skill & { itemDrop: (NamedEntity & { image?: EntityImage | null }) | null }>;
favoriteThingItems: Array<NamedEntity & { image?: EntityImage | null; category: NamedEntity; tags: NamedEntity[] }>;
relatedPokemon: RelatedPokemon[];
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
habitats: Array<{
id: number;
name: string;
image?: EntityImage | null;
time_of_day: string;
weather: string;
rarity: number;
@@ -153,9 +162,10 @@ export interface Habitat extends EditInfo {
id: number;
name: string;
baseName?: string;
isEventItem: boolean;
translations?: TranslationMap;
image: EntityImage | null;
recipe: Array<NamedEntity & { quantity: number }>;
recipe: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
pokemon?: NamedEntity[];
}
@@ -163,6 +173,9 @@ export interface HabitatDetail extends Habitat {
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
pokemon: Array<NamedEntity & {
displayId: number;
isEventItem: boolean;
image?: PokemonImage | null;
time_of_day: string;
weather: string;
rarity: number;
@@ -177,19 +190,28 @@ export interface RecipeSummary extends EditInfo {
export interface RecipeUsage {
id: number;
name: string;
materials: Array<NamedEntity & { quantity: number }>;
image?: EntityImage | null;
materials: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
}
export interface HabitatUsage {
id: number;
name: string;
recipe: Array<NamedEntity & { quantity: number }>;
image?: EntityImage | null;
recipe: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
}
export interface RecipeResultItem extends NamedEntity {
image?: EntityImage | null;
category?: NamedEntity;
usage?: NamedEntity | null;
}
export interface Item extends EditInfo {
id: number;
name: string;
baseName?: string;
isEventItem: boolean;
translations?: TranslationMap;
image: EntityImage | null;
category: NamedEntity;
@@ -212,7 +234,7 @@ export interface ItemDetail extends Item {
editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[];
droppedByPokemon: Array<{
pokemon: NamedEntity;
pokemon: NamedEntity & { displayId: number; isEventItem: boolean; image?: PokemonImage | null };
skill: NamedEntity;
}>;
}
@@ -220,7 +242,7 @@ export interface ItemDetail extends Item {
export interface Recipe extends EditInfo {
id: number;
name: string;
materials: Array<NamedEntity & { quantity: number }>;
materials: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
}
export interface DailyChecklistItem {
@@ -232,16 +254,24 @@ export interface DailyChecklistItem {
export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
export type LifeReactionCounts = Record<LifeReactionType, number>;
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
export interface LifePost {
id: number;
body: string;
moderationStatus: AiModerationStatus;
moderationLanguageCode: string | null;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
updatedBy: UserSummary | null;
tags: NamedEntity[];
comments: LifeComment[];
category: (NamedEntity & { isRateable: boolean }) | null;
gameVersion: GameVersion | null;
ratingAverage: number | null;
ratingCount: number;
myRating: number | null;
commentPreview: LifeComment[];
commentCount: number;
reactionCounts: LifeReactionCounts;
myReaction: LifeReactionType | null;
}
@@ -256,7 +286,17 @@ export interface LifePostsParams {
cursor?: string | null;
limit?: number;
search?: string;
tagId?: string | number;
categoryId?: string | number;
language?: string;
gameVersionId?: string | number;
rateable?: boolean | null;
sort?: 'latest' | 'oldest' | 'top-rated';
}
export interface CommentPageParams {
cursor?: string | null;
limit?: number;
language?: string;
}
export interface LifeComment {
@@ -265,16 +305,25 @@ export interface LifeComment {
parentCommentId: number | null;
body: string;
deleted: boolean;
moderationStatus: AiModerationStatus;
moderationLanguageCode: string | null;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
replies: LifeComment[];
}
export interface LifeCommentsPage {
items: LifeComment[];
nextCursor: string | null;
hasMore: boolean;
total: number;
}
export interface RecipeDetail extends Recipe {
acquisition_methods: NamedEntity[];
editHistory: EditHistoryEntry[];
item: NamedEntity;
item: RecipeResultItem;
}
export interface Options {
@@ -287,7 +336,8 @@ export interface Options {
acquisitionMethods: NamedEntity[];
itemTags: NamedEntity[];
maps: NamedEntity[];
lifeTags: NamedEntity[];
lifeCategories: LifeCategory[];
gameVersions: GameVersion[];
}
export interface AuthUser {
@@ -295,12 +345,124 @@ export interface AuthUser {
email: string;
displayName: string;
emailVerified: boolean;
roles: RoleSummary[];
permissions: string[];
}
export interface ReferralSummary {
code: string;
url: string;
verifiedReferralCount: number;
}
export interface PublicProfileUser extends UserSummary {
joinedAt: string;
}
export interface PublicProfileStats {
wikiEdits: number;
wikiCreates: number;
wikiUpdates: number;
wikiDeletes: number;
imageUploads: number;
lifePosts: number;
lifeComments: number;
lifeReactions: number;
discussionComments: number;
}
export interface PublicProfileContribution {
contentType: string;
total: number;
creates: number;
updates: number;
deletes: number;
lastContributedAt: string | null;
}
export interface PublicUserProfile {
user: PublicProfileUser;
stats: PublicProfileStats;
contributions: PublicProfileContribution[];
}
export type ProfileCommentSource = 'life' | 'discussion';
export interface ProfileActivityParams {
cursor?: string | null;
limit?: number;
reactionType?: LifeReactionType;
source?: ProfileCommentSource;
}
export interface UserReactionActivity {
postId: number;
reactionType: LifeReactionType;
reactedAt: string;
post: LifePost;
}
export interface UserReactionActivityPage {
items: UserReactionActivity[];
nextCursor: string | null;
hasMore: boolean;
}
export interface RoleSummary {
id: number;
key: string;
name: string;
level: number;
}
export interface RoleDetail extends RoleSummary {
description: string;
enabled: boolean;
systemRole: boolean;
permissionIds: number[];
}
export interface Permission {
id: number;
key: string;
name: string;
description: string;
category: string;
enabled: boolean;
systemPermission: boolean;
}
export interface AdminUser extends AuthUser {
roleIds: number[];
createdAt: string;
updatedAt: string;
}
export interface RolePayload {
key?: string;
name: string;
description: string;
level: number;
enabled: boolean;
}
export interface PermissionPayload {
key?: string;
name: string;
description: string;
category: string;
enabled: boolean;
}
export interface UserProfilePayload {
displayName: string;
}
export interface ChangePasswordPayload {
currentPassword: string;
password: string;
}
export interface LoginPayload {
email: string;
password: string;
@@ -309,6 +471,7 @@ export interface LoginPayload {
export interface RegisterPayload extends LoginPayload {
displayName: string;
referralCode?: string;
}
export interface AuthResponse {
@@ -325,10 +488,12 @@ export type ConfigType =
| 'item-usages'
| 'acquisition-methods'
| 'maps'
| 'life-tags';
| 'life-tags'
| 'game-versions';
export interface PokemonPayload {
id: number;
displayId: number;
isEventItem: boolean;
name: string;
genus: string;
details: string;
@@ -377,6 +542,7 @@ export interface ItemPayload {
dualDyeable: boolean;
patternEditable: boolean;
noRecipe: boolean;
isEventItem: boolean;
acquisitionMethodIds: number[];
tagIds: number[];
imagePath: string;
@@ -391,6 +557,7 @@ export interface RecipePayload {
export interface HabitatPayload {
name: string;
translations?: TranslationMap;
isEventItem: boolean;
imagePath: string;
recipeItems: Array<{ itemId: number; quantity: number }>;
pokemonAppearances: Array<{
@@ -409,11 +576,14 @@ export interface DailyChecklistPayload {
export interface LifePostPayload {
body: string;
tagIds?: number[];
categoryId: number;
gameVersionId?: number | null;
languageCode?: string | null;
}
export interface LifeCommentPayload {
body: string;
languageCode?: string | null;
}
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
@@ -425,17 +595,72 @@ export interface EntityDiscussionComment {
parentCommentId: number | null;
body: string;
deleted: boolean;
moderationStatus: AiModerationStatus;
moderationLanguageCode: string | null;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
replies: EntityDiscussionComment[];
}
export interface EntityDiscussionCommentPayload {
body: string;
export interface EntityDiscussionCommentsPage {
items: EntityDiscussionComment[];
nextCursor: string | null;
hasMore: boolean;
total: number;
}
export function buildQuery(params: Record<string, string | number | undefined>): string {
export interface UserCommentActivity {
id: number;
source: ProfileCommentSource;
body: string;
createdAt: string;
target: {
type: 'life-post' | DiscussionEntityType;
id: number;
title: string;
excerpt: string;
};
}
export interface UserCommentActivityPage {
items: UserCommentActivity[];
nextCursor: string | null;
hasMore: boolean;
}
export interface EntityDiscussionCommentPayload {
body: string;
languageCode?: string | null;
}
export type AiModerationApiFormat = 'gemini-generate-content' | 'openai-chat-completions';
export type AiModerationAuthMode = 'query-key' | 'bearer-token';
export interface AiModerationSettings {
enabled: boolean;
apiFormat: AiModerationApiFormat;
authMode: AiModerationAuthMode;
endpoint: string;
model: string;
requestsPerMinute: number;
apiKeyConfigured: boolean;
updatedAt: string;
updatedBy: UserSummary | null;
}
export interface AiModerationSettingsPayload {
enabled: boolean;
apiFormat: AiModerationApiFormat;
authMode: AiModerationAuthMode;
endpoint: string;
model: string;
requestsPerMinute: number;
apiKey?: string;
clearApiKey?: boolean;
}
export function buildQuery(params: Record<string, string | number | boolean | undefined>): string {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
@@ -605,6 +830,9 @@ export const api = {
getJson<SystemWording[]>(`/api/admin/system-wordings${buildQuery(params)}`),
updateSystemWording: (key: string, payload: { locale: string; value: string }) =>
sendJson<SystemWording[]>(`/api/admin/system-wordings/${encodeURIComponent(key)}`, 'PUT', payload),
aiModerationSettings: () => getJson<AiModerationSettings>('/api/admin/ai-moderation'),
updateAiModerationSettings: (payload: AiModerationSettingsPayload) =>
sendJson<AiModerationSettings>('/api/admin/ai-moderation', 'PUT', payload),
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
verifyEmail: (token: string) =>
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
@@ -615,7 +843,50 @@ export const api = {
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
changePassword: (payload: ChangePasswordPayload) =>
sendJson<{ message: string }>('/api/auth/me/password', 'PATCH', payload),
referral: () => getJson<{ referral: ReferralSummary }>('/api/auth/referral'),
logout: () => postEmpty('/api/auth/logout'),
publicProfile: (id: string | number) => getJson<{ profile: PublicUserProfile }>(`/api/users/${id}/profile`),
userLifePosts: (id: string | number, params: ProfileActivityParams = {}) =>
getJson<LifePostsPage>(
`/api/users/${id}/life-posts${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit
})}`
),
userReactions: (id: string | number, params: ProfileActivityParams = {}) =>
getJson<UserReactionActivityPage>(
`/api/users/${id}/reactions${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit,
reactionType: params.reactionType
})}`
),
userComments: (id: string | number, params: ProfileActivityParams = {}) =>
getJson<UserCommentActivityPage>(
`/api/users/${id}/comments${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit,
source: params.source
})}`
),
adminUsers: () => getJson<AdminUser[]>('/api/admin/users'),
updateAdminUserRoles: (id: string | number, roleIds: number[]) =>
sendJson<AdminUser[]>(`/api/admin/users/${id}/roles`, 'PUT', { roleIds }),
roles: () => getJson<RoleDetail[]>('/api/admin/roles'),
createRole: (payload: RolePayload & { key: string }) => sendJson<RoleDetail[]>('/api/admin/roles', 'POST', payload),
updateRole: (id: string | number, payload: RolePayload) =>
sendJson<RoleDetail[]>(`/api/admin/roles/${id}`, 'PUT', payload),
updateRolePermissions: (id: string | number, permissionIds: number[]) =>
sendJson<RoleDetail[]>(`/api/admin/roles/${id}/permissions`, 'PUT', { permissionIds }),
deleteRole: (id: string | number) => deleteJson(`/api/admin/roles/${id}`),
permissions: () => getJson<Permission[]>('/api/admin/permissions'),
createPermission: (payload: PermissionPayload & { key: string }) =>
sendJson<Permission[]>('/api/admin/permissions', 'POST', payload),
updatePermission: (id: string | number, payload: PermissionPayload) =>
sendJson<Permission[]>(`/api/admin/permissions/${id}`, 'PUT', payload),
deletePermission: (id: string | number) => deleteJson(`/api/admin/permissions/${id}`),
options: () => getJson<Options>('/api/options'),
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
lifePosts: (params: LifePostsParams = {}) =>
@@ -624,23 +895,48 @@ export const api = {
cursor: params.cursor ?? undefined,
limit: params.limit,
search: params.search?.trim(),
tagId: params.tagId
categoryId: params.categoryId,
language: params.language,
gameVersionId: params.gameVersionId,
rateable: params.rateable === null ? undefined : params.rateable,
sort: params.sort
})}`
),
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
updateLifePost: (id: string | number, payload: LifePostPayload) =>
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
retryLifePostModeration: (id: string | number) =>
sendJson<LifePost>(`/api/life-posts/${id}/moderation/retry`, 'POST', {}),
deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`),
setLifeReaction: (id: string | number, reactionType: LifeReactionType) =>
sendJson<LifePost>(`/api/life-posts/${id}/reaction`, 'PUT', { reactionType }),
deleteLifeReaction: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/reaction`),
setLifeRating: (id: string | number, rating: number) =>
sendJson<LifePost>(`/api/life-posts/${id}/rating`, 'PUT', { rating }),
deleteLifeRating: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/rating`),
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
lifeComments: (postId: string | number, params: CommentPageParams = {}) =>
getJson<LifeCommentsPage>(
`/api/life-posts/${postId}/comments${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit,
language: params.language
})}`
),
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
retryLifeCommentModeration: (id: string | number) =>
sendJson<LifeComment>(`/api/life-comments/${id}/moderation/retry`, 'POST', {}),
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number) =>
getJson<EntityDiscussionComment[]>(`/api/discussions/${entityType}/${entityId}/comments`),
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) =>
getJson<EntityDiscussionCommentsPage>(
`/api/discussions/${entityType}/${entityId}/comments${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit,
language: params.language
})}`
),
createEntityDiscussionComment: (
entityType: DiscussionEntityType,
entityId: string | number,
@@ -652,6 +948,8 @@ export const api = {
commentId: string | number,
payload: EntityDiscussionCommentPayload
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
retryEntityDiscussionModeration: (id: string | number) =>
sendJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/moderation/retry`, 'POST', {}),
deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`),
uploadImage: (
entityType: ImageUploadEntityType,
@@ -672,13 +970,20 @@ export const api = {
reorderDailyChecklistItems: (ids: number[]) =>
sendJson<DailyChecklistItem[]>('/api/admin/daily-checklist/order', 'PUT', { ids }),
deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`),
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
config: (type: ConfigType) => getJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: (
type: ConfigType,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
) =>
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
reorderConfig: (type: ConfigType, ids: number[]) =>
sendJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
sendJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
updateConfig: (
type: ConfigType,
id: number,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
) =>
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
pokemon: (params: Record<string, string | number | undefined>) =>
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import PageHeader from '../components/PageHeader.vue';
import StatusBadge from '../components/StatusBadge.vue';
import {
iconAction,
iconAutomation,
iconClothes,
iconDish,
iconDreamIsland,
@@ -13,11 +14,11 @@ import {
type AppIcon
} from '../icons';
type ComingSoonPage = 'dish' | 'events' | 'actions' | 'dreamIsland' | 'clothes';
type ComingSoonPage = 'automation' | 'dish' | 'events' | 'actions' | 'dreamIsland' | 'clothes';
type ComingSoonConfig = {
icon: AppIcon;
accent: 'dish' | 'events' | 'actions' | 'dream' | 'clothes';
accent: 'automation' | 'dish' | 'events' | 'actions' | 'dream' | 'clothes';
previewKeys: Array<'one' | 'two' | 'three'>;
};
@@ -28,6 +29,7 @@ const props = defineProps<{
const { t } = useI18n();
const pageConfigByPage: Record<ComingSoonPage, ComingSoonConfig> = {
automation: { icon: iconAutomation, accent: 'automation', previewKeys: ['one', 'two', 'three'] },
dish: { icon: iconDish, accent: 'dish', previewKeys: ['one', 'two', 'three'] },
events: { icon: iconEvent, accent: 'events', previewKeys: ['one', 'two', 'three'] },
actions: { icon: iconAction, accent: 'actions', previewKeys: ['one', 'two', 'three'] },

View File

@@ -8,19 +8,23 @@ import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue';
import PokeBallMark from '../components/PokeBallMark.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit } from '../icons';
import { api, type HabitatDetail } from '../services/api';
import { iconBack, iconEdit, iconHabitat } from '../icons';
import { applySeo } from '../seo';
import { api, getAuthToken, type AuthUser, type HabitatDetail } from '../services/api';
import HabitatEdit from './HabitatEdit.vue';
const route = useRoute();
const { t } = useI18n();
const habitat = ref<HabitatDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const detailTab = ref('details');
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const showEditor = computed(() => route.name === 'habitat-edit');
const canUpdateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.update') === true);
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
@@ -30,6 +34,7 @@ const detailTabs = computed<TabOption[]>(() => [
type PokemonRow = {
id: number;
name: string;
image: HabitatDetail['pokemon'][number]['image'];
timeOfDays: string[];
weathers: string[];
rarity: number;
@@ -74,6 +79,7 @@ const pokemonRows = computed<PokemonRow[]>(() => {
{
id: number;
name: string;
image: HabitatDetail['pokemon'][number]['image'];
timeOfDays: Set<string>;
weathers: Set<string>;
rarity: number;
@@ -86,6 +92,7 @@ const pokemonRows = computed<PokemonRow[]>(() => {
const row = rows.get(key) ?? {
id: pokemon.id,
name: pokemon.name,
image: pokemon.image,
timeOfDays: new Set<string>(),
weathers: new Set<string>(),
rarity: pokemon.rarity,
@@ -101,6 +108,7 @@ const pokemonRows = computed<PokemonRow[]>(() => {
return [...rows.values()].map((row) => ({
id: row.id,
name: row.name,
image: row.image,
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
weathers: sortByOrder(row.weathers, weathers),
rarity: row.rarity,
@@ -108,15 +116,28 @@ const pokemonRows = computed<PokemonRow[]>(() => {
}));
});
function imageFileName(path: string): string {
return path.split('/').at(-1) ?? t('media.image');
}
async function loadHabitatDetail() {
habitat.value = await api.habitatDetail(String(route.params.id));
const nextHabitat = await api.habitatDetail(String(route.params.id));
habitat.value = nextHabitat;
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextHabitat.name} - ${t('pages.habitats.title')}`,
description: t('seo.habitatDetailDescription', { name: nextHabitat.name }),
canonicalPath: `/habitats/${nextHabitat.id}`,
image: nextHabitat.image?.url
});
}
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await loadHabitatDetail();
});
@@ -187,9 +208,9 @@ watch(
</section>
<section v-else class="page-stack">
<PageHeader :title="habitat.name" :subtitle="t('pages.habitats.detailSubtitle')">
<template #kicker>Habitat Detail</template>
<template #kicker>{{ t('pages.habitats.detailKicker') }}</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">
<RouterLink v-if="canUpdateHabitat" class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</RouterLink>
@@ -203,29 +224,48 @@ watch(
<div class="detail-tabs">
<Tabs id="habitat-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
<div v-if="detailTab === 'details'" class="habitat-detail-stack">
<DetailSection v-if="habitat.image || habitat.imageHistory.length" :title="t('media.image')">
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
<div class="entity-profile-grid">
<section class="detail-section entity-profile-media-section" :aria-label="t('media.image')">
<div class="entity-detail-image">
<div v-if="habitat.image" class="entity-detail-image__frame">
<img :src="habitat.image.url" :alt="t('media.imageAlt', { name: habitat.name })" />
</div>
<p v-else class="meta-line">{{ t('media.imageEmpty') }}</p>
<div v-if="habitat.imageHistory.length" class="image-history-list" :aria-label="t('media.imageHistory')">
<div v-for="image in habitat.imageHistory" :key="image.path" class="image-history-list__item">
<img :src="image.url" :alt="t('media.imageAlt', { name: habitat.name })" loading="lazy" />
<span>{{ imageFileName(image.path) }}</span>
<div class="entity-detail-image__frame" :class="{ 'entity-detail-image__frame--placeholder': !habitat.image }">
<img v-if="habitat.image" :src="habitat.image.url" :alt="t('media.imageAlt', { name: habitat.name })" />
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
<Icon :icon="iconHabitat" class="entity-card__icon" aria-hidden="true" />
</span>
</div>
</div>
</div>
</DetailSection>
</section>
<DetailSection :title="t('pages.habitats.recipeList')">
<EntityChips :items="habitat.recipe" />
</DetailSection>
<div class="entity-profile-main">
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
<dl class="entity-profile-facts">
<div>
<dt>{{ t('pages.habitats.recipeList') }}</dt>
<dd>{{ habitat.recipe.length }}</dd>
</div>
<div>
<dt>{{ t('pages.habitats.possiblePokemon') }}</dt>
<dd>{{ pokemonRows.length }}</dd>
</div>
</dl>
<div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.habitats.recipeList') }}</h3>
<EntityChips v-if="habitat.recipe.length" :items="habitat.recipe" />
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
</section>
</div>
</div>
<DetailSection :title="t('pages.habitats.possiblePokemon')">
<ul class="row-list appearance-list">
<ul v-if="pokemonRows.length" class="row-list appearance-list appearance-list--with-media">
<li v-for="item in pokemonRows" :key="`${item.id}-${item.rarity}`">
<span class="related-entity-media related-entity-media--appearance related-entity-media--pokemon" aria-hidden="true">
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
<PokeBallMark v-else size="24px" />
</span>
<RouterLink class="appearance-name" :to="`/pokemon/${item.id}`">{{ item.name }}</RouterLink>
<dl class="appearance-summary">
<div>
@@ -247,6 +287,7 @@ watch(
</dl>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
</div>

View File

@@ -13,6 +13,8 @@ import TranslationFields from '../components/TranslationFields.vue';
import { iconAdd, iconCancel, iconDelete, iconPokemon, iconSave } from '../icons';
import {
api,
getAuthToken,
type AuthUser,
type ConfigType,
type EntityImage,
type EntityImageUpload,
@@ -40,6 +42,7 @@ const options = ref<Options | null>(null);
const itemRows = ref<Item[]>([]);
const pokemonRows = ref<Pokemon[]>([]);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const currentImage = ref<EntityImage | null>(null);
const imageHistory = ref<EntityImageUpload[]>([]);
const loading = ref(true);
@@ -49,6 +52,7 @@ const creatingSelect = ref('');
const habitatForm = ref({
name: '',
translations: {} as TranslationMap,
isEventItem: false,
imagePath: '',
recipeItems: [] as Array<{ itemId: string; quantity: number }>,
pokemonAppearances: [] as HabitatAppearanceForm[]
@@ -71,7 +75,7 @@ const routeId = computed(() => (typeof route.params.id === 'string' ? route.para
const isEditing = computed(() => routeId.value !== '');
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
const pokemonSelectOptions = computed(() =>
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` }))
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.displayId} ${pokemon.name}` }))
);
const pageTitle = computed(() =>
isEditing.value
@@ -80,6 +84,8 @@ const pageTitle = computed(() =>
);
const cancelTo = computed(() => (isEditing.value ? `/habitats/${routeId.value}` : '/habitats'));
const imageEntityName = computed(() => habitatNameForSave().trim());
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
const canUploadImage = computed(() => currentUser.value?.permissions.includes('habitats.upload') === true);
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
@@ -145,12 +151,26 @@ function habitatNameForSave() {
return habitatForm.value.translations[String(locale.value || '')]?.name ?? '';
}
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
async function loadEditor() {
loading.value = true;
message.value = '';
try {
const [loadedOptions, loadedItems, loadedPokemon, loadedLanguages] = await Promise.all([
const [, loadedOptions, loadedItems, loadedPokemon, loadedLanguages] = await Promise.all([
loadCurrentUser(),
api.options(),
api.items({}),
api.pokemon({}),
@@ -166,6 +186,7 @@ async function loadEditor() {
habitatForm.value = {
name: habitat.baseName ?? habitat.name,
translations: habitat.translations ?? {},
isEventItem: habitat.isEventItem,
imagePath: habitat.image?.path ?? '',
recipeItems: habitat.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
pokemonAppearances: groupPokemonAppearances(habitat)
@@ -186,7 +207,7 @@ async function loadOptions() {
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
const cleanName = name.trim();
if (!cleanName) return;
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
@@ -212,6 +233,7 @@ async function saveHabitat() {
const payload: HabitatPayload = {
name: habitatNameForSave(),
translations: habitatForm.value.translations,
isEventItem: habitatForm.value.isEventItem,
imagePath: habitatForm.value.imagePath,
recipeItems: toQuantityRows(habitatForm.value.recipeItems),
pokemonAppearances: habitatForm.value.pokemonAppearances
@@ -271,11 +293,16 @@ onMounted(() => {
:current-image="currentImage"
:history="imageHistory"
:disabled="busy"
:allow-upload="canUploadImage"
@selected="handleImageSelected"
@uploaded="handleImageUploaded"
@error="message = $event"
/>
<div class="check-row">
<label><input v-model="habitatForm.isEventItem" type="checkbox" /> {{ t('pages.habitats.eventItem') }}</label>
</div>
<div class="field">
<label>{{ t('pages.habitats.recipe') }}</label>
<div v-for="(row, index) in habitatForm.recipeItems" :key="index" class="inline-row">
@@ -334,7 +361,7 @@ onMounted(() => {
:id="`appearance-maps-${index}`"
v-model="row.mapIds"
:options="options.maps"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === `appearance-maps-${index}`"
:placeholder="t('pages.habitats.searchMaps')"
@create="createMultiOption(`appearance-maps-${index}`, 'maps', $event, row.mapIds)"

View File

@@ -7,21 +7,30 @@ import EntityCard from '../components/EntityCard.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import { iconAdd, iconHabitat } from '../icons';
import { api, type Habitat } from '../services/api';
import { api, getAuthToken, type AuthUser, type Habitat } from '../services/api';
import HabitatEdit from './HabitatEdit.vue';
const habitats = ref<Habitat[]>([]);
const currentUser = ref<AuthUser | null>(null);
const route = useRoute();
const { t } = useI18n();
const loading = ref(true);
const skeletonCardCount = 6;
const showEditor = computed(() => route.name === 'habitat-new');
const canCreateHabitat = computed(() => currentUser.value?.permissions.includes('habitats.create') === true);
function habitatCardImage(item: Habitat) {
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
habitats.value = await api.habitats();
loading.value = false;
});
@@ -32,7 +41,7 @@ onMounted(async () => {
<PageHeader :title="t('pages.habitats.title')" :subtitle="t('pages.habitats.subtitle')">
<template #kicker>Habitats</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/habitats/new">
<RouterLink v-if="canCreateHabitat" class="ui-button ui-button--primary ui-button--small" to="/habitats/new">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.add') }}
</RouterLink>

View File

@@ -0,0 +1,168 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import PokeBallMark from '../components/PokeBallMark.vue';
import StatusBadge from '../components/StatusBadge.vue';
import {
iconAction,
iconAutomation,
iconChecklist,
iconClothes,
iconDish,
iconDreamIsland,
iconEvent,
iconHabitat,
iconItem,
iconLife,
iconPokemon,
iconRecipe
} from '../icons';
const { t } = useI18n();
const primarySections = computed(() => [
{ key: 'pokemon', to: '/pokemon', icon: iconPokemon },
{ key: 'habitats', to: '/habitats', icon: iconHabitat },
{ key: 'items', to: '/items', icon: iconItem },
{ key: 'recipes', to: '/recipes', icon: iconRecipe }
]);
const communitySections = computed(() => [
{ key: 'checklist', to: '/checklist', icon: iconChecklist },
{ key: 'life', to: '/life', icon: iconLife }
]);
const futureSections = computed(() => [
{ key: 'automation', to: '/automation', icon: iconAutomation },
{ key: 'dish', to: '/dish', icon: iconDish },
{ key: 'events', to: '/events', icon: iconEvent },
{ key: 'actions', to: '/actions', icon: iconAction },
{ key: 'dreamIsland', to: '/dream-island', icon: iconDreamIsland },
{ key: 'clothes', to: '/clothes', icon: iconClothes }
]);
function sectionTitleKey(key: string) {
return `pages.home.sections.${key}.title`;
}
function sectionDescriptionKey(key: string) {
return `pages.home.sections.${key}.description`;
}
</script>
<template>
<section class="home-page">
<section class="home-hero" aria-labelledby="home-title">
<div class="home-hero__copy">
<span class="page-kicker">{{ t('pages.home.kicker') }}</span>
<h1 id="home-title" class="home-hero__title">{{ t('pages.home.title') }}</h1>
<p class="home-hero__subtitle">{{ t('pages.home.subtitle') }}</p>
<div class="home-hero__actions" :aria-label="t('pages.home.primaryActions')">
<RouterLink class="ui-button ui-button--primary" to="/pokemon">
<Icon :icon="iconPokemon" class="ui-icon" aria-hidden="true" />
{{ t('pages.home.browsePokemon') }}
</RouterLink>
<RouterLink class="ui-button ui-button--blue" to="/checklist">
<Icon :icon="iconChecklist" class="ui-icon" aria-hidden="true" />
{{ t('pages.home.openChecklist') }}
</RouterLink>
<RouterLink class="ui-button ui-button--ghost" to="/life">
<Icon :icon="iconLife" class="ui-icon" aria-hidden="true" />
{{ t('pages.home.openLife') }}
</RouterLink>
</div>
<div class="home-quick-index" :aria-label="t('pages.home.quickIndex')">
<RouterLink v-for="section in primarySections" :key="section.key" :to="section.to">
<Icon :icon="section.icon" class="ui-icon" aria-hidden="true" />
<span>{{ t(sectionTitleKey(section.key)) }}</span>
</RouterLink>
</div>
</div>
<aside class="home-dex" :aria-label="t('pages.home.featuredPanel')">
<div class="home-dex__head">
<div class="home-dex__lights" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
<span>{{ t('pages.home.dexCode') }}</span>
</div>
<div class="home-dex__screen">
<PokeBallMark size="84px" />
<div class="home-dex__copy">
<strong>{{ t('pages.home.dexTitle') }}</strong>
<p>{{ t('pages.home.dexBody') }}</p>
</div>
<div class="home-dex__tiles">
<RouterLink v-for="section in primarySections" :key="section.key" :to="section.to">
<Icon :icon="section.icon" class="ui-icon" aria-hidden="true" />
<span>{{ t(sectionTitleKey(section.key)) }}</span>
</RouterLink>
</div>
</div>
</aside>
</section>
<section class="home-section" aria-labelledby="home-wiki-title">
<div class="home-section__header">
<span class="page-kicker">{{ t('pages.home.wikiKicker') }}</span>
<h2 id="home-wiki-title">{{ t('pages.home.wikiTitle') }}</h2>
</div>
<div class="home-card-grid home-card-grid--primary">
<RouterLink v-for="section in primarySections" :key="section.key" class="home-card" :to="section.to">
<span class="home-card__icon">
<Icon :icon="section.icon" class="ui-icon" aria-hidden="true" />
</span>
<span class="home-card__copy">
<strong>{{ t(sectionTitleKey(section.key)) }}</strong>
<span>{{ t(sectionDescriptionKey(section.key)) }}</span>
</span>
</RouterLink>
</div>
</section>
<section class="home-section" aria-labelledby="home-community-title">
<div class="home-section__header">
<span class="page-kicker">{{ t('pages.home.communityKicker') }}</span>
<h2 id="home-community-title">{{ t('pages.home.communityTitle') }}</h2>
</div>
<div class="home-card-grid home-card-grid--community">
<RouterLink v-for="section in communitySections" :key="section.key" class="home-card home-card--wide" :to="section.to">
<span class="home-card__icon">
<Icon :icon="section.icon" class="ui-icon" aria-hidden="true" />
</span>
<span class="home-card__copy">
<strong>{{ t(sectionTitleKey(section.key)) }}</strong>
<span>{{ t(sectionDescriptionKey(section.key)) }}</span>
</span>
</RouterLink>
</div>
</section>
<section class="home-section" aria-labelledby="home-future-title">
<div class="home-section__header">
<span class="page-kicker">{{ t('pages.home.futureKicker') }}</span>
<h2 id="home-future-title">{{ t('pages.home.futureTitle') }}</h2>
</div>
<div class="home-card-grid home-card-grid--future">
<RouterLink v-for="section in futureSections" :key="section.key" class="home-card home-card--future" :to="section.to">
<span class="home-card__icon">
<Icon :icon="section.icon" class="ui-icon" aria-hidden="true" />
</span>
<span class="home-card__copy">
<strong>{{ t(sectionTitleKey(section.key)) }}</strong>
<span>{{ t(sectionDescriptionKey(section.key)) }}</span>
</span>
<StatusBadge :label="t('common.inDev')" tone="info" compact />
</RouterLink>
</div>
</section>
</section>
</template>

View File

@@ -8,22 +8,34 @@ import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue';
import PokeBallMark from '../components/PokeBallMark.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconAdd, iconBack, iconEdit } from '../icons';
import { api, type ItemDetail } from '../services/api';
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
import { applySeo } from '../seo';
import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/api';
import ItemEdit from './ItemEdit.vue';
const route = useRoute();
const { t } = useI18n();
const item = ref<ItemDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const detailTab = ref('details');
const showEditor = computed(() => route.name === 'item-edit');
const canUpdateItem = computed(() => currentUser.value?.permissions.includes('items.update') === true);
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') }
]);
const itemSubtitle = computed(() => {
if (!item.value) {
return '';
}
return item.value.usage ? `${item.value.category.name} · ${item.value.usage.name}` : item.value.category.name;
});
const customization = computed(() => {
if (!item.value) {
@@ -37,15 +49,28 @@ const customization = computed(() => {
].filter(Boolean);
});
function imageFileName(path: string): string {
return path.split('/').at(-1) ?? t('media.image');
}
async function loadItemDetail() {
item.value = await api.itemDetail(String(route.params.id));
const nextItem = await api.itemDetail(String(route.params.id));
item.value = nextItem;
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextItem.name} - ${t('pages.items.title')}`,
description: t('seo.itemDetailDescription', { name: nextItem.name }),
canonicalPath: `/items/${nextItem.id}`,
image: nextItem.image?.url
});
}
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await loadItemDetail();
});
@@ -122,10 +147,10 @@ watch(
</div>
</section>
<section v-else class="page-stack">
<PageHeader :title="item.name" :subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name">
<template #kicker>Item Detail</template>
<PageHeader :title="item.name" :subtitle="itemSubtitle">
<template #kicker>{{ t('pages.items.detailKicker') }}</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</RouterLink>
@@ -139,46 +164,75 @@ watch(
<div class="detail-tabs">
<Tabs id="item-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
<div v-if="detailTab === 'details'" class="detail-grid">
<DetailSection v-if="item.image || item.imageHistory.length" :title="t('media.image')">
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
<div class="entity-profile-grid">
<section class="detail-section entity-profile-media-section" :aria-label="t('media.image')">
<div class="entity-detail-image">
<div v-if="item.image" class="entity-detail-image__frame">
<img :src="item.image.url" :alt="t('media.imageAlt', { name: item.name })" />
</div>
<p v-else class="meta-line">{{ t('media.imageEmpty') }}</p>
<div v-if="item.imageHistory.length" class="image-history-list" :aria-label="t('media.imageHistory')">
<div v-for="image in item.imageHistory" :key="image.path" class="image-history-list__item">
<img :src="image.url" :alt="t('media.imageAlt', { name: item.name })" loading="lazy" />
<span>{{ imageFileName(image.path) }}</span>
<div class="entity-detail-image__frame" :class="{ 'entity-detail-image__frame--placeholder': !item.image }">
<img v-if="item.image" :src="item.image.url" :alt="t('media.imageAlt', { name: item.name })" />
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
<Icon :icon="iconItem" class="entity-card__icon" aria-hidden="true" />
</span>
</div>
</div>
</div>
</DetailSection>
</section>
<DetailSection :title="t('pages.items.acquisitionMethods')">
<EntityChips :items="item.acquisitionMethods" />
</DetailSection>
<div class="entity-profile-main">
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
<dl class="entity-profile-facts">
<div>
<dt>{{ t('pages.items.category') }}</dt>
<dd>{{ item.category.name }}</dd>
</div>
<div>
<dt>{{ t('pages.items.usage') }}</dt>
<dd>{{ item.usage?.name ?? t('common.none') }}</dd>
</div>
<div>
<dt>{{ t('pages.items.recipeInfo') }}</dt>
<dd>{{ item.noRecipe ? t('pages.items.noRecipe') : item.recipe ? item.recipe.name : t('common.none') }}</dd>
</div>
</dl>
<DetailSection :title="t('pages.items.customization')">
<div class="entity-profile-groups">
<div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
<EntityChips v-if="item.acquisitionMethods.length" :items="item.acquisitionMethods" />
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
<div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.items.customization') }}</h3>
<div v-if="customization.length" class="chips">
<span v-for="entry in customization" :key="entry" class="chip">{{ entry }}</span>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
<DetailSection :title="t('pages.items.tags')">
<EntityChips :items="item.tags" />
</DetailSection>
</div>
<div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.items.tags') }}</h3>
<EntityChips v-if="item.tags.length" :items="item.tags" />
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
</div>
</section>
</div>
</div>
<div class="detail-grid">
<DetailSection :title="t('pages.items.recipeInfo')">
<template v-if="item.recipe">
<RouterLink :to="`/recipes/${item.recipe.id}`">{{ item.recipe.name }}</RouterLink>
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/recipes/${item.recipe.id}`">
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="item.recipe.item.image" :src="item.recipe.item.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
</span>
<span>{{ item.recipe.name }}</span>
</RouterLink>
<EntityChips :items="item.recipe.materials" />
</template>
<p v-else-if="item.noRecipe" class="meta-line">{{ t('pages.items.noRecipe') }}</p>
<template v-else>
<p class="meta-line">{{ t('common.none') }}</p>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/new?itemId=${item.id}`">
<RouterLink v-if="canCreateRecipe" class="ui-button ui-button--primary ui-button--small" :to="`/recipes/new?itemId=${item.id}`">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.items.createRecipe') }}
</RouterLink>
@@ -188,7 +242,13 @@ watch(
<DetailSection :title="t('pages.items.relatedRecipes')">
<ul v-if="item.relatedRecipes.length" class="row-list">
<li v-for="recipe in item.relatedRecipes" :key="recipe.id">
<RouterLink :to="`/recipes/${recipe.id}`">{{ recipe.name }}</RouterLink>
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/recipes/${recipe.id}`">
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="recipe.image" :src="recipe.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
</span>
<span>{{ recipe.name }}</span>
</RouterLink>
<EntityChips :items="recipe.materials" />
</li>
</ul>
@@ -198,7 +258,13 @@ watch(
<DetailSection :title="t('pages.items.relatedHabitats')">
<ul v-if="item.relatedHabitats.length" class="row-list">
<li v-for="habitat in item.relatedHabitats" :key="habitat.id">
<RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/habitats/${habitat.id}`">
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="habitat.image" :src="habitat.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconHabitat" class="related-entity-media__icon" aria-hidden="true" />
</span>
<span>{{ habitat.name }}</span>
</RouterLink>
<EntityChips :items="habitat.recipe" />
</li>
</ul>
@@ -208,13 +274,20 @@ watch(
<DetailSection :title="t('pages.items.pokemonDrops')">
<ul v-if="item.droppedByPokemon.length" class="row-list">
<li v-for="entry in item.droppedByPokemon" :key="`${entry.pokemon.id}-${entry.skill.id}`">
<RouterLink :to="`/pokemon/${entry.pokemon.id}`">#{{ entry.pokemon.id }} {{ entry.pokemon.name }}</RouterLink>
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/pokemon/${entry.pokemon.id}`">
<span class="related-entity-media related-entity-media--inline related-entity-media--pokemon" aria-hidden="true">
<img v-if="entry.pokemon.image" :src="entry.pokemon.image.url" alt="" loading="lazy" />
<PokeBallMark v-else size="22px" />
</span>
<span>#{{ entry.pokemon.displayId }} {{ entry.pokemon.name }}</span>
</RouterLink>
<span>{{ t('pages.pokemon.skillDrop', { name: entry.skill.name }) }}</span>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
</div>
</div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="items" :entity-id="item.id" />

View File

@@ -10,13 +10,25 @@ import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave } from '../icons';
import { api, type ConfigType, type EntityImage, type EntityImageUpload, type ItemPayload, type Language, type Options, type TranslationMap } from '../services/api';
import {
api,
getAuthToken,
type AuthUser,
type ConfigType,
type EntityImage,
type EntityImageUpload,
type ItemPayload,
type Language,
type Options,
type TranslationMap
} from '../services/api';
const route = useRoute();
const router = useRouter();
const { locale, t } = useI18n();
const options = ref<Options | null>(null);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const currentImage = ref<EntityImage | null>(null);
const imageHistory = ref<EntityImageUpload[]>([]);
const loading = ref(true);
@@ -32,6 +44,7 @@ const itemForm = ref({
dualDyeable: false,
patternEditable: false,
noRecipe: false,
isEventItem: false,
acquisitionMethodIds: [] as string[],
tagIds: [] as string[],
imagePath: ''
@@ -47,6 +60,8 @@ const pageTitle = computed(() =>
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items'));
const hasRecipe = ref(false);
const imageEntityName = computed(() => itemNameForSave().trim());
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
const canUploadImage = computed(() => currentUser.value?.permissions.includes('items.upload') === true);
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
@@ -75,12 +90,25 @@ async function loadOptions() {
languages.value = loadedLanguages;
}
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
async function loadEditor() {
loading.value = true;
message.value = '';
try {
await loadOptions();
await Promise.all([loadCurrentUser(), loadOptions()]);
if (isEditing.value) {
const item = await api.itemDetail(routeId.value);
itemForm.value = {
@@ -92,6 +120,7 @@ async function loadEditor() {
dualDyeable: item.customization.dualDyeable,
patternEditable: item.customization.patternEditable,
noRecipe: item.noRecipe,
isEventItem: item.isEventItem,
acquisitionMethodIds: item.acquisitionMethods.map((method) => String(method.id)),
tagIds: item.tags.map((tag) => String(tag.id)),
imagePath: item.image?.path ?? ''
@@ -109,7 +138,7 @@ async function loadEditor() {
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
const cleanName = name.trim();
if (!cleanName) return;
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
@@ -126,7 +155,7 @@ async function createSingleOption(selectKey: string, type: ConfigType, name: str
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
const cleanName = name.trim();
if (!cleanName) return;
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
@@ -158,6 +187,7 @@ async function saveItem() {
dualDyeable: itemForm.value.dualDyeable,
patternEditable: itemForm.value.patternEditable,
noRecipe: itemForm.value.noRecipe,
isEventItem: itemForm.value.isEventItem,
acquisitionMethodIds: toIds(itemForm.value.acquisitionMethodIds),
tagIds: toIds(itemForm.value.tagIds),
imagePath: itemForm.value.imagePath
@@ -209,6 +239,7 @@ onMounted(() => {
:current-image="currentImage"
:history="imageHistory"
:disabled="busy"
:allow-upload="canUploadImage"
@selected="handleImageSelected"
@uploaded="handleImageUploaded"
@error="message = $event"
@@ -221,7 +252,7 @@ onMounted(() => {
v-model="itemForm.categoryId"
:options="options.itemCategories"
:multiple="false"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'item-category'"
:placeholder="t('common.select')"
:search-placeholder="t('pages.items.searchCategory')"
@@ -236,7 +267,7 @@ onMounted(() => {
v-model="itemForm.usageId"
:options="options.itemUsages"
:multiple="false"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'item-usage'"
:placeholder="t('common.none')"
:search-placeholder="t('pages.items.searchUsage')"
@@ -249,6 +280,7 @@ onMounted(() => {
<label><input v-model="itemForm.dualDyeable" type="checkbox" /> {{ t('pages.items.dualDyeable') }}</label>
<label><input v-model="itemForm.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label>
<label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> {{ t('pages.items.noRecipe') }}</label>
<label><input v-model="itemForm.isEventItem" type="checkbox" /> {{ t('pages.items.eventItem') }}</label>
</div>
<div class="field">
@@ -257,7 +289,7 @@ onMounted(() => {
id="item-methods"
v-model="itemForm.acquisitionMethodIds"
:options="options.acquisitionMethods"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'item-methods'"
:placeholder="t('pages.items.searchMethods')"
@create="createMultiOption('item-methods', 'acquisition-methods', $event, itemForm.acquisitionMethodIds)"
@@ -270,7 +302,7 @@ onMounted(() => {
id="item-tags"
v-model="itemForm.tagIds"
:options="options.itemTags"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'item-tags'"
:placeholder="t('pages.items.searchTags')"
@create="createMultiOption('item-tags', 'favorite-things', $event, itemForm.tagIds)"

View File

@@ -10,13 +10,14 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconItem } from '../icons';
import { api, type Item, type Options } from '../services/api';
import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api';
import ItemEdit from './ItemEdit.vue';
const options = ref<Options | null>(null);
const route = useRoute();
const { t } = useI18n();
const items = ref<Item[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const search = ref('');
const categoryId = ref('');
@@ -39,6 +40,7 @@ const itemQuery = computed(() => ({
tagIds: tagIds.value.join(',')
}));
const showEditor = computed(() => route.name === 'item-new');
const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true);
function itemCardImage(item: Item) {
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined;
@@ -51,6 +53,13 @@ async function loadItems() {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
options.value = await api.options();
await loadItems();
});
@@ -63,7 +72,7 @@ watch(itemQuery, loadItems);
<PageHeader :title="t('pages.items.title')" :subtitle="t('pages.items.subtitle')">
<template #kicker>Bag</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/items/new">
<RouterLink v-if="canCreateItem" class="ui-button ui-button--primary ui-button--small" to="/items/new">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.add') }}
</RouterLink>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import PageHeader from '../components/PageHeader.vue';
type LegalPage = 'privacy' | 'terms' | 'disclaimers';
type LegalSource = {
labelKey: string;
href: string;
};
type LegalSection = {
key: string;
paragraphKeys: string[];
sources?: LegalSource[];
};
defineProps<{
page: LegalPage;
}>();
const { t } = useI18n();
const sectionsByPage: Record<LegalPage, LegalSection[]> = {
privacy: [
{ key: 'overview', paragraphKeys: ['bodyOne', 'bodyTwo'] },
{ key: 'information', paragraphKeys: ['bodyOne', 'bodyTwo'] },
{ key: 'storage', paragraphKeys: ['bodyOne', 'bodyTwo'] },
{ key: 'content', paragraphKeys: ['bodyOne', 'bodyTwo'] },
{ key: 'sharing', paragraphKeys: ['bodyOne', 'bodyTwo'] },
{ key: 'choices', paragraphKeys: ['bodyOne', 'bodyTwo'] }
],
terms: [
{ key: 'acceptance', paragraphKeys: ['bodyOne', 'bodyTwo'] },
{ key: 'accounts', paragraphKeys: ['bodyOne', 'bodyTwo'] },
{ key: 'contributions', paragraphKeys: ['bodyOne', 'bodyTwo'] },
{ key: 'acceptableUse', paragraphKeys: ['bodyOne', 'bodyTwo'] },
{ key: 'availability', paragraphKeys: ['bodyOne', 'bodyTwo'] },
{ key: 'changes', paragraphKeys: ['bodyOne', 'bodyTwo'] }
],
disclaimers: [
{ key: 'community', paragraphKeys: ['bodyOne', 'bodyTwo'] },
{ key: 'affiliation', paragraphKeys: ['bodyOne', 'bodyTwo'] },
{ key: 'pokeapi', paragraphKeys: ['bodyOne', 'bodyTwo'] },
{
key: 'references',
paragraphKeys: ['bodyOne', 'bodyTwo'],
sources: [
{ labelKey: 'pokeapiDocs', href: 'https://pokeapi.co/docs/v2' },
{ labelKey: 'pokeapiApiDataLicense', href: 'https://github.com/PokeAPI/api-data/blob/master/LICENSE.txt' },
{ labelKey: 'pokeapiSpritesLicense', href: 'https://github.com/PokeAPI/sprites/blob/master/LICENCE.txt' },
{ labelKey: 'pokemonLegal', href: 'https://www.pokemon.com/us/legal/' },
{ labelKey: 'pokopiaWikiReference', href: 'https://www.pokopiawiki.com/' }
]
},
{ key: 'accuracy', paragraphKeys: ['bodyOne', 'bodyTwo'] },
{ key: 'rights', paragraphKeys: ['bodyOne', 'bodyTwo'] }
]
};
function pageKey(page: LegalPage, suffix: string) {
return `pages.legal.${page}.${suffix}`;
}
function sectionKey(page: LegalPage, section: string, suffix: string) {
return `pages.legal.${page}.sections.${section}.${suffix}`;
}
function sourceKey(page: LegalPage, source: string) {
return `pages.legal.${page}.sources.${source}`;
}
</script>
<template>
<section class="page-stack legal-page">
<PageHeader :title="t(pageKey(page, 'title'))" :subtitle="t(pageKey(page, 'subtitle'))">
<template #kicker>{{ t(pageKey(page, 'kicker')) }}</template>
<template #meta>
<p class="legal-page__updated">{{ t('pages.legal.lastUpdated') }}</p>
</template>
</PageHeader>
<article
v-for="section in sectionsByPage[page]"
:key="section.key"
class="detail-section legal-section"
>
<h2>{{ t(sectionKey(page, section.key, 'title')) }}</h2>
<div class="detail-section__body legal-section__body">
<p v-for="paragraphKey in section.paragraphKeys" :key="paragraphKey">
{{ t(sectionKey(page, section.key, paragraphKey)) }}
</p>
<ul v-if="section.sources?.length" class="legal-source-list" :aria-label="t('pages.legal.sourceLinks')">
<li v-for="source in section.sources" :key="source.href">
<a :href="source.href" target="_blank" rel="noreferrer">
{{ t(sourceKey(page, source.labelKey)) }}
</a>
</li>
</ul>
</div>
</article>
</section>
</template>

View File

@@ -3,9 +3,11 @@ import { Icon } from '@iconify/vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import FilterPanel from '../components/FilterPanel.vue';
import LifeRatingControl from '../components/LifeRatingControl.vue';
import Modal from '../components/Modal.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusBadge from '../components/StatusBadge.vue';
import StatusMessage from '../components/StatusMessage.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
@@ -24,6 +26,7 @@ import {
iconReply,
iconSave,
iconSearch,
iconVersion,
iconWarning
} from '../icons';
import {
@@ -31,16 +34,34 @@ import {
getAuthToken,
onAuthTokenChange,
setAuthToken,
type AiModerationStatus,
type AuthUser,
type GameVersion,
type Language,
type LifeCategory,
type LifeComment,
type LifePost,
type LifeReactionType,
type NamedEntity
type LifeReactionType
} from '../services/api';
type LifeCommentPageState = {
items: LifeComment[];
nextCursor: string | null;
hasMore: boolean;
total: number;
loading: boolean;
loadingMore: boolean;
loaded: boolean;
error: string;
};
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
const { locale, t } = useI18n();
const posts = ref<LifePost[]>([]);
const lifeTags = ref<NamedEntity[]>([]);
const lifeCategories = ref<LifeCategory[]>([]);
const gameVersions = ref<GameVersion[]>([]);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const loadingMore = ref(false);
@@ -48,9 +69,14 @@ const authReady = ref(false);
const busy = ref(false);
const searchDraft = ref('');
const submittedSearch = ref('');
const activeTagId = ref('all');
const activeCategoryId = ref('all');
const activeLanguageCode = ref('all');
const activeGameVersionId = ref('all');
const activeRateableFilter = ref('all');
const activeSort = ref<LifePostSort>('latest');
const body = ref('');
const selectedTagIds = ref<string[]>([]);
const selectedCategoryId = ref('');
const selectedGameVersionId = ref('');
const editingPostId = ref<number | null>(null);
const postModalOpen = ref(false);
const formError = ref('');
@@ -59,14 +85,20 @@ const commentBodies = ref<Record<number, string>>({});
const replyBodies = ref<Record<number, string>>({});
const replyTargetId = ref<number | null>(null);
const expandedComments = ref<Record<number, boolean>>({});
const commentPages = ref<Record<number, LifeCommentPageState>>({});
const commentBusyKey = ref('');
const commentErrors = ref<Record<string, string>>({});
const reactionPickerPostId = ref<number | null>(null);
const reactionBusyPostId = ref<number | null>(null);
const reactionErrors = ref<Record<number, string>>({});
const ratingBusyPostId = ref<number | null>(null);
const ratingErrors = ref<Record<number, string>>({});
const moderationBusyPostId = ref<number | null>(null);
const moderationErrors = ref<Record<number, string>>({});
const bodyInput = ref<HTMLTextAreaElement | null>(null);
const loadMoreSentinel = ref<HTMLElement | null>(null);
const lifePostPageSize = 20;
const lifeCommentPageSize = 20;
const bodyMaxLength = 2000;
const commentMaxLength = 1000;
const skeletonPostCount = 3;
@@ -77,7 +109,9 @@ let postsRequestId = 0;
const nextCursor = ref<string | null>(null);
const hasMorePosts = ref(false);
const loadMorePaused = ref(false);
const allTagValue = 'all';
const allCategoryValue = 'all';
const allLanguageValue = 'all';
const allGameVersionValue = 'all';
const reactionOptions = [
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
@@ -86,18 +120,65 @@ const reactionOptions = [
{ type: 'thanks', icon: iconReactionThanks, labelKey: 'pages.life.reactionThanks' }
] as const satisfies ReadonlyArray<{ type: LifeReactionType; icon: string; labelKey: string }>;
const canPost = computed(() => currentUser.value?.emailVerified === true);
function can(permissionKey: string) {
return currentUser.value?.permissions.includes(permissionKey) === true;
}
const canPost = computed(() => can('life.posts.create'));
const canComment = computed(() => can('life.comments.create'));
const canReact = computed(() => can('life.reactions.set'));
const canRate = computed(() => can('life.ratings.set'));
const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
const isEditing = computed(() => editingPostId.value !== null);
const searchQuery = computed(() => submittedSearch.value.trim());
const selectedFeedTagId = computed(() => {
const tagId = Number(activeTagId.value);
return activeTagId.value === allTagValue || !Number.isInteger(tagId) || tagId <= 0 ? undefined : tagId;
const selectedFeedCategoryId = computed(() => {
const categoryId = Number(activeCategoryId.value);
return activeCategoryId.value === allCategoryValue || !Number.isInteger(categoryId) || categoryId <= 0 ? undefined : categoryId;
});
const tagFilterOptions = computed<TabOption[]>(() => [
{ value: allTagValue, label: t('pages.life.allTags') },
...lifeTags.value.map((tag) => ({ value: String(tag.id), label: tag.name }))
const selectedFeedLanguageCode = computed(() =>
activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value
);
const selectedFeedGameVersionId = computed(() => {
const gameVersionId = Number(activeGameVersionId.value);
return activeGameVersionId.value === allGameVersionValue || !Number.isInteger(gameVersionId) || gameVersionId <= 0
? undefined
: gameVersionId;
});
const selectedRateableFilter = computed(() => {
if (activeRateableFilter.value === 'rateable') {
return true;
}
if (activeRateableFilter.value === 'not-rateable') {
return false;
}
return null;
});
const categoryFilterOptions = computed<TabOption[]>(() => [
{ value: allCategoryValue, label: t('pages.life.allCategories') },
...lifeCategories.value.map((category) => ({ value: String(category.id), label: category.name }))
]);
const languageFilterOptions = computed<TabOption[]>(() => [
{ value: allLanguageValue, label: t('pages.life.allLanguages') },
...languages.value.map((language) => ({ value: language.code, label: language.name }))
]);
const gameVersionFilterOptions = computed(() => [
{ value: allGameVersionValue, label: t('pages.life.allVersions') },
...gameVersions.value.map((version) => ({ value: String(version.id), label: version.name }))
]);
const rateableFilterOptions = computed(() => [
{ value: 'all', label: t('pages.life.allRatingModes') },
{ value: 'rateable', label: t('pages.life.rateableOnly') },
{ value: 'not-rateable', label: t('pages.life.notRateableOnly') }
]);
const sortOptions = computed<Array<{ value: LifePostSort; label: string }>>(() => [
{ value: 'latest', label: t('pages.life.sortLatest') },
{ value: 'oldest', label: t('pages.life.sortOldest') },
{ value: 'top-rated', label: t('pages.life.sortTopRated') }
]);
const defaultLifeCategoryId = computed(() => {
const category = lifeCategories.value.find((item) => item.isDefault);
return category ? String(category.id) : '';
});
const postModalTitle = computed(() => (isEditing.value ? t('pages.life.editPost') : t('pages.life.newPost')));
const submitLabel = computed(() => {
if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing');
@@ -124,13 +205,44 @@ async function loadCurrentUser() {
}
}
async function loadLifeTags() {
async function loadLifeCategories() {
try {
const options = await api.options();
lifeTags.value = options.lifeTags;
lifeCategories.value = options.lifeCategories;
gameVersions.value = options.gameVersions;
if (activeTagId.value !== allTagValue && !lifeTags.value.some((tag) => String(tag.id) === activeTagId.value)) {
activeTagId.value = allTagValue;
if (activeCategoryId.value !== allCategoryValue && !lifeCategories.value.some((category) => String(category.id) === activeCategoryId.value)) {
activeCategoryId.value = allCategoryValue;
}
if (
activeGameVersionId.value !== allGameVersionValue &&
!gameVersions.value.some((gameVersion) => String(gameVersion.id) === activeGameVersionId.value)
) {
activeGameVersionId.value = allGameVersionValue;
}
if (!isEditing.value && postModalOpen.value && !selectedCategoryId.value) {
selectedCategoryId.value = defaultLifeCategoryId.value;
}
if (!isEditing.value && selectedCategoryId.value && !lifeCategories.value.some((category) => String(category.id) === selectedCategoryId.value)) {
selectedCategoryId.value = defaultLifeCategoryId.value;
}
if (selectedGameVersionId.value && !gameVersions.value.some((gameVersion) => String(gameVersion.id) === selectedGameVersionId.value)) {
selectedGameVersionId.value = '';
}
} catch (error) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
}
}
async function loadLanguages() {
try {
languages.value = (await api.languages()).filter((language) => language.enabled);
if (
activeLanguageCode.value !== allLanguageValue &&
!languages.value.some((language) => language.code === activeLanguageCode.value)
) {
activeLanguageCode.value = allLanguageValue;
}
} catch (error) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
@@ -147,11 +259,21 @@ async function loadPosts() {
loadMorePaused.value = false;
try {
const page = await api.lifePosts({ limit: lifePostPageSize, search: searchQuery.value, tagId: selectedFeedTagId.value });
const page = await api.lifePosts({
limit: lifePostPageSize,
search: searchQuery.value,
categoryId: selectedFeedCategoryId.value,
language: selectedFeedLanguageCode.value,
gameVersionId: selectedFeedGameVersionId.value,
rateable: selectedRateableFilter.value,
sort: activeSort.value
});
if (requestId !== postsRequestId) {
return;
}
posts.value = page.items;
expandedComments.value = {};
commentPages.value = {};
nextCursor.value = page.nextCursor;
hasMorePosts.value = page.hasMore;
} catch (error) {
@@ -181,7 +303,16 @@ async function loadMorePosts() {
loadError.value = '';
try {
const page = await api.lifePosts({ cursor, limit: lifePostPageSize, search: searchQuery.value, tagId: selectedFeedTagId.value });
const page = await api.lifePosts({
cursor,
limit: lifePostPageSize,
search: searchQuery.value,
categoryId: selectedFeedCategoryId.value,
language: selectedFeedLanguageCode.value,
gameVersionId: selectedFeedGameVersionId.value,
rateable: selectedRateableFilter.value,
sort: activeSort.value
});
if (requestId !== postsRequestId) {
return;
}
@@ -204,7 +335,8 @@ async function loadMorePosts() {
function resetForm() {
body.value = '';
selectedTagIds.value = [];
selectedCategoryId.value = '';
selectedGameVersionId.value = '';
editingPostId.value = null;
formError.value = '';
}
@@ -212,10 +344,22 @@ function resetForm() {
function payload() {
return {
body: body.value.trim(),
tagIds: selectedTagIds.value.map((tagId) => Number(tagId)).filter((tagId) => Number.isInteger(tagId) && tagId > 0)
categoryId: selectedLifeCategoryId() ?? 0,
gameVersionId: selectedGameVersionForPost(),
languageCode: selectedFeedLanguageCode.value ?? null
};
}
function selectedLifeCategoryId() {
const categoryId = Number(selectedCategoryId.value);
return Number.isInteger(categoryId) && categoryId > 0 ? categoryId : null;
}
function selectedGameVersionForPost() {
const gameVersionId = Number(selectedGameVersionId.value);
return Number.isInteger(gameVersionId) && gameVersionId > 0 ? gameVersionId : null;
}
function submitSearch() {
const nextSearch = searchDraft.value.trim();
if (nextSearch === submittedSearch.value && !loadError.value) {
@@ -247,14 +391,21 @@ function retryLoadMore() {
function matchesCurrentFilters(post: LifePost) {
const keyword = searchQuery.value.toLowerCase();
const tagId = selectedFeedTagId.value;
const categoryId = selectedFeedCategoryId.value;
const gameVersionId = selectedFeedGameVersionId.value;
const rateable = selectedRateableFilter.value;
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword);
const matchesTag = tagId === undefined || post.tags.some((tag) => tag.id === tagId);
return matchesSearch && matchesTag;
const matchesCategory = categoryId === undefined || post.category?.id === categoryId;
const matchesGameVersion = gameVersionId === undefined || post.gameVersion?.id === gameVersionId;
const matchesRateable = rateable === null || post.category?.isRateable === rateable;
const matchesLanguage =
selectedFeedLanguageCode.value === undefined || post.moderationLanguageCode === selectedFeedLanguageCode.value;
return matchesSearch && matchesCategory && matchesGameVersion && matchesRateable && matchesLanguage;
}
function openCreatePostModal() {
resetForm();
selectedCategoryId.value = defaultLifeCategoryId.value;
postModalOpen.value = true;
void nextTick(() => bodyInput.value?.focus());
}
@@ -275,6 +426,12 @@ async function submitPost() {
return;
}
if (selectedLifeCategoryId() === null) {
formError.value = t('pages.life.categoryRequired');
document.getElementById('life-post-category')?.focus();
return;
}
busy.value = true;
formError.value = '';
@@ -284,7 +441,9 @@ async function submitPost() {
replacePost(updated);
} else {
const created = await api.createLifePost(payload());
if (matchesCurrentFilters(created)) {
if (activeSort.value !== 'latest') {
void loadPosts();
} else if (matchesCurrentFilters(created)) {
posts.value = [created, ...posts.value];
}
}
@@ -303,11 +462,15 @@ async function submitPost() {
}
function canManage(post: LifePost) {
return currentUser.value?.id === post.author?.id;
return (currentUser.value?.id === post.author?.id && can('life.posts.update')) || can('life.posts.update-any');
}
function canDeletePost(post: LifePost) {
return (currentUser.value?.id === post.author?.id && can('life.posts.delete')) || can('life.posts.delete-any');
}
function canManageComment(comment: LifeComment) {
return !comment.deleted && currentUser.value?.id === comment.author?.id;
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
}
function commentKey(postId: number) {
@@ -318,8 +481,36 @@ function replyKey(commentId: number) {
return `reply-${commentId}`;
}
function initialCommentPage(post: LifePost): LifeCommentPageState {
return {
items: post.commentPreview,
nextCursor: null,
hasMore: post.commentCount > post.commentPreview.reduce((count, comment) => count + 1 + comment.replies.length, 0),
total: post.commentCount,
loading: false,
loadingMore: false,
loaded: false,
error: ''
};
}
function commentPage(post: LifePost) {
return commentPages.value[post.id] ?? initialCommentPage(post);
}
function setCommentPage(postId: number, page: LifeCommentPageState) {
commentPages.value = {
...commentPages.value,
[postId]: page
};
}
function commentsForPost(post: LifePost) {
return commentPage(post).items;
}
function commentCount(post: LifePost) {
return post.comments.reduce((count, comment) => count + 1 + comment.replies.length, 0);
return commentPage(post).total;
}
function reactionTotal(post: LifePost) {
@@ -349,12 +540,63 @@ function reactionCountLabel(post: LifePost, type: LifeReactionType) {
});
}
function moderationLabel(status: AiModerationStatus) {
const labels: Record<AiModerationStatus, string> = {
unreviewed: t('pages.life.moderationUnreviewed'),
reviewing: t('pages.life.moderationReviewing'),
approved: t('pages.life.moderationApproved'),
rejected: t('pages.life.moderationRejected'),
failed: t('pages.life.moderationFailed')
};
return labels[status];
}
function moderationTone(status: AiModerationStatus) {
const tones: Record<AiModerationStatus, 'info' | 'success' | 'warning' | 'danger' | 'neutral'> = {
unreviewed: 'neutral',
reviewing: 'info',
approved: 'success',
rejected: 'danger',
failed: 'warning'
};
return tones[status];
}
function canRetryModeration(post: LifePost) {
return post.moderationStatus !== 'approved' && canManage(post);
}
async function retryPostModeration(post: LifePost) {
moderationBusyPostId.value = post.id;
const nextErrors = { ...moderationErrors.value };
delete nextErrors[post.id];
moderationErrors.value = nextErrors;
try {
replacePost(await api.retryLifePostModeration(post.id));
} catch (error) {
moderationErrors.value = {
...moderationErrors.value,
[post.id]: error instanceof Error && error.message ? error.message : t('pages.life.moderationRetryFailed')
};
} finally {
moderationBusyPostId.value = null;
}
}
function replacePost(updatedPost: LifePost) {
if (!matchesCurrentFilters(updatedPost)) {
posts.value = posts.value.filter((post) => post.id !== updatedPost.id);
return;
}
const existingComments = commentPages.value[updatedPost.id];
if (existingComments) {
setCommentPage(updatedPost.id, {
...existingComments,
total: updatedPost.commentCount
});
}
posts.value = posts.value.map((post) => (post.id === updatedPost.id ? updatedPost : post));
}
@@ -369,8 +611,56 @@ function setCommentsExpanded(postId: number, expanded: boolean) {
};
}
function toggleComments(postId: number) {
setCommentsExpanded(postId, !areCommentsExpanded(postId));
function mergeComments(existing: LifeComment[], incoming: LifeComment[]) {
const ids = new Set(existing.map((comment) => comment.id));
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
}
async function loadComments(post: LifePost, reset = false) {
const existing = commentPage(post);
if (existing.loading || existing.loadingMore || (!reset && existing.loaded && !existing.hasMore)) {
return;
}
const cursor = reset || !existing.loaded ? null : existing.nextCursor;
setCommentPage(post.id, {
...existing,
items: reset || !existing.loaded ? [] : existing.items,
loading: reset || !existing.loaded,
loadingMore: !reset && existing.loaded,
error: ''
});
try {
const page = await api.lifeComments(post.id, { limit: lifeCommentPageSize, cursor, language: selectedFeedLanguageCode.value });
const nextItems = reset || !existing.loaded ? page.items : mergeComments(existing.items, page.items);
setCommentPage(post.id, {
items: nextItems,
nextCursor: page.nextCursor,
hasMore: page.hasMore,
total: page.total,
loading: false,
loadingMore: false,
loaded: true,
error: ''
});
post.commentCount = page.total;
} catch (error) {
setCommentPage(post.id, {
...existing,
loading: false,
loadingMore: false,
error: error instanceof Error && error.message ? error.message : t('errors.loadFailed')
});
}
}
function toggleComments(post: LifePost) {
const expanded = !areCommentsExpanded(post.id);
setCommentsExpanded(post.id, expanded);
if (expanded) {
void loadComments(post);
}
}
function isCommentBusy(key: string) {
@@ -381,6 +671,10 @@ function isReactionBusy(postId: number) {
return reactionBusyPostId.value === postId;
}
function isRatingBusy(postId: number) {
return ratingBusyPostId.value === postId;
}
function commentAuthorName(comment: LifeComment) {
return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown');
}
@@ -400,6 +694,10 @@ function clearCommentError(key: string) {
commentErrors.value = nextErrors;
}
function updateCommentPage(post: LifePost, updater: (page: LifeCommentPageState) => LifeCommentPageState) {
setCommentPage(post.id, updater(commentPage(post)));
}
function setReactionError(postId: number, message: string) {
reactionErrors.value = { ...reactionErrors.value, [postId]: message };
}
@@ -410,8 +708,22 @@ function clearReactionError(postId: number) {
reactionErrors.value = nextErrors;
}
function setRatingError(postId: number, message: string) {
ratingErrors.value = { ...ratingErrors.value, [postId]: message };
}
function clearRatingError(postId: number) {
const nextErrors = { ...ratingErrors.value };
delete nextErrors[postId];
ratingErrors.value = nextErrors;
}
function canUseReactions() {
return canPost.value && reactionBusyPostId.value === null;
return canReact.value && reactionBusyPostId.value === null;
}
function canUseRatings(post: LifePost) {
return canRate.value && ratingBusyPostId.value === null && post.moderationStatus === 'approved' && post.category?.isRateable === true;
}
function closeReactionPicker() {
@@ -462,7 +774,7 @@ async function toggleDefaultReaction(post: LifePost) {
}
async function toggleReaction(post: LifePost, reactionType: LifeReactionType) {
if (!canUseReactions()) {
if (!canUseReactions() || post.moderationStatus !== 'approved') {
return;
}
@@ -483,10 +795,32 @@ async function toggleReaction(post: LifePost, reactionType: LifeReactionType) {
}
}
async function toggleRating(post: LifePost, rating: number) {
if (!canUseRatings(post)) {
return;
}
ratingBusyPostId.value = post.id;
clearRatingError(post.id);
try {
const updatedPost = post.myRating === rating ? await api.deleteLifeRating(post.id) : await api.setLifeRating(post.id, rating);
replacePost(updatedPost);
if (activeSort.value === 'top-rated') {
void loadPosts();
}
} catch (error) {
setRatingError(post.id, error instanceof Error && error.message ? error.message : t('pages.life.ratingFailed'));
} finally {
ratingBusyPostId.value = null;
}
}
function startEdit(post: LifePost) {
editingPostId.value = post.id;
body.value = post.body;
selectedTagIds.value = post.tags.map((tag) => String(tag.id));
selectedCategoryId.value = post.category ? String(post.category.id) : '';
selectedGameVersionId.value = post.gameVersion ? String(post.gameVersion.id) : '';
formError.value = '';
postModalOpen.value = true;
void nextTick(() => bodyInput.value?.focus());
@@ -534,8 +868,18 @@ async function submitComment(post: LifePost) {
clearCommentError(key);
try {
const comment = await api.createLifeComment(post.id, { body: nextBody });
post.comments.push(comment);
const comment = await api.createLifeComment(post.id, {
body: nextBody,
languageCode: selectedFeedLanguageCode.value ?? post.moderationLanguageCode
});
const nextTotal = commentCount(post) + 1;
post.commentCount = nextTotal;
updateCommentPage(post, (page) => ({
...page,
items: mergeComments(page.items, [comment]),
total: nextTotal,
loaded: page.loaded || areCommentsExpanded(post.id)
}));
commentBodies.value[post.id] = '';
setCommentsExpanded(post.id, true);
} catch (error) {
@@ -557,8 +901,17 @@ async function submitReply(post: LifePost, comment: LifeComment) {
clearCommentError(key);
try {
const reply = await api.createLifeCommentReply(post.id, comment.id, { body: nextBody });
const reply = await api.createLifeCommentReply(post.id, comment.id, {
body: nextBody,
languageCode: selectedFeedLanguageCode.value ?? comment.moderationLanguageCode ?? post.moderationLanguageCode
});
const nextTotal = commentCount(post) + 1;
post.commentCount = nextTotal;
comment.replies.push(reply);
updateCommentPage(post, (page) => ({
...page,
total: nextTotal
}));
setCommentsExpanded(post.id, true);
cancelReply(comment.id);
} catch (error) {
@@ -595,7 +948,7 @@ async function deleteComment(post: LifePost, comment: LifeComment) {
try {
await api.deleteLifeComment(comment.id);
markCommentDeleted(post.comments, comment.id);
markCommentDeleted(commentsForPost(post), comment.id);
if (replyTargetId.value === comment.id) {
cancelReply(comment.id);
}
@@ -650,11 +1003,26 @@ function observeLoadMore() {
}
watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], observeLoadMore, { flush: 'post' });
watch(activeTagId, () => {
watch(activeCategoryId, () => {
void loadPosts();
});
watch(activeLanguageCode, () => {
expandedComments.value = {};
commentPages.value = {};
void loadPosts();
});
watch(activeGameVersionId, () => {
void loadPosts();
});
watch(activeRateableFilter, () => {
void loadPosts();
});
watch(activeSort, () => {
void loadPosts();
});
watch(locale, () => {
void loadLifeTags();
void loadLanguages();
void loadLifeCategories();
void loadPosts();
});
@@ -662,7 +1030,8 @@ onMounted(() => {
document.addEventListener('click', closeReactionPickerFromDocument);
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
void loadCurrentUser();
void loadLifeTags();
void loadLanguages();
void loadLifeCategories();
void loadPosts();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
@@ -707,6 +1076,33 @@ onUnmounted(() => {
</button>
</form>
<div class="life-toolbar__filters">
<div class="field life-toolbar__select">
<label for="life-version-filter">{{ t('pages.life.versionFilter') }}</label>
<select id="life-version-filter" v-model="activeGameVersionId">
<option v-for="option in gameVersionFilterOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div class="field life-toolbar__select">
<label for="life-rateable-filter">{{ t('pages.life.ratingFilter') }}</label>
<select id="life-rateable-filter" v-model="activeRateableFilter">
<option v-for="option in rateableFilterOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div class="field life-toolbar__select">
<label for="life-sort">{{ t('pages.life.sort') }}</label>
<select id="life-sort" v-model="activeSort">
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
</div>
<div class="life-toolbar__actions">
<button class="ui-button ui-button--primary" :disabled="!authReady" type="button" @click="openCreatePostModal">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
@@ -745,13 +1141,27 @@ onUnmounted(() => {
</div>
<div class="field">
<label for="life-post-tags">{{ t('pages.life.tags') }}</label>
<label for="life-post-category">{{ t('pages.life.category') }}</label>
<TagsSelect
id="life-post-tags"
v-model="selectedTagIds"
:options="lifeTags"
:placeholder="t('pages.life.tagPlaceholder')"
:search-placeholder="t('pages.life.searchTags')"
id="life-post-category"
v-model="selectedCategoryId"
:options="lifeCategories"
:multiple="false"
:placeholder="t('pages.life.categoryPlaceholder')"
:search-placeholder="t('pages.life.searchCategories')"
dropdown-strategy="fixed"
/>
</div>
<div class="field">
<label for="life-post-version">{{ t('pages.life.gameVersion') }}</label>
<TagsSelect
id="life-post-version"
v-model="selectedGameVersionId"
:options="gameVersions"
:multiple="false"
:placeholder="t('pages.life.versionPlaceholder')"
:search-placeholder="t('pages.life.searchVersions')"
dropdown-strategy="fixed"
/>
</div>
@@ -778,7 +1188,8 @@ onUnmounted(() => {
</div>
</Modal>
<Tabs id="life-tag-filter" v-model="activeTagId" :tabs="tagFilterOptions" :label="t('pages.life.tags')" />
<Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" />
<Tabs id="life-category-filter" v-model="activeCategoryId" :tabs="categoryFilterOptions" :label="t('pages.life.category')" />
<section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')">
<div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')">
@@ -801,19 +1212,23 @@ onUnmounted(() => {
<header class="life-post__header">
<div class="life-post__avatar" aria-hidden="true">{{ authorInitial(post) }}</div>
<div class="life-post__byline">
<strong>{{ post.author?.displayName ?? t('pages.life.byUnknown') }}</strong>
<RouterLink v-if="post.author" class="user-profile-link" :to="`/profile/${post.author.id}`">
{{ post.author.displayName }}
</RouterLink>
<strong v-else>{{ t('pages.life.byUnknown') }}</strong>
<span>
<time :datetime="post.createdAt">{{ formatPostTime(post.createdAt) }}</time>
<template v-if="post.updatedAt !== post.createdAt"> - {{ t('pages.life.edited') }}</template>
</span>
</div>
<div v-if="canManage(post)" class="life-post__actions">
<button class="life-icon-button" type="button" :aria-label="t('pages.life.editPost')" @click="startEdit(post)">
<div v-if="canManage(post) || canDeletePost(post)" class="life-post__actions">
<button v-if="canManage(post)" class="life-icon-button" type="button" :aria-label="t('pages.life.editPost')" @click="startEdit(post)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.editPost') }}</span>
</button>
<button
v-if="canDeletePost(post)"
class="life-icon-button life-icon-button--danger"
type="button"
:aria-label="t('pages.life.deletePost')"
@@ -827,12 +1242,30 @@ onUnmounted(() => {
<p class="life-post__body">{{ post.body }}</p>
<div v-if="post.tags.length" class="life-post__tags" :aria-label="t('pages.life.tags')">
<span v-for="tag in post.tags" :key="tag.id" class="life-post__tag">{{ tag.name }}</span>
<div v-if="post.category || post.gameVersion" class="life-post__tags" :aria-label="t('pages.life.postMeta')">
<span v-if="post.category" class="life-post__tag">{{ post.category.name }}</span>
<span v-if="post.gameVersion" class="life-post__tag life-post__tag--version">
<Icon :icon="iconVersion" class="ui-icon" aria-hidden="true" />
{{ post.gameVersion.name }}
</span>
</div>
<details v-if="post.gameVersion?.changeLog" class="life-version-note">
<summary>{{ t('pages.life.changeLog') }}</summary>
<p>{{ post.gameVersion.changeLog }}</p>
</details>
<div class="life-post__engagement">
<div class="life-post__engagement-actions">
<LifeRatingControl
v-if="post.category?.isRateable"
:rating-average="post.ratingAverage"
:rating-count="post.ratingCount"
:my-rating="post.myRating"
:disabled="!canUseRatings(post)"
:busy="isRatingBusy(post.id)"
@rate="toggleRating(post, $event)"
/>
<div class="life-reactions">
<div class="life-reaction-control">
<button
@@ -842,7 +1275,7 @@ onUnmounted(() => {
:aria-controls="`life-reactions-${post.id}`"
:aria-expanded="reactionPickerPostId === post.id"
:aria-label="reactionButtonLabel(post)"
:disabled="!canPost || reactionBusyPostId !== null"
:disabled="!canReact || post.moderationStatus !== 'approved' || reactionBusyPostId !== null"
@click="toggleDefaultReaction(post)"
@contextmenu="handleReactionContextMenu($event, post.id)"
@keydown="handleReactionKeydown($event, post.id)"
@@ -857,7 +1290,7 @@ onUnmounted(() => {
:aria-controls="`life-reactions-${post.id}`"
:aria-expanded="reactionPickerPostId === post.id"
:aria-label="t('pages.life.chooseReaction')"
:disabled="!canPost || reactionBusyPostId !== null"
:disabled="!canReact || post.moderationStatus !== 'approved' || reactionBusyPostId !== null"
@click="toggleReactionPicker(post.id)"
@contextmenu="handleReactionContextMenu($event, post.id)"
@keydown="handleReactionKeydown($event, post.id)"
@@ -868,7 +1301,7 @@ onUnmounted(() => {
</div>
<div
v-if="reactionPickerPostId === post.id && canPost"
v-if="reactionPickerPostId === post.id && canReact"
:id="`life-reactions-${post.id}`"
class="life-reaction-picker"
role="group"
@@ -882,7 +1315,7 @@ onUnmounted(() => {
type="button"
:aria-pressed="post.myReaction === option.type"
:aria-label="reactionOptionLabel(post, option.type)"
:disabled="isReactionBusy(post.id)"
:disabled="post.moderationStatus !== 'approved' || isReactionBusy(post.id)"
@click="toggleReaction(post, option.type)"
>
<Icon :icon="option.icon" class="ui-icon" aria-hidden="true" />
@@ -897,13 +1330,31 @@ onUnmounted(() => {
:aria-controls="`life-comments-${post.id}`"
:aria-expanded="areCommentsExpanded(post.id)"
:aria-label="areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment')"
@click="toggleComments(post.id)"
:disabled="post.moderationStatus !== 'approved'"
@click="toggleComments(post)"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ areCommentsExpanded(post.id) ? t('pages.life.hideComments') : t('pages.life.comment') }}
</span>
</button>
<div class="life-post__review-actions">
<StatusBadge :label="moderationLabel(post.moderationStatus)" :tone="moderationTone(post.moderationStatus)" compact />
<button
v-if="canRetryModeration(post)"
class="life-icon-button life-review-button"
type="button"
:aria-label="moderationBusyPostId === post.id ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="moderationBusyPostId === post.id"
@click="retryPostModeration(post)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ moderationBusyPostId === post.id ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div>
</div>
<div class="life-post__metrics">
@@ -931,7 +1382,8 @@ onUnmounted(() => {
:aria-controls="`life-comments-${post.id}`"
:aria-expanded="areCommentsExpanded(post.id)"
:aria-label="t('pages.life.commentsCount', { count: commentCount(post) })"
@click="toggleComments(post.id)"
:disabled="post.moderationStatus !== 'approved'"
@click="toggleComments(post)"
>
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
<span>{{ commentCount(post) }}</span>
@@ -940,6 +1392,8 @@ onUnmounted(() => {
</div>
</div>
<p v-if="ratingErrors[post.id]" class="life-form__error" role="alert">{{ ratingErrors[post.id] }}</p>
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
<p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>
<section
@@ -953,7 +1407,7 @@ onUnmounted(() => {
<span>{{ commentCount(post) }}</span>
</div>
<form v-if="canPost" class="life-comment-form" @submit.prevent="submitComment(post)">
<form v-if="canComment" class="life-comment-form" @submit.prevent="submitComment(post)">
<div class="field">
<label :for="`life-comment-${post.id}`">{{ t('pages.life.comment') }}</label>
<textarea
@@ -976,9 +1430,23 @@ onUnmounted(() => {
</button>
</form>
<div v-if="post.comments.length" class="life-comment-list">
<div v-if="commentPage(post).loading && !commentsForPost(post).length" class="life-comment-list" :aria-label="t('pages.life.loadingComments')">
<article v-for="index in 2" :key="`life-comments-loading-${post.id}-${index}`" class="life-comment">
<div class="life-comment__main">
<Skeleton variant="box" width="36px" height="36px" />
<div class="life-comment__content">
<Skeleton width="132px" />
<Skeleton width="86%" />
</div>
</div>
</article>
</div>
<p v-else-if="commentPage(post).error" class="life-form__error" role="alert">{{ commentPage(post).error }}</p>
<div v-else-if="commentsForPost(post).length" class="life-comment-list">
<article
v-for="comment in post.comments"
v-for="comment in commentsForPost(post)"
:key="comment.id"
class="life-comment"
:class="{ 'is-deleted': comment.deleted }"
@@ -987,14 +1455,17 @@ onUnmounted(() => {
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
<div class="life-comment__content">
<div class="life-comment__meta">
<strong>{{ commentAuthorName(comment) }}</strong>
<RouterLink v-if="!comment.deleted && comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
{{ comment.author.displayName }}
</RouterLink>
<strong v-else>{{ commentAuthorName(comment) }}</strong>
<time :datetime="comment.createdAt">{{ formatPostTime(comment.createdAt) }}</time>
</div>
<p v-if="!comment.deleted" class="life-comment__body">{{ comment.body }}</p>
<div v-if="!comment.deleted" class="life-comment__actions">
<button
v-if="canPost"
v-if="canComment"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('pages.life.reply')"
@@ -1020,7 +1491,7 @@ onUnmounted(() => {
</p>
<form
v-if="canPost && replyTargetId === comment.id"
v-if="canComment && replyTargetId === comment.id"
class="life-comment-form life-comment-form--reply"
@submit.prevent="submitReply(post, comment)"
>
@@ -1059,7 +1530,10 @@ onUnmounted(() => {
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
<div class="life-comment__content">
<div class="life-comment__meta">
<strong>{{ commentAuthorName(reply) }}</strong>
<RouterLink v-if="!reply.deleted && reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
{{ reply.author.displayName }}
</RouterLink>
<strong v-else>{{ commentAuthorName(reply) }}</strong>
<time :datetime="reply.createdAt">{{ formatPostTime(reply.createdAt) }}</time>
</div>
<p v-if="!reply.deleted" class="life-comment__body">{{ reply.body }}</p>
@@ -1086,6 +1560,18 @@ onUnmounted(() => {
</div>
<p v-else class="life-comments__empty">{{ t('pages.life.noComments') }}</p>
<div v-if="commentPage(post).hasMore && !commentPage(post).loading" class="life-feed__retry">
<button
class="ui-button ui-button--ghost ui-button--small"
type="button"
:disabled="commentPage(post).loadingMore"
@click="loadComments(post)"
>
<Icon :icon="iconChevronDown" class="ui-icon" aria-hidden="true" />
{{ commentPage(post).loadingMore ? t('common.loading') : t('pages.life.loadMoreComments') }}
</button>
</div>
</section>
</article>

View File

@@ -9,16 +9,19 @@ import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import EntityChips from '../components/EntityChips.vue';
import Modal from '../components/Modal.vue';
import PageHeader from '../components/PageHeader.vue';
import PokeBallMark from '../components/PokeBallMark.vue';
import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit } from '../icons';
import { api, type PokemonDetail } from '../services/api';
import { iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
import { applySeo } from '../seo';
import { api, getAuthToken, type AuthUser, type PokemonDetail } from '../services/api';
import PokemonEdit from './PokemonEdit.vue';
const route = useRoute();
const { locale, t } = useI18n();
const { t } = useI18n();
const pokemon = ref<PokemonDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const itemCategoryTab = ref('');
const relatedHabitatTab = ref('');
const detailTab = ref('details');
@@ -30,6 +33,7 @@ const relatedPokemonLimit = 6;
type HabitatRow = {
id: number;
name: string;
image: PokemonDetail['habitats'][number]['image'];
timeOfDays: string[];
weathers: string[];
rarity: number;
@@ -78,6 +82,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
{
id: number;
name: string;
image: PokemonDetail['habitats'][number]['image'];
timeOfDays: Set<string>;
weathers: Set<string>;
rarity: number;
@@ -90,6 +95,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
const row = rows.get(key) ?? {
id: habitat.id,
name: habitat.name,
image: habitat.image,
timeOfDays: new Set<string>(),
weathers: new Set<string>(),
rarity: habitat.rarity,
@@ -105,6 +111,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
return [...rows.values()].map((row) => ({
id: row.id,
name: row.name,
image: row.image,
timeOfDays: sortByOrder(row.timeOfDays, timeOfDays),
weathers: sortByOrder(row.weathers, weathers),
rarity: row.rarity,
@@ -113,6 +120,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
});
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
const showEditor = computed(() => route.name === 'pokemon-edit');
const canUpdatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.update') === true);
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
@@ -202,17 +210,6 @@ function pokemonImageLabel() {
return pokemon.value.image.source === 'upload' ? t('media.uploadedImage') : `${pokemon.value.image.version} - ${pokemon.value.image.variant}`;
}
function imageFileName(path: string): string {
return path.split('/').at(-1) ?? t('media.image');
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
function openImageModal() {
imageModalOpen.value = true;
}
@@ -225,9 +222,25 @@ async function loadPokemonDetail() {
const nextPokemon = await api.pokemonDetail(String(route.params.id));
pokemon.value = nextPokemon;
relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id);
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextPokemon.name} - ${t('pages.pokemon.title')}`,
description: t('seo.pokemonDetailDescription', { name: nextPokemon.name }),
canonicalPath: `/pokemon/${nextPokemon.id}`,
image: nextPokemon.image?.url
});
}
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await loadPokemonDetail();
});
@@ -310,10 +323,10 @@ watch(
</div>
</section>
<section v-else class="page-stack">
<PageHeader :title="`#${pokemon.id} ${pokemon.name}`" :subtitle="t('pages.pokemon.environmentPrefix', { name: pokemon.environment.name })">
<PageHeader :title="`#${pokemon.displayId} ${pokemon.name}`" :subtitle="t('pages.pokemon.environmentPrefix', { name: pokemon.environment.name })">
<template #kicker>Pokédex Detail</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">
<RouterLink v-if="canUpdatePokemon" class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</RouterLink>
@@ -328,7 +341,7 @@ watch(
<Tabs id="pokemon-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
<div class="pokemon-profile-grid" :class="{ 'pokemon-profile-grid--with-image': pokemon.image }">
<div class="pokemon-profile-grid pokemon-profile-grid--with-image">
<div class="pokemon-profile-main">
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.details')">
<p v-if="pokemon.genus" class="pokemon-genus">{{ pokemon.genus }}</p>
@@ -371,7 +384,7 @@ watch(
</div>
</div>
<div class="pokemon-profile-side" :class="{ 'pokemon-profile-side--with-image': pokemon.image }">
<div class="pokemon-profile-side pokemon-profile-side--with-image">
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')">
<PokemonStatsPanel :stats="pokemon.stats" />
</DetailSection>
@@ -379,6 +392,9 @@ watch(
<button v-if="pokemon.image" type="button" class="pokemon-profile-image" :aria-label="pokemonImageLabel()" @click="openImageModal">
<img :src="pokemon.image.url" :alt="pokemonImageAlt()" />
</button>
<div v-else class="pokemon-profile-image pokemon-profile-image--placeholder" role="img" :aria-label="t('pages.pokemon.imageEmpty')">
<PokeBallMark size="64px" />
</div>
</div>
</div>
@@ -390,7 +406,13 @@ watch(
<ul class="row-list skill-drop-summary">
<li v-for="skill in skillDropRows" :key="skill.id">
<span>{{ t('pages.pokemon.skillDrop', { name: skill.name }) }}</span>
<RouterLink v-if="skill.itemDrop" :to="`/items/${skill.itemDrop.id}`">{{ skill.itemDrop.name }}</RouterLink>
<RouterLink v-if="skill.itemDrop" class="related-entity-link related-entity-link--compact" :to="`/items/${skill.itemDrop.id}`">
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="skill.itemDrop.image" :src="skill.itemDrop.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
</span>
<span>{{ skill.itemDrop.name }}</span>
</RouterLink>
</li>
</ul>
</DetailSection>
@@ -411,9 +433,14 @@ watch(
/>
<ul v-if="relatedPokemonRows.length" class="row-list related-pokemon-list">
<li v-for="related in relatedPokemonRows" :key="related.id">
<div class="related-pokemon-list-item">
<span class="related-entity-media related-entity-media--pokemon" aria-hidden="true">
<img v-if="related.image" :src="related.image.url" alt="" loading="lazy" />
<PokeBallMark v-else size="24px" />
</span>
<div class="related-pokemon-row">
<div class="related-pokemon-row__summary">
<RouterLink class="related-pokemon-row__name" :to="`/pokemon/${related.id}`">#{{ related.id }} {{ related.name }}</RouterLink>
<RouterLink class="related-pokemon-row__name" :to="`/pokemon/${related.id}`">#{{ related.displayId }} {{ related.name }}</RouterLink>
<div class="related-pokemon-row__traits">
<EntityChips
v-if="related.skills.length"
@@ -442,6 +469,7 @@ watch(
</span>
</div>
</div>
</div>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
@@ -460,7 +488,13 @@ watch(
/>
<ul v-if="favoriteThingItems.length" class="row-list">
<li v-for="item in favoriteThingItems" :key="item.id">
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/items/${item.id}`">
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
</span>
<span>{{ item.name }}</span>
</RouterLink>
<EntityChips :items="item.tags" />
</li>
</ul>
@@ -471,8 +505,12 @@ watch(
</div>
<DetailSection :title="t('pages.pokemon.habitats')">
<ul class="row-list appearance-list">
<ul class="row-list appearance-list appearance-list--with-media">
<li v-for="habitat in habitatRows" :key="`${habitat.id}-${habitat.rarity}`">
<span class="related-entity-media related-entity-media--appearance" aria-hidden="true">
<img v-if="habitat.image" :src="habitat.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconHabitat" class="related-entity-media__icon" aria-hidden="true" />
</span>
<RouterLink class="appearance-name" :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
<dl class="appearance-summary">
<div>
@@ -523,13 +561,6 @@ watch(
<strong>{{ pokemonImageLabel() }}</strong>
<span v-if="pokemon.image.style">{{ pokemon.image.style }}</span>
<p v-if="pokemon.image.description">{{ pokemon.image.description }}</p>
<div v-if="pokemon.imageHistory.length" class="image-history-list" :aria-label="t('media.imageHistory')">
<div v-for="image in pokemon.imageHistory" :key="image.path" class="image-history-list__item">
<img :src="image.url" :alt="t('media.imageAlt', { name: pokemon.name })" loading="lazy" />
<span>{{ imageFileName(image.path) }}</span>
<span>{{ formatDateTime(image.uploadedAt) }}</span>
</div>
</div>
</div>
</div>
</Modal>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import ImageUploadField from '../components/ImageUploadField.vue';
@@ -14,6 +14,8 @@ import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave, iconSearch } from '../icons';
import {
api,
getAuthToken,
type AuthUser,
type ConfigType,
type EntityImage,
type EntityImageUpload,
@@ -39,6 +41,7 @@ const { locale, t } = useI18n();
const options = ref<Options | null>(null);
const itemOptions = ref<NamedEntity[]>([]);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const busy = ref(false);
const fetchBusy = ref(false);
@@ -46,8 +49,10 @@ const imageBusy = ref(false);
const fetchOptionsLoading = ref(false);
const fetchOptionsOpen = ref(false);
const message = ref('');
const fetchInput = ref<HTMLInputElement | null>(null);
const fetchIdentifier = ref('');
const fetchOptions = ref<PokemonFetchOption[]>([]);
const fetchResultsStyle = ref<CSSProperties>({});
const imageOptions = ref<PokemonImage[]>([]);
const currentPokemonImage = ref<PokemonImage | null>(null);
const imageHistory = ref<EntityImageUpload[]>([]);
@@ -56,6 +61,7 @@ const activeEditTab = ref('basic');
const heightUnit = ref<'imperial' | 'metric'>('imperial');
const weightUnit = ref<'imperial' | 'metric'>('imperial');
let fetchOptionsController: AbortController | null = null;
let fetchPositionFrame = 0;
function defaultPokemonStats(): PokemonStats {
return {
@@ -70,6 +76,7 @@ function defaultPokemonStats(): PokemonStats {
const pokemonForm = ref({
id: '',
isEventItem: false,
name: '',
genus: '',
details: '',
@@ -124,6 +131,9 @@ const displayedImageOptions = computed(() => {
return [selectedImage, ...imageOptions.value];
});
const selectedUploadImage = computed(() => (selectedPokemonImage.value?.source === 'upload' ? selectedPokemonImage.value : null));
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
const canFetchPokemon = computed(() => currentUser.value?.permissions.includes('pokemon.fetch') === true);
const canUploadImage = computed(() => currentUser.value?.permissions.includes('pokemon.upload') === true);
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
@@ -170,6 +180,19 @@ async function loadOptions() {
languages.value = loadedLanguages;
}
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
function syncSkillItemDrops() {
const selectedSkillIds = new Set(pokemonForm.value.skillIds);
const rows = pokemonForm.value.skillItemDrops.filter((row) => selectedSkillIds.has(row.skillId) && skillSupportsItemDrop(row.skillId));
@@ -206,7 +229,7 @@ function pokemonNameForSave() {
}
function pokemonIdForSave() {
return Number(isEditing.value ? routeId.value : pokemonForm.value.id);
return Number(pokemonForm.value.id);
}
function mergeFetchedTranslations(fetchedTranslations: TranslationMap | undefined): TranslationMap {
@@ -269,11 +292,12 @@ async function loadEditor() {
message.value = '';
try {
await loadOptions();
await Promise.all([loadCurrentUser(), loadOptions()]);
if (isEditing.value) {
const pokemon = await api.pokemonDetail(routeId.value);
pokemonForm.value = {
id: String(pokemon.id),
id: String(pokemon.displayId),
isEventItem: pokemon.isEventItem,
name: pokemon.baseName ?? pokemon.name,
genus: pokemon.baseGenus ?? pokemon.genus,
details: pokemon.baseDetails ?? pokemon.details,
@@ -314,6 +338,10 @@ function fetchOptionLabel(option: PokemonFetchOption) {
}
async function loadFetchOptions() {
if (!canFetchPokemon.value) {
return;
}
cancelFetchOptionsRequest();
const controller = new AbortController();
fetchOptionsController = controller;
@@ -348,18 +376,90 @@ function refreshFetchOptions() {
void loadFetchOptions();
}
function updateFetchResultsPosition() {
if (!fetchInput.value) {
fetchResultsStyle.value = {};
return;
}
const viewportPadding = 12;
const dropdownGap = 6;
const inputRect = fetchInput.value.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const width = Math.min(inputRect.width, viewportWidth - viewportPadding * 2);
const left = Math.min(Math.max(inputRect.left, viewportPadding), viewportWidth - width - viewportPadding);
const spaceBelow = viewportHeight - inputRect.bottom - viewportPadding - dropdownGap;
const spaceAbove = inputRect.top - viewportPadding - dropdownGap;
const placeAbove = spaceBelow < 180 && spaceAbove > spaceBelow;
const maxHeight = Math.max(96, Math.min(260, placeAbove ? spaceAbove : spaceBelow));
const nextStyle = {
left: `${left}px`,
width: `${width}px`,
'--pokemon-fetch-results-max-height': `${maxHeight}px`
} as CSSProperties;
fetchResultsStyle.value = placeAbove
? { ...nextStyle, bottom: `${viewportHeight - inputRect.top + dropdownGap}px` }
: { ...nextStyle, top: `${inputRect.bottom + dropdownGap}px` };
}
function scheduleFetchResultsPositionUpdate() {
if (!fetchOptionsOpen.value || fetchPositionFrame) {
return;
}
fetchPositionFrame = window.requestAnimationFrame(() => {
fetchPositionFrame = 0;
updateFetchResultsPosition();
});
}
function addFetchPositionListeners() {
window.addEventListener('resize', scheduleFetchResultsPositionUpdate);
window.addEventListener('scroll', scheduleFetchResultsPositionUpdate, true);
}
function removeFetchPositionListeners() {
window.removeEventListener('resize', scheduleFetchResultsPositionUpdate);
window.removeEventListener('scroll', scheduleFetchResultsPositionUpdate, true);
if (fetchPositionFrame) {
window.cancelAnimationFrame(fetchPositionFrame);
fetchPositionFrame = 0;
}
}
function positionFetchResultsAfterOpen() {
updateFetchResultsPosition();
addFetchPositionListeners();
void nextTick(updateFetchResultsPosition);
}
function openFetchOptions() {
if (!canFetchPokemon.value) {
return;
}
fetchOptionsOpen.value = true;
positionFetchResultsAfterOpen();
refreshFetchOptions();
}
function closeFetchOptions() {
fetchOptionsOpen.value = false;
fetchResultsStyle.value = {};
removeFetchPositionListeners();
cancelFetchOptionsRequest();
}
function handleFetchIdentifierInput() {
if (!canFetchPokemon.value) {
return;
}
fetchOptionsOpen.value = true;
positionFetchResultsAfterOpen();
}
function closeFetchOptionsAfterBlur() {
@@ -373,6 +473,10 @@ async function selectFetchOption(option: PokemonFetchOption) {
}
async function fetchPokemonByIdentifier(identifierValue?: string) {
if (!canFetchPokemon.value) {
return;
}
const identifier = (identifierValue ?? fetchIdentifier.value).trim() || pokemonForm.value.id.trim();
if (!identifier) {
message.value = t('pages.pokemon.fetchIdentifierRequired');
@@ -444,6 +548,10 @@ function handleUploadImageUploaded(image: EntityImageUpload) {
}
async function fetchPokemonImages() {
if (!canFetchPokemon.value) {
return;
}
const identifier = fetchIdentifier.value.trim() || pokemonForm.value.id.trim();
if (!identifier) {
message.value = t('pages.pokemon.fetchIdentifierRequired');
@@ -485,7 +593,7 @@ function fetchPokemonImagesFromInput() {
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
const cleanName = name.trim();
if (!cleanName) return;
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
@@ -502,7 +610,7 @@ async function createSingleOption(selectKey: string, type: ConfigType, name: str
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[], max = 0) {
const cleanName = name.trim();
if (!cleanName || (max > 0 && values.length >= max)) return;
if (!cleanName || !canCreateConfig.value || (max > 0 && values.length >= max)) return;
creatingSelect.value = selectKey;
message.value = '';
@@ -535,7 +643,8 @@ async function savePokemon() {
try {
const payload: PokemonPayload = {
id: pokemonIdForSave(),
displayId: pokemonIdForSave(),
isEventItem: pokemonForm.value.isEventItem,
name: pokemonNameForSave(),
genus: pokemonForm.value.genus,
details: pokemonForm.value.details,
@@ -565,7 +674,10 @@ onMounted(() => {
void loadEditor();
});
onBeforeUnmount(cancelFetchOptionsRequest);
onBeforeUnmount(() => {
cancelFetchOptionsRequest();
removeFetchPositionListeners();
});
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
watch(fetchIdentifier, refreshFetchOptions);
@@ -578,11 +690,12 @@ watch(fetchIdentifier, refreshFetchOptions);
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form modal-edit-form--tabbed pokemon-edit-form" @submit.prevent="savePokemon">
<Tabs id="pokemon-edit-tabs" v-model="activeEditTab" :tabs="editTabs" :label="t('pages.pokemon.editSections')" />
<div class="pokemon-fetch-panel" :aria-label="t('pages.pokemon.fetchData')">
<div v-if="canFetchPokemon" class="pokemon-fetch-panel" :aria-label="t('pages.pokemon.fetchData')">
<div class="field pokemon-fetch-panel__input">
<label for="pokemon-fetch-identifier">{{ t('pages.pokemon.fetchIdentifier') }}</label>
<input
id="pokemon-fetch-identifier"
ref="fetchInput"
v-model="fetchIdentifier"
type="search"
:placeholder="t('pages.pokemon.fetchIdentifierPlaceholder')"
@@ -596,7 +709,14 @@ watch(fetchIdentifier, refreshFetchOptions);
@keydown.escape.stop="closeFetchOptions"
@keydown.enter.prevent="fetchPokemonFromInput"
/>
<div v-if="fetchOptionsOpen" id="pokemon-fetch-results" class="pokemon-fetch-results" role="listbox" :aria-label="t('pages.pokemon.fetchResults')">
<div
v-if="fetchOptionsOpen"
id="pokemon-fetch-results"
class="pokemon-fetch-results"
:style="fetchResultsStyle"
role="listbox"
:aria-label="t('pages.pokemon.fetchResults')"
>
<p v-if="fetchOptionsLoading" class="pokemon-fetch-results__status">{{ t('pages.pokemon.fetchSearching') }}</p>
<template v-else-if="fetchOptions.length">
<button
@@ -630,8 +750,8 @@ watch(fetchIdentifier, refreshFetchOptions);
<section v-if="activeEditTab === 'basic'" class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabBasic')">
<div class="pokemon-edit-grid">
<div class="field">
<label for="pokemon-id">ID</label>
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
<label for="pokemon-id">{{ t('pages.pokemon.id') }}</label>
<input id="pokemon-id" v-model="pokemonForm.id" min="1" required type="number" />
</div>
<TranslationFields
@@ -645,6 +765,10 @@ watch(fetchIdentifier, refreshFetchOptions);
/>
</div>
<div class="check-row">
<label><input v-model="pokemonForm.isEventItem" type="checkbox" /> {{ t('pages.pokemon.eventItem') }}</label>
</div>
<div class="pokemon-edit-grid">
<div class="field">
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
@@ -653,7 +777,7 @@ watch(fetchIdentifier, refreshFetchOptions);
v-model="pokemonForm.environmentId"
:options="options.environments"
:multiple="false"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'pokemon-environment'"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchEnvironment')"
@@ -668,7 +792,7 @@ watch(fetchIdentifier, refreshFetchOptions);
v-model="pokemonForm.skillIds"
:options="options.skills"
:max="2"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'pokemon-skills'"
:placeholder="t('pages.pokemon.searchSkills')"
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
@@ -683,7 +807,7 @@ watch(fetchIdentifier, refreshFetchOptions);
v-model="pokemonForm.favoriteThingIds"
:options="options.favoriteThings"
:max="6"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'pokemon-things'"
:placeholder="t('pages.pokemon.searchFavoriteThings')"
@create="createMultiOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)"
@@ -757,6 +881,7 @@ watch(fetchIdentifier, refreshFetchOptions);
:current-image="selectedUploadImage"
:history="imageHistory"
:disabled="busy || imageBusy"
:allow-upload="canUploadImage"
:show-preview="false"
@selected="handleUploadImageSelected"
@uploaded="handleUploadImageUploaded"

View File

@@ -9,13 +9,14 @@ import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd } from '../icons';
import { api, type Options, type Pokemon } from '../services/api';
import { api, getAuthToken, type AuthUser, type Options, type Pokemon } from '../services/api';
import PokemonEdit from './PokemonEdit.vue';
const options = ref<Options | null>(null);
const route = useRoute();
const { t } = useI18n();
const pokemon = ref<Pokemon[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const search = ref('');
const environmentId = ref('');
@@ -35,6 +36,7 @@ const query = computed(() => ({
favoriteThingMode: favoriteThingMode.value
}));
const showEditor = computed(() => route.name === 'pokemon-new');
const canCreatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.create') === true);
async function loadPokemon() {
loading.value = true;
@@ -47,6 +49,13 @@ function pokemonCardImage(item: Pokemon) {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
options.value = await api.options();
await loadPokemon();
});
@@ -59,7 +68,7 @@ watch(query, loadPokemon);
<PageHeader :title="t('pages.pokemon.title')" :subtitle="t('pages.pokemon.subtitle')">
<template #kicker>Pokédex</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/pokemon/new">
<RouterLink v-if="canCreatePokemon" class="ui-button ui-button--primary ui-button--small" to="/pokemon/new">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.add') }}
</RouterLink>
@@ -134,7 +143,7 @@ watch(query, loadPokemon);
<EntityCard
v-for="item in pokemon"
:key="item.id"
:title="`#${item.id} ${item.name}`"
:title="`#${item.displayId} ${item.name}`"
:to="`/pokemon/${item.id}`"
:image="pokemonCardImage(item)"
/>

View File

@@ -10,26 +10,60 @@ import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit } from '../icons';
import { api, type RecipeDetail } from '../services/api';
import { iconBack, iconEdit, iconRecipe } from '../icons';
import { applySeo } from '../seo';
import { api, getAuthToken, type AuthUser, type RecipeDetail } from '../services/api';
import RecipeEdit from './RecipeEdit.vue';
const route = useRoute();
const { t } = useI18n();
const recipe = ref<RecipeDetail | null>(null);
const currentUser = ref<AuthUser | null>(null);
const detailTab = ref('details');
const showEditor = computed(() => route.name === 'recipe-edit');
const canUpdateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.update') === true);
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') }
]);
const recipeSubtitle = computed(() => {
if (!recipe.value) {
return '';
}
const categoryName = recipe.value.item.category?.name;
const usageName = recipe.value.item.usage?.name;
if (categoryName && usageName) {
return `${categoryName} · ${usageName}`;
}
return categoryName ?? t('pages.recipes.detailSubtitle');
});
async function loadRecipeDetail() {
recipe.value = await api.recipeDetail(String(route.params.id));
const nextRecipe = await api.recipeDetail(String(route.params.id));
recipe.value = nextRecipe;
if (route.meta.editorModal !== true) {
applySeo({
title: `${nextRecipe.name} - ${t('pages.recipes.title')}`,
description: t('seo.recipeDetailDescription', { name: nextRecipe.name }),
canonicalPath: `/recipes/${nextRecipe.id}`,
image: nextRecipe.item.image?.url
});
}
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await loadRecipeDetail();
});
@@ -80,10 +114,10 @@ watch(
</div>
</section>
<section v-else class="page-stack">
<PageHeader :title="recipe.name" :subtitle="t('pages.recipes.detailSubtitle')">
<template #kicker>Recipe Detail</template>
<PageHeader :title="recipe.name" :subtitle="recipeSubtitle">
<template #kicker>{{ t('pages.recipes.detailKicker') }}</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
<RouterLink v-if="canUpdateRecipe" class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</RouterLink>
@@ -97,14 +131,60 @@ watch(
<div class="detail-tabs">
<Tabs id="recipe-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
<div v-if="detailTab === 'details'" class="detail-grid">
<DetailSection :title="t('pages.items.acquisitionMethods')">
<EntityChips :items="recipe.acquisition_methods" />
</DetailSection>
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
<div class="entity-profile-grid">
<section class="detail-section entity-profile-media-section" :aria-label="t('pages.recipes.item')">
<div class="entity-detail-image">
<RouterLink
class="entity-detail-image__frame entity-detail-image__frame--link"
:class="{ 'entity-detail-image__frame--placeholder': !recipe.item.image }"
:to="`/items/${recipe.item.id}`"
>
<img v-if="recipe.item.image" :src="recipe.item.image.url" :alt="t('media.imageAlt', { name: recipe.item.name })" />
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
<Icon :icon="iconRecipe" class="entity-card__icon" aria-hidden="true" />
</span>
</RouterLink>
<RouterLink class="entity-profile-title-link" :to="`/items/${recipe.item.id}`">{{ recipe.item.name }}</RouterLink>
</div>
</section>
<DetailSection :title="t('pages.recipes.materials')">
<EntityChips :items="recipe.materials" />
</DetailSection>
<div class="entity-profile-main">
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
<dl class="entity-profile-facts">
<div>
<dt>{{ t('pages.items.category') }}</dt>
<dd>{{ recipe.item.category?.name ?? t('common.none') }}</dd>
</div>
<div>
<dt>{{ t('pages.items.usage') }}</dt>
<dd>{{ recipe.item.usage?.name ?? t('common.none') }}</dd>
</div>
<div>
<dt>{{ t('pages.items.acquisitionMethods') }}</dt>
<dd>{{ recipe.acquisition_methods.length }}</dd>
</div>
<div>
<dt>{{ t('pages.recipes.materials') }}</dt>
<dd>{{ recipe.materials.length }}</dd>
</div>
</dl>
<div class="entity-profile-groups">
<div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
<EntityChips v-if="recipe.acquisition_methods.length" :items="recipe.acquisition_methods" />
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
<div class="entity-profile-group">
<h3 class="section-subtitle">{{ t('pages.recipes.materials') }}</h3>
<EntityChips v-if="recipe.materials.length" :items="recipe.materials" />
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
</div>
</section>
</div>
</div>
</div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">

View File

@@ -8,13 +8,14 @@ import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconCancel, iconDelete, iconSave } from '../icons';
import { api, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api';
import { api, getAuthToken, type AuthUser, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const options = ref<Options | null>(null);
const itemRows = ref<Item[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
@@ -40,6 +41,7 @@ const pageTitle = computed(() =>
: t('pages.recipes.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/recipes/${routeId.value}` : '/recipes'));
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
@@ -73,7 +75,7 @@ async function loadEditor() {
message.value = '';
try {
const [loadedOptions, loadedItems] = await Promise.all([api.options(), api.items({})]);
const [, loadedOptions, loadedItems] = await Promise.all([loadCurrentUser(), api.options(), api.items({})]);
options.value = loadedOptions;
itemRows.value = loadedItems;
@@ -102,9 +104,22 @@ async function loadOptions() {
options.value = await api.options();
}
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
const cleanName = name.trim();
if (!cleanName) return;
if (!cleanName || !canCreateConfig.value) return;
creatingSelect.value = selectKey;
message.value = '';
@@ -169,7 +184,7 @@ onMounted(() => {
id="recipe-methods"
v-model="recipeForm.acquisitionMethodIds"
:options="options.acquisitionMethods"
allow-create
:allow-create="canCreateConfig"
:creating="creatingSelect === 'recipe-methods'"
:placeholder="t('pages.items.searchMethods')"
@create="createMultiOption('recipe-methods', 'acquisition-methods', $event, recipeForm.acquisitionMethodIds)"

View File

@@ -10,13 +10,14 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconNoRecipe, iconRecipe } from '../icons';
import { api, type Item, type Options } from '../services/api';
import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api';
import RecipeEdit from './RecipeEdit.vue';
const options = ref<Options | null>(null);
const route = useRoute();
const { t } = useI18n();
const items = ref<Item[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const search = ref('');
const categoryId = ref('');
@@ -40,6 +41,7 @@ const itemQuery = computed(() => ({
recipeOrder: 1
}));
const showEditor = computed(() => route.name === 'recipe-new');
const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true);
function recipeTarget(item: Item) {
return item.recipe ? `/recipes/${item.recipe.id}` : undefined;
@@ -68,6 +70,13 @@ async function loadItems() {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
options.value = await api.options();
await loadItems();
});
@@ -80,7 +89,7 @@ watch(itemQuery, loadItems);
<PageHeader :title="t('pages.recipes.title')" :subtitle="t('pages.recipes.subtitle')">
<template #kicker>Recipes</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/recipes/new">
<RouterLink v-if="canCreateRecipe" class="ui-button ui-button--primary ui-button--small" to="/recipes/new">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('common.add') }}
</RouterLink>
@@ -172,7 +181,7 @@ watch(itemQuery, loadItems);
{{ t('pages.items.createRecipe') }}
</button>
<RouterLink
v-else
v-else-if="canCreateRecipe"
class="ui-button ui-button--primary ui-button--small catalog-card-action"
:to="createRecipeTarget(item)"
>

View File

@@ -2,14 +2,17 @@
import { Icon } from '@iconify/vue';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue';
import { iconMail } from '../icons';
import { api } from '../services/api';
const route = useRoute();
const email = ref('');
const displayName = ref('');
const password = ref('');
const referralCode = ref(typeof route.query.ref === 'string' ? route.query.ref.trim().toUpperCase() : '');
const busy = ref(false);
const message = ref('');
const errorMessage = ref('');
@@ -24,7 +27,8 @@ async function submitRegister() {
const response = await api.register({
email: email.value,
displayName: displayName.value,
password: password.value
password: password.value,
referralCode: referralCode.value
});
message.value = response.message;
} catch (error) {
@@ -65,6 +69,19 @@ async function submitRegister() {
/>
</div>
<div class="field">
<label for="register-referral-code">{{ t('auth.referralCode') }}</label>
<input
id="register-referral-code"
v-model="referralCode"
autocomplete="off"
inputmode="text"
maxlength="16"
:placeholder="t('auth.referralCodePlaceholder')"
/>
<small class="auth-field-note">{{ t('auth.referralCodeHint') }}</small>
</div>
<StatusMessage v-if="message" variant="success">{{ message }}</StatusMessage>
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';
import { createServer } from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), 'dist');
const indexPath = path.join(root, 'index.html');
const host = process.env.HOST ?? '0.0.0.0';
const port = Number.parseInt(process.env.PORT ?? '20015', 10);
const contentTypes = new Map([
['.css', 'text/css; charset=utf-8'],
['.gif', 'image/gif'],
['.html', 'text/html; charset=utf-8'],
['.ico', 'image/x-icon'],
['.jpg', 'image/jpeg'],
['.js', 'text/javascript; charset=utf-8'],
['.json', 'application/json; charset=utf-8'],
['.png', 'image/png'],
['.svg', 'image/svg+xml'],
['.txt', 'text/plain; charset=utf-8'],
['.wasm', 'application/wasm'],
['.webmanifest', 'application/manifest+json; charset=utf-8'],
['.webp', 'image/webp'],
['.woff', 'font/woff'],
['.woff2', 'font/woff2'],
['.xml', 'application/xml; charset=utf-8']
]);
function isInsideRoot(filePath) {
const relativePath = path.relative(root, filePath);
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
}
function resolvePath(url) {
try {
const pathname = new URL(url ?? '/', 'http://localhost').pathname;
const filePath = path.resolve(root, `.${decodeURIComponent(pathname)}`);
return isInsideRoot(filePath) ? filePath : indexPath;
} catch {
return indexPath;
}
}
async function findStaticFile(url) {
const filePath = resolvePath(url);
try {
const fileStat = await stat(filePath);
if (fileStat.isFile()) {
return { filePath, fileStat };
}
} catch {
return { filePath: indexPath, fileStat: await stat(indexPath) };
}
return { filePath: indexPath, fileStat: await stat(indexPath) };
}
createServer(async (request, response) => {
if (request.method !== 'GET' && request.method !== 'HEAD') {
response.writeHead(405);
response.end();
return;
}
const { filePath, fileStat } = await findStaticFile(request.url);
const contentType = contentTypes.get(path.extname(filePath)) ?? 'application/octet-stream';
response.writeHead(200, {
'Cache-Control': filePath.endsWith('index.html') ? 'no-cache' : 'public, max-age=31536000, immutable',
'Content-Length': fileStat.size,
'Content-Type': contentType
});
if (request.method === 'HEAD') {
response.end();
return;
}
createReadStream(filePath).pipe(response);
}).listen(port, host);

View File

@@ -1,9 +1,104 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv, type PluginOption } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 3000
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
const frontendPort = 20015;
const sitemapPaths = ['/pokemon', '/habitats', '/items', '/recipes', '/checklist', '/life'];
const robotsDisallowPaths = [
'/admin',
'/login',
'/register',
'/forgot-password',
'/reset-password',
'/verify-email',
'/pokemon/new',
'/pokemon/*/edit',
'/habitats/new',
'/habitats/*/edit',
'/items/new',
'/items/*/edit',
'/recipes/new',
'/recipes/*/edit',
'/automation',
'/dish',
'/events',
'/actions',
'/dream-island',
'/clothes'
];
function normalizeSiteUrl(value: string | undefined): string {
return (value?.trim() || fallbackSiteUrl).replace(/\/+$/, '');
}
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`;
}
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>
`;
}
function seoFilesPlugin(siteUrl: string): PluginOption {
return {
name: 'pokopia-seo-files',
transformIndexHtml(html) {
return html.replaceAll('%POKOPIA_SITE_URL%', siteUrl);
},
configureServer(server) {
server.middlewares.use((request, response, next) => {
if (request.url === '/robots.txt') {
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
response.end(robotsTxt(siteUrl));
return;
}
if (request.url === '/sitemap.xml') {
response.setHeader('Content-Type', 'application/xml; charset=utf-8');
response.end(sitemapXml(siteUrl));
return;
}
next();
});
},
generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'robots.txt',
source: robotsTxt(siteUrl)
});
this.emitFile({
type: 'asset',
fileName: 'sitemap.xml',
source: sitemapXml(siteUrl)
});
}
};
}
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const siteUrl = normalizeSiteUrl(process.env.VITE_SITE_URL ?? env.VITE_SITE_URL);
return {
plugins: [vue(), seoFilesPlugin(siteUrl)],
server: {
port: frontendPort
}
};
});

52
pnpm-lock.yaml generated
View File

@@ -11,69 +11,72 @@ importers:
backend:
dependencies:
'@fastify/cors':
specifier: latest
specifier: 11.2.0
version: 11.2.0
'@fastify/multipart':
specifier: ^10.0.0
specifier: 10.0.0
version: 10.0.0
'@fastify/rate-limit':
specifier: 10.3.0
version: 10.3.0
'@fastify/static':
specifier: ^9.1.3
specifier: 9.1.3
version: 9.1.3
fastify:
specifier: latest
specifier: 5.8.5
version: 5.8.5
pg:
specifier: latest
specifier: 8.20.0
version: 8.20.0
devDependencies:
'@types/node':
specifier: latest
specifier: 25.6.0
version: 25.6.0
'@types/pg':
specifier: latest
specifier: 8.20.0
version: 8.20.0
tsx:
specifier: latest
specifier: 4.21.0
version: 4.21.0
typescript:
specifier: latest
specifier: 6.0.3
version: 6.0.3
frontend:
dependencies:
'@iconify/vue':
specifier: ^5.0.0
specifier: 5.0.0
version: 5.0.0(vue@3.5.33(typescript@6.0.3))
'@vitejs/plugin-vue':
specifier: latest
specifier: 6.0.6
version: 6.0.6(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3))(vue@3.5.33(typescript@6.0.3))
vite:
specifier: latest
specifier: 8.0.10
version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3)
vue:
specifier: latest
specifier: 3.5.33
version: 3.5.33(typescript@6.0.3)
vue-i18n:
specifier: ^11.4.0
specifier: 11.4.0
version: 11.4.0(vue@3.5.33(typescript@6.0.3))
vue-router:
specifier: latest
specifier: 5.0.6
version: 5.0.6(@vue/compiler-sfc@3.5.33)(vue@3.5.33(typescript@6.0.3))
devDependencies:
'@types/node':
specifier: latest
specifier: 25.6.0
version: 25.6.0
'@vue/tsconfig':
specifier: latest
specifier: 0.9.1
version: 0.9.1(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3))
typescript:
specifier: latest
specifier: 6.0.3
version: 6.0.3
vitest:
specifier: latest
specifier: 4.1.5
version: 4.1.5(@types/node@25.6.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(tsx@4.21.0)(yaml@2.8.3))
vue-tsc:
specifier: latest
specifier: 3.2.7
version: 3.2.7(typescript@6.0.3)
packages:
@@ -297,6 +300,9 @@ packages:
'@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
'@fastify/rate-limit@10.3.0':
resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==}
'@fastify/send@4.1.0':
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
@@ -1446,6 +1452,12 @@ snapshots:
'@fastify/forwarded': 3.0.1
ipaddr.js: 2.3.0
'@fastify/rate-limit@10.3.0':
dependencies:
'@lukeed/ms': 2.0.2
fastify-plugin: 5.1.0
toad-cache: 3.7.0
'@fastify/send@4.1.0':
dependencies:
'@lukeed/ms': 2.0.2

File diff suppressed because it is too large Load Diff