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.
|
* Follow the existing structure and conventions strictly.
|
||||||
* Make **minimal, targeted changes only**. Do not refactor unrelated code.
|
* Make **minimal, targeted changes only**. Do not refactor unrelated code.
|
||||||
* Prefer clarity over cleverness. Avoid unnecessary abstraction.
|
* Prefer clarity over cleverness. Avoid unnecessary abstraction.
|
||||||
|
* Keep `DESIGN.md` aligned with implemented product behavior when changing data models, APIs, routes, permissions, or user-facing workflows.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -22,15 +23,91 @@ For any non-trivial task:
|
|||||||
|
|
||||||
Do NOT skip planning.
|
Do NOT skip planning.
|
||||||
|
|
||||||
|
For documentation-only tasks, still follow the planning workflow, but do not run unrelated builds or tests unless the document change depends on generated output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
* Goal: Pokopia Wiki, a community-editable game wiki.
|
||||||
|
* Repository: pnpm workspace monorepo.
|
||||||
|
* Runtime baseline: Node.js >= 22.
|
||||||
|
* Frontend:
|
||||||
|
|
||||||
|
* Vue
|
||||||
|
* Vite
|
||||||
|
* Vue Router
|
||||||
|
* Vue I18n
|
||||||
|
* Iconify
|
||||||
|
* TypeScript
|
||||||
|
|
||||||
|
* Backend:
|
||||||
|
|
||||||
|
* Node.js
|
||||||
|
* Fastify
|
||||||
|
* PostgreSQL
|
||||||
|
* `pg`
|
||||||
|
* TypeScript
|
||||||
|
|
||||||
|
* Infra:
|
||||||
|
|
||||||
|
* Docker
|
||||||
|
* docker compose
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Existing Product Shape
|
||||||
|
|
||||||
|
* Public users can browse Wiki content.
|
||||||
|
* Registered users must verify email before editing.
|
||||||
|
* Verified users can edit Wiki content and management data; there is no separate role system currently.
|
||||||
|
* Main public sections:
|
||||||
|
|
||||||
|
* Pokemon
|
||||||
|
* Habitats
|
||||||
|
* Items
|
||||||
|
* Recipes
|
||||||
|
* Daily CheckList
|
||||||
|
|
||||||
|
* Management covers:
|
||||||
|
|
||||||
|
* System config
|
||||||
|
* Languages
|
||||||
|
* Daily CheckList tasks
|
||||||
|
* Sorting for Pokemon, items, recipes, and habitats
|
||||||
|
|
||||||
|
* Main entity create/edit flows use route-backed modal dialogs.
|
||||||
|
* Internationalization is part of the product model, not just UI copy.
|
||||||
|
* Detailed edit history and editor attribution are part of entity detail behavior.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## UI Design Guidelines
|
## UI Design Guidelines
|
||||||
|
|
||||||
* Use `DesignGuidelines.html` as the reference for UI design, visual style, and component behavior.
|
* Use `DesignGuidelines.html` as the reference for UI design, visual style, and component behavior.
|
||||||
* Prefer reusing existing components that already match the guidelines.
|
* Prefer reusing existing components that already match the guidelines.
|
||||||
|
* Existing shared UI patterns include:
|
||||||
|
|
||||||
|
* `AppShell`
|
||||||
|
* `PageHeader`
|
||||||
|
* `Modal`
|
||||||
|
* `FilterPanel`
|
||||||
|
* `EntityCard`
|
||||||
|
* `DetailSection`
|
||||||
|
* `EditMeta`
|
||||||
|
* `EditHistoryPanel`
|
||||||
|
* `Skeleton`
|
||||||
|
* `Tabs`
|
||||||
|
* `SwitchGroup`
|
||||||
|
* `TagsSelect`
|
||||||
|
* `TranslationFields`
|
||||||
|
* `ReorderableList`
|
||||||
|
|
||||||
* If a needed component does not exist, create the smallest necessary component based on `DesignGuidelines.html`.
|
* If a needed component does not exist, create the smallest necessary component based on `DesignGuidelines.html`.
|
||||||
* Existing components may be upgraded to match `DesignGuidelines.html`, but only when directly related to the task.
|
* Existing components may be upgraded to match `DesignGuidelines.html`, but only when directly related to the task.
|
||||||
* Do not introduce broad UI rewrites, new design systems, or extra abstraction layers unless explicitly required.
|
* Do not introduce broad UI rewrites, new design systems, or extra abstraction layers unless explicitly required.
|
||||||
|
* Use Skeleton loaders for data loading states instead of user-facing loading remarks when the existing page pattern supports it.
|
||||||
|
* Use icon-based navigation and actions consistently with the existing Iconify setup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -42,6 +119,8 @@ Do NOT skip planning.
|
|||||||
* Introduce new layers (services, utils, hooks, etc.) unless clearly required
|
* Introduce new layers (services, utils, hooks, etc.) unless clearly required
|
||||||
* Split files unnecessarily
|
* Split files unnecessarily
|
||||||
* Rewrite existing modules without explicit instruction
|
* Rewrite existing modules without explicit instruction
|
||||||
|
* Change unrelated route, API, or schema behavior while working on UI-only tasks
|
||||||
|
|
||||||
* Prefer editing existing files over creating new ones.
|
* Prefer editing existing files over creating new ones.
|
||||||
* Keep functions and components small and readable.
|
* Keep functions and components small and readable.
|
||||||
|
|
||||||
@@ -62,31 +141,52 @@ User-facing UI must NEVER contain:
|
|||||||
|
|
||||||
### Strict Rules
|
### Strict Rules
|
||||||
|
|
||||||
* Only render **business data** and intended UI text
|
* Only render **business data** and intended UI text.
|
||||||
* Never display:
|
* Never display:
|
||||||
|
|
||||||
* "Updated successfully because..."
|
* "Updated successfully because..."
|
||||||
* "Changed X to Y"
|
* "Changed X to Y"
|
||||||
* "TODO", "NOTE", "DEBUG"
|
* "TODO", "NOTE", "DEBUG"
|
||||||
* Debug information must go to logs, not UI
|
|
||||||
* Separate internal data from API responses
|
* Debug information must go to logs, not UI.
|
||||||
|
* Separate internal data from API responses.
|
||||||
|
* Do not expose raw database column names in user-facing labels unless `DESIGN.md` explicitly defines that label.
|
||||||
|
|
||||||
Violations are considered critical errors.
|
Violations are considered critical errors.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data & API Design Rules
|
## Data, API, and i18n Rules
|
||||||
|
|
||||||
* Follow `DESIGN.md` as the **single source of truth**
|
* Follow `DESIGN.md` as the **single source of truth**.
|
||||||
* PostgreSQL:
|
* PostgreSQL:
|
||||||
|
|
||||||
* use `snake_case`
|
* use `snake_case`
|
||||||
* define proper primary/foreign keys
|
* define proper primary/foreign keys
|
||||||
|
* preserve existing audit columns on editable entities
|
||||||
|
* preserve `sort_order` behavior for sortable lists
|
||||||
* avoid premature optimization
|
* avoid premature optimization
|
||||||
|
|
||||||
* APIs:
|
* APIs:
|
||||||
|
|
||||||
* return only necessary fields
|
* return only necessary fields
|
||||||
* do not expose internal metadata
|
* do not expose password hashes, verification token hashes, session token hashes, or internal metadata
|
||||||
|
* expose editor attribution with only `id` and `displayName`
|
||||||
|
* keep API response shapes consistent with `frontend/src/services/api.ts`
|
||||||
|
|
||||||
|
* i18n:
|
||||||
|
|
||||||
|
* use `languages` and `entity_translations` for entity translations
|
||||||
|
* use `X-Locale` for localized API reads
|
||||||
|
* keep base `name` / `title` fields as the default-language source
|
||||||
|
* do not let localized editing overwrite the base field unintentionally
|
||||||
|
* include translations only where the current API shape already supports them
|
||||||
|
|
||||||
|
* Editing and audit:
|
||||||
|
|
||||||
|
* create/update/delete operations on Wiki content should record editor information
|
||||||
|
* detail pages should continue to support edit metadata and edit history
|
||||||
|
* delete or update behavior must not leak internal audit payloads to normal UI
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -96,11 +196,15 @@ Violations are considered critical errors.
|
|||||||
|
|
||||||
* Components: `PascalCase`
|
* Components: `PascalCase`
|
||||||
* Composables: `useXxx`
|
* Composables: `useXxx`
|
||||||
|
|
||||||
* General:
|
* General:
|
||||||
|
|
||||||
* variables/functions: `camelCase`
|
* variables/functions: `camelCase`
|
||||||
* Keep files focused and under reasonable length
|
* TypeScript types/interfaces: match existing local style
|
||||||
* Avoid duplication
|
|
||||||
|
* Keep files focused and under reasonable length.
|
||||||
|
* Avoid duplication.
|
||||||
|
* Prefer existing helper APIs and local patterns over introducing new abstractions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -110,10 +214,10 @@ This project is developed from WSL, but runtime validation is done through Docke
|
|||||||
|
|
||||||
Agent workflow:
|
Agent workflow:
|
||||||
|
|
||||||
* Run:
|
* Run when practical:
|
||||||
|
|
||||||
* lint
|
* `pnpm lint`
|
||||||
* typecheck
|
* `pnpm typecheck`
|
||||||
|
|
||||||
* Do NOT run tests in WSL.
|
* Do NOT run tests in WSL.
|
||||||
* Do NOT require local test execution before finishing a task.
|
* Do NOT require local test execution before finishing a task.
|
||||||
@@ -128,12 +232,13 @@ When adding tests is clearly useful, keep them focused and minimal, but do not e
|
|||||||
|
|
||||||
A task is complete ONLY IF:
|
A task is complete ONLY IF:
|
||||||
|
|
||||||
* Matches `DESIGN.md`
|
* Matches `DESIGN.md`.
|
||||||
* Minimal diff (no unrelated changes)
|
* Updates `DESIGN.md` when the implemented behavior changes product, API, schema, permission, route, or i18n expectations.
|
||||||
* No UI leaks of internal info
|
* Minimal diff, with no unrelated changes.
|
||||||
* Code is readable and concise
|
* No UI leaks of internal info.
|
||||||
* Passes lint/typecheck when practical
|
* Code is readable and concise.
|
||||||
* Docker runtime issues are handled from user-provided `docker compose up --build` output
|
* Passes lint/typecheck when practical.
|
||||||
|
* Docker runtime issues are handled from user-provided `docker compose up --build` output.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -143,6 +248,7 @@ A task is complete ONLY IF:
|
|||||||
* Over-engineering simple features
|
* Over-engineering simple features
|
||||||
* Creating unused files or abstractions
|
* Creating unused files or abstractions
|
||||||
* Mixing internal/debug data into UI
|
* Mixing internal/debug data into UI
|
||||||
|
* Exposing token/hash/internal audit data through public API responses
|
||||||
* Large, unfocused commits
|
* Large, unfocused commits
|
||||||
* Silent behavior changes outside scope
|
* Silent behavior changes outside scope
|
||||||
|
|
||||||
@@ -150,17 +256,7 @@ A task is complete ONLY IF:
|
|||||||
|
|
||||||
## When Unsure
|
## When Unsure
|
||||||
|
|
||||||
* Ask for clarification
|
* Ask for clarification.
|
||||||
* Do not guess requirements
|
* Do not guess requirements.
|
||||||
* Do not invent features not in `DESIGN.md`
|
* Do not invent features not in `DESIGN.md`.
|
||||||
|
* If current code and `DESIGN.md` disagree, call out the mismatch before changing behavior.
|
||||||
---
|
|
||||||
|
|
||||||
## Project Context
|
|
||||||
|
|
||||||
* Goal: Pokopia Wiki
|
|
||||||
* Stack:
|
|
||||||
|
|
||||||
* Frontend: Vue
|
|
||||||
* Backend: Node + PostgreSQL
|
|
||||||
* Infra: Docker
|
|
||||||
|
|||||||
628
DESIGN.md
628
DESIGN.md
@@ -1,155 +1,531 @@
|
|||||||
# Pokopia Wiki
|
# Pokopia Wiki
|
||||||
|
|
||||||
|
## 产品目标
|
||||||
|
|
||||||
|
- Pokopia Wiki 是一个面向 Pokopia 游戏资料的社区 Wiki。
|
||||||
|
- 所有人都可以浏览 Wiki 内容。
|
||||||
|
- 已注册并完成邮箱验证的用户可以创建、编辑、删除 Wiki 内容。
|
||||||
|
- 前台以 Pokemon、栖息地、物品、材料单、每日 CheckList、Life、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
|
||||||
|
- 管理入口用于维护全局配置、语言、列表排序和每日 CheckList。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- 后端:Postgresql
|
- Monorepo:pnpm workspace,Node.js >= 22,TypeScript。
|
||||||
- 前端:Vue
|
- 前端:Vue、Vite、Vue Router、Vue I18n、Iconify。
|
||||||
- 运维:Docker
|
- 后端:Node.js、Fastify、pg、PostgreSQL。
|
||||||
都要用最新的框架
|
- 运维:Docker / docker compose。
|
||||||
|
- 依赖版本遵循现有 `package.json`,新增依赖时优先使用当前主流稳定版本,并保持项目结构简单。
|
||||||
|
|
||||||
# 功能描述
|
## 全局设计原则
|
||||||
|
|
||||||
- 一个具有社区功能的 Pokopia 游戏 Wiki
|
- `DESIGN.md` 是产品行为、数据结构和 API 暴露边界的单一事实来源。
|
||||||
|
- API 只返回业务需要的字段,不返回密码、token hash、验证 token、内部调试字段或不必要的元数据。
|
||||||
|
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
|
||||||
|
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
|
||||||
|
- 列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序。
|
||||||
|
|
||||||
## 数据
|
## 国际化
|
||||||
|
|
||||||
|
- 前端使用 Vue I18n 管理界面文案,并通过 `X-Locale` 请求头告知后端当前语言。
|
||||||
|
- 前端当前语言保存在 `localStorage` 的 `pokopia_locale`。
|
||||||
|
- 后端默认语言为 `en`。
|
||||||
|
- 语言配置存储在 `languages`:
|
||||||
|
- `code`
|
||||||
|
- `name`
|
||||||
|
- `enabled`
|
||||||
|
- `is_default`
|
||||||
|
- `sort_order`
|
||||||
|
- 语言 code 格式为 `xx` 或 `xx-YY`,例如 `en`、`zh-CN`。
|
||||||
|
- 系统必须且只能有一个默认语言。
|
||||||
|
- 初始语言包含:
|
||||||
|
- `en`:English,默认语言
|
||||||
|
- `zh-CN`:简体中文
|
||||||
|
- 实体翻译存储在 `entity_translations`:
|
||||||
|
- `entity_type`
|
||||||
|
- `entity_id`
|
||||||
|
- `locale`
|
||||||
|
- `field_name`
|
||||||
|
- `value`
|
||||||
|
- 支持翻译的实体:
|
||||||
|
- Pokemon
|
||||||
|
- 特长
|
||||||
|
- Pokemon Types
|
||||||
|
- 喜欢的环境
|
||||||
|
- 喜欢的东西 / 标签
|
||||||
|
- 物品分类
|
||||||
|
- 物品用途
|
||||||
|
- 入手方式
|
||||||
|
- 物品
|
||||||
|
- 地图
|
||||||
|
- 栖息地
|
||||||
|
- 每日 CheckList Task
|
||||||
|
- Life 标签
|
||||||
|
- 支持翻译的字段:
|
||||||
|
- `name`
|
||||||
|
- `title`
|
||||||
|
- `details`:仅 Pokemon 介绍使用
|
||||||
|
- `genus`:仅 Pokemon Genus 使用
|
||||||
|
- 实体仍保留基础 `name`、`title`、`details` 或 `genus` 字段,默认语言内容以基础字段为准。
|
||||||
|
- API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。
|
||||||
|
- 编辑表单必须避免本地化 UI 覆盖基础名称;翻译字段只展示当前需要编辑的语言。
|
||||||
|
|
||||||
|
## 用户与认证
|
||||||
|
|
||||||
|
- 用户可注册:
|
||||||
|
- 邮箱
|
||||||
|
- 显示名
|
||||||
|
- 密码
|
||||||
|
- 邮箱保存为小写。
|
||||||
|
- 密码只保存 hash。
|
||||||
|
- 注册后必须通过邮箱验证。
|
||||||
|
- 邮件发送使用 Resend:
|
||||||
|
- `RESEND_API_KEY`
|
||||||
|
- `EMAIL_FROM`
|
||||||
|
- `APP_ORIGIN` 或 `FRONTEND_ORIGIN`
|
||||||
|
- 验证邮件包含一次性验证链接。
|
||||||
|
- 验证 token 只保存 hash,并带过期时间和使用状态。
|
||||||
|
- 只有邮箱已验证的用户可以登录。
|
||||||
|
- 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。
|
||||||
|
- 前端将登录 token 保存在 `localStorage` 的 `pokopia_auth_token`。
|
||||||
|
- 用户可退出登录,退出时删除对应 session。
|
||||||
|
- 对外用户字段只包含必要信息:
|
||||||
|
- 当前用户:`id`、`email`、`displayName`、`emailVerified`
|
||||||
|
- 编辑署名:`id`、`displayName`
|
||||||
|
|
||||||
|
## Community 编辑与审计
|
||||||
|
|
||||||
|
- 已验证用户可以通过前台或管理入口编辑 Wiki 内容。
|
||||||
|
- 新增、修改、删除 Wiki 内容时必须写入审计信息。
|
||||||
|
- 可编辑实体包含:
|
||||||
|
- Pokemon
|
||||||
|
- 栖息地
|
||||||
|
- 物品
|
||||||
|
- 材料单
|
||||||
|
- 每日 CheckList Task
|
||||||
|
- 全局配置项
|
||||||
|
- 主要可编辑表包含:
|
||||||
|
- `created_by_user_id`
|
||||||
|
- `updated_by_user_id`
|
||||||
|
- `created_at`
|
||||||
|
- `updated_at`
|
||||||
|
- `sort_order`
|
||||||
|
- 详细编辑历史存储在 `wiki_edit_logs`:
|
||||||
|
- `entity_type`
|
||||||
|
- `entity_id`
|
||||||
|
- `action`:`create` / `update` / `delete`
|
||||||
|
- `user_id`
|
||||||
|
- `changes`
|
||||||
|
- `created_at`
|
||||||
|
- 详情页展示最后编辑者、最后编辑时间和编辑历史面板。
|
||||||
|
- 编辑历史中的用户信息只展示必要署名,不暴露邮箱、token、hash 或内部元数据。
|
||||||
|
|
||||||
|
## 实体讨论
|
||||||
|
|
||||||
|
- Pokemon、物品、材料单、栖息地详情页支持讨论。
|
||||||
|
- 所有人都可以浏览实体讨论。
|
||||||
|
- 已注册并完成邮箱验证的用户可以发表评论,并回复顶层评论。
|
||||||
|
- 讨论回复只支持一层回复,不做无限嵌套。
|
||||||
|
- 评论作者可以删除自己的评论;删除后正文不再展示,已有回复保留在原位置。
|
||||||
|
- 被删除实体的讨论会随实体删除一并清理。
|
||||||
|
- 讨论按创建时间正序展示。
|
||||||
|
- 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||||
|
- API 对外只返回评论作者的 `id` 和 `displayName`。
|
||||||
|
- API 不返回邮箱、token/hash、内部调试字段、`deleted_at`、`deleted_by_user_id` 等内部删除字段。
|
||||||
|
|
||||||
|
## 全局配置数据
|
||||||
|
|
||||||
|
以下配置项都支持创建、编辑、删除、翻译和拖拽排序。
|
||||||
|
|
||||||
|
### 特长
|
||||||
|
|
||||||
|
- 名称
|
||||||
|
- 是否有掉落物:`has_item_drop`
|
||||||
|
- 已移除 `subcategory` 字段。
|
||||||
|
- 当特长允许掉落物时,Pokemon 编辑中可为该 Pokemon + 特长配置一个掉落物品。
|
||||||
|
|
||||||
|
### Pokemon Types
|
||||||
|
|
||||||
|
- 名称
|
||||||
|
- 用于 Pokemon 属性配置。
|
||||||
|
- Pokemon 可选择 1 到 2 个 Type,用于表达双属性。
|
||||||
|
|
||||||
|
### 喜欢的环境
|
||||||
|
|
||||||
|
- 名称
|
||||||
|
|
||||||
|
### 喜欢的东西 / 标签
|
||||||
|
|
||||||
|
- 名称
|
||||||
|
- 同时用于:
|
||||||
|
- Pokemon 喜欢的东西
|
||||||
|
- 物品标签
|
||||||
|
|
||||||
|
### 物品分类
|
||||||
|
|
||||||
|
- 名称
|
||||||
|
- 用于物品和材料单按结果物品分类展示。
|
||||||
|
|
||||||
|
### 物品用途
|
||||||
|
|
||||||
|
- 名称
|
||||||
|
- 物品用途可为空。
|
||||||
|
|
||||||
|
### 入手方式
|
||||||
|
|
||||||
|
- 名称
|
||||||
|
- 可关联到物品和材料单。
|
||||||
|
|
||||||
|
### 地图
|
||||||
|
|
||||||
|
- 名称
|
||||||
|
- 用于栖息地中 Pokemon 出现地点。
|
||||||
|
|
||||||
|
### Life 标签
|
||||||
|
|
||||||
|
- 名称
|
||||||
|
- 用于 Life Post 分类展示和 Feed 筛选。
|
||||||
|
|
||||||
|
## Pokemon
|
||||||
|
|
||||||
Pokemon 可配置:
|
Pokemon 可配置:
|
||||||
|
|
||||||
- ID
|
- ID
|
||||||
- 名字
|
|
||||||
- 特长(可多选,最多 2 个)
|
|
||||||
- 特长掉落物品(按 Pokemon + 特长 配置,单选物品)
|
|
||||||
- 喜欢的环境(单选)
|
|
||||||
- 喜欢的东西(可多选,最多 6 个)
|
|
||||||
- 出现的栖息地(可多选)
|
|
||||||
|
|
||||||
特长 可配置:
|
|
||||||
- 名称
|
- 名称
|
||||||
- 是否有掉落物
|
- Genus:可为空,支持翻译
|
||||||
|
- 介绍 / Details:可为空,支持翻译
|
||||||
|
- Height:默认输入 `ft/in`,可切换输入 `m`;详情页同时展示 `ft/in` 与 `m`
|
||||||
|
- Weight:默认输入磅 `lb`,可切换输入 `kg`;详情页同时展示 `lbs` 与 `kg`
|
||||||
|
- Height / Weight 换算结果四舍五入;`m` / `kg` 保留 2 位小数,`in` 取整数,`lb` 保留 1 位小数。
|
||||||
|
- Types:可多选,最多 2 个
|
||||||
|
- 喜欢的环境:单选
|
||||||
|
- 特长:可多选,最多 2 个
|
||||||
|
- 特长掉落物品:按 Pokemon + 特长配置,单选物品
|
||||||
|
- 喜欢的东西:可多选,最多 6 个
|
||||||
|
- 六维:
|
||||||
|
- HP
|
||||||
|
- Attack
|
||||||
|
- Defense
|
||||||
|
- Special Attack
|
||||||
|
- Special Defense
|
||||||
|
- Speed
|
||||||
|
- 出现的栖息地:由栖息地出现配置反向展示
|
||||||
|
- 翻译
|
||||||
|
- 排序
|
||||||
|
|
||||||
喜欢的环境 可配置:
|
Pokemon 编辑表单使用标签页组织字段:
|
||||||
- 名称
|
|
||||||
|
|
||||||
喜欢的东西(标签) 可配置:
|
- 基础标签页:
|
||||||
- 名称
|
- 第一行:ID、名称
|
||||||
|
- 第二行:喜欢的环境、特长
|
||||||
|
- 第三行:喜欢的东西
|
||||||
|
- 特长掉落物品随已选择且支持掉落物的特长显示
|
||||||
|
- Advance 标签页:
|
||||||
|
- 第一行:Genus
|
||||||
|
- 第二行:Details
|
||||||
|
- 第三行:Height / Weight,身高与体重控件在桌面端同一行展示
|
||||||
|
- 第四行:Types
|
||||||
|
- 第五行:六维 Stats
|
||||||
|
|
||||||
物品 可配置:
|
Pokemon 列表功能:
|
||||||
- 名称
|
|
||||||
- 分类
|
|
||||||
- 用途
|
|
||||||
- 入手方式(可多选)
|
|
||||||
- 客制化:
|
|
||||||
- 可染色
|
|
||||||
- 可双区染色
|
|
||||||
- 可改花纹
|
|
||||||
- 标签(多选)
|
|
||||||
|
|
||||||
材料单 可配置:
|
|
||||||
- 名称
|
|
||||||
- 入手方式(可多选)
|
|
||||||
- 需要材料(可多样,多数量)
|
|
||||||
|
|
||||||
物品 / 材料单分类:
|
|
||||||
- 名称
|
|
||||||
|
|
||||||
物品 / 材料单用途:
|
|
||||||
- 名称
|
|
||||||
|
|
||||||
入手方式 可配置:
|
|
||||||
- 名称
|
|
||||||
|
|
||||||
地图:
|
|
||||||
- 名称
|
|
||||||
|
|
||||||
栖息地:
|
|
||||||
- 名称
|
|
||||||
- 配方(物品,数量)
|
|
||||||
- 可出现的宝可梦(可多选)
|
|
||||||
|
|
||||||
列表顺序:
|
|
||||||
- 全局配置项、Pokemon、物品、材料单、地图、栖息地均可自定义排序
|
|
||||||
- 初始排序按创建时间旧到新
|
|
||||||
|
|
||||||
出现契机
|
|
||||||
- 时间:早晨 / 中午 / 傍晚 / 晚上
|
|
||||||
- 天气:晴天 / 阴天 / 雨天
|
|
||||||
- 稀有度:1 ~ 3 星
|
|
||||||
- 地图关联
|
|
||||||
|
|
||||||
每日 CheckList 可配置:
|
|
||||||
- Task
|
|
||||||
- Task 顺序
|
|
||||||
|
|
||||||
## 功能
|
|
||||||
|
|
||||||
- Pokemon 列表
|
|
||||||
- 搜索
|
- 搜索
|
||||||
- 筛选
|
- 按喜欢的环境筛选
|
||||||
- 特长(可多选,满足任意条件 / 满足全部条件)
|
- 按特长筛选:
|
||||||
- 喜欢的环境
|
- 满足任意条件
|
||||||
- 喜欢的东西(可多选,满足任意条件 / 满足全部条件)
|
- 满足全部条件
|
||||||
- Pokemon 详情页
|
- 按喜欢的东西筛选:
|
||||||
|
- 满足任意条件
|
||||||
|
- 满足全部条件
|
||||||
|
- 按自定义排序展示
|
||||||
|
|
||||||
|
Pokemon 详情页展示:
|
||||||
|
|
||||||
|
- 基本信息
|
||||||
|
- 主内容顶部按以下布局展示:
|
||||||
|
- 左上:Genus & Details;无区块标题;如有 Genus,先展示 Genus,再以分割线连接 Details 内容
|
||||||
|
- 左下:Height / Weight 与 Types 按 2:1 比例并排;Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开,每组按英制、分割线、公制、标签上下排列;Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示
|
||||||
|
- 右侧:六维 Stats
|
||||||
|
- 六维使用 ProgressBar 展示,最大值按 150 计算。
|
||||||
- 特长
|
- 特长
|
||||||
- 特长掉落物品
|
- 特长掉落物品
|
||||||
- 喜欢的环境
|
- 喜欢的环境
|
||||||
- 喜欢的东西
|
- 喜欢的东西
|
||||||
- 栖息地
|
- 相关 Pokemon:与关联喜欢的东西的物品在桌面端左右并排展示;按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
|
||||||
- 栖息地列表
|
- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示
|
||||||
- 栖息地详情页
|
- 出现的栖息地
|
||||||
|
- 最后编辑信息
|
||||||
|
- 讨论
|
||||||
|
- 编辑历史:通过详情页 Tabs 展示
|
||||||
|
|
||||||
|
## 物品
|
||||||
|
|
||||||
|
物品可配置:
|
||||||
|
|
||||||
|
- 名称
|
||||||
|
- 分类:必填
|
||||||
|
- 用途:可为空
|
||||||
|
- 入手方式:可多选
|
||||||
|
- 客制化:
|
||||||
|
- 可染色
|
||||||
|
- 可双区染色
|
||||||
|
- 可改花纹
|
||||||
|
- 无材料单:`no_recipe`
|
||||||
|
- 标签:使用喜欢的东西配置,可多选
|
||||||
|
- 翻译
|
||||||
|
- 排序
|
||||||
|
|
||||||
|
物品列表功能:
|
||||||
|
|
||||||
|
- 搜索
|
||||||
|
- 按分类展示为标签页
|
||||||
|
- 按用途筛选
|
||||||
|
- 按标签筛选
|
||||||
|
- 按自定义排序展示
|
||||||
|
|
||||||
|
物品详情页展示:
|
||||||
|
|
||||||
|
- 基本信息
|
||||||
|
- 分类
|
||||||
|
- 用途
|
||||||
|
- 入手方式
|
||||||
|
- 客制化
|
||||||
|
- 标签
|
||||||
|
- 关联材料单
|
||||||
|
- 作为材料出现的材料单
|
||||||
|
- 相关栖息地
|
||||||
|
- 相关 Pokemon 掉落
|
||||||
|
- 最后编辑信息
|
||||||
|
- 讨论
|
||||||
|
- 编辑历史
|
||||||
|
|
||||||
|
## 材料单
|
||||||
|
|
||||||
|
材料单与物品是一对一关系:
|
||||||
|
|
||||||
|
- 一个材料单必须关联一个结果物品。
|
||||||
|
- 一个物品最多只能有一个材料单。
|
||||||
|
- 标记为 `no_recipe` 的物品不能创建材料单。
|
||||||
|
- 材料单没有独立名称,展示名称来自结果物品。
|
||||||
|
|
||||||
|
材料单可配置:
|
||||||
|
|
||||||
|
- 结果物品
|
||||||
|
- 入手方式:可多选
|
||||||
|
- 需要材料:多项物品 + 数量
|
||||||
|
- 排序
|
||||||
|
|
||||||
|
材料单列表功能:
|
||||||
|
|
||||||
|
- 独立于物品列表展示
|
||||||
|
- 按结果物品分类展示
|
||||||
|
- 按自定义排序展示
|
||||||
|
|
||||||
|
材料单详情页展示:
|
||||||
|
|
||||||
|
- 结果物品
|
||||||
|
- 入手方式
|
||||||
|
- 需要材料列表
|
||||||
|
- 最后编辑信息
|
||||||
|
- 讨论
|
||||||
|
- 编辑历史
|
||||||
|
|
||||||
|
## 栖息地
|
||||||
|
|
||||||
|
栖息地可配置:
|
||||||
|
|
||||||
|
- 名称
|
||||||
|
- 配方:多项物品 + 数量
|
||||||
|
- 可出现的 Pokemon
|
||||||
|
- 翻译
|
||||||
|
- 排序
|
||||||
|
|
||||||
|
Pokemon 出现配置:
|
||||||
|
|
||||||
|
- Pokemon
|
||||||
|
- 地图:可多选
|
||||||
|
- 时间:可多选
|
||||||
|
- 早晨
|
||||||
|
- 中午
|
||||||
|
- 傍晚
|
||||||
|
- 晚上
|
||||||
|
- 天气:可多选
|
||||||
|
- 晴天
|
||||||
|
- 阴天
|
||||||
|
- 雨天
|
||||||
|
- 稀有度:1 到 3 星
|
||||||
|
|
||||||
|
栖息地列表功能:
|
||||||
|
|
||||||
|
- 按自定义排序展示
|
||||||
|
- 展示配方摘要和可能出现的 Pokemon 摘要
|
||||||
|
|
||||||
|
栖息地详情页展示:
|
||||||
|
|
||||||
- 配方列表
|
- 配方列表
|
||||||
- 可能出现的宝可梦列表
|
- 可能出现的 Pokemon 列表
|
||||||
- 出现时间
|
- 出现时间
|
||||||
- 出现天气
|
- 出现天气
|
||||||
- 稀有度
|
- 稀有度
|
||||||
- 出现的地图列表
|
- 出现的地图列表
|
||||||
- 物品 / 材料单列表
|
- 最后编辑信息
|
||||||
- 根据分类显示(标签页)
|
- 讨论
|
||||||
- 筛选
|
- 编辑历史
|
||||||
- 用途
|
|
||||||
- 标签
|
|
||||||
- 物品详情页
|
|
||||||
- 基本信息
|
|
||||||
- 用途
|
|
||||||
- 入手方式
|
|
||||||
- 自定义
|
|
||||||
- 可染色
|
|
||||||
- 可双区染色
|
|
||||||
- 可改花纹
|
|
||||||
- 材料单信息
|
|
||||||
- 入手方式
|
|
||||||
- 需要材料列表
|
|
||||||
- 标签
|
|
||||||
- 相关栖息地
|
|
||||||
- 相关 Pokemon 掉落
|
|
||||||
- 材料单详情页
|
|
||||||
- 基本信息
|
|
||||||
- 入手方式
|
|
||||||
- 需要材料列表
|
|
||||||
- 每日 CheckList
|
|
||||||
- 展示每日做什么
|
|
||||||
- 每个 Task 可勾选
|
|
||||||
- 每天自动清空勾选状态,不删除 Task
|
|
||||||
- 管理中可新增 Task 到列表
|
|
||||||
- 管理中可通过 Handle 拖曳排序
|
|
||||||
|
|
||||||
## 用户系统
|
## 每日 CheckList
|
||||||
|
|
||||||
- 用户可注册
|
每日 CheckList Task 可配置:
|
||||||
- 邮箱
|
|
||||||
- 显示名
|
|
||||||
- 密码
|
|
||||||
- 用户注册后需要通过邮箱验证
|
|
||||||
- 使用 Resend 发送验证邮件
|
|
||||||
- 邮件内包含验证链接
|
|
||||||
- 用户可登录
|
|
||||||
- 仅允许已验证邮箱的用户登录
|
|
||||||
- 登录后可获取当前用户信息
|
|
||||||
- 用户可退出登录
|
|
||||||
- API 只返回必要用户字段,不暴露密码、验证 token、会话 token 哈希或内部元数据
|
|
||||||
|
|
||||||
## Community 编辑
|
- Task 标题
|
||||||
|
- 翻译
|
||||||
|
- Task 顺序
|
||||||
|
|
||||||
- 所有人都可浏览 Wiki 内容
|
前台行为:
|
||||||
- 已注册并完成邮箱验证的用户都可编辑 Wiki 内容
|
|
||||||
- 每次创建、修改、删除 Wiki 内容都需要记录编辑者
|
- 展示每日要做的 Task。
|
||||||
- Wiki 内容展示最后编辑者和最后编辑时间
|
- 每个 Task 可勾选。
|
||||||
- 编辑署名只展示必要用户信息,不暴露邮箱、token、hash 或内部元数据
|
- 勾选状态保存在浏览器本地。
|
||||||
|
- 勾选状态按本地日期自动清空,不删除 Task。
|
||||||
|
- 已删除 Task 的本地勾选状态会自动清理。
|
||||||
|
|
||||||
|
管理行为:
|
||||||
|
|
||||||
|
- 已验证用户可新增、编辑、删除 Task。
|
||||||
|
- 已验证用户可通过 Handle 拖拽排序。
|
||||||
|
|
||||||
|
## Life
|
||||||
|
|
||||||
|
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 text NOT NULL CHECK (
|
||||||
entity_type IN (
|
entity_type IN (
|
||||||
'pokemon',
|
'pokemon',
|
||||||
|
'pokemon-types',
|
||||||
'skills',
|
'skills',
|
||||||
'environments',
|
'environments',
|
||||||
'favorite-things',
|
'favorite-things',
|
||||||
@@ -37,12 +38,13 @@ CREATE TABLE IF NOT EXISTS entity_translations (
|
|||||||
'items',
|
'items',
|
||||||
'maps',
|
'maps',
|
||||||
'habitats',
|
'habitats',
|
||||||
'daily-checklist-items'
|
'daily-checklist-items',
|
||||||
|
'life-tags'
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
entity_id integer NOT NULL,
|
entity_id integer NOT NULL,
|
||||||
locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
|
locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
|
||||||
field_name text NOT NULL CHECK (field_name IN ('name', 'title')),
|
field_name text NOT NULL CHECK (field_name IN ('name', 'title', 'details', 'genus')),
|
||||||
value text NOT NULL,
|
value text NOT NULL,
|
||||||
PRIMARY KEY (entity_type, entity_id, locale, field_name)
|
PRIMARY KEY (entity_type, entity_id, locale, field_name)
|
||||||
);
|
);
|
||||||
@@ -50,6 +52,27 @@ CREATE TABLE IF NOT EXISTS entity_translations (
|
|||||||
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
|
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
|
||||||
ON entity_translations (entity_type, entity_id, field_name, locale);
|
ON entity_translations (entity_type, entity_id, field_name, locale);
|
||||||
|
|
||||||
|
ALTER TABLE entity_translations DROP CONSTRAINT IF EXISTS entity_translations_entity_type_check;
|
||||||
|
ALTER TABLE entity_translations ADD CONSTRAINT entity_translations_entity_type_check CHECK (
|
||||||
|
entity_type IN (
|
||||||
|
'pokemon',
|
||||||
|
'pokemon-types',
|
||||||
|
'skills',
|
||||||
|
'environments',
|
||||||
|
'favorite-things',
|
||||||
|
'item-categories',
|
||||||
|
'item-usages',
|
||||||
|
'acquisition-methods',
|
||||||
|
'items',
|
||||||
|
'maps',
|
||||||
|
'habitats',
|
||||||
|
'daily-checklist-items',
|
||||||
|
'life-tags'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
ALTER TABLE entity_translations DROP CONSTRAINT IF EXISTS entity_translations_field_name_check;
|
||||||
|
ALTER TABLE entity_translations ADD CONSTRAINT entity_translations_field_name_check CHECK (field_name IN ('name', 'title', 'details', 'genus'));
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
email text NOT NULL UNIQUE,
|
email text NOT NULL UNIQUE,
|
||||||
@@ -98,6 +121,78 @@ CREATE TABLE IF NOT EXISTS daily_checklist_items (
|
|||||||
CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
|
CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
|
||||||
ON daily_checklist_items(sort_order, id);
|
ON daily_checklist_items(sort_order, id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS life_tags (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
|
||||||
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS life_posts (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
|
||||||
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE life_posts DROP COLUMN IF EXISTS link_url;
|
||||||
|
ALTER TABLE life_posts DROP COLUMN IF EXISTS link_title;
|
||||||
|
ALTER TABLE life_posts ADD COLUMN IF NOT EXISTS deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE life_posts ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS life_posts_created_at_idx
|
||||||
|
ON life_posts(created_at DESC, id DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS life_posts_active_created_at_idx
|
||||||
|
ON life_posts(created_at DESC, id DESC)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS life_post_tags (
|
||||||
|
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
|
||||||
|
tag_id integer NOT NULL REFERENCES life_tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (post_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS life_post_tags_tag_idx
|
||||||
|
ON life_post_tags(tag_id, post_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS life_post_comments (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
|
||||||
|
parent_comment_id integer REFERENCES life_post_comments(id) ON DELETE SET NULL,
|
||||||
|
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000),
|
||||||
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS life_post_comments_post_idx
|
||||||
|
ON life_post_comments(post_id, created_at, id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS life_post_comments_parent_idx
|
||||||
|
ON life_post_comments(parent_comment_id, created_at, id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS life_post_reactions (
|
||||||
|
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
|
||||||
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
reaction_type text NOT NULL CHECK (reaction_type IN ('like', 'helpful', 'fun', 'thanks')),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (post_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS life_post_reactions_post_idx
|
||||||
|
ON life_post_reactions(post_id, reaction_type);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS skills (
|
CREATE TABLE IF NOT EXISTS skills (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
@@ -115,13 +210,37 @@ CREATE TABLE IF NOT EXISTS favorite_things (
|
|||||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pokemon_types (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
name text NOT NULL UNIQUE,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon (
|
CREATE TABLE IF NOT EXISTS pokemon (
|
||||||
id integer PRIMARY KEY,
|
id integer PRIMARY KEY,
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
|
genus text NOT NULL DEFAULT '',
|
||||||
|
details text NOT NULL DEFAULT '',
|
||||||
|
height_inches double precision NOT NULL DEFAULT 0 CHECK (height_inches >= 0),
|
||||||
|
weight_pounds double precision NOT NULL DEFAULT 0 CHECK (weight_pounds >= 0),
|
||||||
environment_id integer NOT NULL REFERENCES environments(id),
|
environment_id integer NOT NULL REFERENCES environments(id),
|
||||||
|
hp integer NOT NULL DEFAULT 0 CHECK (hp >= 0),
|
||||||
|
attack integer NOT NULL DEFAULT 0 CHECK (attack >= 0),
|
||||||
|
defense integer NOT NULL DEFAULT 0 CHECK (defense >= 0),
|
||||||
|
special_attack integer NOT NULL DEFAULT 0 CHECK (special_attack >= 0),
|
||||||
|
special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0),
|
||||||
|
speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0),
|
||||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pokemon_pokemon_types (
|
||||||
|
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
||||||
|
type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE,
|
||||||
|
slot_order integer NOT NULL CHECK (slot_order BETWEEN 1 AND 2),
|
||||||
|
PRIMARY KEY (pokemon_id, type_id),
|
||||||
|
UNIQUE (pokemon_id, slot_order)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_skills (
|
CREATE TABLE IF NOT EXISTS pokemon_skills (
|
||||||
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
||||||
skill_id integer NOT NULL REFERENCES skills(id),
|
skill_id integer NOT NULL REFERENCES skills(id),
|
||||||
@@ -289,11 +408,33 @@ ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS created_at timestamptz NOT
|
|||||||
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
|
||||||
|
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS genus text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS height_inches double precision NOT NULL DEFAULT 0 CHECK (height_inches >= 0);
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS weight_pounds double precision NOT NULL DEFAULT 0 CHECK (weight_pounds >= 0);
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS hp integer NOT NULL DEFAULT 0 CHECK (hp >= 0);
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS attack integer NOT NULL DEFAULT 0 CHECK (attack >= 0);
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS defense integer NOT NULL DEFAULT 0 CHECK (defense >= 0);
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_attack integer NOT NULL DEFAULT 0 CHECK (special_attack >= 0);
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0);
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0);
|
||||||
|
|
||||||
|
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
|
||||||
|
|
||||||
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
@@ -367,6 +508,16 @@ SET sort_order = ordered.next_sort_order
|
|||||||
FROM ordered
|
FROM ordered
|
||||||
WHERE target.id = ordered.id;
|
WHERE target.id = ordered.id;
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
|
FROM pokemon_types
|
||||||
|
WHERE sort_order = 0
|
||||||
|
)
|
||||||
|
UPDATE pokemon_types target
|
||||||
|
SET sort_order = ordered.next_sort_order
|
||||||
|
FROM ordered
|
||||||
|
WHERE target.id = ordered.id;
|
||||||
|
|
||||||
WITH ordered AS (
|
WITH ordered AS (
|
||||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
FROM pokemon
|
FROM pokemon
|
||||||
@@ -377,6 +528,16 @@ SET sort_order = ordered.next_sort_order
|
|||||||
FROM ordered
|
FROM ordered
|
||||||
WHERE target.id = ordered.id;
|
WHERE target.id = ordered.id;
|
||||||
|
|
||||||
|
WITH ordered AS (
|
||||||
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
|
FROM life_tags
|
||||||
|
WHERE sort_order = 0
|
||||||
|
)
|
||||||
|
UPDATE life_tags target
|
||||||
|
SET sort_order = ordered.next_sort_order
|
||||||
|
FROM ordered
|
||||||
|
WHERE target.id = ordered.id;
|
||||||
|
|
||||||
WITH ordered AS (
|
WITH ordered AS (
|
||||||
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
|
||||||
FROM item_categories
|
FROM item_categories
|
||||||
@@ -450,7 +611,9 @@ WHERE target.id = ordered.id;
|
|||||||
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
|
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
|
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
|
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
|
CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sort_order, id);
|
CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);
|
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
|
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
|
||||||
@@ -476,3 +639,34 @@ CREATE INDEX IF NOT EXISTS wiki_edit_logs_entity_idx
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx
|
CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx
|
||||||
ON wiki_edit_logs(user_id);
|
ON wiki_edit_logs(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS entity_discussion_comments (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats')),
|
||||||
|
entity_id integer NOT NULL,
|
||||||
|
parent_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
|
||||||
|
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000),
|
||||||
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
deleted_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE entity_discussion_comments DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check;
|
||||||
|
ALTER TABLE entity_discussion_comments ADD CONSTRAINT entity_discussion_comments_entity_type_check CHECK (
|
||||||
|
entity_type IN ('pokemon', 'items', 'recipes', 'habitats')
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE entity_discussion_comments ADD COLUMN IF NOT EXISTS deleted_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE entity_discussion_comments ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
|
||||||
|
ALTER TABLE entity_discussion_comments ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS entity_discussion_comments_entity_idx
|
||||||
|
ON entity_discussion_comments(entity_type, entity_id, created_at, id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS entity_discussion_comments_parent_idx
|
||||||
|
ON entity_discussion_comments(parent_comment_id, created_at, id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
|
||||||
|
ON entity_discussion_comments(created_by_user_id);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,16 +7,25 @@ import {
|
|||||||
cleanLocale,
|
cleanLocale,
|
||||||
createConfig,
|
createConfig,
|
||||||
createDailyChecklistItem,
|
createDailyChecklistItem,
|
||||||
|
createEntityDiscussionComment,
|
||||||
|
createEntityDiscussionReply,
|
||||||
createHabitat,
|
createHabitat,
|
||||||
createItem,
|
createItem,
|
||||||
createLanguage,
|
createLanguage,
|
||||||
|
createLifeComment,
|
||||||
|
createLifeCommentReply,
|
||||||
|
createLifePost,
|
||||||
createPokemon,
|
createPokemon,
|
||||||
createRecipe,
|
createRecipe,
|
||||||
deleteConfig,
|
deleteConfig,
|
||||||
deleteDailyChecklistItem,
|
deleteDailyChecklistItem,
|
||||||
|
deleteEntityDiscussionComment,
|
||||||
deleteHabitat,
|
deleteHabitat,
|
||||||
deleteItem,
|
deleteItem,
|
||||||
deleteLanguage,
|
deleteLanguage,
|
||||||
|
deleteLifeComment,
|
||||||
|
deleteLifePost,
|
||||||
|
deleteLifePostReaction,
|
||||||
deletePokemon,
|
deletePokemon,
|
||||||
deleteRecipe,
|
deleteRecipe,
|
||||||
getHabitat,
|
getHabitat,
|
||||||
@@ -25,11 +34,13 @@ import {
|
|||||||
getPokemon,
|
getPokemon,
|
||||||
getRecipe,
|
getRecipe,
|
||||||
isConfigType,
|
isConfigType,
|
||||||
|
listEntityDiscussionComments,
|
||||||
listConfig,
|
listConfig,
|
||||||
listDailyChecklistItems,
|
listDailyChecklistItems,
|
||||||
listHabitats,
|
listHabitats,
|
||||||
listItems,
|
listItems,
|
||||||
listLanguages,
|
listLanguages,
|
||||||
|
listLifePosts,
|
||||||
listPokemon,
|
listPokemon,
|
||||||
listRecipes,
|
listRecipes,
|
||||||
reorderConfig,
|
reorderConfig,
|
||||||
@@ -39,11 +50,13 @@ import {
|
|||||||
reorderLanguages,
|
reorderLanguages,
|
||||||
reorderPokemon,
|
reorderPokemon,
|
||||||
reorderRecipes,
|
reorderRecipes,
|
||||||
|
setLifePostReaction,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
updateDailyChecklistItem,
|
updateDailyChecklistItem,
|
||||||
updateHabitat,
|
updateHabitat,
|
||||||
updateItem,
|
updateItem,
|
||||||
updateLanguage,
|
updateLanguage,
|
||||||
|
updateLifePost,
|
||||||
updatePokemon,
|
updatePokemon,
|
||||||
updateRecipe
|
updateRecipe
|
||||||
} from './queries.ts';
|
} from './queries.ts';
|
||||||
@@ -137,6 +150,19 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function optionalUser(request: FastifyRequest): Promise<AuthUser | null> {
|
||||||
|
const token = getBearerToken(request.headers.authorization);
|
||||||
|
if (!token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await getUserBySessionToken(token);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.post('/api/auth/register', async (request, reply) =>
|
app.post('/api/auth/register', async (request, reply) =>
|
||||||
reply.code(201).send(await registerUser(request.body as Record<string, unknown>, requestLocale(request)))
|
reply.code(201).send(await registerUser(request.body as Record<string, unknown>, requestLocale(request)))
|
||||||
);
|
);
|
||||||
@@ -171,6 +197,147 @@ app.get('/api/options', async (request) => getOptions(requestLocale(request)));
|
|||||||
|
|
||||||
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
||||||
|
|
||||||
|
app.get('/api/life-posts', async (request) => {
|
||||||
|
const user = await optionalUser(request);
|
||||||
|
return listLifePosts(request.query as Record<string, string | string[] | undefined>, user?.id ?? null, requestLocale(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/life-posts', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user
|
||||||
|
? reply.code(201).send(await createLifePost(request.body as Record<string, unknown>, user.id, requestLocale(request)))
|
||||||
|
: undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/life-posts/:postId/comments', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { postId } = request.params as { postId: string };
|
||||||
|
const comment = await createLifeComment(Number(postId), request.body as Record<string, unknown>, user.id);
|
||||||
|
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { postId, commentId } = request.params as { postId: string; commentId: string };
|
||||||
|
const comment = await createLifeCommentReply(
|
||||||
|
Number(postId),
|
||||||
|
Number(commentId),
|
||||||
|
request.body as Record<string, unknown>,
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/life-posts/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const post = await updateLifePost(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
|
return post ? post : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/life-posts/:id/reaction', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const post = await setLifePostReaction(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
|
return post ? post : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const post = await deleteLifePostReaction(Number(id), user.id, requestLocale(request));
|
||||||
|
return post ? post : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/life-posts/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = await deleteLifePost(Number(id), user.id);
|
||||||
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/life-comments/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = await deleteLifeComment(Number(id), user.id);
|
||||||
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
|
||||||
|
const { entityType, entityId } = request.params as { entityType: string; entityId: string };
|
||||||
|
const comments = await listEntityDiscussionComments(entityType, Number(entityId));
|
||||||
|
return comments ? comments : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entityType, entityId } = request.params as { entityType: string; entityId: string };
|
||||||
|
const comment = await createEntityDiscussionComment(
|
||||||
|
entityType,
|
||||||
|
Number(entityId),
|
||||||
|
request.body as Record<string, unknown>,
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { entityType, entityId, commentId } = request.params as {
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
commentId: string;
|
||||||
|
};
|
||||||
|
const comment = await createEntityDiscussionReply(
|
||||||
|
entityType,
|
||||||
|
Number(entityId),
|
||||||
|
Number(commentId),
|
||||||
|
request.body as Record<string, unknown>,
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/discussions/comments/:id', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = await deleteEntityDiscussionComment(Number(id), user.id);
|
||||||
|
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/pokemon', async (request) =>
|
app.get('/api/pokemon', async (request) =>
|
||||||
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,20 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import AppShell from './components/AppShell.vue';
|
import AppShell from './components/AppShell.vue';
|
||||||
import { iconAdmin, iconChecklist, iconHabitat, iconItem, iconPokemon, iconRecipe } from './icons';
|
import {
|
||||||
|
iconAction,
|
||||||
|
iconAdmin,
|
||||||
|
iconChecklist,
|
||||||
|
iconClothes,
|
||||||
|
iconDish,
|
||||||
|
iconDreamIsland,
|
||||||
|
iconEvent,
|
||||||
|
iconHabitat,
|
||||||
|
iconItem,
|
||||||
|
iconLife,
|
||||||
|
iconPokemon,
|
||||||
|
iconRecipe
|
||||||
|
} from './icons';
|
||||||
import { getCurrentLocale, onLocaleChange, setCurrentLocale } from './i18n';
|
import { getCurrentLocale, onLocaleChange, setCurrentLocale } from './i18n';
|
||||||
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
|
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
|
||||||
|
|
||||||
@@ -18,12 +31,22 @@ const languages = ref<Language[]>([
|
|||||||
let removeAuthListener: (() => void) | null = null;
|
let removeAuthListener: (() => void) | null = null;
|
||||||
let removeLocaleListener: (() => void) | null = null;
|
let removeLocaleListener: (() => void) | null = null;
|
||||||
|
|
||||||
|
function inDevBadge() {
|
||||||
|
return { label: t('common.inDev'), tone: 'info' as const };
|
||||||
|
}
|
||||||
|
|
||||||
const navItems = computed(() => [
|
const navItems = computed(() => [
|
||||||
{ label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon },
|
{ label: t('nav.pokemon'), to: '/pokemon', icon: iconPokemon },
|
||||||
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
|
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
|
||||||
{ label: t('nav.items'), to: '/items', icon: iconItem },
|
{ label: t('nav.items'), to: '/items', icon: iconItem },
|
||||||
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
|
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
|
||||||
|
{ label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() },
|
||||||
|
{ label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() },
|
||||||
|
{ label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() },
|
||||||
|
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
|
||||||
|
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
|
||||||
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
|
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
|
||||||
|
{ label: t('nav.life'), to: '/life', icon: iconLife },
|
||||||
{ label: t('nav.admin'), to: '/admin', icon: iconAdmin }
|
{ label: t('nav.admin'), to: '/admin', icon: iconAdmin }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { iconLogin, iconLogout, iconRegister, iconTranslate, type AppIcon } from '../icons';
|
import { useRoute } from 'vue-router';
|
||||||
|
import { iconClose, iconLogin, iconLogout, iconMenu, iconRegister, iconTranslate, type AppIcon } from '../icons';
|
||||||
import type { AuthUser, Language } from '../services/api';
|
import type { AuthUser, Language } from '../services/api';
|
||||||
import PokeBallMark from './PokeBallMark.vue';
|
import PokeBallMark from './PokeBallMark.vue';
|
||||||
|
import StatusBadge from './StatusBadge.vue';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
currentUser: AuthUser | null;
|
currentUser: AuthUser | null;
|
||||||
languages: Language[];
|
languages: Language[];
|
||||||
locale: string;
|
locale: string;
|
||||||
navItems: Array<{ label: string; to: string; icon?: AppIcon }>;
|
navItems: Array<{
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
icon?: AppIcon;
|
||||||
|
badge?: {
|
||||||
|
label: string;
|
||||||
|
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
|
||||||
|
};
|
||||||
|
}>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -19,14 +29,26 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
const languageMenu = ref<HTMLElement | null>(null);
|
const languageMenu = ref<HTMLElement | null>(null);
|
||||||
const languageMenuButton = ref<HTMLButtonElement | null>(null);
|
const languageMenuButton = ref<HTMLButtonElement | null>(null);
|
||||||
const languageMenuOpen = ref(false);
|
const languageMenuOpen = ref(false);
|
||||||
|
const sidebarOpen = ref(false);
|
||||||
|
|
||||||
function closeLanguageMenu() {
|
function closeLanguageMenu() {
|
||||||
languageMenuOpen.value = false;
|
languageMenuOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeSidebar() {
|
||||||
|
sidebarOpen.value = false;
|
||||||
|
closeLanguageMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
sidebarOpen.value = !sidebarOpen.value;
|
||||||
|
closeLanguageMenu();
|
||||||
|
}
|
||||||
|
|
||||||
function toggleLanguageMenu() {
|
function toggleLanguageMenu() {
|
||||||
languageMenuOpen.value = !languageMenuOpen.value;
|
languageMenuOpen.value = !languageMenuOpen.value;
|
||||||
}
|
}
|
||||||
@@ -51,20 +73,57 @@ function onLanguageMenuKeydown(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestLogout() {
|
||||||
|
closeSidebar();
|
||||||
|
emit('logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNavActive(path: string) {
|
||||||
|
return route.path === path || route.path.startsWith(`${path}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(sidebarOpen, (open) => {
|
||||||
|
document.body.classList.toggle('lock-scroll', open);
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('pointerdown', onDocumentPointerDown);
|
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||||
|
document.body.classList.remove('lock-scroll');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app-shell">
|
<div class="app-shell" :class="{ 'app-shell--sidebar-open': sidebarOpen }">
|
||||||
<header class="site-header">
|
<header class="mobile-topbar">
|
||||||
<div class="container top-nav">
|
<button
|
||||||
<RouterLink class="brand-lockup" to="/pokemon" aria-label="Pokopia Wiki">
|
class="sidebar-toggle"
|
||||||
|
type="button"
|
||||||
|
:aria-label="sidebarOpen ? t('nav.closeMenu') : t('nav.openMenu')"
|
||||||
|
:aria-expanded="sidebarOpen"
|
||||||
|
aria-controls="app-sidebar"
|
||||||
|
@click="toggleSidebar"
|
||||||
|
>
|
||||||
|
<Icon :icon="sidebarOpen ? iconClose : iconMenu" class="ui-icon" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<RouterLink class="brand-lockup brand-lockup--mobile" to="/pokemon" aria-label="Pokopia Wiki" @click="closeSidebar">
|
||||||
|
<PokeBallMark size="34px" />
|
||||||
|
<span>
|
||||||
|
<span class="pokemon-word">Pokopia</span>
|
||||||
|
<span class="brand-subtitle">Community Wiki</span>
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<button class="site-sidebar-scrim" type="button" :aria-label="t('nav.closeMenu')" @click="closeSidebar"></button>
|
||||||
|
|
||||||
|
<aside id="app-sidebar" class="site-sidebar" :aria-label="t('nav.main')">
|
||||||
|
<div class="site-sidebar__inner">
|
||||||
|
<RouterLink class="brand-lockup" to="/pokemon" aria-label="Pokopia Wiki" @click="closeSidebar">
|
||||||
<PokeBallMark size="42px" />
|
<PokeBallMark size="42px" />
|
||||||
<span>
|
<span>
|
||||||
<span class="pokemon-word">Pokopia</span>
|
<span class="pokemon-word">Pokopia</span>
|
||||||
@@ -72,10 +131,24 @@ onBeforeUnmount(() => {
|
|||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<nav class="nav-links" :aria-label="t('nav.main')">
|
<nav class="side-nav" :aria-label="t('nav.main')">
|
||||||
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to">
|
<RouterLink
|
||||||
<Icon v-if="item.icon" :icon="item.icon" class="ui-icon nav-links__icon" aria-hidden="true" />
|
v-for="item in navItems"
|
||||||
{{ item.label }}
|
:key="item.to"
|
||||||
|
class="side-nav__link"
|
||||||
|
:class="{ 'router-link-active': isNavActive(item.to) }"
|
||||||
|
:to="item.to"
|
||||||
|
@click="closeSidebar"
|
||||||
|
>
|
||||||
|
<Icon v-if="item.icon" :icon="item.icon" class="ui-icon side-nav__icon" aria-hidden="true" />
|
||||||
|
<span class="side-nav__label">{{ item.label }}</span>
|
||||||
|
<StatusBadge
|
||||||
|
v-if="item.badge"
|
||||||
|
class="side-nav__badge"
|
||||||
|
:label="item.badge.label"
|
||||||
|
:tone="item.badge.tone"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -112,24 +185,24 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<template v-if="currentUser">
|
<template v-if="currentUser">
|
||||||
<span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span>
|
<span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span>
|
||||||
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="$emit('logout')">
|
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="requestLogout">
|
||||||
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('nav.logout') }}
|
{{ t('nav.logout') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<RouterLink class="ui-button ui-button--ghost ui-button--small" to="/login">
|
<RouterLink class="ui-button ui-button--ghost ui-button--small" to="/login" @click="closeSidebar">
|
||||||
<Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('nav.login') }}
|
{{ t('nav.login') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/register">
|
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/register" @click="closeSidebar">
|
||||||
<Icon :icon="iconRegister" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconRegister" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('nav.register') }}
|
{{ t('nav.register') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</aside>
|
||||||
|
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ const changeLabelKeys: Record<string, string> = {
|
|||||||
Name: 'common.name',
|
Name: 'common.name',
|
||||||
名字: 'common.name',
|
名字: 'common.name',
|
||||||
名称: 'common.name',
|
名称: 'common.name',
|
||||||
|
Genus: 'pages.pokemon.genus',
|
||||||
|
Details: 'pages.pokemon.details',
|
||||||
|
介绍: 'pages.pokemon.details',
|
||||||
|
Height: 'pages.pokemon.height',
|
||||||
|
身高: 'pages.pokemon.height',
|
||||||
|
Weight: 'pages.pokemon.weight',
|
||||||
|
体重: 'pages.pokemon.weight',
|
||||||
|
Types: 'pages.pokemon.types',
|
||||||
|
属性: 'pages.pokemon.types',
|
||||||
|
Stats: 'pages.pokemon.statsTitle',
|
||||||
|
六维: 'pages.pokemon.statsTitle',
|
||||||
'Ideal Habitat': 'pages.pokemon.environment',
|
'Ideal Habitat': 'pages.pokemon.environment',
|
||||||
'Favorite environment': 'pages.pokemon.environment',
|
'Favorite environment': 'pages.pokemon.environment',
|
||||||
喜欢的环境: 'pages.pokemon.environment',
|
喜欢的环境: 'pages.pokemon.environment',
|
||||||
@@ -96,7 +107,7 @@ function formatDateTime(value: string): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="edit-history-panel" aria-labelledby="edit-history-panel-title">
|
<section class="edit-history-panel" aria-labelledby="edit-history-panel-title">
|
||||||
<div class="edit-history-panel__header">
|
<div class="edit-history-panel__header">
|
||||||
<h2 id="edit-history-panel-title">{{ t('history.title') }}</h2>
|
<h2 id="edit-history-panel-title">{{ t('history.title') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -163,5 +174,5 @@ function formatDateTime(value: string): string {
|
|||||||
</ol>
|
</ol>
|
||||||
<p v-else class="meta-line">{{ t('history.empty') }}</p>
|
<p v-else class="meta-line">{{ t('history.empty') }}</p>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
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">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type CSSProperties } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { iconCheck, iconChevronDown, iconClose } from '../icons';
|
import { iconCheck, iconChevronDown, iconClose } from '../icons';
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ type OptionRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type CandidateRow = { type: 'option'; id: string; value: string; label: string } | { type: 'create'; id: string };
|
type CandidateRow = { type: 'option'; id: string; value: string; label: string } | { type: 'create'; id: string };
|
||||||
|
type DropdownStrategy = 'absolute' | 'fixed';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -31,12 +32,14 @@ const props = withDefaults(
|
|||||||
allowCreate?: boolean;
|
allowCreate?: boolean;
|
||||||
creating?: boolean;
|
creating?: boolean;
|
||||||
createLabel?: string;
|
createLabel?: string;
|
||||||
|
dropdownStrategy?: DropdownStrategy;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
multiple: true,
|
multiple: true,
|
||||||
max: 0,
|
max: 0,
|
||||||
allowCreate: false,
|
allowCreate: false,
|
||||||
creating: false
|
creating: false,
|
||||||
|
dropdownStrategy: 'absolute'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -47,10 +50,14 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const root = ref<HTMLElement | null>(null);
|
const root = ref<HTMLElement | null>(null);
|
||||||
|
const trigger = ref<HTMLButtonElement | null>(null);
|
||||||
const searchInput = ref<HTMLInputElement | null>(null);
|
const searchInput = ref<HTMLInputElement | null>(null);
|
||||||
const isOpen = ref(false);
|
const isOpen = ref(false);
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
const activeIndex = ref(-1);
|
const activeIndex = ref(-1);
|
||||||
|
const dropdownStyle = ref<CSSProperties>({});
|
||||||
|
const dropdownPlacement = ref<'top' | 'bottom'>('bottom');
|
||||||
|
let positionFrame = 0;
|
||||||
|
|
||||||
const optionRows = computed(() =>
|
const optionRows = computed(() =>
|
||||||
props.options.map((option, index) => ({
|
props.options.map((option, index) => ({
|
||||||
@@ -104,6 +111,7 @@ const candidateRows = computed<CandidateRow[]>(() => {
|
|||||||
});
|
});
|
||||||
const activeCandidate = computed(() => candidateRows.value[activeIndex.value]);
|
const activeCandidate = computed(() => candidateRows.value[activeIndex.value]);
|
||||||
const activeDescendant = computed(() => activeCandidate.value?.id);
|
const activeDescendant = computed(() => activeCandidate.value?.id);
|
||||||
|
const usesFixedDropdown = computed(() => props.dropdownStrategy === 'fixed');
|
||||||
|
|
||||||
function setDefaultActiveIndex() {
|
function setDefaultActiveIndex() {
|
||||||
const keyword = createName.value.toLowerCase();
|
const keyword = createName.value.toLowerCase();
|
||||||
@@ -130,6 +138,8 @@ function clampActiveIndex() {
|
|||||||
async function openDropdown() {
|
async function openDropdown() {
|
||||||
isOpen.value = true;
|
isOpen.value = true;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
updateDropdownPosition();
|
||||||
|
addPositionListeners();
|
||||||
setDefaultActiveIndex();
|
setDefaultActiveIndex();
|
||||||
searchInput.value?.focus();
|
searchInput.value?.focus();
|
||||||
}
|
}
|
||||||
@@ -138,6 +148,8 @@ function closeDropdown() {
|
|||||||
isOpen.value = false;
|
isOpen.value = false;
|
||||||
search.value = '';
|
search.value = '';
|
||||||
activeIndex.value = -1;
|
activeIndex.value = -1;
|
||||||
|
dropdownStyle.value = {};
|
||||||
|
removePositionListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDropdown() {
|
function toggleDropdown() {
|
||||||
@@ -168,11 +180,13 @@ function selectOption(value: string) {
|
|||||||
updateValue([...modelValues.value, value]);
|
updateValue([...modelValues.value, value]);
|
||||||
search.value = '';
|
search.value = '';
|
||||||
setDefaultActiveIndex();
|
setDefaultActiveIndex();
|
||||||
|
scheduleDropdownPositionUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(value: string) {
|
function remove(value: string) {
|
||||||
updateValue(modelValues.value.filter((item) => item !== value));
|
updateValue(modelValues.value.filter((item) => item !== value));
|
||||||
|
scheduleDropdownPositionUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOption() {
|
function createOption() {
|
||||||
@@ -225,22 +239,107 @@ function onDocumentPointerDown(event: PointerEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleDropdownPositionUpdate() {
|
||||||
|
if (!usesFixedDropdown.value || !isOpen.value || positionFrame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
positionFrame = window.requestAnimationFrame(() => {
|
||||||
|
positionFrame = 0;
|
||||||
|
updateDropdownPosition();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDropdownPosition() {
|
||||||
|
if (!usesFixedDropdown.value || !isOpen.value || !trigger.value) {
|
||||||
|
dropdownStyle.value = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportPadding = 12;
|
||||||
|
const dropdownGap = 6;
|
||||||
|
const dropdownChromeHeight = 72;
|
||||||
|
const triggerRect = trigger.value.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const width = Math.min(triggerRect.width, viewportWidth - viewportPadding * 2);
|
||||||
|
const left = Math.min(Math.max(triggerRect.left, viewportPadding), viewportWidth - width - viewportPadding);
|
||||||
|
const spaceBelow = viewportHeight - triggerRect.bottom - viewportPadding - dropdownGap;
|
||||||
|
const spaceAbove = triggerRect.top - viewportPadding - dropdownGap;
|
||||||
|
const placeAbove = spaceBelow < 220 && spaceAbove > spaceBelow;
|
||||||
|
const availableSpace = Math.max(144, placeAbove ? spaceAbove : spaceBelow);
|
||||||
|
const optionsMaxHeight = Math.max(96, Math.min(240, availableSpace - dropdownChromeHeight));
|
||||||
|
const nextStyle = {
|
||||||
|
left: `${left}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
'--tags-select-options-max-height': `${optionsMaxHeight}px`
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
|
if (placeAbove) {
|
||||||
|
dropdownPlacement.value = 'top';
|
||||||
|
dropdownStyle.value = {
|
||||||
|
...nextStyle,
|
||||||
|
bottom: `${viewportHeight - triggerRect.top + dropdownGap}px`
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdownPlacement.value = 'bottom';
|
||||||
|
dropdownStyle.value = {
|
||||||
|
...nextStyle,
|
||||||
|
top: `${triggerRect.bottom + dropdownGap}px`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPositionListeners() {
|
||||||
|
if (!usesFixedDropdown.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', scheduleDropdownPositionUpdate);
|
||||||
|
window.addEventListener('scroll', scheduleDropdownPositionUpdate, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePositionListeners() {
|
||||||
|
window.removeEventListener('resize', scheduleDropdownPositionUpdate);
|
||||||
|
window.removeEventListener('scroll', scheduleDropdownPositionUpdate, true);
|
||||||
|
|
||||||
|
if (positionFrame) {
|
||||||
|
window.cancelAnimationFrame(positionFrame);
|
||||||
|
positionFrame = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('pointerdown', onDocumentPointerDown);
|
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||||
|
removePositionListeners();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(search, setDefaultActiveIndex);
|
watch(search, setDefaultActiveIndex);
|
||||||
watch(candidateRows, clampActiveIndex);
|
watch(candidateRows, clampActiveIndex);
|
||||||
|
watch(
|
||||||
|
() => props.dropdownStrategy,
|
||||||
|
() => {
|
||||||
|
if (!isOpen.value) return;
|
||||||
|
|
||||||
|
removePositionListeners();
|
||||||
|
void nextTick(() => {
|
||||||
|
updateDropdownPosition();
|
||||||
|
addPositionListeners();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="root" class="tags-select" :class="{ 'tags-select--single': !multiple }" @keydown="onRootKeydown">
|
<div ref="root" class="tags-select" :class="{ 'tags-select--single': !multiple }" @keydown="onRootKeydown">
|
||||||
<button
|
<button
|
||||||
:id="id"
|
:id="id"
|
||||||
|
ref="trigger"
|
||||||
type="button"
|
type="button"
|
||||||
class="tags-select__trigger"
|
class="tags-select__trigger"
|
||||||
:class="{ open: isOpen }"
|
:class="{ open: isOpen }"
|
||||||
@@ -271,7 +370,15 @@ watch(candidateRows, clampActiveIndex);
|
|||||||
<Icon :icon="iconChevronDown" class="tags-select__arrow" aria-hidden="true" />
|
<Icon :icon="iconChevronDown" class="tags-select__arrow" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="isOpen" class="tags-select__dropdown">
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="tags-select__dropdown"
|
||||||
|
:class="{
|
||||||
|
'tags-select__dropdown--fixed': usesFixedDropdown,
|
||||||
|
'tags-select__dropdown--top': usesFixedDropdown && dropdownPlacement === 'top'
|
||||||
|
}"
|
||||||
|
:style="dropdownStyle"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
v-model="search"
|
v-model="search"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const props = defineProps<{
|
|||||||
translations: TranslationMap;
|
translations: TranslationMap;
|
||||||
languages: Language[];
|
languages: Language[];
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
multiline?: boolean;
|
||||||
|
rows?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -79,11 +81,20 @@ function updateField(language: Language, value: string) {
|
|||||||
{{ t('common.fieldForLanguage', { field: label, language: currentLanguage.name }) }}
|
{{ t('common.fieldForLanguage', { field: label, language: currentLanguage.name }) }}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
v-if="!multiline"
|
||||||
:id="`${idPrefix}-${currentLanguage.code}`"
|
:id="`${idPrefix}-${currentLanguage.code}`"
|
||||||
v-model="currentValue"
|
v-model="currentValue"
|
||||||
:placeholder="currentPlaceholder"
|
:placeholder="currentPlaceholder"
|
||||||
:required="currentRequired"
|
:required="currentRequired"
|
||||||
/>
|
/>
|
||||||
|
<textarea
|
||||||
|
v-else
|
||||||
|
:id="`${idPrefix}-${currentLanguage.code}`"
|
||||||
|
v-model="currentValue"
|
||||||
|
:placeholder="currentPlaceholder"
|
||||||
|
:required="currentRequired"
|
||||||
|
:rows="rows ?? 4"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const messages = {
|
|||||||
create: 'Create',
|
create: 'Create',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
edit: 'Edit',
|
edit: 'Edit',
|
||||||
|
details: 'Details',
|
||||||
filters: 'Filters',
|
filters: 'Filters',
|
||||||
loading: 'Loading',
|
loading: 'Loading',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
@@ -34,6 +35,7 @@ const messages = {
|
|||||||
noMatches: 'No matches',
|
noMatches: 'No matches',
|
||||||
createNamed: 'Add "{name}"',
|
createNamed: 'Add "{name}"',
|
||||||
creating: 'Adding',
|
creating: 'Adding',
|
||||||
|
inDev: 'In-Dev',
|
||||||
removeNamed: 'Remove {name}',
|
removeNamed: 'Remove {name}',
|
||||||
quantity: 'Quantity',
|
quantity: 'Quantity',
|
||||||
required: 'Required'
|
required: 'Required'
|
||||||
@@ -43,9 +45,17 @@ const messages = {
|
|||||||
habitats: 'Habitats',
|
habitats: 'Habitats',
|
||||||
items: 'Items',
|
items: 'Items',
|
||||||
recipes: 'Recipes',
|
recipes: 'Recipes',
|
||||||
|
dish: 'Dish',
|
||||||
|
events: 'Events',
|
||||||
|
actions: 'Actions',
|
||||||
|
dreamIsland: 'Dream Island',
|
||||||
|
clothes: 'Clothes',
|
||||||
checklist: 'CheckList',
|
checklist: 'CheckList',
|
||||||
|
life: 'Life',
|
||||||
admin: 'Admin',
|
admin: 'Admin',
|
||||||
main: 'Main navigation',
|
main: 'Main navigation',
|
||||||
|
openMenu: 'Open navigation',
|
||||||
|
closeMenu: 'Close navigation',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
login: 'Log in',
|
login: 'Log in',
|
||||||
logout: 'Log out',
|
logout: 'Log out',
|
||||||
@@ -87,13 +97,43 @@ const messages = {
|
|||||||
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
|
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
|
||||||
detailKicker: 'Pokédex Detail',
|
detailKicker: 'Pokédex Detail',
|
||||||
editKicker: 'Pokédex Edit',
|
editKicker: 'Pokédex Edit',
|
||||||
editSubtitle: 'Maintain Pokemon profile, specialities, and favourites.',
|
editSubtitle: 'Maintain Pokemon profile, details, types, stats, specialities, and favourites.',
|
||||||
|
editSections: 'Pokemon edit sections',
|
||||||
|
editTabBasic: 'Basic',
|
||||||
|
editTabAdvance: 'Advance',
|
||||||
newTitle: 'New Pokemon',
|
newTitle: 'New Pokemon',
|
||||||
editTitle: 'Edit #{id} {name}',
|
editTitle: 'Edit #{id} {name}',
|
||||||
loadingList: 'Loading Pokemon list',
|
loadingList: 'Loading Pokemon list',
|
||||||
loadingDetail: 'Loading Pokemon detail',
|
loadingDetail: 'Loading Pokemon detail',
|
||||||
loadingEdit: 'Loading Pokemon editor',
|
loadingEdit: 'Loading Pokemon editor',
|
||||||
environmentPrefix: 'Ideal Habitat: {name}',
|
environmentPrefix: 'Ideal Habitat: {name}',
|
||||||
|
details: 'Details',
|
||||||
|
genus: 'Genus',
|
||||||
|
height: 'Height',
|
||||||
|
heightInput: 'Height (in)',
|
||||||
|
heightImperial: 'ft / in',
|
||||||
|
heightMetric: 'm',
|
||||||
|
feet: 'ft',
|
||||||
|
inches: 'in',
|
||||||
|
meters: 'm',
|
||||||
|
weight: 'Weight',
|
||||||
|
weightInput: 'Weight (lb)',
|
||||||
|
pounds: 'lb',
|
||||||
|
kilograms: 'kg',
|
||||||
|
measurements: 'Height & Weight',
|
||||||
|
types: 'Types',
|
||||||
|
typeOne: 'Type 1',
|
||||||
|
typeTwo: 'Type 2',
|
||||||
|
typesAndStats: 'Types & Base stats',
|
||||||
|
statsTitle: 'Base stats',
|
||||||
|
stats: {
|
||||||
|
hp: 'HP',
|
||||||
|
attack: 'Attack',
|
||||||
|
defense: 'Defense',
|
||||||
|
specialAttack: 'Special Attack',
|
||||||
|
specialDefense: 'Special Defense',
|
||||||
|
speed: 'Speed'
|
||||||
|
},
|
||||||
environment: 'Ideal Habitat',
|
environment: 'Ideal Habitat',
|
||||||
skills: 'Specialities',
|
skills: 'Specialities',
|
||||||
skillMatchMode: 'Speciality match mode',
|
skillMatchMode: 'Speciality match mode',
|
||||||
@@ -105,10 +145,13 @@ const messages = {
|
|||||||
skillDrop: '{name} drop',
|
skillDrop: '{name} drop',
|
||||||
dropItem: 'Drop item',
|
dropItem: 'Drop item',
|
||||||
searchPokemon: 'Search Pokemon',
|
searchPokemon: 'Search Pokemon',
|
||||||
|
relatedPokemon: 'Related Pokemon',
|
||||||
|
relatedHabitat: 'Related Pokemon habitat',
|
||||||
relatedItems: 'Related items',
|
relatedItems: 'Related items',
|
||||||
relatedItemCategory: 'Related item category',
|
relatedItemCategory: 'Related item category',
|
||||||
habitats: 'Habitats',
|
habitats: 'Habitats',
|
||||||
namePlaceholder: 'Name',
|
namePlaceholder: 'Name',
|
||||||
|
searchTypes: 'Search types',
|
||||||
searchEnvironment: 'Search ideal habitats',
|
searchEnvironment: 'Search ideal habitats',
|
||||||
searchSkills: 'Search specialities',
|
searchSkills: 'Search specialities',
|
||||||
searchFavoriteThings: 'Search favourites',
|
searchFavoriteThings: 'Search favourites',
|
||||||
@@ -182,6 +225,68 @@ const messages = {
|
|||||||
materials: 'Materials',
|
materials: 'Materials',
|
||||||
addMaterial: 'Add material'
|
addMaterial: 'Add material'
|
||||||
},
|
},
|
||||||
|
comingSoon: {
|
||||||
|
status: 'In development',
|
||||||
|
heading: 'This wiki section is being prepared.',
|
||||||
|
previewLabel: 'Section preview',
|
||||||
|
sections: {
|
||||||
|
dish: {
|
||||||
|
kicker: 'Dish',
|
||||||
|
title: 'Dish',
|
||||||
|
subtitle: 'A future home for cooked dishes and food discoveries.',
|
||||||
|
body: 'Dish pages are being shaped for clear browsing, source notes, and useful ingredient links.',
|
||||||
|
preview: {
|
||||||
|
one: 'Dish records will focus on names, effects, and discovery context.',
|
||||||
|
two: 'Ingredient relationships will connect back to items and recipes where useful.',
|
||||||
|
three: 'The page will stay browse-first so community edits can grow naturally.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
kicker: 'Events',
|
||||||
|
title: 'Events',
|
||||||
|
subtitle: 'Seasonal and limited-time game activity records are coming later.',
|
||||||
|
body: 'Events will collect timing, rewards, and participation details once the section is ready.',
|
||||||
|
preview: {
|
||||||
|
one: 'Event cards will make dates and active windows easy to scan.',
|
||||||
|
two: 'Rewards and related items will sit close to the event summary.',
|
||||||
|
three: 'Archived activities will remain readable after they end.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
kicker: 'Actions',
|
||||||
|
title: 'Actions',
|
||||||
|
subtitle: 'Game shortcut actions such as waving and dancing will be documented here.',
|
||||||
|
body: 'Actions are being prepared as a quick reference for expressive in-game gestures and shortcuts.',
|
||||||
|
preview: {
|
||||||
|
one: 'Each action will describe the gesture or shortcut in player-facing language.',
|
||||||
|
two: 'Common examples include waving, dancing, and other social actions.',
|
||||||
|
three: 'Related unlock or usage details can be linked when the data model is ready.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dreamIsland: {
|
||||||
|
kicker: 'Dream Island',
|
||||||
|
title: 'Dream Island',
|
||||||
|
subtitle: 'Dream Island information is being organized for future browsing.',
|
||||||
|
body: 'This area will present island details with a calm, destination-style layout when content is ready.',
|
||||||
|
preview: {
|
||||||
|
one: 'Island notes will prioritize location, availability, and notable discoveries.',
|
||||||
|
two: 'Related Pokemon, items, or activities can be connected from the page.',
|
||||||
|
three: 'The layout will support browsing without adding another management flow yet.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clothes: {
|
||||||
|
kicker: 'Clothes',
|
||||||
|
title: 'Clothes',
|
||||||
|
subtitle: 'Outfit and clothing references are being prepared.',
|
||||||
|
body: 'Clothes pages will make it easy to compare appearance, acquisition, and customization details.',
|
||||||
|
preview: {
|
||||||
|
one: 'Clothing entries will focus on display names and visual categories.',
|
||||||
|
two: 'Acquisition and customization details can be connected when available.',
|
||||||
|
three: 'The page will keep item-like details readable without mixing them into the item list.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
checklist: {
|
checklist: {
|
||||||
title: 'Daily checklist',
|
title: 'Daily checklist',
|
||||||
subtitle: 'See what can be completed each day.',
|
subtitle: 'See what can be completed each day.',
|
||||||
@@ -192,6 +297,79 @@ const messages = {
|
|||||||
newTask: 'New task',
|
newTask: 'New task',
|
||||||
editTask: 'Edit task'
|
editTask: 'Edit task'
|
||||||
},
|
},
|
||||||
|
life: {
|
||||||
|
title: 'Life',
|
||||||
|
subtitle: 'Share favourite thoughts, tips, and community finds.',
|
||||||
|
kicker: 'Community Feed',
|
||||||
|
composerTitle: 'Share something',
|
||||||
|
composerPrompt: 'What would you like to share?',
|
||||||
|
bodyLabel: 'Post',
|
||||||
|
bodyPlaceholder: 'Share a thought, tip, or discovery...',
|
||||||
|
newPost: 'New Post',
|
||||||
|
tags: 'Tags',
|
||||||
|
allTags: 'All',
|
||||||
|
tagPlaceholder: 'Select tags',
|
||||||
|
searchTags: 'Search tags',
|
||||||
|
search: 'Search Life',
|
||||||
|
searchPlaceholder: 'Search post content...',
|
||||||
|
clearSearch: 'Clear search',
|
||||||
|
searchEmpty: 'No posts match your search',
|
||||||
|
searchEmptyHint: 'Try another keyword or clear the search.',
|
||||||
|
comments: 'Comments',
|
||||||
|
commentsCount: '{count} comments',
|
||||||
|
comment: 'Comment',
|
||||||
|
hideComments: 'Hide comments',
|
||||||
|
react: 'Like',
|
||||||
|
reactions: 'Reactions',
|
||||||
|
reactionsCount: '{count} reactions',
|
||||||
|
reactionCountLabel: '{reaction}: {count}',
|
||||||
|
reactionLike: 'Like',
|
||||||
|
reactionHelpful: 'Helpful',
|
||||||
|
reactionFun: 'Fun',
|
||||||
|
reactionThanks: 'Thanks',
|
||||||
|
chooseReaction: 'Choose reaction',
|
||||||
|
reactionMenu: 'Reaction menu',
|
||||||
|
removeReaction: 'Remove reaction',
|
||||||
|
reactionFailed: 'Reaction failed',
|
||||||
|
commentPlaceholder: 'Write a comment...',
|
||||||
|
commentReplyPlaceholder: 'Write a reply...',
|
||||||
|
postComment: 'Post comment',
|
||||||
|
postingComment: 'Posting comment',
|
||||||
|
reply: 'Reply',
|
||||||
|
postReply: 'Post reply',
|
||||||
|
postingReply: 'Posting reply',
|
||||||
|
cancelReply: 'Cancel reply',
|
||||||
|
noComments: 'No comments yet',
|
||||||
|
deleteComment: 'Delete comment',
|
||||||
|
deleteCommentConfirm: 'Delete this comment?',
|
||||||
|
commentDeleted: 'Comment deleted',
|
||||||
|
commentRequired: 'Please enter a comment.',
|
||||||
|
commentFailed: 'Comment failed',
|
||||||
|
replyFailed: 'Reply failed',
|
||||||
|
deleteCommentFailed: 'Delete comment failed',
|
||||||
|
publish: 'Post',
|
||||||
|
publishing: 'Posting',
|
||||||
|
update: 'Update',
|
||||||
|
updating: 'Updating',
|
||||||
|
cancelEdit: 'Cancel edit',
|
||||||
|
empty: 'No posts yet',
|
||||||
|
emptyHint: 'Verified members can start the first Life post.',
|
||||||
|
loading: 'Loading Life feed',
|
||||||
|
retryFeed: 'Retry loading',
|
||||||
|
loginPrompt: 'Log in with a verified email to post.',
|
||||||
|
verifyPrompt: 'Complete email verification to post.',
|
||||||
|
editPost: 'Edit post',
|
||||||
|
deletePost: 'Delete post',
|
||||||
|
saveEdit: 'Save edit',
|
||||||
|
postFailed: 'Post failed',
|
||||||
|
saveFailed: 'Save failed',
|
||||||
|
deleteFailed: 'Delete failed',
|
||||||
|
bodyRequired: 'Please enter a post.',
|
||||||
|
byUnknown: 'Community member',
|
||||||
|
edited: 'Edited',
|
||||||
|
deleteConfirm: 'Delete this post?',
|
||||||
|
charactersLeft: '{count} characters left'
|
||||||
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: 'Admin',
|
title: 'Admin',
|
||||||
subtitle: 'Maintain system configuration and manage Wiki records.',
|
subtitle: 'Maintain system configuration and manage Wiki records.',
|
||||||
@@ -220,13 +398,15 @@ const messages = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
|
pokemonTypes: 'Pokemon Types',
|
||||||
skills: 'Specialities',
|
skills: 'Specialities',
|
||||||
environments: 'Ideal Habitats',
|
environments: 'Ideal Habitats',
|
||||||
favoriteThings: 'Favourites / tags',
|
favoriteThings: 'Favourites / tags',
|
||||||
itemCategories: 'Item categories',
|
itemCategories: 'Item categories',
|
||||||
itemUsages: 'Item usages',
|
itemUsages: 'Item usages',
|
||||||
acquisitionMethods: 'Acquisition methods',
|
acquisitionMethods: 'Acquisition methods',
|
||||||
maps: 'Maps'
|
maps: 'Maps',
|
||||||
|
lifeTags: 'Life tags'
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
time: 'Time',
|
time: 'Time',
|
||||||
@@ -257,6 +437,33 @@ const messages = {
|
|||||||
update: 'Edit',
|
update: 'Edit',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
empty: 'No edit history'
|
empty: 'No edit history'
|
||||||
|
},
|
||||||
|
discussion: {
|
||||||
|
title: 'Discussion',
|
||||||
|
count: '{count} comments',
|
||||||
|
comment: 'Comment',
|
||||||
|
commentPlaceholder: 'Write a comment...',
|
||||||
|
replyPlaceholder: 'Write a reply...',
|
||||||
|
postComment: 'Post comment',
|
||||||
|
postingComment: 'Posting comment',
|
||||||
|
reply: 'Reply',
|
||||||
|
postReply: 'Post reply',
|
||||||
|
postingReply: 'Posting reply',
|
||||||
|
cancelReply: 'Cancel reply',
|
||||||
|
deleteComment: 'Delete comment',
|
||||||
|
deleteConfirm: 'Delete this comment?',
|
||||||
|
deletedComment: 'Comment deleted',
|
||||||
|
commentRequired: 'Please enter a comment.',
|
||||||
|
commentFailed: 'Comment failed',
|
||||||
|
replyFailed: 'Reply failed',
|
||||||
|
deleteFailed: 'Delete failed',
|
||||||
|
loading: 'Loading discussion',
|
||||||
|
empty: 'No discussion yet',
|
||||||
|
emptyHint: 'Start a new discussion now.',
|
||||||
|
loginPrompt: 'Log in with a verified email to comment.',
|
||||||
|
verifyPrompt: 'Complete email verification to comment.',
|
||||||
|
byUnknown: 'Community member',
|
||||||
|
charactersLeft: '{count} characters left'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'zh-CN': {
|
'zh-CN': {
|
||||||
@@ -271,6 +478,7 @@ const messages = {
|
|||||||
create: '创建',
|
create: '创建',
|
||||||
delete: '删除',
|
delete: '删除',
|
||||||
edit: '编辑',
|
edit: '编辑',
|
||||||
|
details: '详情',
|
||||||
filters: '筛选',
|
filters: '筛选',
|
||||||
loading: '加载中',
|
loading: '加载中',
|
||||||
name: '名称',
|
name: '名称',
|
||||||
@@ -288,6 +496,7 @@ const messages = {
|
|||||||
noMatches: '没有匹配项',
|
noMatches: '没有匹配项',
|
||||||
createNamed: '添加「{name}」',
|
createNamed: '添加「{name}」',
|
||||||
creating: '添加中',
|
creating: '添加中',
|
||||||
|
inDev: '开发中',
|
||||||
removeNamed: '移除{name}',
|
removeNamed: '移除{name}',
|
||||||
quantity: '数量',
|
quantity: '数量',
|
||||||
required: '必填'
|
required: '必填'
|
||||||
@@ -297,9 +506,17 @@ const messages = {
|
|||||||
habitats: '栖息地',
|
habitats: '栖息地',
|
||||||
items: '物品',
|
items: '物品',
|
||||||
recipes: '材料单',
|
recipes: '材料单',
|
||||||
|
dish: '料理',
|
||||||
|
events: '活动',
|
||||||
|
actions: '动作',
|
||||||
|
dreamIsland: 'Dream Island',
|
||||||
|
clothes: '服装',
|
||||||
checklist: 'CheckList',
|
checklist: 'CheckList',
|
||||||
|
life: 'Life',
|
||||||
admin: '管理',
|
admin: '管理',
|
||||||
main: '主导航',
|
main: '主导航',
|
||||||
|
openMenu: '打开导航',
|
||||||
|
closeMenu: '关闭导航',
|
||||||
language: '语言',
|
language: '语言',
|
||||||
login: '登录',
|
login: '登录',
|
||||||
logout: '退出',
|
logout: '退出',
|
||||||
@@ -341,13 +558,43 @@ const messages = {
|
|||||||
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',
|
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',
|
||||||
detailKicker: 'Pokédex Detail',
|
detailKicker: 'Pokédex Detail',
|
||||||
editKicker: 'Pokédex Edit',
|
editKicker: 'Pokédex Edit',
|
||||||
editSubtitle: '维护 Pokemon 基本资料、特长和喜欢的东西。',
|
editSubtitle: '维护 Pokemon 介绍、属性、六维、特长和喜欢的东西。',
|
||||||
|
editSections: 'Pokemon 编辑分区',
|
||||||
|
editTabBasic: '基础',
|
||||||
|
editTabAdvance: '进阶',
|
||||||
newTitle: '新增 Pokemon',
|
newTitle: '新增 Pokemon',
|
||||||
editTitle: '编辑 #{id} {name}',
|
editTitle: '编辑 #{id} {name}',
|
||||||
loadingList: '正在加载 Pokemon 列表',
|
loadingList: '正在加载 Pokemon 列表',
|
||||||
loadingDetail: '正在加载 Pokemon 详情',
|
loadingDetail: '正在加载 Pokemon 详情',
|
||||||
loadingEdit: '正在加载 Pokemon 编辑内容',
|
loadingEdit: '正在加载 Pokemon 编辑内容',
|
||||||
environmentPrefix: '喜欢的环境:{name}',
|
environmentPrefix: '喜欢的环境:{name}',
|
||||||
|
details: '介绍',
|
||||||
|
genus: '分类',
|
||||||
|
height: '身高',
|
||||||
|
heightInput: '身高(in)',
|
||||||
|
heightImperial: 'ft / in',
|
||||||
|
heightMetric: 'm',
|
||||||
|
feet: 'ft',
|
||||||
|
inches: 'in',
|
||||||
|
meters: 'm',
|
||||||
|
weight: '体重',
|
||||||
|
weightInput: '体重(lb)',
|
||||||
|
pounds: 'lb',
|
||||||
|
kilograms: 'kg',
|
||||||
|
measurements: '身高与体重',
|
||||||
|
types: '属性',
|
||||||
|
typeOne: '属性 1',
|
||||||
|
typeTwo: '属性 2',
|
||||||
|
typesAndStats: '属性与六维',
|
||||||
|
statsTitle: '六维',
|
||||||
|
stats: {
|
||||||
|
hp: 'HP',
|
||||||
|
attack: '攻击',
|
||||||
|
defense: '防御',
|
||||||
|
specialAttack: '特攻',
|
||||||
|
specialDefense: '特防',
|
||||||
|
speed: '速度'
|
||||||
|
},
|
||||||
environment: '喜欢的环境',
|
environment: '喜欢的环境',
|
||||||
skills: '特长',
|
skills: '特长',
|
||||||
skillMatchMode: '特长匹配方式',
|
skillMatchMode: '特长匹配方式',
|
||||||
@@ -359,10 +606,13 @@ const messages = {
|
|||||||
skillDrop: '{name}掉落物',
|
skillDrop: '{name}掉落物',
|
||||||
dropItem: '掉落物',
|
dropItem: '掉落物',
|
||||||
searchPokemon: '搜索 Pokemon',
|
searchPokemon: '搜索 Pokemon',
|
||||||
|
relatedPokemon: '相关 Pokemon',
|
||||||
|
relatedHabitat: '相关 Pokemon 栖息地',
|
||||||
relatedItems: '关联物品',
|
relatedItems: '关联物品',
|
||||||
relatedItemCategory: '关联物品分类',
|
relatedItemCategory: '关联物品分类',
|
||||||
habitats: '栖息地',
|
habitats: '栖息地',
|
||||||
namePlaceholder: '名字',
|
namePlaceholder: '名字',
|
||||||
|
searchTypes: '搜索属性',
|
||||||
searchEnvironment: '搜索喜欢的环境',
|
searchEnvironment: '搜索喜欢的环境',
|
||||||
searchSkills: '搜索特长',
|
searchSkills: '搜索特长',
|
||||||
searchFavoriteThings: '搜索喜欢的东西',
|
searchFavoriteThings: '搜索喜欢的东西',
|
||||||
@@ -436,6 +686,68 @@ const messages = {
|
|||||||
materials: '需要材料',
|
materials: '需要材料',
|
||||||
addMaterial: '添加材料'
|
addMaterial: '添加材料'
|
||||||
},
|
},
|
||||||
|
comingSoon: {
|
||||||
|
status: '正在开发中',
|
||||||
|
heading: '这个 Wiki 分区正在准备中。',
|
||||||
|
previewLabel: '分区预览',
|
||||||
|
sections: {
|
||||||
|
dish: {
|
||||||
|
kicker: 'Dish',
|
||||||
|
title: '料理',
|
||||||
|
subtitle: '未来会用于整理料理和食物相关发现。',
|
||||||
|
body: '料理页面会围绕清晰浏览、来源记录和材料关联来设计。',
|
||||||
|
preview: {
|
||||||
|
one: '料理记录会优先呈现名称、效果和发现方式。',
|
||||||
|
two: '需要时会把材料关系连接回物品和材料单。',
|
||||||
|
three: '页面会先保持浏览友好,后续再自然承接社区编辑内容。'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
kicker: 'Events',
|
||||||
|
title: '活动',
|
||||||
|
subtitle: '季节活动和限时内容资料会在这里整理。',
|
||||||
|
body: '活动分区会在准备好后集中展示时间、奖励和参与信息。',
|
||||||
|
preview: {
|
||||||
|
one: '活动卡片会让日期和开放时间更容易浏览。',
|
||||||
|
two: '奖励与关联物品会靠近活动摘要展示。',
|
||||||
|
three: '活动结束后,历史记录也会保持可读。'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
kicker: 'Actions',
|
||||||
|
title: '动作',
|
||||||
|
subtitle: '挥手、跳舞等游戏内快捷动作会记录在这里。',
|
||||||
|
body: '动作分区会作为游戏内表情、社交动作和快捷动作的快速参考。',
|
||||||
|
preview: {
|
||||||
|
one: '每个动作会用面向玩家的语言说明动作或快捷方式。',
|
||||||
|
two: '常见内容包括挥手、跳舞和其他社交动作。',
|
||||||
|
three: '后续可在数据模型准备好后补充解锁或使用条件。'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dreamIsland: {
|
||||||
|
kicker: 'Dream Island',
|
||||||
|
title: 'Dream Island',
|
||||||
|
subtitle: 'Dream Island 相关资料正在整理。',
|
||||||
|
body: '这个区域未来会用更像目的地资料页的方式展示岛屿信息。',
|
||||||
|
preview: {
|
||||||
|
one: '岛屿记录会优先整理地点、开放状态和重要发现。',
|
||||||
|
two: '可关联的 Pokemon、物品或活动会从页面中连接出来。',
|
||||||
|
three: '目前先保持公开浏览入口,不额外增加管理流程。'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clothes: {
|
||||||
|
kicker: 'Clothes',
|
||||||
|
title: '服装',
|
||||||
|
subtitle: '外观和服装资料正在准备。',
|
||||||
|
body: '服装页面会用于对比外观、入手方式和自定义信息。',
|
||||||
|
preview: {
|
||||||
|
one: '服装条目会优先整理展示名称和视觉分类。',
|
||||||
|
two: '入手方式与自定义信息会在资料可用后接入。',
|
||||||
|
three: '页面会保持服装资料清晰,不和普通物品列表混在一起。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
checklist: {
|
checklist: {
|
||||||
title: '每日清单',
|
title: '每日清单',
|
||||||
subtitle: '查看每天可以完成的事项。',
|
subtitle: '查看每天可以完成的事项。',
|
||||||
@@ -446,6 +758,79 @@ const messages = {
|
|||||||
newTask: '新增 Task',
|
newTask: '新增 Task',
|
||||||
editTask: '编辑 Task'
|
editTask: '编辑 Task'
|
||||||
},
|
},
|
||||||
|
life: {
|
||||||
|
title: 'Life',
|
||||||
|
subtitle: '分享喜欢的心得、想法和社区发现。',
|
||||||
|
kicker: '社区动态',
|
||||||
|
composerTitle: '分享动态',
|
||||||
|
composerPrompt: '想分享什么?',
|
||||||
|
bodyLabel: '动态内容',
|
||||||
|
bodyPlaceholder: '分享一段想法、心得或发现……',
|
||||||
|
newPost: 'New Post',
|
||||||
|
tags: '标签',
|
||||||
|
allTags: '全部',
|
||||||
|
tagPlaceholder: '选择标签',
|
||||||
|
searchTags: '搜索标签',
|
||||||
|
search: '搜索动态',
|
||||||
|
searchPlaceholder: '搜索动态内容……',
|
||||||
|
clearSearch: '清除搜索',
|
||||||
|
searchEmpty: '没有匹配的动态',
|
||||||
|
searchEmptyHint: '换个关键词或清除搜索。',
|
||||||
|
comments: '评论',
|
||||||
|
commentsCount: '{count} 条评论',
|
||||||
|
comment: '评论',
|
||||||
|
hideComments: '收起评论',
|
||||||
|
react: '点赞',
|
||||||
|
reactions: '互动',
|
||||||
|
reactionsCount: '{count} 次互动',
|
||||||
|
reactionCountLabel: '{reaction}:{count}',
|
||||||
|
reactionLike: '喜欢',
|
||||||
|
reactionHelpful: '有帮助',
|
||||||
|
reactionFun: '有趣',
|
||||||
|
reactionThanks: '感谢',
|
||||||
|
chooseReaction: '选择互动',
|
||||||
|
reactionMenu: '互动菜单',
|
||||||
|
removeReaction: '取消互动',
|
||||||
|
reactionFailed: '互动失败',
|
||||||
|
commentPlaceholder: '写下评论……',
|
||||||
|
commentReplyPlaceholder: '写下回复……',
|
||||||
|
postComment: '发表评论',
|
||||||
|
postingComment: '评论中',
|
||||||
|
reply: '回复',
|
||||||
|
postReply: '发布回复',
|
||||||
|
postingReply: '回复中',
|
||||||
|
cancelReply: '取消回复',
|
||||||
|
noComments: '暂无评论',
|
||||||
|
deleteComment: '删除评论',
|
||||||
|
deleteCommentConfirm: '确认删除这条评论?',
|
||||||
|
commentDeleted: '评论已删除',
|
||||||
|
commentRequired: '请输入评论内容。',
|
||||||
|
commentFailed: '评论失败',
|
||||||
|
replyFailed: '回复失败',
|
||||||
|
deleteCommentFailed: '删除评论失败',
|
||||||
|
publish: '发布',
|
||||||
|
publishing: '发布中',
|
||||||
|
update: '更新',
|
||||||
|
updating: '更新中',
|
||||||
|
cancelEdit: '取消编辑',
|
||||||
|
empty: '暂无动态',
|
||||||
|
emptyHint: '已验证成员可以发布第一条 Life 动态。',
|
||||||
|
loading: '正在加载 Life 动态',
|
||||||
|
retryFeed: '重试加载',
|
||||||
|
loginPrompt: '使用已验证邮箱登录后即可发布。',
|
||||||
|
verifyPrompt: '完成邮箱验证后即可发布。',
|
||||||
|
editPost: '编辑动态',
|
||||||
|
deletePost: '删除动态',
|
||||||
|
saveEdit: '保存编辑',
|
||||||
|
postFailed: '发布失败',
|
||||||
|
saveFailed: '保存失败',
|
||||||
|
deleteFailed: '删除失败',
|
||||||
|
bodyRequired: '请输入动态内容。',
|
||||||
|
byUnknown: '社区成员',
|
||||||
|
edited: '已编辑',
|
||||||
|
deleteConfirm: '确认删除这条动态?',
|
||||||
|
charactersLeft: '还可以输入 {count} 个字符'
|
||||||
|
},
|
||||||
admin: {
|
admin: {
|
||||||
title: '管理',
|
title: '管理',
|
||||||
subtitle: '维护系统配置,查看并删除 Wiki 数据记录。',
|
subtitle: '维护系统配置,查看并删除 Wiki 数据记录。',
|
||||||
@@ -474,13 +859,15 @@ const messages = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
config: {
|
config: {
|
||||||
|
pokemonTypes: 'Pokemon 属性',
|
||||||
skills: '特长',
|
skills: '特长',
|
||||||
environments: '喜欢的环境',
|
environments: '喜欢的环境',
|
||||||
favoriteThings: '喜欢的东西 / 标签',
|
favoriteThings: '喜欢的东西 / 标签',
|
||||||
itemCategories: '物品分类',
|
itemCategories: '物品分类',
|
||||||
itemUsages: '物品用途',
|
itemUsages: '物品用途',
|
||||||
acquisitionMethods: '入手方式',
|
acquisitionMethods: '入手方式',
|
||||||
maps: '地图'
|
maps: '地图',
|
||||||
|
lifeTags: 'Life 标签'
|
||||||
},
|
},
|
||||||
appearance: {
|
appearance: {
|
||||||
time: '时段',
|
time: '时段',
|
||||||
@@ -511,6 +898,33 @@ const messages = {
|
|||||||
update: '编辑',
|
update: '编辑',
|
||||||
delete: '删除',
|
delete: '删除',
|
||||||
empty: '暂无编辑历史'
|
empty: '暂无编辑历史'
|
||||||
|
},
|
||||||
|
discussion: {
|
||||||
|
title: '讨论',
|
||||||
|
count: '{count} 条评论',
|
||||||
|
comment: '评论',
|
||||||
|
commentPlaceholder: '写下评论……',
|
||||||
|
replyPlaceholder: '写下回复……',
|
||||||
|
postComment: '发表评论',
|
||||||
|
postingComment: '评论中',
|
||||||
|
reply: '回复',
|
||||||
|
postReply: '发布回复',
|
||||||
|
postingReply: '回复中',
|
||||||
|
cancelReply: '取消回复',
|
||||||
|
deleteComment: '删除评论',
|
||||||
|
deleteConfirm: '确认删除这条评论?',
|
||||||
|
deletedComment: '评论已删除',
|
||||||
|
commentRequired: '请输入评论内容。',
|
||||||
|
commentFailed: '评论失败',
|
||||||
|
replyFailed: '回复失败',
|
||||||
|
deleteFailed: '删除失败',
|
||||||
|
loading: '正在加载讨论',
|
||||||
|
empty: '暂无讨论',
|
||||||
|
emptyHint: '现在发起新的讨论。',
|
||||||
|
loginPrompt: '使用已验证邮箱登录后即可评论。',
|
||||||
|
verifyPrompt: '完成邮箱验证后即可评论。',
|
||||||
|
byUnknown: '社区成员',
|
||||||
|
charactersLeft: '还可以输入 {count} 个字符'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,27 +2,41 @@ export type AppIcon = string;
|
|||||||
|
|
||||||
export const iconAdd: AppIcon = 'mdi:plus';
|
export const iconAdd: AppIcon = 'mdi:plus';
|
||||||
export const iconAdmin: AppIcon = 'mdi:tune-variant';
|
export const iconAdmin: AppIcon = 'mdi:tune-variant';
|
||||||
|
export const iconAction: AppIcon = 'mdi:gesture-tap-button';
|
||||||
export const iconBack: AppIcon = 'mdi:arrow-left';
|
export const iconBack: AppIcon = 'mdi:arrow-left';
|
||||||
export const iconCancel: AppIcon = 'mdi:close';
|
export const iconCancel: AppIcon = 'mdi:close';
|
||||||
export const iconCheck: AppIcon = 'mdi:check';
|
export const iconCheck: AppIcon = 'mdi:check';
|
||||||
export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
|
export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
|
||||||
export const iconChevronDown: AppIcon = 'mdi:chevron-down';
|
export const iconChevronDown: AppIcon = 'mdi:chevron-down';
|
||||||
export const iconClose: AppIcon = 'mdi:close';
|
export const iconClose: AppIcon = 'mdi:close';
|
||||||
|
export const iconComment: AppIcon = 'mdi:comment-outline';
|
||||||
export const iconDelete: AppIcon = 'mdi:trash-can-outline';
|
export const iconDelete: AppIcon = 'mdi:trash-can-outline';
|
||||||
|
export const iconDish: AppIcon = 'mdi:silverware-fork-knife';
|
||||||
export const iconDragHandle: AppIcon = 'mdi:drag';
|
export const iconDragHandle: AppIcon = 'mdi:drag';
|
||||||
|
export const iconDreamIsland: AppIcon = 'mdi:palm-tree';
|
||||||
export const iconEdit: AppIcon = 'mdi:pencil-outline';
|
export const iconEdit: AppIcon = 'mdi:pencil-outline';
|
||||||
export const iconError: AppIcon = 'mdi:close-circle-outline';
|
export const iconError: AppIcon = 'mdi:close-circle-outline';
|
||||||
|
export const iconEvent: AppIcon = 'mdi:calendar-star';
|
||||||
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
||||||
export const iconInfo: AppIcon = 'mdi:information-outline';
|
export const iconInfo: AppIcon = 'mdi:information-outline';
|
||||||
export const iconItem: AppIcon = 'mdi:bag-personal-outline';
|
export const iconItem: AppIcon = 'mdi:bag-personal-outline';
|
||||||
|
export const iconLife: AppIcon = 'mdi:post-outline';
|
||||||
|
export const iconClothes: AppIcon = 'mdi:tshirt-crew-outline';
|
||||||
export const iconLogin: AppIcon = 'mdi:login';
|
export const iconLogin: AppIcon = 'mdi:login';
|
||||||
export const iconLogout: AppIcon = 'mdi:logout';
|
export const iconLogout: AppIcon = 'mdi:logout';
|
||||||
export const iconMail: AppIcon = 'mdi:email-fast-outline';
|
export const iconMail: AppIcon = 'mdi:email-fast-outline';
|
||||||
|
export const iconMenu: AppIcon = 'mdi:menu';
|
||||||
export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
|
export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
|
||||||
export const iconPokemon: AppIcon = 'mdi:pokeball';
|
export const iconPokemon: AppIcon = 'mdi:pokeball';
|
||||||
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
|
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
|
||||||
export const iconRegister: AppIcon = 'mdi:account-plus-outline';
|
export const iconRegister: AppIcon = 'mdi:account-plus-outline';
|
||||||
|
export const iconReply: AppIcon = 'mdi:reply-outline';
|
||||||
|
export const iconReactionFun: AppIcon = 'mdi:party-popper';
|
||||||
|
export const iconReactionHelpful: AppIcon = 'mdi:lightbulb-on-outline';
|
||||||
|
export const iconReactionLike: AppIcon = 'mdi:thumb-up-outline';
|
||||||
|
export const iconReactionThanks: AppIcon = 'mdi:hand-heart-outline';
|
||||||
export const iconSave: AppIcon = 'mdi:content-save-outline';
|
export const iconSave: AppIcon = 'mdi:content-save-outline';
|
||||||
|
export const iconSearch: AppIcon = 'mdi:magnify';
|
||||||
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
||||||
export const iconTranslate: AppIcon = 'mdi:translate';
|
export const iconTranslate: AppIcon = 'mdi:translate';
|
||||||
export const iconWarning: AppIcon = 'mdi:alert-outline';
|
export const iconWarning: AppIcon = 'mdi:alert-outline';
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import ItemDetail from '../views/ItemDetail.vue';
|
|||||||
import RecipeList from '../views/RecipeList.vue';
|
import RecipeList from '../views/RecipeList.vue';
|
||||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||||
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||||
|
import LifeView from '../views/LifeView.vue';
|
||||||
|
import ComingSoonView from '../views/ComingSoonView.vue';
|
||||||
import AdminView from '../views/AdminView.vue';
|
import AdminView from '../views/AdminView.vue';
|
||||||
import LoginView from '../views/LoginView.vue';
|
import LoginView from '../views/LoginView.vue';
|
||||||
import RegisterView from '../views/RegisterView.vue';
|
import RegisterView from '../views/RegisterView.vue';
|
||||||
@@ -34,7 +36,13 @@ export const router = createRouter({
|
|||||||
{ path: '/recipes/new', name: 'recipe-new', component: RecipeList, meta: { requiresVerified: true, editorModal: true } },
|
{ path: '/recipes/new', name: 'recipe-new', component: RecipeList, meta: { requiresVerified: true, editorModal: true } },
|
||||||
{ path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiresVerified: true, editorModal: true } },
|
{ path: '/recipes/:id/edit', name: 'recipe-edit', component: RecipeDetail, meta: { requiresVerified: true, editorModal: true } },
|
||||||
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail },
|
{ path: '/recipes/:id', name: 'recipe-detail', component: RecipeDetail },
|
||||||
|
{ path: '/dish', name: 'dish', component: ComingSoonView, props: { page: 'dish' } },
|
||||||
|
{ path: '/events', name: 'events', component: ComingSoonView, props: { page: 'events' } },
|
||||||
|
{ path: '/actions', name: 'actions', component: ComingSoonView, props: { page: 'actions' } },
|
||||||
|
{ path: '/dream-island', name: 'dream-island', component: ComingSoonView, props: { page: 'dreamIsland' } },
|
||||||
|
{ path: '/clothes', name: 'clothes', component: ComingSoonView, props: { page: 'clothes' } },
|
||||||
{ path: '/checklist', component: DailyChecklistView },
|
{ path: '/checklist', component: DailyChecklistView },
|
||||||
|
{ path: '/life', component: LifeView },
|
||||||
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
||||||
{ path: '/login', component: LoginView },
|
{ path: '/login', component: LoginView },
|
||||||
{ path: '/register', component: RegisterView },
|
{ path: '/register', component: RegisterView },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
|
|||||||
const authTokenKey = 'pokopia_auth_token';
|
const authTokenKey = 'pokopia_auth_token';
|
||||||
const authChangeEvent = 'pokopia-auth-change';
|
const authChangeEvent = 'pokopia-auth-change';
|
||||||
|
|
||||||
export type TranslationField = 'name' | 'title';
|
export type TranslationField = 'name' | 'title' | 'details' | 'genus';
|
||||||
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
|
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
|
||||||
|
|
||||||
export interface Language {
|
export interface Language {
|
||||||
@@ -26,6 +26,15 @@ export interface Skill extends NamedEntity {
|
|||||||
hasItemDrop: boolean;
|
hasItemDrop: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PokemonStats {
|
||||||
|
hp: number;
|
||||||
|
attack: number;
|
||||||
|
defense: number;
|
||||||
|
specialAttack: number;
|
||||||
|
specialDefense: number;
|
||||||
|
speed: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserSummary {
|
export interface UserSummary {
|
||||||
id: number;
|
id: number;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -57,15 +66,34 @@ export interface Pokemon extends EditInfo {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
baseName?: string;
|
baseName?: string;
|
||||||
|
genus: string;
|
||||||
|
baseGenus?: string;
|
||||||
|
details: string;
|
||||||
|
baseDetails?: string;
|
||||||
|
heightInches: number;
|
||||||
|
heightMeters: number;
|
||||||
|
weightPounds: number;
|
||||||
|
weightKg: number;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
|
types: NamedEntity[];
|
||||||
|
stats: PokemonStats;
|
||||||
environment: NamedEntity;
|
environment: NamedEntity;
|
||||||
skills: Skill[];
|
skills: Skill[];
|
||||||
favorite_things: NamedEntity[];
|
favorite_things: NamedEntity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RelatedPokemon {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
environment: NamedEntity;
|
||||||
|
skills: Skill[];
|
||||||
|
favorite_things: Array<NamedEntity & { matches: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PokemonDetail extends Pokemon {
|
export interface PokemonDetail extends Pokemon {
|
||||||
skills: Array<Skill & { itemDrop: NamedEntity | null }>;
|
skills: Array<Skill & { itemDrop: NamedEntity | null }>;
|
||||||
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
|
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
|
||||||
|
relatedPokemon: RelatedPokemon[];
|
||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
habitats: Array<{
|
habitats: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
@@ -154,6 +182,47 @@ export interface DailyChecklistItem {
|
|||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
||||||
|
export type LifeReactionCounts = Record<LifeReactionType, number>;
|
||||||
|
|
||||||
|
export interface LifePost {
|
||||||
|
id: number;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
author: UserSummary | null;
|
||||||
|
updatedBy: UserSummary | null;
|
||||||
|
tags: NamedEntity[];
|
||||||
|
comments: LifeComment[];
|
||||||
|
reactionCounts: LifeReactionCounts;
|
||||||
|
myReaction: LifeReactionType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LifePostsPage {
|
||||||
|
items: LifePost[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LifePostsParams {
|
||||||
|
cursor?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
search?: string;
|
||||||
|
tagId?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LifeComment {
|
||||||
|
id: number;
|
||||||
|
postId: number;
|
||||||
|
parentCommentId: number | null;
|
||||||
|
body: string;
|
||||||
|
deleted: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
author: UserSummary | null;
|
||||||
|
replies: LifeComment[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecipeDetail extends Recipe {
|
export interface RecipeDetail extends Recipe {
|
||||||
acquisition_methods: NamedEntity[];
|
acquisition_methods: NamedEntity[];
|
||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
@@ -161,6 +230,7 @@ export interface RecipeDetail extends Recipe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
|
pokemonTypes: NamedEntity[];
|
||||||
skills: Skill[];
|
skills: Skill[];
|
||||||
environments: NamedEntity[];
|
environments: NamedEntity[];
|
||||||
favoriteThings: NamedEntity[];
|
favoriteThings: NamedEntity[];
|
||||||
@@ -169,6 +239,7 @@ export interface Options {
|
|||||||
acquisitionMethods: NamedEntity[];
|
acquisitionMethods: NamedEntity[];
|
||||||
itemTags: NamedEntity[];
|
itemTags: NamedEntity[];
|
||||||
maps: NamedEntity[];
|
maps: NamedEntity[];
|
||||||
|
lifeTags: NamedEntity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
@@ -193,18 +264,26 @@ export interface AuthResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ConfigType =
|
export type ConfigType =
|
||||||
|
| 'pokemon-types'
|
||||||
| 'skills'
|
| 'skills'
|
||||||
| 'environments'
|
| 'environments'
|
||||||
| 'favorite-things'
|
| 'favorite-things'
|
||||||
| 'item-categories'
|
| 'item-categories'
|
||||||
| 'item-usages'
|
| 'item-usages'
|
||||||
| 'acquisition-methods'
|
| 'acquisition-methods'
|
||||||
| 'maps';
|
| 'maps'
|
||||||
|
| 'life-tags';
|
||||||
|
|
||||||
export interface PokemonPayload {
|
export interface PokemonPayload {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
genus: string;
|
||||||
|
details: string;
|
||||||
|
heightInches: number;
|
||||||
|
weightPounds: number;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
|
typeIds: number[];
|
||||||
|
stats: PokemonStats;
|
||||||
environmentId: number;
|
environmentId: number;
|
||||||
skillIds: number[];
|
skillIds: number[];
|
||||||
favoriteThingIds: number[];
|
favoriteThingIds: number[];
|
||||||
@@ -248,6 +327,34 @@ export interface DailyChecklistPayload {
|
|||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LifePostPayload {
|
||||||
|
body: string;
|
||||||
|
tagIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LifeCommentPayload {
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||||
|
|
||||||
|
export interface EntityDiscussionComment {
|
||||||
|
id: number;
|
||||||
|
entityType: DiscussionEntityType;
|
||||||
|
entityId: number;
|
||||||
|
parentCommentId: number | null;
|
||||||
|
body: string;
|
||||||
|
deleted: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
author: UserSummary | null;
|
||||||
|
replies: EntityDiscussionComment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityDiscussionCommentPayload {
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildQuery(params: Record<string, string | number | undefined>): string {
|
export function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
|
|
||||||
@@ -360,6 +467,19 @@ async function deleteJson(path: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteAndGetJson<T>(path: string): Promise<T> {
|
||||||
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: requestHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await getErrorMessage(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
languages: () => getJson<Language[]>('/api/languages'),
|
languages: () => getJson<Language[]>('/api/languages'),
|
||||||
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
|
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
|
||||||
@@ -377,6 +497,41 @@ export const api = {
|
|||||||
logout: () => postEmpty('/api/auth/logout'),
|
logout: () => postEmpty('/api/auth/logout'),
|
||||||
options: () => getJson<Options>('/api/options'),
|
options: () => getJson<Options>('/api/options'),
|
||||||
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
||||||
|
lifePosts: (params: LifePostsParams = {}) =>
|
||||||
|
getJson<LifePostsPage>(
|
||||||
|
`/api/life-posts${buildQuery({
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit,
|
||||||
|
search: params.search?.trim(),
|
||||||
|
tagId: params.tagId
|
||||||
|
})}`
|
||||||
|
),
|
||||||
|
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
|
||||||
|
updateLifePost: (id: string | number, payload: LifePostPayload) =>
|
||||||
|
sendJson<LifePost>(`/api/life-posts/${id}`, 'PUT', payload),
|
||||||
|
deleteLifePost: (id: string | number) => deleteJson(`/api/life-posts/${id}`),
|
||||||
|
setLifeReaction: (id: string | number, reactionType: LifeReactionType) =>
|
||||||
|
sendJson<LifePost>(`/api/life-posts/${id}/reaction`, 'PUT', { reactionType }),
|
||||||
|
deleteLifeReaction: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/reaction`),
|
||||||
|
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
|
||||||
|
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
|
||||||
|
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
|
||||||
|
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
|
||||||
|
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
|
||||||
|
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number) =>
|
||||||
|
getJson<EntityDiscussionComment[]>(`/api/discussions/${entityType}/${entityId}/comments`),
|
||||||
|
createEntityDiscussionComment: (
|
||||||
|
entityType: DiscussionEntityType,
|
||||||
|
entityId: string | number,
|
||||||
|
payload: EntityDiscussionCommentPayload
|
||||||
|
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments`, 'POST', payload),
|
||||||
|
createEntityDiscussionReply: (
|
||||||
|
entityType: DiscussionEntityType,
|
||||||
|
entityId: string | number,
|
||||||
|
commentId: string | number,
|
||||||
|
payload: EntityDiscussionCommentPayload
|
||||||
|
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
|
||||||
|
deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`),
|
||||||
createDailyChecklistItem: (payload: DailyChecklistPayload) =>
|
createDailyChecklistItem: (payload: DailyChecklistPayload) =>
|
||||||
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
|
sendJson<DailyChecklistItem>('/api/admin/daily-checklist', 'POST', payload),
|
||||||
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>
|
updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) =>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -66,13 +66,15 @@ const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }>>(() => [
|
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }>>(() => [
|
||||||
|
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
|
||||||
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
|
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
|
||||||
{ key: 'environments', label: t('config.environments') },
|
{ key: 'environments', label: t('config.environments') },
|
||||||
{ key: 'favorite-things', label: t('config.favoriteThings') },
|
{ key: 'favorite-things', label: t('config.favoriteThings') },
|
||||||
{ key: 'item-categories', label: t('config.itemCategories') },
|
{ key: 'item-categories', label: t('config.itemCategories') },
|
||||||
{ key: 'item-usages', label: t('config.itemUsages') },
|
{ key: 'item-usages', label: t('config.itemUsages') },
|
||||||
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
|
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
|
||||||
{ key: 'maps', label: t('config.maps') }
|
{ key: 'maps', label: t('config.maps') },
|
||||||
|
{ key: 'life-tags', label: t('config.lifeTags') }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const activeTab = ref<AdminTab>('config');
|
const activeTab = ref<AdminTab>('config');
|
||||||
|
|||||||
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 { useRoute } from 'vue-router';
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
|
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import { iconBack, iconEdit } from '../icons';
|
import { iconBack, iconEdit } from '../icons';
|
||||||
import { api, type HabitatDetail } from '../services/api';
|
import { api, type HabitatDetail } from '../services/api';
|
||||||
import HabitatEdit from './HabitatEdit.vue';
|
import HabitatEdit from './HabitatEdit.vue';
|
||||||
@@ -15,9 +17,15 @@ import HabitatEdit from './HabitatEdit.vue';
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const habitat = ref<HabitatDetail | null>(null);
|
const habitat = ref<HabitatDetail | null>(null);
|
||||||
|
const detailTab = ref('details');
|
||||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||||
const weathers = ['晴天', '阴天', '雨天'];
|
const weathers = ['晴天', '阴天', '雨天'];
|
||||||
const showEditor = computed(() => route.name === 'habitat-edit');
|
const showEditor = computed(() => route.name === 'habitat-edit');
|
||||||
|
const detailTabs = computed<TabOption[]>(() => [
|
||||||
|
{ value: 'details', label: t('common.details') },
|
||||||
|
{ value: 'discussion', label: t('discussion.title') },
|
||||||
|
{ value: 'history', label: t('history.editHistory') }
|
||||||
|
]);
|
||||||
|
|
||||||
type PokemonRow = {
|
type PokemonRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -121,6 +129,7 @@ watch(
|
|||||||
() => route.params.id,
|
() => route.params.id,
|
||||||
() => {
|
() => {
|
||||||
habitat.value = null;
|
habitat.value = null;
|
||||||
|
detailTab.value = 'details';
|
||||||
void loadHabitatDetail();
|
void loadHabitatDetail();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -187,8 +196,10 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="detail-with-sidebar">
|
<div class="detail-tabs">
|
||||||
<div class="habitat-detail-stack">
|
<Tabs id="habitat-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||||
|
|
||||||
|
<div v-if="detailTab === 'details'" class="habitat-detail-stack">
|
||||||
<DetailSection :title="t('pages.habitats.recipeList')">
|
<DetailSection :title="t('pages.habitats.recipeList')">
|
||||||
<EntityChips :items="habitat.recipe" />
|
<EntityChips :items="habitat.recipe" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
@@ -220,8 +231,14 @@ watch(
|
|||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
|
||||||
|
<EntityDiscussionPanel entity-type="habitats" :entity-id="habitat.id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="detail-tab-panel">
|
||||||
<EditHistoryPanel :entity="habitat" :history="habitat.editHistory" />
|
<EditHistoryPanel :entity="habitat" :history="habitat.editHistory" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<HabitatEdit v-if="showEditor" />
|
<HabitatEdit v-if="showEditor" />
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
|
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import { iconAdd, iconBack, iconEdit } from '../icons';
|
import { iconAdd, iconBack, iconEdit } from '../icons';
|
||||||
import { api, type ItemDetail } from '../services/api';
|
import { api, type ItemDetail } from '../services/api';
|
||||||
import ItemEdit from './ItemEdit.vue';
|
import ItemEdit from './ItemEdit.vue';
|
||||||
@@ -15,7 +17,13 @@ import ItemEdit from './ItemEdit.vue';
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const item = ref<ItemDetail | null>(null);
|
const item = ref<ItemDetail | null>(null);
|
||||||
|
const detailTab = ref('details');
|
||||||
const showEditor = computed(() => route.name === 'item-edit');
|
const showEditor = computed(() => route.name === 'item-edit');
|
||||||
|
const detailTabs = computed<TabOption[]>(() => [
|
||||||
|
{ value: 'details', label: t('common.details') },
|
||||||
|
{ value: 'discussion', label: t('discussion.title') },
|
||||||
|
{ value: 'history', label: t('history.editHistory') }
|
||||||
|
]);
|
||||||
|
|
||||||
const customization = computed(() => {
|
const customization = computed(() => {
|
||||||
if (!item.value) {
|
if (!item.value) {
|
||||||
@@ -50,6 +58,7 @@ watch(
|
|||||||
() => route.params.id,
|
() => route.params.id,
|
||||||
() => {
|
() => {
|
||||||
item.value = null;
|
item.value = null;
|
||||||
|
detailTab.value = 'details';
|
||||||
void loadItemDetail();
|
void loadItemDetail();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -123,8 +132,10 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="detail-with-sidebar">
|
<div class="detail-tabs">
|
||||||
<div class="detail-grid">
|
<Tabs id="item-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||||
|
|
||||||
|
<div v-if="detailTab === 'details'" class="detail-grid">
|
||||||
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
||||||
<EntityChips :items="item.acquisitionMethods" />
|
<EntityChips :items="item.acquisitionMethods" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
@@ -186,8 +197,14 @@ watch(
|
|||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
|
||||||
|
<EntityDiscussionPanel entity-type="items" :entity-id="item.id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="detail-tab-panel">
|
||||||
<EditHistoryPanel :entity="item" :history="item.editHistory" />
|
<EditHistoryPanel :entity="item" :history="item.editHistory" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ItemEdit v-if="showEditor" />
|
<ItemEdit v-if="showEditor" />
|
||||||
|
|||||||
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 { useRoute } from 'vue-router';
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
|
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import { iconBack, iconEdit } from '../icons';
|
import { iconBack, iconEdit } from '../icons';
|
||||||
@@ -17,8 +19,11 @@ const route = useRoute();
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const pokemon = ref<PokemonDetail | null>(null);
|
const pokemon = ref<PokemonDetail | null>(null);
|
||||||
const itemCategoryTab = ref('');
|
const itemCategoryTab = ref('');
|
||||||
|
const relatedHabitatTab = ref('');
|
||||||
|
const detailTab = ref('details');
|
||||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||||
const weathers = ['晴天', '阴天', '雨天'];
|
const weathers = ['晴天', '阴天', '雨天'];
|
||||||
|
const relatedPokemonLimit = 6;
|
||||||
|
|
||||||
type HabitatRow = {
|
type HabitatRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -40,6 +45,10 @@ function sortByOrder(values: Set<string>, order: string[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function habitatTabValue(id: number): string {
|
||||||
|
return `habitat-${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
function timeLabel(value: string): string {
|
function timeLabel(value: string): string {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
早晨: t('appearance.morning'),
|
早晨: t('appearance.morning'),
|
||||||
@@ -102,6 +111,11 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
|||||||
});
|
});
|
||||||
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
|
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
|
||||||
const showEditor = computed(() => route.name === 'pokemon-edit');
|
const showEditor = computed(() => route.name === 'pokemon-edit');
|
||||||
|
const detailTabs = computed<TabOption[]>(() => [
|
||||||
|
{ value: 'details', label: t('common.details') },
|
||||||
|
{ value: 'discussion', label: t('discussion.title') },
|
||||||
|
{ value: 'history', label: t('history.editHistory') }
|
||||||
|
]);
|
||||||
const itemCategoryTabs = computed<TabOption[]>(() => {
|
const itemCategoryTabs = computed<TabOption[]>(() => {
|
||||||
const categories = new Map<string, string>();
|
const categories = new Map<string, string>();
|
||||||
|
|
||||||
@@ -122,9 +136,54 @@ const favoriteThingItems = computed(() => {
|
|||||||
|
|
||||||
return items.filter((item) => String(item.category.id) === itemCategoryTab.value);
|
return items.filter((item) => String(item.category.id) === itemCategoryTab.value);
|
||||||
});
|
});
|
||||||
|
const relatedHabitatTabs = computed<TabOption[]>(() => {
|
||||||
|
if (!pokemon.value?.relatedPokemon.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const habitats = new Map<string, string>();
|
||||||
|
habitats.set(habitatTabValue(pokemon.value.environment.id), pokemon.value.environment.name);
|
||||||
|
|
||||||
|
pokemon.value.relatedPokemon.forEach((item) => {
|
||||||
|
habitats.set(habitatTabValue(item.environment.id), item.environment.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs = [...habitats.entries()].map(([value, label]) => ({ value, label }));
|
||||||
|
return [...tabs, { value: 'all', label: t('common.all') }];
|
||||||
|
});
|
||||||
|
const relatedPokemonRows = computed(() => {
|
||||||
|
const rows = pokemon.value?.relatedPokemon ?? [];
|
||||||
|
const selectedTab = relatedHabitatTab.value || (pokemon.value ? habitatTabValue(pokemon.value.environment.id) : '');
|
||||||
|
|
||||||
|
if (selectedTab === 'all') {
|
||||||
|
return rows.slice(0, relatedPokemonLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.filter((item) => habitatTabValue(item.environment.id) === selectedTab).slice(0, relatedPokemonLimit);
|
||||||
|
});
|
||||||
|
const typeSlotClass = computed(() => ({
|
||||||
|
'pokemon-type-slots--single': (pokemon.value?.types.length ?? 0) === 1
|
||||||
|
}));
|
||||||
|
|
||||||
|
function formatMetricMeasure(value: number): string {
|
||||||
|
return value.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPoundsMeasure(value: number): string {
|
||||||
|
return (Math.round(value * 10) / 10).toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatImperialHeight(inches: number): string {
|
||||||
|
const totalInches = Math.round(inches);
|
||||||
|
const feet = Math.floor(totalInches / 12);
|
||||||
|
const remainingInches = totalInches - feet * 12;
|
||||||
|
return `${feet}'${remainingInches}"`;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPokemonDetail() {
|
async function loadPokemonDetail() {
|
||||||
pokemon.value = await api.pokemonDetail(String(route.params.id));
|
const nextPokemon = await api.pokemonDetail(String(route.params.id));
|
||||||
|
pokemon.value = nextPokemon;
|
||||||
|
relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -144,6 +203,8 @@ watch(
|
|||||||
() => route.params.id,
|
() => route.params.id,
|
||||||
() => {
|
() => {
|
||||||
pokemon.value = null;
|
pokemon.value = null;
|
||||||
|
relatedHabitatTab.value = '';
|
||||||
|
detailTab.value = 'details';
|
||||||
void loadPokemonDetail();
|
void loadPokemonDetail();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -221,8 +282,55 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="detail-with-sidebar">
|
<div class="detail-tabs">
|
||||||
<div class="detail-grid detail-grid--stack">
|
<Tabs id="pokemon-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||||
|
|
||||||
|
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
|
||||||
|
<div class="pokemon-profile-grid">
|
||||||
|
<div class="pokemon-profile-main">
|
||||||
|
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.details')">
|
||||||
|
<p v-if="pokemon.genus" class="pokemon-genus">{{ pokemon.genus }}</p>
|
||||||
|
<div v-if="pokemon.genus && pokemon.details.trim()" class="pokemon-profile-divider"></div>
|
||||||
|
<p v-if="pokemon.details.trim()" class="detail-text">{{ pokemon.details }}</p>
|
||||||
|
<p v-if="!pokemon.genus && !pokemon.details.trim()" class="meta-line">{{ t('common.none') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="pokemon-profile-row">
|
||||||
|
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.measurements')">
|
||||||
|
<div class="pokemon-measurement-display">
|
||||||
|
<div class="pokemon-measurement-item" :title="`${formatImperialHeight(pokemon.heightInches)} / ${formatMetricMeasure(pokemon.heightMeters)} m`">
|
||||||
|
<div class="pokemon-measurement-stack">
|
||||||
|
<strong class="pokemon-measurement-value">{{ formatImperialHeight(pokemon.heightInches) }}</strong>
|
||||||
|
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
|
||||||
|
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.heightMeters) }} m</strong>
|
||||||
|
<span class="pokemon-measurement-label">{{ t('pages.pokemon.height') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pokemon-measurement-item" :title="`${formatPoundsMeasure(pokemon.weightPounds)} lbs / ${formatMetricMeasure(pokemon.weightKg)} kg`">
|
||||||
|
<div class="pokemon-measurement-stack">
|
||||||
|
<strong class="pokemon-measurement-value">{{ formatPoundsMeasure(pokemon.weightPounds) }} lbs</strong>
|
||||||
|
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
|
||||||
|
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.weightKg) }} kg</strong>
|
||||||
|
<span class="pokemon-measurement-label">{{ t('pages.pokemon.weight') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section pokemon-profile-card pokemon-types-card" :aria-label="t('pages.pokemon.types')">
|
||||||
|
<div v-if="pokemon.types.length" class="pokemon-type-slots" :class="typeSlotClass">
|
||||||
|
<span v-for="type in pokemon.types.slice(0, 2)" :key="type.id" class="chip">{{ type.name }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')">
|
||||||
|
<PokemonStatsPanel :stats="pokemon.stats" />
|
||||||
|
</DetailSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DetailSection :title="t('pages.pokemon.skills')">
|
<DetailSection :title="t('pages.pokemon.skills')">
|
||||||
<EntityChips :items="pokemon.skills" />
|
<EntityChips :items="pokemon.skills" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
@@ -240,6 +348,56 @@ watch(
|
|||||||
<EntityChips :items="pokemon.favorite_things" />
|
<EntityChips :items="pokemon.favorite_things" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
|
<div class="pokemon-related-grid">
|
||||||
|
<DetailSection :title="t('pages.pokemon.relatedPokemon')">
|
||||||
|
<template v-if="pokemon.relatedPokemon.length">
|
||||||
|
<Tabs
|
||||||
|
v-if="relatedHabitatTabs.length"
|
||||||
|
id="pokemon-related-habitats"
|
||||||
|
v-model="relatedHabitatTab"
|
||||||
|
:tabs="relatedHabitatTabs"
|
||||||
|
:label="t('pages.pokemon.relatedHabitat')"
|
||||||
|
/>
|
||||||
|
<ul v-if="relatedPokemonRows.length" class="row-list related-pokemon-list">
|
||||||
|
<li v-for="related in relatedPokemonRows" :key="related.id">
|
||||||
|
<div class="related-pokemon-row">
|
||||||
|
<div class="related-pokemon-row__summary">
|
||||||
|
<RouterLink class="related-pokemon-row__name" :to="`/pokemon/${related.id}`">#{{ related.id }} {{ related.name }}</RouterLink>
|
||||||
|
<div class="related-pokemon-row__traits">
|
||||||
|
<EntityChips
|
||||||
|
v-if="related.skills.length"
|
||||||
|
class="related-pokemon-row__skills"
|
||||||
|
:items="related.skills"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="chip related-pokemon-row__environment"
|
||||||
|
:class="{ 'related-pokemon-row__environment--match': related.environment.id === pokemon.environment.id }"
|
||||||
|
>
|
||||||
|
{{ related.environment.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="related.favorite_things.length"
|
||||||
|
class="chips related-pokemon-row__favourites"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="thing in related.favorite_things"
|
||||||
|
:key="thing.id"
|
||||||
|
class="chip related-favourite-chip"
|
||||||
|
:class="{ 'related-favourite-chip--match': thing.matches }"
|
||||||
|
>
|
||||||
|
{{ thing.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection :title="t('pages.pokemon.relatedItems')">
|
<DetailSection :title="t('pages.pokemon.relatedItems')">
|
||||||
<template v-if="pokemon.favoriteThingItems.length">
|
<template v-if="pokemon.favoriteThingItems.length">
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -259,6 +417,7 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DetailSection :title="t('pages.pokemon.habitats')">
|
<DetailSection :title="t('pages.pokemon.habitats')">
|
||||||
<ul class="row-list appearance-list">
|
<ul class="row-list appearance-list">
|
||||||
@@ -287,8 +446,14 @@ watch(
|
|||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
|
||||||
|
<EntityDiscussionPanel entity-type="pokemon" :entity-id="pokemon.id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="detail-tab-panel">
|
||||||
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
|
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<PokemonEdit v-if="showEditor" />
|
<PokemonEdit v-if="showEditor" />
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import Modal from '../components/Modal.vue';
|
import Modal from '../components/Modal.vue';
|
||||||
|
import PokemonStatsFields from '../components/PokemonStatsFields.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
|
import Tabs from '../components/Tabs.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
import TranslationFields from '../components/TranslationFields.vue';
|
import TranslationFields from '../components/TranslationFields.vue';
|
||||||
import { iconCancel, iconSave } from '../icons';
|
import { iconCancel, iconSave } from '../icons';
|
||||||
import { api, type ConfigType, type Language, type NamedEntity, type Options, type PokemonPayload, type TranslationMap } from '../services/api';
|
import {
|
||||||
|
api,
|
||||||
|
type ConfigType,
|
||||||
|
type Language,
|
||||||
|
type NamedEntity,
|
||||||
|
type Options,
|
||||||
|
type PokemonPayload,
|
||||||
|
type PokemonStats,
|
||||||
|
type TranslationMap
|
||||||
|
} from '../services/api';
|
||||||
|
|
||||||
type SkillItemDropForm = {
|
type SkillItemDropForm = {
|
||||||
skillId: string;
|
skillId: string;
|
||||||
@@ -26,10 +37,31 @@ const loading = ref(true);
|
|||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const creatingSelect = ref('');
|
const creatingSelect = ref('');
|
||||||
|
const activeEditTab = ref('basic');
|
||||||
|
const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||||
|
const weightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||||
|
|
||||||
|
function defaultPokemonStats(): PokemonStats {
|
||||||
|
return {
|
||||||
|
hp: 0,
|
||||||
|
attack: 0,
|
||||||
|
defense: 0,
|
||||||
|
specialAttack: 0,
|
||||||
|
specialDefense: 0,
|
||||||
|
speed: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const pokemonForm = ref({
|
const pokemonForm = ref({
|
||||||
id: '',
|
id: '',
|
||||||
name: '',
|
name: '',
|
||||||
|
genus: '',
|
||||||
|
details: '',
|
||||||
|
heightInches: 0,
|
||||||
|
weightPounds: 0,
|
||||||
translations: {} as TranslationMap,
|
translations: {} as TranslationMap,
|
||||||
|
typeIds: [] as string[],
|
||||||
|
stats: defaultPokemonStats(),
|
||||||
environmentId: '',
|
environmentId: '',
|
||||||
skillIds: [] as string[],
|
skillIds: [] as string[],
|
||||||
favoriteThingIds: [] as string[],
|
favoriteThingIds: [] as string[],
|
||||||
@@ -47,11 +79,51 @@ const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` :
|
|||||||
const selectedSkillDropRows = computed(() =>
|
const selectedSkillDropRows = computed(() =>
|
||||||
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId))
|
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId))
|
||||||
);
|
);
|
||||||
|
const editTabs = computed(() => [
|
||||||
|
{ value: 'basic', label: t('pages.pokemon.editTabBasic') },
|
||||||
|
{ value: 'advance', label: t('pages.pokemon.editTabAdvance') }
|
||||||
|
]);
|
||||||
|
const totalHeightInchesValue = computed(() => Math.round(pokemonForm.value.heightInches));
|
||||||
|
const heightFeetValue = computed(() => Math.floor(totalHeightInchesValue.value / 12));
|
||||||
|
const heightInchesValue = computed(() => totalHeightInchesValue.value - heightFeetValue.value * 12);
|
||||||
|
const heightMetersValue = computed(() => roundMeasurement(pokemonForm.value.heightInches * 0.0254, 2));
|
||||||
|
const weightPoundsValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds, 1));
|
||||||
|
const weightKgValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds * 0.45359237, 2));
|
||||||
|
|
||||||
function toIds(values: string[]): number[] {
|
function toIds(values: string[]): number[] {
|
||||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function numericInputValue(event: Event): number {
|
||||||
|
const value = event.target instanceof HTMLInputElement ? Number(event.target.value) : 0;
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundMeasurement(value: number, precision: number): number {
|
||||||
|
const scale = 10 ** precision;
|
||||||
|
return Math.round(value * scale) / scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeightFeet(event: Event) {
|
||||||
|
pokemonForm.value.heightInches = Math.round(numericInputValue(event) * 12 + heightInchesValue.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeightInches(event: Event) {
|
||||||
|
pokemonForm.value.heightInches = Math.round(heightFeetValue.value * 12 + numericInputValue(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeightMeters(event: Event) {
|
||||||
|
pokemonForm.value.heightInches = roundMeasurement(numericInputValue(event) / 0.0254, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWeightPounds(event: Event) {
|
||||||
|
pokemonForm.value.weightPounds = roundMeasurement(numericInputValue(event), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWeightKg(event: Event) {
|
||||||
|
pokemonForm.value.weightPounds = roundMeasurement(numericInputValue(event) / 0.45359237, 1);
|
||||||
|
}
|
||||||
|
|
||||||
function errorText(error: unknown, fallback: string) {
|
function errorText(error: unknown, fallback: string) {
|
||||||
return error instanceof Error && error.message ? error.message : fallback;
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
}
|
}
|
||||||
@@ -98,6 +170,21 @@ function pokemonNameForSave() {
|
|||||||
return pokemonForm.value.translations[String(locale.value || '')]?.name ?? '';
|
return pokemonForm.value.translations[String(locale.value || '')]?.name ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pokemonIdForSave() {
|
||||||
|
return Number(isEditing.value ? routeId.value : pokemonForm.value.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRequiredBasicFields() {
|
||||||
|
const id = pokemonIdForSave();
|
||||||
|
return Number.isInteger(id) && id > 0 && pokemonNameForSave().trim() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showBasicFieldValidation() {
|
||||||
|
activeEditTab.value = 'basic';
|
||||||
|
await nextTick();
|
||||||
|
document.querySelector<HTMLFormElement>('#pokemon-edit-form')?.reportValidity();
|
||||||
|
}
|
||||||
|
|
||||||
function closeEditor() {
|
function closeEditor() {
|
||||||
void router.push(cancelTo.value);
|
void router.push(cancelTo.value);
|
||||||
}
|
}
|
||||||
@@ -113,7 +200,13 @@ async function loadEditor() {
|
|||||||
pokemonForm.value = {
|
pokemonForm.value = {
|
||||||
id: String(pokemon.id),
|
id: String(pokemon.id),
|
||||||
name: pokemon.baseName ?? pokemon.name,
|
name: pokemon.baseName ?? pokemon.name,
|
||||||
|
genus: pokemon.baseGenus ?? pokemon.genus,
|
||||||
|
details: pokemon.baseDetails ?? pokemon.details,
|
||||||
|
heightInches: pokemon.heightInches,
|
||||||
|
weightPounds: pokemon.weightPounds,
|
||||||
translations: pokemon.translations ?? {},
|
translations: pokemon.translations ?? {},
|
||||||
|
typeIds: pokemon.types.map((type) => String(type.id)),
|
||||||
|
stats: pokemon.stats,
|
||||||
environmentId: String(pokemon.environment.id),
|
environmentId: String(pokemon.environment.id),
|
||||||
skillIds: pokemon.skills.map((skill) => String(skill.id)),
|
skillIds: pokemon.skills.map((skill) => String(skill.id)),
|
||||||
favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id)),
|
favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id)),
|
||||||
@@ -169,14 +262,25 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function savePokemon() {
|
async function savePokemon() {
|
||||||
|
if (!hasRequiredBasicFields()) {
|
||||||
|
await showBasicFieldValidation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
message.value = '';
|
message.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload: PokemonPayload = {
|
const payload: PokemonPayload = {
|
||||||
id: Number(isEditing.value ? routeId.value : pokemonForm.value.id),
|
id: pokemonIdForSave(),
|
||||||
name: pokemonNameForSave(),
|
name: pokemonNameForSave(),
|
||||||
|
genus: pokemonForm.value.genus,
|
||||||
|
details: pokemonForm.value.details,
|
||||||
|
heightInches: pokemonForm.value.heightInches,
|
||||||
|
weightPounds: pokemonForm.value.weightPounds,
|
||||||
translations: pokemonForm.value.translations,
|
translations: pokemonForm.value.translations,
|
||||||
|
typeIds: toIds(pokemonForm.value.typeIds.slice(0, 2)),
|
||||||
|
stats: pokemonForm.value.stats,
|
||||||
environmentId: Number(pokemonForm.value.environmentId),
|
environmentId: Number(pokemonForm.value.environmentId),
|
||||||
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
|
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
|
||||||
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
|
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
|
||||||
@@ -204,7 +308,11 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
|||||||
<Modal :title="pageTitle" :subtitle="t('pages.pokemon.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
<Modal :title="pageTitle" :subtitle="t('pages.pokemon.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
||||||
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
||||||
|
|
||||||
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form" @submit.prevent="savePokemon">
|
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form modal-edit-form--tabbed pokemon-edit-form" @submit.prevent="savePokemon">
|
||||||
|
<Tabs id="pokemon-edit-tabs" v-model="activeEditTab" :tabs="editTabs" :label="t('pages.pokemon.editSections')" />
|
||||||
|
|
||||||
|
<section v-if="activeEditTab === 'basic'" class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabBasic')">
|
||||||
|
<div class="pokemon-edit-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="pokemon-id">ID</label>
|
<label for="pokemon-id">ID</label>
|
||||||
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
|
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
|
||||||
@@ -219,7 +327,9 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
|||||||
:languages="languages"
|
:languages="languages"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pokemon-edit-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
|
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
|
||||||
<TagsSelect
|
<TagsSelect
|
||||||
@@ -248,6 +358,7 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
|||||||
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
|
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="pokemon-things">{{ t('pages.pokemon.favoriteThings') }}</label>
|
<label for="pokemon-things">{{ t('pages.pokemon.favoriteThings') }}</label>
|
||||||
@@ -279,6 +390,104 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabAdvance')">
|
||||||
|
<TranslationFields
|
||||||
|
id-prefix="pokemon-genus"
|
||||||
|
v-model:base-value="pokemonForm.genus"
|
||||||
|
v-model:translations="pokemonForm.translations"
|
||||||
|
field="genus"
|
||||||
|
:label="t('pages.pokemon.genus')"
|
||||||
|
:languages="languages"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TranslationFields
|
||||||
|
id-prefix="pokemon-details"
|
||||||
|
v-model:base-value="pokemonForm.details"
|
||||||
|
v-model:translations="pokemonForm.translations"
|
||||||
|
field="details"
|
||||||
|
:label="t('pages.pokemon.details')"
|
||||||
|
:languages="languages"
|
||||||
|
multiline
|
||||||
|
:rows="5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span id="pokemon-measurements-label" class="field-label">{{ t('pages.pokemon.measurements') }}</span>
|
||||||
|
<div class="pokemon-measurement-row" aria-labelledby="pokemon-measurements-label">
|
||||||
|
<div class="pokemon-measurement-control">
|
||||||
|
<span id="pokemon-height-label" class="field-label">{{ t('pages.pokemon.height') }}</span>
|
||||||
|
<div class="segmented" aria-labelledby="pokemon-height-label">
|
||||||
|
<button :class="{ active: heightUnit === 'imperial' }" type="button" @click="heightUnit = 'imperial'">
|
||||||
|
{{ t('pages.pokemon.heightImperial') }}
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: heightUnit === 'metric' }" type="button" @click="heightUnit = 'metric'">
|
||||||
|
{{ t('pages.pokemon.heightMetric') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="heightUnit === 'imperial'" class="pokemon-measurement-fields">
|
||||||
|
<div class="field">
|
||||||
|
<label for="pokemon-height-feet">{{ t('pages.pokemon.feet') }}</label>
|
||||||
|
<input id="pokemon-height-feet" :value="heightFeetValue" min="0" step="1" type="number" inputmode="numeric" @input="updateHeightFeet" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="pokemon-height-inches">{{ t('pages.pokemon.inches') }}</label>
|
||||||
|
<input id="pokemon-height-inches" :value="heightInchesValue" min="0" step="1" type="number" inputmode="numeric" @input="updateHeightInches" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="field">
|
||||||
|
<label for="pokemon-height-meters">{{ t('pages.pokemon.meters') }}</label>
|
||||||
|
<input id="pokemon-height-meters" :value="heightMetersValue" min="0" step="0.01" type="number" inputmode="decimal" @input="updateHeightMeters" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pokemon-measurement-control">
|
||||||
|
<span id="pokemon-weight-label" class="field-label">{{ t('pages.pokemon.weight') }}</span>
|
||||||
|
<div class="segmented" aria-labelledby="pokemon-weight-label">
|
||||||
|
<button :class="{ active: weightUnit === 'imperial' }" type="button" @click="weightUnit = 'imperial'">
|
||||||
|
{{ t('pages.pokemon.pounds') }}
|
||||||
|
</button>
|
||||||
|
<button :class="{ active: weightUnit === 'metric' }" type="button" @click="weightUnit = 'metric'">
|
||||||
|
{{ t('pages.pokemon.kilograms') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="weightUnit === 'imperial'" class="field">
|
||||||
|
<label for="pokemon-weight-pounds">{{ t('pages.pokemon.pounds') }}</label>
|
||||||
|
<input id="pokemon-weight-pounds" :value="weightPoundsValue" min="0" step="0.1" type="number" inputmode="decimal" @input="updateWeightPounds" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="field">
|
||||||
|
<label for="pokemon-weight-kg">{{ t('pages.pokemon.kilograms') }}</label>
|
||||||
|
<input id="pokemon-weight-kg" :value="weightKgValue" min="0" step="0.01" type="number" inputmode="decimal" @input="updateWeightKg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="pokemon-types">{{ t('pages.pokemon.types') }}</label>
|
||||||
|
<TagsSelect
|
||||||
|
id="pokemon-types"
|
||||||
|
v-model="pokemonForm.typeIds"
|
||||||
|
:options="options.pokemonTypes"
|
||||||
|
:max="2"
|
||||||
|
allow-create
|
||||||
|
:creating="creatingSelect === 'pokemon-types'"
|
||||||
|
:placeholder="t('pages.pokemon.searchTypes')"
|
||||||
|
@create="createMultiOption('pokemon-types', 'pokemon-types', $event, pokemonForm.typeIds, 2)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">{{ t('pages.pokemon.statsTitle') }}</span>
|
||||||
|
<PokemonStatsFields id-prefix="pokemon-stats" v-model="pokemonForm.stats" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.pokemon.loadingEdit')">
|
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.pokemon.loadingEdit')">
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ watch(query, loadPokemon);
|
|||||||
:to="`/pokemon/${item.id}`"
|
:to="`/pokemon/${item.id}`"
|
||||||
>
|
>
|
||||||
<EditMeta :entity="item" />
|
<EditMeta :entity="item" />
|
||||||
|
<EntityChips v-if="item.types.length" :items="item.types" />
|
||||||
<EntityChips :items="item.skills" />
|
<EntityChips :items="item.skills" />
|
||||||
<EntityChips :items="item.favorite_things" />
|
<EntityChips :items="item.favorite_things" />
|
||||||
</EntityCard>
|
</EntityCard>
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import DetailSection from '../components/DetailSection.vue';
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
|
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
import { iconBack, iconEdit } from '../icons';
|
import { iconBack, iconEdit } from '../icons';
|
||||||
import { api, type RecipeDetail } from '../services/api';
|
import { api, type RecipeDetail } from '../services/api';
|
||||||
import RecipeEdit from './RecipeEdit.vue';
|
import RecipeEdit from './RecipeEdit.vue';
|
||||||
@@ -15,7 +17,13 @@ import RecipeEdit from './RecipeEdit.vue';
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const recipe = ref<RecipeDetail | null>(null);
|
const recipe = ref<RecipeDetail | null>(null);
|
||||||
|
const detailTab = ref('details');
|
||||||
const showEditor = computed(() => route.name === 'recipe-edit');
|
const showEditor = computed(() => route.name === 'recipe-edit');
|
||||||
|
const detailTabs = computed<TabOption[]>(() => [
|
||||||
|
{ value: 'details', label: t('common.details') },
|
||||||
|
{ value: 'discussion', label: t('discussion.title') },
|
||||||
|
{ value: 'history', label: t('history.editHistory') }
|
||||||
|
]);
|
||||||
|
|
||||||
async function loadRecipeDetail() {
|
async function loadRecipeDetail() {
|
||||||
recipe.value = await api.recipeDetail(String(route.params.id));
|
recipe.value = await api.recipeDetail(String(route.params.id));
|
||||||
@@ -38,6 +46,7 @@ watch(
|
|||||||
() => route.params.id,
|
() => route.params.id,
|
||||||
() => {
|
() => {
|
||||||
recipe.value = null;
|
recipe.value = null;
|
||||||
|
detailTab.value = 'details';
|
||||||
void loadRecipeDetail();
|
void loadRecipeDetail();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -85,8 +94,10 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="detail-with-sidebar">
|
<div class="detail-tabs">
|
||||||
<div class="detail-grid">
|
<Tabs id="recipe-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||||
|
|
||||||
|
<div v-if="detailTab === 'details'" class="detail-grid">
|
||||||
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
<DetailSection :title="t('pages.items.acquisitionMethods')">
|
||||||
<EntityChips :items="recipe.acquisition_methods" />
|
<EntityChips :items="recipe.acquisition_methods" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
@@ -96,8 +107,14 @@ watch(
|
|||||||
</DetailSection>
|
</DetailSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
|
||||||
|
<EntityDiscussionPanel entity-type="recipes" :entity-id="recipe.id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="detail-tab-panel">
|
||||||
<EditHistoryPanel :entity="recipe" :history="recipe.editHistory" />
|
<EditHistoryPanel :entity="recipe" :history="recipe.editHistory" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<RecipeEdit v-if="showEditor" />
|
<RecipeEdit v-if="showEditor" />
|
||||||
|
|||||||
Reference in New Issue
Block a user