Compare commits
122 Commits
7c8426651d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 82f08c1684 | |||
| df78685dc3 | |||
| cc440ea949 | |||
| 5ef1f4ecc9 | |||
| 4dc73d42cb | |||
| fa656a8d02 | |||
| f26cfdc830 | |||
| 71b35b9cc6 | |||
| 70f7a73e6d | |||
| f92e97b747 | |||
| d66124862a | |||
| f7986ca520 | |||
| 425f2f4d5f | |||
| 35ee164794 | |||
| cf1eb6965e | |||
| 337a6bda1f | |||
| fd1f3ef636 | |||
| afed409127 | |||
| 6e8edbbb09 | |||
| c821e9ebba | |||
| 91a001e3f9 | |||
| 22016365d8 | |||
| 5b22d788d7 | |||
| 0e2743b469 | |||
| 5a83a73108 | |||
| 839a24566b | |||
| 9312156a3c | |||
| 8ee29e9549 | |||
| 357dc061d6 | |||
| a17344d216 | |||
| cd0f8868c3 | |||
| 28f4e6032c | |||
| 2220d5d595 | |||
| 2ff2519647 | |||
| 504849c14a | |||
| 8cb8190554 | |||
| 016364a8b8 | |||
| b0e2036965 | |||
| 06e0cbb1c1 | |||
| 3dd3998a5c | |||
| bd944556d9 | |||
| 07698e063d | |||
| 3d6188748d | |||
| a25f1661b5 | |||
| 579d092020 | |||
| 7ff7e18b94 | |||
| bcff83a512 | |||
| 03f5735bd2 | |||
| 4238be7761 | |||
| 5ccc25b248 | |||
| f2a8b67ebf | |||
| fa06d24826 | |||
| 8dfd03f3d2 | |||
| a0e07f101a | |||
| df212a4e27 | |||
| deb0b54e71 | |||
| b0e2464c24 | |||
| 40f85ae85c | |||
| 3a8a61487a | |||
| 72ddae6f9d | |||
| fcb9b57aa3 | |||
| d80c9325cd | |||
| 105274eec8 | |||
| 4ebb45aa94 | |||
| 6758aaaa7e | |||
| 6782ddd101 | |||
| 18baf7b513 | |||
| 590bd6a0ae | |||
| 7aa80430d9 | |||
| 960898c858 | |||
| 0c76d6bfc8 | |||
| 8f55db9061 | |||
| 1dab650c2c | |||
| 282481bbcc | |||
| 0e835f9c03 | |||
| b9ec8076ac | |||
| 043ebe392a | |||
| ef82fc805d | |||
| 95d76522df | |||
| accd6f98cf | |||
| 3ca66d7124 | |||
| 8bc311916d | |||
| 05f531ddf2 | |||
| 05898f9441 | |||
| 3d99f00c75 | |||
| 4d05618530 | |||
| 784cbdacd1 | |||
| 36e10a06b0 | |||
| 4a42756e2e | |||
| 97f06794a8 | |||
| 874ecc5625 | |||
| cf0ae566c0 | |||
| 475e3577dd | |||
| 976a2a2482 | |||
| e8e20539c9 | |||
| b0d18a845d | |||
| 7ee25e2437 | |||
| c2f58fe661 | |||
| 21bbbc7137 | |||
| f5ab96c2b1 | |||
| ec2a21bae6 | |||
| 6462ed23de | |||
| 0ca6f779ec | |||
| f1ed1e7e40 | |||
| 433b19eb67 | |||
| 866d7add16 | |||
| c03d4271e1 | |||
| 71b7e838ed | |||
| a683982b80 | |||
| cd1891cc82 | |||
| 49aae3bd7c | |||
| ec3494ea28 | |||
| 9fece8f54f | |||
| ca3ca35dfc | |||
| 62406bdc84 | |||
| 6812ddc428 | |||
| bd068ce2f6 | |||
| 239a2ec3b5 | |||
| 27100fbd22 | |||
| 91dd834413 | |||
| 60cad3f5e8 | |||
| 14b13e479d |
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
**/*.log
|
||||||
|
**/.env
|
||||||
29
.env.example
29
.env.example
@@ -3,8 +3,31 @@ POSTGRES_USER=pokopia
|
|||||||
POSTGRES_PASSWORD=pokopia
|
POSTGRES_PASSWORD=pokopia
|
||||||
DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia
|
DATABASE_URL=postgres://pokopia:pokopia@localhost:5432/pokopia
|
||||||
BACKEND_PORT=3001
|
BACKEND_PORT=3001
|
||||||
FRONTEND_ORIGIN=http://localhost:3000
|
TRUST_PROXY=false
|
||||||
APP_ORIGIN=http://localhost:3000
|
FRONTEND_ORIGIN=http://localhost:20015
|
||||||
VITE_API_BASE_URL=http://localhost:3001
|
APP_ORIGIN=http://localhost:20015
|
||||||
|
BACKEND_PUBLIC_ORIGIN=http://localhost:20016
|
||||||
|
NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
|
||||||
|
NUXT_SERVER_API_BASE_URL=http://localhost:3001
|
||||||
|
NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
|
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
|
||||||
|
RESEND_DAILY_QUOTA_LIMIT=100
|
||||||
|
RESEND_MONTHLY_QUOTA_LIMIT=3000
|
||||||
|
RESEND_QUOTA_RESERVE=5
|
||||||
|
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES=10
|
||||||
|
AI_MODERATION_API_KEY=
|
||||||
|
|
||||||
|
# Local Docker debug defaults:
|
||||||
|
# docker compose -f docker-compose.debug.yml up --build
|
||||||
|
# NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
|
||||||
|
# NUXT_SERVER_API_BASE_URL=http://backend:3001
|
||||||
|
# NUXT_PUBLIC_SITE_URL=http://localhost:20015
|
||||||
|
|
||||||
|
# Cloudflared tunnel deployment example:
|
||||||
|
# FRONTEND_ORIGIN=https://pokopiawiki.tootaio.com,http://localhost:20015
|
||||||
|
# APP_ORIGIN=https://pokopiawiki.tootaio.com
|
||||||
|
# BACKEND_PUBLIC_ORIGIN=https://api-pokopiawiki.tootaio.com
|
||||||
|
# NUXT_PUBLIC_API_BASE_URL=https://api-pokopiawiki.tootaio.com
|
||||||
|
# NUXT_SERVER_API_BASE_URL=http://backend:3001
|
||||||
|
# NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
dist/
|
dist/
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|||||||
1
.repomixignore
Normal file
1
.repomixignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
data/**/*.csv
|
||||||
188
AGENTS.md
188
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,23 +15,110 @@
|
|||||||
For any non-trivial task:
|
For any non-trivial task:
|
||||||
|
|
||||||
1. **Read `DESIGN.md`**
|
1. **Read `DESIGN.md`**
|
||||||
2. For UI, component, layout, or styling tasks, **also read `DesignGuidelines.html`**
|
2. While `SSR_MIGRATION_TASKLIST.md` exists, **also read `SSR_MIGRATION_TASKLIST.md`** and keep SSR migration work aligned with it.
|
||||||
3. **Produce a short plan (no code)**
|
3. For UI, component, layout, or styling tasks, **also read `DesignGuidelines.html`**
|
||||||
4. Wait for approval
|
4. **Produce a short plan (no code)**
|
||||||
5. Implement in small steps
|
5. Wait for approval
|
||||||
6. Run lightweight validation when practical (lint/typecheck). Do not run tests in WSL.
|
6. Implement in small steps
|
||||||
|
7. Run lightweight validation when practical (lint/typecheck). Do not run tests in WSL.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Temporary SSR Migration Workflow
|
||||||
|
|
||||||
|
* `SSR_MIGRATION_TASKLIST.md` is the active task list for completing the Nuxt SSR migration.
|
||||||
|
* Until that migration is fully implemented and validated, every task that touches frontend routing, auth, API fetching, i18n, SEO, Docker frontend deployment, Nuxt config, or SSR/client runtime behavior must read and follow `SSR_MIGRATION_TASKLIST.md`.
|
||||||
|
* Update task checkboxes in `SSR_MIGRATION_TASKLIST.md` only when the corresponding implementation is actually complete and validated.
|
||||||
|
* Do not delete `SSR_MIGRATION_TASKLIST.md` early. Delete it only after the project is fully migrated to the final SSR deployment model, validation is complete, and `DESIGN.md` reflects the final behavior.
|
||||||
|
* When deleting `SSR_MIGRATION_TASKLIST.md`, also remove this Temporary SSR Migration Workflow section and the mandatory workflow step that requires reading the task list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
* Goal: Pokopia Wiki, a community-editable game wiki.
|
||||||
|
* Repository: pnpm workspace monorepo.
|
||||||
|
* Runtime baseline: Node.js >= 22.
|
||||||
|
* Frontend:
|
||||||
|
|
||||||
|
* Nuxt SPA mode currently (`ssr: false`), with SSR migration tracked in `SSR_MIGRATION_TASKLIST.md`
|
||||||
|
* Vue
|
||||||
|
* 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 +130,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.
|
||||||
|
|
||||||
@@ -49,6 +139,15 @@ If a task grows beyond scope, STOP and ask.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Git Diff Hygiene
|
||||||
|
|
||||||
|
* Do not inspect, summarize, or report diffs for `data/**/*.csv` by default.
|
||||||
|
* In WSL, CSV files under `data` may appear changed even when their content has not meaningfully changed.
|
||||||
|
* Ignore `data/**/*.csv` entries in `git status` / `git diff` unless the task explicitly involves CSV data, import/seed data, or the user asks to check them.
|
||||||
|
* Only mention CSV files in final change summaries if you intentionally changed them or verified they are relevant to the current task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## UI Safety Rules (CRITICAL)
|
## UI Safety Rules (CRITICAL)
|
||||||
|
|
||||||
User-facing UI must NEVER contain:
|
User-facing UI must NEVER contain:
|
||||||
@@ -62,31 +161,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 +216,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 +234,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 +252,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 +268,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 +276,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
|
|
||||||
|
|||||||
60
SSR_MIGRATION_TASKLIST.md
Normal file
60
SSR_MIGRATION_TASKLIST.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# SSR Migration Remaining Tasks
|
||||||
|
|
||||||
|
This temporary file tracks only the work still required before the Nuxt SSR migration can be considered complete.
|
||||||
|
|
||||||
|
Delete this file only after all items below are complete and `AGENTS.md` no longer needs the temporary SSR migration workflow.
|
||||||
|
|
||||||
|
## Remaining Work
|
||||||
|
|
||||||
|
- [ ] Run production Docker validation with `docker compose up --build`.
|
||||||
|
- [ ] Fix any Docker runtime errors from the production SSR container, frontend gateway, backend API, or SSR server-to-backend API connection.
|
||||||
|
- [ ] Verify anonymous SSR HTML for public routes contains meaningful public business content and route/detail metadata:
|
||||||
|
- `/`
|
||||||
|
- `/pokemon`
|
||||||
|
- `/event-pokemon`
|
||||||
|
- `/habitats`
|
||||||
|
- `/event-habitats`
|
||||||
|
- `/items`
|
||||||
|
- `/event-items`
|
||||||
|
- `/ancient-artifacts`
|
||||||
|
- `/recipes`
|
||||||
|
- `/checklist`
|
||||||
|
- `/dish`
|
||||||
|
- `/life`
|
||||||
|
- `/life/:id`
|
||||||
|
- `/profile/:id`
|
||||||
|
- `/project-updates`
|
||||||
|
- [ ] Verify generated HTML, Nuxt payloads, API responses used by SSR, metadata, and logs do not expose password hashes, session token hashes, verification/reset token hashes, private current-user data on public pages, role internals, permission internals, internal audit payloads, debug fields, stack traces, or implementation notes.
|
||||||
|
- [ ] Verify localized SSR reads and metadata follow the `DESIGN.md` fallback order: requested locale, default-language translation, then base field.
|
||||||
|
- [ ] Verify auth and permission route behavior with SSR enabled:
|
||||||
|
- anonymous users redirect from protected routes to login
|
||||||
|
- unverified users cannot access verified-only write flows
|
||||||
|
- users missing permissions cannot access permissioned routes
|
||||||
|
- current-user reads expose only fields allowed by `DESIGN.md`
|
||||||
|
- [ ] Verify hydrated logged-in flows still work:
|
||||||
|
- login
|
||||||
|
- logout
|
||||||
|
- Remember me
|
||||||
|
- `/profile`
|
||||||
|
- notifications
|
||||||
|
- route-backed create/edit modals
|
||||||
|
- uploads
|
||||||
|
- Life comments/reactions
|
||||||
|
- entity discussion comments
|
||||||
|
- admin access
|
||||||
|
- [ ] Verify browser-only UI behavior runs only on the client and remains stable after hydration:
|
||||||
|
- modal focus and body locking
|
||||||
|
- dropdown positioning
|
||||||
|
- scroll/resize listeners
|
||||||
|
- infinite-scroll sentinels
|
||||||
|
- clipboard actions
|
||||||
|
- `window.confirm` actions
|
||||||
|
- notification WebSocket
|
||||||
|
- upload file APIs
|
||||||
|
- [ ] Verify route-backed modal pages preserve underlying page context and avoid unwanted scroll jumps.
|
||||||
|
- [ ] Verify `robots.txt`, `sitemap.xml`, canonical URLs, `noindex` routes, Open Graph, Twitter card, and public detail metadata in the production runtime.
|
||||||
|
- [x] Remove legacy SPA-only compatibility paths once SSR behavior is stable.
|
||||||
|
- [x] Remove obsolete `VITE_*` fallback support after deployment has fully moved to documented `NUXT_*` variables.
|
||||||
|
- [x] Update `DESIGN.md` if final behavior differs from the current documented SSR deployment, auth, SEO, or environment-variable model.
|
||||||
|
- [ ] Update `AGENTS.md` to remove the temporary SSR migration workflow and the requirement to read this task list.
|
||||||
|
- [ ] Delete `SSR_MIGRATION_TASKLIST.md`.
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
RUN corepack enable && pnpm install
|
COPY backend/package.json ./backend/package.json
|
||||||
COPY . .
|
COPY frontend/package.json ./frontend/package.json
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate && pnpm install --frozen-lockfile --filter @pokopia/backend...
|
||||||
|
COPY backend ./backend
|
||||||
|
COPY data ./data
|
||||||
|
COPY system-wordings.ts ./system-wordings.ts
|
||||||
|
RUN mkdir -p /app/uploads && chown -R node:node /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app/backend
|
||||||
|
USER node
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["pnpm", "run", "start"]
|
CMD ["pnpm", "run", "start"]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,14 +13,17 @@
|
|||||||
"test": "node --test --import tsx tests/*.test.ts"
|
"test": "node --test --import tsx tests/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "latest",
|
"@fastify/cors": "11.2.0",
|
||||||
"fastify": "latest",
|
"@fastify/multipart": "10.0.0",
|
||||||
"pg": "latest"
|
"@fastify/rate-limit": "10.3.0",
|
||||||
|
"@fastify/static": "9.1.3",
|
||||||
|
"fastify": "5.8.5",
|
||||||
|
"pg": "8.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "latest",
|
"@types/node": "25.6.0",
|
||||||
"@types/pg": "latest",
|
"@types/pg": "8.20.0",
|
||||||
"tsx": "latest",
|
"tsx": "4.21.0",
|
||||||
"typescript": "latest"
|
"typescript": "6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1099
backend/src/aiModeration.ts
Normal file
1099
backend/src/aiModeration.ts
Normal file
File diff suppressed because it is too large
Load Diff
1510
backend/src/auth.ts
1510
backend/src/auth.ts
File diff suppressed because it is too large
Load Diff
1003
backend/src/notifications.ts
Normal file
1003
backend/src/notifications.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
282
backend/src/systemWordingQueries.ts
Normal file
282
backend/src/systemWordingQueries.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { defaultLocale, systemWordingCatalogEntries, systemWordingFallback, type SystemWordingTree } from '../../system-wordings.ts';
|
||||||
|
import { pool, query } from './db.ts';
|
||||||
|
|
||||||
|
type SystemWordingSurface = 'frontend' | 'backend' | 'email';
|
||||||
|
type SystemWordingValueRow = {
|
||||||
|
key: string;
|
||||||
|
module: string;
|
||||||
|
surface: SystemWordingSurface;
|
||||||
|
description: string;
|
||||||
|
placeholders: unknown;
|
||||||
|
value: string;
|
||||||
|
defaultValue: string;
|
||||||
|
missing: boolean;
|
||||||
|
updatedAt: Date | null;
|
||||||
|
updatedBy: { id: number; displayName: string } | null;
|
||||||
|
};
|
||||||
|
type ValidationError = Error & { statusCode: number };
|
||||||
|
|
||||||
|
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
|
||||||
|
const wordingKeyPattern = /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$/;
|
||||||
|
const placeholderPattern = /\{([A-Za-z0-9_]+)\}/g;
|
||||||
|
const surfaces = new Set<SystemWordingSurface>(['frontend', 'backend', 'email']);
|
||||||
|
|
||||||
|
function validationError(message: string): ValidationError {
|
||||||
|
const error = new Error(message) as ValidationError;
|
||||||
|
error.statusCode = 400;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanLocale(value: unknown): string {
|
||||||
|
const locale = typeof value === 'string' ? value.trim() : '';
|
||||||
|
return localePattern.test(locale) ? locale : defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireLocale(value: unknown): string {
|
||||||
|
const locale = typeof value === 'string' ? value.trim() : '';
|
||||||
|
if (!localePattern.test(locale)) {
|
||||||
|
throw validationError('server.wordings.localeRequired');
|
||||||
|
}
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireWordingKey(value: unknown): string {
|
||||||
|
const key = typeof value === 'string' ? value.trim() : '';
|
||||||
|
if (!wordingKeyPattern.test(key)) {
|
||||||
|
throw validationError('server.wordings.keyNotFound');
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanSurface(value: unknown): SystemWordingSurface | '' {
|
||||||
|
const surface = typeof value === 'string' ? value.trim() : '';
|
||||||
|
return surfaces.has(surface as SystemWordingSurface) ? (surface as SystemWordingSurface) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPlaceholders(value: string): string[] {
|
||||||
|
return [...new Set([...value.matchAll(placeholderPattern)].map((match) => match[1]))].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholdersMatch(first: string[], second: string[]): boolean {
|
||||||
|
return first.length === second.length && first.every((placeholder, index) => placeholder === second[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(message: string, params: Record<string, string | number>): string {
|
||||||
|
return Object.entries(params).reduce(
|
||||||
|
(nextMessage, [key, value]) => nextMessage.replaceAll(`{${key}}`, String(value)),
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNestedMessage(target: SystemWordingTree, key: string, value: string): void {
|
||||||
|
const parts = key.split('.');
|
||||||
|
let node = target;
|
||||||
|
|
||||||
|
for (const part of parts.slice(0, -1)) {
|
||||||
|
const current = node[part];
|
||||||
|
if (typeof current !== 'object' || current === null) {
|
||||||
|
node[part] = {};
|
||||||
|
}
|
||||||
|
node = node[part] as SystemWordingTree;
|
||||||
|
}
|
||||||
|
|
||||||
|
node[parts[parts.length - 1]] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nestedMessages(rows: Array<{ key: string; value: string }>): SystemWordingTree {
|
||||||
|
const messages: SystemWordingTree = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
setNestedMessage(messages, row.key, row.value);
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlaceholders(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) ? value.map((item) => String(item)).sort() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncSystemWordingCatalog(): Promise<void> {
|
||||||
|
const entries = systemWordingCatalogEntries();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
for (const entry of entries) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO system_wording_keys (key, module, surface, description, placeholders)
|
||||||
|
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET module = EXCLUDED.module,
|
||||||
|
surface = EXCLUDED.surface,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
placeholders = EXCLUDED.placeholders,
|
||||||
|
updated_at = now()
|
||||||
|
`,
|
||||||
|
[entry.key, entry.module, entry.surface, entry.description, JSON.stringify(entry.placeholders)]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [locale, value] of Object.entries(entry.values)) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO system_wording_values (key, locale, value)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (key, locale) DO NOTHING
|
||||||
|
`,
|
||||||
|
[entry.key, locale, value]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function systemMessage(
|
||||||
|
locale: string,
|
||||||
|
key: string,
|
||||||
|
params: Record<string, string | number> = {}
|
||||||
|
): Promise<string> {
|
||||||
|
const requestedLocale = cleanLocale(locale);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query<{ value: string }>(
|
||||||
|
`
|
||||||
|
SELECT COALESCE(requested.value, fallback.value) AS value
|
||||||
|
FROM system_wording_keys k
|
||||||
|
LEFT JOIN system_wording_values requested
|
||||||
|
ON requested.key = k.key
|
||||||
|
AND requested.locale = $2
|
||||||
|
LEFT JOIN system_wording_values fallback
|
||||||
|
ON fallback.key = k.key
|
||||||
|
AND fallback.locale = $3
|
||||||
|
WHERE k.key = $1
|
||||||
|
`,
|
||||||
|
[key, requestedLocale, defaultLocale]
|
||||||
|
);
|
||||||
|
const message = result.rows[0]?.value ?? systemWordingFallback(key, requestedLocale) ?? key;
|
||||||
|
return interpolate(message, params);
|
||||||
|
} catch {
|
||||||
|
return interpolate(systemWordingFallback(key, requestedLocale) ?? key, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function localizedStatusMessage(locale: string, message: string): Promise<string> {
|
||||||
|
return message.startsWith('server.') || message.startsWith('email.') ? systemMessage(locale, message) : message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSystemWordings(locale: string) {
|
||||||
|
const requestedLocale = cleanLocale(locale);
|
||||||
|
const rows = await query<{ key: string; value: string; missing: boolean }>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
k.key,
|
||||||
|
COALESCE(requested.value, fallback.value, '') AS value,
|
||||||
|
($1 <> $2 AND requested.value IS NULL) AS missing
|
||||||
|
FROM system_wording_keys k
|
||||||
|
LEFT JOIN system_wording_values requested
|
||||||
|
ON requested.key = k.key
|
||||||
|
AND requested.locale = $1
|
||||||
|
LEFT JOIN system_wording_values fallback
|
||||||
|
ON fallback.key = k.key
|
||||||
|
AND fallback.locale = $2
|
||||||
|
WHERE k.enabled = true
|
||||||
|
ORDER BY k.key
|
||||||
|
`,
|
||||||
|
[requestedLocale, defaultLocale]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale: requestedLocale,
|
||||||
|
fallbackLocale: defaultLocale,
|
||||||
|
messages: nestedMessages(rows),
|
||||||
|
missingKeys: rows.filter((row) => row.missing).map((row) => row.key)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSystemWordingRows(filters: Record<string, unknown>) {
|
||||||
|
const locale = cleanLocale(filters.locale);
|
||||||
|
const module = typeof filters.module === 'string' ? filters.module.trim() : '';
|
||||||
|
const surface = cleanSurface(filters.surface);
|
||||||
|
const missingOnly = filters.missing === 'true' || filters.missing === true;
|
||||||
|
|
||||||
|
return query<SystemWordingValueRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
k.key,
|
||||||
|
k.module,
|
||||||
|
k.surface,
|
||||||
|
k.description,
|
||||||
|
k.placeholders,
|
||||||
|
COALESCE(requested.value, '') AS value,
|
||||||
|
COALESCE(fallback.value, '') AS "defaultValue",
|
||||||
|
($1 <> $2 AND requested.value IS NULL) AS missing,
|
||||||
|
requested.updated_at AS "updatedAt",
|
||||||
|
CASE
|
||||||
|
WHEN updated_user.id IS NULL THEN NULL
|
||||||
|
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
|
||||||
|
END AS "updatedBy"
|
||||||
|
FROM system_wording_keys k
|
||||||
|
LEFT JOIN system_wording_values requested
|
||||||
|
ON requested.key = k.key
|
||||||
|
AND requested.locale = $1
|
||||||
|
LEFT JOIN system_wording_values fallback
|
||||||
|
ON fallback.key = k.key
|
||||||
|
AND fallback.locale = $2
|
||||||
|
LEFT JOIN users updated_user ON updated_user.id = requested.updated_by_user_id
|
||||||
|
WHERE k.enabled = true
|
||||||
|
AND ($3 = '' OR k.module = $3)
|
||||||
|
AND ($4 = '' OR k.surface = $4)
|
||||||
|
AND ($5 = false OR ($1 <> $2 AND requested.value IS NULL))
|
||||||
|
ORDER BY k.module, k.key
|
||||||
|
`,
|
||||||
|
[locale, defaultLocale, module, surface, missingOnly]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSystemWordingValue(keyValue: string, payload: Record<string, unknown>, userId: number) {
|
||||||
|
const key = requireWordingKey(keyValue);
|
||||||
|
const locale = requireLocale(payload.locale);
|
||||||
|
const value = typeof payload.value === 'string' ? payload.value.trim() : '';
|
||||||
|
|
||||||
|
const keyRow = await pool.query<{ placeholders: unknown }>('SELECT placeholders FROM system_wording_keys WHERE key = $1', [key]);
|
||||||
|
const placeholders = normalizePlaceholders(keyRow.rows[0]?.placeholders);
|
||||||
|
if (keyRow.rowCount === 0) {
|
||||||
|
throw validationError('server.wordings.keyNotFound');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locale === defaultLocale && value === '') {
|
||||||
|
throw validationError('server.wordings.valueRequired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value !== '' && !placeholdersMatch(placeholders, collectPlaceholders(value))) {
|
||||||
|
throw validationError('server.wordings.placeholderMismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query<{ code: string }>('SELECT code FROM languages WHERE code = $1', [locale]);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw validationError('server.wordings.localeRequired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === '') {
|
||||||
|
await pool.query('DELETE FROM system_wording_values WHERE key = $1 AND locale = $2', [key, locale]);
|
||||||
|
} else {
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO system_wording_values (key, locale, value, created_by_user_id, updated_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $4)
|
||||||
|
ON CONFLICT (key, locale) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value,
|
||||||
|
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
||||||
|
updated_at = now()
|
||||||
|
`,
|
||||||
|
[key, locale, value, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return listSystemWordingRows({ locale });
|
||||||
|
}
|
||||||
265
backend/src/uploads.ts
Normal file
265
backend/src/uploads.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { MultipartFile } from '@fastify/multipart';
|
||||||
|
import type { PoolClient } from 'pg';
|
||||||
|
import type { AuthUser } from './auth.ts';
|
||||||
|
import { query, queryOne } from './db.ts';
|
||||||
|
|
||||||
|
export type UploadEntityType = 'pokemon' | 'items' | 'habitats' | 'ancient-artifacts';
|
||||||
|
|
||||||
|
export type EntityImageUpload = {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
url: string;
|
||||||
|
uploadedAt: Date;
|
||||||
|
uploadedBy: { id: number; displayName: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadRow = {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
uploadedAt: Date;
|
||||||
|
uploadedBy: { id: number; displayName: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MultipartField = {
|
||||||
|
value?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadEntityTypes = new Set<UploadEntityType>(['pokemon', 'items', 'habitats', 'ancient-artifacts']);
|
||||||
|
const imageMimeTypes = new Map([
|
||||||
|
['image/png', '.png'],
|
||||||
|
['image/jpeg', '.jpg'],
|
||||||
|
['image/webp', '.webp'],
|
||||||
|
['image/gif', '.gif']
|
||||||
|
]);
|
||||||
|
|
||||||
|
const backendPublicOrigin = process.env.BACKEND_PUBLIC_ORIGIN ?? `http://localhost:${process.env.BACKEND_PORT ?? 3001}`;
|
||||||
|
export const imageUploadMaxBytes = 3 * 1024 * 1024;
|
||||||
|
export const uploadRoot = path.resolve(process.env.UPLOAD_DIR ?? path.join(process.cwd(), 'uploads'));
|
||||||
|
export const uploadPublicBaseUrl = (process.env.UPLOAD_PUBLIC_BASE_URL ?? `${backendPublicOrigin}/uploads/`).replace(/\/?$/, '/');
|
||||||
|
|
||||||
|
export function isUploadEntityType(value: string): value is UploadEntityType {
|
||||||
|
return uploadEntityTypes.has(value as UploadEntityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUploadImagePath(value: string | null | undefined): boolean {
|
||||||
|
const cleanPath = value?.trim() ?? '';
|
||||||
|
if (cleanPath === '' || cleanPath.startsWith('/') || cleanPath.includes('..')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [entityType] = cleanPath.split('/');
|
||||||
|
return isUploadEntityType(entityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadImageUrl(relativePath: string): string {
|
||||||
|
return `${uploadPublicBaseUrl}${relativePath.split('/').map(encodeURIComponent).join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validationError(message: string): Error & { statusCode: number } {
|
||||||
|
const error = new Error(message) as Error & { statusCode: number };
|
||||||
|
error.statusCode = 400;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldValue(fields: Record<string, unknown> | undefined, fieldName: string): string {
|
||||||
|
const field = fields?.[fieldName];
|
||||||
|
if (field && typeof field === 'object' && 'value' in field) {
|
||||||
|
const value = (field as MultipartField).value;
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalPositiveInteger(value: string): number | null {
|
||||||
|
const numberValue = Number(value);
|
||||||
|
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safePathSegment(value: string): string {
|
||||||
|
const segment = value
|
||||||
|
.normalize('NFKC')
|
||||||
|
.trim()
|
||||||
|
.replace(/[\\/:*?"<>|#%?&\u0000-\u001F]+/g, '-')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.replace(/^\.+$/, '')
|
||||||
|
.slice(0, 80);
|
||||||
|
|
||||||
|
return segment || 'record';
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampForPath(date = new Date()): string {
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0');
|
||||||
|
return [
|
||||||
|
date.getUTCFullYear(),
|
||||||
|
pad(date.getUTCMonth() + 1),
|
||||||
|
pad(date.getUTCDate()),
|
||||||
|
pad(date.getUTCHours()),
|
||||||
|
pad(date.getUTCMinutes()),
|
||||||
|
pad(date.getUTCSeconds())
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileExists(filePath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await stat(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uniqueRelativePath(entityType: UploadEntityType, entityName: string, extension: string): Promise<string> {
|
||||||
|
const entitySegment = safePathSegment(entityName);
|
||||||
|
const dir = path.join(uploadRoot, entityType, entitySegment);
|
||||||
|
const timestamp = timestampForPath();
|
||||||
|
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
for (let index = 1; index <= 99; index += 1) {
|
||||||
|
const suffix = index === 1 ? '' : `-${index}`;
|
||||||
|
const fileName = `${timestamp}${suffix}${extension}`;
|
||||||
|
const candidate = path.join(dir, fileName);
|
||||||
|
if (!(await fileExists(candidate))) {
|
||||||
|
return `${entityType}/${entitySegment}/${fileName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw validationError('server.validation.imageUploadFailed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidImageSignature(mimeType: string, buffer: Buffer): boolean {
|
||||||
|
if (mimeType === 'image/png') {
|
||||||
|
return buffer.length > 8 && buffer[0] === 0x89 && buffer.subarray(1, 4).toString('ascii') === 'PNG';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType === 'image/jpeg') {
|
||||||
|
return buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType === 'image/webp') {
|
||||||
|
return buffer.length > 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mimeType === 'image/gif') {
|
||||||
|
const signature = buffer.subarray(0, 6).toString('ascii');
|
||||||
|
return signature === 'GIF87a' || signature === 'GIF89a';
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUploadRow(row: UploadRow): EntityImageUpload {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
path: row.path,
|
||||||
|
uploadedAt: row.uploadedAt,
|
||||||
|
uploadedBy: row.uploadedBy,
|
||||||
|
url: uploadImageUrl(row.path)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEntityImageUpload(
|
||||||
|
entityType: UploadEntityType,
|
||||||
|
file: MultipartFile | undefined,
|
||||||
|
user: AuthUser
|
||||||
|
): Promise<EntityImageUpload> {
|
||||||
|
if (!file) {
|
||||||
|
throw validationError('server.validation.imageUploadRequired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = imageMimeTypes.get(file.mimetype);
|
||||||
|
if (!extension) {
|
||||||
|
throw validationError('server.validation.imageUploadTypeInvalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityName = fieldValue(file.fields as Record<string, unknown>, 'entityName');
|
||||||
|
if (entityName === '') {
|
||||||
|
throw validationError('server.validation.imageUploadEntityNameRequired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await file.toBuffer();
|
||||||
|
if (buffer.length === 0 || buffer.length > imageUploadMaxBytes || !hasValidImageSignature(file.mimetype, buffer)) {
|
||||||
|
throw validationError('server.validation.imageUploadContentInvalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityId = optionalPositiveInteger(fieldValue(file.fields as Record<string, unknown>, 'entityId'));
|
||||||
|
const relativePath = await uniqueRelativePath(entityType, entityName, extension);
|
||||||
|
const absolutePath = path.join(uploadRoot, relativePath);
|
||||||
|
await writeFile(absolutePath, buffer, { flag: 'wx' });
|
||||||
|
|
||||||
|
const row = await queryOne<UploadRow>(
|
||||||
|
`
|
||||||
|
INSERT INTO entity_image_uploads (
|
||||||
|
entity_type,
|
||||||
|
entity_id,
|
||||||
|
entity_name,
|
||||||
|
path,
|
||||||
|
original_filename,
|
||||||
|
mime_type,
|
||||||
|
byte_size,
|
||||||
|
created_by_user_id
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING
|
||||||
|
id,
|
||||||
|
path,
|
||||||
|
created_at AS "uploadedAt",
|
||||||
|
json_build_object('id', $8::integer, 'displayName', $9::text) AS "uploadedBy"
|
||||||
|
`,
|
||||||
|
[entityType, entityId, entityName.trim(), relativePath, file.filename, file.mimetype, buffer.length, user.id, user.displayName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw validationError('server.validation.imageUploadFailed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapUploadRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEntityImageUploads(entityType: UploadEntityType, entityId: number): Promise<EntityImageUpload[]> {
|
||||||
|
const rows = await query<UploadRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
upload.id,
|
||||||
|
upload.path,
|
||||||
|
upload.created_at AS "uploadedAt",
|
||||||
|
CASE
|
||||||
|
WHEN u.id IS NULL THEN NULL
|
||||||
|
ELSE json_build_object('id', u.id, 'displayName', u.display_name)
|
||||||
|
END AS "uploadedBy"
|
||||||
|
FROM entity_image_uploads upload
|
||||||
|
LEFT JOIN users u ON u.id = upload.created_by_user_id
|
||||||
|
WHERE upload.entity_type = $1
|
||||||
|
AND upload.entity_id = $2
|
||||||
|
ORDER BY upload.created_at DESC, upload.id DESC
|
||||||
|
`,
|
||||||
|
[entityType, entityId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map(mapUploadRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function linkEntityImageUpload(
|
||||||
|
client: PoolClient,
|
||||||
|
entityType: UploadEntityType,
|
||||||
|
entityId: number,
|
||||||
|
imagePath: string | null | undefined,
|
||||||
|
entityName: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!isUploadImagePath(imagePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
UPDATE entity_image_uploads
|
||||||
|
SET entity_id = $1,
|
||||||
|
entity_name = $2
|
||||||
|
WHERE entity_type = $3
|
||||||
|
AND path = $4
|
||||||
|
`,
|
||||||
|
[entityId, entityName.trim(), entityType, imagePath]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,5 +10,5 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
"include": ["src/**/*.ts", "tests/**/*.ts", "../system-wordings.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
1026
data/localized_pokemon_genus.csv
Normal file
1026
data/localized_pokemon_genus.csv
Normal file
File diff suppressed because it is too large
Load Diff
1026
data/localized_pokemon_name.csv
Normal file
1026
data/localized_pokemon_name.csv
Normal file
File diff suppressed because it is too large
Load Diff
22
data/localized_type_name.csv
Normal file
22
data/localized_type_name.csv
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"type_id","identifier","ja_hrkt","ja_roma","ko","zh_hant","fr","de","es","it","en","ja","zh_hans"
|
||||||
|
"1","normal","ノーマル",,"노말","一般","Normal","Normal","Normal","Normale","Normal","ノーマル","一般"
|
||||||
|
"2","fighting","かくとう",,"격투","格鬥","Combat","Kampf","Lucha","Lotta","Fighting","かくとう","格斗"
|
||||||
|
"3","flying","ひこう",,"비행","飛行","Vol","Flug","Volador","Volante","Flying","ひこう","飞行"
|
||||||
|
"4","poison","どく",,"독","毒","Poison","Gift","Veneno","Veleno","Poison","どく","毒"
|
||||||
|
"5","ground","じめん",,"땅","地面","Sol","Boden","Tierra","Terra","Ground","じめん","地面"
|
||||||
|
"6","rock","いわ",,"바위","岩石","Roche","Gestein","Roca","Roccia","Rock","いわ","岩石"
|
||||||
|
"7","bug","むし",,"벌레","蟲","Insecte","Käfer","Bicho","Coleottero","Bug","むし","虫"
|
||||||
|
"8","ghost","ゴースト",,"고스트","幽靈","Spectre","Geist","Fantasma","Spettro","Ghost","ゴースト","幽灵"
|
||||||
|
"9","steel","はがね",,"강철","鋼","Acier","Stahl","Acero","Acciaio","Steel","はがね","钢"
|
||||||
|
"10","fire","ほのお",,"불꽃","火","Feu","Feuer","Fuego","Fuoco","Fire","ほのお","火"
|
||||||
|
"11","water","みず",,"물","水","Eau","Wasser","Agua","Acqua","Water","みず","水"
|
||||||
|
"12","grass","くさ",,"풀","草","Plante","Pflanze","Planta","Erba","Grass","くさ","草"
|
||||||
|
"13","electric","でんき",,"전기","電","Électrik","Elektro","Eléctrico","Elettro","Electric","でんき","电"
|
||||||
|
"14","psychic","エスパー",,"에스퍼","超能力","Psy","Psycho","Psíquico","Psico","Psychic","エスパー","超能力"
|
||||||
|
"15","ice","こおり",,"얼음","冰","Glace","Eis","Hielo","Ghiaccio","Ice","こおり","冰"
|
||||||
|
"16","dragon","ドラゴン",,"드래곤","龍","Dragon","Drache","Dragón","Drago","Dragon","ドラゴン","龙"
|
||||||
|
"17","dark","あく",,"악","惡","Ténèbres","Unlicht","Siniestro","Buio","Dark","あく","恶"
|
||||||
|
"18","fairy","フェアリー",,"페어리","妖精","Fée","Fee","Hada","Folletto","Fairy","フェアリー","妖精"
|
||||||
|
"19","stellar","ステラ"," Stella"," 스텔라","星晶","Stellaire","Stellar","Astral","Astrale","Stellar","ステラ","星晶"
|
||||||
|
"10001","unknown","???",,"???","???","???","???","???","???","???","???","???"
|
||||||
|
"10002","shadow","ダーク",,"다크","暗","Obscur","Crypto",,"Ombra","Shadow","ダーク","暗"
|
||||||
|
1351
data/pokemon_data.csv
Normal file
1351
data/pokemon_data.csv
Normal file
File diff suppressed because it is too large
Load Diff
108
docker-compose.debug.yml
Normal file
108
docker-compose.debug.yml
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: pokopia
|
||||||
|
POSTGRES_USER: pokopia
|
||||||
|
POSTGRES_PASSWORD: pokopia
|
||||||
|
volumes:
|
||||||
|
- postgres18_data:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U pokopia -d pokopia"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
deps:
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
PNPM_HOME: /pnpm
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- root_node_modules:/app/node_modules
|
||||||
|
- backend_node_modules:/app/backend/node_modules
|
||||||
|
- frontend_node_modules:/app/frontend/node_modules
|
||||||
|
- pnpm_store:/pnpm/store
|
||||||
|
command: >
|
||||||
|
sh -lc "corepack enable &&
|
||||||
|
corepack prepare pnpm@10.33.2 --activate &&
|
||||||
|
pnpm config set store-dir /pnpm/store &&
|
||||||
|
pnpm install --frozen-lockfile"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
PNPM_HOME: /pnpm
|
||||||
|
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
|
||||||
|
BACKEND_PORT: 3001
|
||||||
|
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||||
|
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:20015}
|
||||||
|
APP_ORIGIN: ${APP_ORIGIN:-http://localhost:20015}
|
||||||
|
UPLOAD_DIR: /app/uploads
|
||||||
|
BACKEND_PUBLIC_ORIGIN: ${BACKEND_PUBLIC_ORIGIN:-http://localhost:20016}
|
||||||
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
|
EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki <onboarding@resend.dev>}"
|
||||||
|
RESEND_DAILY_QUOTA_LIMIT: ${RESEND_DAILY_QUOTA_LIMIT:-100}
|
||||||
|
RESEND_MONTHLY_QUOTA_LIMIT: ${RESEND_MONTHLY_QUOTA_LIMIT:-3000}
|
||||||
|
RESEND_QUOTA_RESERVE: ${RESEND_QUOTA_RESERVE:-5}
|
||||||
|
RESEND_QUOTA_SNAPSHOT_TTL_MINUTES: ${RESEND_QUOTA_SNAPSHOT_TTL_MINUTES:-10}
|
||||||
|
AI_MODERATION_API_KEY: ${AI_MODERATION_API_KEY:-}
|
||||||
|
ports:
|
||||||
|
- "20016:3001"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- root_node_modules:/app/node_modules
|
||||||
|
- backend_node_modules:/app/backend/node_modules
|
||||||
|
- frontend_node_modules:/app/frontend/node_modules
|
||||||
|
- pnpm_store:/pnpm/store
|
||||||
|
- backend_uploads:/app/uploads
|
||||||
|
command: >
|
||||||
|
sh -lc "corepack enable &&
|
||||||
|
corepack prepare pnpm@10.33.2 --activate &&
|
||||||
|
pnpm --filter @pokopia/backend dev"
|
||||||
|
depends_on:
|
||||||
|
deps:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: node:22-alpine
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
PNPM_HOME: /pnpm
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: 20015
|
||||||
|
CHOKIDAR_USEPOLLING: "true"
|
||||||
|
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
|
||||||
|
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
|
||||||
|
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-http://localhost:20015}
|
||||||
|
ports:
|
||||||
|
- "20015:20015"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- root_node_modules:/app/node_modules
|
||||||
|
- backend_node_modules:/app/backend/node_modules
|
||||||
|
- frontend_node_modules:/app/frontend/node_modules
|
||||||
|
- pnpm_store:/pnpm/store
|
||||||
|
command: >
|
||||||
|
sh -lc "corepack enable &&
|
||||||
|
corepack prepare pnpm@10.33.2 --activate &&
|
||||||
|
pnpm --filter @pokopia/frontend dev"
|
||||||
|
depends_on:
|
||||||
|
deps:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
backend:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres18_data:
|
||||||
|
backend_uploads:
|
||||||
|
root_node_modules:
|
||||||
|
backend_node_modules:
|
||||||
|
frontend_node_modules:
|
||||||
|
pnpm_store:
|
||||||
@@ -5,8 +5,6 @@ services:
|
|||||||
POSTGRES_DB: pokopia
|
POSTGRES_DB: pokopia
|
||||||
POSTGRES_USER: pokopia
|
POSTGRES_USER: pokopia
|
||||||
POSTGRES_PASSWORD: pokopia
|
POSTGRES_PASSWORD: pokopia
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres18_data:/var/lib/postgresql
|
- postgres18_data:/var/lib/postgresql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -17,29 +15,54 @@ services:
|
|||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: .
|
||||||
|
dockerfile: backend/Dockerfile
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
|
DATABASE_URL: postgres://pokopia:pokopia@postgres:5432/pokopia
|
||||||
BACKEND_PORT: 3001
|
BACKEND_PORT: 3001
|
||||||
FRONTEND_ORIGIN: http://localhost:3000
|
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||||
APP_ORIGIN: http://localhost:3000
|
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:20015}
|
||||||
|
APP_ORIGIN: ${APP_ORIGIN:-http://localhost:20015}
|
||||||
|
UPLOAD_DIR: /app/uploads
|
||||||
|
BACKEND_PUBLIC_ORIGIN: ${BACKEND_PUBLIC_ORIGIN:-http://localhost:20016}
|
||||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||||
EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki <onboarding@resend.dev>}"
|
EMAIL_FROM: "${EMAIL_FROM:-Pokopia Wiki <onboarding@resend.dev>}"
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "20016:3001"
|
||||||
|
volumes:
|
||||||
|
- backend_uploads:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
|
args:
|
||||||
|
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
|
||||||
|
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
|
||||||
|
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://pokopiawiki.tootaio.com}
|
||||||
environment:
|
environment:
|
||||||
VITE_API_BASE_URL: http://localhost:3001
|
PORT: 20015
|
||||||
ports:
|
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
|
||||||
- "3000:3000"
|
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
|
||||||
|
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://pokopiawiki.tootaio.com}
|
||||||
|
expose:
|
||||||
|
- "20015"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
frontend_gateway:
|
||||||
|
image: nginx:1.29-alpine
|
||||||
|
ports:
|
||||||
|
- "20015:20015"
|
||||||
|
volumes:
|
||||||
|
- ./frontend/gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
- ./frontend/gateway/maintenance.html:/usr/share/nginx/html/maintenance.html:ro
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres18_data:
|
postgres18_data:
|
||||||
|
backend_uploads:
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
RUN corepack enable && pnpm install
|
COPY backend/package.json ./backend/package.json
|
||||||
COPY . .
|
COPY frontend/package.json ./frontend/package.json
|
||||||
EXPOSE 3000
|
RUN corepack enable && corepack prepare pnpm@10.33.2 --activate && pnpm install --frozen-lockfile --filter @pokopia/frontend...
|
||||||
CMD ["pnpm", "run", "dev"]
|
COPY frontend ./frontend
|
||||||
|
COPY system-wordings.ts ./system-wordings.ts
|
||||||
|
|
||||||
|
ARG NUXT_PUBLIC_API_BASE_URL=http://localhost:3001
|
||||||
|
ARG NUXT_SERVER_API_BASE_URL=http://localhost:3001
|
||||||
|
ARG NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
|
||||||
|
ENV NUXT_PUBLIC_API_BASE_URL=$NUXT_PUBLIC_API_BASE_URL
|
||||||
|
ENV NUXT_SERVER_API_BASE_URL=$NUXT_SERVER_API_BASE_URL
|
||||||
|
ENV NUXT_PUBLIC_SITE_URL=$NUXT_PUBLIC_SITE_URL
|
||||||
|
RUN pnpm --filter @pokopia/frontend build
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=20015
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/frontend/.output ./.output
|
||||||
|
|
||||||
|
USER node
|
||||||
|
EXPOSE 20015
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
|
|||||||
185
frontend/app.vue
Normal file
185
frontend/app.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import AppShell from './src/components/AppShell.vue';
|
||||||
|
import {
|
||||||
|
iconAction,
|
||||||
|
iconAdmin,
|
||||||
|
iconArtifact,
|
||||||
|
iconAutomation,
|
||||||
|
iconChecklist,
|
||||||
|
iconClothes,
|
||||||
|
iconDish,
|
||||||
|
iconDreamIsland,
|
||||||
|
iconEvent,
|
||||||
|
iconHabitat,
|
||||||
|
iconHome,
|
||||||
|
iconItem,
|
||||||
|
iconLife,
|
||||||
|
iconPokemon,
|
||||||
|
iconRecipe,
|
||||||
|
type AppIcon
|
||||||
|
} from './src/icons';
|
||||||
|
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
|
||||||
|
import { api, notifyAuthChange, onAuthChange, type AuthUser, type Language } from './src/services/api';
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
|
const languages = ref<Language[]>([
|
||||||
|
{ code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 10 },
|
||||||
|
{ code: 'zh-CN', name: '简体中文', enabled: true, isDefault: false, sortOrder: 20 }
|
||||||
|
]);
|
||||||
|
let removeAuthListener: (() => void) | null = null;
|
||||||
|
let removeLocaleListener: (() => void) | null = null;
|
||||||
|
|
||||||
|
type NavBadge = {
|
||||||
|
label: string;
|
||||||
|
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavLinkItem = {
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
icon?: AppIcon;
|
||||||
|
badge?: NavBadge;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavGroupItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: AppIcon;
|
||||||
|
children: NavLinkItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavItem = NavLinkItem | NavGroupItem;
|
||||||
|
|
||||||
|
function inDevBadge(): NavBadge {
|
||||||
|
return { label: t('common.inDev'), tone: 'info' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function can(permissionKey: string) {
|
||||||
|
return currentUser.value?.permissions.includes(permissionKey) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems = computed<NavItem[]>(() => {
|
||||||
|
const items: NavItem[] = [
|
||||||
|
{ label: t('nav.home'), to: '/', icon: iconHome },
|
||||||
|
{
|
||||||
|
key: 'pokedex',
|
||||||
|
label: t('nav.pokedex'),
|
||||||
|
icon: iconPokemon,
|
||||||
|
children: [
|
||||||
|
{ label: t('nav.mainGame'), to: '/pokemon', icon: iconPokemon },
|
||||||
|
{ label: t('nav.event'), to: '/event-pokemon', icon: iconEvent }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'habitat-dex',
|
||||||
|
label: t('nav.habitatDex'),
|
||||||
|
icon: iconHabitat,
|
||||||
|
children: [
|
||||||
|
{ label: t('nav.mainGame'), to: '/habitats', icon: iconHabitat },
|
||||||
|
{ label: t('nav.event'), to: '/event-habitats', icon: iconEvent }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'collections',
|
||||||
|
label: t('nav.collections'),
|
||||||
|
icon: iconItem,
|
||||||
|
children: [
|
||||||
|
{ label: t('nav.mainGame'), to: '/items', icon: iconItem },
|
||||||
|
{ label: t('nav.event'), to: '/event-items', icon: iconEvent },
|
||||||
|
{ label: t('nav.ancientArtifacts'), to: '/ancient-artifacts', icon: iconArtifact }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
|
||||||
|
{ label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() },
|
||||||
|
{ label: t('nav.dish'), to: '/dish', icon: iconDish },
|
||||||
|
{ label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() },
|
||||||
|
{ label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() },
|
||||||
|
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
|
||||||
|
{ label: t('nav.clothes'), to: '/clothes', icon: iconClothes, badge: inDevBadge() },
|
||||||
|
{ label: t('nav.checklist'), to: '/checklist', icon: iconChecklist },
|
||||||
|
{ label: t('nav.life'), to: '/life', icon: iconLife }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (can('admin.access')) {
|
||||||
|
items.push({ label: t('nav.admin'), to: '/admin', icon: iconAdmin });
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadCurrentUser() {
|
||||||
|
try {
|
||||||
|
const response = await api.me();
|
||||||
|
currentUser.value = response.user;
|
||||||
|
} catch {
|
||||||
|
currentUser.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await api.logout();
|
||||||
|
} catch {
|
||||||
|
// The local session is cleared even when the server session is already gone.
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser.value = null;
|
||||||
|
notifyAuthChange();
|
||||||
|
await router.push('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLanguages() {
|
||||||
|
try {
|
||||||
|
const loadedLanguages = await api.languages();
|
||||||
|
if (loadedLanguages.length) {
|
||||||
|
languages.value = loadedLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!languages.value.some((language) => language.code === getCurrentLocale() && language.enabled)) {
|
||||||
|
setCurrentLocale('en');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadSystemWordings(getCurrentLocale());
|
||||||
|
} catch {
|
||||||
|
// Keep the built-in language list when the API is not ready yet.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateLocale(value: string) {
|
||||||
|
await loadSystemWordings(value);
|
||||||
|
setCurrentLocale(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadLanguages();
|
||||||
|
void loadCurrentUser();
|
||||||
|
removeAuthListener = onAuthChange(() => {
|
||||||
|
void loadCurrentUser();
|
||||||
|
});
|
||||||
|
removeLocaleListener = onLocaleChange(() => {
|
||||||
|
void loadLanguages();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
removeAuthListener?.();
|
||||||
|
removeLocaleListener?.();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppShell
|
||||||
|
:current-user="currentUser"
|
||||||
|
:languages="languages"
|
||||||
|
:locale="locale"
|
||||||
|
:nav-items="navItems"
|
||||||
|
@logout="logout"
|
||||||
|
@update:locale="updateLocale"
|
||||||
|
>
|
||||||
|
<NuxtPage :key="locale" />
|
||||||
|
</AppShell>
|
||||||
|
</template>
|
||||||
9
frontend/app/router.options.ts
Normal file
9
frontend/app/router.options.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { RouterConfig } from '@nuxt/schema';
|
||||||
|
|
||||||
|
export default <RouterConfig>{
|
||||||
|
scrollBehavior(to, from, savedPosition) {
|
||||||
|
if (savedPosition) return savedPosition;
|
||||||
|
if (to.meta.editorModal === true || from.meta.editorModal === true) return false;
|
||||||
|
return { top: 0 };
|
||||||
|
}
|
||||||
|
};
|
||||||
224
frontend/gateway/maintenance.html
Normal file
224
frontend/gateway/maintenance.html
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<meta http-equiv="refresh" content="30" />
|
||||||
|
<title>Pokopia Wiki is upgrading</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--pokemon-yellow: #ffcb05;
|
||||||
|
--pokemon-yellow-soft: #ffe46b;
|
||||||
|
--pokemon-blue: #2a75bb;
|
||||||
|
--pokemon-blue-deep: #003a70;
|
||||||
|
--pokemon-red: #ee1515;
|
||||||
|
--pokemon-red-deep: #cc0000;
|
||||||
|
--bg: #f2f5fa;
|
||||||
|
--bg-alt: #eaf1fb;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-soft: #f8fafd;
|
||||||
|
--ink: #151923;
|
||||||
|
--ink-soft: #354052;
|
||||||
|
--muted: #687487;
|
||||||
|
--line: #d8deea;
|
||||||
|
--line-strong: #1f2a3b;
|
||||||
|
--shadow-raised: 0 14px 32px rgba(23, 35, 54, .13);
|
||||||
|
--font-sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--font-display: "Arial Rounded MT Bold", "Nunito", "Avenir Next Rounded", var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(42, 117, 187, .08) 1px, transparent 1px) 0 0 / 32px 32px,
|
||||||
|
linear-gradient(rgba(42, 117, 187, .08) 1px, transparent 1px) 0 0 / 32px 32px,
|
||||||
|
linear-gradient(180deg, var(--bg) 0%, var(--bg-alt) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 32px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-card {
|
||||||
|
width: min(100%, 560px);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(31, 42, 59, .14);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ribbon {
|
||||||
|
height: 12px;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, var(--pokemon-red) 0 28%, var(--line-strong) 28% 34%, var(--surface) 34% 66%, var(--line-strong) 66% 72%, var(--pokemon-blue) 72% 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: clamp(28px, 6vw, 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 4px solid var(--line-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, var(--pokemon-red) 0 45%, var(--line-strong) 45% 55%, var(--surface) 55% 100%);
|
||||||
|
box-shadow: 0 4px 0 rgba(31, 42, 59, .2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 13px;
|
||||||
|
border: 4px solid var(--line-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
display: block;
|
||||||
|
color: var(--pokemon-yellow);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: .95;
|
||||||
|
-webkit-text-stroke: 2px var(--pokemon-blue-deep);
|
||||||
|
text-shadow: 2px 3px 0 var(--pokemon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: .78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--pokemon-blue) 28%, var(--line));
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface));
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
font-size: .82rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 20px 0 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1.04;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
max-width: 38rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 1.12rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter {
|
||||||
|
height: 12px;
|
||||||
|
margin-top: 30px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter span {
|
||||||
|
display: block;
|
||||||
|
width: 70%;
|
||||||
|
height: 100%;
|
||||||
|
border-right: 1px solid rgba(31, 42, 59, .28);
|
||||||
|
background: linear-gradient(90deg, var(--pokemon-yellow) 0%, var(--pokemon-yellow-soft) 46%, var(--pokemon-blue) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
main {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maintenance-card {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 1.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main aria-labelledby="maintenance-title">
|
||||||
|
<section class="maintenance-card" aria-live="polite">
|
||||||
|
<div class="status-ribbon" aria-hidden="true"></div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="mark" aria-hidden="true"></span>
|
||||||
|
<div>
|
||||||
|
<span class="brand-name">Pokopia</span>
|
||||||
|
<span class="brand-subtitle">Wiki</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="status">Upgrading</span>
|
||||||
|
<h1 id="maintenance-title">Pokopia Wiki is upgrading</h1>
|
||||||
|
<p>We'll be online within 5 minutes.</p>
|
||||||
|
<div class="meter" aria-hidden="true"><span></span></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
45
frontend/gateway/nginx.conf
Normal file
45
frontend/gateway/nginx.conf
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
server {
|
||||||
|
listen 20015;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
resolver 127.0.0.11 valid=5s ipv6=off;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
auth_request /backend-health;
|
||||||
|
error_page 500 502 503 504 =503 /maintenance.html;
|
||||||
|
|
||||||
|
set $frontend_upstream http://frontend:20015;
|
||||||
|
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
proxy_connect_timeout 1s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
proxy_intercept_errors on;
|
||||||
|
|
||||||
|
proxy_pass $frontend_upstream;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /backend-health {
|
||||||
|
internal;
|
||||||
|
set $backend_upstream http://backend:3001/health;
|
||||||
|
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_connect_timeout 1s;
|
||||||
|
proxy_read_timeout 1s;
|
||||||
|
|
||||||
|
proxy_pass $backend_upstream;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /maintenance.html {
|
||||||
|
internal;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
add_header Retry-After "300" always;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Pokopia Wiki</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
35
frontend/middleware/auth.global.ts
Normal file
35
frontend/middleware/auth.global.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { api } from '../src/services/api';
|
||||||
|
|
||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
const requiredPermissions = to.matched
|
||||||
|
.map((record) => record.meta.requiredPermission)
|
||||||
|
.filter((permission): permission is string => typeof permission === 'string');
|
||||||
|
const requiredAnyPermissions = to.matched.flatMap((record) =>
|
||||||
|
Array.isArray(record.meta.requiredAnyPermission)
|
||||||
|
? record.meta.requiredAnyPermission.filter((permission): permission is string => typeof permission === 'string')
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true) || requiredPermissions.length > 0 || requiredAnyPermissions.length > 0;
|
||||||
|
const requiresAuth = requiresVerified || to.matched.some((record) => record.meta.requiresAuth === true);
|
||||||
|
|
||||||
|
if (!requiresAuth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.me(import.meta.server ? { headers: useRequestHeaders(['cookie']) } : undefined);
|
||||||
|
if (requiresVerified && !response.user.emailVerified) {
|
||||||
|
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionSet = new Set(response.user.permissions);
|
||||||
|
if (requiredPermissions.some((permission) => !permissionSet.has(permission))) {
|
||||||
|
return navigateTo('/pokemon');
|
||||||
|
}
|
||||||
|
if (requiredAnyPermissions.length && !requiredAnyPermissions.some((permission) => permissionSet.has(permission))) {
|
||||||
|
return navigateTo('/pokemon');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
|
||||||
|
}
|
||||||
|
});
|
||||||
50
frontend/nuxt.config.ts
Normal file
50
frontend/nuxt.config.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||||
|
|
||||||
|
function normalizeSiteUrl(value: string | undefined): string {
|
||||||
|
return (value?.trim() || fallbackSiteUrl).replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
ssr: true,
|
||||||
|
devtools: { enabled: false },
|
||||||
|
css: ['~/src/styles/main.css'],
|
||||||
|
compatibilityDate: '2026-05-06',
|
||||||
|
runtimeConfig: {
|
||||||
|
serverApiBaseUrl:
|
||||||
|
process.env.NUXT_SERVER_API_BASE_URL ??
|
||||||
|
process.env.NUXT_PUBLIC_API_BASE_URL ??
|
||||||
|
'http://localhost:3001',
|
||||||
|
public: {
|
||||||
|
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3001',
|
||||||
|
siteUrl: normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
htmlAttrs: {
|
||||||
|
lang: 'en'
|
||||||
|
},
|
||||||
|
title: 'Pokopia Wiki - Pokemon Pokopia Guide',
|
||||||
|
meta: [
|
||||||
|
{ charset: 'utf-8' },
|
||||||
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
|
||||||
|
{ name: 'theme-color', content: '#6ccf32' }
|
||||||
|
],
|
||||||
|
link: [
|
||||||
|
{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' }
|
||||||
|
],
|
||||||
|
script: [
|
||||||
|
{
|
||||||
|
async: true,
|
||||||
|
src: 'https://umami.tootaio.com/script.js',
|
||||||
|
'data-website-id': '6c00a2e5-dc72-41f3-9d5d-aac93aaaf1cb'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nitro: {
|
||||||
|
prerender: {
|
||||||
|
routes: ['/robots.txt', '/sitemap.xml']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -5,23 +5,25 @@
|
|||||||
"packageManager": "pnpm@10.33.2",
|
"packageManager": "pnpm@10.33.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0 --port 3000",
|
"dev": "nuxt dev --host 0.0.0.0 --port 20015",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "nuxt build",
|
||||||
"lint": "vue-tsc --noEmit",
|
"lint": "nuxt typecheck",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "nuxt typecheck",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-vue": "latest",
|
"@iconify/vue": "5.0.0",
|
||||||
"vite": "latest",
|
"nuxt": "4.4.4",
|
||||||
"vue": "latest",
|
"vue": "3.5.33",
|
||||||
"vue-router": "latest"
|
"vue-i18n": "11.4.0",
|
||||||
|
"vue-router": "5.0.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "latest",
|
"@types/node": "25.6.0",
|
||||||
"@vue/tsconfig": "latest",
|
"@vue/tsconfig": "0.9.1",
|
||||||
"typescript": "latest",
|
"postcss": "8.5.13",
|
||||||
"vitest": "latest",
|
"typescript": "6.0.3",
|
||||||
"vue-tsc": "latest"
|
"vitest": "4.1.5",
|
||||||
|
"vue-tsc": "3.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
frontend/pages/actions.vue
Normal file
12
frontend/pages/actions.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'actions',
|
||||||
|
seo: { titleKey: 'pages.comingSoon.sections.actions.title', descriptionKey: 'pages.comingSoon.sections.actions.subtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComingSoonView page="actions" />
|
||||||
|
</template>
|
||||||
13
frontend/pages/admin.vue
Normal file
13
frontend/pages/admin.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AdminView from '../src/views/AdminView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'admin',
|
||||||
|
requiredPermission: 'admin.access',
|
||||||
|
seo: { titleKey: 'pages.admin.title', descriptionKey: 'pages.admin.subtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AdminView />
|
||||||
|
</template>
|
||||||
20
frontend/pages/ancient-artifacts/[id]/edit.vue
Normal file
20
frontend/pages/ancient-artifacts/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
|
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'ancient-artifact-edit',
|
||||||
|
requiredPermission: 'items.update',
|
||||||
|
editorModal: true,
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.ancientArtifacts.editKicker',
|
||||||
|
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
|
||||||
|
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/ancient-artifacts/${String(route.params.id)}`,
|
||||||
|
noindex: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/ancient-artifacts/[id]/index.vue
Normal file
12
frontend/pages/ancient-artifacts/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'ancient-artifact-detail',
|
||||||
|
seo: { titleKey: 'pages.ancientArtifacts.detailKicker', descriptionKey: 'pages.ancientArtifacts.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/ancient-artifacts/index.vue
Normal file
12
frontend/pages/ancient-artifacts/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AncientArtifactList from '../../src/views/AncientArtifactList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'ancient-artifact-list',
|
||||||
|
seo: { titleKey: 'pages.ancientArtifacts.title', descriptionKey: 'pages.ancientArtifacts.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AncientArtifactList />
|
||||||
|
</template>
|
||||||
19
frontend/pages/ancient-artifacts/new.vue
Normal file
19
frontend/pages/ancient-artifacts/new.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import AncientArtifactList from '../../src/views/AncientArtifactList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'ancient-artifact-new',
|
||||||
|
requiredPermission: 'items.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.ancientArtifacts.newTitle',
|
||||||
|
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
|
||||||
|
canonicalPath: '/ancient-artifacts',
|
||||||
|
noindex: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AncientArtifactList />
|
||||||
|
</template>
|
||||||
12
frontend/pages/automation.vue
Normal file
12
frontend/pages/automation.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'automation',
|
||||||
|
seo: { titleKey: 'pages.comingSoon.sections.automation.title', descriptionKey: 'pages.comingSoon.sections.automation.subtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComingSoonView page="automation" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/checklist.vue
Normal file
12
frontend/pages/checklist.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DailyChecklistView from '../src/views/DailyChecklistView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'checklist',
|
||||||
|
seo: { titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DailyChecklistView />
|
||||||
|
</template>
|
||||||
12
frontend/pages/clothes.vue
Normal file
12
frontend/pages/clothes.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'clothes',
|
||||||
|
seo: { titleKey: 'pages.comingSoon.sections.clothes.title', descriptionKey: 'pages.comingSoon.sections.clothes.subtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComingSoonView page="clothes" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/disclaimers.vue
Normal file
12
frontend/pages/disclaimers.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LegalView from '../src/views/LegalView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'disclaimers',
|
||||||
|
seo: { titleKey: 'pages.legal.disclaimers.title', descriptionKey: 'pages.legal.disclaimers.subtitle', canonicalPath: '/disclaimers' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LegalView page="disclaimers" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/dish.vue
Normal file
12
frontend/pages/dish.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DishView from '../src/views/DishView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'dish',
|
||||||
|
seo: { titleKey: 'pages.dish.title', descriptionKey: 'pages.dish.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DishView />
|
||||||
|
</template>
|
||||||
12
frontend/pages/dream-island.vue
Normal file
12
frontend/pages/dream-island.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'dream-island',
|
||||||
|
seo: { titleKey: 'pages.comingSoon.sections.dreamIsland.title', descriptionKey: 'pages.comingSoon.sections.dreamIsland.subtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComingSoonView page="dreamIsland" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/event-habitats/index.vue
Normal file
12
frontend/pages/event-habitats/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import HabitatList from '../../src/views/HabitatList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'event-habitat-list',
|
||||||
|
seo: { titleKey: 'pages.eventHabitats.title', descriptionKey: 'pages.eventHabitats.subtitle', canonicalPath: '/event-habitats' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HabitatList :event-only="true" />
|
||||||
|
</template>
|
||||||
14
frontend/pages/event-habitats/new.vue
Normal file
14
frontend/pages/event-habitats/new.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import HabitatList from '../../src/views/HabitatList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'event-habitat-new',
|
||||||
|
requiredPermission: 'habitats.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: { titleKey: 'pages.eventHabitats.newTitle', descriptionKey: 'pages.eventHabitats.editSubtitle', canonicalPath: '/event-habitats', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HabitatList :event-only="true" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/event-items/index.vue
Normal file
12
frontend/pages/event-items/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ItemsList from '../../src/views/ItemsList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'event-item-list',
|
||||||
|
seo: { titleKey: 'pages.eventItems.title', descriptionKey: 'pages.eventItems.subtitle', canonicalPath: '/event-items' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemsList :event-only="true" />
|
||||||
|
</template>
|
||||||
14
frontend/pages/event-items/new.vue
Normal file
14
frontend/pages/event-items/new.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ItemsList from '../../src/views/ItemsList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'event-item-new',
|
||||||
|
requiredPermission: 'items.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: { titleKey: 'pages.eventItems.newTitle', descriptionKey: 'pages.eventItems.editSubtitle', canonicalPath: '/event-items', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemsList :event-only="true" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/event-pokemon/index.vue
Normal file
12
frontend/pages/event-pokemon/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PokemonList from '../../src/views/PokemonList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'event-pokemon-list',
|
||||||
|
seo: { titleKey: 'pages.eventPokemon.title', descriptionKey: 'pages.eventPokemon.subtitle', canonicalPath: '/event-pokemon' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PokemonList :event-only="true" />
|
||||||
|
</template>
|
||||||
14
frontend/pages/event-pokemon/new.vue
Normal file
14
frontend/pages/event-pokemon/new.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PokemonList from '../../src/views/PokemonList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'event-pokemon-new',
|
||||||
|
requiredPermission: 'pokemon.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: { titleKey: 'pages.eventPokemon.newTitle', descriptionKey: 'pages.eventPokemon.editSubtitle', canonicalPath: '/event-pokemon', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PokemonList :event-only="true" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/events.vue
Normal file
12
frontend/pages/events.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ComingSoonView from '../src/views/ComingSoonView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'events',
|
||||||
|
seo: { titleKey: 'pages.comingSoon.sections.events.title', descriptionKey: 'pages.comingSoon.sections.events.subtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ComingSoonView page="events" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/forgot-password.vue
Normal file
12
frontend/pages/forgot-password.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ForgotPasswordView from '../src/views/ForgotPasswordView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'forgot-password',
|
||||||
|
seo: { titleKey: 'auth.requestResetTitle', descriptionKey: 'auth.requestResetSubtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ForgotPasswordView />
|
||||||
|
</template>
|
||||||
20
frontend/pages/habitats/[id]/edit.vue
Normal file
20
frontend/pages/habitats/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
|
import HabitatDetail from '../../../src/views/HabitatDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'habitat-edit',
|
||||||
|
requiredPermission: 'habitats.update',
|
||||||
|
editorModal: true,
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.habitats.detailKicker',
|
||||||
|
descriptionKey: 'pages.habitats.editSubtitle',
|
||||||
|
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/habitats/${String(route.params.id)}`,
|
||||||
|
noindex: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HabitatDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/habitats/[id]/index.vue
Normal file
12
frontend/pages/habitats/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import HabitatDetail from '../../../src/views/HabitatDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'habitat-detail',
|
||||||
|
seo: { titleKey: 'pages.habitats.detailKicker', descriptionKey: 'pages.habitats.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HabitatDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/habitats/index.vue
Normal file
12
frontend/pages/habitats/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import HabitatList from '../../src/views/HabitatList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'habitat-list',
|
||||||
|
seo: { titleKey: 'pages.habitats.title', descriptionKey: 'pages.habitats.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HabitatList :event-only="false" />
|
||||||
|
</template>
|
||||||
14
frontend/pages/habitats/new.vue
Normal file
14
frontend/pages/habitats/new.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import HabitatList from '../../src/views/HabitatList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'habitat-new',
|
||||||
|
requiredPermission: 'habitats.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: { titleKey: 'pages.habitats.newTitle', descriptionKey: 'pages.habitats.editSubtitle', canonicalPath: '/habitats', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HabitatList :event-only="false" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/index.vue
Normal file
12
frontend/pages/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import HomeView from '../src/views/HomeView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'home',
|
||||||
|
seo: { titleKey: 'pages.home.title', descriptionKey: 'pages.home.subtitle', canonicalPath: '/' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HomeView />
|
||||||
|
</template>
|
||||||
20
frontend/pages/items/[id]/edit.vue
Normal file
20
frontend/pages/items/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
|
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'item-edit',
|
||||||
|
requiredPermission: 'items.update',
|
||||||
|
editorModal: true,
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.items.editKicker',
|
||||||
|
descriptionKey: 'pages.items.editSubtitle',
|
||||||
|
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/items/${String(route.params.id)}`,
|
||||||
|
noindex: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/items/[id]/index.vue
Normal file
12
frontend/pages/items/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ItemDetail from '../../../src/views/ItemDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'item-detail',
|
||||||
|
seo: { titleKey: 'pages.items.detailKicker', descriptionKey: 'pages.items.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/items/index.vue
Normal file
12
frontend/pages/items/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ItemsList from '../../src/views/ItemsList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'item-list',
|
||||||
|
seo: { titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemsList :event-only="false" />
|
||||||
|
</template>
|
||||||
14
frontend/pages/items/new.vue
Normal file
14
frontend/pages/items/new.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ItemsList from '../../src/views/ItemsList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'item-new',
|
||||||
|
requiredPermission: 'items.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: { titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/items', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ItemsList :event-only="false" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/life/[id].vue
Normal file
12
frontend/pages/life/[id].vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LifePostDetail from '../../src/views/LifePostDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'life-id',
|
||||||
|
seo: { titleKey: 'pages.life.detailTitle', descriptionKey: 'pages.life.detailSubtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LifePostDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/life/index.vue
Normal file
12
frontend/pages/life/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LifeView from '../../src/views/LifeView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'life',
|
||||||
|
seo: { titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LifeView />
|
||||||
|
</template>
|
||||||
12
frontend/pages/login.vue
Normal file
12
frontend/pages/login.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LoginView from '../src/views/LoginView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'login',
|
||||||
|
seo: { titleKey: 'auth.loginTitle', descriptionKey: 'auth.loginSubtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LoginView />
|
||||||
|
</template>
|
||||||
20
frontend/pages/pokemon/[id]/edit.vue
Normal file
20
frontend/pages/pokemon/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
|
import PokemonDetail from '../../../src/views/PokemonDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'pokemon-edit',
|
||||||
|
requiredPermission: 'pokemon.update',
|
||||||
|
editorModal: true,
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.pokemon.editKicker',
|
||||||
|
descriptionKey: 'pages.pokemon.editSubtitle',
|
||||||
|
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/pokemon/${String(route.params.id)}`,
|
||||||
|
noindex: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PokemonDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/pokemon/[id]/index.vue
Normal file
12
frontend/pages/pokemon/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PokemonDetail from '../../../src/views/PokemonDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'pokemon-detail',
|
||||||
|
seo: { titleKey: 'pages.pokemon.detailKicker', descriptionKey: 'pages.pokemon.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PokemonDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/pokemon/index.vue
Normal file
12
frontend/pages/pokemon/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PokemonList from '../../src/views/PokemonList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'pokemon-list',
|
||||||
|
seo: { titleKey: 'pages.pokemon.title', descriptionKey: 'pages.pokemon.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PokemonList :event-only="false" />
|
||||||
|
</template>
|
||||||
14
frontend/pages/pokemon/new.vue
Normal file
14
frontend/pages/pokemon/new.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PokemonList from '../../src/views/PokemonList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'pokemon-new',
|
||||||
|
requiredPermission: 'pokemon.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: { titleKey: 'pages.pokemon.newTitle', descriptionKey: 'pages.pokemon.editSubtitle', canonicalPath: '/pokemon', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PokemonList :event-only="false" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/privacy-policy.vue
Normal file
12
frontend/pages/privacy-policy.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LegalView from '../src/views/LegalView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'privacy-policy',
|
||||||
|
seo: { titleKey: 'pages.legal.privacy.title', descriptionKey: 'pages.legal.privacy.subtitle', canonicalPath: '/privacy-policy' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LegalView page="privacy" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/profile/[id].vue
Normal file
12
frontend/pages/profile/[id].vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import UserProfileView from '../../src/views/UserProfileView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'profile-id',
|
||||||
|
seo: { titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.publicSubtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UserProfileView />
|
||||||
|
</template>
|
||||||
13
frontend/pages/profile/index.vue
Normal file
13
frontend/pages/profile/index.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import UserProfileView from '../../src/views/UserProfileView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'profile',
|
||||||
|
requiresAuth: true,
|
||||||
|
seo: { titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.subtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UserProfileView />
|
||||||
|
</template>
|
||||||
16
frontend/pages/project-updates.vue
Normal file
16
frontend/pages/project-updates.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ProjectUpdatesView from '../src/views/ProjectUpdatesView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'project-updates',
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.projectUpdates.title',
|
||||||
|
descriptionKey: 'pages.projectUpdates.subtitle',
|
||||||
|
canonicalPath: '/project-updates'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ProjectUpdatesView />
|
||||||
|
</template>
|
||||||
20
frontend/pages/recipes/[id]/edit.vue
Normal file
20
frontend/pages/recipes/[id]/edit.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||||
|
import RecipeDetail from '../../../src/views/RecipeDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'recipe-edit',
|
||||||
|
requiredPermission: 'recipes.update',
|
||||||
|
editorModal: true,
|
||||||
|
seo: {
|
||||||
|
titleKey: 'pages.recipes.editKicker',
|
||||||
|
descriptionKey: 'pages.recipes.editSubtitle',
|
||||||
|
canonicalPath: (route: RouteLocationNormalizedLoaded) => `/recipes/${String(route.params.id)}`,
|
||||||
|
noindex: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RecipeDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/recipes/[id]/index.vue
Normal file
12
frontend/pages/recipes/[id]/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import RecipeDetail from '../../../src/views/RecipeDetail.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'recipe-detail',
|
||||||
|
seo: { titleKey: 'pages.recipes.detailKicker', descriptionKey: 'pages.recipes.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RecipeDetail />
|
||||||
|
</template>
|
||||||
12
frontend/pages/recipes/index.vue
Normal file
12
frontend/pages/recipes/index.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import RecipeList from '../../src/views/RecipeList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'recipe-list',
|
||||||
|
seo: { titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RecipeList />
|
||||||
|
</template>
|
||||||
14
frontend/pages/recipes/new.vue
Normal file
14
frontend/pages/recipes/new.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import RecipeList from '../../src/views/RecipeList.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'recipe-new',
|
||||||
|
requiredPermission: 'recipes.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: { titleKey: 'pages.recipes.newTitle', descriptionKey: 'pages.recipes.editSubtitle', canonicalPath: '/recipes', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RecipeList />
|
||||||
|
</template>
|
||||||
12
frontend/pages/register.vue
Normal file
12
frontend/pages/register.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import RegisterView from '../src/views/RegisterView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'register',
|
||||||
|
seo: { titleKey: 'auth.registerTitle', descriptionKey: 'auth.registerSubtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RegisterView />
|
||||||
|
</template>
|
||||||
12
frontend/pages/reset-password.vue
Normal file
12
frontend/pages/reset-password.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ResetPasswordView from '../src/views/ResetPasswordView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'reset-password',
|
||||||
|
seo: { titleKey: 'auth.resetTitle', descriptionKey: 'auth.resetSubtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ResetPasswordView />
|
||||||
|
</template>
|
||||||
12
frontend/pages/terms-of-service.vue
Normal file
12
frontend/pages/terms-of-service.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LegalView from '../src/views/LegalView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'terms-of-service',
|
||||||
|
seo: { titleKey: 'pages.legal.terms.title', descriptionKey: 'pages.legal.terms.subtitle', canonicalPath: '/terms-of-service' }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LegalView page="terms" />
|
||||||
|
</template>
|
||||||
12
frontend/pages/verify-email.vue
Normal file
12
frontend/pages/verify-email.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import VerifyEmailView from '../src/views/VerifyEmailView.vue';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
name: 'verify-email',
|
||||||
|
seo: { titleKey: 'auth.verifyTitle', descriptionKey: 'auth.verifySubtitle', noindex: true }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VerifyEmailView />
|
||||||
|
</template>
|
||||||
15
frontend/plugins/00-runtime-config.ts
Normal file
15
frontend/plugins/00-runtime-config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { setSystemWordingsApiBaseUrls } from '../src/i18n';
|
||||||
|
import { setConfiguredSiteUrl } from '../src/seo';
|
||||||
|
import { setApiBaseUrls } from '../src/services/api';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiBaseUrls = {
|
||||||
|
browser: config.public.apiBaseUrl,
|
||||||
|
server: config.serverApiBaseUrl
|
||||||
|
};
|
||||||
|
|
||||||
|
setApiBaseUrls(apiBaseUrls);
|
||||||
|
setSystemWordingsApiBaseUrls(apiBaseUrls);
|
||||||
|
setConfiguredSiteUrl(config.public.siteUrl);
|
||||||
|
});
|
||||||
15
frontend/plugins/01-i18n.ts
Normal file
15
frontend/plugins/01-i18n.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { createPokopiaI18n, setActiveI18n } from '../src/i18n';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
const i18n = createPokopiaI18n();
|
||||||
|
if (import.meta.client) {
|
||||||
|
setActiveI18n(i18n);
|
||||||
|
}
|
||||||
|
|
||||||
|
nuxtApp.vueApp.use(i18n);
|
||||||
|
return {
|
||||||
|
provide: {
|
||||||
|
pokopiaI18n: i18n
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
32
frontend/plugins/02-seo.ts
Normal file
32
frontend/plugins/02-seo.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { onLocaleChange } from '../src/i18n';
|
||||||
|
import { applyRouteSeo, onSeoChange, resolvedSeoHead, resolveRouteSeo, setSeoTranslator, type ResolvedSeoConfig } from '../src/seo';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
const router = useRouter();
|
||||||
|
const nuxtApp = useNuxtApp();
|
||||||
|
const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
|
||||||
|
const dynamicSeo = ref<ResolvedSeoConfig | null>(null);
|
||||||
|
const activeSeo = computed(() => dynamicSeo.value ?? resolveRouteSeo(router.currentRoute.value, t));
|
||||||
|
useHead(() => resolvedSeoHead(activeSeo.value));
|
||||||
|
|
||||||
|
if (import.meta.server) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSeoTranslator(t);
|
||||||
|
onSeoChange((seo) => {
|
||||||
|
dynamicSeo.value = seo;
|
||||||
|
});
|
||||||
|
onLocaleChange(() => {
|
||||||
|
dynamicSeo.value = null;
|
||||||
|
applyRouteSeo(router.currentRoute.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.afterEach((to) => {
|
||||||
|
dynamicSeo.value = null;
|
||||||
|
applyRouteSeo(to);
|
||||||
|
});
|
||||||
|
|
||||||
|
applyRouteSeo(router.currentRoute.value);
|
||||||
|
});
|
||||||
76
frontend/plugins/03-detail-seo.server.ts
Normal file
76
frontend/plugins/03-detail-seo.server.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { resolvedSeoHead, resolveSeo, type SeoConfig } from '../src/seo';
|
||||||
|
import { api } from '../src/services/api';
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(async () => {
|
||||||
|
const route = useRoute();
|
||||||
|
const routeId = typeof route.params.id === 'string' && route.params.id.trim() !== '' ? route.params.id : null;
|
||||||
|
if (!routeId || typeof route.name !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nuxtApp = useNuxtApp();
|
||||||
|
const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
|
||||||
|
const seo = await detailSeo(String(route.name), routeId, t);
|
||||||
|
if (seo) {
|
||||||
|
useHead(resolvedSeoHead(resolveSeo(seo)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function detailSeo(
|
||||||
|
routeName: string,
|
||||||
|
routeId: string,
|
||||||
|
t: (key: string, values?: Record<string, string | number>) => string
|
||||||
|
): Promise<SeoConfig | null> {
|
||||||
|
try {
|
||||||
|
if (routeName === 'pokemon-detail') {
|
||||||
|
const pokemon = await api.pokemonDetail(routeId);
|
||||||
|
return {
|
||||||
|
title: `${pokemon.name} - ${t(pokemon.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`,
|
||||||
|
description: t('seo.pokemonDetailDescription', { name: pokemon.name }),
|
||||||
|
canonicalPath: `/pokemon/${pokemon.id}`,
|
||||||
|
image: pokemon.image?.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeName === 'habitat-detail') {
|
||||||
|
const habitat = await api.habitatDetail(routeId);
|
||||||
|
return {
|
||||||
|
title: `${habitat.name} - ${t(habitat.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`,
|
||||||
|
description: t('seo.habitatDetailDescription', { name: habitat.name }),
|
||||||
|
canonicalPath: `/habitats/${habitat.id}`,
|
||||||
|
image: habitat.image?.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeName === 'item-detail' || routeName === 'ancient-artifact-detail') {
|
||||||
|
const item = await api.itemDetail(routeId);
|
||||||
|
const ancientArtifactRoute = routeName === 'ancient-artifact-detail';
|
||||||
|
if (ancientArtifactRoute && !item.ancientArtifactCategory) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleKey = ancientArtifactRoute ? 'pages.ancientArtifacts.title' : item.isEventItem ? 'pages.eventItems.title' : 'pages.items.title';
|
||||||
|
const descriptionKey = ancientArtifactRoute ? 'seo.ancientArtifactDetailDescription' : 'seo.itemDetailDescription';
|
||||||
|
return {
|
||||||
|
title: `${item.name} - ${t(titleKey)}`,
|
||||||
|
description: t(descriptionKey, { name: item.name }),
|
||||||
|
canonicalPath: ancientArtifactRoute ? `/ancient-artifacts/${item.id}` : `/items/${item.id}`,
|
||||||
|
image: item.image?.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (routeName === 'recipe-detail') {
|
||||||
|
const recipe = await api.recipeDetail(routeId);
|
||||||
|
return {
|
||||||
|
title: `${recipe.name} - ${t('pages.recipes.title')}`,
|
||||||
|
description: t('seo.recipeDetailDescription', { name: recipe.name }),
|
||||||
|
canonicalPath: `/recipes/${recipe.id}`,
|
||||||
|
image: recipe.item.image?.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/public/seo/pokopia-hero.jpg
Normal file
BIN
frontend/public/seo/pokopia-hero.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 736 KiB |
BIN
frontend/public/seo/pokopia-logo.png
Normal file
BIN
frontend/public/seo/pokopia-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
7
frontend/server/routes/robots.txt.ts
Normal file
7
frontend/server/routes/robots.txt.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { normalizeSiteUrl, robotsTxt } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'text/plain; charset=utf-8');
|
||||||
|
return robotsTxt(normalizeSiteUrl(config.public.siteUrl));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap.xml.ts
Normal file
7
frontend/server/routes/sitemap.xml.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { normalizeSiteUrl, sitemapXml } from '../utils/seo-files';
|
||||||
|
|
||||||
|
export default defineEventHandler((event) => {
|
||||||
|
const config = useRuntimeConfig(event);
|
||||||
|
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||||
|
return sitemapXml(normalizeSiteUrl(config.public.siteUrl));
|
||||||
|
});
|
||||||
73
frontend/server/utils/seo-files.ts
Normal file
73
frontend/server/utils/seo-files.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||||
|
|
||||||
|
const sitemapPaths = [
|
||||||
|
'/',
|
||||||
|
'/pokemon',
|
||||||
|
'/event-pokemon',
|
||||||
|
'/habitats',
|
||||||
|
'/event-habitats',
|
||||||
|
'/items',
|
||||||
|
'/event-items',
|
||||||
|
'/ancient-artifacts',
|
||||||
|
'/recipes',
|
||||||
|
'/dish',
|
||||||
|
'/checklist',
|
||||||
|
'/life',
|
||||||
|
'/project-updates',
|
||||||
|
'/privacy-policy',
|
||||||
|
'/terms-of-service',
|
||||||
|
'/disclaimers'
|
||||||
|
];
|
||||||
|
|
||||||
|
const robotsDisallowPaths = [
|
||||||
|
'/admin',
|
||||||
|
'/login',
|
||||||
|
'/register',
|
||||||
|
'/forgot-password',
|
||||||
|
'/reset-password',
|
||||||
|
'/verify-email',
|
||||||
|
'/pokemon/new',
|
||||||
|
'/event-pokemon/new',
|
||||||
|
'/pokemon/*/edit',
|
||||||
|
'/habitats/new',
|
||||||
|
'/event-habitats/new',
|
||||||
|
'/habitats/*/edit',
|
||||||
|
'/items/new',
|
||||||
|
'/event-items/new',
|
||||||
|
'/items/*/edit',
|
||||||
|
'/ancient-artifacts/new',
|
||||||
|
'/ancient-artifacts/*/edit',
|
||||||
|
'/recipes/new',
|
||||||
|
'/recipes/*/edit',
|
||||||
|
'/automation',
|
||||||
|
'/events',
|
||||||
|
'/actions',
|
||||||
|
'/dream-island',
|
||||||
|
'/clothes'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeSiteUrl(value: unknown): string {
|
||||||
|
return (typeof value === 'string' && value.trim() ? value.trim() : fallbackSiteUrl).replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function robotsTxt(siteUrl: string): string {
|
||||||
|
const disallowLines = robotsDisallowPaths.map((path) => `Disallow: ${path}`).join('\n');
|
||||||
|
return `User-agent: *\nAllow: /\n${disallowLines}\nSitemap: ${siteUrl}/sitemap.xml\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sitemapXml(siteUrl: string): string {
|
||||||
|
const urls = sitemapPaths
|
||||||
|
.map(
|
||||||
|
(path) => ` <url>
|
||||||
|
<loc>${siteUrl}${path}</loc>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
</url>`
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
${urls}
|
||||||
|
</urlset>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, onUnmounted, ref } from 'vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import AppShell from './components/AppShell.vue';
|
|
||||||
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser } from './services/api';
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ label: 'Pokemon', to: '/pokemon' },
|
|
||||||
{ label: '栖息地', to: '/habitats' },
|
|
||||||
{ label: '物品', to: '/items' },
|
|
||||||
{ label: '材料单', to: '/recipes' },
|
|
||||||
{ label: '管理', to: '/admin' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
|
||||||
let removeAuthListener: (() => void) | null = null;
|
|
||||||
|
|
||||||
async function loadCurrentUser() {
|
|
||||||
if (!getAuthToken()) {
|
|
||||||
currentUser.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.me();
|
|
||||||
currentUser.value = response.user;
|
|
||||||
} catch {
|
|
||||||
currentUser.value = null;
|
|
||||||
setAuthToken(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
try {
|
|
||||||
await api.logout();
|
|
||||||
} catch {
|
|
||||||
// The local session is cleared even when the server session is already gone.
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUser.value = null;
|
|
||||||
setAuthToken(null);
|
|
||||||
await router.push('/pokemon');
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
void loadCurrentUser();
|
|
||||||
removeAuthListener = onAuthTokenChange(() => {
|
|
||||||
void loadCurrentUser();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
removeAuthListener?.();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AppShell :current-user="currentUser" :nav-items="navItems" @logout="logout">
|
|
||||||
<RouterView />
|
|
||||||
</AppShell>
|
|
||||||
</template>
|
|
||||||
@@ -1,22 +1,356 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AuthUser } from '../services/api';
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import {
|
||||||
|
iconChevronDown,
|
||||||
|
iconChevronRight,
|
||||||
|
iconClose,
|
||||||
|
iconLogin,
|
||||||
|
iconLogout,
|
||||||
|
iconMenu,
|
||||||
|
iconProfile,
|
||||||
|
iconRegister,
|
||||||
|
iconTranslate,
|
||||||
|
type AppIcon
|
||||||
|
} from '../icons';
|
||||||
|
import type { AuthUser, Language } from '../services/api';
|
||||||
|
import GlobalSearch from './GlobalSearch.vue';
|
||||||
|
import NotificationBell from './NotificationBell.vue';
|
||||||
import PokeBallMark from './PokeBallMark.vue';
|
import PokeBallMark from './PokeBallMark.vue';
|
||||||
|
import StatusBadge from './StatusBadge.vue';
|
||||||
|
|
||||||
|
type NavBadge = {
|
||||||
|
label: string;
|
||||||
|
tone?: 'info' | 'success' | 'warning' | 'danger' | 'neutral';
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavLinkItem = {
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
icon?: AppIcon;
|
||||||
|
badge?: NavBadge;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavGroupItem = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: AppIcon;
|
||||||
|
children: NavLinkItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavItem = NavLinkItem | NavGroupItem;
|
||||||
|
|
||||||
|
type SidebarTooltip = {
|
||||||
|
label: string;
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
};
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
currentUser: AuthUser | null;
|
currentUser: AuthUser | null;
|
||||||
navItems: Array<{ label: string; to: string }>;
|
languages: Language[];
|
||||||
|
locale: string;
|
||||||
|
navItems: NavItem[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
logout: [];
|
logout: [];
|
||||||
|
'update:locale': [value: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const copyrightYear = new Date().getFullYear();
|
||||||
|
const languageMenu = ref<HTMLElement | null>(null);
|
||||||
|
const languageMenuButton = ref<HTMLButtonElement | null>(null);
|
||||||
|
const sideNav = ref<HTMLElement | null>(null);
|
||||||
|
const languageMenuOpen = ref(false);
|
||||||
|
const sidebarOpen = ref(false);
|
||||||
|
const sidebarCollapsed = ref(false);
|
||||||
|
const expandedNavGroups = ref<Set<string>>(new Set());
|
||||||
|
const sidebarTooltip = ref<SidebarTooltip | null>(null);
|
||||||
|
const sidebarTooltipTarget = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
function closeLanguageMenu() {
|
||||||
|
languageMenuOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSidebarTooltipTarget() {
|
||||||
|
sidebarTooltipTarget.value?.removeAttribute('aria-describedby');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSidebarTooltip() {
|
||||||
|
clearSidebarTooltipTarget();
|
||||||
|
sidebarTooltipTarget.value = null;
|
||||||
|
sidebarTooltip.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSidebar() {
|
||||||
|
sidebarOpen.value = false;
|
||||||
|
closeLanguageMenu();
|
||||||
|
hideSidebarTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
sidebarOpen.value = !sidebarOpen.value;
|
||||||
|
closeLanguageMenu();
|
||||||
|
hideSidebarTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebarCollapsed() {
|
||||||
|
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||||
|
closeLanguageMenu();
|
||||||
|
hideSidebarTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNavGroup(key: string) {
|
||||||
|
const nextGroups = new Set(expandedNavGroups.value);
|
||||||
|
if (nextGroups.has(key)) {
|
||||||
|
nextGroups.delete(key);
|
||||||
|
} else {
|
||||||
|
nextGroups.add(key);
|
||||||
|
}
|
||||||
|
expandedNavGroups.value = nextGroups;
|
||||||
|
closeLanguageMenu();
|
||||||
|
hideSidebarTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLanguageMenu() {
|
||||||
|
languageMenuOpen.value = !languageMenuOpen.value;
|
||||||
|
hideSidebarTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLocale(value: string) {
|
||||||
|
emit('update:locale', value);
|
||||||
|
closeLanguageMenu();
|
||||||
|
languageMenuButton.value?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentPointerDown(event: PointerEvent) {
|
||||||
|
if (languageMenu.value && !languageMenu.value.contains(event.target as Node)) {
|
||||||
|
closeLanguageMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLanguageMenuKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
closeLanguageMenu();
|
||||||
|
languageMenuButton.value?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestLogout() {
|
||||||
|
closeSidebar();
|
||||||
|
emit('logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDesktopSidebar() {
|
||||||
|
return typeof window !== 'undefined' && window.matchMedia('(min-width: 901px)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canShowSidebarTooltip(collapsedOnly = true) {
|
||||||
|
return isDesktopSidebar() && (!collapsedOnly || sidebarCollapsed.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSidebarTooltip(label: string, target: HTMLElement) {
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
clearSidebarTooltipTarget();
|
||||||
|
sidebarTooltipTarget.value = target;
|
||||||
|
target.setAttribute('aria-describedby', 'sidebar-tooltip');
|
||||||
|
sidebarTooltip.value = {
|
||||||
|
label,
|
||||||
|
top: rect.top + rect.height / 2,
|
||||||
|
left: rect.right + 10
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSidebarTooltip(label: string, event: MouseEvent | FocusEvent, collapsedOnly = true) {
|
||||||
|
if (!canShowSidebarTooltip(collapsedOnly) || languageMenuOpen.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.currentTarget;
|
||||||
|
if (target instanceof HTMLElement) {
|
||||||
|
setSidebarTooltip(label, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSidebarTooltipPosition() {
|
||||||
|
const target = sidebarTooltipTarget.value;
|
||||||
|
const currentTooltip = sidebarTooltip.value;
|
||||||
|
if (!target || !currentTooltip || !canShowSidebarTooltip()) {
|
||||||
|
hideSidebarTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sideNav.value?.contains(target)) {
|
||||||
|
const navRect = sideNav.value.getBoundingClientRect();
|
||||||
|
const targetRect = target.getBoundingClientRect();
|
||||||
|
if (targetRect.bottom < navRect.top || targetRect.top > navRect.bottom) {
|
||||||
|
hideSidebarTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSidebarTooltip(currentTooltip.label, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNavActive(path: string) {
|
||||||
|
return route.path === path || route.path.startsWith(`${path}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNavGroup(item: NavItem): item is NavGroupItem {
|
||||||
|
return 'children' in item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNavGroupActive(item: NavGroupItem) {
|
||||||
|
return item.children.some((child) => isNavActive(child.to));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNavGroupExpanded(item: NavGroupItem) {
|
||||||
|
return expandedNavGroups.value.has(item.key) || isNavGroupActive(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function navItemKey(item: NavItem) {
|
||||||
|
return isNavGroup(item) ? item.key : item.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(sidebarOpen, (open) => {
|
||||||
|
document.body.classList.toggle('lock-scroll', open);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(sidebarCollapsed, (collapsed) => {
|
||||||
|
if (!collapsed) {
|
||||||
|
hideSidebarTooltip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||||
|
window.addEventListener('resize', updateSidebarTooltipPosition);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||||
|
window.removeEventListener('resize', updateSidebarTooltipPosition);
|
||||||
|
document.body.classList.remove('lock-scroll');
|
||||||
|
hideSidebarTooltip();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app-shell">
|
<div
|
||||||
<header class="site-header">
|
class="app-shell"
|
||||||
<div class="container top-nav">
|
:class="{
|
||||||
<RouterLink class="brand-lockup" to="/pokemon" aria-label="Pokopia Wiki">
|
'app-shell--sidebar-open': sidebarOpen,
|
||||||
|
'app-shell--sidebar-collapsed': sidebarCollapsed
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<header class="site-topbar">
|
||||||
|
<div class="site-topbar__inner">
|
||||||
|
<div class="site-topbar__brand">
|
||||||
|
<button
|
||||||
|
class="sidebar-toggle"
|
||||||
|
type="button"
|
||||||
|
:aria-label="sidebarOpen ? t('nav.closeMenu') : t('nav.openMenu')"
|
||||||
|
:aria-expanded="sidebarOpen"
|
||||||
|
aria-controls="app-sidebar"
|
||||||
|
@click="toggleSidebar"
|
||||||
|
>
|
||||||
|
<Icon :icon="sidebarOpen ? iconClose : iconMenu" class="ui-icon" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<RouterLink class="brand-lockup brand-lockup--topbar" to="/" 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GlobalSearch class="site-topbar__search" @navigate="closeSidebar" />
|
||||||
|
|
||||||
|
<div class="site-topbar__spacer" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<div ref="languageMenu" class="language-menu" @keydown="onLanguageMenuKeydown">
|
||||||
|
<button
|
||||||
|
ref="languageMenuButton"
|
||||||
|
class="language-menu__trigger"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('nav.language')"
|
||||||
|
:aria-expanded="languageMenuOpen"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
@click="toggleLanguageMenu"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconTranslate" class="language-menu__icon" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="languageMenuOpen" class="language-menu__dropdown" role="menu">
|
||||||
|
<button
|
||||||
|
v-for="language in languages"
|
||||||
|
:key="language.code"
|
||||||
|
class="language-menu__item"
|
||||||
|
:class="{ active: language.code === locale }"
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
:aria-checked="language.code === locale"
|
||||||
|
@click="selectLocale(language.code)"
|
||||||
|
>
|
||||||
|
<span>{{ language.name }}</span>
|
||||||
|
<span class="language-menu__code">{{ language.code }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="currentUser">
|
||||||
|
<NotificationBell :current-user="currentUser" />
|
||||||
|
<RouterLink class="auth-user" to="/profile" :aria-label="t('nav.profile')" @click="closeSidebar">
|
||||||
|
<Icon :icon="iconProfile" class="ui-icon auth-user__icon" aria-hidden="true" />
|
||||||
|
<span class="auth-user__name">{{ currentUser.displayName || currentUser.email }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
<button
|
||||||
|
class="ui-button ui-button--ghost ui-button--small topbar-actions__icon-button"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('nav.logout')"
|
||||||
|
@click="requestLogout"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<RouterLink
|
||||||
|
class="ui-button ui-button--ghost ui-button--small topbar-actions__icon-button"
|
||||||
|
to="/login"
|
||||||
|
:aria-label="t('nav.login')"
|
||||||
|
@click="closeSidebar"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" />
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
class="ui-button ui-button--primary ui-button--small topbar-actions__icon-button"
|
||||||
|
to="/register"
|
||||||
|
:aria-label="t('nav.register')"
|
||||||
|
@click="closeSidebar"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconRegister" class="ui-icon" aria-hidden="true" />
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<div class="site-sidebar__header">
|
||||||
|
<RouterLink class="brand-lockup" to="/" 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>
|
||||||
@@ -24,27 +358,131 @@ defineEmits<{
|
|||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<nav class="nav-links" aria-label="主导航">
|
<button
|
||||||
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to">
|
class="sidebar-collapse-toggle"
|
||||||
{{ item.label }}
|
type="button"
|
||||||
</RouterLink>
|
:aria-label="sidebarCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar')"
|
||||||
</nav>
|
:aria-expanded="!sidebarCollapsed"
|
||||||
|
aria-controls="app-sidebar"
|
||||||
|
@focus="showSidebarTooltip(sidebarCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar'), $event, false)"
|
||||||
|
@blur="hideSidebarTooltip"
|
||||||
|
@pointerenter="showSidebarTooltip(sidebarCollapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar'), $event, false)"
|
||||||
|
@pointerleave="hideSidebarTooltip"
|
||||||
|
@click="toggleSidebarCollapsed"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="iconChevronRight"
|
||||||
|
class="ui-icon sidebar-collapse-toggle__icon"
|
||||||
|
:class="{ 'sidebar-collapse-toggle__icon--expanded': !sidebarCollapsed }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="auth-actions">
|
<nav ref="sideNav" class="side-nav" :aria-label="t('nav.main')" @scroll="updateSidebarTooltipPosition">
|
||||||
<template v-if="currentUser">
|
<template v-for="item in navItems" :key="navItemKey(item)">
|
||||||
<span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span>
|
<div v-if="isNavGroup(item)" class="side-nav__group" :class="{ 'side-nav__group--active': isNavGroupActive(item) }">
|
||||||
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="$emit('logout')">退出</button>
|
<button
|
||||||
</template>
|
class="side-nav__link side-nav__group-trigger"
|
||||||
<template v-else>
|
:class="{ 'router-link-active': isNavGroupActive(item) }"
|
||||||
<RouterLink class="ui-button ui-button--ghost ui-button--small" to="/login">登录</RouterLink>
|
type="button"
|
||||||
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/register">注册</RouterLink>
|
:aria-expanded="isNavGroupExpanded(item)"
|
||||||
</template>
|
:aria-controls="`side-nav-group-${item.key}`"
|
||||||
|
:aria-label="item.label"
|
||||||
|
@focus="showSidebarTooltip(item.label, $event)"
|
||||||
|
@blur="hideSidebarTooltip"
|
||||||
|
@pointerenter="showSidebarTooltip(item.label, $event)"
|
||||||
|
@pointerleave="hideSidebarTooltip"
|
||||||
|
@click="toggleNavGroup(item.key)"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<Icon
|
||||||
|
:icon="isNavGroupExpanded(item) ? iconChevronDown : iconChevronRight"
|
||||||
|
class="ui-icon side-nav__chevron"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div v-if="isNavGroupExpanded(item)" :id="`side-nav-group-${item.key}`" class="side-nav__children">
|
||||||
|
<RouterLink
|
||||||
|
v-for="child in item.children"
|
||||||
|
:key="child.to"
|
||||||
|
class="side-nav__link side-nav__link--child"
|
||||||
|
:class="{ 'router-link-active': isNavActive(child.to) }"
|
||||||
|
:to="child.to"
|
||||||
|
:aria-label="child.label"
|
||||||
|
@focus="showSidebarTooltip(child.label, $event)"
|
||||||
|
@blur="hideSidebarTooltip"
|
||||||
|
@pointerenter="showSidebarTooltip(child.label, $event)"
|
||||||
|
@pointerleave="hideSidebarTooltip"
|
||||||
|
@click="closeSidebar"
|
||||||
|
>
|
||||||
|
<Icon v-if="child.icon" :icon="child.icon" class="ui-icon side-nav__icon" aria-hidden="true" />
|
||||||
|
<span class="side-nav__label">{{ child.label }}</span>
|
||||||
|
<StatusBadge
|
||||||
|
v-if="child.badge"
|
||||||
|
class="side-nav__badge"
|
||||||
|
:label="child.badge.label"
|
||||||
|
:tone="child.badge.tone"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
<RouterLink
|
||||||
|
v-else
|
||||||
|
class="side-nav__link"
|
||||||
|
:class="{ 'router-link-active': isNavActive(item.to) }"
|
||||||
|
:to="item.to"
|
||||||
|
:aria-label="item.label"
|
||||||
|
@focus="showSidebarTooltip(item.label, $event)"
|
||||||
|
@blur="hideSidebarTooltip"
|
||||||
|
@pointerenter="showSidebarTooltip(item.label, $event)"
|
||||||
|
@pointerleave="hideSidebarTooltip"
|
||||||
|
@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>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="sidebarTooltip"
|
||||||
|
id="sidebar-tooltip"
|
||||||
|
class="sidebar-tooltip"
|
||||||
|
role="tooltip"
|
||||||
|
:style="{ top: `${sidebarTooltip.top}px`, left: `${sidebarTooltip.left}px` }"
|
||||||
|
>
|
||||||
|
{{ sidebarTooltip.label }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="site-footer__inner">
|
||||||
|
<p class="site-footer__copyright">
|
||||||
|
{{ t('legal.footer.copyright', { year: copyrightYear }) }}
|
||||||
|
</p>
|
||||||
|
<nav class="site-footer__links" :aria-label="t('legal.footer.linksLabel')">
|
||||||
|
<RouterLink to="/privacy-policy" @click="closeSidebar">{{ t('legal.footer.privacy') }}</RouterLink>
|
||||||
|
<RouterLink to="/terms-of-service" @click="closeSidebar">{{ t('legal.footer.terms') }}</RouterLink>
|
||||||
|
<RouterLink to="/disclaimers" @click="closeSidebar">{{ t('legal.footer.disclaimers') }}</RouterLink>
|
||||||
|
</nav>
|
||||||
|
<p class="site-footer__notice">{{ t('legal.footer.notice') }}</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
47
frontend/src/components/ConfirmDialog.vue
Normal file
47
frontend/src/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import Modal from './Modal.vue';
|
||||||
|
import { iconCancel, iconDelete } from '../icons';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
closeLabel: string;
|
||||||
|
busy?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
busy: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
cancel: [];
|
||||||
|
confirm: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:title="title"
|
||||||
|
:close-label="closeLabel"
|
||||||
|
:close-on-backdrop="!busy"
|
||||||
|
:close-on-escape="!busy"
|
||||||
|
@close="emit('cancel')"
|
||||||
|
>
|
||||||
|
<p class="confirm-dialog__message">{{ message }}</p>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button type="button" class="link-button link-button--danger" :disabled="busy" @click="emit('confirm')">
|
||||||
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ confirmLabel }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="plain-button" :disabled="busy" @click="emit('cancel')">
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ cancelLabel }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
229
frontend/src/components/EditHistoryPanel.vue
Normal file
229
frontend/src/components/EditHistoryPanel.vue
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import EditMeta from './EditMeta.vue';
|
||||||
|
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entity: EditInfo;
|
||||||
|
history: EditHistoryEntry[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { locale, t } = useI18n();
|
||||||
|
const changeLabelKeys: Record<string, string> = {
|
||||||
|
Name: 'common.name',
|
||||||
|
名字: 'common.name',
|
||||||
|
名称: 'common.name',
|
||||||
|
Title: 'pages.checklist.task',
|
||||||
|
标题: 'pages.checklist.task',
|
||||||
|
'Pokemon ID': 'pages.pokemon.id',
|
||||||
|
'Pokopia ID': 'pages.pokemon.id',
|
||||||
|
'Event item': 'common.eventItem',
|
||||||
|
'Event Pokemon': 'pages.pokemon.eventItem',
|
||||||
|
'Event Habitat': 'pages.habitats.eventItem',
|
||||||
|
Genus: 'pages.pokemon.genus',
|
||||||
|
Details: 'pages.pokemon.details',
|
||||||
|
Description: 'pages.items.description',
|
||||||
|
介绍: 'pages.pokemon.details',
|
||||||
|
Image: 'pages.pokemon.image',
|
||||||
|
图片: 'pages.pokemon.image',
|
||||||
|
Height: 'pages.pokemon.height',
|
||||||
|
身高: 'pages.pokemon.height',
|
||||||
|
Weight: 'pages.pokemon.weight',
|
||||||
|
体重: 'pages.pokemon.weight',
|
||||||
|
Types: 'pages.pokemon.types',
|
||||||
|
属性: 'pages.pokemon.types',
|
||||||
|
Stats: 'pages.pokemon.statsTitle',
|
||||||
|
六维: 'pages.pokemon.statsTitle',
|
||||||
|
'Ideal Habitat': 'pages.pokemon.environment',
|
||||||
|
'Favorite environment': 'pages.pokemon.environment',
|
||||||
|
喜欢的环境: 'pages.pokemon.environment',
|
||||||
|
Specialities: 'pages.pokemon.skills',
|
||||||
|
Skills: 'pages.pokemon.skills',
|
||||||
|
特长: 'pages.pokemon.skills',
|
||||||
|
Favourites: 'pages.pokemon.favoriteThings',
|
||||||
|
'Favorite things': 'pages.pokemon.favoriteThings',
|
||||||
|
喜欢的东西: 'pages.pokemon.favoriteThings',
|
||||||
|
'Speciality drops': 'pages.pokemon.skillDrops',
|
||||||
|
'Skill drops': 'pages.pokemon.skillDrops',
|
||||||
|
特长掉落物: 'pages.pokemon.skillDrops',
|
||||||
|
Trading: 'pages.pokemon.trading',
|
||||||
|
'Trading items': 'pages.pokemon.tradingItems',
|
||||||
|
Category: 'pages.items.category',
|
||||||
|
分类: 'pages.items.category',
|
||||||
|
Usage: 'pages.items.usage',
|
||||||
|
用途: 'pages.items.usage',
|
||||||
|
'Base Price': 'pages.items.basePrice',
|
||||||
|
'Base price': 'pages.items.basePrice',
|
||||||
|
基础价格: 'pages.items.basePrice',
|
||||||
|
Dyeable: 'pages.items.dyeable',
|
||||||
|
可染色: 'pages.items.dyeable',
|
||||||
|
'Dual dyeable': 'pages.items.dualDyeable',
|
||||||
|
可双区染色: 'pages.items.dualDyeable',
|
||||||
|
'Pattern editable': 'pages.items.patternEditable',
|
||||||
|
可改花纹: 'pages.items.patternEditable',
|
||||||
|
'No recipe': 'pages.items.noRecipe',
|
||||||
|
无材料单: 'pages.items.noRecipe',
|
||||||
|
'Acquisition methods': 'pages.items.acquisitionMethods',
|
||||||
|
入手方式: 'pages.items.acquisitionMethods',
|
||||||
|
Tags: 'pages.items.tags',
|
||||||
|
标签: 'pages.items.tags',
|
||||||
|
Recipe: 'pages.habitats.recipe',
|
||||||
|
配方: 'pages.habitats.recipe',
|
||||||
|
'Possible Pokemon': 'pages.habitats.possiblePokemon',
|
||||||
|
可能出现的宝可梦: 'pages.habitats.possiblePokemon',
|
||||||
|
Item: 'pages.recipes.item',
|
||||||
|
物品: 'pages.recipes.item',
|
||||||
|
Materials: 'pages.recipes.materials',
|
||||||
|
需要材料: 'pages.recipes.materials',
|
||||||
|
'Sort order': 'pages.admin.sortOrder',
|
||||||
|
排序: 'pages.admin.sortOrder',
|
||||||
|
'Has item drop': 'pages.admin.hasItemDrop',
|
||||||
|
有掉落物: 'pages.admin.hasItemDrop',
|
||||||
|
'Has trading': 'pages.admin.hasTrading',
|
||||||
|
'有 Trading': 'pages.admin.hasTrading',
|
||||||
|
'Default category': 'pages.admin.defaultCategory',
|
||||||
|
默认分类: 'pages.admin.defaultCategory',
|
||||||
|
Rateable: 'pages.admin.rateableCategory',
|
||||||
|
可评分: 'pages.admin.rateableCategory',
|
||||||
|
ChangeLog: 'pages.admin.changeLog'
|
||||||
|
};
|
||||||
|
|
||||||
|
function displayName(user: UserSummary | null): string {
|
||||||
|
return user?.displayName ?? t('common.system');
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(action: EditHistoryAction): string {
|
||||||
|
return t(`history.${action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionMark(action: EditHistoryAction): string {
|
||||||
|
return actionLabel(action).charAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeLabel(label: string): string {
|
||||||
|
const localizedFieldMatch = label.match(/^(.+) \(([^()]+)\)$/);
|
||||||
|
if (localizedFieldMatch) {
|
||||||
|
const [, fieldLabel, languageCode] = localizedFieldMatch;
|
||||||
|
if (fieldLabel && languageCode) {
|
||||||
|
return `${changeLabel(fieldLabel)} (${languageCode})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = changeLabelKeys[label];
|
||||||
|
return key ? t(key) : label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeValue(value: string): string {
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
None: t('common.none'),
|
||||||
|
无: t('common.none'),
|
||||||
|
Yes: locale.value === 'zh-CN' ? '是' : 'Yes',
|
||||||
|
是: locale.value === 'zh-CN' ? '是' : 'Yes',
|
||||||
|
No: locale.value === 'zh-CN' ? '否' : 'No',
|
||||||
|
否: locale.value === 'zh-CN' ? '否' : 'No'
|
||||||
|
};
|
||||||
|
return values[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleChanges(entry: EditHistoryEntry) {
|
||||||
|
return entry.changes.filter((change) => change.label !== 'Display ID' && change.label !== 'Sort order' && change.label !== '排序');
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleHistoryEntries() {
|
||||||
|
return props.history.filter((entry) => entry.action !== 'update' || visibleChanges(entry).length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function historySummary(entry: EditHistoryEntry): string {
|
||||||
|
const changes = visibleChanges(entry);
|
||||||
|
if (!changes.length) {
|
||||||
|
return actionLabel(entry.action);
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes.map((change) => changeLabel(change.label)).join(locale.value === 'zh-CN' ? '、' : ', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
return new Intl.DateTimeFormat(locale.value, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="edit-history-panel" aria-labelledby="edit-history-panel-title">
|
||||||
|
<div class="edit-history-panel__header">
|
||||||
|
<h2 id="edit-history-panel-title">{{ t('history.title') }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="edit-history-summary">
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('history.createdBy') }}</dt>
|
||||||
|
<dd>
|
||||||
|
<RouterLink v-if="props.entity.createdBy" class="user-profile-link" :to="`/profile/${props.entity.createdBy.id}`">
|
||||||
|
{{ props.entity.createdBy.displayName }}
|
||||||
|
</RouterLink>
|
||||||
|
<strong v-else>{{ displayName(props.entity.createdBy) }}</strong>
|
||||||
|
<time :datetime="props.entity.createdAt">{{ formatDateTime(props.entity.createdAt) }}</time>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('history.lastEdited') }}</dt>
|
||||||
|
<dd>
|
||||||
|
<EditMeta :entity="props.entity" :show-label="false" />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<section class="edit-history-list" aria-labelledby="edit-history-list-title">
|
||||||
|
<h3 id="edit-history-list-title">{{ t('history.editHistory') }}</h3>
|
||||||
|
<ol v-if="visibleHistoryEntries().length" class="edit-timeline">
|
||||||
|
<li v-for="entry in visibleHistoryEntries()" :key="`${entry.action}-${entry.createdAt}-${entry.user?.id ?? 'system'}`">
|
||||||
|
<span class="edit-timeline__avatar" aria-hidden="true">{{ actionMark(entry.action) }}</span>
|
||||||
|
<div class="edit-timeline__body">
|
||||||
|
<details class="edit-history-entry">
|
||||||
|
<summary>
|
||||||
|
<span class="edit-history-entry__title">{{ historySummary(entry) }}</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="edit-history-entry__content">
|
||||||
|
<dl v-if="visibleChanges(entry).length" class="edit-change-list">
|
||||||
|
<div v-for="change in visibleChanges(entry)" :key="`${change.label}-${change.before}-${change.after}`">
|
||||||
|
<dt>{{ changeLabel(change.label) }}</dt>
|
||||||
|
<dd>
|
||||||
|
<span class="edit-change-list__label">{{ t('history.before') }}</span>
|
||||||
|
<span>{{ changeValue(change.before) }}</span>
|
||||||
|
<span class="edit-change-list__label">{{ t('history.after') }}</span>
|
||||||
|
<span>{{ changeValue(change.after) }}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<dl class="edit-history-detail-meta">
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('history.author') }}</dt>
|
||||||
|
<dd>
|
||||||
|
<RouterLink v-if="entry.user" class="user-profile-link" :to="`/profile/${entry.user.id}`">
|
||||||
|
{{ entry.user.displayName }}
|
||||||
|
</RouterLink>
|
||||||
|
<span v-else>{{ displayName(entry.user) }}</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('history.time') }}</dt>
|
||||||
|
<dd><time :datetime="entry.createdAt">{{ formatDateTime(entry.createdAt) }}</time></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('history.action') }}</dt>
|
||||||
|
<dd>{{ actionLabel(entry.action) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p v-else class="meta-line">{{ t('history.empty') }}</p>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import type { EditInfo } from '../services/api';
|
import type { EditInfo } from '../services/api';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
entity: EditInfo;
|
entity: EditInfo;
|
||||||
}>();
|
showLabel?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
showLabel: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { locale, t } = useI18n();
|
||||||
|
|
||||||
function formatDateTime(value: string): string {
|
function formatDateTime(value: string): string {
|
||||||
return new Intl.DateTimeFormat('zh-CN', {
|
return new Intl.DateTimeFormat(locale.value, {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short'
|
timeStyle: 'short'
|
||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
@@ -15,6 +24,11 @@ function formatDateTime(value: string): string {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<p class="edit-meta">
|
<p class="edit-meta">
|
||||||
最后编辑:{{ entity.updatedBy?.displayName ?? '系统' }} / {{ formatDateTime(entity.updatedAt) }}
|
<template v-if="showLabel">{{ t('history.lastEdited') }}: </template>
|
||||||
|
<RouterLink v-if="entity.updatedBy" class="user-profile-link" :to="`/profile/${entity.updatedBy.id}`">
|
||||||
|
{{ entity.updatedBy.displayName }}
|
||||||
|
</RouterLink>
|
||||||
|
<span v-else>{{ t('common.system') }}</span>
|
||||||
|
/ <time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,34 +1,60 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import type { AppIcon } from '../icons';
|
||||||
import PokeBallMark from './PokeBallMark.vue';
|
import PokeBallMark from './PokeBallMark.vue';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
icon?: AppIcon;
|
||||||
marker?: string;
|
marker?: string;
|
||||||
|
image?: { src: string; alt: string };
|
||||||
|
ribbon?: string;
|
||||||
|
compactTooltip?: boolean;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterLink v-if="to" class="entity-card entity-card--link" :to="to">
|
<RouterLink
|
||||||
<span class="entity-card__mark">
|
v-if="to"
|
||||||
<PokeBallMark v-if="!marker" size="30px" />
|
class="entity-card entity-card--link"
|
||||||
|
:class="{ 'entity-card--collection-compact': compactTooltip }"
|
||||||
|
:to="to"
|
||||||
|
:aria-label="compactTooltip ? title : undefined"
|
||||||
|
>
|
||||||
|
<span v-if="ribbon" class="entity-card__ribbon-clip" aria-hidden="true">
|
||||||
|
<span class="entity-card__ribbon">{{ ribbon }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
||||||
|
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
|
||||||
|
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||||
|
<PokeBallMark v-else-if="!marker" size="30px" />
|
||||||
<span v-else>{{ marker }}</span>
|
<span v-else>{{ marker }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="compactTooltip" class="entity-card__tooltip" role="tooltip">{{ title }}</span>
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
<span class="entity-card__title">{{ title }}</span>
|
<span class="entity-card__title">{{ title }}</span>
|
||||||
|
<slot name="after-title"></slot>
|
||||||
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
|
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<article v-else class="entity-card">
|
<article v-else class="entity-card" :class="{ 'entity-card--collection-compact': compactTooltip }">
|
||||||
<span class="entity-card__mark">
|
<span v-if="ribbon" class="entity-card__ribbon-clip" aria-hidden="true">
|
||||||
<PokeBallMark v-if="!marker" size="30px" />
|
<span class="entity-card__ribbon">{{ ribbon }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
||||||
|
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
|
||||||
|
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||||
|
<PokeBallMark v-else-if="!marker" size="30px" />
|
||||||
<span v-else>{{ marker }}</span>
|
<span v-else>{{ marker }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="compactTooltip" class="entity-card__tooltip" role="tooltip">{{ title }}</span>
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
<span class="entity-card__title">{{ title }}</span>
|
<span class="entity-card__title">{{ title }}</span>
|
||||||
|
<slot name="after-title"></slot>
|
||||||
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
|
<span v-if="subtitle" class="entity-card__subtitle">{{ subtitle }}</span>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { NamedEntity } from '../services/api';
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { iconItem } from '../icons';
|
||||||
|
import type { EntityImage, NamedEntity, PokemonImage } from '../services/api';
|
||||||
|
|
||||||
|
type ChipItem = NamedEntity & {
|
||||||
|
image?: EntityImage | PokemonImage | null;
|
||||||
|
quantity?: number;
|
||||||
|
};
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
items: Array<NamedEntity & { quantity?: number }>;
|
items: ChipItem[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
function hasImageSlot(item: ChipItem) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(item, 'image');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chips">
|
<div class="chips">
|
||||||
<span v-for="item in items" :key="`${item.id}-${item.name}`" class="chip">
|
<span v-for="item in items" :key="`${item.id}-${item.name}`" class="chip" :class="{ 'chip--with-media': hasImageSlot(item) }">
|
||||||
|
<span v-if="hasImageSlot(item)" class="chip__media" aria-hidden="true">
|
||||||
|
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
|
||||||
|
<Icon v-else :icon="iconItem" class="chip__icon" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
{{ item.name }}<span v-if="item.quantity"> × {{ item.quantity }}</span>
|
{{ item.name }}<span v-if="item.quantity"> × {{ item.quantity }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
820
frontend/src/components/EntityDiscussionPanel.vue
Normal file
820
frontend/src/components/EntityDiscussionPanel.vue
Normal file
@@ -0,0 +1,820 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import ConfirmDialog from './ConfirmDialog.vue';
|
||||||
|
import LoadMoreSentinel from './LoadMoreSentinel.vue';
|
||||||
|
import StatusBadge from './StatusBadge.vue';
|
||||||
|
import Tabs, { type TabOption } from './Tabs.vue';
|
||||||
|
import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
moderationUpdateEvent,
|
||||||
|
onAuthChange,
|
||||||
|
type AiModerationStatus,
|
||||||
|
type AuthUser,
|
||||||
|
type CommentSort,
|
||||||
|
type DiscussionEntityType,
|
||||||
|
type EntityDiscussionComment,
|
||||||
|
type Language,
|
||||||
|
type ModerationUpdateDetail
|
||||||
|
} 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 languages = ref<Language[]>([]);
|
||||||
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const authReady = ref(false);
|
||||||
|
const body = ref('');
|
||||||
|
const replyBodies = ref<Record<number, string>>({});
|
||||||
|
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 activeLanguageCode = ref('all');
|
||||||
|
const activeSort = ref<CommentSort>('oldest');
|
||||||
|
const moderationBusyId = ref<number | null>(null);
|
||||||
|
const likeBusyId = ref<number | null>(null);
|
||||||
|
const commentMaxLength = 1000;
|
||||||
|
const discussionPageSize = 20;
|
||||||
|
const allLanguageValue = 'all';
|
||||||
|
let requestId = 0;
|
||||||
|
let removeAuthListener: (() => void) | null = null;
|
||||||
|
const nextCursor = ref<string | null>(null);
|
||||||
|
const hasMoreComments = ref(false);
|
||||||
|
const commentTotal = ref(0);
|
||||||
|
const pendingDeleteComment = ref<EntityDiscussionComment | null>(null);
|
||||||
|
const deleteConfirmBusy = ref(false);
|
||||||
|
|
||||||
|
function can(permissionKey: string) {
|
||||||
|
return currentUser.value?.permissions.includes(permissionKey) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canComment = computed(() => can('discussions.comments.create'));
|
||||||
|
const canLikeComments = computed(() => can('discussions.comments.like'));
|
||||||
|
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
|
||||||
|
const selectedLanguageCode = computed(() => (activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value));
|
||||||
|
const languageTabs = computed<TabOption[]>(() => [
|
||||||
|
{ value: allLanguageValue, label: t('discussion.allLanguages') },
|
||||||
|
...languages.value.map((language) => ({ value: language.code, label: language.name }))
|
||||||
|
]);
|
||||||
|
const sortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
|
||||||
|
{ value: 'oldest', label: t('discussion.sortOldest') },
|
||||||
|
{ value: 'latest', label: t('discussion.sortLatest') },
|
||||||
|
{ value: 'most-liked', label: t('discussion.sortMostLiked') },
|
||||||
|
{ value: 'most-replied', label: t('discussion.sortMostReplied') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function loadCurrentUser() {
|
||||||
|
authReady.value = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.me();
|
||||||
|
currentUser.value = response.user;
|
||||||
|
} catch {
|
||||||
|
currentUser.value = null;
|
||||||
|
} finally {
|
||||||
|
authReady.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLanguages() {
|
||||||
|
try {
|
||||||
|
languages.value = (await api.languages()).filter((language) => language.enabled);
|
||||||
|
if (
|
||||||
|
activeLanguageCode.value !== allLanguageValue &&
|
||||||
|
!languages.value.some((language) => language.code === activeLanguageCode.value)
|
||||||
|
) {
|
||||||
|
activeLanguageCode.value = allLanguageValue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
languages.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeComments(existing: EntityDiscussionComment[], incoming: EntityDiscussionComment[]) {
|
||||||
|
const ids = new Set(existing.map((comment) => comment.id));
|
||||||
|
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDiscussion(reset = true) {
|
||||||
|
if (!reset && (loadingMore.value || !hasMoreComments.value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRequestId = ++requestId;
|
||||||
|
if (reset) {
|
||||||
|
loading.value = true;
|
||||||
|
} else {
|
||||||
|
loadingMore.value = true;
|
||||||
|
}
|
||||||
|
loadError.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await api.entityDiscussion(props.entityType, props.entityId, {
|
||||||
|
limit: discussionPageSize,
|
||||||
|
cursor: reset ? null : nextCursor.value,
|
||||||
|
language: selectedLanguageCode.value,
|
||||||
|
sort: activeSort.value
|
||||||
|
});
|
||||||
|
if (nextRequestId === requestId) {
|
||||||
|
comments.value = reset ? page.items : mergeComments(comments.value, page.items);
|
||||||
|
nextCursor.value = page.nextCursor;
|
||||||
|
hasMoreComments.value = page.hasMore;
|
||||||
|
commentTotal.value = page.total;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (nextRequestId === requestId) {
|
||||||
|
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (nextRequestId === requestId) {
|
||||||
|
loading.value = false;
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetComposer() {
|
||||||
|
body.value = '';
|
||||||
|
replyBodies.value = {};
|
||||||
|
replyTargetId.value = null;
|
||||||
|
formError.value = '';
|
||||||
|
commentErrors.value = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentKey(commentId: number) {
|
||||||
|
return `comment-${commentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function likeKey(commentId: number) {
|
||||||
|
return `like-${commentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSortChange(event: Event) {
|
||||||
|
if (event.target instanceof HTMLSelectElement) {
|
||||||
|
activeSort.value = event.target.value as CommentSort;
|
||||||
|
void loadDiscussion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && can('discussions.comments.delete')) ||
|
||||||
|
can('discussions.comments.delete-any'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSeeModeration(comment: EntityDiscussionComment) {
|
||||||
|
return currentUser.value?.id === comment.author?.id || can('discussions.comments.delete-any');
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRetryModeration(comment: EntityDiscussionComment) {
|
||||||
|
return !comment.deleted && comment.moderationStatus !== 'approved' && comment.moderationStatus !== 'reviewing' && canSeeModeration(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canLikeComment(comment: EntityDiscussionComment) {
|
||||||
|
return canLikeComments.value && !comment.deleted && comment.moderationStatus === 'approved';
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentLikeLabel(comment: EntityDiscussionComment) {
|
||||||
|
return comment.myLiked ? t('discussion.unlikeComment') : t('discussion.likeComment');
|
||||||
|
}
|
||||||
|
|
||||||
|
function moderationReasonVisible(comment: EntityDiscussionComment) {
|
||||||
|
return (
|
||||||
|
!comment.deleted &&
|
||||||
|
canSeeModeration(comment) &&
|
||||||
|
(comment.moderationStatus === 'rejected' || comment.moderationStatus === 'failed') &&
|
||||||
|
comment.moderationReason !== null &&
|
||||||
|
comment.moderationReason.trim() !== ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moderationLabel(status: AiModerationStatus) {
|
||||||
|
const labels: Record<AiModerationStatus, string> = {
|
||||||
|
unreviewed: t('discussion.moderationUnreviewed'),
|
||||||
|
reviewing: t('discussion.moderationReviewing'),
|
||||||
|
approved: t('discussion.moderationApproved'),
|
||||||
|
rejected: t('discussion.moderationRejected'),
|
||||||
|
failed: t('discussion.moderationFailed')
|
||||||
|
};
|
||||||
|
return labels[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
function moderationTone(status: AiModerationStatus) {
|
||||||
|
const tones: Record<AiModerationStatus, 'info' | 'success' | 'warning' | 'danger' | 'neutral'> = {
|
||||||
|
unreviewed: 'neutral',
|
||||||
|
reviewing: 'info',
|
||||||
|
approved: 'success',
|
||||||
|
rejected: 'danger',
|
||||||
|
failed: 'warning'
|
||||||
|
};
|
||||||
|
return tones[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
function commentAuthorName(comment: EntityDiscussionComment) {
|
||||||
|
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,
|
||||||
|
languageCode: selectedLanguageCode.value ?? null
|
||||||
|
});
|
||||||
|
comments.value = [...comments.value, comment];
|
||||||
|
commentTotal.value += 1;
|
||||||
|
body.value = '';
|
||||||
|
if (activeSort.value !== 'oldest') {
|
||||||
|
void loadDiscussion();
|
||||||
|
}
|
||||||
|
} 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,
|
||||||
|
languageCode: selectedLanguageCode.value ?? comment.moderationLanguageCode
|
||||||
|
});
|
||||||
|
comment.replies.push(reply);
|
||||||
|
comment.replyCount += 1;
|
||||||
|
commentTotal.value += 1;
|
||||||
|
cancelReply(comment.id);
|
||||||
|
if (activeSort.value === 'most-replied') {
|
||||||
|
void loadDiscussion();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed'));
|
||||||
|
} finally {
|
||||||
|
busyKey.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryModeration(comment: EntityDiscussionComment) {
|
||||||
|
const key = commentKey(comment.id);
|
||||||
|
moderationBusyId.value = comment.id;
|
||||||
|
clearCommentError(key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await api.retryEntityDiscussionModeration(comment.id);
|
||||||
|
comment.moderationStatus = updated.moderationStatus;
|
||||||
|
comment.moderationLanguageCode = updated.moderationLanguageCode;
|
||||||
|
comment.moderationReason = updated.moderationReason;
|
||||||
|
} catch (error) {
|
||||||
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.moderationRetryFailed'));
|
||||||
|
} finally {
|
||||||
|
moderationBusyId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceCommentInTree(items: EntityDiscussionComment[], updated: EntityDiscussionComment): boolean {
|
||||||
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
|
const comment = items[index];
|
||||||
|
if (!comment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (comment.id === updated.id) {
|
||||||
|
items[index] = { ...updated, replies: comment.replies };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (replaceCommentInTree(comment.replies, updated)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCommentLike(comment: EntityDiscussionComment) {
|
||||||
|
if (!canLikeComment(comment)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = likeKey(comment.id);
|
||||||
|
likeBusyId.value = comment.id;
|
||||||
|
clearCommentError(key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = comment.myLiked
|
||||||
|
? await api.deleteEntityDiscussionCommentLike(comment.id)
|
||||||
|
: await api.setEntityDiscussionCommentLike(comment.id);
|
||||||
|
replaceCommentInTree(comments.value, updated);
|
||||||
|
comments.value = [...comments.value];
|
||||||
|
if (activeSort.value === 'most-liked') {
|
||||||
|
void loadDiscussion();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.commentLikeFailed'));
|
||||||
|
} finally {
|
||||||
|
likeBusyId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDiscussionCommentModeration(
|
||||||
|
items: EntityDiscussionComment[],
|
||||||
|
commentId: number,
|
||||||
|
status: AiModerationStatus,
|
||||||
|
languageCode: string | null,
|
||||||
|
reason: string | null
|
||||||
|
): boolean {
|
||||||
|
for (const comment of items) {
|
||||||
|
if (comment.id === commentId) {
|
||||||
|
comment.moderationStatus = status;
|
||||||
|
comment.moderationLanguageCode = languageCode;
|
||||||
|
comment.moderationReason = reason;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateDiscussionCommentModeration(comment.replies, commentId, status, languageCode, reason)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModerationUpdateEvent(event: Event): event is CustomEvent<ModerationUpdateDetail> {
|
||||||
|
return event instanceof CustomEvent && event.detail?.type === 'moderation.updated';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModerationUpdate(event: Event) {
|
||||||
|
if (!isModerationUpdateEvent(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { target, moderationStatus, moderationLanguageCode, moderationReason } = event.detail;
|
||||||
|
if (
|
||||||
|
target.type !== 'discussion-comment' ||
|
||||||
|
target.discussionCommentId === null ||
|
||||||
|
target.entityType !== props.entityType ||
|
||||||
|
target.entityId !== Number(props.entityId)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = updateDiscussionCommentModeration(
|
||||||
|
comments.value,
|
||||||
|
target.discussionCommentId,
|
||||||
|
moderationStatus,
|
||||||
|
moderationLanguageCode,
|
||||||
|
moderationReason
|
||||||
|
);
|
||||||
|
if (updated) {
|
||||||
|
comments.value = [...comments.value];
|
||||||
|
} else if (moderationStatus === 'approved') {
|
||||||
|
void loadDiscussion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDeleteComment(comment: EntityDiscussionComment) {
|
||||||
|
pendingDeleteComment.value = comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteConfirm() {
|
||||||
|
if (deleteConfirmBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDeleteComment.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteComment() {
|
||||||
|
const comment = pendingDeleteComment.value;
|
||||||
|
if (!comment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteConfirmBusy.value = true;
|
||||||
|
try {
|
||||||
|
await deleteComment(comment);
|
||||||
|
pendingDeleteComment.value = null;
|
||||||
|
} finally {
|
||||||
|
deleteConfirmBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteComment(comment: EntityDiscussionComment) {
|
||||||
|
const key = commentKey(comment.id);
|
||||||
|
clearCommentError(key);
|
||||||
|
|
||||||
|
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 = [];
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMoreComments.value = false;
|
||||||
|
commentTotal.value = 0;
|
||||||
|
void loadDiscussion();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(activeLanguageCode, () => {
|
||||||
|
comments.value = [];
|
||||||
|
nextCursor.value = null;
|
||||||
|
hasMoreComments.value = false;
|
||||||
|
commentTotal.value = 0;
|
||||||
|
void loadDiscussion();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
|
||||||
|
void loadCurrentUser();
|
||||||
|
void loadLanguages();
|
||||||
|
void loadDiscussion();
|
||||||
|
removeAuthListener = onAuthChange(() => {
|
||||||
|
void loadCurrentUser();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener(moderationUpdateEvent, handleModerationUpdate);
|
||||||
|
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>
|
||||||
|
|
||||||
|
<Tabs id="entity-discussion-language" v-model="activeLanguageCode" :tabs="languageTabs" :label="t('discussion.languages')" />
|
||||||
|
<label class="entity-discussion-sort">
|
||||||
|
<span>{{ t('discussion.sort') }}</span>
|
||||||
|
<select :value="activeSort" @change="handleSortChange">
|
||||||
|
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<RouterLink v-if="!comment.deleted && comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
|
||||||
|
{{ comment.author.displayName }}
|
||||||
|
</RouterLink>
|
||||||
|
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
||||||
|
<time :datetime="comment.createdAt">{{ formatDateTime(comment.createdAt) }}</time>
|
||||||
|
<StatusBadge
|
||||||
|
v-if="canSeeModeration(comment)"
|
||||||
|
:label="moderationLabel(comment.moderationStatus)"
|
||||||
|
:tone="moderationTone(comment.moderationStatus)"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="!comment.deleted" class="entity-discussion-comment__body">{{ comment.body }}</p>
|
||||||
|
<p v-if="moderationReasonVisible(comment)" class="life-moderation-detail life-moderation-detail--comment">
|
||||||
|
<strong>{{ t('discussion.moderationReason') }}</strong>
|
||||||
|
<span>{{ comment.moderationReason }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="!comment.deleted" class="entity-discussion-comment__actions">
|
||||||
|
<button
|
||||||
|
class="life-icon-button life-icon-button--flat"
|
||||||
|
type="button"
|
||||||
|
:aria-label="commentLikeLabel(comment)"
|
||||||
|
:aria-pressed="comment.myLiked"
|
||||||
|
:disabled="!canLikeComment(comment) || likeBusyId === comment.id"
|
||||||
|
@click="toggleCommentLike(comment)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span class="life-comment__action-count">{{ comment.likeCount }}</span>
|
||||||
|
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.commentLikeCount', { count: comment.likeCount }) }}</span>
|
||||||
|
</button>
|
||||||
|
<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="canRetryModeration(comment)"
|
||||||
|
class="life-icon-button life-icon-button--flat"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('discussion.moderationRetry')"
|
||||||
|
:disabled="moderationBusyId === comment.id"
|
||||||
|
@click="retryModeration(comment)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span class="life-action-tooltip" role="tooltip">
|
||||||
|
{{ moderationBusyId === comment.id ? t('discussion.moderationRetrying') : t('discussion.moderationRetry') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canManageComment(comment)"
|
||||||
|
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('discussion.deleteComment')"
|
||||||
|
@click="requestDeleteComment(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[likeKey(comment.id)]" class="entity-discussion-form__error" role="alert">
|
||||||
|
{{ commentErrors[likeKey(comment.id)] }}
|
||||||
|
</p>
|
||||||
|
<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">
|
||||||
|
<RouterLink v-if="!reply.deleted && reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
|
||||||
|
{{ reply.author.displayName }}
|
||||||
|
</RouterLink>
|
||||||
|
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
||||||
|
<time :datetime="reply.createdAt">{{ formatDateTime(reply.createdAt) }}</time>
|
||||||
|
<StatusBadge
|
||||||
|
v-if="canSeeModeration(reply)"
|
||||||
|
:label="moderationLabel(reply.moderationStatus)"
|
||||||
|
:tone="moderationTone(reply.moderationStatus)"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="!reply.deleted" class="entity-discussion-comment__body">{{ reply.body }}</p>
|
||||||
|
<p v-if="moderationReasonVisible(reply)" class="life-moderation-detail life-moderation-detail--comment">
|
||||||
|
<strong>{{ t('discussion.moderationReason') }}</strong>
|
||||||
|
<span>{{ reply.moderationReason }}</span>
|
||||||
|
</p>
|
||||||
|
<div v-if="!reply.deleted" class="entity-discussion-comment__actions">
|
||||||
|
<button
|
||||||
|
class="life-icon-button life-icon-button--flat"
|
||||||
|
type="button"
|
||||||
|
:aria-label="commentLikeLabel(reply)"
|
||||||
|
:aria-pressed="reply.myLiked"
|
||||||
|
:disabled="!canLikeComment(reply) || likeBusyId === reply.id"
|
||||||
|
@click="toggleCommentLike(reply)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span class="life-comment__action-count">{{ reply.likeCount }}</span>
|
||||||
|
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.commentLikeCount', { count: reply.likeCount }) }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRetryModeration(reply)"
|
||||||
|
class="life-icon-button life-icon-button--flat"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('discussion.moderationRetry')"
|
||||||
|
:disabled="moderationBusyId === reply.id"
|
||||||
|
@click="retryModeration(reply)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span class="life-action-tooltip" role="tooltip">
|
||||||
|
{{ moderationBusyId === reply.id ? t('discussion.moderationRetrying') : t('discussion.moderationRetry') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canManageComment(reply)"
|
||||||
|
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('discussion.deleteComment')"
|
||||||
|
@click="requestDeleteComment(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[likeKey(reply.id)]" class="entity-discussion-form__error" role="alert">
|
||||||
|
{{ commentErrors[likeKey(reply.id)] }}
|
||||||
|
</p>
|
||||||
|
<p v-if="commentErrors[commentKey(reply.id)]" class="entity-discussion-form__error" role="alert">
|
||||||
|
{{ commentErrors[commentKey(reply.id)] }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<LoadMoreSentinel :active="hasMoreComments" :disabled="loading || loadingMore" @load="loadDiscussion(false)" />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
v-if="pendingDeleteComment"
|
||||||
|
:title="t('discussion.deleteComment')"
|
||||||
|
:message="t('discussion.deleteConfirm')"
|
||||||
|
:confirm-label="t('common.delete')"
|
||||||
|
:cancel-label="t('common.cancel')"
|
||||||
|
:close-label="t('common.close')"
|
||||||
|
:busy="deleteConfirmBusy"
|
||||||
|
@cancel="closeDeleteConfirm"
|
||||||
|
@confirm="confirmDeleteComment"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="filter-panel" aria-label="筛选">
|
<section class="filter-panel" :aria-label="t('common.filters')">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user