Compare commits
17 Commits
9fece8f54f
...
b0d18a845d
| Author | SHA1 | Date | |
|---|---|---|---|
| b0d18a845d | |||
| 7ee25e2437 | |||
| c2f58fe661 | |||
| 21bbbc7137 | |||
| f5ab96c2b1 | |||
| ec2a21bae6 | |||
| 6462ed23de | |||
| 0ca6f779ec | |||
| f1ed1e7e40 | |||
| 433b19eb67 | |||
| 866d7add16 | |||
| c03d4271e1 | |||
| 71b7e838ed | |||
| a683982b80 | |||
| cd1891cc82 | |||
| 49aae3bd7c | |||
| ec3494ea28 |
158
AGENTS.md
158
AGENTS.md
@@ -6,6 +6,7 @@
|
||||
* Follow the existing structure and conventions strictly.
|
||||
* Make **minimal, targeted changes only**. Do not refactor unrelated code.
|
||||
* 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.
|
||||
|
||||
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
|
||||
|
||||
* Use `DesignGuidelines.html` as the reference for UI design, visual style, and component behavior.
|
||||
* 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`.
|
||||
* 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.
|
||||
* 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
|
||||
* Split files unnecessarily
|
||||
* 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.
|
||||
* Keep functions and components small and readable.
|
||||
|
||||
@@ -62,31 +141,52 @@ User-facing UI must NEVER contain:
|
||||
|
||||
### Strict Rules
|
||||
|
||||
* Only render **business data** and intended UI text
|
||||
* Only render **business data** and intended UI text.
|
||||
* Never display:
|
||||
|
||||
* "Updated successfully because..."
|
||||
* "Changed X to Y"
|
||||
* "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.
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
* use `snake_case`
|
||||
* define proper primary/foreign keys
|
||||
* preserve existing audit columns on editable entities
|
||||
* preserve `sort_order` behavior for sortable lists
|
||||
* avoid premature optimization
|
||||
|
||||
* APIs:
|
||||
|
||||
* 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`
|
||||
* Composables: `useXxx`
|
||||
|
||||
* General:
|
||||
|
||||
* variables/functions: `camelCase`
|
||||
* Keep files focused and under reasonable length
|
||||
* Avoid duplication
|
||||
* TypeScript types/interfaces: match existing local style
|
||||
|
||||
* 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:
|
||||
|
||||
* Run:
|
||||
* Run when practical:
|
||||
|
||||
* lint
|
||||
* typecheck
|
||||
* `pnpm lint`
|
||||
* `pnpm typecheck`
|
||||
|
||||
* Do NOT run tests in WSL.
|
||||
* 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:
|
||||
|
||||
* Matches `DESIGN.md`
|
||||
* Minimal diff (no unrelated changes)
|
||||
* No UI leaks of internal info
|
||||
* Code is readable and concise
|
||||
* Passes lint/typecheck when practical
|
||||
* Docker runtime issues are handled from user-provided `docker compose up --build` output
|
||||
* Matches `DESIGN.md`.
|
||||
* Updates `DESIGN.md` when the implemented behavior changes product, API, schema, permission, route, or i18n expectations.
|
||||
* Minimal diff, with no unrelated changes.
|
||||
* No UI leaks of internal info.
|
||||
* Code is readable and concise.
|
||||
* 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
|
||||
* Creating unused files or abstractions
|
||||
* Mixing internal/debug data into UI
|
||||
* Exposing token/hash/internal audit data through public API responses
|
||||
* Large, unfocused commits
|
||||
* Silent behavior changes outside scope
|
||||
|
||||
@@ -150,17 +256,7 @@ A task is complete ONLY IF:
|
||||
|
||||
## When Unsure
|
||||
|
||||
* Ask for clarification
|
||||
* Do not guess requirements
|
||||
* Do not invent features not in `DESIGN.md`
|
||||
|
||||
---
|
||||
|
||||
## Project Context
|
||||
|
||||
* Goal: Pokopia Wiki
|
||||
* Stack:
|
||||
|
||||
* Frontend: Vue
|
||||
* Backend: Node + PostgreSQL
|
||||
* Infra: Docker
|
||||
* Ask for clarification.
|
||||
* Do not guess requirements.
|
||||
* Do not invent features not in `DESIGN.md`.
|
||||
* If current code and `DESIGN.md` disagree, call out the mismatch before changing behavior.
|
||||
|
||||
618
DESIGN.md
618
DESIGN.md
@@ -1,155 +1,531 @@
|
||||
# Pokopia Wiki
|
||||
|
||||
## 产品目标
|
||||
|
||||
- Pokopia Wiki 是一个面向 Pokopia 游戏资料的社区 Wiki。
|
||||
- 所有人都可以浏览 Wiki 内容。
|
||||
- 已注册并完成邮箱验证的用户可以创建、编辑、删除 Wiki 内容。
|
||||
- 前台以 Pokemon、栖息地、物品、材料单、每日 CheckList、Life、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
|
||||
- 管理入口用于维护全局配置、语言、列表排序和每日 CheckList。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 后端:Postgresql
|
||||
- 前端:Vue
|
||||
- 运维:Docker
|
||||
都要用最新的框架
|
||||
- Monorepo:pnpm workspace,Node.js >= 22,TypeScript。
|
||||
- 前端:Vue、Vite、Vue Router、Vue I18n、Iconify。
|
||||
- 后端: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 可配置:
|
||||
|
||||
- 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 列表
|
||||
- 出现时间
|
||||
- 出现天气
|
||||
- 稀有度
|
||||
- 出现的地图列表
|
||||
- 最后编辑信息
|
||||
- 讨论
|
||||
- 编辑历史
|
||||
|
||||
出现契机
|
||||
- 时间:早晨 / 中午 / 傍晚 / 晚上
|
||||
- 天气:晴天 / 阴天 / 雨天
|
||||
- 稀有度:1 ~ 3 星
|
||||
- 地图关联
|
||||
## 每日 CheckList
|
||||
|
||||
每日 CheckList 可配置:
|
||||
- Task
|
||||
每日 CheckList Task 可配置:
|
||||
|
||||
- Task 标题
|
||||
- 翻译
|
||||
- Task 顺序
|
||||
|
||||
## 功能
|
||||
前台行为:
|
||||
|
||||
- Pokemon 列表
|
||||
- 搜索
|
||||
- 筛选
|
||||
- 特长(可多选,满足任意条件 / 满足全部条件)
|
||||
- 喜欢的环境
|
||||
- 喜欢的东西(可多选,满足任意条件 / 满足全部条件)
|
||||
- Pokemon 详情页
|
||||
- 特长
|
||||
- 特长掉落物品
|
||||
- 喜欢的环境
|
||||
- 喜欢的东西
|
||||
- 栖息地
|
||||
- 栖息地列表
|
||||
- 栖息地详情页
|
||||
- 配方列表
|
||||
- 可能出现的宝可梦列表
|
||||
- 出现时间
|
||||
- 出现天气
|
||||
- 稀有度
|
||||
- 出现的地图列表
|
||||
- 物品 / 材料单列表
|
||||
- 根据分类显示(标签页)
|
||||
- 筛选
|
||||
- 用途
|
||||
- 标签
|
||||
- 物品详情页
|
||||
- 基本信息
|
||||
- 用途
|
||||
- 入手方式
|
||||
- 自定义
|
||||
- 可染色
|
||||
- 可双区染色
|
||||
- 可改花纹
|
||||
- 材料单信息
|
||||
- 入手方式
|
||||
- 需要材料列表
|
||||
- 标签
|
||||
- 相关栖息地
|
||||
- 相关 Pokemon 掉落
|
||||
- 材料单详情页
|
||||
- 基本信息
|
||||
- 入手方式
|
||||
- 需要材料列表
|
||||
- 每日 CheckList
|
||||
- 展示每日做什么
|
||||
- 每个 Task 可勾选
|
||||
- 每天自动清空勾选状态,不删除 Task
|
||||
- 管理中可新增 Task 到列表
|
||||
- 管理中可通过 Handle 拖曳排序
|
||||
- 展示每日要做的 Task。
|
||||
- 每个 Task 可勾选。
|
||||
- 勾选状态保存在浏览器本地。
|
||||
- 勾选状态按本地日期自动清空,不删除 Task。
|
||||
- 已删除 Task 的本地勾选状态会自动清理。
|
||||
|
||||
## 用户系统
|
||||
管理行为:
|
||||
|
||||
- 用户可注册
|
||||
- 邮箱
|
||||
- 显示名
|
||||
- 密码
|
||||
- 用户注册后需要通过邮箱验证
|
||||
- 使用 Resend 发送验证邮件
|
||||
- 邮件内包含验证链接
|
||||
- 用户可登录
|
||||
- 仅允许已验证邮箱的用户登录
|
||||
- 登录后可获取当前用户信息
|
||||
- 用户可退出登录
|
||||
- API 只返回必要用户字段,不暴露密码、验证 token、会话 token 哈希或内部元数据
|
||||
- 已验证用户可新增、编辑、删除 Task。
|
||||
- 已验证用户可通过 Handle 拖拽排序。
|
||||
|
||||
## Community 编辑
|
||||
## Life
|
||||
|
||||
- 所有人都可浏览 Wiki 内容
|
||||
- 已注册并完成邮箱验证的用户都可编辑 Wiki 内容
|
||||
- 每次创建、修改、删除 Wiki 内容都需要记录编辑者
|
||||
- Wiki 内容展示最后编辑者和最后编辑时间
|
||||
- 编辑署名只展示必要用户信息,不暴露邮箱、token、hash 或内部元数据
|
||||
Life 是社区生活分享信息流,类似轻量社交动态。
|
||||
|
||||
Life Post 可配置:
|
||||
|
||||
- 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` 输出为准进行后续修复。
|
||||
|
||||
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS entity_translations (
|
||||
entity_type text NOT NULL CHECK (
|
||||
entity_type IN (
|
||||
'pokemon',
|
||||
'pokemon-types',
|
||||
'skills',
|
||||
'environments',
|
||||
'favorite-things',
|
||||
@@ -37,12 +38,13 @@ CREATE TABLE IF NOT EXISTS entity_translations (
|
||||
'items',
|
||||
'maps',
|
||||
'habitats',
|
||||
'daily-checklist-items'
|
||||
'daily-checklist-items',
|
||||
'life-tags'
|
||||
)
|
||||
),
|
||||
entity_id integer NOT NULL,
|
||||
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,
|
||||
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
|
||||
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 (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
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
|
||||
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 (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
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)
|
||||
);
|
||||
|
||||
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 (
|
||||
id integer PRIMARY KEY,
|
||||
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),
|
||||
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)
|
||||
);
|
||||
|
||||
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 (
|
||||
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
||||
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 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 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 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 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 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
|
||||
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 (
|
||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||
FROM pokemon
|
||||
@@ -377,6 +528,16 @@ SET sort_order = ordered.next_sort_order
|
||||
FROM ordered
|
||||
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 (
|
||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||
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 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 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 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_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);
|
||||
@@ -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
|
||||
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
@@ -7,16 +7,25 @@ import {
|
||||
cleanLocale,
|
||||
createConfig,
|
||||
createDailyChecklistItem,
|
||||
createEntityDiscussionComment,
|
||||
createEntityDiscussionReply,
|
||||
createHabitat,
|
||||
createItem,
|
||||
createLanguage,
|
||||
createLifeComment,
|
||||
createLifeCommentReply,
|
||||
createLifePost,
|
||||
createPokemon,
|
||||
createRecipe,
|
||||
deleteConfig,
|
||||
deleteDailyChecklistItem,
|
||||
deleteEntityDiscussionComment,
|
||||
deleteHabitat,
|
||||
deleteItem,
|
||||
deleteLanguage,
|
||||
deleteLifeComment,
|
||||
deleteLifePost,
|
||||
deleteLifePostReaction,
|
||||
deletePokemon,
|
||||
deleteRecipe,
|
||||
getHabitat,
|
||||
@@ -25,11 +34,13 @@ import {
|
||||
getPokemon,
|
||||
getRecipe,
|
||||
isConfigType,
|
||||
listEntityDiscussionComments,
|
||||
listConfig,
|
||||
listDailyChecklistItems,
|
||||
listHabitats,
|
||||
listItems,
|
||||
listLanguages,
|
||||
listLifePosts,
|
||||
listPokemon,
|
||||
listRecipes,
|
||||
reorderConfig,
|
||||
@@ -39,11 +50,13 @@ import {
|
||||
reorderLanguages,
|
||||
reorderPokemon,
|
||||
reorderRecipes,
|
||||
setLifePostReaction,
|
||||
updateConfig,
|
||||
updateDailyChecklistItem,
|
||||
updateHabitat,
|
||||
updateItem,
|
||||
updateLanguage,
|
||||
updateLifePost,
|
||||
updatePokemon,
|
||||
updateRecipe
|
||||
} from './queries.ts';
|
||||
@@ -137,6 +150,19 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
|
||||
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) =>
|
||||
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/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) =>
|
||||
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||
);
|
||||
|
||||
@@ -3,7 +3,20 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
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 { 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 removeLocaleListener: (() => void) | null = null;
|
||||
|
||||
function inDevBadge() {
|
||||
return { label: t('common.inDev'), tone: 'info' as const };
|
||||
}
|
||||
|
||||
const navItems = computed(() => [
|
||||
{ label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon },
|
||||
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
|
||||
{ label: t('nav.items'), to: '/items', icon: iconItem },
|
||||
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
|
||||
{ label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() },
|
||||
{ label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() },
|
||||
{ label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() },
|
||||
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
|
||||
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
|
||||
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
|
||||
{ label: t('nav.life'), to: '/life', icon: iconLife },
|
||||
{ label: t('nav.admin'), to: '/admin', icon: iconAdmin }
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
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 PokeBallMark from './PokeBallMark.vue';
|
||||
import StatusBadge from './StatusBadge.vue';
|
||||
|
||||
defineProps<{
|
||||
currentUser: AuthUser | null;
|
||||
languages: Language[];
|
||||
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<{
|
||||
@@ -19,14 +29,26 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const languageMenu = ref<HTMLElement | null>(null);
|
||||
const languageMenuButton = ref<HTMLButtonElement | null>(null);
|
||||
const languageMenuOpen = ref(false);
|
||||
const sidebarOpen = ref(false);
|
||||
|
||||
function closeLanguageMenu() {
|
||||
languageMenuOpen.value = false;
|
||||
}
|
||||
|
||||
function closeSidebar() {
|
||||
sidebarOpen.value = false;
|
||||
closeLanguageMenu();
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen.value = !sidebarOpen.value;
|
||||
closeLanguageMenu();
|
||||
}
|
||||
|
||||
function toggleLanguageMenu() {
|
||||
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(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||
document.body.classList.remove('lock-scroll');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<header class="site-header">
|
||||
<div class="container top-nav">
|
||||
<RouterLink class="brand-lockup" to="/pokemon" aria-label="Pokopia Wiki">
|
||||
<div class="app-shell" :class="{ 'app-shell--sidebar-open': sidebarOpen }">
|
||||
<header class="mobile-topbar">
|
||||
<button
|
||||
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" />
|
||||
<span>
|
||||
<span class="pokemon-word">Pokopia</span>
|
||||
@@ -72,10 +131,24 @@ onBeforeUnmount(() => {
|
||||
</span>
|
||||
</RouterLink>
|
||||
|
||||
<nav class="nav-links" :aria-label="t('nav.main')">
|
||||
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to">
|
||||
<Icon v-if="item.icon" :icon="item.icon" class="ui-icon nav-links__icon" aria-hidden="true" />
|
||||
{{ item.label }}
|
||||
<nav class="side-nav" :aria-label="t('nav.main')">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
: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>
|
||||
</nav>
|
||||
|
||||
@@ -112,24 +185,24 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<template v-if="currentUser">
|
||||
<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" />
|
||||
{{ t('nav.logout') }}
|
||||
</button>
|
||||
</template>
|
||||
<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" />
|
||||
{{ t('nav.login') }}
|
||||
</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" />
|
||||
{{ t('nav.register') }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</aside>
|
||||
|
||||
<main class="page">
|
||||
<slot></slot>
|
||||
|
||||
@@ -12,6 +12,17 @@ const changeLabelKeys: Record<string, string> = {
|
||||
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',
|
||||
'Favorite environment': 'pages.pokemon.environment',
|
||||
喜欢的环境: 'pages.pokemon.environment',
|
||||
@@ -96,7 +107,7 @@ function formatDateTime(value: string): string {
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<h2 id="edit-history-panel-title">{{ t('history.title') }}</h2>
|
||||
</div>
|
||||
@@ -163,5 +174,5 @@ function formatDateTime(value: string): string {
|
||||
</ol>
|
||||
<p v-else class="meta-line">{{ t('history.empty') }}</p>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
418
frontend/src/components/EntityDiscussionPanel.vue
Normal file
418
frontend/src/components/EntityDiscussionPanel.vue
Normal 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>
|
||||
50
frontend/src/components/PokemonStatsFields.vue
Normal file
50
frontend/src/components/PokemonStatsFields.vue
Normal 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>
|
||||
33
frontend/src/components/PokemonStatsPanel.vue
Normal file
33
frontend/src/components/PokemonStatsPanel.vue
Normal 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>
|
||||
33
frontend/src/components/ProgressBar.vue
Normal file
33
frontend/src/components/ProgressBar.vue
Normal 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>
|
||||
20
frontend/src/components/StatusBadge.vue
Normal file
20
frontend/src/components/StatusBadge.vue
Normal 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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { 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 DropdownStrategy = 'absolute' | 'fixed';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -31,12 +32,14 @@ const props = withDefaults(
|
||||
allowCreate?: boolean;
|
||||
creating?: boolean;
|
||||
createLabel?: string;
|
||||
dropdownStrategy?: DropdownStrategy;
|
||||
}>(),
|
||||
{
|
||||
multiple: true,
|
||||
max: 0,
|
||||
allowCreate: false,
|
||||
creating: false
|
||||
creating: false,
|
||||
dropdownStrategy: 'absolute'
|
||||
}
|
||||
);
|
||||
|
||||
@@ -47,10 +50,14 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const trigger = ref<HTMLButtonElement | null>(null);
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
const isOpen = ref(false);
|
||||
const search = ref('');
|
||||
const activeIndex = ref(-1);
|
||||
const dropdownStyle = ref<CSSProperties>({});
|
||||
const dropdownPlacement = ref<'top' | 'bottom'>('bottom');
|
||||
let positionFrame = 0;
|
||||
|
||||
const optionRows = computed(() =>
|
||||
props.options.map((option, index) => ({
|
||||
@@ -104,6 +111,7 @@ const candidateRows = computed<CandidateRow[]>(() => {
|
||||
});
|
||||
const activeCandidate = computed(() => candidateRows.value[activeIndex.value]);
|
||||
const activeDescendant = computed(() => activeCandidate.value?.id);
|
||||
const usesFixedDropdown = computed(() => props.dropdownStrategy === 'fixed');
|
||||
|
||||
function setDefaultActiveIndex() {
|
||||
const keyword = createName.value.toLowerCase();
|
||||
@@ -130,6 +138,8 @@ function clampActiveIndex() {
|
||||
async function openDropdown() {
|
||||
isOpen.value = true;
|
||||
await nextTick();
|
||||
updateDropdownPosition();
|
||||
addPositionListeners();
|
||||
setDefaultActiveIndex();
|
||||
searchInput.value?.focus();
|
||||
}
|
||||
@@ -138,6 +148,8 @@ function closeDropdown() {
|
||||
isOpen.value = false;
|
||||
search.value = '';
|
||||
activeIndex.value = -1;
|
||||
dropdownStyle.value = {};
|
||||
removePositionListeners();
|
||||
}
|
||||
|
||||
function toggleDropdown() {
|
||||
@@ -168,11 +180,13 @@ function selectOption(value: string) {
|
||||
updateValue([...modelValues.value, value]);
|
||||
search.value = '';
|
||||
setDefaultActiveIndex();
|
||||
scheduleDropdownPositionUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
function remove(value: string) {
|
||||
updateValue(modelValues.value.filter((item) => item !== value));
|
||||
scheduleDropdownPositionUpdate();
|
||||
}
|
||||
|
||||
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(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||
removePositionListeners();
|
||||
});
|
||||
|
||||
watch(search, setDefaultActiveIndex);
|
||||
watch(candidateRows, clampActiveIndex);
|
||||
watch(
|
||||
() => props.dropdownStrategy,
|
||||
() => {
|
||||
if (!isOpen.value) return;
|
||||
|
||||
removePositionListeners();
|
||||
void nextTick(() => {
|
||||
updateDropdownPosition();
|
||||
addPositionListeners();
|
||||
});
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="tags-select" :class="{ 'tags-select--single': !multiple }" @keydown="onRootKeydown">
|
||||
<button
|
||||
:id="id"
|
||||
ref="trigger"
|
||||
type="button"
|
||||
class="tags-select__trigger"
|
||||
:class="{ open: isOpen }"
|
||||
@@ -271,7 +370,15 @@ watch(candidateRows, clampActiveIndex);
|
||||
<Icon :icon="iconChevronDown" class="tags-select__arrow" aria-hidden="true" />
|
||||
</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
|
||||
ref="searchInput"
|
||||
v-model="search"
|
||||
|
||||
@@ -11,6 +11,8 @@ const props = defineProps<{
|
||||
translations: TranslationMap;
|
||||
languages: Language[];
|
||||
required?: boolean;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -79,11 +81,20 @@ function updateField(language: Language, value: string) {
|
||||
{{ t('common.fieldForLanguage', { field: label, language: currentLanguage.name }) }}
|
||||
</label>
|
||||
<input
|
||||
v-if="!multiline"
|
||||
:id="`${idPrefix}-${currentLanguage.code}`"
|
||||
v-model="currentValue"
|
||||
:placeholder="currentPlaceholder"
|
||||
:required="currentRequired"
|
||||
/>
|
||||
<textarea
|
||||
v-else
|
||||
:id="`${idPrefix}-${currentLanguage.code}`"
|
||||
v-model="currentValue"
|
||||
:placeholder="currentPlaceholder"
|
||||
:required="currentRequired"
|
||||
:rows="rows ?? 4"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -17,6 +17,7 @@ const messages = {
|
||||
create: 'Create',
|
||||
delete: 'Delete',
|
||||
edit: 'Edit',
|
||||
details: 'Details',
|
||||
filters: 'Filters',
|
||||
loading: 'Loading',
|
||||
name: 'Name',
|
||||
@@ -34,6 +35,7 @@ const messages = {
|
||||
noMatches: 'No matches',
|
||||
createNamed: 'Add "{name}"',
|
||||
creating: 'Adding',
|
||||
inDev: 'In-Dev',
|
||||
removeNamed: 'Remove {name}',
|
||||
quantity: 'Quantity',
|
||||
required: 'Required'
|
||||
@@ -43,9 +45,17 @@ const messages = {
|
||||
habitats: 'Habitats',
|
||||
items: 'Items',
|
||||
recipes: 'Recipes',
|
||||
dish: 'Dish',
|
||||
events: 'Events',
|
||||
actions: 'Actions',
|
||||
dreamIsland: 'Dream Island',
|
||||
clothes: 'Clothes',
|
||||
checklist: 'CheckList',
|
||||
life: 'Life',
|
||||
admin: 'Admin',
|
||||
main: 'Main navigation',
|
||||
openMenu: 'Open navigation',
|
||||
closeMenu: 'Close navigation',
|
||||
language: 'Language',
|
||||
login: 'Log in',
|
||||
logout: 'Log out',
|
||||
@@ -87,13 +97,43 @@ const messages = {
|
||||
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
|
||||
detailKicker: 'Pokédex Detail',
|
||||
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',
|
||||
editTitle: 'Edit #{id} {name}',
|
||||
loadingList: 'Loading Pokemon list',
|
||||
loadingDetail: 'Loading Pokemon detail',
|
||||
loadingEdit: 'Loading Pokemon editor',
|
||||
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',
|
||||
skills: 'Specialities',
|
||||
skillMatchMode: 'Speciality match mode',
|
||||
@@ -105,10 +145,13 @@ const messages = {
|
||||
skillDrop: '{name} drop',
|
||||
dropItem: 'Drop item',
|
||||
searchPokemon: 'Search Pokemon',
|
||||
relatedPokemon: 'Related Pokemon',
|
||||
relatedHabitat: 'Related Pokemon habitat',
|
||||
relatedItems: 'Related items',
|
||||
relatedItemCategory: 'Related item category',
|
||||
habitats: 'Habitats',
|
||||
namePlaceholder: 'Name',
|
||||
searchTypes: 'Search types',
|
||||
searchEnvironment: 'Search ideal habitats',
|
||||
searchSkills: 'Search specialities',
|
||||
searchFavoriteThings: 'Search favourites',
|
||||
@@ -182,6 +225,68 @@ const messages = {
|
||||
materials: 'Materials',
|
||||
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: {
|
||||
title: 'Daily checklist',
|
||||
subtitle: 'See what can be completed each day.',
|
||||
@@ -192,6 +297,79 @@ const messages = {
|
||||
newTask: 'New 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: {
|
||||
title: 'Admin',
|
||||
subtitle: 'Maintain system configuration and manage Wiki records.',
|
||||
@@ -220,13 +398,15 @@ const messages = {
|
||||
}
|
||||
},
|
||||
config: {
|
||||
pokemonTypes: 'Pokemon Types',
|
||||
skills: 'Specialities',
|
||||
environments: 'Ideal Habitats',
|
||||
favoriteThings: 'Favourites / tags',
|
||||
itemCategories: 'Item categories',
|
||||
itemUsages: 'Item usages',
|
||||
acquisitionMethods: 'Acquisition methods',
|
||||
maps: 'Maps'
|
||||
maps: 'Maps',
|
||||
lifeTags: 'Life tags'
|
||||
},
|
||||
appearance: {
|
||||
time: 'Time',
|
||||
@@ -257,6 +437,33 @@ const messages = {
|
||||
update: 'Edit',
|
||||
delete: 'Delete',
|
||||
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': {
|
||||
@@ -271,6 +478,7 @@ const messages = {
|
||||
create: '创建',
|
||||
delete: '删除',
|
||||
edit: '编辑',
|
||||
details: '详情',
|
||||
filters: '筛选',
|
||||
loading: '加载中',
|
||||
name: '名称',
|
||||
@@ -288,6 +496,7 @@ const messages = {
|
||||
noMatches: '没有匹配项',
|
||||
createNamed: '添加「{name}」',
|
||||
creating: '添加中',
|
||||
inDev: '开发中',
|
||||
removeNamed: '移除{name}',
|
||||
quantity: '数量',
|
||||
required: '必填'
|
||||
@@ -297,9 +506,17 @@ const messages = {
|
||||
habitats: '栖息地',
|
||||
items: '物品',
|
||||
recipes: '材料单',
|
||||
dish: '料理',
|
||||
events: '活动',
|
||||
actions: '动作',
|
||||
dreamIsland: 'Dream Island',
|
||||
clothes: '服装',
|
||||
checklist: 'CheckList',
|
||||
life: 'Life',
|
||||
admin: '管理',
|
||||
main: '主导航',
|
||||
openMenu: '打开导航',
|
||||
closeMenu: '关闭导航',
|
||||
language: '语言',
|
||||
login: '登录',
|
||||
logout: '退出',
|
||||
@@ -341,13 +558,43 @@ const messages = {
|
||||
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',
|
||||
detailKicker: 'Pokédex Detail',
|
||||
editKicker: 'Pokédex Edit',
|
||||
editSubtitle: '维护 Pokemon 基本资料、特长和喜欢的东西。',
|
||||
editSubtitle: '维护 Pokemon 介绍、属性、六维、特长和喜欢的东西。',
|
||||
editSections: 'Pokemon 编辑分区',
|
||||
editTabBasic: '基础',
|
||||
editTabAdvance: '进阶',
|
||||
newTitle: '新增 Pokemon',
|
||||
editTitle: '编辑 #{id} {name}',
|
||||
loadingList: '正在加载 Pokemon 列表',
|
||||
loadingDetail: '正在加载 Pokemon 详情',
|
||||
loadingEdit: '正在加载 Pokemon 编辑内容',
|
||||
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: '喜欢的环境',
|
||||
skills: '特长',
|
||||
skillMatchMode: '特长匹配方式',
|
||||
@@ -359,10 +606,13 @@ const messages = {
|
||||
skillDrop: '{name}掉落物',
|
||||
dropItem: '掉落物',
|
||||
searchPokemon: '搜索 Pokemon',
|
||||
relatedPokemon: '相关 Pokemon',
|
||||
relatedHabitat: '相关 Pokemon 栖息地',
|
||||
relatedItems: '关联物品',
|
||||
relatedItemCategory: '关联物品分类',
|
||||
habitats: '栖息地',
|
||||
namePlaceholder: '名字',
|
||||
searchTypes: '搜索属性',
|
||||
searchEnvironment: '搜索喜欢的环境',
|
||||
searchSkills: '搜索特长',
|
||||
searchFavoriteThings: '搜索喜欢的东西',
|
||||
@@ -436,6 +686,68 @@ const messages = {
|
||||
materials: '需要材料',
|
||||
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: {
|
||||
title: '每日清单',
|
||||
subtitle: '查看每天可以完成的事项。',
|
||||
@@ -446,6 +758,79 @@ const messages = {
|
||||
newTask: '新增 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: {
|
||||
title: '管理',
|
||||
subtitle: '维护系统配置,查看并删除 Wiki 数据记录。',
|
||||
@@ -474,13 +859,15 @@ const messages = {
|
||||
}
|
||||
},
|
||||
config: {
|
||||
pokemonTypes: 'Pokemon 属性',
|
||||
skills: '特长',
|
||||
environments: '喜欢的环境',
|
||||
favoriteThings: '喜欢的东西 / 标签',
|
||||
itemCategories: '物品分类',
|
||||
itemUsages: '物品用途',
|
||||
acquisitionMethods: '入手方式',
|
||||
maps: '地图'
|
||||
maps: '地图',
|
||||
lifeTags: 'Life 标签'
|
||||
},
|
||||
appearance: {
|
||||
time: '时段',
|
||||
@@ -511,6 +898,33 @@ const messages = {
|
||||
update: '编辑',
|
||||
delete: '删除',
|
||||
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} 个字符'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,27 +2,41 @@ export type AppIcon = string;
|
||||
|
||||
export const iconAdd: AppIcon = 'mdi:plus';
|
||||
export const iconAdmin: AppIcon = 'mdi:tune-variant';
|
||||
export const iconAction: AppIcon = 'mdi:gesture-tap-button';
|
||||
export const iconBack: AppIcon = 'mdi:arrow-left';
|
||||
export const iconCancel: AppIcon = 'mdi:close';
|
||||
export const iconCheck: AppIcon = 'mdi:check';
|
||||
export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
|
||||
export const iconChevronDown: AppIcon = 'mdi:chevron-down';
|
||||
export const iconClose: AppIcon = 'mdi:close';
|
||||
export const iconComment: AppIcon = 'mdi:comment-outline';
|
||||
export const iconDelete: AppIcon = 'mdi:trash-can-outline';
|
||||
export const iconDish: AppIcon = 'mdi:silverware-fork-knife';
|
||||
export const iconDragHandle: AppIcon = 'mdi:drag';
|
||||
export const iconDreamIsland: AppIcon = 'mdi:palm-tree';
|
||||
export const iconEdit: AppIcon = 'mdi:pencil-outline';
|
||||
export const iconError: AppIcon = 'mdi:close-circle-outline';
|
||||
export const iconEvent: AppIcon = 'mdi:calendar-star';
|
||||
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
||||
export const iconInfo: AppIcon = 'mdi:information-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 iconLogout: AppIcon = 'mdi:logout';
|
||||
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 iconPokemon: AppIcon = 'mdi:pokeball';
|
||||
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-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 iconSearch: AppIcon = 'mdi:magnify';
|
||||
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
||||
export const iconTranslate: AppIcon = 'mdi:translate';
|
||||
export const iconWarning: AppIcon = 'mdi:alert-outline';
|
||||
|
||||
@@ -8,6 +8,8 @@ import ItemDetail from '../views/ItemDetail.vue';
|
||||
import RecipeList from '../views/RecipeList.vue';
|
||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||
import LifeView from '../views/LifeView.vue';
|
||||
import ComingSoonView from '../views/ComingSoonView.vue';
|
||||
import AdminView from '../views/AdminView.vue';
|
||||
import LoginView from '../views/LoginView.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/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiresVerified: true, editorModal: true } },
|
||||
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail },
|
||||
{ path: '/dish', name: 'dish', component: ComingSoonView, props: { page: 'dish' } },
|
||||
{ path: '/events', name: 'events', component: ComingSoonView, props: { page: 'events' } },
|
||||
{ path: '/actions', name: 'actions', component: ComingSoonView, props: { page: 'actions' } },
|
||||
{ path: '/dream-island', name: 'dream-island', component: ComingSoonView, props: { page: 'dreamIsland' } },
|
||||
{ path: '/clothes', name: 'clothes', component: ComingSoonView, props: { page: 'clothes' } },
|
||||
{ path: '/checklist', component: DailyChecklistView },
|
||||
{ path: '/life', component: LifeView },
|
||||
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/register', component: RegisterView },
|
||||
|
||||
@@ -4,7 +4,7 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
|
||||
const authTokenKey = 'pokopia_auth_token';
|
||||
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 interface Language {
|
||||
@@ -26,6 +26,15 @@ export interface Skill extends NamedEntity {
|
||||
hasItemDrop: boolean;
|
||||
}
|
||||
|
||||
export interface PokemonStats {
|
||||
hp: number;
|
||||
attack: number;
|
||||
defense: number;
|
||||
specialAttack: number;
|
||||
specialDefense: number;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
id: number;
|
||||
displayName: string;
|
||||
@@ -57,15 +66,34 @@ export interface Pokemon extends EditInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
baseName?: string;
|
||||
genus: string;
|
||||
baseGenus?: string;
|
||||
details: string;
|
||||
baseDetails?: string;
|
||||
heightInches: number;
|
||||
heightMeters: number;
|
||||
weightPounds: number;
|
||||
weightKg: number;
|
||||
translations?: TranslationMap;
|
||||
types: NamedEntity[];
|
||||
stats: PokemonStats;
|
||||
environment: NamedEntity;
|
||||
skills: Skill[];
|
||||
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 {
|
||||
skills: Array<Skill & { itemDrop: NamedEntity | null }>;
|
||||
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
|
||||
relatedPokemon: RelatedPokemon[];
|
||||
editHistory: EditHistoryEntry[];
|
||||
habitats: Array<{
|
||||
id: number;
|
||||
@@ -154,6 +182,47 @@ export interface DailyChecklistItem {
|
||||
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 {
|
||||
acquisition_methods: NamedEntity[];
|
||||
editHistory: EditHistoryEntry[];
|
||||
@@ -161,6 +230,7 @@ export interface RecipeDetail extends Recipe {
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
pokemonTypes: NamedEntity[];
|
||||
skills: Skill[];
|
||||
environments: NamedEntity[];
|
||||
favoriteThings: NamedEntity[];
|
||||
@@ -169,6 +239,7 @@ export interface Options {
|
||||
acquisitionMethods: NamedEntity[];
|
||||
itemTags: NamedEntity[];
|
||||
maps: NamedEntity[];
|
||||
lifeTags: NamedEntity[];
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
@@ -193,18 +264,26 @@ export interface AuthResponse {
|
||||
}
|
||||
|
||||
export type ConfigType =
|
||||
| 'pokemon-types'
|
||||
| 'skills'
|
||||
| 'environments'
|
||||
| 'favorite-things'
|
||||
| 'item-categories'
|
||||
| 'item-usages'
|
||||
| 'acquisition-methods'
|
||||
| 'maps';
|
||||
| 'maps'
|
||||
| 'life-tags';
|
||||
|
||||
export interface PokemonPayload {
|
||||
id: number;
|
||||
name: string;
|
||||
genus: string;
|
||||
details: string;
|
||||
heightInches: number;
|
||||
weightPounds: number;
|
||||
translations?: TranslationMap;
|
||||
typeIds: number[];
|
||||
stats: PokemonStats;
|
||||
environmentId: number;
|
||||
skillIds: number[];
|
||||
favoriteThingIds: number[];
|
||||
@@ -248,6 +327,34 @@ export interface DailyChecklistPayload {
|
||||
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 {
|
||||
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 = {
|
||||
languages: () => getJson<Language[]>('/api/languages'),
|
||||
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
|
||||
@@ -377,6 +497,41 @@ export const api = {
|
||||
logout: () => postEmpty('/api/auth/logout'),
|
||||
options: () => getJson<Options>('/api/options'),
|
||||
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) =>
|
||||
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
|
||||
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,13 +66,15 @@ const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
|
||||
]);
|
||||
|
||||
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: 'environments', label: t('config.environments') },
|
||||
{ key: 'favorite-things', label: t('config.favoriteThings') },
|
||||
{ key: 'item-categories', label: t('config.itemCategories') },
|
||||
{ key: 'item-usages', label: t('config.itemUsages') },
|
||||
{ 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');
|
||||
|
||||
82
frontend/src/views/ComingSoonView.vue
Normal file
82
frontend/src/views/ComingSoonView.vue
Normal 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>
|
||||
@@ -5,9 +5,11 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import DetailSection from '../components/DetailSection.vue';
|
||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit } from '../icons';
|
||||
import { api, type HabitatDetail } from '../services/api';
|
||||
import HabitatEdit from './HabitatEdit.vue';
|
||||
@@ -15,9 +17,15 @@ import HabitatEdit from './HabitatEdit.vue';
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const habitat = ref<HabitatDetail | null>(null);
|
||||
const detailTab = ref('details');
|
||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||
const weathers = ['晴天', '阴天', '雨天'];
|
||||
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 = {
|
||||
id: number;
|
||||
@@ -121,6 +129,7 @@ watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
habitat.value = null;
|
||||
detailTab.value = 'details';
|
||||
void loadHabitatDetail();
|
||||
}
|
||||
);
|
||||
@@ -187,8 +196,10 @@ watch(
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="detail-with-sidebar">
|
||||
<div class="habitat-detail-stack">
|
||||
<div class="detail-tabs">
|
||||
<Tabs id="habitat-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||
|
||||
<div v-if="detailTab === 'details'" class="habitat-detail-stack">
|
||||
<DetailSection :title="t('pages.habitats.recipeList')">
|
||||
<EntityChips :items="habitat.recipe" />
|
||||
</DetailSection>
|
||||
@@ -220,7 +231,13 @@ watch(
|
||||
</DetailSection>
|
||||
</div>
|
||||
|
||||
<EditHistoryPanel :entity="habitat" :history="habitat.editHistory" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import DetailSection from '../components/DetailSection.vue';
|
||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconAdd, iconBack, iconEdit } from '../icons';
|
||||
import { api, type ItemDetail } from '../services/api';
|
||||
import ItemEdit from './ItemEdit.vue';
|
||||
@@ -15,7 +17,13 @@ import ItemEdit from './ItemEdit.vue';
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const item = ref<ItemDetail | null>(null);
|
||||
const detailTab = ref('details');
|
||||
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(() => {
|
||||
if (!item.value) {
|
||||
@@ -50,6 +58,7 @@ watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
item.value = null;
|
||||
detailTab.value = 'details';
|
||||
void loadItemDetail();
|
||||
}
|
||||
);
|
||||
@@ -123,8 +132,10 @@ watch(
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="detail-with-sidebar">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-tabs">
|
||||
<Tabs id="item-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||
|
||||
<div v-if="detailTab === 'details'" class="detail-grid">
|
||||
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
||||
<EntityChips :items="item.acquisitionMethods" />
|
||||
</DetailSection>
|
||||
@@ -186,7 +197,13 @@ watch(
|
||||
</DetailSection>
|
||||
</div>
|
||||
|
||||
<EditHistoryPanel :entity="item" :history="item.editHistory" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
1131
frontend/src/views/LifeView.vue
Normal file
1131
frontend/src/views/LifeView.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,10 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import DetailSection from '../components/DetailSection.vue';
|
||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit } from '../icons';
|
||||
@@ -17,8 +19,11 @@ const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const pokemon = ref<PokemonDetail | null>(null);
|
||||
const itemCategoryTab = ref('');
|
||||
const relatedHabitatTab = ref('');
|
||||
const detailTab = ref('details');
|
||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||
const weathers = ['晴天', '阴天', '雨天'];
|
||||
const relatedPokemonLimit = 6;
|
||||
|
||||
type HabitatRow = {
|
||||
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 {
|
||||
const labels: Record<string, string> = {
|
||||
早晨: t('appearance.morning'),
|
||||
@@ -102,6 +111,11 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
||||
});
|
||||
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
|
||||
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 categories = new Map<string, string>();
|
||||
|
||||
@@ -122,9 +136,54 @@ const favoriteThingItems = computed(() => {
|
||||
|
||||
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() {
|
||||
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 () => {
|
||||
@@ -144,6 +203,8 @@ watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
pokemon.value = null;
|
||||
relatedHabitatTab.value = '';
|
||||
detailTab.value = 'details';
|
||||
void loadPokemonDetail();
|
||||
}
|
||||
);
|
||||
@@ -221,8 +282,55 @@ watch(
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="detail-with-sidebar">
|
||||
<div class="detail-grid detail-grid--stack">
|
||||
<div class="detail-tabs">
|
||||
<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')">
|
||||
<EntityChips :items="pokemon.skills" />
|
||||
</DetailSection>
|
||||
@@ -240,25 +348,76 @@ watch(
|
||||
<EntityChips :items="pokemon.favorite_things" />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection :title="t('pages.pokemon.relatedItems')">
|
||||
<template v-if="pokemon.favoriteThingItems.length">
|
||||
<Tabs
|
||||
v-if="itemCategoryTabs.length"
|
||||
id="pokemon-favorite-items"
|
||||
v-model="itemCategoryTab"
|
||||
:tabs="itemCategoryTabs"
|
||||
:label="t('pages.pokemon.relatedItemCategory')"
|
||||
/>
|
||||
<ul v-if="favoriteThingItems.length" class="row-list">
|
||||
<li v-for="item in favoriteThingItems" :key="item.id">
|
||||
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
|
||||
<EntityChips :items="item.tags" />
|
||||
</li>
|
||||
</ul>
|
||||
<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>
|
||||
</template>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</DetailSection>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection :title="t('pages.pokemon.relatedItems')">
|
||||
<template v-if="pokemon.favoriteThingItems.length">
|
||||
<Tabs
|
||||
v-if="itemCategoryTabs.length"
|
||||
id="pokemon-favorite-items"
|
||||
v-model="itemCategoryTab"
|
||||
:tabs="itemCategoryTabs"
|
||||
:label="t('pages.pokemon.relatedItemCategory')"
|
||||
/>
|
||||
<ul v-if="favoriteThingItems.length" class="row-list">
|
||||
<li v-for="item in favoriteThingItems" :key="item.id">
|
||||
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
|
||||
<EntityChips :items="item.tags" />
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</template>
|
||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||
</DetailSection>
|
||||
</div>
|
||||
|
||||
<DetailSection :title="t('pages.pokemon.habitats')">
|
||||
<ul class="row-list appearance-list">
|
||||
@@ -287,7 +446,13 @@ watch(
|
||||
</DetailSection>
|
||||
</div>
|
||||
|
||||
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import PokemonStatsFields from '../components/PokemonStatsFields.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import Tabs from '../components/Tabs.vue';
|
||||
import TagsSelect from '../components/TagsSelect.vue';
|
||||
import TranslationFields from '../components/TranslationFields.vue';
|
||||
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 = {
|
||||
skillId: string;
|
||||
@@ -26,10 +37,31 @@ const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const message = 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({
|
||||
id: '',
|
||||
name: '',
|
||||
genus: '',
|
||||
details: '',
|
||||
heightInches: 0,
|
||||
weightPounds: 0,
|
||||
translations: {} as TranslationMap,
|
||||
typeIds: [] as string[],
|
||||
stats: defaultPokemonStats(),
|
||||
environmentId: '',
|
||||
skillIds: [] as string[],
|
||||
favoriteThingIds: [] as string[],
|
||||
@@ -47,11 +79,51 @@ const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` :
|
||||
const selectedSkillDropRows = computed(() =>
|
||||
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[] {
|
||||
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) {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
@@ -98,6 +170,21 @@ function pokemonNameForSave() {
|
||||
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() {
|
||||
void router.push(cancelTo.value);
|
||||
}
|
||||
@@ -113,7 +200,13 @@ async function loadEditor() {
|
||||
pokemonForm.value = {
|
||||
id: String(pokemon.id),
|
||||
name: pokemon.baseName ?? pokemon.name,
|
||||
genus: pokemon.baseGenus ?? pokemon.genus,
|
||||
details: pokemon.baseDetails ?? pokemon.details,
|
||||
heightInches: pokemon.heightInches,
|
||||
weightPounds: pokemon.weightPounds,
|
||||
translations: pokemon.translations ?? {},
|
||||
typeIds: pokemon.types.map((type) => String(type.id)),
|
||||
stats: pokemon.stats,
|
||||
environmentId: String(pokemon.environment.id),
|
||||
skillIds: pokemon.skills.map((skill) => String(skill.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() {
|
||||
if (!hasRequiredBasicFields()) {
|
||||
await showBasicFieldValidation();
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
message.value = '';
|
||||
|
||||
try {
|
||||
const payload: PokemonPayload = {
|
||||
id: Number(isEditing.value ? routeId.value : pokemonForm.value.id),
|
||||
id: pokemonIdForSave(),
|
||||
name: pokemonNameForSave(),
|
||||
genus: pokemonForm.value.genus,
|
||||
details: pokemonForm.value.details,
|
||||
heightInches: pokemonForm.value.heightInches,
|
||||
weightPounds: pokemonForm.value.weightPounds,
|
||||
translations: pokemonForm.value.translations,
|
||||
typeIds: toIds(pokemonForm.value.typeIds.slice(0, 2)),
|
||||
stats: pokemonForm.value.stats,
|
||||
environmentId: Number(pokemonForm.value.environmentId),
|
||||
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
|
||||
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
|
||||
@@ -204,81 +308,186 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
||||
<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>
|
||||
|
||||
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form" @submit.prevent="savePokemon">
|
||||
<div class="field">
|
||||
<label for="pokemon-id">ID</label>
|
||||
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
|
||||
</div>
|
||||
<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')" />
|
||||
|
||||
<TranslationFields
|
||||
id-prefix="pokemon-name"
|
||||
v-model:base-value="pokemonForm.name"
|
||||
v-model:translations="pokemonForm.translations"
|
||||
field="name"
|
||||
:label="t('common.name')"
|
||||
:languages="languages"
|
||||
required
|
||||
/>
|
||||
<section v-if="activeEditTab === 'basic'" class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabBasic')">
|
||||
<div class="pokemon-edit-grid">
|
||||
<div class="field">
|
||||
<label for="pokemon-id">ID</label>
|
||||
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
|
||||
<TagsSelect
|
||||
id="pokemon-environment"
|
||||
v-model="pokemonForm.environmentId"
|
||||
:options="options.environments"
|
||||
:multiple="false"
|
||||
allow-create
|
||||
:creating="creatingSelect === 'pokemon-environment'"
|
||||
:placeholder="t('common.select')"
|
||||
:search-placeholder="t('pages.pokemon.searchEnvironment')"
|
||||
@create="createSingleOption('pokemon-environment', 'environments', $event, (value) => (pokemonForm.environmentId = value))"
|
||||
/>
|
||||
</div>
|
||||
<TranslationFields
|
||||
id-prefix="pokemon-name"
|
||||
v-model:base-value="pokemonForm.name"
|
||||
v-model:translations="pokemonForm.translations"
|
||||
field="name"
|
||||
:label="t('common.name')"
|
||||
:languages="languages"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="pokemon-skills">{{ t('pages.pokemon.skills') }}</label>
|
||||
<TagsSelect
|
||||
id="pokemon-skills"
|
||||
v-model="pokemonForm.skillIds"
|
||||
:options="options.skills"
|
||||
:max="2"
|
||||
allow-create
|
||||
:creating="creatingSelect === 'pokemon-skills'"
|
||||
:placeholder="t('pages.pokemon.searchSkills')"
|
||||
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="pokemon-things">{{ t('pages.pokemon.favoriteThings') }}</label>
|
||||
<TagsSelect
|
||||
id="pokemon-things"
|
||||
v-model="pokemonForm.favoriteThingIds"
|
||||
:options="options.favoriteThings"
|
||||
:max="6"
|
||||
allow-create
|
||||
:creating="creatingSelect === 'pokemon-things'"
|
||||
:placeholder="t('pages.pokemon.searchFavoriteThings')"
|
||||
@create="createMultiOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedSkillDropRows.length" class="field">
|
||||
<span class="field-label">{{ t('pages.pokemon.skillDrops') }}</span>
|
||||
<div class="skill-drop-list">
|
||||
<div v-for="row in selectedSkillDropRows" :key="row.skillId" class="skill-drop-row">
|
||||
<label :for="`pokemon-skill-drops-${row.skillId}`">{{ skillDropLabel(row.skillId) }}</label>
|
||||
<div class="pokemon-edit-grid">
|
||||
<div class="field">
|
||||
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
|
||||
<TagsSelect
|
||||
:id="`pokemon-skill-drops-${row.skillId}`"
|
||||
v-model="row.itemId"
|
||||
:options="itemOptions"
|
||||
id="pokemon-environment"
|
||||
v-model="pokemonForm.environmentId"
|
||||
:options="options.environments"
|
||||
:multiple="false"
|
||||
:placeholder="t('pages.pokemon.dropItem')"
|
||||
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||
allow-create
|
||||
:creating="creatingSelect === 'pokemon-environment'"
|
||||
:placeholder="t('common.select')"
|
||||
:search-placeholder="t('pages.pokemon.searchEnvironment')"
|
||||
@create="createSingleOption('pokemon-environment', 'environments', $event, (value) => (pokemonForm.environmentId = value))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="pokemon-skills">{{ t('pages.pokemon.skills') }}</label>
|
||||
<TagsSelect
|
||||
id="pokemon-skills"
|
||||
v-model="pokemonForm.skillIds"
|
||||
:options="options.skills"
|
||||
:max="2"
|
||||
allow-create
|
||||
:creating="creatingSelect === 'pokemon-skills'"
|
||||
:placeholder="t('pages.pokemon.searchSkills')"
|
||||
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="pokemon-things">{{ t('pages.pokemon.favoriteThings') }}</label>
|
||||
<TagsSelect
|
||||
id="pokemon-things"
|
||||
v-model="pokemonForm.favoriteThingIds"
|
||||
:options="options.favoriteThings"
|
||||
:max="6"
|
||||
allow-create
|
||||
:creating="creatingSelect === 'pokemon-things'"
|
||||
:placeholder="t('pages.pokemon.searchFavoriteThings')"
|
||||
@create="createMultiOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedSkillDropRows.length" class="field">
|
||||
<span class="field-label">{{ t('pages.pokemon.skillDrops') }}</span>
|
||||
<div class="skill-drop-list">
|
||||
<div v-for="row in selectedSkillDropRows" :key="row.skillId" class="skill-drop-row">
|
||||
<label :for="`pokemon-skill-drops-${row.skillId}`">{{ skillDropLabel(row.skillId) }}</label>
|
||||
<TagsSelect
|
||||
:id="`pokemon-skill-drops-${row.skillId}`"
|
||||
v-model="row.itemId"
|
||||
:options="itemOptions"
|
||||
:multiple="false"
|
||||
:placeholder="t('pages.pokemon.dropItem')"
|
||||
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.pokemon.loadingEdit')">
|
||||
|
||||
@@ -145,6 +145,7 @@ watch(query, loadPokemon);
|
||||
:to="`/pokemon/${item.id}`"
|
||||
>
|
||||
<EditMeta :entity="item" />
|
||||
<EntityChips v-if="item.types.length" :items="item.types" />
|
||||
<EntityChips :items="item.skills" />
|
||||
<EntityChips :items="item.favorite_things" />
|
||||
</EntityCard>
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import DetailSection from '../components/DetailSection.vue';
|
||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||
import { iconBack, iconEdit } from '../icons';
|
||||
import { api, type RecipeDetail } from '../services/api';
|
||||
import RecipeEdit from './RecipeEdit.vue';
|
||||
@@ -15,7 +17,13 @@ import RecipeEdit from './RecipeEdit.vue';
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const recipe = ref<RecipeDetail | null>(null);
|
||||
const detailTab = ref('details');
|
||||
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() {
|
||||
recipe.value = await api.recipeDetail(String(route.params.id));
|
||||
@@ -38,6 +46,7 @@ watch(
|
||||
() => route.params.id,
|
||||
() => {
|
||||
recipe.value = null;
|
||||
detailTab.value = 'details';
|
||||
void loadRecipeDetail();
|
||||
}
|
||||
);
|
||||
@@ -85,8 +94,10 @@ watch(
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="detail-with-sidebar">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-tabs">
|
||||
<Tabs id="recipe-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||
|
||||
<div v-if="detailTab === 'details'" class="detail-grid">
|
||||
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
||||
<EntityChips :items="recipe.acquisition_methods" />
|
||||
</DetailSection>
|
||||
@@ -96,7 +107,13 @@ watch(
|
||||
</DetailSection>
|
||||
</div>
|
||||
|
||||
<EditHistoryPanel :entity="recipe" :history="recipe.editHistory" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user