Compare commits

..

17 Commits

Author SHA1 Message Date
b0d18a845d feat(discussion): add discussion feature for game entities
Create entity_discussion_comments table and API endpoints
Add discussion tabs to Pokemon, Item, Recipe, and Habitat detail views
Support top-level comments, single-level replies, and deletion
2026-05-02 09:54:00 +08:00
7ee25e2437 refactor(pokemon): reorganize edit form fields into tabs
Split form into Basic and Advance tabs to improve usability
Add validation logic to switch tabs if required fields are missing
2026-05-02 09:32:08 +08:00
c2f58fe661 refactor(pokemon): redesign related pokemon and items layout
Display related Pokemon and items sections side-by-side on desktop
Streamline related Pokemon rows by grouping traits and removing labels
2026-05-02 08:44:03 +08:00
21bbbc7137 feat(pokemon): add related Pokemon section to detail view
Fetch related Pokemon based on shared environment and favorite things
Add UI with habitat filtering and highlighted shared attributes
2026-05-02 08:21:46 +08:00
f5ab96c2b1 feat(nav): add in-dev sections and coming soon placeholders
Add navigation links for Dish, Events, Actions, Dream Island, and Clothes.
Implement StatusBadge component and ComingSoonView for future content.
2026-05-02 07:55:04 +08:00
ec2a21bae6 feat(layout): redesign app navigation and replace sidebars with tabs
Move global navigation to a responsive sidebar drawer in AppShell
Replace sidebars in detail pages and Life feed with Tab components
Add mobile topbar with hamburger menu for navigation
2026-05-02 01:16:39 +08:00
6462ed23de feat(life): redesign feed layout with sidebar and icon buttons
Replace tag tabs with a responsive sidebar for filtering
Convert post and comment action buttons to icon-only with tooltips
Standardize engagement buttons into reusable icon and metric components
2026-05-02 00:54:07 +08:00
0ca6f779ec feat(life): enhance search, empty states, and reaction controls
Add clear button to search input and improve empty state UI
Add split button for reactions and close picker on outside click
Add retry button for paused feed pagination
2026-05-02 00:47:42 +08:00
f1ed1e7e40 feat(life): implement soft delete for life posts
Add deleted_at and deleted_by_user_id to life_posts schema
Update queries to filter out and prevent interactions with deleted posts
2026-05-02 00:22:48 +08:00
433b19eb67 feat(life): add tags to life posts and feed filtering
Allow users to select tags when creating or editing life posts
Add tag tabs to the life feed for filtering posts by tag
2026-05-02 00:16:30 +08:00
866d7add16 feat(life): add search and move post composer to modal
Support searching life posts by content
Move post creation and editing to a modal dialog
Add search toolbar and update empty states
2026-05-01 23:48:57 +08:00
c03d4271e1 feat(life): add infinite scroll pagination to feed
Implement cursor-based pagination in backend API
Add IntersectionObserver to frontend for automatic loading on scroll
2026-05-01 23:29:05 +08:00
71b7e838ed feat(life): add reactions to life posts
Support 'like', 'helpful', 'fun', and 'thanks' reactions.
Add API endpoints and database schema for post reactions.
Update UI with reaction picker and summary counts.
2026-05-01 21:49:56 +08:00
a683982b80 feat(life): add comments and replies to life posts
Introduce life_post_comments table for nested comment threads
Add API endpoints to create, reply to, and delete comments
Implement frontend UI with engagement counts and collapsible threads
2026-05-01 21:29:25 +08:00
cd1891cc82 feat(life): add community feed for user posts
Add life_posts schema and CRUD API endpoints
Implement LifeView with inline composer and feed display
2026-05-01 21:03:09 +08:00
49aae3bd7c feat(pokemon): add types, stats, genus, dimensions, and details
Update schema and API to support expanded Pokemon profile fields
Add UI for editing and displaying types, base stats, and dimensions
Support translations for details and genus fields
2026-05-01 17:58:33 +08:00
ec3494ea28 docs: expand project design and agent guidelines
Rewrite DESIGN.md to detail product goals, data models, and API boundaries
Update AGENTS.md with existing UI patterns, i18n rules, and strict completion criteria
2026-05-01 14:50:05 +08:00
29 changed files with 7009 additions and 353 deletions

158
AGENTS.md
View File

@@ -6,6 +6,7 @@
* Follow the existing structure and conventions strictly. * Follow the existing structure and conventions strictly.
* Make **minimal, targeted changes only**. Do not refactor unrelated code. * Make **minimal, targeted changes only**. Do not refactor unrelated code.
* Prefer clarity over cleverness. Avoid unnecessary abstraction. * Prefer clarity over cleverness. Avoid unnecessary abstraction.
* Keep `DESIGN.md` aligned with implemented product behavior when changing data models, APIs, routes, permissions, or user-facing workflows.
--- ---
@@ -22,15 +23,91 @@ For any non-trivial task:
Do NOT skip planning. Do NOT skip planning.
For documentation-only tasks, still follow the planning workflow, but do not run unrelated builds or tests unless the document change depends on generated output.
---
## Project Context
* Goal: Pokopia Wiki, a community-editable game wiki.
* Repository: pnpm workspace monorepo.
* Runtime baseline: Node.js >= 22.
* Frontend:
* Vue
* Vite
* Vue Router
* Vue I18n
* Iconify
* TypeScript
* Backend:
* Node.js
* Fastify
* PostgreSQL
* `pg`
* TypeScript
* Infra:
* Docker
* docker compose
---
## Existing Product Shape
* Public users can browse Wiki content.
* Registered users must verify email before editing.
* Verified users can edit Wiki content and management data; there is no separate role system currently.
* Main public sections:
* Pokemon
* Habitats
* Items
* Recipes
* Daily CheckList
* Management covers:
* System config
* Languages
* Daily CheckList tasks
* Sorting for Pokemon, items, recipes, and habitats
* Main entity create/edit flows use route-backed modal dialogs.
* Internationalization is part of the product model, not just UI copy.
* Detailed edit history and editor attribution are part of entity detail behavior.
--- ---
## UI Design Guidelines ## UI Design Guidelines
* Use `DesignGuidelines.html` as the reference for UI design, visual style, and component behavior. * Use `DesignGuidelines.html` as the reference for UI design, visual style, and component behavior.
* Prefer reusing existing components that already match the guidelines. * Prefer reusing existing components that already match the guidelines.
* Existing shared UI patterns include:
* `AppShell`
* `PageHeader`
* `Modal`
* `FilterPanel`
* `EntityCard`
* `DetailSection`
* `EditMeta`
* `EditHistoryPanel`
* `Skeleton`
* `Tabs`
* `SwitchGroup`
* `TagsSelect`
* `TranslationFields`
* `ReorderableList`
* If a needed component does not exist, create the smallest necessary component based on `DesignGuidelines.html`. * If a needed component does not exist, create the smallest necessary component based on `DesignGuidelines.html`.
* Existing components may be upgraded to match `DesignGuidelines.html`, but only when directly related to the task. * Existing components may be upgraded to match `DesignGuidelines.html`, but only when directly related to the task.
* Do not introduce broad UI rewrites, new design systems, or extra abstraction layers unless explicitly required. * Do not introduce broad UI rewrites, new design systems, or extra abstraction layers unless explicitly required.
* Use Skeleton loaders for data loading states instead of user-facing loading remarks when the existing page pattern supports it.
* Use icon-based navigation and actions consistently with the existing Iconify setup.
--- ---
@@ -42,6 +119,8 @@ Do NOT skip planning.
* Introduce new layers (services, utils, hooks, etc.) unless clearly required * Introduce new layers (services, utils, hooks, etc.) unless clearly required
* Split files unnecessarily * Split files unnecessarily
* Rewrite existing modules without explicit instruction * Rewrite existing modules without explicit instruction
* Change unrelated route, API, or schema behavior while working on UI-only tasks
* Prefer editing existing files over creating new ones. * Prefer editing existing files over creating new ones.
* Keep functions and components small and readable. * Keep functions and components small and readable.
@@ -62,31 +141,52 @@ User-facing UI must NEVER contain:
### Strict Rules ### Strict Rules
* Only render **business data** and intended UI text * Only render **business data** and intended UI text.
* Never display: * Never display:
* "Updated successfully because..." * "Updated successfully because..."
* "Changed X to Y" * "Changed X to Y"
* "TODO", "NOTE", "DEBUG" * "TODO", "NOTE", "DEBUG"
* Debug information must go to logs, not UI
* Separate internal data from API responses * Debug information must go to logs, not UI.
* Separate internal data from API responses.
* Do not expose raw database column names in user-facing labels unless `DESIGN.md` explicitly defines that label.
Violations are considered critical errors. Violations are considered critical errors.
--- ---
## Data & API Design Rules ## Data, API, and i18n Rules
* Follow `DESIGN.md` as the **single source of truth** * Follow `DESIGN.md` as the **single source of truth**.
* PostgreSQL: * PostgreSQL:
* use `snake_case` * use `snake_case`
* define proper primary/foreign keys * define proper primary/foreign keys
* preserve existing audit columns on editable entities
* preserve `sort_order` behavior for sortable lists
* avoid premature optimization * avoid premature optimization
* APIs: * APIs:
* return only necessary fields * return only necessary fields
* do not expose internal metadata * do not expose password hashes, verification token hashes, session token hashes, or internal metadata
* expose editor attribution with only `id` and `displayName`
* keep API response shapes consistent with `frontend/src/services/api.ts`
* i18n:
* use `languages` and `entity_translations` for entity translations
* use `X-Locale` for localized API reads
* keep base `name` / `title` fields as the default-language source
* do not let localized editing overwrite the base field unintentionally
* include translations only where the current API shape already supports them
* Editing and audit:
* create/update/delete operations on Wiki content should record editor information
* detail pages should continue to support edit metadata and edit history
* delete or update behavior must not leak internal audit payloads to normal UI
--- ---
@@ -96,11 +196,15 @@ Violations are considered critical errors.
* Components: `PascalCase` * Components: `PascalCase`
* Composables: `useXxx` * Composables: `useXxx`
* General: * General:
* variables/functions: `camelCase` * variables/functions: `camelCase`
* Keep files focused and under reasonable length * TypeScript types/interfaces: match existing local style
* Avoid duplication
* Keep files focused and under reasonable length.
* Avoid duplication.
* Prefer existing helper APIs and local patterns over introducing new abstractions.
--- ---
@@ -110,10 +214,10 @@ This project is developed from WSL, but runtime validation is done through Docke
Agent workflow: Agent workflow:
* Run: * Run when practical:
* lint * `pnpm lint`
* typecheck * `pnpm typecheck`
* Do NOT run tests in WSL. * Do NOT run tests in WSL.
* Do NOT require local test execution before finishing a task. * Do NOT require local test execution before finishing a task.
@@ -128,12 +232,13 @@ When adding tests is clearly useful, keep them focused and minimal, but do not e
A task is complete ONLY IF: A task is complete ONLY IF:
* Matches `DESIGN.md` * Matches `DESIGN.md`.
* Minimal diff (no unrelated changes) * Updates `DESIGN.md` when the implemented behavior changes product, API, schema, permission, route, or i18n expectations.
* No UI leaks of internal info * Minimal diff, with no unrelated changes.
* Code is readable and concise * No UI leaks of internal info.
* Passes lint/typecheck when practical * Code is readable and concise.
* Docker runtime issues are handled from user-provided `docker compose up --build` output * Passes lint/typecheck when practical.
* Docker runtime issues are handled from user-provided `docker compose up --build` output.
--- ---
@@ -143,6 +248,7 @@ A task is complete ONLY IF:
* Over-engineering simple features * Over-engineering simple features
* Creating unused files or abstractions * Creating unused files or abstractions
* Mixing internal/debug data into UI * Mixing internal/debug data into UI
* Exposing token/hash/internal audit data through public API responses
* Large, unfocused commits * Large, unfocused commits
* Silent behavior changes outside scope * Silent behavior changes outside scope
@@ -150,17 +256,7 @@ A task is complete ONLY IF:
## When Unsure ## When Unsure
* Ask for clarification * Ask for clarification.
* Do not guess requirements * Do not guess requirements.
* Do not invent features not in `DESIGN.md` * Do not invent features not in `DESIGN.md`.
* If current code and `DESIGN.md` disagree, call out the mismatch before changing behavior.
---
## Project Context
* Goal: Pokopia Wiki
* Stack:
* Frontend: Vue
* Backend: Node + PostgreSQL
* Infra: Docker

614
DESIGN.md
View File

@@ -1,155 +1,531 @@
# Pokopia Wiki # Pokopia Wiki
## 产品目标
- Pokopia Wiki 是一个面向 Pokopia 游戏资料的社区 Wiki。
- 所有人都可以浏览 Wiki 内容。
- 已注册并完成邮箱验证的用户可以创建、编辑、删除 Wiki 内容。
- 前台以 Pokemon、栖息地、物品、材料单、每日 CheckList、Life、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
- 管理入口用于维护全局配置、语言、列表排序和每日 CheckList。
## 技术栈 ## 技术栈
- 后端Postgresql - Monorepopnpm workspaceNode.js >= 22TypeScript。
- 前端Vue - 前端Vue、Vite、Vue Router、Vue I18n、Iconify。
- 运维Docker - 后端Node.js、Fastify、pg、PostgreSQL。
都要用最新的框架 - 运维Docker / docker compose。
- 依赖版本遵循现有 `package.json`,新增依赖时优先使用当前主流稳定版本,并保持项目结构简单。
# 功能描述 ## 全局设计原则
- 一个具有社区功能的 Pokopia 游戏 Wiki - `DESIGN.md` 是产品行为、数据结构和 API 暴露边界的单一事实来源。
- API 只返回业务需要的字段不返回密码、token hash、验证 token、内部调试字段或不必要的元数据。
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
- 列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序。
## 数据 ## 国际化
- 前端使用 Vue I18n 管理界面文案,并通过 `X-Locale` 请求头告知后端当前语言。
- 前端当前语言保存在 `localStorage``pokopia_locale`
- 后端默认语言为 `en`
- 语言配置存储在 `languages`
- `code`
- `name`
- `enabled`
- `is_default`
- `sort_order`
- 语言 code 格式为 `xx``xx-YY`,例如 `en``zh-CN`
- 系统必须且只能有一个默认语言。
- 初始语言包含:
- `en`English默认语言
- `zh-CN`:简体中文
- 实体翻译存储在 `entity_translations`
- `entity_type`
- `entity_id`
- `locale`
- `field_name`
- `value`
- 支持翻译的实体:
- Pokemon
- 特长
- Pokemon Types
- 喜欢的环境
- 喜欢的东西 / 标签
- 物品分类
- 物品用途
- 入手方式
- 物品
- 地图
- 栖息地
- 每日 CheckList Task
- Life 标签
- 支持翻译的字段:
- `name`
- `title`
- `details`:仅 Pokemon 介绍使用
- `genus`:仅 Pokemon Genus 使用
- 实体仍保留基础 `name``title``details``genus` 字段,默认语言内容以基础字段为准。
- API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。
- 编辑表单必须避免本地化 UI 覆盖基础名称;翻译字段只展示当前需要编辑的语言。
## 用户与认证
- 用户可注册:
- 邮箱
- 显示名
- 密码
- 邮箱保存为小写。
- 密码只保存 hash。
- 注册后必须通过邮箱验证。
- 邮件发送使用 Resend
- `RESEND_API_KEY`
- `EMAIL_FROM`
- `APP_ORIGIN``FRONTEND_ORIGIN`
- 验证邮件包含一次性验证链接。
- 验证 token 只保存 hash并带过期时间和使用状态。
- 只有邮箱已验证的用户可以登录。
- 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。
- 前端将登录 token 保存在 `localStorage``pokopia_auth_token`
- 用户可退出登录,退出时删除对应 session。
- 对外用户字段只包含必要信息:
- 当前用户:`id``email``displayName``emailVerified`
- 编辑署名:`id``displayName`
## Community 编辑与审计
- 已验证用户可以通过前台或管理入口编辑 Wiki 内容。
- 新增、修改、删除 Wiki 内容时必须写入审计信息。
- 可编辑实体包含:
- Pokemon
- 栖息地
- 物品
- 材料单
- 每日 CheckList Task
- 全局配置项
- 主要可编辑表包含:
- `created_by_user_id`
- `updated_by_user_id`
- `created_at`
- `updated_at`
- `sort_order`
- 详细编辑历史存储在 `wiki_edit_logs`
- `entity_type`
- `entity_id`
- `action``create` / `update` / `delete`
- `user_id`
- `changes`
- `created_at`
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
- 编辑历史中的用户信息只展示必要署名不暴露邮箱、token、hash 或内部元数据。
## 实体讨论
- Pokemon、物品、材料单、栖息地详情页支持讨论。
- 所有人都可以浏览实体讨论。
- 已注册并完成邮箱验证的用户可以发表评论,并回复顶层评论。
- 讨论回复只支持一层回复,不做无限嵌套。
- 评论作者可以删除自己的评论;删除后正文不再展示,已有回复保留在原位置。
- 被删除实体的讨论会随实体删除一并清理。
- 讨论按创建时间正序展示。
- 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`
- API 对外只返回评论作者的 `id``displayName`
- API 不返回邮箱、token/hash、内部调试字段、`deleted_at``deleted_by_user_id` 等内部删除字段。
## 全局配置数据
以下配置项都支持创建、编辑、删除、翻译和拖拽排序。
### 特长
- 名称
- 是否有掉落物:`has_item_drop`
- 已移除 `subcategory` 字段。
- 当特长允许掉落物时Pokemon 编辑中可为该 Pokemon + 特长配置一个掉落物品。
### Pokemon Types
- 名称
- 用于 Pokemon 属性配置。
- Pokemon 可选择 1 到 2 个 Type用于表达双属性。
### 喜欢的环境
- 名称
### 喜欢的东西 / 标签
- 名称
- 同时用于:
- Pokemon 喜欢的东西
- 物品标签
### 物品分类
- 名称
- 用于物品和材料单按结果物品分类展示。
### 物品用途
- 名称
- 物品用途可为空。
### 入手方式
- 名称
- 可关联到物品和材料单。
### 地图
- 名称
- 用于栖息地中 Pokemon 出现地点。
### Life 标签
- 名称
- 用于 Life Post 分类展示和 Feed 筛选。
## Pokemon
Pokemon 可配置: Pokemon 可配置:
- ID - ID
- 名字
- 特长(可多选,最多 2 个)
- 特长掉落物品(按 Pokemon + 特长 配置,单选物品)
- 喜欢的环境(单选)
- 喜欢的东西(可多选,最多 6 个)
- 出现的栖息地(可多选)
特长 可配置:
- 名称 - 名称
- 是否有掉落物 - Genus可为空支持翻译
- 介绍 / Details可为空支持翻译
- Height默认输入 `ft/in`,可切换输入 `m`;详情页同时展示 `ft/in``m`
- Weight默认输入磅 `lb`,可切换输入 `kg`;详情页同时展示 `lbs``kg`
- Height / Weight 换算结果四舍五入;`m` / `kg` 保留 2 位小数,`in` 取整数,`lb` 保留 1 位小数。
- Types可多选最多 2 个
- 喜欢的环境:单选
- 特长:可多选,最多 2 个
- 特长掉落物品:按 Pokemon + 特长配置,单选物品
- 喜欢的东西:可多选,最多 6 个
- 六维:
- HP
- Attack
- Defense
- Special Attack
- Special Defense
- Speed
- 出现的栖息地:由栖息地出现配置反向展示
- 翻译
- 排序
喜欢的环境 可配置 Pokemon 编辑表单使用标签页组织字段
- 名称
喜欢的东西(标签) 可配置 - 基础标签页
- 名称 - 第一行ID、名称
- 第二行:喜欢的环境、特长
- 第三行:喜欢的东西
- 特长掉落物品随已选择且支持掉落物的特长显示
- Advance 标签页:
- 第一行Genus
- 第二行Details
- 第三行Height / Weight身高与体重控件在桌面端同一行展示
- 第四行Types
- 第五行:六维 Stats
Pokemon 列表功能:
- 搜索
- 按喜欢的环境筛选
- 按特长筛选:
- 满足任意条件
- 满足全部条件
- 按喜欢的东西筛选:
- 满足任意条件
- 满足全部条件
- 按自定义排序展示
Pokemon 详情页展示:
- 基本信息
- 主内容顶部按以下布局展示:
- 左上Genus & Details无区块标题如有 Genus先展示 Genus再以分割线连接 Details 内容
- 左下Height / Weight 与 Types 按 2:1 比例并排Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开每组按英制、分割线、公制、标签上下排列Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示
- 右侧:六维 Stats
- 六维使用 ProgressBar 展示,最大值按 150 计算。
- 特长
- 特长掉落物品
- 喜欢的环境
- 喜欢的东西
- 相关 Pokemon与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示
- 出现的栖息地
- 最后编辑信息
- 讨论
- 编辑历史:通过详情页 Tabs 展示
## 物品
物品可配置:
物品 可配置:
- 名称 - 名称
- 分类 - 分类:必填
- 用途 - 用途:可为空
- 入手方式可多选 - 入手方式可多选
- 客制化: - 客制化:
- 可染色 - 可染色
- 可双区染色 - 可双区染色
- 可改花纹 - 可改花纹
- 标签(多选) - 无材料单:`no_recipe`
- 标签:使用喜欢的东西配置,可多选
- 翻译
- 排序
物品列表功能:
- 搜索
- 按分类展示为标签页
- 按用途筛选
- 按标签筛选
- 按自定义排序展示
物品详情页展示:
- 基本信息
- 分类
- 用途
- 入手方式
- 客制化
- 标签
- 关联材料单
- 作为材料出现的材料单
- 相关栖息地
- 相关 Pokemon 掉落
- 最后编辑信息
- 讨论
- 编辑历史
## 材料单
材料单与物品是一对一关系:
- 一个材料单必须关联一个结果物品。
- 一个物品最多只能有一个材料单。
- 标记为 `no_recipe` 的物品不能创建材料单。
- 材料单没有独立名称,展示名称来自结果物品。
材料单可配置:
- 结果物品
- 入手方式:可多选
- 需要材料:多项物品 + 数量
- 排序
材料单列表功能:
- 独立于物品列表展示
- 按结果物品分类展示
- 按自定义排序展示
材料单详情页展示:
- 结果物品
- 入手方式
- 需要材料列表
- 最后编辑信息
- 讨论
- 编辑历史
## 栖息地
栖息地可配置:
材料单 可配置:
- 名称 - 名称
- 入手方式(可多选) - 配方:多项物品 + 数量
- 需要材料(可多样,多数量) - 可出现的 Pokemon
- 翻译
- 排序
物品 / 材料单分类 Pokemon 出现配置
- 名称
物品 / 材料单用途: - Pokemon
- 名称 - 地图:可多选
- 时间:可多选
- 早晨
- 中午
- 傍晚
- 晚上
- 天气:可多选
- 晴天
- 阴天
- 雨天
- 稀有度1 到 3 星
入手方式 可配置 栖息地列表功能
- 名称
地图: - 按自定义排序展示
- 名称 - 展示配方摘要和可能出现的 Pokemon 摘要
栖息地: 栖息地详情页展示
- 名称
- 配方(物品,数量)
- 可出现的宝可梦(可多选)
列表顺序: - 配方列表
- 全局配置项、Pokemon、物品、材料单、地图、栖息地均可自定义排序 - 可能出现的 Pokemon 列表
- 初始排序按创建时间旧到新 - 出现时间
- 出现天气
- 稀有度
- 出现的地图列表
- 最后编辑信息
- 讨论
- 编辑历史
出现契机 ## 每日 CheckList
- 时间:早晨 / 中午 / 傍晚 / 晚上
- 天气:晴天 / 阴天 / 雨天
- 稀有度1 ~ 3 星
- 地图关联
每日 CheckList 可配置: 每日 CheckList Task 可配置:
- Task
- Task 标题
- 翻译
- Task 顺序 - Task 顺序
## 功能 前台行为:
- Pokemon 列表 - 展示每日要做的 Task。
- 搜索 - 每个 Task 可勾选。
- 筛选 - 勾选状态保存在浏览器本地。
- 特长(可多选,满足任意条件 / 满足全部条件) - 勾选状态按本地日期自动清空,不删除 Task。
- 喜欢的环境 - 已删除 Task 的本地勾选状态会自动清理。
- 喜欢的东西(可多选,满足任意条件 / 满足全部条件)
- Pokemon 详情页
- 特长
- 特长掉落物品
- 喜欢的环境
- 喜欢的东西
- 栖息地
- 栖息地列表
- 栖息地详情页
- 配方列表
- 可能出现的宝可梦列表
- 出现时间
- 出现天气
- 稀有度
- 出现的地图列表
- 物品 / 材料单列表
- 根据分类显示(标签页)
- 筛选
- 用途
- 标签
- 物品详情页
- 基本信息
- 用途
- 入手方式
- 自定义
- 可染色
- 可双区染色
- 可改花纹
- 材料单信息
- 入手方式
- 需要材料列表
- 标签
- 相关栖息地
- 相关 Pokemon 掉落
- 材料单详情页
- 基本信息
- 入手方式
- 需要材料列表
- 每日 CheckList
- 展示每日做什么
- 每个 Task 可勾选
- 每天自动清空勾选状态,不删除 Task
- 管理中可新增 Task 到列表
- 管理中可通过 Handle 拖曳排序
## 用户系统 管理行为:
- 用户可注册 - 已验证用户可新增、编辑、删除 Task。
- 邮箱 - 已验证用户可通过 Handle 拖拽排序。
- 显示名
- 密码
- 用户注册后需要通过邮箱验证
- 使用 Resend 发送验证邮件
- 邮件内包含验证链接
- 用户可登录
- 仅允许已验证邮箱的用户登录
- 登录后可获取当前用户信息
- 用户可退出登录
- API 只返回必要用户字段,不暴露密码、验证 token、会话 token 哈希或内部元数据
## Community 编辑 ## Life
- 所有人都可浏览 Wiki 内容 Life 是社区生活分享信息流,类似轻量社交动态。
- 已注册并完成邮箱验证的用户都可编辑 Wiki 内容
- 每次创建、修改、删除 Wiki 内容都需要记录编辑者 Life Post 可配置:
- Wiki 内容展示最后编辑者和最后编辑时间
- 编辑署名只展示必要用户信息不暴露邮箱、token、hash 或内部元数据 - Post 内容正文
- 标签:使用 Life 标签配置,可多选
- 创建者、最后编辑者、创建时间、最后编辑时间
- 评论
- 评论回复:仅支持回复顶层评论,不做无限嵌套
- Reactions`like``helpful``fun``thanks`
前台行为:
- 所有人都可以浏览 Life 信息流。
- 信息流按创建时间倒序展示。
- 已注册并完成邮箱验证的用户可以发布 Life Post。
- 作者本人可以编辑、删除自己的 Life Post删除 Life Post 使用软删除。
- 已注册并完成邮箱验证的用户发布或编辑 Life Post 时可以选择一个或多个 Life 标签。
- 已注册并完成邮箱验证的用户可以评论 Life Post并回复顶层评论。
- 评论作者可以删除自己的评论;删除评论后正文不再展示,已有回复保留在原位置。
- 已软删除的 Life Post 不出现在信息流、搜索或标签筛选结果中,也不能继续编辑、评论或设置 Reaction。
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
- 已注册并完成邮箱验证的用户可以对每条 Life Post 选择一个 Reaction普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
- 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。
- Feed 使用 Tabs 展示 Life 标签筛选;包含 All 和后台配置的 Life 标签;点击标签后按该标签筛选,搜索和标签筛选可以同时生效。
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
- 当前没有图片上传、转发、置顶或单独审核流程。
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`
API 暴露边界:
- Life Post 作者信息只返回 `id``displayName`
- Life Post 标签只返回 `id` 和按当前语言解析后的 `name`
- Life Comment 作者信息只返回 `id``displayName`
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction不返回其他用户的 Reaction 明细。
- Life Post 列表 API 返回分页结果:`items``nextCursor``hasMore``cursor` 是不透明分页令牌。
- API 不返回邮箱、token/hash、内部调试字段或不必要的审计 payload。
- API 不返回 Life Post 的 `deleted_at``deleted_by_user_id` 等内部软删除字段。
- 非作者不能编辑或删除其他用户的 Life Post。
- 非作者不能删除其他用户的 Life Comment。
## 开发中入口
以下前台公开入口当前仅展示“正在开发中”占位页,不提供数据模型、后端 API、编辑表单、管理入口或排序能力
- Dish
- Events
- Actions游戏内快捷动作例如挥手、跳舞等。
- Dream Island
- Clothes
这些开发中入口在主导航和占位页中显示状态 Badge便于用户识别当前功能状态。
## 前端交互与 UI
- UI 风格以 `DesignGuidelines.html` 为准。
- 页面结构以 `AppShell``PageHeader`、列表、详情区和管理区为核心。
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
- 页面级分类、筛选或辅助内容切换使用 Tabs避免在内容页继续增加侧边栏。
- 导航和主要操作使用图标增强识别。
- 数据加载状态使用 Skeleton避免裸文本 loading。
- 分类切换使用 Tabs。
- 布尔或模式选择使用 SwitchGroup、checkbox、segmented control 等合适控件。
- 多选和单选复用 `TagsSelect`,支持搜索、键盘操作和必要时的内联创建。
- 主要实体的新建和编辑使用路由驱动的 Modal
- `/pokemon/new`
- `/pokemon/:id/edit`
- `/habitats/new`
- `/habitats/:id/edit`
- `/items/new`
- `/items/:id/edit`
- `/recipes/new`
- `/recipes/:id/edit`
- Life 使用信息流顶部 New Post / 编辑按钮打开普通 Modal 发布与编辑,不使用路由驱动 Modal。
- 进入或关闭编辑 Modal 时应保留底层页面上下文,不进行不必要的滚动跳转。
- 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。
## API 概览
公开浏览 API
- `GET /api/languages`
- `GET /api/options`
- `GET /api/daily-checklist`
- `GET /api/pokemon`
- `GET /api/pokemon/:id`
- `GET /api/habitats`
- `GET /api/habitats/:id`
- `GET /api/items`
- `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`
认证 API
- `POST /api/auth/register`
- `POST /api/auth/verify-email`
- `POST /api/auth/login`
- `GET /api/auth/me`
- `POST /api/auth/logout`
已验证用户编辑 API
- Pokemon、栖息地、物品、材料单的创建、更新、删除。
- Life Post 的创建,以及作者本人对 Life Post 的更新、删除。
- `POST /api/life-posts`
- `PUT /api/life-posts/:id`
- `DELETE /api/life-posts/:id`
- Life Comment 的创建,以及作者本人对 Life Comment 的删除。
- `POST /api/life-posts/:postId/comments`
- `POST /api/life-posts/:postId/comments/:commentId/replies`
- `DELETE /api/life-comments/:id`
- 实体讨论评论的创建、回复,以及作者本人对评论的删除。
- `POST /api/discussions/:entityType/:entityId/comments`
- `POST /api/discussions/:entityType/:entityId/comments/:commentId/replies`
- `DELETE /api/discussions/comments/:id`
- Life Reaction 的设置、替换和取消。
- `PUT /api/life-posts/:id/reaction`
- `DELETE /api/life-posts/:id/reaction`
- 每日 CheckList 的创建、更新、删除、排序。
- 全局配置项的创建、更新、删除、排序。
- 语言的创建、更新、删除、排序。
- Pokemon、物品、材料单、栖息地的列表排序。
## 开发与验证
- 本项目在 WSL 中开发,运行验证主要通过 Docker。
- 常规轻量验证:
- `pnpm lint`
- `pnpm typecheck`
- 不在 WSL 中运行测试作为完成任务的前置条件。
- Docker 运行问题以用户提供的 `docker compose up --build` 输出为准进行后续修复。

View File

@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS entity_translations (
entity_type text NOT NULL CHECK ( entity_type text NOT NULL CHECK (
entity_type IN ( entity_type IN (
'pokemon', 'pokemon',
'pokemon-types',
'skills', 'skills',
'environments', 'environments',
'favorite-things', 'favorite-things',
@@ -37,12 +38,13 @@ CREATE TABLE IF NOT EXISTS entity_translations (
'items', 'items',
'maps', 'maps',
'habitats', 'habitats',
'daily-checklist-items' 'daily-checklist-items',
'life-tags'
) )
), ),
entity_id integer NOT NULL, entity_id integer NOT NULL,
locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE, locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
field_name text NOT NULL CHECK (field_name IN ('name', 'title')), field_name text NOT NULL CHECK (field_name IN ('name', 'title', 'details', 'genus')),
value text NOT NULL, value text NOT NULL,
PRIMARY KEY (entity_type, entity_id, locale, field_name) PRIMARY KEY (entity_type, entity_id, locale, field_name)
); );
@@ -50,6 +52,27 @@ CREATE TABLE IF NOT EXISTS entity_translations (
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
ON entity_translations (entity_type, entity_id, field_name, locale); ON entity_translations (entity_type, entity_id, field_name, locale);
ALTER TABLE entity_translations DROP CONSTRAINT IF EXISTS entity_translations_entity_type_check;
ALTER TABLE entity_translations ADD CONSTRAINT entity_translations_entity_type_check CHECK (
entity_type IN (
'pokemon',
'pokemon-types',
'skills',
'environments',
'favorite-things',
'item-categories',
'item-usages',
'acquisition-methods',
'items',
'maps',
'habitats',
'daily-checklist-items',
'life-tags'
)
);
ALTER TABLE entity_translations DROP CONSTRAINT IF EXISTS entity_translations_field_name_check;
ALTER TABLE entity_translations ADD CONSTRAINT entity_translations_field_name_check CHECK (field_name IN ('name', 'title', 'details', 'genus'));
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE, email text NOT NULL UNIQUE,
@@ -98,6 +121,78 @@ CREATE TABLE IF NOT EXISTS daily_checklist_items (
CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
ON daily_checklist_items(sort_order, id); ON daily_checklist_items(sort_order, id);
CREATE TABLE IF NOT EXISTS life_tags (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS life_posts (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
deleted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE life_posts DROP COLUMN IF EXISTS link_url;
ALTER TABLE life_posts DROP COLUMN IF EXISTS link_title;
ALTER TABLE life_posts ADD COLUMN IF NOT EXISTS deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE life_posts ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
CREATE INDEX IF NOT EXISTS life_posts_created_at_idx
ON life_posts(created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS life_posts_active_created_at_idx
ON life_posts(created_at DESC, id DESC)
WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS life_post_tags (
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
tag_id integer NOT NULL REFERENCES life_tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX IF NOT EXISTS life_post_tags_tag_idx
ON life_post_tags(tag_id, post_id);
CREATE TABLE IF NOT EXISTS life_post_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
parent_comment_id integer REFERENCES life_post_comments(id) ON DELETE SET NULL,
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
deleted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS life_post_comments_post_idx
ON life_post_comments(post_id, created_at, id);
CREATE INDEX IF NOT EXISTS life_post_comments_parent_idx
ON life_post_comments(parent_comment_id, created_at, id);
CREATE TABLE IF NOT EXISTS life_post_reactions (
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
reaction_type text NOT NULL CHECK (reaction_type IN ('like', 'helpful', 'fun', 'thanks')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (post_id, user_id)
);
CREATE INDEX IF NOT EXISTS life_post_reactions_post_idx
ON life_post_reactions(post_id, reaction_type);
CREATE TABLE IF NOT EXISTS skills ( CREATE TABLE IF NOT EXISTS skills (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,
@@ -115,13 +210,37 @@ CREATE TABLE IF NOT EXISTS favorite_things (
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0) sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
); );
CREATE TABLE IF NOT EXISTS pokemon_types (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
);
CREATE TABLE IF NOT EXISTS pokemon ( CREATE TABLE IF NOT EXISTS pokemon (
id integer PRIMARY KEY, id integer PRIMARY KEY,
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,
genus text NOT NULL DEFAULT '',
details text NOT NULL DEFAULT '',
height_inches double precision NOT NULL DEFAULT 0 CHECK (height_inches >= 0),
weight_pounds double precision NOT NULL DEFAULT 0 CHECK (weight_pounds >= 0),
environment_id integer NOT NULL REFERENCES environments(id), environment_id integer NOT NULL REFERENCES environments(id),
hp integer NOT NULL DEFAULT 0 CHECK (hp >= 0),
attack integer NOT NULL DEFAULT 0 CHECK (attack >= 0),
defense integer NOT NULL DEFAULT 0 CHECK (defense >= 0),
special_attack integer NOT NULL DEFAULT 0 CHECK (special_attack >= 0),
special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0),
speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0),
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0) sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
); );
CREATE TABLE IF NOT EXISTS pokemon_pokemon_types (
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE,
slot_order integer NOT NULL CHECK (slot_order BETWEEN 1 AND 2),
PRIMARY KEY (pokemon_id, type_id),
UNIQUE (pokemon_id, slot_order)
);
CREATE TABLE IF NOT EXISTS pokemon_skills ( CREATE TABLE IF NOT EXISTS pokemon_skills (
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
skill_id integer NOT NULL REFERENCES skills(id), skill_id integer NOT NULL REFERENCES skills(id),
@@ -289,11 +408,33 @@ ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS created_at timestamptz NOT
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0); ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0); ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS genus text NOT NULL DEFAULT '';
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '';
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS height_inches double precision NOT NULL DEFAULT 0 CHECK (height_inches >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS weight_pounds double precision NOT NULL DEFAULT 0 CHECK (weight_pounds >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS hp integer NOT NULL DEFAULT 0 CHECK (hp >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS attack integer NOT NULL DEFAULT 0 CHECK (attack >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS defense integer NOT NULL DEFAULT 0 CHECK (defense >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_attack integer NOT NULL DEFAULT 0 CHECK (special_attack >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0);
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
@@ -367,6 +508,16 @@ SET sort_order = ordered.next_sort_order
FROM ordered FROM ordered
WHERE target.id = ordered.id; WHERE target.id = ordered.id;
WITH ordered AS (
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
FROM pokemon_types
WHERE sort_order = 0
)
UPDATE pokemon_types target
SET sort_order = ordered.next_sort_order
FROM ordered
WHERE target.id = ordered.id;
WITH ordered AS ( WITH ordered AS (
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
FROM pokemon FROM pokemon
@@ -377,6 +528,16 @@ SET sort_order = ordered.next_sort_order
FROM ordered FROM ordered
WHERE target.id = ordered.id; WHERE target.id = ordered.id;
WITH ordered AS (
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
FROM life_tags
WHERE sort_order = 0
)
UPDATE life_tags target
SET sort_order = ordered.next_sort_order
FROM ordered
WHERE target.id = ordered.id;
WITH ordered AS ( WITH ordered AS (
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
FROM item_categories FROM item_categories
@@ -450,7 +611,9 @@ WHERE target.id = ordered.id;
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id); CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id); CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id); CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id);
CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id); CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id);
CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sort_order, id); CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sort_order, id);
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id); CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id); CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
@@ -476,3 +639,34 @@ CREATE INDEX IF NOT EXISTS wiki_edit_logs_entity_idx
CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx
ON wiki_edit_logs(user_id); ON wiki_edit_logs(user_id);
CREATE TABLE IF NOT EXISTS entity_discussion_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats')),
entity_id integer NOT NULL,
parent_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
deleted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE entity_discussion_comments DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check;
ALTER TABLE entity_discussion_comments ADD CONSTRAINT entity_discussion_comments_entity_type_check CHECK (
entity_type IN ('pokemon', 'items', 'recipes', 'habitats')
);
ALTER TABLE entity_discussion_comments ADD COLUMN IF NOT EXISTS deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE entity_discussion_comments ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
ALTER TABLE entity_discussion_comments ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
CREATE INDEX IF NOT EXISTS entity_discussion_comments_entity_idx
ON entity_discussion_comments(entity_type, entity_id, created_at, id);
CREATE INDEX IF NOT EXISTS entity_discussion_comments_parent_idx
ON entity_discussion_comments(parent_comment_id, created_at, id);
CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
ON entity_discussion_comments(created_by_user_id);

File diff suppressed because it is too large Load Diff

View File

@@ -7,16 +7,25 @@ import {
cleanLocale, cleanLocale,
createConfig, createConfig,
createDailyChecklistItem, createDailyChecklistItem,
createEntityDiscussionComment,
createEntityDiscussionReply,
createHabitat, createHabitat,
createItem, createItem,
createLanguage, createLanguage,
createLifeComment,
createLifeCommentReply,
createLifePost,
createPokemon, createPokemon,
createRecipe, createRecipe,
deleteConfig, deleteConfig,
deleteDailyChecklistItem, deleteDailyChecklistItem,
deleteEntityDiscussionComment,
deleteHabitat, deleteHabitat,
deleteItem, deleteItem,
deleteLanguage, deleteLanguage,
deleteLifeComment,
deleteLifePost,
deleteLifePostReaction,
deletePokemon, deletePokemon,
deleteRecipe, deleteRecipe,
getHabitat, getHabitat,
@@ -25,11 +34,13 @@ import {
getPokemon, getPokemon,
getRecipe, getRecipe,
isConfigType, isConfigType,
listEntityDiscussionComments,
listConfig, listConfig,
listDailyChecklistItems, listDailyChecklistItems,
listHabitats, listHabitats,
listItems, listItems,
listLanguages, listLanguages,
listLifePosts,
listPokemon, listPokemon,
listRecipes, listRecipes,
reorderConfig, reorderConfig,
@@ -39,11 +50,13 @@ import {
reorderLanguages, reorderLanguages,
reorderPokemon, reorderPokemon,
reorderRecipes, reorderRecipes,
setLifePostReaction,
updateConfig, updateConfig,
updateDailyChecklistItem, updateDailyChecklistItem,
updateHabitat, updateHabitat,
updateItem, updateItem,
updateLanguage, updateLanguage,
updateLifePost,
updatePokemon, updatePokemon,
updateRecipe updateRecipe
} from './queries.ts'; } from './queries.ts';
@@ -137,6 +150,19 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
return user; return user;
} }
async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
const token = getBearerToken(request.headers.authorization);
if (!token) {
return null;
}
try {
return await getUserBySessionToken(token);
} catch {
return null;
}
}
app.post('/api/auth/register', async (request, reply) => app.post('/api/auth/register', async (request, reply) =>
reply.code(201).send(await registerUser(request.body as Record<string, unknown>, requestLocale(request))) reply.code(201).send(await registerUser(request.body as Record<string, unknown>, requestLocale(request)))
); );
@@ -171,6 +197,147 @@ app.get('/api/options', async (request) => getOptions(requestLocale(request)));
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request))); app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
app.get('/api/life-posts', async (request) => {
const user = await optionalUser(request);
return listLifePosts(request.query as Record<string, string | string[] | undefined>, user?.id ?? null, requestLocale(request));
});
app.post('/api/life-posts', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
return user
? reply.code(201).send(await createLifePost(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.post('/api/life-posts/:postId/comments', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { postId } = request.params as { postId: string };
const comment = await createLifeComment(Number(postId), request.body as Record<string, unknown>, user.id);
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
});
app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { postId, commentId } = request.params as { postId: string; commentId: string };
const comment = await createLifeCommentReply(
Number(postId),
Number(commentId),
request.body as Record<string, unknown>,
user.id
);
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
});
app.put('/api/life-posts/:id', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await updateLifePost(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return post ? post : reply.code(404).send({ message: 'Not found' });
});
app.put('/api/life-posts/:id/reaction', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await setLifePostReaction(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return post ? post : reply.code(404).send({ message: 'Not found' });
});
app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await deleteLifePostReaction(Number(id), user.id, requestLocale(request));
return post ? post : reply.code(404).send({ message: 'Not found' });
});
app.delete('/api/life-posts/:id', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteLifePost(Number(id), user.id);
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
});
app.delete('/api/life-comments/:id', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteLifeComment(Number(id), user.id);
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
});
app.get('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
const { entityType, entityId } = request.params as { entityType: string; entityId: string };
const comments = await listEntityDiscussionComments(entityType, Number(entityId));
return comments ? comments : reply.code(404).send({ message: 'Not found' });
});
app.post('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { entityType, entityId } = request.params as { entityType: string; entityId: string };
const comment = await createEntityDiscussionComment(
entityType,
Number(entityId),
request.body as Record<string, unknown>,
user.id
);
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
});
app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { entityType, entityId, commentId } = request.params as {
entityType: string;
entityId: string;
commentId: string;
};
const comment = await createEntityDiscussionReply(
entityType,
Number(entityId),
Number(commentId),
request.body as Record<string, unknown>,
user.id
);
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
});
app.delete('/api/discussions/comments/:id', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteEntityDiscussionComment(Number(id), user.id);
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
});
app.get('/api/pokemon', async (request) => app.get('/api/pokemon', async (request) =>
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request)) listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
); );

View File

@@ -3,7 +3,20 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import AppShell from './components/AppShell.vue'; import AppShell from './components/AppShell.vue';
import { iconAdmin, iconChecklist, iconHabitat, iconItem, iconPokemon, iconRecipe } from './icons'; import {
iconAction,
iconAdmin,
iconChecklist,
iconClothes,
iconDish,
iconDreamIsland,
iconEvent,
iconHabitat,
iconItem,
iconLife,
iconPokemon,
iconRecipe
} from './icons';
import { getCurrentLocale, onLocaleChange, setCurrentLocale } from './i18n'; import { getCurrentLocale, onLocaleChange, setCurrentLocale } from './i18n';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api'; import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
@@ -18,12 +31,22 @@ const languages = ref<Language[]>([
let removeAuthListener: (() => void) | null = null; let removeAuthListener: (() => void) | null = null;
let removeLocaleListener: (() => void) | null = null; let removeLocaleListener: (() => void) | null = null;
function inDevBadge() {
return { label: t('common.inDev'), tone: 'info' as const };
}
const navItems = computed(() => [ const navItems = computed(() => [
{ label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon }, { label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon },
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat }, { label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
{ label: t('nav.items'), to: '/items', icon: iconItem }, { label: t('nav.items'), to: '/items', icon: iconItem },
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe }, { label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
{ 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.checklist'), to: '/checklist', icon: iconChecklist },
{ label: t('nav.life'), to: '/life', icon: iconLife },
{ label: t('nav.admin'), to: '/admin', icon: iconAdmin } { label: t('nav.admin'), to: '/admin', icon: iconAdmin }
]); ]);

View File

@@ -1,16 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { onBeforeUnmount, onMounted, ref } from 'vue'; import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { iconLogin, iconLogout, iconRegister, iconTranslate, type AppIcon } from '../icons'; import { useRoute } from 'vue-router';
import { iconClose, iconLogin, iconLogout, iconMenu, iconRegister, iconTranslate, type AppIcon } from '../icons';
import type { AuthUser, Language } from '../services/api'; import type { AuthUser, Language } from '../services/api';
import PokeBallMark from './PokeBallMark.vue'; import PokeBallMark from './PokeBallMark.vue';
import StatusBadge from './StatusBadge.vue';
defineProps<{ defineProps<{
currentUser: AuthUser | null; currentUser: AuthUser | null;
languages: Language[]; languages: Language[];
locale: string; locale: string;
navItems: Array<{ label: string; to: string; icon?: AppIcon }>; navItems: Array<{
label: string;
to: string;
icon?: AppIcon;
badge?: {
label: string;
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
};
}>;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -19,14 +29,26 @@ const emit = defineEmits<{
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute();
const languageMenu = ref<HTMLElement | null>(null); const languageMenu = ref<HTMLElement | null>(null);
const languageMenuButton = ref<HTMLButtonElement | null>(null); const languageMenuButton = ref<HTMLButtonElement | null>(null);
const languageMenuOpen = ref(false); const languageMenuOpen = ref(false);
const sidebarOpen = ref(false);
function closeLanguageMenu() { function closeLanguageMenu() {
languageMenuOpen.value = false; languageMenuOpen.value = false;
} }
function closeSidebar() {
sidebarOpen.value = false;
closeLanguageMenu();
}
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value;
closeLanguageMenu();
}
function toggleLanguageMenu() { function toggleLanguageMenu() {
languageMenuOpen.value = !languageMenuOpen.value; languageMenuOpen.value = !languageMenuOpen.value;
} }
@@ -51,20 +73,57 @@ function onLanguageMenuKeydown(event: KeyboardEvent) {
} }
} }
function requestLogout() {
closeSidebar();
emit('logout');
}
function isNavActive(path: string) {
return route.path === path || route.path.startsWith(`${path}/`);
}
watch(sidebarOpen, (open) => {
document.body.classList.toggle('lock-scroll', open);
});
onMounted(() => { onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown); document.addEventListener('pointerdown', onDocumentPointerDown);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown); document.removeEventListener('pointerdown', onDocumentPointerDown);
document.body.classList.remove('lock-scroll');
}); });
</script> </script>
<template> <template>
<div class="app-shell"> <div class="app-shell" :class="{ 'app-shell--sidebar-open': sidebarOpen }">
<header class="site-header"> <header class="mobile-topbar">
<div class="container top-nav"> <button
<RouterLink class="brand-lockup" to="/pokemon" aria-label="Pokopia Wiki"> class="sidebar-toggle"
type="button"
:aria-label="sidebarOpen ? t('nav.closeMenu') : t('nav.openMenu')"
:aria-expanded="sidebarOpen"
aria-controls="app-sidebar"
@click="toggleSidebar"
>
<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">
<PokeBallMark size="34px" />
<span>
<span class="pokemon-word">Pokopia</span>
<span class="brand-subtitle">Community Wiki</span>
</span>
</RouterLink>
</header>
<button class="site-sidebar-scrim" type="button" :aria-label="t('nav.closeMenu')" @click="closeSidebar"></button>
<aside id="app-sidebar" class="site-sidebar" :aria-label="t('nav.main')">
<div class="site-sidebar__inner">
<RouterLink class="brand-lockup" to="/pokemon" aria-label="Pokopia Wiki" @click="closeSidebar">
<PokeBallMark size="42px" /> <PokeBallMark size="42px" />
<span> <span>
<span class="pokemon-word">Pokopia</span> <span class="pokemon-word">Pokopia</span>
@@ -72,10 +131,24 @@ onBeforeUnmount(() => {
</span> </span>
</RouterLink> </RouterLink>
<nav class="nav-links" :aria-label="t('nav.main')"> <nav class="side-nav" :aria-label="t('nav.main')">
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to"> <RouterLink
<Icon v-if="item.icon" :icon="item.icon" class="ui-icon nav-links__icon" aria-hidden="true" /> v-for="item in navItems"
{{ item.label }} :key="item.to"
class="side-nav__link"
:class="{ 'router-link-active': isNavActive(item.to) }"
:to="item.to"
@click="closeSidebar"
>
<Icon v-if="item.icon" :icon="item.icon" class="ui-icon side-nav__icon" aria-hidden="true" />
<span class="side-nav__label">{{ item.label }}</span>
<StatusBadge
v-if="item.badge"
class="side-nav__badge"
:label="item.badge.label"
:tone="item.badge.tone"
compact
/>
</RouterLink> </RouterLink>
</nav> </nav>
@@ -112,24 +185,24 @@ onBeforeUnmount(() => {
</div> </div>
<template v-if="currentUser"> <template v-if="currentUser">
<span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span> <span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span>
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="$emit('logout')"> <button class="ui-button ui-button--ghost ui-button--small" type="button" @click="requestLogout">
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
{{ t('nav.logout') }} {{ t('nav.logout') }}
</button> </button>
</template> </template>
<template v-else> <template v-else>
<RouterLink class="ui-button ui-button--ghost ui-button--small" to="/login"> <RouterLink class="ui-button ui-button--ghost ui-button--small" to="/login" @click="closeSidebar">
<Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" />
{{ t('nav.login') }} {{ t('nav.login') }}
</RouterLink> </RouterLink>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/register"> <RouterLink class="ui-button ui-button--primary ui-button--small" to="/register" @click="closeSidebar">
<Icon :icon="iconRegister" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconRegister" class="ui-icon" aria-hidden="true" />
{{ t('nav.register') }} {{ t('nav.register') }}
</RouterLink> </RouterLink>
</template> </template>
</div> </div>
</div> </div>
</header> </aside>
<main class="page"> <main class="page">
<slot></slot> <slot></slot>

View File

@@ -12,6 +12,17 @@ const changeLabelKeys: Record<string, string> = {
Name: 'common.name', Name: 'common.name',
名字: 'common.name', 名字: 'common.name',
名称: 'common.name', 名称: 'common.name',
Genus: 'pages.pokemon.genus',
Details: 'pages.pokemon.details',
介绍: 'pages.pokemon.details',
Height: 'pages.pokemon.height',
身高: 'pages.pokemon.height',
Weight: 'pages.pokemon.weight',
体重: 'pages.pokemon.weight',
Types: 'pages.pokemon.types',
属性: 'pages.pokemon.types',
Stats: 'pages.pokemon.statsTitle',
六维: 'pages.pokemon.statsTitle',
'Ideal Habitat': 'pages.pokemon.environment', 'Ideal Habitat': 'pages.pokemon.environment',
'Favorite environment': 'pages.pokemon.environment', 'Favorite environment': 'pages.pokemon.environment',
喜欢的环境: 'pages.pokemon.environment', 喜欢的环境: 'pages.pokemon.environment',
@@ -96,7 +107,7 @@ function formatDateTime(value: string): string {
</script> </script>
<template> <template>
<aside class="edit-history-panel" aria-labelledby="edit-history-panel-title"> <section class="edit-history-panel" aria-labelledby="edit-history-panel-title">
<div class="edit-history-panel__header"> <div class="edit-history-panel__header">
<h2 id="edit-history-panel-title">{{ t('history.title') }}</h2> <h2 id="edit-history-panel-title">{{ t('history.title') }}</h2>
</div> </div>
@@ -163,5 +174,5 @@ function formatDateTime(value: string): string {
</ol> </ol>
<p v-else class="meta-line">{{ t('history.empty') }}</p> <p v-else class="meta-line">{{ t('history.empty') }}</p>
</section> </section>
</aside> </section>
</template> </template>

View File

@@ -0,0 +1,418 @@
<script setup lang="ts">
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 {
api,
getAuthToken,
onAuthTokenChange,
setAuthToken,
type AuthUser,
type DiscussionEntityType,
type EntityDiscussionComment
} from '../services/api';
import Skeleton from './Skeleton.vue';
const props = defineProps<{
entityType: DiscussionEntityType;
entityId: string | number;
}>();
const { locale, t } = useI18n();
const comments = ref<EntityDiscussionComment[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const authReady = ref(false);
const body = ref('');
const replyBodies = ref<Record<number, string>>({});
const replyTargetId = ref<number | null>(null);
const busyKey = ref('');
const loadError = ref('');
const formError = ref('');
const commentErrors = ref<Record<string, string>>({});
const commentInput = ref<HTMLTextAreaElement | null>(null);
const commentMaxLength = 1000;
let requestId = 0;
let removeAuthListener: (() => void) | null = null;
const canComment = computed(() => currentUser.value?.emailVerified === true);
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
const commentTotal = computed(() => comments.value.reduce((total, comment) => total + 1 + comment.replies.length, 0));
async function loadCurrentUser() {
authReady.value = false;
if (!getAuthToken()) {
currentUser.value = null;
authReady.value = true;
return;
}
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
setAuthToken(null);
} finally {
authReady.value = true;
}
}
async function loadDiscussion() {
const nextRequestId = ++requestId;
loading.value = true;
loadError.value = '';
try {
const rows = await api.entityDiscussion(props.entityType, props.entityId);
if (nextRequestId === requestId) {
comments.value = rows;
}
} catch (error) {
if (nextRequestId === requestId) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
}
} finally {
if (nextRequestId === requestId) {
loading.value = false;
}
}
}
function resetComposer() {
body.value = '';
replyBodies.value = {};
replyTargetId.value = null;
formError.value = '';
commentErrors.value = {};
}
function commentKey(commentId: number) {
return `comment-${commentId}`;
}
function replyBody(commentId: number) {
return replyBodies.value[commentId] ?? '';
}
function setCommentError(key: string, message: string) {
commentErrors.value = { ...commentErrors.value, [key]: message };
}
function clearCommentError(key: string) {
const nextErrors = { ...commentErrors.value };
delete nextErrors[key];
commentErrors.value = nextErrors;
}
function canManageComment(comment: EntityDiscussionComment) {
return !comment.deleted && currentUser.value?.id === comment.author?.id;
}
function commentAuthorName(comment: EntityDiscussionComment) {
return comment.deleted ? t('discussion.deletedComment') : comment.author?.displayName ?? t('discussion.byUnknown');
}
function commentInitial(comment: EntityDiscussionComment) {
return commentAuthorName(comment).slice(0, 1).toUpperCase();
}
function formatDateTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date);
}
function isBusy(key: string) {
return busyKey.value === key;
}
function startReply(comment: EntityDiscussionComment) {
replyTargetId.value = comment.id;
clearCommentError(commentKey(comment.id));
}
function cancelReply(commentId: number) {
replyTargetId.value = null;
replyBodies.value[commentId] = '';
clearCommentError(commentKey(commentId));
}
async function submitComment() {
const nextBody = body.value.trim();
if (!nextBody) {
formError.value = t('discussion.commentRequired');
commentInput.value?.focus();
return;
}
busyKey.value = 'new-comment';
formError.value = '';
try {
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, { body: nextBody });
comments.value = [...comments.value, comment];
body.value = '';
} catch (error) {
formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed');
} finally {
busyKey.value = '';
}
}
async function submitReply(comment: EntityDiscussionComment) {
const key = commentKey(comment.id);
const nextBody = replyBody(comment.id).trim();
if (!nextBody) {
setCommentError(key, t('discussion.commentRequired'));
return;
}
busyKey.value = key;
clearCommentError(key);
try {
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, { body: nextBody });
comment.replies.push(reply);
cancelReply(comment.id);
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed'));
} finally {
busyKey.value = '';
}
}
function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolean {
for (const comment of rows) {
if (comment.id === id) {
comment.deleted = true;
comment.body = '';
comment.author = null;
return true;
}
if (markCommentDeleted(comment.replies, id)) {
return true;
}
}
return false;
}
async function deleteComment(comment: EntityDiscussionComment) {
if (!window.confirm(t('discussion.deleteConfirm'))) {
return;
}
const key = commentKey(comment.id);
clearCommentError(key);
try {
await api.deleteEntityDiscussionComment(comment.id);
markCommentDeleted(comments.value, comment.id);
if (replyTargetId.value === comment.id) {
cancelReply(comment.id);
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.deleteFailed'));
}
}
watch(
() => [props.entityType, props.entityId],
() => {
resetComposer();
comments.value = [];
void loadDiscussion();
}
);
onMounted(() => {
void loadCurrentUser();
void loadDiscussion();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
});
});
onUnmounted(() => {
removeAuthListener?.();
});
</script>
<template>
<section class="entity-discussion-panel" aria-labelledby="entity-discussion-title">
<div class="entity-discussion-panel__header">
<div>
<h2 id="entity-discussion-title">{{ t('discussion.title') }}</h2>
<p>{{ t('discussion.count', { count: commentTotal }) }}</p>
</div>
</div>
<div v-if="!authReady" class="entity-discussion-skeleton" aria-hidden="true">
<Skeleton variant="box" height="112px" />
</div>
<form v-else-if="canComment" class="entity-discussion-form" @submit.prevent="submitComment">
<div class="field">
<label :for="`entity-discussion-comment-${props.entityType}-${props.entityId}`">{{ t('discussion.comment') }}</label>
<textarea
:id="`entity-discussion-comment-${props.entityType}-${props.entityId}`"
ref="commentInput"
v-model="body"
:maxlength="commentMaxLength"
:placeholder="t('discussion.commentPlaceholder')"
></textarea>
<span class="entity-discussion-form__counter">{{ t('discussion.charactersLeft', { count: charactersLeft }) }}</span>
</div>
<p v-if="formError" class="entity-discussion-form__error" role="alert">{{ formError }}</p>
<button class="ui-button ui-button--primary ui-button--small" :disabled="isBusy('new-comment') || !body.trim()" type="submit">
<Icon :icon="iconComment" class="ui-icon" aria-hidden="true" />
{{ isBusy('new-comment') ? t('discussion.postingComment') : t('discussion.postComment') }}
</button>
</form>
<div v-else class="entity-discussion-auth-note">
<p>{{ currentUser ? t('discussion.verifyPrompt') : t('discussion.loginPrompt') }}</p>
<RouterLink v-if="!currentUser" class="ui-button ui-button--primary ui-button--small" :to="{ path: '/login', query: { redirect: $route.fullPath } }">
{{ t('nav.login') }}
</RouterLink>
</div>
<div v-if="loading" class="entity-discussion-list" :aria-label="t('discussion.loading')">
<article v-for="index in 3" :key="index" class="entity-discussion-comment entity-discussion-comment--skeleton">
<Skeleton variant="box" width="40px" height="40px" />
<div class="entity-discussion-comment__content">
<Skeleton width="148px" />
<Skeleton width="88%" />
<Skeleton width="62%" />
</div>
</article>
</div>
<p v-else-if="loadError" class="entity-discussion-form__error" role="alert">{{ loadError }}</p>
<div v-else-if="comments.length" class="entity-discussion-list">
<article
v-for="comment in comments"
:key="comment.id"
class="entity-discussion-comment"
:class="{ 'is-deleted': comment.deleted }"
>
<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>
<time :datetime="comment.createdAt">{{ formatDateTime(comment.createdAt) }}</time>
</div>
<p v-if="!comment.deleted" class="entity-discussion-comment__body">{{ comment.body }}</p>
<div v-if="!comment.deleted" class="entity-discussion-comment__actions">
<button
v-if="canComment"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('discussion.reply')"
@click="startReply(comment)"
>
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.reply') }}</span>
</button>
<button
v-if="canManageComment(comment)"
class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button"
:aria-label="t('discussion.deleteComment')"
@click="deleteComment(comment)"
>
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
</button>
</div>
<p v-if="commentErrors[commentKey(comment.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[commentKey(comment.id)] }}
</p>
<form
v-if="canComment && replyTargetId === comment.id"
class="entity-discussion-form entity-discussion-form--reply"
@submit.prevent="submitReply(comment)"
>
<div class="field">
<label :for="`entity-discussion-reply-${comment.id}`">{{ t('discussion.reply') }}</label>
<textarea
:id="`entity-discussion-reply-${comment.id}`"
v-model="replyBodies[comment.id]"
:maxlength="commentMaxLength"
:placeholder="t('discussion.replyPlaceholder')"
></textarea>
</div>
<div class="entity-discussion-form__actions">
<button
class="ui-button ui-button--ghost ui-button--small"
:disabled="isBusy(commentKey(comment.id)) || !replyBody(comment.id).trim()"
type="submit"
>
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
{{ isBusy(commentKey(comment.id)) ? t('discussion.postingReply') : t('discussion.postReply') }}
</button>
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="cancelReply(comment.id)">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('discussion.cancelReply') }}
</button>
</div>
</form>
<div v-if="comment.replies.length" class="entity-discussion-replies">
<article
v-for="reply in comment.replies"
:key="reply.id"
class="entity-discussion-comment entity-discussion-comment--reply"
:class="{ 'is-deleted': reply.deleted }"
>
<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>
<time :datetime="reply.createdAt">{{ formatDateTime(reply.createdAt) }}</time>
</div>
<p v-if="!reply.deleted" class="entity-discussion-comment__body">{{ reply.body }}</p>
<div v-if="canManageComment(reply)" class="entity-discussion-comment__actions">
<button
class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button"
:aria-label="t('discussion.deleteComment')"
@click="deleteComment(reply)"
>
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
</button>
</div>
<p v-if="commentErrors[commentKey(reply.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[commentKey(reply.id)] }}
</p>
</div>
</article>
</div>
</div>
</article>
</div>
<div v-else class="entity-discussion-empty">
<Icon :icon="iconComment" class="entity-discussion-empty__icon" aria-hidden="true" />
<div>
<h3>{{ t('discussion.empty') }}</h3>
<p>{{ t('discussion.emptyHint') }}</p>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { PokemonStats } from '../services/api';
const props = defineProps<{
idPrefix: string;
modelValue: PokemonStats;
}>();
const emit = defineEmits<{
'update:modelValue': [value: PokemonStats];
}>();
const { t } = useI18n();
const statRows: Array<{ key: keyof PokemonStats; labelKey: string }> = [
{ key: 'hp', labelKey: 'pages.pokemon.stats.hp' },
{ key: 'attack', labelKey: 'pages.pokemon.stats.attack' },
{ key: 'defense', labelKey: 'pages.pokemon.stats.defense' },
{ key: 'specialAttack', labelKey: 'pages.pokemon.stats.specialAttack' },
{ key: 'specialDefense', labelKey: 'pages.pokemon.stats.specialDefense' },
{ key: 'speed', labelKey: 'pages.pokemon.stats.speed' }
];
function updateStat(key: keyof PokemonStats, event: Event) {
const input = event.target as HTMLInputElement;
const value = Number(input.value);
emit('update:modelValue', {
...props.modelValue,
[key]: Number.isInteger(value) && value >= 0 ? value : 0
});
}
</script>
<template>
<div class="pokemon-stats-fields">
<div v-for="stat in statRows" :key="stat.key" class="field">
<label :for="`${idPrefix}-${stat.key}`">{{ t(stat.labelKey) }}</label>
<input
:id="`${idPrefix}-${stat.key}`"
:value="modelValue[stat.key]"
min="0"
step="1"
type="number"
inputmode="numeric"
@input="updateStat(stat.key, $event)"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import ProgressBar from './ProgressBar.vue';
import type { PokemonStats } from '../services/api';
defineProps<{
stats: PokemonStats;
}>();
const { t } = useI18n();
const statMax = 150;
const statRows: Array<{ key: keyof PokemonStats; labelKey: string; color: string }> = [
{ key: 'hp', labelKey: 'pages.pokemon.stats.hp', color: 'var(--success)' },
{ key: 'attack', labelKey: 'pages.pokemon.stats.attack', color: 'var(--pokemon-red)' },
{ key: 'defense', labelKey: 'pages.pokemon.stats.defense', color: 'var(--pokemon-blue)' },
{ key: 'specialAttack', labelKey: 'pages.pokemon.stats.specialAttack', color: 'var(--type-psychic)' },
{ key: 'specialDefense', labelKey: 'pages.pokemon.stats.specialDefense', color: 'var(--type-water)' },
{ key: 'speed', labelKey: 'pages.pokemon.stats.speed', color: 'var(--pokemon-yellow)' }
];
</script>
<template>
<div class="pokemon-stats-panel">
<ProgressBar
v-for="stat in statRows"
:key="stat.key"
:label="t(stat.labelKey)"
:value="stats[stat.key]"
:max="statMax"
:color="stat.color"
/>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(
defineProps<{
label: string;
value: number;
max?: number;
color?: string;
}>(),
{
max: 100,
color: 'var(--pokemon-blue)'
}
);
const safeMax = computed(() => (Number.isFinite(props.max) && props.max > 0 ? props.max : 100));
const safeValue = computed(() => (Number.isFinite(props.value) && props.value > 0 ? props.value : 0));
const percentage = computed(() => Math.min(100, Math.round((safeValue.value / safeMax.value) * 100)));
const valueText = computed(() => `${safeValue.value} / ${safeMax.value}`);
</script>
<template>
<div class="progress" role="meter" :aria-label="label" :aria-valuenow="safeValue" aria-valuemin="0" :aria-valuemax="safeMax">
<div class="progress-label">
<span>{{ label }}</span>
<span>{{ valueText }}</span>
</div>
<div class="progress-track">
<span class="progress-fill" :style="{ width: `${percentage}%`, background: color }"></span>
</div>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
withDefaults(
defineProps<{
label: string;
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
compact?: boolean;
}>(),
{
tone: 'info',
compact: false
}
);
</script>
<template>
<span class="status-badge" :class="[`status-badge--${tone}`, { 'status-badge--compact': compact }]">
<span class="status-badge__dot" aria-hidden="true"></span>
<span class="status-badge__label">{{ label }}</span>
</span>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { iconCheck, iconChevronDown, iconClose } from '../icons'; import { iconCheck, iconChevronDown, iconClose } from '../icons';
@@ -17,6 +17,7 @@ type OptionRow = {
}; };
type CandidateRow = { type: 'option'; id: string; value: string; label: string } | { type: 'create'; id: string }; type CandidateRow = { type: 'option'; id: string; value: string; label: string } | { type: 'create'; id: string };
type DropdownStrategy = 'absolute' | 'fixed';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -31,12 +32,14 @@ const props = withDefaults(
allowCreate?: boolean; allowCreate?: boolean;
creating?: boolean; creating?: boolean;
createLabel?: string; createLabel?: string;
dropdownStrategy?: DropdownStrategy;
}>(), }>(),
{ {
multiple: true, multiple: true,
max: 0, max: 0,
allowCreate: false, allowCreate: false,
creating: false creating: false,
dropdownStrategy: 'absolute'
} }
); );
@@ -47,10 +50,14 @@ const emit = defineEmits<{
const { t } = useI18n(); const { t } = useI18n();
const root = ref<HTMLElement | null>(null); const root = ref<HTMLElement | null>(null);
const trigger = ref<HTMLButtonElement | null>(null);
const searchInput = ref<HTMLInputElement | null>(null); const searchInput = ref<HTMLInputElement | null>(null);
const isOpen = ref(false); const isOpen = ref(false);
const search = ref(''); const search = ref('');
const activeIndex = ref(-1); const activeIndex = ref(-1);
const dropdownStyle = ref<CSSProperties>({});
const dropdownPlacement = ref<'top' | 'bottom'>('bottom');
let positionFrame = 0;
const optionRows = computed(() => const optionRows = computed(() =>
props.options.map((option, index) => ({ props.options.map((option, index) => ({
@@ -104,6 +111,7 @@ const candidateRows = computed<CandidateRow[]>(() => {
}); });
const activeCandidate = computed(() => candidateRows.value[activeIndex.value]); const activeCandidate = computed(() => candidateRows.value[activeIndex.value]);
const activeDescendant = computed(() => activeCandidate.value?.id); const activeDescendant = computed(() => activeCandidate.value?.id);
const usesFixedDropdown = computed(() => props.dropdownStrategy === 'fixed');
function setDefaultActiveIndex() { function setDefaultActiveIndex() {
const keyword = createName.value.toLowerCase(); const keyword = createName.value.toLowerCase();
@@ -130,6 +138,8 @@ function clampActiveIndex() {
async function openDropdown() { async function openDropdown() {
isOpen.value = true; isOpen.value = true;
await nextTick(); await nextTick();
updateDropdownPosition();
addPositionListeners();
setDefaultActiveIndex(); setDefaultActiveIndex();
searchInput.value?.focus(); searchInput.value?.focus();
} }
@@ -138,6 +148,8 @@ function closeDropdown() {
isOpen.value = false; isOpen.value = false;
search.value = ''; search.value = '';
activeIndex.value = -1; activeIndex.value = -1;
dropdownStyle.value = {};
removePositionListeners();
} }
function toggleDropdown() { function toggleDropdown() {
@@ -168,11 +180,13 @@ function selectOption(value: string) {
updateValue([...modelValues.value, value]); updateValue([...modelValues.value, value]);
search.value = ''; search.value = '';
setDefaultActiveIndex(); setDefaultActiveIndex();
scheduleDropdownPositionUpdate();
} }
} }
function remove(value: string) { function remove(value: string) {
updateValue(modelValues.value.filter((item) => item !== value)); updateValue(modelValues.value.filter((item) => item !== value));
scheduleDropdownPositionUpdate();
} }
function createOption() { function createOption() {
@@ -225,22 +239,107 @@ function onDocumentPointerDown(event: PointerEvent) {
} }
} }
function scheduleDropdownPositionUpdate() {
if (!usesFixedDropdown.value || !isOpen.value || positionFrame) {
return;
}
positionFrame = window.requestAnimationFrame(() => {
positionFrame = 0;
updateDropdownPosition();
});
}
function updateDropdownPosition() {
if (!usesFixedDropdown.value || !isOpen.value || !trigger.value) {
dropdownStyle.value = {};
return;
}
const viewportPadding = 12;
const dropdownGap = 6;
const dropdownChromeHeight = 72;
const triggerRect = trigger.value.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const width = Math.min(triggerRect.width, viewportWidth - viewportPadding * 2);
const left = Math.min(Math.max(triggerRect.left, viewportPadding), viewportWidth - width - viewportPadding);
const spaceBelow = viewportHeight - triggerRect.bottom - viewportPadding - dropdownGap;
const spaceAbove = triggerRect.top - viewportPadding - dropdownGap;
const placeAbove = spaceBelow < 220 && spaceAbove > spaceBelow;
const availableSpace = Math.max(144, placeAbove ? spaceAbove : spaceBelow);
const optionsMaxHeight = Math.max(96, Math.min(240, availableSpace - dropdownChromeHeight));
const nextStyle = {
left: `${left}px`,
width: `${width}px`,
'--tags-select-options-max-height': `${optionsMaxHeight}px`
} as CSSProperties;
if (placeAbove) {
dropdownPlacement.value = 'top';
dropdownStyle.value = {
...nextStyle,
bottom: `${viewportHeight - triggerRect.top + dropdownGap}px`
};
return;
}
dropdownPlacement.value = 'bottom';
dropdownStyle.value = {
...nextStyle,
top: `${triggerRect.bottom + dropdownGap}px`
};
}
function addPositionListeners() {
if (!usesFixedDropdown.value) {
return;
}
window.addEventListener('resize', scheduleDropdownPositionUpdate);
window.addEventListener('scroll', scheduleDropdownPositionUpdate, true);
}
function removePositionListeners() {
window.removeEventListener('resize', scheduleDropdownPositionUpdate);
window.removeEventListener('scroll', scheduleDropdownPositionUpdate, true);
if (positionFrame) {
window.cancelAnimationFrame(positionFrame);
positionFrame = 0;
}
}
onMounted(() => { onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown); document.addEventListener('pointerdown', onDocumentPointerDown);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown); document.removeEventListener('pointerdown', onDocumentPointerDown);
removePositionListeners();
}); });
watch(search, setDefaultActiveIndex); watch(search, setDefaultActiveIndex);
watch(candidateRows, clampActiveIndex); watch(candidateRows, clampActiveIndex);
watch(
() => props.dropdownStrategy,
() => {
if (!isOpen.value) return;
removePositionListeners();
void nextTick(() => {
updateDropdownPosition();
addPositionListeners();
});
}
);
</script> </script>
<template> <template>
<div ref="root" class="tags-select" :class="{ 'tags-select--single': !multiple }" @keydown="onRootKeydown"> <div ref="root" class="tags-select" :class="{ 'tags-select--single': !multiple }" @keydown="onRootKeydown">
<button <button
:id="id" :id="id"
ref="trigger"
type="button" type="button"
class="tags-select__trigger" class="tags-select__trigger"
:class="{ open: isOpen }" :class="{ open: isOpen }"
@@ -271,7 +370,15 @@ watch(candidateRows, clampActiveIndex);
<Icon :icon="iconChevronDown" class="tags-select__arrow" aria-hidden="true" /> <Icon :icon="iconChevronDown" class="tags-select__arrow" aria-hidden="true" />
</button> </button>
<div v-if="isOpen" class="tags-select__dropdown"> <div
v-if="isOpen"
class="tags-select__dropdown"
:class="{
'tags-select__dropdown--fixed': usesFixedDropdown,
'tags-select__dropdown--top': usesFixedDropdown && dropdownPlacement === 'top'
}"
:style="dropdownStyle"
>
<input <input
ref="searchInput" ref="searchInput"
v-model="search" v-model="search"

View File

@@ -11,6 +11,8 @@ const props = defineProps<{
translations: TranslationMap; translations: TranslationMap;
languages: Language[]; languages: Language[];
required?: boolean; required?: boolean;
multiline?: boolean;
rows?: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -79,11 +81,20 @@ function updateField(language: Language, value: string) {
{{ t('common.fieldForLanguage', { field: label, language: currentLanguage.name }) }} {{ t('common.fieldForLanguage', { field: label, language: currentLanguage.name }) }}
</label> </label>
<input <input
v-if="!multiline"
:id="`${idPrefix}-${currentLanguage.code}`" :id="`${idPrefix}-${currentLanguage.code}`"
v-model="currentValue" v-model="currentValue"
:placeholder="currentPlaceholder" :placeholder="currentPlaceholder"
:required="currentRequired" :required="currentRequired"
/> />
<textarea
v-else
:id="`${idPrefix}-${currentLanguage.code}`"
v-model="currentValue"
:placeholder="currentPlaceholder"
:required="currentRequired"
:rows="rows ?? 4"
></textarea>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -17,6 +17,7 @@ const messages = {
create: 'Create', create: 'Create',
delete: 'Delete', delete: 'Delete',
edit: 'Edit', edit: 'Edit',
details: 'Details',
filters: 'Filters', filters: 'Filters',
loading: 'Loading', loading: 'Loading',
name: 'Name', name: 'Name',
@@ -34,6 +35,7 @@ const messages = {
noMatches: 'No matches', noMatches: 'No matches',
createNamed: 'Add "{name}"', createNamed: 'Add "{name}"',
creating: 'Adding', creating: 'Adding',
inDev: 'In-Dev',
removeNamed: 'Remove {name}', removeNamed: 'Remove {name}',
quantity: 'Quantity', quantity: 'Quantity',
required: 'Required' required: 'Required'
@@ -43,9 +45,17 @@ const messages = {
habitats: 'Habitats', habitats: 'Habitats',
items: 'Items', items: 'Items',
recipes: 'Recipes', recipes: 'Recipes',
dish: 'Dish',
events: 'Events',
actions: 'Actions',
dreamIsland: 'Dream Island',
clothes: 'Clothes',
checklist: 'CheckList', checklist: 'CheckList',
life: 'Life',
admin: 'Admin', admin: 'Admin',
main: 'Main navigation', main: 'Main navigation',
openMenu: 'Open navigation',
closeMenu: 'Close navigation',
language: 'Language', language: 'Language',
login: 'Log in', login: 'Log in',
logout: 'Log out', logout: 'Log out',
@@ -87,13 +97,43 @@ const messages = {
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.', subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
detailKicker: 'Pokédex Detail', detailKicker: 'Pokédex Detail',
editKicker: 'Pokédex Edit', editKicker: 'Pokédex Edit',
editSubtitle: 'Maintain Pokemon profile, specialities, and favourites.', editSubtitle: 'Maintain Pokemon profile, details, types, stats, specialities, and favourites.',
editSections: 'Pokemon edit sections',
editTabBasic: 'Basic',
editTabAdvance: 'Advance',
newTitle: 'New Pokemon', newTitle: 'New Pokemon',
editTitle: 'Edit #{id} {name}', editTitle: 'Edit #{id} {name}',
loadingList: 'Loading Pokemon list', loadingList: 'Loading Pokemon list',
loadingDetail: 'Loading Pokemon detail', loadingDetail: 'Loading Pokemon detail',
loadingEdit: 'Loading Pokemon editor', loadingEdit: 'Loading Pokemon editor',
environmentPrefix: 'Ideal Habitat: {name}', environmentPrefix: 'Ideal Habitat: {name}',
details: 'Details',
genus: 'Genus',
height: 'Height',
heightInput: 'Height (in)',
heightImperial: 'ft / in',
heightMetric: 'm',
feet: 'ft',
inches: 'in',
meters: 'm',
weight: 'Weight',
weightInput: 'Weight (lb)',
pounds: 'lb',
kilograms: 'kg',
measurements: 'Height & Weight',
types: 'Types',
typeOne: 'Type 1',
typeTwo: 'Type 2',
typesAndStats: 'Types & Base stats',
statsTitle: 'Base stats',
stats: {
hp: 'HP',
attack: 'Attack',
defense: 'Defense',
specialAttack: 'Special Attack',
specialDefense: 'Special Defense',
speed: 'Speed'
},
environment: 'Ideal Habitat', environment: 'Ideal Habitat',
skills: 'Specialities', skills: 'Specialities',
skillMatchMode: 'Speciality match mode', skillMatchMode: 'Speciality match mode',
@@ -105,10 +145,13 @@ const messages = {
skillDrop: '{name} drop', skillDrop: '{name} drop',
dropItem: 'Drop item', dropItem: 'Drop item',
searchPokemon: 'Search Pokemon', searchPokemon: 'Search Pokemon',
relatedPokemon: 'Related Pokemon',
relatedHabitat: 'Related Pokemon habitat',
relatedItems: 'Related items', relatedItems: 'Related items',
relatedItemCategory: 'Related item category', relatedItemCategory: 'Related item category',
habitats: 'Habitats', habitats: 'Habitats',
namePlaceholder: 'Name', namePlaceholder: 'Name',
searchTypes: 'Search types',
searchEnvironment: 'Search ideal habitats', searchEnvironment: 'Search ideal habitats',
searchSkills: 'Search specialities', searchSkills: 'Search specialities',
searchFavoriteThings: 'Search favourites', searchFavoriteThings: 'Search favourites',
@@ -182,6 +225,68 @@ const messages = {
materials: 'Materials', materials: 'Materials',
addMaterial: 'Add material' addMaterial: 'Add material'
}, },
comingSoon: {
status: 'In development',
heading: 'This wiki section is being prepared.',
previewLabel: 'Section preview',
sections: {
dish: {
kicker: 'Dish',
title: 'Dish',
subtitle: 'A future home for cooked dishes and food discoveries.',
body: 'Dish pages are being shaped for clear browsing, source notes, and useful ingredient links.',
preview: {
one: 'Dish records will focus on names, effects, and discovery context.',
two: 'Ingredient relationships will connect back to items and recipes where useful.',
three: 'The page will stay browse-first so community edits can grow naturally.'
}
},
events: {
kicker: 'Events',
title: 'Events',
subtitle: 'Seasonal and limited-time game activity records are coming later.',
body: 'Events will collect timing, rewards, and participation details once the section is ready.',
preview: {
one: 'Event cards will make dates and active windows easy to scan.',
two: 'Rewards and related items will sit close to the event summary.',
three: 'Archived activities will remain readable after they end.'
}
},
actions: {
kicker: 'Actions',
title: 'Actions',
subtitle: 'Game shortcut actions such as waving and dancing will be documented here.',
body: 'Actions are being prepared as a quick reference for expressive in-game gestures and shortcuts.',
preview: {
one: 'Each action will describe the gesture or shortcut in player-facing language.',
two: 'Common examples include waving, dancing, and other social actions.',
three: 'Related unlock or usage details can be linked when the data model is ready.'
}
},
dreamIsland: {
kicker: 'Dream Island',
title: 'Dream Island',
subtitle: 'Dream Island information is being organized for future browsing.',
body: 'This area will present island details with a calm, destination-style layout when content is ready.',
preview: {
one: 'Island notes will prioritize location, availability, and notable discoveries.',
two: 'Related Pokemon, items, or activities can be connected from the page.',
three: 'The layout will support browsing without adding another management flow yet.'
}
},
clothes: {
kicker: 'Clothes',
title: 'Clothes',
subtitle: 'Outfit and clothing references are being prepared.',
body: 'Clothes pages will make it easy to compare appearance, acquisition, and customization details.',
preview: {
one: 'Clothing entries will focus on display names and visual categories.',
two: 'Acquisition and customization details can be connected when available.',
three: 'The page will keep item-like details readable without mixing them into the item list.'
}
}
}
},
checklist: { checklist: {
title: 'Daily checklist', title: 'Daily checklist',
subtitle: 'See what can be completed each day.', subtitle: 'See what can be completed each day.',
@@ -192,6 +297,79 @@ const messages = {
newTask: 'New task', newTask: 'New task',
editTask: 'Edit task' editTask: 'Edit task'
}, },
life: {
title: 'Life',
subtitle: 'Share favourite thoughts, tips, and community finds.',
kicker: 'Community Feed',
composerTitle: 'Share something',
composerPrompt: 'What would you like to share?',
bodyLabel: 'Post',
bodyPlaceholder: 'Share a thought, tip, or discovery...',
newPost: 'New Post',
tags: 'Tags',
allTags: 'All',
tagPlaceholder: 'Select tags',
searchTags: 'Search tags',
search: 'Search Life',
searchPlaceholder: 'Search post content...',
clearSearch: 'Clear search',
searchEmpty: 'No posts match your search',
searchEmptyHint: 'Try another keyword or clear the search.',
comments: 'Comments',
commentsCount: '{count} comments',
comment: 'Comment',
hideComments: 'Hide comments',
react: 'Like',
reactions: 'Reactions',
reactionsCount: '{count} reactions',
reactionCountLabel: '{reaction}: {count}',
reactionLike: 'Like',
reactionHelpful: 'Helpful',
reactionFun: 'Fun',
reactionThanks: 'Thanks',
chooseReaction: 'Choose reaction',
reactionMenu: 'Reaction menu',
removeReaction: 'Remove reaction',
reactionFailed: 'Reaction failed',
commentPlaceholder: 'Write a comment...',
commentReplyPlaceholder: 'Write a reply...',
postComment: 'Post comment',
postingComment: 'Posting comment',
reply: 'Reply',
postReply: 'Post reply',
postingReply: 'Posting reply',
cancelReply: 'Cancel reply',
noComments: 'No comments yet',
deleteComment: 'Delete comment',
deleteCommentConfirm: 'Delete this comment?',
commentDeleted: 'Comment deleted',
commentRequired: 'Please enter a comment.',
commentFailed: 'Comment failed',
replyFailed: 'Reply failed',
deleteCommentFailed: 'Delete comment failed',
publish: 'Post',
publishing: 'Posting',
update: 'Update',
updating: 'Updating',
cancelEdit: 'Cancel edit',
empty: 'No posts yet',
emptyHint: 'Verified members can start the first Life post.',
loading: 'Loading Life feed',
retryFeed: 'Retry loading',
loginPrompt: 'Log in with a verified email to post.',
verifyPrompt: 'Complete email verification to post.',
editPost: 'Edit post',
deletePost: 'Delete post',
saveEdit: 'Save edit',
postFailed: 'Post failed',
saveFailed: 'Save failed',
deleteFailed: 'Delete failed',
bodyRequired: 'Please enter a post.',
byUnknown: 'Community member',
edited: 'Edited',
deleteConfirm: 'Delete this post?',
charactersLeft: '{count} characters left'
},
admin: { admin: {
title: 'Admin', title: 'Admin',
subtitle: 'Maintain system configuration and manage Wiki records.', subtitle: 'Maintain system configuration and manage Wiki records.',
@@ -220,13 +398,15 @@ const messages = {
} }
}, },
config: { config: {
pokemonTypes: 'Pokemon Types',
skills: 'Specialities', skills: 'Specialities',
environments: 'Ideal Habitats', environments: 'Ideal Habitats',
favoriteThings: 'Favourites / tags', favoriteThings: 'Favourites / tags',
itemCategories: 'Item categories', itemCategories: 'Item categories',
itemUsages: 'Item usages', itemUsages: 'Item usages',
acquisitionMethods: 'Acquisition methods', acquisitionMethods: 'Acquisition methods',
maps: 'Maps' maps: 'Maps',
lifeTags: 'Life tags'
}, },
appearance: { appearance: {
time: 'Time', time: 'Time',
@@ -257,6 +437,33 @@ const messages = {
update: 'Edit', update: 'Edit',
delete: 'Delete', delete: 'Delete',
empty: 'No edit history' empty: 'No edit history'
},
discussion: {
title: 'Discussion',
count: '{count} comments',
comment: 'Comment',
commentPlaceholder: 'Write a comment...',
replyPlaceholder: 'Write a reply...',
postComment: 'Post comment',
postingComment: 'Posting comment',
reply: 'Reply',
postReply: 'Post reply',
postingReply: 'Posting reply',
cancelReply: 'Cancel reply',
deleteComment: 'Delete comment',
deleteConfirm: 'Delete this comment?',
deletedComment: 'Comment deleted',
commentRequired: 'Please enter a comment.',
commentFailed: 'Comment failed',
replyFailed: 'Reply failed',
deleteFailed: 'Delete failed',
loading: 'Loading discussion',
empty: 'No discussion yet',
emptyHint: 'Start a new discussion now.',
loginPrompt: 'Log in with a verified email to comment.',
verifyPrompt: 'Complete email verification to comment.',
byUnknown: 'Community member',
charactersLeft: '{count} characters left'
} }
}, },
'zh-CN': { 'zh-CN': {
@@ -271,6 +478,7 @@ const messages = {
create: '创建', create: '创建',
delete: '删除', delete: '删除',
edit: '编辑', edit: '编辑',
details: '详情',
filters: '筛选', filters: '筛选',
loading: '加载中', loading: '加载中',
name: '名称', name: '名称',
@@ -288,6 +496,7 @@ const messages = {
noMatches: '没有匹配项', noMatches: '没有匹配项',
createNamed: '添加「{name}」', createNamed: '添加「{name}」',
creating: '添加中', creating: '添加中',
inDev: '开发中',
removeNamed: '移除{name}', removeNamed: '移除{name}',
quantity: '数量', quantity: '数量',
required: '必填' required: '必填'
@@ -297,9 +506,17 @@ const messages = {
habitats: '栖息地', habitats: '栖息地',
items: '物品', items: '物品',
recipes: '材料单', recipes: '材料单',
dish: '料理',
events: '活动',
actions: '动作',
dreamIsland: 'Dream Island',
clothes: '服装',
checklist: 'CheckList', checklist: 'CheckList',
life: 'Life',
admin: '管理', admin: '管理',
main: '主导航', main: '主导航',
openMenu: '打开导航',
closeMenu: '关闭导航',
language: '语言', language: '语言',
login: '登录', login: '登录',
logout: '退出', logout: '退出',
@@ -341,13 +558,43 @@ const messages = {
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。', subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',
detailKicker: 'Pokédex Detail', detailKicker: 'Pokédex Detail',
editKicker: 'Pokédex Edit', editKicker: 'Pokédex Edit',
editSubtitle: '维护 Pokemon 基本资料、特长和喜欢的东西。', editSubtitle: '维护 Pokemon 介绍、属性、六维、特长和喜欢的东西。',
editSections: 'Pokemon 编辑分区',
editTabBasic: '基础',
editTabAdvance: '进阶',
newTitle: '新增 Pokemon', newTitle: '新增 Pokemon',
editTitle: '编辑 #{id} {name}', editTitle: '编辑 #{id} {name}',
loadingList: '正在加载 Pokemon 列表', loadingList: '正在加载 Pokemon 列表',
loadingDetail: '正在加载 Pokemon 详情', loadingDetail: '正在加载 Pokemon 详情',
loadingEdit: '正在加载 Pokemon 编辑内容', loadingEdit: '正在加载 Pokemon 编辑内容',
environmentPrefix: '喜欢的环境:{name}', environmentPrefix: '喜欢的环境:{name}',
details: '介绍',
genus: '分类',
height: '身高',
heightInput: '身高in',
heightImperial: 'ft / in',
heightMetric: 'm',
feet: 'ft',
inches: 'in',
meters: 'm',
weight: '体重',
weightInput: '体重lb',
pounds: 'lb',
kilograms: 'kg',
measurements: '身高与体重',
types: '属性',
typeOne: '属性 1',
typeTwo: '属性 2',
typesAndStats: '属性与六维',
statsTitle: '六维',
stats: {
hp: 'HP',
attack: '攻击',
defense: '防御',
specialAttack: '特攻',
specialDefense: '特防',
speed: '速度'
},
environment: '喜欢的环境', environment: '喜欢的环境',
skills: '特长', skills: '特长',
skillMatchMode: '特长匹配方式', skillMatchMode: '特长匹配方式',
@@ -359,10 +606,13 @@ const messages = {
skillDrop: '{name}掉落物', skillDrop: '{name}掉落物',
dropItem: '掉落物', dropItem: '掉落物',
searchPokemon: '搜索 Pokemon', searchPokemon: '搜索 Pokemon',
relatedPokemon: '相关 Pokemon',
relatedHabitat: '相关 Pokemon 栖息地',
relatedItems: '关联物品', relatedItems: '关联物品',
relatedItemCategory: '关联物品分类', relatedItemCategory: '关联物品分类',
habitats: '栖息地', habitats: '栖息地',
namePlaceholder: '名字', namePlaceholder: '名字',
searchTypes: '搜索属性',
searchEnvironment: '搜索喜欢的环境', searchEnvironment: '搜索喜欢的环境',
searchSkills: '搜索特长', searchSkills: '搜索特长',
searchFavoriteThings: '搜索喜欢的东西', searchFavoriteThings: '搜索喜欢的东西',
@@ -436,6 +686,68 @@ const messages = {
materials: '需要材料', materials: '需要材料',
addMaterial: '添加材料' addMaterial: '添加材料'
}, },
comingSoon: {
status: '正在开发中',
heading: '这个 Wiki 分区正在准备中。',
previewLabel: '分区预览',
sections: {
dish: {
kicker: 'Dish',
title: '料理',
subtitle: '未来会用于整理料理和食物相关发现。',
body: '料理页面会围绕清晰浏览、来源记录和材料关联来设计。',
preview: {
one: '料理记录会优先呈现名称、效果和发现方式。',
two: '需要时会把材料关系连接回物品和材料单。',
three: '页面会先保持浏览友好,后续再自然承接社区编辑内容。'
}
},
events: {
kicker: 'Events',
title: '活动',
subtitle: '季节活动和限时内容资料会在这里整理。',
body: '活动分区会在准备好后集中展示时间、奖励和参与信息。',
preview: {
one: '活动卡片会让日期和开放时间更容易浏览。',
two: '奖励与关联物品会靠近活动摘要展示。',
three: '活动结束后,历史记录也会保持可读。'
}
},
actions: {
kicker: 'Actions',
title: '动作',
subtitle: '挥手、跳舞等游戏内快捷动作会记录在这里。',
body: '动作分区会作为游戏内表情、社交动作和快捷动作的快速参考。',
preview: {
one: '每个动作会用面向玩家的语言说明动作或快捷方式。',
two: '常见内容包括挥手、跳舞和其他社交动作。',
three: '后续可在数据模型准备好后补充解锁或使用条件。'
}
},
dreamIsland: {
kicker: 'Dream Island',
title: 'Dream Island',
subtitle: 'Dream Island 相关资料正在整理。',
body: '这个区域未来会用更像目的地资料页的方式展示岛屿信息。',
preview: {
one: '岛屿记录会优先整理地点、开放状态和重要发现。',
two: '可关联的 Pokemon、物品或活动会从页面中连接出来。',
three: '目前先保持公开浏览入口,不额外增加管理流程。'
}
},
clothes: {
kicker: 'Clothes',
title: '服装',
subtitle: '外观和服装资料正在准备。',
body: '服装页面会用于对比外观、入手方式和自定义信息。',
preview: {
one: '服装条目会优先整理展示名称和视觉分类。',
two: '入手方式与自定义信息会在资料可用后接入。',
three: '页面会保持服装资料清晰,不和普通物品列表混在一起。'
}
}
}
},
checklist: { checklist: {
title: '每日清单', title: '每日清单',
subtitle: '查看每天可以完成的事项。', subtitle: '查看每天可以完成的事项。',
@@ -446,6 +758,79 @@ const messages = {
newTask: '新增 Task', newTask: '新增 Task',
editTask: '编辑 Task' editTask: '编辑 Task'
}, },
life: {
title: 'Life',
subtitle: '分享喜欢的心得、想法和社区发现。',
kicker: '社区动态',
composerTitle: '分享动态',
composerPrompt: '想分享什么?',
bodyLabel: '动态内容',
bodyPlaceholder: '分享一段想法、心得或发现……',
newPost: 'New Post',
tags: '标签',
allTags: '全部',
tagPlaceholder: '选择标签',
searchTags: '搜索标签',
search: '搜索动态',
searchPlaceholder: '搜索动态内容……',
clearSearch: '清除搜索',
searchEmpty: '没有匹配的动态',
searchEmptyHint: '换个关键词或清除搜索。',
comments: '评论',
commentsCount: '{count} 条评论',
comment: '评论',
hideComments: '收起评论',
react: '点赞',
reactions: '互动',
reactionsCount: '{count} 次互动',
reactionCountLabel: '{reaction}{count}',
reactionLike: '喜欢',
reactionHelpful: '有帮助',
reactionFun: '有趣',
reactionThanks: '感谢',
chooseReaction: '选择互动',
reactionMenu: '互动菜单',
removeReaction: '取消互动',
reactionFailed: '互动失败',
commentPlaceholder: '写下评论……',
commentReplyPlaceholder: '写下回复……',
postComment: '发表评论',
postingComment: '评论中',
reply: '回复',
postReply: '发布回复',
postingReply: '回复中',
cancelReply: '取消回复',
noComments: '暂无评论',
deleteComment: '删除评论',
deleteCommentConfirm: '确认删除这条评论?',
commentDeleted: '评论已删除',
commentRequired: '请输入评论内容。',
commentFailed: '评论失败',
replyFailed: '回复失败',
deleteCommentFailed: '删除评论失败',
publish: '发布',
publishing: '发布中',
update: '更新',
updating: '更新中',
cancelEdit: '取消编辑',
empty: '暂无动态',
emptyHint: '已验证成员可以发布第一条 Life 动态。',
loading: '正在加载 Life 动态',
retryFeed: '重试加载',
loginPrompt: '使用已验证邮箱登录后即可发布。',
verifyPrompt: '完成邮箱验证后即可发布。',
editPost: '编辑动态',
deletePost: '删除动态',
saveEdit: '保存编辑',
postFailed: '发布失败',
saveFailed: '保存失败',
deleteFailed: '删除失败',
bodyRequired: '请输入动态内容。',
byUnknown: '社区成员',
edited: '已编辑',
deleteConfirm: '确认删除这条动态?',
charactersLeft: '还可以输入 {count} 个字符'
},
admin: { admin: {
title: '管理', title: '管理',
subtitle: '维护系统配置,查看并删除 Wiki 数据记录。', subtitle: '维护系统配置,查看并删除 Wiki 数据记录。',
@@ -474,13 +859,15 @@ const messages = {
} }
}, },
config: { config: {
pokemonTypes: 'Pokemon 属性',
skills: '特长', skills: '特长',
environments: '喜欢的环境', environments: '喜欢的环境',
favoriteThings: '喜欢的东西 / 标签', favoriteThings: '喜欢的东西 / 标签',
itemCategories: '物品分类', itemCategories: '物品分类',
itemUsages: '物品用途', itemUsages: '物品用途',
acquisitionMethods: '入手方式', acquisitionMethods: '入手方式',
maps: '地图' maps: '地图',
lifeTags: 'Life 标签'
}, },
appearance: { appearance: {
time: '时段', time: '时段',
@@ -511,6 +898,33 @@ const messages = {
update: '编辑', update: '编辑',
delete: '删除', delete: '删除',
empty: '暂无编辑历史' empty: '暂无编辑历史'
},
discussion: {
title: '讨论',
count: '{count} 条评论',
comment: '评论',
commentPlaceholder: '写下评论……',
replyPlaceholder: '写下回复……',
postComment: '发表评论',
postingComment: '评论中',
reply: '回复',
postReply: '发布回复',
postingReply: '回复中',
cancelReply: '取消回复',
deleteComment: '删除评论',
deleteConfirm: '确认删除这条评论?',
deletedComment: '评论已删除',
commentRequired: '请输入评论内容。',
commentFailed: '评论失败',
replyFailed: '回复失败',
deleteFailed: '删除失败',
loading: '正在加载讨论',
empty: '暂无讨论',
emptyHint: '现在发起新的讨论。',
loginPrompt: '使用已验证邮箱登录后即可评论。',
verifyPrompt: '完成邮箱验证后即可评论。',
byUnknown: '社区成员',
charactersLeft: '还可以输入 {count} 个字符'
} }
} }
}; };

View File

@@ -2,27 +2,41 @@ export type AppIcon = string;
export const iconAdd: AppIcon = 'mdi:plus'; export const iconAdd: AppIcon = 'mdi:plus';
export const iconAdmin: AppIcon = 'mdi:tune-variant'; export const iconAdmin: AppIcon = 'mdi:tune-variant';
export const iconAction: AppIcon = 'mdi:gesture-tap-button';
export const iconBack: AppIcon = 'mdi:arrow-left'; export const iconBack: AppIcon = 'mdi:arrow-left';
export const iconCancel: AppIcon = 'mdi:close'; export const iconCancel: AppIcon = 'mdi:close';
export const iconCheck: AppIcon = 'mdi:check'; export const iconCheck: AppIcon = 'mdi:check';
export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline'; export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
export const iconChevronDown: AppIcon = 'mdi:chevron-down'; export const iconChevronDown: AppIcon = 'mdi:chevron-down';
export const iconClose: AppIcon = 'mdi:close'; export const iconClose: AppIcon = 'mdi:close';
export const iconComment: AppIcon = 'mdi:comment-outline';
export const iconDelete: AppIcon = 'mdi:trash-can-outline'; export const iconDelete: AppIcon = 'mdi:trash-can-outline';
export const iconDish: AppIcon = 'mdi:silverware-fork-knife';
export const iconDragHandle: AppIcon = 'mdi:drag'; export const iconDragHandle: AppIcon = 'mdi:drag';
export const iconDreamIsland: AppIcon = 'mdi:palm-tree';
export const iconEdit: AppIcon = 'mdi:pencil-outline'; export const iconEdit: AppIcon = 'mdi:pencil-outline';
export const iconError: AppIcon = 'mdi:close-circle-outline'; export const iconError: AppIcon = 'mdi:close-circle-outline';
export const iconEvent: AppIcon = 'mdi:calendar-star';
export const iconHabitat: AppIcon = 'mdi:pine-tree'; export const iconHabitat: AppIcon = 'mdi:pine-tree';
export const iconInfo: AppIcon = 'mdi:information-outline'; export const iconInfo: AppIcon = 'mdi:information-outline';
export const iconItem: AppIcon = 'mdi:bag-personal-outline'; export const iconItem: AppIcon = 'mdi:bag-personal-outline';
export const iconLife: AppIcon = 'mdi:post-outline';
export const iconClothes: AppIcon = 'mdi:tshirt-crew-outline';
export const iconLogin: AppIcon = 'mdi:login'; export const iconLogin: AppIcon = 'mdi:login';
export const iconLogout: AppIcon = 'mdi:logout'; export const iconLogout: AppIcon = 'mdi:logout';
export const iconMail: AppIcon = 'mdi:email-fast-outline'; export const iconMail: AppIcon = 'mdi:email-fast-outline';
export const iconMenu: AppIcon = 'mdi:menu';
export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline'; export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
export const iconPokemon: AppIcon = 'mdi:pokeball'; export const iconPokemon: AppIcon = 'mdi:pokeball';
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline'; export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
export const iconRegister: AppIcon = 'mdi:account-plus-outline'; export const iconRegister: AppIcon = 'mdi:account-plus-outline';
export const iconReply: AppIcon = 'mdi:reply-outline';
export const iconReactionFun: AppIcon = 'mdi:party-popper';
export const iconReactionHelpful: AppIcon = 'mdi:lightbulb-on-outline';
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 iconSave: AppIcon = 'mdi:content-save-outline';
export const iconSearch: AppIcon = 'mdi:magnify';
export const iconSuccess: AppIcon = 'mdi:check-circle-outline'; export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
export const iconTranslate: AppIcon = 'mdi:translate'; export const iconTranslate: AppIcon = 'mdi:translate';
export const iconWarning: AppIcon = 'mdi:alert-outline'; export const iconWarning: AppIcon = 'mdi:alert-outline';

View File

@@ -8,6 +8,8 @@ import ItemDetail from '../views/ItemDetail.vue';
import RecipeList from '../views/RecipeList.vue'; import RecipeList from '../views/RecipeList.vue';
import RecipeDetail from '../views/RecipeDetail.vue'; import RecipeDetail from '../views/RecipeDetail.vue';
import DailyChecklistView from '../views/DailyChecklistView.vue'; import DailyChecklistView from '../views/DailyChecklistView.vue';
import LifeView from '../views/LifeView.vue';
import ComingSoonView from '../views/ComingSoonView.vue';
import AdminView from '../views/AdminView.vue'; import AdminView from '../views/AdminView.vue';
import LoginView from '../views/LoginView.vue'; import LoginView from '../views/LoginView.vue';
import RegisterView from '../views/RegisterView.vue'; import RegisterView from '../views/RegisterView.vue';
@@ -34,7 +36,13 @@ export const router = createRouter({
{ path: '/recipes/new', name: 'recipe-new', component: RecipeList, meta: { requiresVerified: true, editorModal: true } }, { 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/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiresVerified: true, editorModal: true } },
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail }, { 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: '/checklist', component: DailyChecklistView },
{ path: '/life', component: LifeView },
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } }, { path: '/admin', component: AdminView, meta: { requiresVerified: true } },
{ path: '/login', component: LoginView }, { path: '/login', component: LoginView },
{ path: '/register', component: RegisterView }, { path: '/register', component: RegisterView },

View File

@@ -4,7 +4,7 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
const authTokenKey = 'pokopia_auth_token'; const authTokenKey = 'pokopia_auth_token';
const authChangeEvent = 'pokopia-auth-change'; const authChangeEvent = 'pokopia-auth-change';
export type TranslationField = 'name' | 'title'; export type TranslationField = 'name' | 'title' | 'details' | 'genus';
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>; export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
export interface Language { export interface Language {
@@ -26,6 +26,15 @@ export interface Skill extends NamedEntity {
hasItemDrop: boolean; hasItemDrop: boolean;
} }
export interface PokemonStats {
hp: number;
attack: number;
defense: number;
specialAttack: number;
specialDefense: number;
speed: number;
}
export interface UserSummary { export interface UserSummary {
id: number; id: number;
displayName: string; displayName: string;
@@ -57,15 +66,34 @@ export interface Pokemon extends EditInfo {
id: number; id: number;
name: string; name: string;
baseName?: string; baseName?: string;
genus: string;
baseGenus?: string;
details: string;
baseDetails?: string;
heightInches: number;
heightMeters: number;
weightPounds: number;
weightKg: number;
translations?: TranslationMap; translations?: TranslationMap;
types: NamedEntity[];
stats: PokemonStats;
environment: NamedEntity; environment: NamedEntity;
skills: Skill[]; skills: Skill[];
favorite_things: NamedEntity[]; favorite_things: NamedEntity[];
} }
export interface RelatedPokemon {
id: number;
name: string;
environment: NamedEntity;
skills: Skill[];
favorite_things: Array<NamedEntity & { matches: boolean }>;
}
export interface PokemonDetail extends Pokemon { export interface PokemonDetail extends Pokemon {
skills: Array<Skill & { itemDrop: NamedEntity | null }>; skills: Array<Skill & { itemDrop: NamedEntity | null }>;
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>; favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
relatedPokemon: RelatedPokemon[];
editHistory: EditHistoryEntry[]; editHistory: EditHistoryEntry[];
habitats: Array<{ habitats: Array<{
id: number; id: number;
@@ -154,6 +182,47 @@ export interface DailyChecklistItem {
translations?: TranslationMap; translations?: TranslationMap;
} }
export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
export type LifeReactionCounts = Record<LifeReactionType, number>;
export interface LifePost {
id: number;
body: string;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
updatedBy: UserSummary | null;
tags: NamedEntity[];
comments: LifeComment[];
reactionCounts: LifeReactionCounts;
myReaction: LifeReactionType | null;
}
export interface LifePostsPage {
items: LifePost[];
nextCursor: string | null;
hasMore: boolean;
}
export interface LifePostsParams {
cursor?: string | null;
limit?: number;
search?: string;
tagId?: string | number;
}
export interface LifeComment {
id: number;
postId: number;
parentCommentId: number | null;
body: string;
deleted: boolean;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
replies: LifeComment[];
}
export interface RecipeDetail extends Recipe { export interface RecipeDetail extends Recipe {
acquisition_methods: NamedEntity[]; acquisition_methods: NamedEntity[];
editHistory: EditHistoryEntry[]; editHistory: EditHistoryEntry[];
@@ -161,6 +230,7 @@ export interface RecipeDetail extends Recipe {
} }
export interface Options { export interface Options {
pokemonTypes: NamedEntity[];
skills: Skill[]; skills: Skill[];
environments: NamedEntity[]; environments: NamedEntity[];
favoriteThings: NamedEntity[]; favoriteThings: NamedEntity[];
@@ -169,6 +239,7 @@ export interface Options {
acquisitionMethods: NamedEntity[]; acquisitionMethods: NamedEntity[];
itemTags: NamedEntity[]; itemTags: NamedEntity[];
maps: NamedEntity[]; maps: NamedEntity[];
lifeTags: NamedEntity[];
} }
export interface AuthUser { export interface AuthUser {
@@ -193,18 +264,26 @@ export interface AuthResponse {
} }
export type ConfigType = export type ConfigType =
| 'pokemon-types'
| 'skills' | 'skills'
| 'environments' | 'environments'
| 'favorite-things' | 'favorite-things'
| 'item-categories' | 'item-categories'
| 'item-usages' | 'item-usages'
| 'acquisition-methods' | 'acquisition-methods'
| 'maps'; | 'maps'
| 'life-tags';
export interface PokemonPayload { export interface PokemonPayload {
id: number; id: number;
name: string; name: string;
genus: string;
details: string;
heightInches: number;
weightPounds: number;
translations?: TranslationMap; translations?: TranslationMap;
typeIds: number[];
stats: PokemonStats;
environmentId: number; environmentId: number;
skillIds: number[]; skillIds: number[];
favoriteThingIds: number[]; favoriteThingIds: number[];
@@ -248,6 +327,34 @@ export interface DailyChecklistPayload {
translations?: TranslationMap; translations?: TranslationMap;
} }
export interface LifePostPayload {
body: string;
tagIds?: number[];
}
export interface LifeCommentPayload {
body: string;
}
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
export interface EntityDiscussionComment {
id: number;
entityType: DiscussionEntityType;
entityId: number;
parentCommentId: number | null;
body: string;
deleted: boolean;
createdAt: string;
updatedAt: string;
author: UserSummary | null;
replies: EntityDiscussionComment[];
}
export interface EntityDiscussionCommentPayload {
body: string;
}
export function buildQuery(params: Record<string, string | number | undefined>): string { export function buildQuery(params: Record<string, string | number | undefined>): string {
const search = new URLSearchParams(); const search = new URLSearchParams();
@@ -360,6 +467,19 @@ async function deleteJson(path: string): Promise<void> {
} }
} }
async function deleteAndGetJson<T>(path: string): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'DELETE',
headers: requestHeaders()
});
if (!response.ok) {
throw new Error(await getErrorMessage(response));
}
return response.json() as Promise<T>;
}
export const api = { export const api = {
languages: () => getJson<Language[]>('/api/languages'), languages: () => getJson<Language[]>('/api/languages'),
adminLanguages: () => getJson<Language[]>('/api/admin/languages'), adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
@@ -377,6 +497,41 @@ export const api = {
logout: () => postEmpty('/api/auth/logout'), logout: () => postEmpty('/api/auth/logout'),
options: () => getJson<Options>('/api/options'), options: () => getJson<Options>('/api/options'),
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'), dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
lifePosts: (params: LifePostsParams = {}) =>
getJson<LifePostsPage>(
`/api/life-posts${buildQuery({
cursor: params.cursor ?? undefined,
limit: params.limit,
search: params.search?.trim(),
tagId: params.tagId
})}`
),
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
updateLifePost: (id: string | number, payload: LifePostPayload) =>
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
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`),
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number) =>
getJson<EntityDiscussionComment[]>(`/api/discussions/${entityType}/${entityId}/comments`),
createEntityDiscussionComment: (
entityType: DiscussionEntityType,
entityId: string | number,
payload: EntityDiscussionCommentPayload
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments`, 'POST', payload),
createEntityDiscussionReply: (
entityType: DiscussionEntityType,
entityId: string | number,
commentId: string | number,
payload: EntityDiscussionCommentPayload
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`),
createDailyChecklistItem: (payload: DailyChecklistPayload) => createDailyChecklistItem: (payload: DailyChecklistPayload) =>
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload), sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) => updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>

File diff suppressed because it is too large Load Diff

View File

@@ -66,13 +66,15 @@ const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
]); ]);
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }>>(() => [ const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }>>(() => [
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true }, { key: 'skills', label: t('config.skills'), supportsItemDrop: true },
{ key: 'environments', label: t('config.environments') }, { key: 'environments', label: t('config.environments') },
{ key: 'favorite-things', label: t('config.favoriteThings') }, { key: 'favorite-things', label: t('config.favoriteThings') },
{ key: 'item-categories', label: t('config.itemCategories') }, { key: 'item-categories', label: t('config.itemCategories') },
{ key: 'item-usages', label: t('config.itemUsages') }, { key: 'item-usages', label: t('config.itemUsages') },
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') }, { key: 'acquisition-methods', label: t('config.acquisitionMethods') },
{ key: 'maps', label: t('config.maps') } { key: 'maps', label: t('config.maps') },
{ key: 'life-tags', label: t('config.lifeTags') }
]); ]);
const activeTab = ref<AdminTab>('config'); const activeTab = ref<AdminTab>('config');

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import PageHeader from '../components/PageHeader.vue';
import StatusBadge from '../components/StatusBadge.vue';
import {
iconAction,
iconClothes,
iconDish,
iconDreamIsland,
iconEvent,
type AppIcon
} from '../icons';
type ComingSoonPage = 'dish' | 'events' | 'actions' | 'dreamIsland' | 'clothes';
type ComingSoonConfig = {
icon: AppIcon;
accent: 'dish' | 'events' | 'actions' | 'dream' | 'clothes';
previewKeys: Array<'one' | 'two' | 'three'>;
};
const props = defineProps<{
page: ComingSoonPage;
}>();
const { t } = useI18n();
const pageConfigByPage: Record<ComingSoonPage, ComingSoonConfig> = {
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'] },
dreamIsland: { icon: iconDreamIsland, accent: 'dream', previewKeys: ['one', 'two', 'three'] },
clothes: { icon: iconClothes, accent: 'clothes', previewKeys: ['one', 'two', 'three'] }
};
const pageConfig = computed(() => pageConfigByPage[props.page]);
const previewItems = computed(() =>
pageConfig.value.previewKeys.map((previewKey, index) => ({
code: String(index + 1).padStart(2, '0'),
text: t(pageMessageKey(`preview.${previewKey}`))
}))
);
function pageMessageKey(suffix: string) {
return `pages.comingSoon.sections.${props.page}.${suffix}`;
}
</script>
<template>
<section class="page-stack coming-soon-page">
<PageHeader :title="t(pageMessageKey('title'))" :subtitle="t(pageMessageKey('subtitle'))">
<template #kicker>{{ t(pageMessageKey('kicker')) }}</template>
</PageHeader>
<section class="coming-soon-panel" :class="`coming-soon-panel--${pageConfig.accent}`">
<div class="coming-soon-panel__icon" aria-hidden="true">
<Icon :icon="pageConfig.icon" class="ui-icon" />
</div>
<div class="coming-soon-panel__copy">
<StatusBadge :label="t('pages.comingSoon.status')" tone="info" />
<h2>{{ t('pages.comingSoon.heading') }}</h2>
<p>{{ t(pageMessageKey('body')) }}</p>
</div>
<div class="coming-soon-panel__signal" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</section>
<section class="coming-soon-preview" :aria-label="t('pages.comingSoon.previewLabel')">
<article v-for="item in previewItems" :key="item.code" class="coming-soon-preview__item">
<span class="coming-soon-preview__index">{{ item.code }}</span>
<p>{{ item.text }}</p>
</article>
</section>
</section>
</template>

View File

@@ -5,9 +5,11 @@ import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit } from '../icons'; import { iconBack, iconEdit } from '../icons';
import { api, type HabitatDetail } from '../services/api'; import { api, type HabitatDetail } from '../services/api';
import HabitatEdit from './HabitatEdit.vue'; import HabitatEdit from './HabitatEdit.vue';
@@ -15,9 +17,15 @@ import HabitatEdit from './HabitatEdit.vue';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const habitat = ref<HabitatDetail | null>(null); const habitat = ref<HabitatDetail | null>(null);
const detailTab = ref('details');
const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天']; const weathers = ['晴天', '阴天', '雨天'];
const showEditor = computed(() => route.name === 'habitat-edit'); const showEditor = computed(() => route.name === 'habitat-edit');
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') }
]);
type PokemonRow = { type PokemonRow = {
id: number; id: number;
@@ -121,6 +129,7 @@ watch(
() => route.params.id, () => route.params.id,
() => { () => {
habitat.value = null; habitat.value = null;
detailTab.value = 'details';
void loadHabitatDetail(); void loadHabitatDetail();
} }
); );
@@ -187,8 +196,10 @@ watch(
</template> </template>
</PageHeader> </PageHeader>
<div class="detail-with-sidebar"> <div class="detail-tabs">
<div class="habitat-detail-stack"> <Tabs id="habitat-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
<div v-if="detailTab === 'details'" class="habitat-detail-stack">
<DetailSection :title="t('pages.habitats.recipeList')"> <DetailSection :title="t('pages.habitats.recipeList')">
<EntityChips :items="habitat.recipe" /> <EntityChips :items="habitat.recipe" />
</DetailSection> </DetailSection>
@@ -220,8 +231,14 @@ watch(
</DetailSection> </DetailSection>
</div> </div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="habitats" :entity-id="habitat.id" />
</div>
<div v-else class="detail-tab-panel">
<EditHistoryPanel :entity="habitat" :history="habitat.editHistory" /> <EditHistoryPanel :entity="habitat" :history="habitat.editHistory" />
</div> </div>
</div>
</section> </section>
<HabitatEdit v-if="showEditor" /> <HabitatEdit v-if="showEditor" />

View File

@@ -5,9 +5,11 @@ import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconAdd, iconBack, iconEdit } from '../icons'; import { iconAdd, iconBack, iconEdit } from '../icons';
import { api, type ItemDetail } from '../services/api'; import { api, type ItemDetail } from '../services/api';
import ItemEdit from './ItemEdit.vue'; import ItemEdit from './ItemEdit.vue';
@@ -15,7 +17,13 @@ import ItemEdit from './ItemEdit.vue';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const item = ref<ItemDetail | null>(null); const item = ref<ItemDetail | null>(null);
const detailTab = ref('details');
const showEditor = computed(() => route.name === 'item-edit'); const showEditor = computed(() => route.name === 'item-edit');
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') }
]);
const customization = computed(() => { const customization = computed(() => {
if (!item.value) { if (!item.value) {
@@ -50,6 +58,7 @@ watch(
() => route.params.id, () => route.params.id,
() => { () => {
item.value = null; item.value = null;
detailTab.value = 'details';
void loadItemDetail(); void loadItemDetail();
} }
); );
@@ -123,8 +132,10 @@ watch(
</template> </template>
</PageHeader> </PageHeader>
<div class="detail-with-sidebar"> <div class="detail-tabs">
<div class="detail-grid"> <Tabs id="item-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')"> <DetailSection :title="t('pages.items.acquisitionMethods')">
<EntityChips :items="item.acquisitionMethods" /> <EntityChips :items="item.acquisitionMethods" />
</DetailSection> </DetailSection>
@@ -186,8 +197,14 @@ watch(
</DetailSection> </DetailSection>
</div> </div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="items" :entity-id="item.id" />
</div>
<div v-else class="detail-tab-panel">
<EditHistoryPanel :entity="item" :history="item.editHistory" /> <EditHistoryPanel :entity="item" :history="item.editHistory" />
</div> </div>
</div>
</section> </section>
<ItemEdit v-if="showEditor" /> <ItemEdit v-if="showEditor" />

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,10 @@ import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit } from '../icons'; import { iconBack, iconEdit } from '../icons';
@@ -17,8 +19,11 @@ const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const pokemon = ref<PokemonDetail | null>(null); const pokemon = ref<PokemonDetail | null>(null);
const itemCategoryTab = ref(''); const itemCategoryTab = ref('');
const relatedHabitatTab = ref('');
const detailTab = ref('details');
const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天']; const weathers = ['晴天', '阴天', '雨天'];
const relatedPokemonLimit = 6;
type HabitatRow = { type HabitatRow = {
id: number; id: number;
@@ -40,6 +45,10 @@ function sortByOrder(values: Set<string>, order: string[]) {
}); });
} }
function habitatTabValue(id: number): string {
return `habitat-${id}`;
}
function timeLabel(value: string): string { function timeLabel(value: string): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
早晨: t('appearance.morning'), 早晨: t('appearance.morning'),
@@ -102,6 +111,11 @@ const habitatRows = computed<HabitatRow[]>(() => {
}); });
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []); const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
const showEditor = computed(() => route.name === 'pokemon-edit'); const showEditor = computed(() => route.name === 'pokemon-edit');
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') }
]);
const itemCategoryTabs = computed<TabOption[]>(() => { const itemCategoryTabs = computed<TabOption[]>(() => {
const categories = new Map<string, string>(); const categories = new Map<string, string>();
@@ -122,9 +136,54 @@ const favoriteThingItems = computed(() => {
return items.filter((item) => String(item.category.id) === itemCategoryTab.value); return items.filter((item) => String(item.category.id) === itemCategoryTab.value);
}); });
const relatedHabitatTabs = computed<TabOption[]>(() => {
if (!pokemon.value?.relatedPokemon.length) {
return [];
}
const habitats = new Map<string, string>();
habitats.set(habitatTabValue(pokemon.value.environment.id), pokemon.value.environment.name);
pokemon.value.relatedPokemon.forEach((item) => {
habitats.set(habitatTabValue(item.environment.id), item.environment.name);
});
const tabs = [...habitats.entries()].map(([value, label]) => ({ value, label }));
return [...tabs, { value: 'all', label: t('common.all') }];
});
const relatedPokemonRows = computed(() => {
const rows = pokemon.value?.relatedPokemon ?? [];
const selectedTab = relatedHabitatTab.value || (pokemon.value ? habitatTabValue(pokemon.value.environment.id) : '');
if (selectedTab === 'all') {
return rows.slice(0, relatedPokemonLimit);
}
return rows.filter((item) => habitatTabValue(item.environment.id) === selectedTab).slice(0, relatedPokemonLimit);
});
const typeSlotClass = computed(() => ({
'pokemon-type-slots--single': (pokemon.value?.types.length ?? 0) === 1
}));
function formatMetricMeasure(value: number): string {
return value.toFixed(2);
}
function formatPoundsMeasure(value: number): string {
return (Math.round(value * 10) / 10).toFixed(1);
}
function formatImperialHeight(inches: number): string {
const totalInches = Math.round(inches);
const feet = Math.floor(totalInches / 12);
const remainingInches = totalInches - feet * 12;
return `${feet}'${remainingInches}"`;
}
async function loadPokemonDetail() { async function loadPokemonDetail() {
pokemon.value = await api.pokemonDetail(String(route.params.id)); const nextPokemon = await api.pokemonDetail(String(route.params.id));
pokemon.value = nextPokemon;
relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id);
} }
onMounted(async () => { onMounted(async () => {
@@ -144,6 +203,8 @@ watch(
() => route.params.id, () => route.params.id,
() => { () => {
pokemon.value = null; pokemon.value = null;
relatedHabitatTab.value = '';
detailTab.value = 'details';
void loadPokemonDetail(); void loadPokemonDetail();
} }
); );
@@ -221,8 +282,55 @@ watch(
</template> </template>
</PageHeader> </PageHeader>
<div class="detail-with-sidebar"> <div class="detail-tabs">
<div class="detail-grid detail-grid--stack"> <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">
<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>
<div v-if="pokemon.genus && pokemon.details.trim()" class="pokemon-profile-divider"></div>
<p v-if="pokemon.details.trim()" class="detail-text">{{ pokemon.details }}</p>
<p v-if="!pokemon.genus && !pokemon.details.trim()" class="meta-line">{{ t('common.none') }}</p>
</section>
<div class="pokemon-profile-row">
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.measurements')">
<div class="pokemon-measurement-display">
<div class="pokemon-measurement-item" :title="`${formatImperialHeight(pokemon.heightInches)} / ${formatMetricMeasure(pokemon.heightMeters)} m`">
<div class="pokemon-measurement-stack">
<strong class="pokemon-measurement-value">{{ formatImperialHeight(pokemon.heightInches) }}</strong>
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.heightMeters) }} m</strong>
<span class="pokemon-measurement-label">{{ t('pages.pokemon.height') }}</span>
</div>
</div>
<div class="pokemon-measurement-item" :title="`${formatPoundsMeasure(pokemon.weightPounds)} lbs / ${formatMetricMeasure(pokemon.weightKg)} kg`">
<div class="pokemon-measurement-stack">
<strong class="pokemon-measurement-value">{{ formatPoundsMeasure(pokemon.weightPounds) }} lbs</strong>
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.weightKg) }} kg</strong>
<span class="pokemon-measurement-label">{{ t('pages.pokemon.weight') }}</span>
</div>
</div>
</div>
</section>
<section class="detail-section pokemon-profile-card pokemon-types-card" :aria-label="t('pages.pokemon.types')">
<div v-if="pokemon.types.length" class="pokemon-type-slots" :class="typeSlotClass">
<span v-for="type in pokemon.types.slice(0, 2)" :key="type.id" class="chip">{{ type.name }}</span>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</section>
</div>
</div>
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')">
<PokemonStatsPanel :stats="pokemon.stats" />
</DetailSection>
</div>
<DetailSection :title="t('pages.pokemon.skills')"> <DetailSection :title="t('pages.pokemon.skills')">
<EntityChips :items="pokemon.skills" /> <EntityChips :items="pokemon.skills" />
</DetailSection> </DetailSection>
@@ -240,6 +348,56 @@ watch(
<EntityChips :items="pokemon.favorite_things" /> <EntityChips :items="pokemon.favorite_things" />
</DetailSection> </DetailSection>
<div class="pokemon-related-grid">
<DetailSection :title="t('pages.pokemon.relatedPokemon')">
<template v-if="pokemon.relatedPokemon.length">
<Tabs
v-if="relatedHabitatTabs.length"
id="pokemon-related-habitats"
v-model="relatedHabitatTab"
:tabs="relatedHabitatTabs"
:label="t('pages.pokemon.relatedHabitat')"
/>
<ul v-if="relatedPokemonRows.length" class="row-list related-pokemon-list">
<li v-for="related in relatedPokemonRows" :key="related.id">
<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>
<div class="related-pokemon-row__traits">
<EntityChips
v-if="related.skills.length"
class="related-pokemon-row__skills"
:items="related.skills"
/>
<span
class="chip related-pokemon-row__environment"
:class="{ 'related-pokemon-row__environment--match': related.environment.id === pokemon.environment.id }"
>
{{ related.environment.name }}
</span>
</div>
</div>
<div
v-if="related.favorite_things.length"
class="chips related-pokemon-row__favourites"
>
<span
v-for="thing in related.favorite_things"
:key="thing.id"
class="chip related-favourite-chip"
:class="{ 'related-favourite-chip--match': thing.matches }"
>
{{ thing.name }}
</span>
</div>
</div>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</template>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
<DetailSection :title="t('pages.pokemon.relatedItems')"> <DetailSection :title="t('pages.pokemon.relatedItems')">
<template v-if="pokemon.favoriteThingItems.length"> <template v-if="pokemon.favoriteThingItems.length">
<Tabs <Tabs
@@ -259,6 +417,7 @@ watch(
</template> </template>
<p v-else class="meta-line">{{ t('common.none') }}</p> <p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection> </DetailSection>
</div>
<DetailSection :title="t('pages.pokemon.habitats')"> <DetailSection :title="t('pages.pokemon.habitats')">
<ul class="row-list appearance-list"> <ul class="row-list appearance-list">
@@ -287,8 +446,14 @@ watch(
</DetailSection> </DetailSection>
</div> </div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="pokemon" :entity-id="pokemon.id" />
</div>
<div v-else class="detail-tab-panel">
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" /> <EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
</div> </div>
</div>
</section> </section>
<PokemonEdit v-if="showEditor" /> <PokemonEdit v-if="showEditor" />

View File

@@ -1,15 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Modal from '../components/Modal.vue'; import Modal from '../components/Modal.vue';
import PokemonStatsFields from '../components/PokemonStatsFields.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import Tabs from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import TranslationFields from '../components/TranslationFields.vue'; import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave } from '../icons'; import { iconCancel, iconSave } from '../icons';
import { api, type ConfigType, type Language, type NamedEntity, type Options, type PokemonPayload, type TranslationMap } from '../services/api'; import {
api,
type ConfigType,
type Language,
type NamedEntity,
type Options,
type PokemonPayload,
type PokemonStats,
type TranslationMap
} from '../services/api';
type SkillItemDropForm = { type SkillItemDropForm = {
skillId: string; skillId: string;
@@ -26,10 +37,31 @@ const loading = ref(true);
const busy = ref(false); const busy = ref(false);
const message = ref(''); const message = ref('');
const creatingSelect = ref(''); const creatingSelect = ref('');
const activeEditTab = ref('basic');
const heightUnit = ref<'imperial' | 'metric'>('imperial');
const weightUnit = ref<'imperial' | 'metric'>('imperial');
function defaultPokemonStats(): PokemonStats {
return {
hp: 0,
attack: 0,
defense: 0,
specialAttack: 0,
specialDefense: 0,
speed: 0
};
}
const pokemonForm = ref({ const pokemonForm = ref({
id: '', id: '',
name: '', name: '',
genus: '',
details: '',
heightInches: 0,
weightPounds: 0,
translations: {} as TranslationMap, translations: {} as TranslationMap,
typeIds: [] as string[],
stats: defaultPokemonStats(),
environmentId: '', environmentId: '',
skillIds: [] as string[], skillIds: [] as string[],
favoriteThingIds: [] as string[], favoriteThingIds: [] as string[],
@@ -47,11 +79,51 @@ const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` :
const selectedSkillDropRows = computed(() => const selectedSkillDropRows = computed(() =>
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId)) pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId))
); );
const editTabs = computed(() => [
{ value: 'basic', label: t('pages.pokemon.editTabBasic') },
{ value: 'advance', label: t('pages.pokemon.editTabAdvance') }
]);
const totalHeightInchesValue = computed(() => Math.round(pokemonForm.value.heightInches));
const heightFeetValue = computed(() => Math.floor(totalHeightInchesValue.value / 12));
const heightInchesValue = computed(() => totalHeightInchesValue.value - heightFeetValue.value * 12);
const heightMetersValue = computed(() => roundMeasurement(pokemonForm.value.heightInches * 0.0254, 2));
const weightPoundsValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds, 1));
const weightKgValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds * 0.45359237, 2));
function toIds(values: string[]): number[] { function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0); return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
} }
function numericInputValue(event: Event): number {
const value = event.target instanceof HTMLInputElement ? Number(event.target.value) : 0;
return Number.isFinite(value) && value > 0 ? value : 0;
}
function roundMeasurement(value: number, precision: number): number {
const scale = 10 ** precision;
return Math.round(value * scale) / scale;
}
function updateHeightFeet(event: Event) {
pokemonForm.value.heightInches = Math.round(numericInputValue(event) * 12 + heightInchesValue.value);
}
function updateHeightInches(event: Event) {
pokemonForm.value.heightInches = Math.round(heightFeetValue.value * 12 + numericInputValue(event));
}
function updateHeightMeters(event: Event) {
pokemonForm.value.heightInches = roundMeasurement(numericInputValue(event) / 0.0254, 2);
}
function updateWeightPounds(event: Event) {
pokemonForm.value.weightPounds = roundMeasurement(numericInputValue(event), 1);
}
function updateWeightKg(event: Event) {
pokemonForm.value.weightPounds = roundMeasurement(numericInputValue(event) / 0.45359237, 1);
}
function errorText(error: unknown, fallback: string) { function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback; return error instanceof Error && error.message ? error.message : fallback;
} }
@@ -98,6 +170,21 @@ function pokemonNameForSave() {
return pokemonForm.value.translations[String(locale.value || '')]?.name ?? ''; return pokemonForm.value.translations[String(locale.value || '')]?.name ?? '';
} }
function pokemonIdForSave() {
return Number(isEditing.value ? routeId.value : pokemonForm.value.id);
}
function hasRequiredBasicFields() {
const id = pokemonIdForSave();
return Number.isInteger(id) && id > 0 && pokemonNameForSave().trim() !== '';
}
async function showBasicFieldValidation() {
activeEditTab.value = 'basic';
await nextTick();
document.querySelector<HTMLFormElement>('#pokemon-edit-form')?.reportValidity();
}
function closeEditor() { function closeEditor() {
void router.push(cancelTo.value); void router.push(cancelTo.value);
} }
@@ -113,7 +200,13 @@ async function loadEditor() {
pokemonForm.value = { pokemonForm.value = {
id: String(pokemon.id), id: String(pokemon.id),
name: pokemon.baseName ?? pokemon.name, name: pokemon.baseName ?? pokemon.name,
genus: pokemon.baseGenus ?? pokemon.genus,
details: pokemon.baseDetails ?? pokemon.details,
heightInches: pokemon.heightInches,
weightPounds: pokemon.weightPounds,
translations: pokemon.translations ?? {}, translations: pokemon.translations ?? {},
typeIds: pokemon.types.map((type) => String(type.id)),
stats: pokemon.stats,
environmentId: String(pokemon.environment.id), environmentId: String(pokemon.environment.id),
skillIds: pokemon.skills.map((skill) => String(skill.id)), skillIds: pokemon.skills.map((skill) => String(skill.id)),
favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id)), favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id)),
@@ -169,14 +262,25 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
} }
async function savePokemon() { async function savePokemon() {
if (!hasRequiredBasicFields()) {
await showBasicFieldValidation();
return;
}
busy.value = true; busy.value = true;
message.value = ''; message.value = '';
try { try {
const payload: PokemonPayload = { const payload: PokemonPayload = {
id: Number(isEditing.value ? routeId.value : pokemonForm.value.id), id: pokemonIdForSave(),
name: pokemonNameForSave(), name: pokemonNameForSave(),
genus: pokemonForm.value.genus,
details: pokemonForm.value.details,
heightInches: pokemonForm.value.heightInches,
weightPounds: pokemonForm.value.weightPounds,
translations: pokemonForm.value.translations, translations: pokemonForm.value.translations,
typeIds: toIds(pokemonForm.value.typeIds.slice(0, 2)),
stats: pokemonForm.value.stats,
environmentId: Number(pokemonForm.value.environmentId), environmentId: Number(pokemonForm.value.environmentId),
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)), skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)), favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
@@ -204,7 +308,11 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
<Modal :title="pageTitle" :subtitle="t('pages.pokemon.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor"> <Modal :title="pageTitle" :subtitle="t('pages.pokemon.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage> <StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form" @submit.prevent="savePokemon"> <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')" />
<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"> <div class="field">
<label for="pokemon-id">ID</label> <label for="pokemon-id">ID</label>
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" /> <input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
@@ -219,7 +327,9 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
:languages="languages" :languages="languages"
required required
/> />
</div>
<div class="pokemon-edit-grid">
<div class="field"> <div class="field">
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label> <label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
<TagsSelect <TagsSelect
@@ -248,6 +358,7 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)" @create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
/> />
</div> </div>
</div>
<div class="field"> <div class="field">
<label for="pokemon-things">{{ t('pages.pokemon.favoriteThings') }}</label> <label for="pokemon-things">{{ t('pages.pokemon.favoriteThings') }}</label>
@@ -279,6 +390,104 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
</div> </div>
</div> </div>
</div> </div>
</section>
<section v-else class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabAdvance')">
<TranslationFields
id-prefix="pokemon-genus"
v-model:base-value="pokemonForm.genus"
v-model:translations="pokemonForm.translations"
field="genus"
:label="t('pages.pokemon.genus')"
:languages="languages"
/>
<TranslationFields
id-prefix="pokemon-details"
v-model:base-value="pokemonForm.details"
v-model:translations="pokemonForm.translations"
field="details"
:label="t('pages.pokemon.details')"
:languages="languages"
multiline
:rows="5"
/>
<div class="field">
<span id="pokemon-measurements-label" class="field-label">{{ t('pages.pokemon.measurements') }}</span>
<div class="pokemon-measurement-row" aria-labelledby="pokemon-measurements-label">
<div class="pokemon-measurement-control">
<span id="pokemon-height-label" class="field-label">{{ t('pages.pokemon.height') }}</span>
<div class="segmented" aria-labelledby="pokemon-height-label">
<button :class="{ active: heightUnit === 'imperial' }" type="button" @click="heightUnit = 'imperial'">
{{ t('pages.pokemon.heightImperial') }}
</button>
<button :class="{ active: heightUnit === 'metric' }" type="button" @click="heightUnit = 'metric'">
{{ t('pages.pokemon.heightMetric') }}
</button>
</div>
<div v-if="heightUnit === 'imperial'" class="pokemon-measurement-fields">
<div class="field">
<label for="pokemon-height-feet">{{ t('pages.pokemon.feet') }}</label>
<input id="pokemon-height-feet" :value="heightFeetValue" min="0" step="1" type="number" inputmode="numeric" @input="updateHeightFeet" />
</div>
<div class="field">
<label for="pokemon-height-inches">{{ t('pages.pokemon.inches') }}</label>
<input id="pokemon-height-inches" :value="heightInchesValue" min="0" step="1" type="number" inputmode="numeric" @input="updateHeightInches" />
</div>
</div>
<div v-else class="field">
<label for="pokemon-height-meters">{{ t('pages.pokemon.meters') }}</label>
<input id="pokemon-height-meters" :value="heightMetersValue" min="0" step="0.01" type="number" inputmode="decimal" @input="updateHeightMeters" />
</div>
</div>
<div class="pokemon-measurement-control">
<span id="pokemon-weight-label" class="field-label">{{ t('pages.pokemon.weight') }}</span>
<div class="segmented" aria-labelledby="pokemon-weight-label">
<button :class="{ active: weightUnit === 'imperial' }" type="button" @click="weightUnit = 'imperial'">
{{ t('pages.pokemon.pounds') }}
</button>
<button :class="{ active: weightUnit === 'metric' }" type="button" @click="weightUnit = 'metric'">
{{ t('pages.pokemon.kilograms') }}
</button>
</div>
<div v-if="weightUnit === 'imperial'" class="field">
<label for="pokemon-weight-pounds">{{ t('pages.pokemon.pounds') }}</label>
<input id="pokemon-weight-pounds" :value="weightPoundsValue" min="0" step="0.1" type="number" inputmode="decimal" @input="updateWeightPounds" />
</div>
<div v-else class="field">
<label for="pokemon-weight-kg">{{ t('pages.pokemon.kilograms') }}</label>
<input id="pokemon-weight-kg" :value="weightKgValue" min="0" step="0.01" type="number" inputmode="decimal" @input="updateWeightKg" />
</div>
</div>
</div>
</div>
<div class="field">
<label for="pokemon-types">{{ t('pages.pokemon.types') }}</label>
<TagsSelect
id="pokemon-types"
v-model="pokemonForm.typeIds"
:options="options.pokemonTypes"
:max="2"
allow-create
:creating="creatingSelect === 'pokemon-types'"
:placeholder="t('pages.pokemon.searchTypes')"
@create="createMultiOption('pokemon-types', 'pokemon-types', $event, pokemonForm.typeIds, 2)"
/>
</div>
<div class="field">
<span class="field-label">{{ t('pages.pokemon.statsTitle') }}</span>
<PokemonStatsFields id-prefix="pokemon-stats" v-model="pokemonForm.stats" />
</div>
</section>
</form> </form>
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.pokemon.loadingEdit')"> <section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.pokemon.loadingEdit')">

View File

@@ -145,6 +145,7 @@ watch(query, loadPokemon);
:to="`/pokemon/${item.id}`" :to="`/pokemon/${item.id}`"
> >
<EditMeta :entity="item" /> <EditMeta :entity="item" />
<EntityChips v-if="item.types.length" :items="item.types" />
<EntityChips :items="item.skills" /> <EntityChips :items="item.skills" />
<EntityChips :items="item.favorite_things" /> <EntityChips :items="item.favorite_things" />
</EntityCard> </EntityCard>

View File

@@ -5,9 +5,11 @@ import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit } from '../icons'; import { iconBack, iconEdit } from '../icons';
import { api, type RecipeDetail } from '../services/api'; import { api, type RecipeDetail } from '../services/api';
import RecipeEdit from './RecipeEdit.vue'; import RecipeEdit from './RecipeEdit.vue';
@@ -15,7 +17,13 @@ import RecipeEdit from './RecipeEdit.vue';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const recipe = ref<RecipeDetail | null>(null); const recipe = ref<RecipeDetail | null>(null);
const detailTab = ref('details');
const showEditor = computed(() => route.name === 'recipe-edit'); const showEditor = computed(() => route.name === 'recipe-edit');
const detailTabs = computed<TabOption[]>(() => [
{ value: 'details', label: t('common.details') },
{ value: 'discussion', label: t('discussion.title') },
{ value: 'history', label: t('history.editHistory') }
]);
async function loadRecipeDetail() { async function loadRecipeDetail() {
recipe.value = await api.recipeDetail(String(route.params.id)); recipe.value = await api.recipeDetail(String(route.params.id));
@@ -38,6 +46,7 @@ watch(
() => route.params.id, () => route.params.id,
() => { () => {
recipe.value = null; recipe.value = null;
detailTab.value = 'details';
void loadRecipeDetail(); void loadRecipeDetail();
} }
); );
@@ -85,8 +94,10 @@ watch(
</template> </template>
</PageHeader> </PageHeader>
<div class="detail-with-sidebar"> <div class="detail-tabs">
<div class="detail-grid"> <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')"> <DetailSection :title="t('pages.items.acquisitionMethods')">
<EntityChips :items="recipe.acquisition_methods" /> <EntityChips :items="recipe.acquisition_methods" />
</DetailSection> </DetailSection>
@@ -96,8 +107,14 @@ watch(
</DetailSection> </DetailSection>
</div> </div>
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
<EntityDiscussionPanel entity-type="recipes" :entity-id="recipe.id" />
</div>
<div v-else class="detail-tab-panel">
<EditHistoryPanel :entity="recipe" :history="recipe.editHistory" /> <EditHistoryPanel :entity="recipe" :history="recipe.editHistory" />
</div> </div>
</div>
</section> </section>
<RecipeEdit v-if="showEditor" /> <RecipeEdit v-if="showEditor" />